writeScreenshotFrames function

Future<String?> writeScreenshotFrames({
  1. required String testId,
  2. required ScreenshotConfig config,
  3. required List<ScreenshotSheetFrame> frames,
  4. required TestStatus status,
  5. int? failedStepIndex,
  6. String? failedStepLabel,
  7. String? failureMessage,
  8. 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);
}