Native and mobile captures
Your renderer. Stable PNG names. One review workflow.
Use your existing iOS, Android, Flutter, desktop or PDF tooling to
record PNGs. CLI 0.12.0 or newer adds
difora annotate for declared device metadata. Difora stores
and compares the images you upload; rendering stays in your CI.
Record, export, annotate, upload
First record a completed capture set into an empty attempt directory. Export only the intended PNGs, then annotate each variant directory with values from the actual renderer configuration. Finish annotation before uploading the whole suite.
npm install --save-dev difora
npx difora annotate screenshots/ios-iphone15-3x-dark \
--platform ios --device-model "iPhone 15" --runtime simulator \
--os iOS --os-version 17.5 --scale 3 --orientation portrait \
--logical-width 393 --logical-height 852 --font-scale 1 \
--framework SwiftUI --renderer Xcode --renderer-version 15.4 \
--surface screen --theme Dark --color-scheme dark \
--locale en-US --timezone UTC \
--environment ios-ci --environment-revision fonts-and-runtime-v1 \
--variant ios-iphone15-3x-dark --dry-run
# Remove --dry-run after checking the declared values.
npx difora upload screenshots
The command hashes exact file bytes and creates
<image>.png.difora.json. It never changes a PNG or
its name, overwrites a sidecar, contacts a server, or infers a device
from pixels. --dry-run writes nothing. Validation and write
failures exit 2. Existing, stale or orphan sidecars and unfinished
capture locks stop the operation. Use a fresh attempt directory after a
crashed or interrupted capture.
Stable names and deliberate variants
A relative PNG path without its extension is the snapshot identity. Put
device, scale, theme and locale combinations into stable directories,
such as ios-iphone15-3x-dark/checkout.png. Metadata
--variant is a label and does not rename files. Keep 2x and
3x captures as separate snapshots; Difora does not rescale them.
For generated attachment IDs, counters or hash suffixes, maintain an
explicit source-to-name mapping. Do not strip suffixes blindly:
collisions can replace a different screen. Export only original
captures, excluding comparison images, failure composites and Git LFS
pointer text. Save this small exporter as export-pngs.mjs:
import { constants, copyFileSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
import { dirname, relative, resolve, sep } from 'node:path';
const [source, mapping, output] = process.argv.slice(2);
const root = realpathSync(source), out = resolve(output);
const names = new Set();
const entries = JSON.parse(readFileSync(mapping, 'utf8')).map(({ file, name }) => {
if (!/^[a-z0-9][a-z0-9/_-]*\.png$/.test(name) || names.has(name)) throw Error('Invalid or duplicate stable name');
names.add(name);
const path = resolve(root, file), actual = realpathSync(path);
const rel = relative(root, actual);
if (rel.startsWith('..' + sep) || rel === '..' || lstatSync(path).isSymbolicLink()) throw Error('Capture must stay inside source root');
if (!readFileSync(path).subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) throw Error('Expected an original PNG');
return { path, target: resolve(out, name) };
});
for (const { path, target } of entries) {
mkdirSync(dirname(target), { recursive: true });
copyFileSync(path, target, constants.COPYFILE_EXCL);
}
For example, names.json contains
[{"file":"actual-exported-name.png","name":"checkout.png"}]. Replace the source filename with the actual tool output, commit the
mapping, and use
node export-pngs.mjs tool-output names.json screenshots/variant. This explicitly fails on duplicate destinations. Start with empty
output; a failed export may leave earlier files.
XCTest attachments
Verified on macOS: a passing AppKit XCTest attachment was retained, exported and annotated. The iOS simulator variation below is documented-only. Keep screenshots from passing tests: set the attachment lifetime explicitly. Apple attachment documentation.
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = "checkout"
attachment.lifetime = .keepAlways
add(attachment)
xcodebuild test -scheme YourApp \
-destination "platform=iOS Simulator,name=iPhone 15,OS=17.5" \
-resultBundlePath capture.xcresult
xcrun xcresulttool export attachments \
--path capture.xcresult --output-path exported
node export-pngs.mjs exported names.json screenshots/ios-iphone15-3x-dark
npx difora annotate screenshots/ios-iphone15-3x-dark \
--platform ios --device-model "iPhone 15" --runtime simulator \
--os iOS --os-version 17.5 --scale 3 --theme Dark --surface screen
npx difora upload screenshots
Use an installed, pinned destination. Build the mapping from
manifest.json: identify the test and attachment, then map
its exportedFileName to a stable name. Reject multiple
matches. Export all attachments, not only failures. Fix simulator status
bar/time, locale, accessibility text size and appearance before
capturing.
swift-snapshot-testing
Documented-only. Use your installed SnapshotTesting version and explicit snapshot names. A recording assertion can intentionally fail while writing references; keep this in a separate recording job, check its expected result and generated images, and preserve the normal unit-test job as a separate gate.
withSnapshotTesting(record: .all) {
assertSnapshot(of: controller, as: .image(on: .iPhone13), named: "checkout-light")
}
xcodebuild test -scheme YourSnapshotTests -destination "$PINNED_DESTINATION"
# After checking the recording result, export the intended __Snapshots__ PNGs.
node export-pngs.mjs Tests/__Snapshots__ names.json screenshots/ios-light
npx difora annotate screenshots/ios-light --platform ios \
--runtime simulator --device-model "$DEVICE_MODEL" --os iOS \
--os-version "$IOS_VERSION" --scale "$CAPTURE_SCALE" --theme Light \
--framework SnapshotTesting --framework-version "$SNAPSHOT_TESTING_VERSION"
npx difora upload screenshots
The image strategy may override device size and traits. Declare those configured values, not just the simulator model. Do not make an entire pipeline pass by suppressing unknown test failures. In CI, run the selected recording target, verify expected records, then the export/annotate/upload steps from the shared job below.
Paparazzi
Documented-only. Paparazzi renders Android layouts on the host. Use record mode and the exact build variant you configured.
./gradlew :app:recordPaparazziDebug
node export-pngs.mjs app/src/test/snapshots/images names.json screenshots/android-paparazzi
npx difora annotate screenshots/android-paparazzi --platform android \
--runtime host-render --device-model "$PAPARAZZI_DEVICE" --os Android \
--os-version "$ANDROID_API" --scale "$DISPLAY_SCALE" \
--renderer LayoutLib --renderer-version "$LAYOUTLIB_VERSION" \
--framework Paparazzi --framework-version "$PAPARAZZI_VERSION"
npx difora upload screenshots
Use the selected device configuration and resolved LayoutLib version. Fetch LFS objects before export; a pointer file is not a PNG. Keep record and verify tasks separate, clean attempt output, and map only intended images. In CI use these commands as the recording/export step before upload.
Roborazzi
Verified in CI: The isolated recipe fixture records
real Android View output, then annotates and uploads it; Difora detects
both intentional changes and keeps the control identical.
Roborazzi can write
relative to its configured output directory; set this in
gradle.properties:
roborazzi.record.filePathStrategy=relativePathFromRoborazziContextOutputDirectory
./gradlew :app:recordRoborazziDebug
node export-pngs.mjs app/build/outputs/roborazzi names.json screenshots/android-roborazzi
npx difora annotate screenshots/android-roborazzi --platform android \
--runtime host-render --device-model "$ROBOLECTRIC_DEVICE" --os Android \
--os-version "$ANDROID_API" --scale "$DISPLAY_SCALE" \
--renderer Robolectric --renderer-version "$ROBOLECTRIC_VERSION" \
--framework Roborazzi --framework-version "$ROBORAZZI_VERSION"
npx difora upload screenshots
Call captureRoboImage("checkout.png") with stable names.
Clear the configured output directory for each attempt. Exclude
_compare.png files and verification artifacts. Run each
variant in its own output root. CI must use record mode even when no
previous local reference exists; Difora compares against its approved
baseline.
Compose Preview screenshot testing
Documented-only. Configure the Android screenshot testing plugin for your project version and record the chosen preview variant.
./gradlew :app:updateDebugScreenshotTest
node export-pngs.mjs app/src/screenshotTestDebug/reference names.json screenshots/android-preview
npx difora annotate screenshots/android-preview --platform android \
--runtime host-render --device-model "$PREVIEW_DEVICE" --os Android \
--os-version "$ANDROID_API" --scale "$DISPLAY_SCALE" \
--framework Compose --framework-version "$COMPOSE_VERSION" \
--renderer LayoutLib --renderer-version "$LAYOUTLIB_VERSION"
npx difora upload screenshots
Confirm the actual reference directory for the installed plugin. Preview parameters and generated hashes can alter names. Maintain a one-to-one mapping for every preview configuration; do not simply remove a hash suffix. In CI run the update task, validate the expected export inventory, then annotate and upload.
Flutter goldens
Verified in CI: The fixture uses Flutter 3.47.2 host goldens with the default test font. Annotation, upload, both intentional changes and the unchanged control pass the review pipeline. Flutter golden assertions record files relative to the test source.
await expectLater(find.byKey(const ValueKey('checkout')),
matchesGoldenFile('goldens/checkout.png'));
flutter test --update-goldens
node export-pngs.mjs test/goldens names.json screenshots/flutter-linux
npx difora annotate screenshots/flutter-linux --platform flutter \
--runtime host-render --os Linux --os-version "$LINUX_VERSION" \
--renderer Flutter --renderer-version "$FLUTTER_VERSION" \
--scale 1 --logical-width 400 --logical-height 600 --font-scale 1 \
--surface element --environment flutter-ci --environment-revision fonts-v1
npx difora upload screenshots
Pin logical size, device pixel ratio, text scale, locale and theme in the test. Default widget-test fonts are suited to layout checks; they do not represent production typography. For typography checks, load bundled production fonts explicitly and use a separate stable variant/environment revision. Keep Linux CI and macOS captures in separate variants. Run update-goldens in CI to produce captures, then let Difora provide review gating.
Maestro
Documented-only. Use explicit takeScreenshot commands in a local device flow:
- takeScreenshot: raw/checkout
maestro test .maestro/checkout.yaml
node export-pngs.mjs raw names.json screenshots/android-maestro
npx difora annotate screenshots/android-maestro --platform android \
--runtime emulator --device-model "$DEVICE_MODEL" --os Android \
--os-version "$ANDROID_API" --scale "$DISPLAY_SCALE" --surface screen
npx difora upload screenshots
Screenshot paths are relative to the workspace, not the flow file. Export the named screen, excluding diagnostic captures. Configure deterministic status-bar demo mode, time, network indicators, keyboard and animations on the test device. In CI, wait for the device and application readiness before this flow, then run annotation/upload.
Detox
Documented-only. Detox screenshot calls return an artifact path. Retain it and give each capture a stable logical name in your export mapping.
const path = await device.takeScreenshot('checkout');
// Record path in the mapping for checkout.png; keep the actual returned path.
detox test --configuration ios.sim.release --take-screenshots manual
node export-pngs.mjs artifacts names.json screenshots/ios-detox
npx difora annotate screenshots/ios-detox --platform ios \
--runtime simulator --device-model "$DEVICE_MODEL" --os iOS \
--os-version "$IOS_VERSION" --scale "$DISPLAY_SCALE" --surface screen
npx difora upload screenshots
Timestamped artifact directories are not stable identities. Map manual captures and exclude automatic failure/debug images. Pin the simulator and status bar and wait for the intended UI state. Keep the usual Detox assertions as a CI gate before exporting its successful capture set.
PDF pages
Verified in CI: The fixture renders original PDF pages with Poppler; annotation, upload and review detect both intentional changes while the control remains identical. Pin the renderer and font set. Choose a resolution explicitly and use a stable name for each page.
mkdir -p screenshots/document-150dpi/page-001
pdftoppm -f 1 -l 1 -singlefile -png -r 150 invoice.pdf \
screenshots/document-150dpi/page-001/invoice
npx difora annotate screenshots/document-150dpi/page-001 \
--platform document --runtime host-render --os Linux \
--os-version "$LINUX_VERSION" --renderer Poppler \
--renderer-version "$POPPLER_VERSION" --surface document-page \
--document-page 1 --document-pages "$PAGE_COUNT" --document-dpi 150
npx difora upload screenshots
Repeat with explicit page index/name pairs; do not silently change page identity when content reorders. Fonts, timestamps and generated IDs can change pixels. PNG-only upload has a 30-million-pixel limit, so select a resolution that fits. Do not feed PDFs directly to upload. In CI generate the document, render the expected pages, check their count, annotate and upload.
Metadata and review
In review, open Variants to combine Platform and Device with existing filters. Browser is “Not applicable” for native captures. Rows summarize declared OS/device/scale/theme; Capture details compares baseline and current Device and Renderer fields. Missing values remain “Not recorded.” Metadata never changes baseline identity, thresholds or approval decisions.
Version 2 adds platform, device model/runtime, display
scale/density/orientation/logical size, font scale, framework/renderer,
surface, document page/count/DPI and group. Use
npx difora annotate --help for all flags. Text is bounded,
normalized and rejects control characters. Scale and font scale are
greater than zero through 16; density/DPI is 1–2,400; logical dimensions
are positive through 32,768; document pages/counts are integers
1–100,000. Both logical dimensions must be supplied together. A page
cannot exceed the declared page count.
Version 1 remains accepted at 2,048 UTF-8 bytes; version 2 allows 3,072. Sidecars retain envelope version 1 and a 4,096-byte limit. The CLI checks every metadata version and manifest size against server capabilities before creating a build. An older or disabled server produces an error rather than discarding metadata. Keep sidecars with their PNGs when sharding.
Environment warnings compare declared native renderer, OS and applicable device evidence. Intentional theme, orientation, logical size and font scale remain capture settings. Stability history still requires the same full commit and complete, exactly matching settings; native runs need their renderer/device settings instead of browser-only fields. See environment guidance and stability diagnostics.
Use the same upload step in CI
Install and pin the renderer using your platform’s normal CI setup. Save
the selected recipe’s recording/export/annotate commands in
ci/record-screenshots.sh, with strict error handling and
actual configuration values. The following job fragment applies to each
recipe; choose a macOS runner for XCTest/iOS and a Linux runner for the
verified host recipes. Store the project upload token as a CI secret.
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
# Install the selected pinned native toolchain before recording.
- run: bash ci/record-screenshots.sh
- run: npx difora upload screenshots
env:
DIFORA_TOKEN: ${{ secrets.DIFORA_TOKEN }}
Review the initial CI baseline in Difora. Subsequent uploads use the same paths and renderer configuration. Keep normal unit/integration tests as separate gates. CI provider recipes cover branch detection, merge-base comparisons, sharding and exit codes.