Skip to content

Commit c15bc52

Browse files
Lykhoydaclaude
andauthored
fix: iOS bug batch — screenshot path honored (#422), run-action fallback resilience (#423), cold build persists .xctestrun (#424) (#427)
* fix(rn-fast-runner): cold start persists a reusable .xctestrun (#424) xcodebuild's bare 'test' action never writes a .xctestrun to Build/Products, so a self-built runner stayed permanently 'not prebuilt' — every runner death paid another multi-minute cold build. The cold path is now two-phase: build-for-testing (awaited to exit, 360s budget, persists the artifact with the same shape as the documented manual prebuild) followed by the standard test-without-building launch (30s ready window). resolveRunnerXcodebuildArgs is replaced by resolveRunnerStartPlan, which encodes the ordered invocations; the gh-424 unit test supersedes rn-fast-runner-build-args.test.js whose cold assertion pinned the buggy bare-test behavior. The #418 rebuild tier funnels through the same path and is fixed by the same change. Closes #424 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(device_screenshot): iOS pixels route to simctl — caller path honored (#422) The rn-fast-runner screenshot verb writes into its own sandbox tmp/ and returns a relative path; the caller's path is structurally droppable at three layers (argv translation, RunIOSArgs wire shape, Swift handler), so with a device session open the observe UI got a dimensionless unservable asset and sips resize read the wrong file (reason: no-dimensions). iOS now routes to the already-path-honoring raw simctl capture even when the platform is inferred — simctl was already the flow-active and runner-down backend, this makes it the sole iOS pixel path per the D1249 'pixels → simctl' doctrine. Android keeps its runner path (outPath honored host-side via base64). Defense-in-depth: the observe recorder now rejects relative screenshot paths instead of resolving them against the bridge cwd (silent stat-miss blanked the panel; a cwd file sharing the name would have been misread). Per-edit review hardening: flow-lease acquisition asserted in gh-210 tests; stale GH #136/A2 routing comments updated to the new contract. Closes #422 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cdp_run_action): CDP/JS fallback probe retries + surfaced skip reasons (#423) Field root-cause chain (differs from the issue's framing): the park that the issue presumed missing exists and works — cdp_run_action shares createMaestroRunHandler → runFlowParked → stopFastRunner with maestro_run, and a live experiment showed SIGTERM to the xcodebuild host tears down the sim-side XCUITest runner in ~400ms. The actual dead end: WDA died at launch (iOS 26.x, #317 family), the CDP/JS replay fallback was eligible (UNKNOWN is in its trigger) but its single tree probe ran while CDP was mid-reconnect after the flow's app relaunch, catch(() => null) swallowed it, and the fallback silently skipped — surfacing an unexplained UNKNOWN after ~50s. - probeTreeWithRetry: bounded retry (default 3×1.5s, clamped) until the probe testID is PRESENT — tolerates both a reconnecting CDP and a still-mounting app (per-edit review upgrade over tree-readable-only). - Every skip is typed in meta.cdpJsFallback { attempted:false, reason: no-replay-deps | no-probe-testid | cdp-unreachable | testid-not-in-tree }. - cdp-unreachable appends actionable guidance (cdp_status, reconnect, stop foreign XCUITest automation). GH #422 hardening bundled from per-edit review (raw is now the primary iOS pixel path, so its resolution rules tightened): simctl parsers only count iOS runtimes (booted paired watch can't poison the pick), raw captures bind to the open session's UDID, no-session resolution refuses on multi-sim ambiguity instead of first-pick, capturer exceptions honor the RawScreenshotResult contract, and the observe live panel binds to the session device too. parseSimctlBootedUDID removed (single-pick semantics were the hazard); parseSimctlBootedAll is the sole parser. Closes #423 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: oxfmt reflow of gh-422/423/424 files (pre-push format gate) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1bd2a38 commit c15bc52

22 files changed

Lines changed: 847 additions & 220 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"rn-dev-agent-cdp": patch
3+
"rn-dev-agent-plugin": patch
4+
---
5+
6+
iOS `device_screenshot` honors the caller's `path` (#422): iOS pixels now route
7+
to `xcrun simctl io screenshot` even with an rn-fast-runner session open — the
8+
runner's screenshot verb writes inside its own sandbox and returns a relative
9+
`tmp/…` path the host can never serve, which blanked the observe UI panel and
10+
broke `sips` resizing (`meta.resize.reason: no-dimensions`). simctl was already
11+
the flow-active and runner-down backend; it is now the sole iOS pixel path
12+
("pixels → simctl", D1249). Android is unchanged (its runner honors `outPath`
13+
host-side). Defense-in-depth: the observe recorder rejects relative screenshot
14+
paths instead of resolving them against the bridge cwd.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"rn-dev-agent-cdp": patch
3+
"rn-dev-agent-plugin": patch
4+
---
5+
6+
`cdp_run_action` no longer dead-ends in an opaque UNKNOWN when WDA dies at
7+
launch (#423). Root cause chain from the field failure: the #317 CDP/JS replay
8+
fallback covers this exact case, but its single tree probe ran while CDP was
9+
mid-reconnect (the failed flow had just relaunched the app), was silently
10+
swallowed, and the fallback never engaged. The probe now retries (bounded,
11+
default 3×1.5s) until the probe testID is actually present — tolerating both a
12+
reconnecting CDP and a still-mounting app — and every skip is surfaced as
13+
`meta.cdpJsFallback: { attempted: false, reason }`
14+
(`no-replay-deps | no-probe-testid | cdp-unreachable | testid-not-in-tree`).
15+
A `cdp-unreachable` skip appends actionable guidance (check `cdp_status`,
16+
reconnect, stop foreign XCUITest automation) instead of a bare
17+
"failure not auto-repairable". Also (#422 hardening): the simctl UDID parsers
18+
now only consider iOS runtimes (a booted paired watchOS/tvOS simulator can
19+
neither win the screenshot UDID pick nor make the single iPhone look ambiguous
20+
to `resolveIosUdid`), and raw captures bind to the open device session's UDID
21+
when platforms match instead of picking the first booted device.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"rn-dev-agent-cdp": patch
3+
"rn-dev-agent-plugin": patch
4+
---
5+
6+
iOS cold start persists a reusable `.xctestrun` (#424): `startFastRunner()` now
7+
runs `xcodebuild build-for-testing` first when no test product exists and then
8+
launches via the same `test-without-building` path as every warm start, instead
9+
of a single bare `xcodebuild test` — which never writes a `.xctestrun`, so
10+
self-built runners were permanently "not prebuilt" and every runner death cost
11+
another multi-minute cold build. The build phase keeps the 360s cold timeout;
12+
the launch phase uses the standard 30s ready window. The #418 stale-artifact
13+
rebuild tier funnels through the same path, so it also leaves a reusable
14+
artifact now.

scripts/cdp-bridge/dist/index.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,12 @@ const liveDeps = buildLiveDeps({
203203
isFlowActive: () => arbiter.flowActive || foreignFlowGate.lastActive,
204204
getActiveSession,
205205
getClient: () => getClient(),
206-
captureScreenshot: (platform, path) => tryRawScreenshot(platform, path),
206+
captureScreenshot: (platform, path) => {
207+
// GH #422: bind the live panel to the session device too — raw resolution
208+
// refuses on multi-sim ambiguity instead of first-pick now.
209+
const session = getActiveSession();
210+
return tryRawScreenshot(platform, path, session && session.platform === platform ? session.deviceId : undefined);
211+
},
207212
readRoute: (c) => readLiveRoute(c),
208213
readShotFile: (path) => {
209214
try {

scripts/cdp-bridge/dist/observability/recorder.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { readFileSync, statSync } from 'node:fs';
2+
import { isAbsolute } from 'node:path';
23
import { RingBuffer } from '../ring-buffer.js';
34
import { mapObservation, unwrapResult } from './events.js';
45
const DEFAULT_CAP = 500;
56
const MAX_SHOT_BYTES = 4_000_000;
7+
// GH #422: absolute paths only — a runner-internal relative path (e.g. iOS
8+
// "tmp/…") would resolve against the bridge cwd, silently blanking the panel
9+
// or reading an unrelated file that shares the name.
610
function screenshotPath(result) {
711
const data = (unwrapResult(result)?.data ?? result?.data);
812
const p = data?.path ?? data?.message;
9-
return typeof p === 'string' && (p.endsWith('.jpg') || p.endsWith('.jpeg') || p.endsWith('.png'))
13+
return typeof p === 'string' &&
14+
isAbsolute(p) &&
15+
(p.endsWith('.jpg') || p.endsWith('.jpeg') || p.endsWith('.png'))
1016
? p
1117
: null;
1218
}

scripts/cdp-bridge/dist/runners/rn-fast-runner-client.js

Lines changed: 73 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -203,18 +203,20 @@ export function isFastRunnerAvailable() {
203203
return false;
204204
}
205205
/**
206-
* Decide which xcodebuild invocation launches the runner.
206+
* Decide the ordered xcodebuild invocations that start the runner.
207207
*
208-
* `test-without-building` is the fast steady-state path, but it requires a prior
209-
* `build-for-testing` to have produced a .xctestrun. On a fresh machine that
210-
* artifact is absent (build/ is gitignored), so we fall back to a full `test`,
211-
* which compiles the project first and then runs it — making the runner
212-
* self-install on first use (D1219 follow-up).
208+
* `test-without-building` is the fast steady-state launch, but it requires a
209+
* prior `build-for-testing` to have produced a .xctestrun. On a fresh machine
210+
* that artifact is absent (build/ is gitignored), so the cold path builds it
211+
* first and then launches warm — making the runner self-install on first use
212+
* (D1219 follow-up). GH #424: a bare `xcodebuild test` never writes a
213+
* .xctestrun, so the previous single-invocation cold path left the runner
214+
* permanently "not prebuilt" and every runner death cost another multi-minute
215+
* cold build. The build step carries no -only-testing so it produces the same
216+
* artifact as the documented manual prebuild.
213217
*/
214-
export function resolveRunnerXcodebuildArgs(opts) {
215-
const action = opts.hasBuiltTestProduct ? 'test-without-building' : 'test';
216-
return [
217-
action,
218+
export function resolveRunnerStartPlan(opts) {
219+
const common = [
218220
'-project',
219221
opts.projectPath,
220222
'-scheme',
@@ -223,8 +225,14 @@ export function resolveRunnerXcodebuildArgs(opts) {
223225
`platform=iOS Simulator,id=${opts.deviceId}`,
224226
'-derivedDataPath',
225227
opts.derivedDataPath,
226-
`-only-testing:${opts.onlyTesting}`,
227228
];
229+
const launch = {
230+
action: 'test-without-building',
231+
args: ['test-without-building', ...common, `-only-testing:${opts.onlyTesting}`],
232+
};
233+
if (opts.hasBuiltTestProduct)
234+
return [launch];
235+
return [{ action: 'build-for-testing', args: ['build-for-testing', ...common] }, launch];
228236
}
229237
/** True when a prior build-for-testing left a .xctestrun under DerivedData. */
230238
export function hasBuiltTestProduct(derivedDataPath) {
@@ -325,28 +333,64 @@ export function buildRunnerVersionEnv(pluginVersion) {
325333
TEST_RUNNER_RN_PLUGIN_VERSION: pluginVersion,
326334
};
327335
}
336+
/**
337+
* GH #424: run a build-phase xcodebuild (build-for-testing) to completion.
338+
* Unlike the launch invocation it exits on its own and emits no READY marker,
339+
* so success is exit code 0 — the .xctestrun it writes is what makes every
340+
* later start warm.
341+
*/
342+
function runXcodebuildToExit(args, timeoutMs) {
343+
return new Promise((resolve, reject) => {
344+
const child = spawn('xcodebuild', args, { stdio: ['ignore', 'ignore', 'pipe'] });
345+
let stderrTail = '';
346+
const timer = setTimeout(() => {
347+
child.kill('SIGTERM');
348+
reject(new Error(`xcodebuild ${args[0]} did not complete within ${timeoutMs / 1000}s (cold build — first run compiles the runner)`));
349+
}, timeoutMs);
350+
child.stderr.setEncoding('utf-8');
351+
child.stderr.on('data', (chunk) => {
352+
stderrTail = (stderrTail + chunk).slice(-2000);
353+
});
354+
child.on('error', (err) => {
355+
clearTimeout(timer);
356+
reject(new Error(`Failed to spawn xcodebuild: ${err.message}`));
357+
});
358+
child.on('exit', (code) => {
359+
clearTimeout(timer);
360+
if (code === 0)
361+
resolve();
362+
else
363+
reject(new Error(`xcodebuild ${args[0]} failed (code ${code})${stderrTail ? `: ${stderrTail.trim()}` : ''}`));
364+
});
365+
});
366+
}
328367
export async function startFastRunner(deviceId, bundleId, port) {
329368
adoptPersistedFastRunnerState(deviceId);
330369
if (shouldReuseRunner(runnerState, deviceId))
331370
return runnerState;
332371
const desired = port ?? ((await isPortFree(DEFAULT_PORT)) ? DEFAULT_PORT : 0);
333-
return new Promise((resolve, reject) => {
334-
const projectPath = join(FAST_RUNNER_PROJECT, 'RnFastRunner', 'RnFastRunner.xcodeproj');
335-
if (!existsSync(projectPath)) {
336-
reject(new Error(`RnFastRunner.xcodeproj not found at ${projectPath}.`));
337-
return;
372+
const projectPath = join(FAST_RUNNER_PROJECT, 'RnFastRunner', 'RnFastRunner.xcodeproj');
373+
if (!existsSync(projectPath)) {
374+
throw new Error(`RnFastRunner.xcodeproj not found at ${projectPath}.`);
375+
}
376+
const derivedDataPath = derivedDataPathForRunner();
377+
const plan = resolveRunnerStartPlan({
378+
projectPath,
379+
scheme: 'RnFastRunner',
380+
deviceId,
381+
derivedDataPath,
382+
onlyTesting: 'RnFastRunnerUITests/RnFastRunnerTests/testCommand',
383+
hasBuiltTestProduct: hasBuiltTestProduct(derivedDataPath),
384+
});
385+
for (const step of plan.slice(0, -1)) {
386+
await runXcodebuildToExit(step.args, BUILD_READY_TIMEOUT_MS);
387+
if (!hasBuiltTestProduct(derivedDataPath)) {
388+
throw new Error(`xcodebuild ${step.action} completed but left no .xctestrun under ${derivedDataPath}/Build/Products — unexpected DerivedData layout`);
338389
}
339-
const derivedDataPath = derivedDataPathForRunner();
340-
const built = hasBuiltTestProduct(derivedDataPath);
341-
const args = resolveRunnerXcodebuildArgs({
342-
projectPath,
343-
scheme: 'RnFastRunner',
344-
deviceId,
345-
derivedDataPath,
346-
onlyTesting: 'RnFastRunnerUITests/RnFastRunnerTests/testCommand',
347-
hasBuiltTestProduct: built,
348-
});
349-
const child = spawn('xcodebuild', args, {
390+
}
391+
const launch = plan[plan.length - 1];
392+
return new Promise((resolve, reject) => {
393+
const child = spawn('xcodebuild', launch.args, {
350394
env: {
351395
...process.env,
352396
RN_FAST_RUNNER_PORT: String(desired),
@@ -358,12 +402,10 @@ export async function startFastRunner(deviceId, bundleId, port) {
358402
runnerProcess = child;
359403
const parser = createReadySignalParser();
360404
let resolved = false;
361-
const readyTimeoutMs = built ? READY_TIMEOUT_MS : BUILD_READY_TIMEOUT_MS;
362405
const timer = setTimeout(() => {
363406
child.kill('SIGTERM');
364-
const phase = built ? '' : ' (cold build — first run compiles the runner)';
365-
reject(new Error(`Fast runner did not become ready within ${readyTimeoutMs / 1000}s${phase}`));
366-
}, readyTimeoutMs);
407+
reject(new Error(`Fast runner did not become ready within ${READY_TIMEOUT_MS / 1000}s`));
408+
}, READY_TIMEOUT_MS);
367409
const handleChunk = (chunk) => {
368410
if (resolved)
369411
return;

scripts/cdp-bridge/dist/tools/device-list.js

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,11 +237,17 @@ export function wrapResultWithAdvisories(result, advisories) {
237237
* - flow active + platform known → 'simctl' (OS-level, flow-safe).
238238
* - flow active + platform unknown → 'fail' (NEVER the runner — would hit XCUITest
239239
* unleased and crash the flow, A3).
240-
* - no flow → 'runner' (rn-fast-runner primary; its own simctl fallback fires on error).
240+
* - no flow, iOS → 'simctl' (GH #422: the rn-fast-runner screenshot verb writes
241+
* inside its own sandbox and returns a relative tmp/ path — the caller's `path`
242+
* cannot reach it over the wire, so simctl is the only iOS backend that can
243+
* honor it; "pixels → simctl" per D1249).
244+
* - no flow, otherwise → 'runner' (Android's runner honors outPath host-side).
241245
*/
242246
export function chooseScreenshotPath(input) {
243247
if (input.flowActive)
244248
return input.platform ? 'simctl' : 'fail';
249+
if (input.platform === 'ios')
250+
return 'simctl';
245251
return 'runner';
246252
}
247253
/**
@@ -287,7 +293,7 @@ export async function captureAndResizeScreenshot(args) {
287293
const rawResultFail = (platform, reason) => {
288294
const cli = platform === 'ios' ? 'xcrun simctl' : 'adb';
289295
const hint = reason === 'no-device'
290-
? `No booted ${platform === 'ios' ? 'iOS Simulator' : 'Android emulator'} detected by ${cli}. Boot one and retry; if your emulator is in 'offline' or 'unauthorized' state, restart it.`
296+
? `No booted ${platform === 'ios' ? 'iOS Simulator' : 'Android emulator'} was unambiguously resolvable by ${cli} — none booted, or several booted with no open device session. Boot exactly one, or open a session (device_snapshot action=open) to bind the target; if your emulator is 'offline'/'unauthorized', restart it.`
291297
: `Capture command failed (${cli}). The device may be transitioning state (booting, OOM, locked). Retry once it stabilizes.`;
292298
return failResult(`device_screenshot platform=${platform} failed: ${hint}`, 'SCREENSHOT_FAILED', { platform, reason });
293299
};
@@ -306,18 +312,24 @@ export async function captureAndResizeScreenshot(args) {
306312
}
307313
// simctl path: a flow owns the device (raw-ONLY — never fall through to the runner, A3),
308314
// OR the existing GH#136 explicit-platform disambiguation (no flow). Both hard-fail on error.
315+
// GH #422: bind raw captures to the open session's device when platforms
316+
// match — raw is now the primary iOS path and must not pick "first booted"
317+
// over the session device on multi-sim setups.
318+
const session = getActiveSession();
319+
const sessionDeviceId = session && session.platform === args.platform ? session.deviceId : undefined;
309320
if ((route === 'simctl' || args.platformExplicit) &&
310321
(args.platform === 'ios' || args.platform === 'android')) {
311-
const raw = await tryRawScreenshot(args.platform, requestedPath);
322+
const raw = await tryRawScreenshot(args.platform, requestedPath, sessionDeviceId);
312323
if (raw.ok)
313324
result = rawResultOk(raw.path, args.platform);
314325
else
315326
return rawResultFail(args.platform, raw.reason);
316327
}
317328
if (!result) {
318329
// route === 'runner' (NO flow — runAgentDevice can never run while a flow is active here).
319-
// A2: runIOS()/postCommand THROW when the runner is down, so the bare isError check is dead
320-
// code for that path; catch it, then fall back to simctl so iOS never hard-fails.
330+
// Since GH #422 a known-iOS platform never reaches this branch (routed to simctl above);
331+
// it serves Android and the platform-unresolved case. A2: the runner client THROWS when
332+
// down, so catch it, then fall back to raw capture when the platform is known.
321333
try {
322334
result = await runAgentDeviceFn(buildScreenshotArgs(argsWithPath), {
323335
platform: args.platform ?? null,
@@ -327,7 +339,7 @@ export async function captureAndResizeScreenshot(args) {
327339
result = failResult(err instanceof Error ? err.message : String(err), 'SCREENSHOT_FAILED');
328340
}
329341
if (result.isError && (args.platform === 'ios' || args.platform === 'android')) {
330-
const raw = await tryRawScreenshot(args.platform, requestedPath);
342+
const raw = await tryRawScreenshot(args.platform, requestedPath, sessionDeviceId);
331343
if (raw.ok)
332344
result = rawResultOk(raw.path, args.platform);
333345
}

scripts/cdp-bridge/dist/tools/device-screenshot-raw.js

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
* `--platform` routing issue when both an iOS sim and an Android emu are
99
* booted simultaneously (the field-reported bug from issue #136 #1).
1010
*
11-
* On any failure (resolution fails, command errors), the caller falls through
12-
* to the existing `runAgentDevice` path — no behavior change for users who
13-
* don't pass `platform` or who run a single device.
11+
* Failure behavior (updated by GH #136 PR-B): callers on the explicit-platform
12+
* or raw-only routes HARD-FAIL with an actionable SCREENSHOT_FAILED — falling
13+
* through to another backend was the wrong-platform regression vector. Only
14+
* the Android runner-error path still falls back here (see device-list.ts).
15+
* Since GH #422 this raw path is also the primary iOS pixel backend.
1416
*
1517
* Test seams (`_setForTest`, `_resetForTest`) follow the GH #136 picker
1618
* precedent — allow unit tests to inject resolver/capturer fakes without
@@ -20,31 +22,9 @@ import { execFile, spawn } from 'node:child_process';
2022
import { createWriteStream, unlinkSync } from 'node:fs';
2123
import { promisify } from 'node:util';
2224
const execFileAsync = promisify(execFile);
23-
export function parseSimctlBootedUDID(jsonText) {
24-
let data;
25-
try {
26-
data = JSON.parse(jsonText);
27-
}
28-
catch {
29-
return null;
30-
}
31-
const runtimes = data?.devices;
32-
if (!runtimes || typeof runtimes !== 'object')
33-
return null;
34-
for (const list of Object.values(runtimes)) {
35-
if (!Array.isArray(list))
36-
continue;
37-
for (const device of list) {
38-
if (device &&
39-
device.state === 'Booted' &&
40-
typeof device.udid === 'string' &&
41-
device.udid.length > 0) {
42-
return device.udid;
43-
}
44-
}
45-
}
46-
return null;
47-
}
25+
// GH #422: the single-pick parseSimctlBootedUDID was removed — first-booted
26+
// selection was a silent wrong-device capture once raw became the primary iOS
27+
// path. All resolution goes through parseSimctlBootedAll + exactly-one.
4828
export function parseSimctlBootedAll(jsonText) {
4929
let data;
5030
try {
@@ -57,7 +37,12 @@ export function parseSimctlBootedAll(jsonText) {
5737
if (!runtimes || typeof runtimes !== 'object')
5838
return [];
5939
const udids = [];
60-
for (const list of Object.values(runtimes)) {
40+
for (const [runtime, list] of Object.entries(runtimes)) {
41+
// GH #422 hardening: a booted paired watchOS/tvOS sim must not make the
42+
// single iOS sim look ambiguous to resolveIosUdid, nor be counted as an
43+
// iOS device.
44+
if (!runtime.includes('SimRuntime.iOS'))
45+
continue;
6146
if (!Array.isArray(list))
6247
continue;
6348
for (const device of list) {
@@ -110,7 +95,11 @@ const defaultIosResolver = async () => {
11095
timeout: 5000,
11196
maxBuffer: 1024 * 1024,
11297
});
113-
return parseSimctlBootedUDID(stdout);
98+
// GH #422: raw is the primary iOS pixel path now, so a no-session resolve
99+
// must be unambiguous — with several booted iOS sims, first-pick was a
100+
// silent wrong-device capture. Callers with a session pass its UDID.
101+
const all = parseSimctlBootedAll(stdout);
102+
return all.length === 1 ? all[0] : null;
114103
}
115104
catch {
116105
return null;
@@ -249,12 +238,22 @@ export function _resetForTest() {
249238
iosCapturer = defaultIosCapturer;
250239
androidCapturer = defaultAndroidCapturer;
251240
}
252-
export async function tryRawScreenshot(platform, path) {
241+
export async function tryRawScreenshot(platform, path, preferredDeviceId) {
253242
const resolver = platform === 'ios' ? iosResolver : androidResolver;
254243
const capturer = platform === 'ios' ? iosCapturer : androidCapturer;
255-
const id = await resolver();
244+
// GH #422: raw is now the PRIMARY iOS pixel path, so it must inherit the
245+
// session's device binding — with two booted sims, "first booted" could
246+
// capture the wrong one. No session → single-booted resolution as before.
247+
const id = preferredDeviceId ?? (await resolver());
256248
if (!id)
257249
return { ok: false, reason: 'no-device' };
258-
const ok = await capturer(id, path);
259-
return ok ? { ok: true, path } : { ok: false, reason: 'capture-failed' };
250+
try {
251+
const ok = await capturer(id, path);
252+
return ok ? { ok: true, path } : { ok: false, reason: 'capture-failed' };
253+
}
254+
catch {
255+
// Raw is the primary iOS path since GH #422 — a thrown capturer error
256+
// (fs validation, spawn failure) must honor the result contract.
257+
return { ok: false, reason: 'capture-failed' };
258+
}
260259
}

0 commit comments

Comments
 (0)