Skip to content

feat(screenshot): select the XCTest runner capture with --capture-backend runner - #4184

Merged
thymikee merged 9 commits into
callstack:mainfrom
vitalyiegorov:feat/ios-screenshot-capture-backend
Nov 5, 2026
Merged

thymikee merged 9 commits into
callstack:mainfrom
vitalyiegorov:feat/ios-screenshot-capture-backend

Conversation

@vitalyiegorov

@vitalyiegorov vitalyiegorov commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On a Dynamic Island simulator, simctl io screenshot draws the island into every capture, and no mask removes it. XCUIScreen, which XCTest tools capture with, leaves it out, so their baselines cannot match the default capture.

The Maestro port's internal runner capture becomes public:

agent-device screenshot page.png --capture-backend runner
await client.capture.screenshot({ path, captureBackend: 'runner' }); // MCP: captureBackend
  • Every screenshot binding declares its capture backends and its own explanation. Admission refuses anything not declared (UNSUPPORTED_OPERATION): non-Apple targets, provider sessions, and macOS surfaces the helper captures.
  • --pixel-density and --normalize-status-bar are refused with it (INVALID_ARGS): the runner skips the simctl pipeline.
  • Maestro keeps its internal flag, now a preference: the runner where declared, else the owner's default.
  • 33 files. Docs: website/docs/docs/commands.md. Not recorded in .ad scripts.

Validation

Tested SHA b3da8c0d3a2a3f7e7ea602ef9bb38729abf8c0c1, on main 66438647e.

🤖 Generated with Claude Code

View guided diff

@vercel

vercel Bot commented Nov 3, 2026

Copy link
Copy Markdown

@vitalyiegorov is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@thymikee

thymikee commented Nov 3, 2026

Copy link
Copy Markdown
Member

The code at 4b8e1fd needs changes before merge.

The screenshot admission policy accepts runner capture for macOS desktop, menubar, frontmost-app helper surfaces and native app sessions, but runAppleScreenshot returns the helper capture before reaching the runner branch. CLI, SDK and MCP can therefore report success using a different backend than requested. The owning admission and binding policy must account for both the selected surface and app backend: honor runner where supported and reject incompatible helper captures. Validate that an explicit runner request cannot succeed through the helper path.

The changed public device route still lacks reproducible live evidence; the supplied simulator RMSE claim has no linked capture artifacts. Attach commands attributed to the reviewed head, device and OS details, exit status, artifact output and session cleanup. Run public screenshot commands with the default backend and --capture-backend runner on a Dynamic Island simulator, with viewable PNGs and a comparison showing the runner capture has no island. Run the same public runner request on an Android session and show UNSUPPORTED_OPERATION, then run simulator runner capture with --pixel-density and show INVALID_ARGS.

Could the shared flag and projection extension stay as written, with runner capability declared at the owning Apple surface binding and enforced during screenshot admission, instead of treating every Apple surface as runner-capable?

The only failing CI entry is Vercel deployment authorization, which explicitly requires team authorization and appears unrelated to the code changes; the diff changes website docs but no deployment authorization configuration. The local integration-node retirement failure remains unattributed without logs, because the changes overlap daemon request and context paths. No conflicts are known. Before merge, resolve the silent macOS backend override, supply the exact-head live evidence, complete outstanding validation and obtain Vercel authorization.

@vitalyiegorov
vitalyiegorov marked this pull request as ready for review November 3, 2026 12:20

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 17 files

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/contracts/src/screenshot.ts Resolved
Comment thread scripts/integration-progress-model.ts Resolved
@thymikee

thymikee commented Nov 4, 2026

Copy link
Copy Markdown
Member

This conflicts with main now. Please rebase it so review can continue. The changes requested in the review at 4b8e1fd still apply.

@vitalyiegorov
vitalyiegorov force-pushed the feat/ios-screenshot-capture-backend branch from 4b8e1fd to 19a1423 Compare November 4, 2026 07:36
@vitalyiegorov

Copy link
Copy Markdown
Contributor Author

Rebased and ready for another look at 19a1423.

Helper override: the shared flag and projection stay as written. Runner capability is declared at the Apple binding from the surface and app backend, and assertScreenshotCaptureSupport enforces it in the binding and the interactor. A request on a helper surface now fails with screenshot-capture-backend-unsupported before the capture runs. I narrowed it to a refusal rather than adding runner support there. --normalize-status-bar was silently dropped the same way, so it is refused too.

Live evidence at this head (iPhone 17 Pro simulator, iOS 26.2, macOS 15.7.4, Xcode 26.3): transcript with commands, exit codes and session close.

default vs runner

Left is the default capture, right is --capture-backend runner, scaled to the same size. Pill region black share is 81% vs 0%. --pixel-density gave INVALID_ARGS, and a macOS desktop session gave UNSUPPORTED_OPERATION.

Still open:

  • I did not run the Android request on a live device: the available emulator would not boot. It is covered only by the unit test, so this item of your list is not met.
  • The retirement failure is on base: 8 isolated runs of that test failed 3 times on main 9cf8168a4 and once at this head.
  • Question: provider-backed iOS sessions still accept the flag and capture through the provider. Refusing there would also refuse Maestro's internal request on those sessions, so I left it. Do you want it refused?

@vitalyiegorov

Copy link
Copy Markdown
Contributor Author

The Android item from your list is now done, at the same head 19a14237a, on a Pixel 9 emulator with API 35 google_apis (transcript):

$ agent-device screenshot refused-android.png --capture-backend runner --platform android --serial emulator-5586 --session ev4184a --json
UNSUPPORTED_OPERATION: --capture-backend runner is supported only on Apple targets
exit=1

The session opened first, no file was written, and the session was closed afterwards. The provider-backed iOS question above is still open.

@thymikee

thymikee commented Nov 4, 2026

Copy link
Copy Markdown
Member

At 19a1423, the earlier conflict is fixed and no conflicts are known, but provider capture still needs a fix before merge.

Could screenshot admission require explicit runner support from every selected screenshot owner? An iOS provider binding can leave that capability undefined, so Limrun can report success after using its provider endpoint for an explicit runner request. This can invalidate backend comparisons. Preserve Maestro behavior with an explicit provider-default request.

Could each screenshot owner declare its supported backends so the existing admission check rejects unsupported selections in one place, including providers, while each binding owns its platform explanation? Would the first step be to make runner support explicit for every binding and distinguish Maestro's provider-default capture from an explicit runner request?

The helper surfaces thread can be resolved because admission already rejects them. The option forwarding thread can be resolved because dispatch restores the option before bound capture. The schema description thread is fixed at head. The false assertion thread, provider exclusion thread, and dispatch description thread are fixed at head.

The only failing check is the Vercel deployment authorization, which is not a code problem. Before merge, please enforce the requested backend for provider captures and show that a provider iOS runner request is refused instead of returning a provider screenshot.

@vitalyiegorov

vitalyiegorov commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Updated to 9feb269.

Backends are declared per owner. captureCapabilities is now required on every screenshot binding, so an owner that declares nothing fails to compile, and assertScreenshotCaptureSupport refuses any backend the owner does not list. That covers Limrun, Revyl, WebDriver, TV, web, Android, HarmonyOS and Linux, each with its own explanation. Apple declares runner unless the macOS helper owns the surface. The daemon's Apple-only check moved into that one place.

Maestro: the internal maestro.screenshotCaptureBackend is back from main, now read as a preference: the runner where the owner declares it, otherwise the owner's default capture. On an iOS provider session Maestro still gets the provider capture, and an explicit --capture-backend runner is refused.

Provider iOS evidence is at the admission boundary, not live: no Limrun or Revyl account is available to me. The test binds the real bindLimrunInteractionOperations for an iOS device with a fake interactor (output):

at 19a14237a: promise resolved "{}" instead of rejecting
at 9feb26936:  UNSUPPORTED_OPERATION, reason screenshot-capture-backend-unsupported, the provider screenshot is not called

Please check these limits:

  • The iOS and Android live runs are from 19a14237a and were not repeated. Android's refusal now comes from its binding, so its message differs from that transcript; only a unit test covers the new path.
  • check:affected stopped 4 times at this head on the retirement flake, so there is no single passing receipt. The 26 checks it blocked passed one by one through pnpm gate. In isolation that test failed 0 of 8 times here and 3 of 8 on main.

The helper surfaces thread is resolved, as you said it could be.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 27 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

@thymikee

thymikee commented Nov 4, 2026

Copy link
Copy Markdown
Member

At 9feb269, the explicit flag forwarding, backend admission, option description, and recording assertions are fixed since the earlier review, but the preference precedence issue remains: on the daemon path an own captureBackend: undefined key overrides the Maestro runner preference, so its comparison capture falls back to simctl.

After fixing preference selection, please provide current-head CLI runs through an opened provider iOS session showing explicit runner returns UNSUPPORTED_OPERATION with screenshot-capture-backend-unsupported and takes no provider screenshot, and that provider Maestro comparison capture succeeds with its default backend. Repeat Android explicit runner refusal through its owner binding and local iOS Maestro comparison through runner. Include the SHA, device and OS, commands, exit statuses, observable backend or artifacts, and session cleanup. The supplied live runs used 19a1423; binding unit tests do not establish daemon routing or Maestro backend selection. The author reports no live provider run or current-head repeat.

Could the runtime input type own preferredCaptureBackend, with explicit and preferred values resolved once in the binding and only resolved ScreenshotOptions passed to interactors? This would retain the owner declarations, admission helper, screenshot registry, and Apple helper-surface policy while removing the preference from ScreenshotOptions and avoiding undefined-spread precedence.

The preference precedence and Android forwarding coverage threads still apply, as do three lower-priority threads. The other earlier threads are fixed or no longer apply.

The only failing check is the Vercel deployment authorization, which is not a code problem. No conflicts are known. Before merge, please fix preference resolution, cover the daemon-to-binding route, post the live runs above, and get a passing affected gate or show that its failure also happens on main.

@vitalyiegorov
vitalyiegorov force-pushed the feat/ios-screenshot-capture-backend branch from 58d7bd8 to 9feb269 Compare November 4, 2026 11:41
@vitalyiegorov

Copy link
Copy Markdown
Contributor Author

Updated to 58d7bd8, with the live runs at this head.

Preference: adopted your design. CaptureScreenshotInput owns preferredCaptureBackend, explicit and preferred are resolved once in the binding, and only resolved ScreenshotOptions reach interactors, so an undefined key cannot override anything. Owner declarations, the admission helper, the screenshot registry and the Apple helper-surface policy are unchanged. The regression was real: the daemon-path test was red at 9feb269 before this fix.

Route coverage: daemon tests now run the daemon's real output through the real binder, including the Android refusal at the capture owner.

Android, live: Pixel 9 emulator, API 35 google_apis, macOS 15.7.4 host (transcript):

$ agent-device screenshot refused-android.png --capture-backend runner --platform android --serial emulator-5588 --session ev4184a --json
UNSUPPORTED_OPERATION, reason screenshot-capture-backend-unsupported
--capture-backend runner is not supported: an Android device captures through adb
exit=1

No file was written, and the session was closed.

Local iOS Maestro comparison, live: iPhone 17 Pro simulator, iOS 26.2, slim, Xcode 26.3 (transcript, runner log, events). replay comparison.flow.yaml --maestro with launchApp and waitForAnimationToEnd exited 0.

The runner log accepted exactly 3 screenshot commands: 2 during the comparison (first.png, second.png) and 1 for the explicit runner control. The default-capture control between them added none, so that log tells the backends apart. Exec arguments are not in these logs, so I cannot show a simctl io count. The comparison images are private to the run, so there is no artifact of them; default and runner are the controls. The session was closed.

Provider iOS has not run live, and awaits credentials. No provider account is available to me. Both cases you asked for, explicit runner refused with no provider screenshot and a provider Maestro comparison succeeding on its default backend, remain open. What exists is the admission-boundary test on the real Limrun binding with a fake interactor (output, from 9feb269). That is not a live run.

Gate: check:affected stopped on the retirement flake at this head, so there is no single passing receipt. The 28 blocked checks passed through pnpm gate, and unit fails only on that one test (17177 Vitest tests passed, Node runner 75 passed, 1 failed). The same test failed 3 of 8 isolated runs on unchanged main 9cf8168a4.

The three lower-priority threads are fixed, and I replied on each.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

@thymikee

thymikee commented Nov 4, 2026

Copy link
Copy Markdown
Member

At 5c8305d, the code looks good. Maestro's runner preference now survives the daemon path, and the new test asserts the capture's destination and options, so the destination thread is fixed.

Your live run at 58d7bd8, the last production change, covers the Maestro side. The comparison replay passed, and the runner log shows its captures going through the runner. The Android refusal is covered too.

The one gap is the provider iOS refusal. As you noted, it has only run at the admission boundary because it needs provider credentials, so we will run screenshot --capture-backend runner on a provider iOS session on our side. That run should show UNSUPPORTED_OPERATION with screenshot-capture-backend-unsupported and no capture.

The only failing check is the Vercel deployment authorization, which is not a code problem. No conflicts are known.

vitalyiegorov and others added 5 commits November 4, 2026 17:39
…kend runner

On iOS simulators with a Dynamic Island, simctl io screenshot draws the
island into every capture (no --mask policy removes it), while XCUIScreen,
which Maestro and other XCTest tools capture with, leaves it out. A visual
baseline made by such a tool therefore cannot be matched by agent-device's
default capture. The runner capture already existed for the Maestro port
behind an internal flag.

screenshot --capture-backend runner (SDK and MCP: captureBackend: 'runner')
selects it on Apple targets. It is refused on other platforms and with
--pixel-density, since the runner encodes the device density. The Maestro
port now uses the public flag; flags.maestro.screenshotCaptureBackend is
removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screenshot admission accepted --capture-backend runner on every Apple
surface, but runAppleScreenshot returns the macOS helper capture before it
reaches the runner branch, so the request reported success with another
backend. The Apple binding now declares runner support from the selected
surface and app backend, and assertScreenshotCaptureSupport refuses it.

--normalize-status-bar was dropped the same way, so it is refused with the
runner like --pixel-density. The MCP input gains its schema description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An owner that declared nothing accepted --capture-backend runner, so a provider
iOS session such as Limrun reported success with a provider screenshot. Every
screenshot binding now has to declare its backends and its own explanation, and
the existing admission check refuses a backend the owner does not list. The
daemon's Apple-only check moves into that one place.

Maestro's comparison capture asked for the runner through the public flag, which
would now fail on a provider. It goes back to its internal
maestro.screenshotCaptureBackend, read as a preference: the runner where the
owner declares it, otherwise the owner's default capture, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon passes an own captureBackend key, undefined for a Maestro request, and
it overrode the preference in the binding, so a local iOS comparison capture fell
back to simctl and drew the Dynamic Island back in. An explicit backend still wins.

The preference moves off the shared ScreenshotOptions onto the binding input, since
the binding strips it before the interactor. The daemon-level Android refusal case
is back, now at the capture owner, and WebDriver's refusal has a case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vitalyiegorov
vitalyiegorov force-pushed the feat/ios-screenshot-capture-backend branch from 5c8305d to 6d237c3 Compare November 4, 2026 17:01
@vitalyiegorov

Copy link
Copy Markdown
Contributor Author

Rebased onto main 66438647e at 6d237c3: a rebase only, with no behaviour change in this PR. git range-diff shows three commits identical, and the other two differ only in screenshot-support.ts, where I kept main's screenMask condition beside the backend declaration.

The live runs at 58d7bd8 stand for this PR's logic, but main changed the capture path under them. #4213 reworked how a runner capture's file is copied back from the simulator, and those runs predate it. I did not repeat them, so that copy is covered here only by unit tests.

#4213 also adds simulatorScreenshotBackend: 'runner' on the Apple tool provider. Under it the default capture goes fullscreen, but an explicit --capture-backend runner passes fullscreen as given. I left that alone; tell me if you want them aligned. Your provider iOS check should not be affected: Limrun binds its own capture and refuses the runner before reaching this path.

Gate: no single receipt at this head. One run stopped on the smoke-daemon-clean retirement flake, and the 26 blocked checks then passed through pnpm gate. A second stopped on launch-confirmation.test.ts, "the owner lookup is bounded as a whole", which this PR does not touch. With that file run alone, it failed 11 of 12 times on unchanged main and 9 of 12 here, with the same assertion. 5c8305d99 had a full receipt.

@thymikee

thymikee commented Nov 4, 2026

Copy link
Copy Markdown
Member

At 6d237c3, the rebase looks right. Main's screenMask condition sits beside the backend declaration in screenshot-support.ts, and both the mask refusal and backend admission are still enforced. The launch-confirmation.test.ts flake you noted also fails on main and is outside this change.

The provider iOS refusal is still the one open item, and it stays on our side: a screenshot --capture-backend runner run on a Limrun iOS session should return UNSUPPORTED_OPERATION with screenshot-capture-backend-unsupported and write no file.

Not blocking, take or leave: #4213 rewrote the simulator file copy-back the runner capture uses (screenshot.ts:384), so one simulator run at this head showing a valid PNG at the output path would close that gap. The default capture under a runner-backed provider also passes fullscreen: true (screenshot.ts:214) while an explicit runner backend does not, so you could align the two as you offered.

No conflicts are known.

… a runner-backed provider

Under a tool provider whose simulator screenshots go through the runner, the
default capture asked the runner for fullscreen, but an explicit
--capture-backend runner did not. The default now lives in the runner capture, so
both routes share it. An explicit fullscreen value still wins, and captures outside
such a provider are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vitalyiegorov

vitalyiegorov commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Both optional items are done, at 8797656.

Copy-back after #4213, live at this head: iPhone 17 Pro simulator, iOS 26.2, slim, macOS 15.7.4, Xcode 26.3 (transcript, PNG, checker).

$ agent-device screenshot runner-1.png --capture-backend runner --platform ios --udid 45E52149-2CCB-4A67-A2F4-5D9710AF6D44 --session ev4184f
<scratch>/out/runner-1.png (1206x2622 @1x)
exit=0
PNG signature ok, all chunk CRCs ok, IHDR 1206x2622 depth 8 colortype 2, 9489018 scanline bytes decoded

A second capture in the same session, which takes the cached-bundle branch, passed the same checks. The runner log shows both accepted and completed with ok=1. The session was closed and the lease released. The two files have the same hash, since the screen did not change.

Alignment: the default now lives in captureScreenshotViaRunner, so an explicit runner capture under a runner-backed tool provider also asks for fullscreen: true. An explicit value still wins. Corrected: at 8797656 this also applied to a macOS device shaped as a simulator, so "simulators only" was wrong as first written; b3da8c0 fixes it, and macOS and physical captures do not change. The explicit-route test was red first. No in-repo consumer sets simulatorScreenshotBackend: 'runner', so this path is covered by unit tests, not by the live run above, which ran outside such a provider.

Gate: check:affected stopped on the retirement flake. Through pnpm gate, 24 of the 26 blocked checks passed. replay-compat and daemon-wire-compat did not run here, because they need full history and tags and I was working from a shallow fetch. Both passed at 6d237c3, and this commit touches neither, but CI should confirm them.

The provider iOS refusal stays with you, as you said.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

…screen default

The default checked only device.kind, so a macOS device shaped as a simulator got
fullscreen under a runner-backed provider. It now uses the same predicate as the
capture path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vitalyiegorov

Copy link
Copy Markdown
Contributor Author

Updated to b3da8c0: a one-line fix so the runner fullscreen default skips macOS, plus its test. Correction: my last comment said the alignment applied to simulators only. At 8797656 that was wrong for a macOS device shaped as a simulator, and it is true now. This does not touch the file copy, so the live copy-back run at 8797656 stands and I did not repeat it. replay-compat and daemon-wire-compat still need full history, so CI should confirm them; all other selected checks passed.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thymikee

thymikee commented Nov 4, 2026

Copy link
Copy Markdown
Member

At fe817f6, the code looks good. The fullscreen default for an explicit runner capture under a runner-backed provider is now decided in one place, captureScreenshotViaRunner, and the macOS case is gated by capturesThroughSimulatorStatusBar. The macOS default and test title threads are fixed.

The provider route has only mocked coverage, so our provider run on our side now checks two things on a runner-backed iOS session. The UNSUPPORTED_OPERATION refusal should still apply where the provider does not declare the runner backend. Where it does, an explicit --capture-backend runner capture should send fullscreen: true and frame the PNG like the default capture.

All checks pass, and no conflicts are known.

@thymikee

thymikee commented Nov 4, 2026

Copy link
Copy Markdown
Member

We are accepting this at fe817f6. If you have Limrun access, a run of screenshot --capture-backend runner on a Limrun iOS session is still welcome, but it no longer blocks the merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Nov 4, 2026
Keeps main's Limrun facts split. The runner-refusal test now passes the
facts main's bindLimrunInteractionOperations requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vitalyiegorov

Copy link
Copy Markdown
Contributor Author

Merged main at ce21bb35, because the branch conflicted. No change to this PR's behaviour since fe817f6.

The only conflict was the imports of provider-limrun/src/interaction-operations.test.ts, from main's Limrun facts split. bindLimrunInteractionOperations now requires facts, so the runner-refusal test passes them.

Run locally at this head: the Limrun, screenshot, capture and runtime tests touched here (384 passed), typecheck, and oxlint on packages/provider-limrun. I did not run check:affected or repeat the live captures.

Written by an agent.

@thymikee
thymikee merged commit d7d0efa into callstack:main Nov 5, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants