Repository navigation
Description
Part of #2725
Purpose
Before replacing remaining XCUIScreen.main reads, establish which native screen-capture operation actually captures the active app window's display. PR #2724 established that resolving a window frame before reading its screen yields the correct gesture display identity. That does not yet prove screenshot behavior.
Inputs and narrow scope
Read PR #2724, ADR 0025, RunnerXCTestEventBridge.m, RunnerTests+Lifecycle.swift, and the screenshot case in RunnerTests+CommandExecution.swift. This task is a bounded investigation, not a runtime migration.
Use a dedicated session and iPhone Duo/iOS 27.1-capable Xcode. Read the fixture app setup instructions. Test closed, half-open, and open; use visibly different app content to distinguish panels. Also check an ordinary single-panel iPhone. Resolve the window first, inspect its screen identity, and test the screen's screenshot operation. Include orientation, logical window size, pixel size/scale, and whether system overlays are included. Do not infer success from nonempty PNG bytes alone.
Deliverable / stop condition
Post a remote-accessible evidence table on this issue, with tested commit, toolchain, commands, images or artifact links, and an explicit supported/unsupported verdict for each case. Propose one narrow native helper signature that returns the captured image with its actual display/geometry metadata. State how an unresolved window is handled without silently selecting main.
No permanent runtime code changes or new fallback chains in this task. Remove probes and clean up the session. If a required simulator is unavailable, record that blocker; do not substitute mocks for the measurement.
Dependency context: #2724 is open at issue creation; use its measured findings as evidence and check its latest status. Existing input bugs #2711/#2719 stay owned by that work, not this investigation.
Working rules
Read AGENTS.md and its relevant task procedures. Keep this to one task/PR; do not absorb sibling issues. Before implementation, check current main and prerequisite PRs: the review baseline is v0.21.8 and findings may already be fixed. Do not change the active handoff worktree or use its simulator without checking ownership. Follow repository validation and manual-session cleanup requirements. Report an unavailable runtime or device as a blocker, not a passing validation.
Activity
thymikee commentedon Sep 21, 2026
Investigation completed — 2026-09-21
Base commit
a2f0e2068985f8c02bb69520305b78665d02ce67plus a temporary native XCTest probe (source pattern below; not a production change). Xcode 27.1 beta, Duo iOS 27.1 build 24A94401; single-panel control iPhone 18 Pro/iOS 27.0. Fixturecom.callstack.agentdevicelab, Metro readiness checked and visible Agent Device Tester surface verified. Every successful test below executed, rather than only building/discovering.The resolved-screen PNGs were visually inspected, not accepted merely for having bytes. Open/half-open
XCUIScreen.maininstead captured a black outer panel. On the closed Duo both sources selected display 1.Critical encoding result
On open/half-open Duo the returned UIImage has size approximately 950.667x668.667 points, scale 3, and
.rightorientation (raw 3). Its CGImage andimage.pngData()IHDR remain 2006x2852. Drawing that image at its oriented logical size/actual scale produces upright 2852x2006 pixels. This is one pixel smaller per axis than CoreDevice's nominal 2853x2007; preserve actual capture dimensions, do not stretch or infer scale from nominal panel size. The existing raw PNG-size consumer cannot safely assume EXIF orientation has already been applied.Minimal reproducible probe
Run inside a runner XCTest on each selected pose (activate is test setup, not proposed capture-helper behavior):
Build with
xcodebuild build-for-testingagainstAgentDeviceRunner.xcodeproj, schemeAgentDeviceRunner, an explicit iOS Simulator UDID and dedicated derived data; runtest-without-building -only-testing:AgentDeviceRunnerUITests/RunnerTests/testDisplayCaptureInvestigation. Extract images usingxcresulttool export attachments. The alert probe navigated Settings -> Open automation lab -> Open automation alert, asserted the alert existed, then used the same capture path. An initial alert attempt relying on a deep link failed setup and was not counted as evidence; visible navigation fixed it.Decision and limits
Use one resolved-window screen-capture helper with upright raster plus actual display/scale/dimensions; #2728 now specifies the production boundary and failure semantics. No guessed screen selection or main-screen fallback. Native in-app alert and status UI inclusion are proven; this does not establish every cross-process system surface or physical-device configuration. Consumer-level fallback/navigation/keyboard tests remain implementation acceptance in #2728.
Local tooling observation
The fold helper initially reported
device-hub-window-missingwhile the user confirmed the window existed on a second host display. After the user moved it to the main display, half-open/closed/open controls succeeded. Record this as a workaround and possible helper limitation, not proof that the window was absent. No source workaround was added. Duo was restored open, the temporary probe was removed, and the single-panel control was returned to shutdown.thymikee commentedon Sep 21, 2026
Measured: the resolved app window is the capture source that follows the fold
Recorded in ADR 0025 by #2739, which replaces the
Runner screenshot fallbackgap that asked this question.Every
XCUIScreenshotProvidingsurface a runner can reach, measured against thesimctl io screenshot --display=<panel>capture of the panel CoreDevice reports lit. Supported:window.screenshot()on a resolved window. Unsupported:XCUIScreen.main.screenshot()andXCUIApplication.screenshot()once the inner panel is lit — the first is the runner's iOSscreenshotbranch, its recording frame source, and its navigation probe.Erratum (2026-09-21, second review pass). Six statements in the first version of this comment were wrong or misattributed, and the numbers here now match the artifacts: (1) the table formerly headed "Open pose, hinge 180°" carried numbers from a session whose hinge read back 130° — the settled 180° session's values are now the primary column and the 130° runs are labelled corroboration; (2) "half-open reproduced every open-pose row" compared two 130° sessions; (3) the main-screen capture is
1398x2034in the PNG header, and2034x1398was an NSImage orientation artifact; (4) theUIImageorientation case was named without proof — onlyrawValue: 3is recorded; (5)RunnerTests+CommandExecution.swift:2068and:2072are themselvesXCUIScreen.maincaptures, and only the:2070branch between them captures the app root; (6)iPhone19,4appears in no retained artifact and is dropped. See #2739 for the same corrections in ADR 0025.Corrected after adversarial review: the identity keys are KVC-only and declared in no XCTest header, the
#if os(macOS)branches were never reached by these runs, one open-table oracle figure was the runner capture's own luma, and pixel agreement is now stated in pixels rather than PNG bytes. #2739 carries the same corrections.Test bed
9542F4F5-51D1-4F5D-B3C9-02D78A69F0D9, iOS 27.1 (24A94401)0E0FCBDC-88E2-4CB6-A1DA-E148F77743F4, iOS 27.0 (24A434)DEVELOPER_DIR=/Applications/Xcode-27.1.0-Beta.app/Contents/Developerfor everyxcrun/xcodebuildcallexamples/test-apprepacked dev build (com.callstack.agentdevicelab, JS embedded — no Metro), a different screen per pose so no panel could be confused for the other by content45e4c594a(v0.21.8) runner sources, plus a test-only probe file inAgentDeviceRunnerUITests/UnitTests/compiled under-D AGENT_DEVICE_RUNNER_UNIT_TESTS. The probe calls only XCTest capture APIs.git grep -n XCUIScreen.main 45e4c594a origin/main -- applereturns the same six call expressions in four files on both commits, andgit diff --stat 45e4c594a origin/main -- appleis empty, so what was built is the capture path onorigin/main. Disclosure: the host checkout was shared and later moved to another branch, so the later runs are not pinned to a named commitxcrun devicectl device motion hinge-angle. Read-back is the record, not the request: closed 0°, and two sessions requested asopenread back 130°, so only a later session reached a settled 180°xcrun devicectl device info displays(jsonVersion 5) for lit panel +nativeSize+pointScale, thensimctl io screenshot --display=LCDand--display=LCD-1Probe method: one XCTest function activated the app, then captured from
XCUIScreen.main, everyXCUIScreen.screenselement,app.windows.firstMatch(frame read first, and again with no frame read),app, one button element, and the SpringBoard alert window. Per capture it recordedUIImagesize/scale/orientation,CGImagepixel size, PNG byte counts from bothPNGRepresentationandUIImage.pngData(), mean/max luma and non-black fraction over a 48×48 resample, and wrote every PNG. Luma never carries an equality claim here: panel identity comes from CoreDevice'sdisplayIdand pixel agreement from decoding both PNGs and comparing every pixel. Panel identity was confirmed against CoreDevicedisplayId, never inferred from pixels; mean luma only corroborated.Evidence
Closed pose, hinge 0°, CoreDevice:
LCDdisplayId 1primaryactiveOn1398x2034 @3rot0;LCD-1displayId 3off2007x2853 @3rot90.simctl --display=LCD(oracle)XCUIScreen.main.screenshot()XCUIScreen.screens[0](displayID 1)XCUIScreen.screens[1](displayID 3, dark)window.screenshot()screens[0]app.screenshot()element.screenshot()("Drag source" cell, 188.67x56 pt)Proof images, resized for upload — the pixel dimensions cited in the tables are the originals.
Inner panel lit. The settled 180° session (hinge 180.0°, CoreDevice
LCDoff/LCD-1activeOn) is the primary run; two 130° sessions on other app content are corroboration. Every verdict below holds in all three.simctl --display=LCD-1(oracle)simctl --display=LCD(now dark)XCUIScreen.main.screenshot()XCUIScreen.hdocumentsmainScreenas "the primary screen of the device", so that is the documented meaning, not a glitchXCUIScreen.screens[0]/[1]window.screenshot()screens[1]; the 130° run: luma 236.91, 187 differing, sum 530window.screenshot()with no priorframereadXCUIElement.screenshot()resolves its own snapshot; the frame-first rule is needed for thedisplayIDread, not for the captureapp.screenshot()app.screenis pinned to panel 1element.screenshot()Single-panel control, iPhone 17 / iOS 27.0, one integrated panel (
LCDdisplayId 1, 1206x2622 @3,activeOn, noactivekey — as ADR 0025 records): every surface agreed at 1206x2622, luma 224.48 vs oracle 224.44,rot0, no rotation, and both element crops came back upright. Nothing here changes that path except that panel identity becomes explicit.Facts the capture path cannot read off the image
window.screenis anXCUIScreenwhosedisplayIDequals CoreDevice'sdisplayId(1=LCD,3=LCD-1) — but only after the window snapshot is resolved. Measured in open pose:app.screen.displayID= 1, unresolvedwindow.screen.displayID= 1, resolvedwindow.screen.displayID= 3. Same ordering fix(ios): target the active window for foldable interactions #2724 measured for gesture routing.XCUIScreenanswersdisplayID,scale,bounds(KVC/responds(to:)) and raisesNSUnknownKeyExceptionforframeandname;XCUIScreen.selfresponds only toscreens/mainScreen— nodisplayWithID:/screenWithDisplayID:orError:— so a panel is selected by filteringXCUIScreen.screens. No backlight oractivefact exists there, so panel power stays CoreDevice's. ADR 0025's sentence was right about the declared surface and understated the runtime:XCUIAutomation.framework/Headers/XCUIScreen.hin this toolchain declaresmainScreenandscreensand nothing else, and no XCTest header mentionsdisplayID. Every identity read above is KVC against an undeclared key — the same category of read fix(ios): target the active window for foldable interactions #2724 already depends on for gesture routing — so picking the lit panel was never impossible, it was undocumented, and taking that on is fix(ios): use the resolved display for screenshots and visual verification #2728's decision to make, not this measurement's.XCUIScreen.scale= 3 = that panel's CoreDevicepointScale. Captures arrive in the panel's native geometry, not the interface orientation: on therot90inner panel the PNG header is 2006x2852 with the landscape content turned inside it (UIImageOrientation(rawValue: 3), whose display size transposes the panel — naming the case was not verified and a 180-degree case is ruled out by that transposition), whilesimctlexports that panel 2853x2007 upright. Rotating alone does not make them comparable: the canvases are a pixel apart on each axis (2006x2852against2853x2007). The runner's production encoder andPNGRepresentationagree on the pixels — 0 differing pixels from358,402and328,181encoded bytes for the same capture — so only the compression differs, and the rotation is the capture's own. A runner capture and asimctlcapture of the same panel are not comparable until one is rotated.com.apple.springboardwindow is a black668x950crop — and that window's resolvedscreen.displayIDwas3, the dark panel in the closed pose, so panel power explains the crop without needing any system-window rule. Whether a system window on the lit panel captures was not measured, so verifying a system surface on the app's panel remains the safe rule. Proof, a closed-pose app-window capture with the keyboard composited in:Element Window (First Match) cannot request screenshot data because it does not exist— no silentXCUIScreen.mainfallback is observable on that path.XCUIAutomationdoes containCould not resolve displayID for snapshot %@; falling back to main display (id %lld), so an internal snapshot→display resolution failure can fall back to main silently; that is a reason to resolve explicitly, not to rely on the element path's own lookup.Proposed helper (not implemented here)
Order is the one #2724 proved and ships: read
windows.firstMatch.frameto resolve the snapshot, then readscreenfordisplayID/scale/bounds, thenwindow.screenshot(). #2724 already owns that resolution inRunnerResolveApplicationDisplayID(RunnerXCTestEventBridge.m) with these same two failure cases and unit tests, so the capture helper should extend it rather than open a third route to the same private key — and a fourth path has to converge with it:onScreenWindowFrame(RunnerTests+Interaction.swift:222-231) resolves the same window throughwindows.element(boundBy: 0)and silently falls back toapp.frame, the never-refuse shape this issue warns about. An unresolved window throws.windowUnresolved— it never capturesXCUIScreen.mainand never pairs an image with a panel nobody measured. That matters becauseXCUIScreen.mainis exactly what today's fallback captures, so a main-screen default would reintroduce the black-panel bug under a new name.displayIDis the seam to the existing resolver, with the caveat that it is an undeclared KVC key, so #2728 inherits the breakage risk #2724 accepted for gesture routing. #2728 can requirecapture.displayID == litPanel.displayIdfromresolveAppleCaptureDisplayand report a typed mismatch instead of trusting either side, which keeps CoreDevice as the panel authority and gives density normalization the captured panel's ownpointScaleinstead of a scale probe. The rotation column is the input #2729 needs before runner andsimctlcaptures are compared.Consequence for #2728
XCUIScreen.mainis six call expressions in four files, not one site. iOS-reachable: thescreenshotcommand's#elsebranch (RunnerTests+CommandExecution.swift:2075) —:2068and:2072sit under#if os(macOS)and are themselvesXCUIScreen.maincaptures — only the:2070branch between them captures the app's root — so these runs prove nothing about the desktop behavior, and the desktop also has a Screen Capture Kit route (AgentDeviceMacOSHelper/main.swift:567);captureRunnerFrame(RunnerTests+Lifecycle.swift:45-48), which feeds both the recorder (RunnerTests+CommandExecution.swift:1468) and the keyboard stability fingerprint (RunnerTests+Keyboard.swift:259); the in-app-back visual-state probe (RunnerTests+Navigation.swift:141); and the synthesized-gesture reference frame (RunnerTests+Interaction.swift:841), which only the unmerged #2724 replaces.Two of those fail less visibly than a black screenshot: identical black samples satisfy
runnerScreenshotStabilitySettledon the first interval whatever the keyboard is doing, and an unchanged black pair logsAGENT_DEVICE_RUNNER_IN_APP_BACK_FALLBACK_NO_STATE_CHANGEand reports a back navigation that worked as one that failed. They call the API this probe ran, so that follows by construction and not by measurement.The host side moves with them:
packages/platform-apple/src/core/screenshot.ts:179passesundefinedscale because "The runner capturesXCUIScreen.main",display-inventory.test.ts:468asserts exactly that, andwebsite/docs/docs/commands.md:1122tells users physical capture is built from repeatedXCUIScreen.main.screenshot()frames.Collision to resolve before review: #2724 edits this same gap paragraph and inserts a section at the same point in ADR 0025. #2739 supersedes the fallback bullet's question, and whichever PR lands second rebases onto this measurement.
Method limits, and one incidental defect
agent-device foldwas tried first and is where the incidental defect below surfaced. ADR 0025'sDevice identityrow saidfoldselects by UDID and only then presses;DeviceHubPose.swift:201-204skips selection when the sidebar row is absent and the window title already matches, and:217-229confirms by title too, so that row is corrected in docs(adr): record which runner capture surface sees the lit foldable panel #2739.rot90outer panel and a quarter-turned foldable were not reachable: the pose presets areClosed/Book/Openand no available path rotates a foldable, so the rotation finding covers the inner panel only.adprobecontainers removed, the session closed, the daemon stopped with--clean, both simulators I booted shut down, and my Duo left at the default closed pose.iPhone Duo,foldaimed at one pressed the pose control of the other. It correctly refused (iPhone Duo did not reach the open pose: CoreDevice still reports a hinge angle of 0°) because the hinge read-back is the verdict — but the other simulator physically unfolded. The Device Hub window title (iPhone Duo – iOS 27.1) cannot distinguish twins, andpressed.selectedis only emitted as theapple_fold_pose_presseddiagnostic (packages/platform-apple/src/foldable/pose.ts:65,selectedat :72) while the refusal at:163keys on the hinge angle, so an unconfirmed selection still presses. Explicitly selecting the target's sidebar row before pressing routed every later pose correctly. fix(fold): distinguish an observed half-open angle from a settled pose #2730 turned out to be the settle rule rather than this, so the selection hazard is still open and is recorded as an ADR 0025 evidence gap by docs(adr): record which runner capture surface sees the lit foldable panel #2739.thymikee commentedon Sep 21, 2026
Erratum to my evidence comment above
A second review pass checked the comment against the retained artifacts instead of against my notes, and six statements in the first version did not survive that. The comment has been rewritten in place; this is the record of what changed.
Angle:130,0°, as did an earlier session I had also booked as open. The settled 180° session is a different one, and its values are now the primary column:simctl --display=LCD-1oracle luma 192.02,XCUIScreen.main0.00, resolved window 192.03 with 142 of 5,721,112 pixels differing fromscreens[1]at a maximum of one per channel,app.screenshot()0.00, element crop 193.41. The requested pose is not the pose; thehinge-angleread-back is. That is a method error, not a typo, and it is the same mistake ADR 0025 warns about forfold.2034x1398for theXCUIScreen.maincapture is not its PNG header size, which is1398x2034. The transposed figure came fromNSImage-based dimension reporting — the same tool whose dimensions this comment already distrusts for another reason.UIImageorientation case was named without evidence. OnlyUIImageOrientation(rawValue: 3)is in the probe log. The case name was wrong enough to be load-bearing: a 180° orientation would not transpose the display size, and the recorded size does transpose.RunnerTests+CommandExecution.swift:2068and:2072are themselvesXCUIScreen.main.screenshot()calls, both under#if os(macOS); the app-root capture is the:2070branch between them. The first version said those two "keep app-targeted capture", which would have sent fix(ios): use the resolved display for screenshots and visual verification #2728's implementer to the wrong branches.iPhone19,4is dropped. No retained artifact records a model identifier for the Duo simulator.Two smaller fixes rode along: the closed-pose element is the "Drag source" cell rather than a button, and the element-crop luma is 241.81 from the PNG rather than 241.82 from the probe's own resample.
One addition worth the reviewers' attention: rotating a runner capture is not enough to compare it with a
simctlcapture of the same panel. The runner's canvas is2006x2852andsimctl's is2853x2007, so they are a pixel apart on each axis as well as turned — #2729 owes a rotation and a crop.ADR 0025 carries the same corrections in #2739, now
45e29845bafter rebasing onto main; the two review-pass commits (8b3d56a0e,97fc04631) stay readable in the PR's timeline. The verdict this issue asked for is unchanged: the resolved app window is the capture surface that follows the fold, andXCUIScreen.mainandXCUIApplication.screenshot()are not — reproduced in all three inner-panel sessions.docs(adr): record which runner capture surface sees the lit foldable …
docs(adr): record which runner capture surface sees the lit foldable …