writeScreenshotFrames function
Future<String?>
writeScreenshotFrames({
- required String testId,
- required ScreenshotConfig config,
- required List<
ScreenshotSheetFrame> frames, - required TestStatus status,
- int? failedStepIndex,
- String? failedStepLabel,
- String? failureMessage,
- String? failedDeviceId,
Encodes each frame to a compressed device-framed image and writes a
frames.json manifest.
Returns the display path of the frames manifest. The HTML report builds the contact-sheet gallery from these per-step files.
Implementation
Future<String?> writeScreenshotFrames({
required String testId,
required ScreenshotConfig config,
required List<ScreenshotSheetFrame> frames,
required TestStatus status,
int? failedStepIndex,
String? failedStepLabel,
String? failureMessage,
String? failedDeviceId,
}) async {
if (frames.isEmpty) return null;
final defaultDevice = resolveScreenshotDevice(const {});
final manifestDirectory = ensembleTestArtifactDirectory('screenshots');
manifestDirectory.createSync(recursive: true);
final imageDirectory = ensembleTestArtifactDirectory(
p.join('report', 'screenshots'),
);
imageDirectory.createSync(recursive: true);
final safeTestId = _safeFileName(testId);
final frameEntries = <Map<String, dynamic>>[];
try {
for (final frame in frames) {
final failedFrame = status == TestStatus.failed &&
frame.stepIndex == failedStepIndex &&
(failedDeviceId == null || frame.deviceId == failedDeviceId);
final frameDevice = _deviceForFrame(frame, defaultDevice);
final encoded = frame.encodedReportImage ??
await _encodeFrameImage(frame, frameDevice);
frame.encodedReportImage ??= encoded;
// Byte-exact dedup only: any real pixel difference keeps its own file.
// Perceptual hashing previously collapsed small UI changes (day-button
// selection, success toasts) into stale frames and broke report highlights.
final frameFileName = _dedupedImageFileName(encoded);
final frameFile = File(p.join(imageDirectory.path, frameFileName));
if (!frameFile.existsSync()) {
AtomicFile.writeBytesSync(frameFile, encoded.bytes);
}
frameEntries.add({
'stepIndex': frame.stepIndex,
'label': frame.label,
'file': frameFileName,
if (failedFrame) 'failed': true,
if (frame.deviceId != null) 'deviceId': frame.deviceId,
if (frame.deviceLabel != null) 'deviceLabel': frame.deviceLabel,
if (frame.highlight != null) 'highlight': frame.highlight!.toJson(),
});
}
} finally {
for (final frame in frames) {
try {
frame.image.dispose();
} catch (_) {}
}
}
if (frameEntries.isEmpty) return null;
// Drop legacy composite sheet artifacts from older runner versions.
for (final legacyName in [
'$safeTestId.png',
'${safeTestId}_sheet.png',
]) {
final legacy = File(p.join(manifestDirectory.path, legacyName));
if (legacy.existsSync()) {
legacy.deleteSync();
}
}
final framesFileName = '${safeTestId}_frames.json';
final framesFile = ensembleTestArtifactFile('screenshots', framesFileName);
AtomicFile.writeStringSync(
framesFile,
const JsonEncoder.withIndent(' ').convert({
'status': status.name,
if (failedStepIndex != null) 'failedStepIndex': failedStepIndex,
if (failedStepLabel != null) 'failedStepLabel': failedStepLabel,
if (failureMessage != null) 'failureMessage': failureMessage,
'frames': frameEntries,
}),
);
return ensembleTestArtifactDisplayPath('screenshots', framesFileName);
}