Maestro Deck
Guides

Visual regression

Compare each run's screenshots against a bank of reference images on Maestro Deck Cloud, and fail the build on a visual change from GitHub Actions.

Functional assertions tell you a button is still there. They say nothing about it having turned white on white, moved behind a banner, or lost its icon. Visual regression closes that gap: you commit a folder of reference images, and every run compares its own screenshots against them.

It works the same way on ios, android and web.

- uses: BlueShork/maestro-action@v5
  id: maestro
  with:
    api_key: ${{ secrets.MAESTRO_API_KEY }}
    platform: android
    app: build/app-release.apk
    flow: .maestro/
    bank_path: .maestro/bank
    app_name: my-app
    visual_strict: "true"

How it works

1. Your flows take screenshots

Every takeScreenshot: home in a flow writes home.png during the run. Those captures are what gets compared. A flow with no takeScreenshot produces nothing to compare.

2. The action uploads your bank

bank_path points at a folder of .png files in your repo. The action uploads them alongside the flows, as the reference for this run.

3. The platform matches by filename

home.png in the bank is compared against the capture named home.png. Matching is by filename only, recursively, so nested flows writing into subfolders still line up.

4. Each reference gets a verdict

matched, changed, or missing. The counters land on the run, in the report, and on the step's outputs.

The bank is the set of images under examination, not the captures. A capture with no matching reference is ignored, since you have not vouched for it yet. A reference with no matching capture is missing, because a flow that stopped taking a screenshot is worth reporting.

Building the first bank

There is no way around running once first. Run your flows with takeScreenshot and no bank_path, open the report, and take the captures it produced as your starting references. Commit them into .maestro/bank, then add bank_path to the step.

Take the references from a run, never from a screenshot you produced some other way. The platform refuses to compare two images of different dimensions and files the capture as changed, with no ratio and no diff image. On web, note that screen_size sets the browser window while the capture is the viewport inside it, so a capture is shorter than the size you asked for, see web testing. On mobile it means keeping the same device profile.

From then on, a deliberate UI change is a diff on those .png files in the same pull request as the code that caused it. That is the point of keeping the bank in the repo rather than on the platform: your references are reviewed like everything else.

What counts as changed

Comparison is per pixel, on a perceptual color delta, with two knobs applied for you:

  • A pixel counts as different when its color delta exceeds a tolerance of 0.1. Antialiasing and a compression artifact do not trip it.
  • An image is changed when more than 0.1% of its compared pixels are different. Below that, matched.

Some bands are excluded from the comparison entirely, because they carry a clock and a battery level that change on their own:

PlatformTopBottomRight
iOS6%4%2%
Android4.5%4.5%2%
Webnonenonenone

Excluded pixels do not count in the denominator either, so masking never dilutes a real change elsewhere on the screen.

Failing the build

By default a visual change is recorded, not enforced: the run finishes, the report shows the diffs, and your pipeline stays green. Set visual_strict: "true" to fail the step when any image is changed or missing.

- uses: BlueShork/maestro-action@v5
  id: maestro
  with:
    api_key: ${{ secrets.MAESTRO_API_KEY }}
    platform: web
    url: https://example.com
    flow: .maestro/
    bank_path: .maestro/bank
    visual_strict: "true"
- if: always()
  run: |
    echo "changed: ${{ steps.maestro.outputs.visual_changed }}"
    echo "missing: ${{ steps.maestro.outputs.visual_missing }}"

visual_strict fails the step, never the run. A run's own status comes from your functional assertions alone, so it can be passed and still carry changed images. Whether that stops a merge is a policy decision, and it stays in your workflow file.

The visual_changed and visual_missing outputs are readable even when the step fails, so an if: always() step can post them. Both are empty strings when no bank was configured.

Grouping runs under an app

app_name files the bank under a named app on the platform and groups every run of that app together in the dashboard's Apps section. It only means something when bank_path is set, and a bank without it still gets its comparison. You just lose the per-app history.

Limits

Images per bank200
Size per image10 MB
Format.png only

Filenames are sanitized: every character outside a-z, A-Z, 0-9, ., _ and - becomes an underscore. Two references that collide once sanitized are rejected, so mon écran.png and mon_écran.png cannot both be in the same bank. The action checks this locally before uploading anything.

Reading the report

The run page shows each reference next to its capture and, when they differ, a diff image with the changed pixels painted red. Alongside it, the four counters: total, matched, changed, missing.

The same counters are on the CI status endpoint the action polls, which is how visual_strict decides. Per-image detail and the images themselves stay behind your dashboard login.

Turning it off

Leave bank_path empty or drop it. No bank is sent, no comparison runs, visual_strict becomes a no-op, and the two outputs come back as empty strings. Nothing else about the run changes.