Repository navigation
ci: run the visual capture harness on macOS and require it to be deterministic - #199
Conversation
…rministic #179 gave the visual gate a macOS capture leg and deliberately added no CI job to run it. The reason it gave was about --check-baseline specifically: the committed macos-arm64 manifest has been reproduced on exactly one Mac, so gating it would fail on its first run and read as a UI regression. That reasoning does not reach --determinism, which compares the runner against ITSELF and asserts nothing about any other machine's bytes. So the `Desktop head on macOS` job now runs `tools/parity-audit/run.sh --determinism` into a RUNNER_TEMP out-dir: five headless captures of the real app, every PNG required to be byte-identical to the first run's. That is the first thing in this workflow that executes this repo's UI code rather than compiling it, and it is what stops the next self-race in the capture harness reaching main — the two #179 found (a pane saved mid-fade, ~1 run in 8; the shared SplitView reopened mid-close, a 6-pixel edge flip, 50/50) were found by nothing except this check. Two things the step has to get right, and does: * It asserts the capture produced 18 screens. A determinism run compares whatever both runs contain, so a consistently absent screen reads as a clean "17/17 byte-identical" rather than as an incomplete capture. #179 guards that inside the harness (ExpectedScreens); this is a deliberate SECOND copy of the contract, because the harness's list is a variable a change can edit downwards. * It uploads <out>/determinism/ when — and only when — this step is the step that failed. A determinism failure is unactionable without the differing PNGs, and #179 already had to fix a cleanup that destroyed the evidence its own failure message asked for. The harness is also restored explicitly, so an NU1004 lands on a restore step and the existing lock-file guard covers AvaloniaCapture's lock file on a macOS derivation host — which nothing did before (#106 was exactly a host-dependent key). Out of scope, and the job header now says so in place of the stale claim that the harness is not run here at all: gating --check-baseline (blocked on #188) and a Linux capture gate (never watched to succeed there). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoGate macOS visual captures on deterministic 18-screen output
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)
Great, no issues found!Qodo reviewed your code and found no material issues that require reviewTip of the day💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history |
…is not settled The CI determinism gate added in the previous commit failed on its first run on a GitHub macos-latest runner — a host this harness had never been executed on — and uploaded the evidence, which is exactly what it is for (run 33655089083, job 100331537976). What it caught: run 4 of 5 wrote desktop-8-livegraph-settings-flyout with 6 pixels one unit off, every one of them in the SplitView pane's left edge column (x=1059). That is 0.0005% of the image and the same signature as the flyout-edge flip #179 fixed for desktop-7 and desktop-9. The other four runs, 20 consecutive runs on an M-series Mac, and the committed baselines/macos-arm64.sha256 all agree byte for byte — so 89 of the 90 screen-vs-screen comparisons between this Mac and that runner are identical, and the one that is not is this. Cause: the headless render timer is MANUAL, so an animation advances only while Pump() ticks it. The settle loop is therefore driving the UI, not observing it, and stopping the loop freezes whatever frame the animation had reached. Near the end of a transition the remaining motion is sub-pixel, two samples 50 ms apart render identically, and the loop stops with the animation parked a hair short of its target — saving a frame that is stable, plausible, and wrong. It is the quietest member of this family: the 65.6% and 81% cases before it announced themselves. Fix: require two CONSECUTIVE identical comparisons (SettleStableSamples), resetting the count on any change. That buys 50 ms more of ticked animation clock past the point where the picture stopped moving, which is longer than any tail in this app. It cannot change a frame that had genuinely finished, and does not: with this in place 20 local runs produce all 18 screens byte-identical to the 20-run set captured before it, and `run.sh --check-baseline` still passes against the committed manifest. Cost is one extra 50 ms round per screen, ~0.9 s per capture pass (measured: 7.1 s to 8.2 s a run on this Mac). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he runner Reverted in the next commit. Five green runs against a defect that showed up once in five is ~33% likely by luck; fifteen brings that to ~3.5%. This commit exists so the evidence for the previous one is on the record with a run id attached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fix on the runner" This reverts commit 269b13a.
…ll pair is not settled" That fix was wrong, and the gate it was written for is what proved it. Requiring two consecutive identical comparisons passed a 5-run job on the runner, then failed a 15-run one three times (run 33657356313) at the same ~1-in-5 rate as before. The flyout-edge flip is not an animation stopping one sample early. A settle FLOOR was tried next and was worse: with 10 rounds (500 ms) forced before any frame is accepted, the same 6-pixel flip reproduced on a Mac that had been clean for 40 consecutive runs (2 failures in 12), and desktop-7-notifications-flyout settled to a THIRD encoding that no previous run had produced. So the picture is still changing after half a second, and waiting longer selects a different wrong answer rather than the right one. Both experiments are dead ends and neither belongs in the tree. What is left is the measurement, which is in the follow-up issue: two stable encodings of column x=1059 — the SplitView pane's own left edge — differing by one unit on six pixels, on whichever flyout the run happens to hit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
…s behind The determinism gate uploads `<out>/determinism/` when it fails, so a reviewer has the differing PNGs. But `run.sh`'s `run_capture` writes each pass's full stdout and stderr to `<out>/.capture-determinism-N.log` — at the output ROOT, one level above the directory being uploaded, and as a dotfile. Two independent reasons nothing under the upload's `path:` ever matched them, which is why `include-hidden-files` on its own would not have fixed it either. The case that pays for this is the one with the least to go on. A pass that dies BEFORE writing a single PNG — a crash during boot, a build error out of `dotnet run` — leaves `<out>/determinism/rN` empty, so the upload found no files at all, `if-no-files-found: warn` shrugged, and no artifact was produced. #191 asked for this gate because a determinism failure is only actionable with the evidence in hand; a failure with no PNGs is only actionable with the log, and the log was the one thing being thrown away. That is #179's cleanup-destroys-the-evidence trap in new clothes. A new failure-only step copies the logs into `<out>/determinism/capture-logs/`, dropping only the leading dot, so `capture-determinism-3.log` in the artifact is the `.capture-determinism-3.log` the failing step named on screen. Copied rather than added to `path:` deliberately: the glob would force `include-hidden-files: true`, and that flag is not selective — it would also sweep in each run's throwaway `.appdata` (SQLite DB + app logs) from inside the tree being uploaded. Copying keeps that default exclusion doing its job, and puts the evidence where a plain `ls` on the unzipped artifact shows it, rather than under a name `ls` hides — a trap this README already warns readers about. The step is the only place that knows `run_capture`'s log naming, so it refuses to fail silently: no match emits a `::warning::` and writes a `NO-CAPTURE-LOGS.txt` into the artifact naming the glob it looked for and the step to update. Nothing about the gate itself moves. Five runs, byte identity, and the independent 18-screen assertion are all unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 9496201 |
…e hoisted The collect step makes three copies of this path in one job. The obvious tidy-up — a job-level env: — silently does not work, because the runner context is not available to jobs.<job_id>.env (only to steps). Name both facts where someone would try it.
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit c6dfa35 |
|
Qodo-clean, CI green apart from the documented #202 dependency — ready for review. Qodo on The failure-artifact fix is proven in both directions on real runs, artifacts downloaded and listed rather than inferred from step status: a failure before any PNG is written now uploads an artifact whose entire contents are Merge after #202, or the gate goes red again on |
Not merging — for review. Mergeable now, but land it after #202: see
Status.
closes #191
What this catches that nothing caught before
The visual capture harness renders the real app's UI against a wall-clock animation
clock. When it races itself it produces differences indistinguishable from a real
regression, and this project has already paid for that twice:
commit, which was an unfinished fade-in.
macOS:
mobile-portrait-3-storagesaved mid-fade with 81% of its pixels wrong(~1 run in 8), and the three right-hand drawers' shared
SplitViewreopenedmid-close, flipping a 6-pixel column on the pane edge 50/50 per run.
Both were fixed, and nothing stopped the next one landing. #179 added the macOS capture
leg and deliberately added no CI job — but the reason it gave was about
--check-baselinespecifically: that manifest has been reproduced on exactly one Mac,so gating it would fail on its first run and read as a UI regression. That reasoning
does not reach
--determinism, which compares the runner against itself.On its first CI run this gate found a third one, which is the whole argument for
having it. It is now fixed in #202. Details below.
What the step does
The existing
Desktop head on macOSjob gains one step plus a failure-only upload. Itruns
tools/parity-audit/run.sh --determinisminto aRUNNER_TEMPout-dir: fiveheadless captures of the real app, every PNG required to be byte-identical to the first
run's. It is the first thing in this workflow that executes this repo's UI code rather
than compiling it.
The two traps #191 names:
both runs contain, so a consistently absent screen reads as a clean
17/17 byte-identicalrather than as an incomplete capture. fix(parity): the visual gate now captures on macOS — two settle-loop races fixed, no CI gate yet #179 guards that inside theharness (
ExpectedScreens); the CI check is a deliberate second, independentcopy of the contract, because the harness's list is a variable a change can edit
downwards — and if the only record of the expected count sits beside the code that
produces it, a gate that shrinks stays green. Adding a screen now means editing two
places on purpose.
<out>/determinism/is uploaded on failure (artifactparity-determinism-macos),so the differing PNGs are in hand. fix(parity): the visual gate now captures on macOS — two settle-loop races fixed, no CI gate yet #179 already had to fix a cleanup that destroyed
the evidence its own failure message asked for. The upload is conditioned on
failure() && steps.determinism.outcome == 'failure', not a barefailure(): abare one would also fire after an earlier failure (restore, the arm64 check),
uploading nothing and adding a second, empty red step to a job that already said
what was wrong.
Also here, because the above made them stale or attributable:
Restorestep, so an NU1004lands on a restore step rather than surfacing as "the visual gate broke", and the
job's existing lock-file guard now covers
AvaloniaCapture/packages.lock.jsonon amacOS derivation host — which nothing did before (build: --force-evaluate restore on macOS rewrites the Oxyplot project key in every lock file #106 was exactly a lock-file
key macOS and Linux derived with different casing).
so it says which half runs and which does not, and why the other half waits on tooling: the macos-arm64 baseline is named for OS+arch but has only ever been reproduced on one Mac #188.
The
desktopjob's "compiled, not run" note is narrowed to the Linux runner, whereit is still true.
tools/parity-audit/README.mddocuments the gate.Out of scope per #191, deliberately: gating
--check-baseline(blocked on #188) andany Linux capture gate (never watched to succeed there).
The failure artifact was dropping the one file a pre-PNG failure leaves behind
Trap 2 above was only half true when it was written, and the missing half is the case
that needs it most.
run.sh'srun_capturesends each pass's full stdout and stderr to<out>/.capture-determinism-N.log, and its own failure line points straight at it(
!! determinism-3 failed (see …)). Two independent things kept those logs out of theartifact:
<out>/determinism,which is the entire
path:of the upload. Nothing underpath:ever matched them,so
include-hidden-files: trueon its own would not have fixed this.actions/upload-artifactdrops by default.The case that pays for this is the one with the least to go on. A pass that dies
before writing a single PNG — a crash during boot, a build error out of
dotnet run— leaves<out>/determinism/rNempty, so the upload matched zero files,if-no-files-found: warnshrugged, and no artifact was produced at all. #191 askedfor this gate on the grounds that a determinism failure is only actionable with the
evidence in hand; a failure with no PNGs is only actionable with the log, and the log
was the one thing being thrown away. That is #179's cleanup-destroys-the-evidence trap
wearing different clothes.
A new failure-only step copies the logs to
<out>/determinism/capture-logs/, droppingonly the leading dot, so
capture-determinism-3.login the artifact is the.capture-determinism-3.logthe failing step named on screen.Copied rather than added to
path:, deliberately. Adding the glob would forceinclude-hidden-files: true, and that flag is not selective:AvaloniaCapturepointsDAQIFI_DATA_DIRat<out>/determinism/rN/.appdata, inside the tree being uploaded,so turning hidden files on would also sweep in every run's throwaway SQLite DB and app
logs. Copying keeps that default exclusion doing its job, and puts the evidence where a
plain
lson the unzipped artifact will show it rather than under a namelshides —a trap
tools/parity-audit/README.mdalready warns readers about.The step is the only place that knows
run_capture's log naming, so it refuses to failquietly: no match emits a
::warning::and writes aNO-CAPTURE-LOGS.txtinto theartifact naming the glob it looked for and the step to update.
Nothing about the gate itself moves. Five runs, byte identity, and the independent
18-screen assertion are all unchanged.
What it found, on its first run
Run 33655089083
failed, uploaded its captures, and the artifact says exactly what happened: run 4 of 5
wrote
desktop-8-livegraph-settings-flyoutwith six pixels one unit off, every oneof them in column x=1059 — the
SplitViewpane's own left edge (1440 − 380 − 1).0.0005% of the frame, and the same signature as the flip #179 fixed for
desktop-9.A 15-run job (33657356313)
put a rate on it: 3 failures in 15, on
desktop-8anddesktop-9, always thosesame six pixels. Across four
macos-latestjobs the tally was 30 capture runs and 7carrying the flip, against 40 clean runs on one M-series Mac.
Worth recording because it is the opposite of what one would guess: this is not a
cross-machine problem. Of the 90 screen-by-screen comparisons between that Mac's set
and the runner's five, 89 are byte-identical; the one mismatch is this flip.
Two fixes tried on this branch were both wrong, and their commits and reverts are in
the history with the measurements: requiring two consecutive identical settle
comparisons (passed a 5-run job, failed the 15-run one at the same rate), and a 500 ms
settle floor (worse — it reproduced the flip on the 40-for-40 Mac, 2 in 12). More
time selected a different wrong answer, which is what said the settle loop was not
where the bug lived. That was filed as #201.
Status
#201 is fixed in #202, and that is the only thing that was holding this PR.
#202's root cause turned out not to be the settle loop at all:
EncodecalledCaptureRenderedFrame(), which reads the persistent headless framebuffer that thecompositor updates in place a dirty region at a time — so the saved bytes were a
function of the redraw history rather than of the visual tree, and no settle rule could
ever have seen that. It now renders into a fresh
RenderTargetBitmap.Confirmed on real runs rather than on the claim. #202 carried a temporary commit that
enabled this gate on its own branch, and the macOS job reported
determinism OK: 18/18 screens byte-identical across 15 runs:(
DETERMINISM_RUNS: 15, green)(green)
And directly for this branch, because #202's evidence used
DETERMINISM_RUNS=15ratherthan the setting this gate actually ships: this branch merged with #202 goes green
on
macos-latestat the real 5-run default — run33669480144.
Merge order: this PR lands after #202. Without #202 the gate is red at roughly 1
capture run in 5, so a 5-run job fails about two times in three, and the red check on
this head is exactly that and nothing else: on run
33670298263 the
only failing step is the determinism one,
Collect the capture logsand the upload bothsucceeded after it, and the artifact shows the familiar signature —
desktop-8-livegraph-settings-flyoutat 52,114 bytes on r1 and 52,137 on r4. Merging inthe other order puts a known-red required check on
main.Weakening the check — a pixel tolerance, a retry, a smaller run count — would be the
"gate that quietly shrinks its own scope" this ticket exists to prevent, so nothing of
the kind is here.
Verification
macos-latest. Green:33656922699 —
determinism OK: 18/18 screens byte-identical across 5 runs, then the step's own18 screens, byte-identical across every run. Red, with the artifact:33655089083 and
33657356313.
The count guard was tested in both directions locally, against a real capture set and
against a truncated copy: 18 passes, 1 fails with the diagnostic and an
ls.macos-latestruns — thesame bar the original upload condition was held to. All three artifacts were
downloaded and their contents listed, not inferred from step status.
Failing with no PNG ever written — the case that used to produce nothing at
all. Scratch branch with a forced pre-capture crash, run
33669444638:
determinism
failure→ collectsuccess→ uploadsuccess. The artifact is4,903 bytes and its complete contents are one file,
capture-logs/capture-determinism-1.log, carryingUnhandled exception. System.InvalidOperationExceptionat line 228. Before thischange that upload matched zero files and produced no artifact.
Failing with PNGs — this PR's own CI, red on the real parity: the capture harness flips the SplitView pane edge on GitHub macOS runners, ~1 run in 5 #201 flip: runs
33669352002
and 33670298263.
Artifact 2.79 MB:
r1..r5/with all 90 PNGs, pluscapture-logs/with all fivelogs.
find -name '.*'over the unzipped artifact returns nothing, so thethrowaway
.appdata— which exists on this run, the app booted — is stillexcluded. The copy did not cost the default exclusion, which is the whole reason
for copying rather than
include-hidden-files: true.The logs turned out to carry evidence the PNGs do not surface as readily. Straight
off
capture-logs/on the second of those runs, without decoding an image:Two stable encodings with their settle-round counts beside them — the parity: the capture harness flips the SplitView pane edge on GitHub macOS runners, ~1 run in 5 #201
signature, readable at a glance. That was not the argument for collecting them,
but it is a good one.
Passing — runs
33669480144
and 33670132486:
determinism
success, and both the collect step and the upload skipped oneach. Their artifacts are
assets-androidandassets-desktoponly — noparity-determinism-macos.failure()-gated and shows asskipped. Re-measured on those two green runs: thedeterminism step took 55 s and 60 s, and the job 1 m 36 s and 1 m
52 s, against the 95 s / 2 m 35 s recorded here earlier and 63 s for the job on
main. That saving belongs to fix(parity): the capture harness photographed a surface with a history, not the UI (#201) #202, not to this PR — rendering the tree ischeaper than reading the framebuffer back on every settle round.
-c Release -r iossimulator-arm64 --no-restorebuild, andgit status -- '**/packages.lock.json'clean afterwards. The Android head cannot build on this Mac (workload absent); CI
covers it. No warning-count claim: this change compiles nothing new.
Diff against
main: 218 added / 23 removed in.github/workflows/build.yml,13 added in
tools/parity-audit/README.md. No other file differs; the twoexperiment commits are reverted in full.
🤖 Generated with Claude Code