Skip to content

Commit 3f23ac6

Browse files
Lykhoydaclaude
andauthored
fix(device): hard-fail device_screenshot on explicit-platform raw failure (closes #136 sub-issue 1) (#174)
* chore: gitignore codex-pair per-developer artifacts codex-pair (ask-llm plugin) is opt-in via a .codex-pair-context.md marker in the project root. The marker enables the PostToolUse hook; its content is the per-file project context the reviewer uses. Marker is per-developer (own model/threshold preferences, own rules) so it does not belong in shared history. Same for the log, cache, and pause sentinel directories — all developer-local. See https://github.com/Lykhoyda/ask-llm marketplace plugin for details. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(device): hard-fail device_screenshot when platform: explicit and raw path fails Closes #136 (sub-issue 1 — re-evidence on #60). PR #142's "graceful degradation" — on raw screenshot failure, fall through to runAgentDevice — was the regression vector. agent-device's --platform routing is exactly the broken path PR #142 was created to avoid. The fallback re- introduced the bug whenever the raw path tripped on transient state (the user's session: OOM-unstable emulator → adb devices reports it as 'offline' → parseAdbDevicesEmu skips it → resolver returns null → fallback fires → iOS screen captured for a platform=android request). New behavior: when platform: is passed explicitly and the raw path fails, return a structured failResult with code=SCREENSHOT_FAILED and meta.reason=('no-device' | 'capture-failed') instead of trying agent-device. The error message names the underlying CLI (xcrun simctl / adb) and the sub-cause so the caller knows what to fix (boot a device vs retry a transient state). Implicit-platform calls (platformExplicit=false) still route through runAgentDevice — backward parity preserved by the existing 'platformExplicit=false → uses runAgentDevice (backward parity)' test. Changes: - src/tools/device-screenshot-raw.ts: tryRawScreenshot now returns a discriminated union {ok:true,path} | {ok:false,reason} so callers can distinguish resolver miss from capturer failure. - src/tools/device-list.ts: captureAndResizeScreenshot returns failResult on explicit-platform raw failure; imports failResult. - test/unit/gh-136-screenshot-raw-platform.test.js: inverted the existing 'falls through' test to assert hard-fail; added a second hard-fail test for the capture-failed surface; tightened the three pre-existing resolver/capturer null-checks to deepEqual on the new union shape. Verified: 1465/1465 cdp-bridge unit tests passing (+1 net new test). Also included: scripts/cdp-bridge/dist/tools/device-permission.js — dist was stale relative to source (PR #169's second escapeRegex site fix never made it to dist). Rebuilt locally; no source change. NOTE: codex-pair flagged an unrelated pre-existing issue in this file: deriveScreenshotPath throws PathTraversalScreenshotError on a '..' path, but no catch wraps the handler, so a bad path becomes an uncaught tool exception. Filed as #136-followup, NOT addressed here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6fa8860 commit 3f23ac6

7 files changed

Lines changed: 121 additions & 39 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,9 @@ scripts/rn-fast-runner/**/DerivedData/
2020

2121
# deepsec scanner workspace (local-only — config, data/<id>/INFO.md, scan output)
2222
.deepsec/
23+
24+
# codex-pair (per-developer opt-in; marker presence enables the hook)
25+
.codex-pair-context.md
26+
.codex-pair-log.jsonl
27+
.codex-pair-cache/
28+
.codex-pair-state/

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { runAgentDevice } from '../agent-device-wrapper.js';
2+
import { failResult } from '../utils.js';
23
import { resizeWithSips } from './device-screenshot-resize.js';
34
import { tryRawScreenshot } from './device-screenshot-raw.js';
45
import { pathHasTraversal } from '../domain/path-safety.js';
@@ -146,18 +147,29 @@ export async function captureAndResizeScreenshot(args) {
146147
// regardless of which dispatch tier (fast-runner / daemon / CLI) responded.
147148
const argsWithPath = { ...args, path: requestedPath };
148149
const advisories = computeScreenshotAdvisories(args, requestedPath);
149-
// GH #136 PR-A: explicit-platform raw path bypasses agent-device's --platform
150-
// routing when both iOS sim and Android emu are booted. Falls through to
151-
// runAgentDevice on any failure (resolution miss, command error) — graceful
152-
// degradation preserves single-device behavior identically.
150+
// GH #136 PR-B: when `platform:` is explicit, hard-fail instead of falling
151+
// through to runAgentDevice. The original PR-A "graceful degradation" was
152+
// backwards — if the caller explicitly asked for iOS or Android, silently
153+
// capturing the other platform via agent-device's broken `--platform`
154+
// routing defeats the entire purpose of passing the arg. Re-evidence on
155+
// the user-reported regression: an OOM-unstable emulator leaves
156+
// `adb devices` returning the emulator as `offline`, parseAdbDevicesEmu
157+
// skips it, the fallback fires, iOS screen is returned.
153158
let result;
154159
if (args.platformExplicit && (args.platform === 'ios' || args.platform === 'android')) {
155160
const raw = await tryRawScreenshot(args.platform, requestedPath);
156-
if (raw) {
161+
if (raw.ok) {
157162
result = {
158163
content: [{ type: 'text', text: JSON.stringify({ ok: true, data: { path: raw.path } }) }],
159164
};
160165
}
166+
else {
167+
const cli = args.platform === 'ios' ? 'xcrun simctl' : 'adb';
168+
const hint = raw.reason === 'no-device'
169+
? `No booted ${args.platform === 'ios' ? 'iOS Simulator' : 'Android emulator'} detected by ${cli}. Boot one and retry; if your emulator is in 'offline' or 'unauthorized' state, restart it.`
170+
: `Capture command failed (${cli}). The device may be transitioning state (booting, OOM, locked). Retry once it stabilizes.`;
171+
return failResult(`device_screenshot platform=${args.platform} failed: ${hint}`, 'SCREENSHOT_FAILED', { platform: args.platform, reason: raw.reason });
172+
}
161173
}
162174
if (!result) {
163175
result = await runAgentDeviceFn(buildScreenshotArgs(argsWithPath), { platform: args.platform ?? null });

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ async function androidQueryPermission(permission, appId) {
122122
if (stdout.includes('Unable to find package')) {
123123
return failResult(`Package "${appId}" not installed on device`);
124124
}
125-
const re = new RegExp(`${androidKey.replace(/\./g, '\\.')}:.*granted=(true|false)`, 'i');
125+
const re = new RegExp(`${escapeRegex(androidKey)}:.*granted=(true|false)`, 'i');
126126
const match = stdout.match(re);
127127
if (match) {
128128
return okResult({

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export async function tryRawScreenshot(platform, path) {
186186
const capturer = platform === 'ios' ? iosCapturer : androidCapturer;
187187
const id = await resolver();
188188
if (!id)
189-
return null;
189+
return { ok: false, reason: 'no-device' };
190190
const ok = await capturer(id, path);
191-
return ok ? { ok: true, path } : null;
191+
return ok ? { ok: true, path } : { ok: false, reason: 'capture-failed' };
192192
}

scripts/cdp-bridge/src/tools/device-list.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CDPClient } from '../cdp-client.js';
22
import { runAgentDevice } from '../agent-device-wrapper.js';
3+
import { failResult } from '../utils.js';
34
import type { ToolResult } from '../utils.js';
45
import { resizeWithSips, type ResizeResult, type ResizeOpts } from './device-screenshot-resize.js';
56
import { tryRawScreenshot } from './device-screenshot-raw.js';
@@ -210,17 +211,31 @@ export async function captureAndResizeScreenshot(
210211
// regardless of which dispatch tier (fast-runner / daemon / CLI) responded.
211212
const argsWithPath = { ...args, path: requestedPath };
212213
const advisories = computeScreenshotAdvisories(args, requestedPath);
213-
// GH #136 PR-A: explicit-platform raw path bypasses agent-device's --platform
214-
// routing when both iOS sim and Android emu are booted. Falls through to
215-
// runAgentDevice on any failure (resolution miss, command error) — graceful
216-
// degradation preserves single-device behavior identically.
214+
// GH #136 PR-B: when `platform:` is explicit, hard-fail instead of falling
215+
// through to runAgentDevice. The original PR-A "graceful degradation" was
216+
// backwards — if the caller explicitly asked for iOS or Android, silently
217+
// capturing the other platform via agent-device's broken `--platform`
218+
// routing defeats the entire purpose of passing the arg. Re-evidence on
219+
// the user-reported regression: an OOM-unstable emulator leaves
220+
// `adb devices` returning the emulator as `offline`, parseAdbDevicesEmu
221+
// skips it, the fallback fires, iOS screen is returned.
217222
let result: ToolResult | undefined;
218223
if (args.platformExplicit && (args.platform === 'ios' || args.platform === 'android')) {
219224
const raw = await tryRawScreenshot(args.platform, requestedPath);
220-
if (raw) {
225+
if (raw.ok) {
221226
result = {
222227
content: [{ type: 'text' as const, text: JSON.stringify({ ok: true, data: { path: raw.path } }) }],
223228
};
229+
} else {
230+
const cli = args.platform === 'ios' ? 'xcrun simctl' : 'adb';
231+
const hint = raw.reason === 'no-device'
232+
? `No booted ${args.platform === 'ios' ? 'iOS Simulator' : 'Android emulator'} detected by ${cli}. Boot one and retry; if your emulator is in 'offline' or 'unauthorized' state, restart it.`
233+
: `Capture command failed (${cli}). The device may be transitioning state (booting, OOM, locked). Retry once it stabilizes.`;
234+
return failResult(
235+
`device_screenshot platform=${args.platform} failed: ${hint}`,
236+
'SCREENSHOT_FAILED',
237+
{ platform: args.platform, reason: raw.reason },
238+
);
224239
}
225240
}
226241
if (!result) {

scripts/cdp-bridge/src/tools/device-screenshot-raw.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,19 +201,20 @@ export function _resetForTest(): void {
201201
androidCapturer = defaultAndroidCapturer;
202202
}
203203

204-
export interface RawScreenshotResult {
205-
ok: true;
206-
path: string;
207-
}
204+
export type RawScreenshotFailureReason = 'no-device' | 'capture-failed';
205+
206+
export type RawScreenshotResult =
207+
| { ok: true; path: string }
208+
| { ok: false; reason: RawScreenshotFailureReason };
208209

209210
export async function tryRawScreenshot(
210211
platform: 'ios' | 'android',
211212
path: string,
212-
): Promise<RawScreenshotResult | null> {
213+
): Promise<RawScreenshotResult> {
213214
const resolver = platform === 'ios' ? iosResolver : androidResolver;
214215
const capturer = platform === 'ios' ? iosCapturer : androidCapturer;
215216
const id = await resolver();
216-
if (!id) return null;
217+
if (!id) return { ok: false, reason: 'no-device' };
217218
const ok = await capturer(id, path);
218-
return ok ? { ok: true, path } : null;
219+
return ok ? { ok: true, path } : { ok: false, reason: 'capture-failed' };
219220
}

scripts/cdp-bridge/test/unit/gh-136-screenshot-raw-platform.test.js

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
// directly to disambiguate when both an iOS sim and an Android emu are booted.
55
//
66
// Tests cover (1) pure parsers for `xcrun simctl list -j devices booted` JSON
7-
// and `adb devices` stdout, (2) the `tryRawScreenshot` orchestrator branches,
7+
// and `adb devices` stdout, (2) the `tryRawScreenshot` orchestrator branches
8+
// (now returning a discriminated union `{ok:true,path}` | `{ok:false,reason}`),
89
// and (3) the device-list `captureAndResizeScreenshot` plumbing — that the
9-
// raw path is taken iff `platformExplicit` is true, and falls through to
10-
// `runAgentDevice` on any failure (graceful degradation per spec).
10+
// raw path is taken iff `platformExplicit` is true, and **hard-fails with an
11+
// actionable SCREENSHOT_FAILED envelope** when raw fails (per PR-B; the
12+
// original PR-A graceful-fallback was the regression vector for #136).
13+
// Implicit-platform calls (platformExplicit=false) still route through
14+
// runAgentDevice — backward parity preserved.
1115
import { test } from 'node:test';
1216
import assert from 'node:assert/strict';
1317

@@ -122,7 +126,7 @@ test('tryRawScreenshot(ios): resolver returns UDID, capturer succeeds → envelo
122126
}
123127
});
124128

125-
test('tryRawScreenshot(ios): resolver returns null → returns null (no capture attempt)', async () => {
129+
test('tryRawScreenshot(ios): resolver returns null → ok:false with reason no-device (no capture attempt)', async () => {
126130
const mod = await import(RAW_MOD);
127131
const { tryRawScreenshot, _setForTest, _resetForTest } = mod;
128132
let capturerCalled = false;
@@ -132,14 +136,14 @@ test('tryRawScreenshot(ios): resolver returns null → returns null (no capture
132136
});
133137
try {
134138
const result = await tryRawScreenshot('ios', '/tmp/shot.jpg');
135-
assert.equal(result, null);
139+
assert.deepEqual(result, { ok: false, reason: 'no-device' });
136140
assert.equal(capturerCalled, false);
137141
} finally {
138142
_resetForTest();
139143
}
140144
});
141145

142-
test('tryRawScreenshot(ios): capturer fails → returns null', async () => {
146+
test('tryRawScreenshot(ios): capturer fails → ok:false with reason capture-failed', async () => {
143147
const mod = await import(RAW_MOD);
144148
const { tryRawScreenshot, _setForTest, _resetForTest } = mod;
145149
_setForTest({
@@ -148,7 +152,7 @@ test('tryRawScreenshot(ios): capturer fails → returns null', async () => {
148152
});
149153
try {
150154
const result = await tryRawScreenshot('ios', '/tmp/shot.jpg');
151-
assert.equal(result, null);
155+
assert.deepEqual(result, { ok: false, reason: 'capture-failed' });
152156
} finally {
153157
_resetForTest();
154158
}
@@ -171,7 +175,7 @@ test('tryRawScreenshot(android): resolver returns emu-id, capturer succeeds →
171175
}
172176
});
173177

174-
test('tryRawScreenshot(android): capturer fails → returns null (mirrors iOS test for symmetry)', async () => {
178+
test('tryRawScreenshot(android): capturer fails → ok:false capture-failed (mirrors iOS for symmetry)', async () => {
175179
const mod = await import(RAW_MOD);
176180
const { tryRawScreenshot, _setForTest, _resetForTest } = mod;
177181
_setForTest({
@@ -180,7 +184,7 @@ test('tryRawScreenshot(android): capturer fails → returns null (mirrors iOS te
180184
});
181185
try {
182186
const result = await tryRawScreenshot('android', '/tmp/shot.png');
183-
assert.equal(result, null);
187+
assert.deepEqual(result, { ok: false, reason: 'capture-failed' });
184188
} finally {
185189
_resetForTest();
186190
}
@@ -254,31 +258,75 @@ test('captureAndResizeScreenshot: platformExplicit + android resolved → raw pa
254258
}
255259
});
256260

257-
test('captureAndResizeScreenshot: platformExplicit + resolver fails → falls through to runAgentDevice', async () => {
261+
test('captureAndResizeScreenshot: platformExplicit + resolver miss → hard-fails (does NOT fall through to runAgentDevice)', async () => {
262+
// GH #136 PR-B: the original PR-A behavior was to fall through to
263+
// runAgentDevice on resolver miss. That was the regression vector — the
264+
// fallback re-introduced the broken `--platform` routing. With an explicit
265+
// platform, we must hard-fail with an actionable message instead.
258266
const raw = await import(RAW_MOD);
259267
const dl = await import(DEVICE_LIST_MOD);
260268
const { captureAndResizeScreenshot, _setRunAgentDeviceForTest, _resetRunAgentDeviceForTest } = dl;
261269
let runAgentDeviceCalled = false;
262-
_setRunAgentDeviceForTest(async (args, opts) => {
270+
_setRunAgentDeviceForTest(async () => {
263271
runAgentDeviceCalled = true;
264-
// Mimic agent-device success envelope shape.
265-
return {
266-
content: [{ type: 'text', text: JSON.stringify({ ok: true, data: { path: '/tmp/fallback.jpg' } }) }],
267-
};
272+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, data: { path: '/tmp/wrong.jpg' } }) }] };
268273
});
269274
raw._setForTest({
270-
iosResolver: async () => null, // resolver fails — should fall through
275+
androidResolver: async () => null, // no booted Android emulator
276+
});
277+
try {
278+
const result = await captureAndResizeScreenshot({
279+
platform: 'android',
280+
platformExplicit: true,
281+
path: '/tmp/shot.png',
282+
maxWidth: 0,
283+
});
284+
assert.equal(runAgentDeviceCalled, false, 'runAgentDevice MUST NOT be the fallback when platform is explicit');
285+
assert.equal(result.isError, true);
286+
const envelope = JSON.parse(result.content[0].text);
287+
assert.equal(envelope.ok, false);
288+
assert.equal(envelope.code, 'SCREENSHOT_FAILED');
289+
assert.equal(envelope.meta.platform, 'android');
290+
assert.equal(envelope.meta.reason, 'no-device');
291+
// Error message names the platform and the underlying CLI so users know what to fix.
292+
assert.match(envelope.error, /platform=android/);
293+
assert.match(envelope.error, /adb/);
294+
} finally {
295+
_resetRunAgentDeviceForTest();
296+
raw._resetForTest();
297+
}
298+
});
299+
300+
test('captureAndResizeScreenshot: platformExplicit + capture fails → hard-fails with capture-failed reason', async () => {
301+
// Distinct from the resolver-miss case: device IS detected but the
302+
// capture command itself failed (transient adb error, simctl crash,
303+
// disk full, etc.). User-facing hint differs from "no device booted".
304+
const raw = await import(RAW_MOD);
305+
const dl = await import(DEVICE_LIST_MOD);
306+
const { captureAndResizeScreenshot, _setRunAgentDeviceForTest, _resetRunAgentDeviceForTest } = dl;
307+
let runAgentDeviceCalled = false;
308+
_setRunAgentDeviceForTest(async () => {
309+
runAgentDeviceCalled = true;
310+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, data: { path: '/tmp/wrong.jpg' } }) }] };
311+
});
312+
raw._setForTest({
313+
iosResolver: async () => 'UDID-IOS',
314+
iosCapturer: async () => false,
271315
});
272316
try {
273317
const result = await captureAndResizeScreenshot({
274318
platform: 'ios',
275319
platformExplicit: true,
276-
path: '/tmp/fallback.jpg',
320+
path: '/tmp/shot.jpg',
277321
maxWidth: 0,
278322
});
279-
assert.equal(runAgentDeviceCalled, true, 'runAgentDevice should be the fallback');
323+
assert.equal(runAgentDeviceCalled, false);
324+
assert.equal(result.isError, true);
280325
const envelope = JSON.parse(result.content[0].text);
281-
assert.equal(envelope.ok, true);
326+
assert.equal(envelope.ok, false);
327+
assert.equal(envelope.code, 'SCREENSHOT_FAILED');
328+
assert.equal(envelope.meta.reason, 'capture-failed');
329+
assert.match(envelope.error, /xcrun simctl/);
282330
} finally {
283331
_resetRunAgentDeviceForTest();
284332
raw._resetForTest();

0 commit comments

Comments
 (0)