Skip to content

Commit 38e7c9d

Browse files
authored
Merge pull request #152 from Lykhoyda/fix/issue-110-test-seam-fuse-hardening
fix(gh-110): one-way fuse for _setRunAgentDeviceForTest test seam
2 parents aab5b4d + 5f01efe commit 38e7c9d

7 files changed

Lines changed: 219 additions & 3 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.44.38",
12+
"version": "0.44.39",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.44.38",
3+
"version": "0.44.39",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ All notable changes to rn-dev-agent will be documented in this file.
44

55
Format follows [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.44.39] — 2026-05-13
8+
9+
### Hardened (GH #110 — agent-device test seam fuse)
10+
11+
- **`_setRunAgentDeviceForTest` is now one-way fused.** Once any production
12+
`runAgentDevice` call has dispatched in this process, attempting to install
13+
a new override throws with `blown fuse — a production runAgentDevice call
14+
(cliArgs[0]="...") already dispatched in this process. ... (GH #110
15+
hardening)`. The fuse fires BEFORE any tier selection (Codex review
16+
conf 90), so a production call that throws downstream still seals the
17+
seam.
18+
- **No reset escape hatch.** A reset seam would be functionally equivalent
19+
to no fuse — any code that could call reset is the same code that could
20+
leak the override (Codex review conf 90). Tests that genuinely need both
21+
production and override paths should use Node 22's
22+
`node --test --test-isolation=process` to get a fresh worker per file.
23+
- **Throw, not no-op** (Codex review conf 95). Silent no-op would let a
24+
forgotten `afterEach(() => _setRunAgentDeviceForTest(null))` route a
25+
test through the real `agent-device` CLI, producing `ENOENT` errors that
26+
look nothing like a test-seam bug. The fuse error message includes the
27+
`cliArgs[0]` that blew it, so post-mortem debugging can identify which
28+
production call leaked first — that's the test missing its cleanup.
29+
- 5 new subprocess-isolated regression tests cover: override is honored
30+
pre-fuse; setting null pre-fuse re-arms cleanly; production dispatch
31+
blows the fuse before tier completion; error message carries GH #110 +
32+
remediation hint; standard afterEach `null` cleanup remains legal.
33+
Suite: 1312 → 1317 passing.
34+
735
## [0.44.38] — 2026-05-13
836

937
### Added (GH #106 — flow + skeleton bundling in experience export/import)

scripts/cdp-bridge/dist/agent-device-wrapper.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,13 +435,40 @@ function cacheRefMapFromResult(result) {
435435
catch { /* not a snapshot response — ignore */ }
436436
}
437437
let _runAgentDeviceOverrideForTest = null;
438+
// GH #110: test-seam fuse. Once any production runAgentDevice call has
439+
// dispatched in this process, refuse to install a new override —
440+
// makes test-pollution-into-prod impossible by construction. The fuse
441+
// is intentionally one-way: tests that need both production and override
442+
// paths in the same suite should use `node --test --test-isolation=process`
443+
// (Node 22 LTS supports this) to get a fresh worker per file. A reset
444+
// seam here would defeat the entire guarantee — any code that could call
445+
// the reset is the same code that could leak the override (Codex review
446+
// conf 90).
447+
let _testSeamFused = false;
448+
let _testSeamFuseBlownBy = null;
438449
export function _setRunAgentDeviceForTest(fn) {
450+
if (_testSeamFused) {
451+
throw new Error(`_setRunAgentDeviceForTest: blown fuse — a production runAgentDevice ` +
452+
`call (cliArgs[0]=${JSON.stringify(_testSeamFuseBlownBy)}) already ` +
453+
`dispatched in this process. The test seam cannot be re-armed at runtime ` +
454+
`(GH #110 hardening). The most likely cause is a prior test forgot to ` +
455+
`clear its override in afterEach. Spawn a fresh Node process — e.g. ` +
456+
`\`node --test --test-isolation=process\` — if you genuinely need to ` +
457+
`mix production and override paths.`);
458+
}
439459
_runAgentDeviceOverrideForTest = fn;
440460
}
441461
export async function runAgentDevice(cliArgs, opts = {}) {
442462
if (_runAgentDeviceOverrideForTest) {
443463
return _runAgentDeviceOverrideForTest(cliArgs, opts);
444464
}
465+
// GH #110: production dispatch reached. Lock the fuse BEFORE any tier
466+
// selection so a production call that throws downstream still seals
467+
// the seam (Codex review conf 90).
468+
if (!_testSeamFused) {
469+
_testSeamFused = true;
470+
_testSeamFuseBlownBy = cliArgs[0] ?? '<empty>';
471+
}
445472
// GH #60: when an explicit platform is requested AND it doesn't match the
446473
// active session's platform (e.g. user asks for android while an iOS
447474
// session is active from prior work), skip the session-bound dispatch

scripts/cdp-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent-cdp",
3-
"version": "0.38.33",
3+
"version": "0.38.34",
44
"type": "module",
55
"main": "dist/index.js",
66
"scripts": {

scripts/cdp-bridge/src/agent-device-wrapper.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,30 @@ type RunAgentDeviceFn = (
475475
) => Promise<ToolResult>;
476476
let _runAgentDeviceOverrideForTest: RunAgentDeviceFn | null = null;
477477

478+
// GH #110: test-seam fuse. Once any production runAgentDevice call has
479+
// dispatched in this process, refuse to install a new override —
480+
// makes test-pollution-into-prod impossible by construction. The fuse
481+
// is intentionally one-way: tests that need both production and override
482+
// paths in the same suite should use `node --test --test-isolation=process`
483+
// (Node 22 LTS supports this) to get a fresh worker per file. A reset
484+
// seam here would defeat the entire guarantee — any code that could call
485+
// the reset is the same code that could leak the override (Codex review
486+
// conf 90).
487+
let _testSeamFused = false;
488+
let _testSeamFuseBlownBy: string | null = null;
489+
478490
export function _setRunAgentDeviceForTest(fn: RunAgentDeviceFn | null): void {
491+
if (_testSeamFused) {
492+
throw new Error(
493+
`_setRunAgentDeviceForTest: blown fuse — a production runAgentDevice ` +
494+
`call (cliArgs[0]=${JSON.stringify(_testSeamFuseBlownBy)}) already ` +
495+
`dispatched in this process. The test seam cannot be re-armed at runtime ` +
496+
`(GH #110 hardening). The most likely cause is a prior test forgot to ` +
497+
`clear its override in afterEach. Spawn a fresh Node process — e.g. ` +
498+
`\`node --test --test-isolation=process\` — if you genuinely need to ` +
499+
`mix production and override paths.`,
500+
);
501+
}
479502
_runAgentDeviceOverrideForTest = fn;
480503
}
481504

@@ -486,6 +509,13 @@ export async function runAgentDevice(
486509
if (_runAgentDeviceOverrideForTest) {
487510
return _runAgentDeviceOverrideForTest(cliArgs, opts);
488511
}
512+
// GH #110: production dispatch reached. Lock the fuse BEFORE any tier
513+
// selection so a production call that throws downstream still seals
514+
// the seam (Codex review conf 90).
515+
if (!_testSeamFused) {
516+
_testSeamFused = true;
517+
_testSeamFuseBlownBy = cliArgs[0] ?? '<empty>';
518+
}
489519
// GH #60: when an explicit platform is requested AND it doesn't match the
490520
// active session's platform (e.g. user asks for android while an iOS
491521
// session is active from prior work), skip the session-bound dispatch
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// GH #110: regression tests for the agent-device-wrapper test-seam fuse.
2+
//
3+
// The fuse is process-global by design (Codex review conf 90/90/95 —
4+
// adding a reset seam would defeat the guarantee, since any code that
5+
// could call reset is the same code that could leak the override). Each
6+
// scenario therefore runs in a freshly-spawned Node subprocess so the
7+
// fuse state is isolated.
8+
import { test } from 'node:test';
9+
import assert from 'node:assert/strict';
10+
import { spawnSync } from 'node:child_process';
11+
import { fileURLToPath } from 'node:url';
12+
import { dirname, resolve } from 'node:path';
13+
14+
const __dirname = dirname(fileURLToPath(import.meta.url));
15+
const MOD_ABS_PATH = resolve(__dirname, '../../dist/agent-device-wrapper.js');
16+
17+
function runScenario(scenarioCode) {
18+
// Use the modern dynamic-import form to avoid CJS/ESM confusion.
19+
const wrapped = `
20+
(async () => {
21+
try {
22+
const mod = await import(${JSON.stringify(MOD_ABS_PATH)});
23+
${scenarioCode}
24+
console.log('SCENARIO_OK');
25+
} catch (e) {
26+
console.log('SCENARIO_THREW:' + (e && e.message ? e.message : String(e)));
27+
}
28+
})().catch(e => { console.log('SCENARIO_REJECTED:' + (e && e.message ? e.message : String(e))); process.exit(1); });
29+
`;
30+
const result = spawnSync('node', ['--input-type=module', '-e', wrapped], {
31+
encoding: 'utf-8',
32+
timeout: 20_000,
33+
});
34+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', status: result.status };
35+
}
36+
37+
test('fuse: override fn is honored when set before any production dispatch', () => {
38+
const { stdout } = runScenario(`
39+
let captured = null;
40+
mod._setRunAgentDeviceForTest(async (args, opts) => {
41+
captured = { args, opts };
42+
return { content: [{ type: 'text', text: '{"ok":true,"data":"stubbed"}' }] };
43+
});
44+
const r = await mod.runAgentDevice(['snapshot'], {});
45+
if (!r.content[0].text.includes('stubbed')) throw new Error('override not invoked');
46+
if (captured.args[0] !== 'snapshot') throw new Error('args not threaded through');
47+
`);
48+
assert.match(stdout, /SCENARIO_OK/, `expected SCENARIO_OK, got: ${stdout}`);
49+
});
50+
51+
test('fuse: setting override to null re-enables production tier as long as fuse has not blown', () => {
52+
const { stdout } = runScenario(`
53+
let called = 0;
54+
mod._setRunAgentDeviceForTest(async () => { called++; return { content: [{ type: 'text', text: '{"ok":true}' }] }; });
55+
await mod.runAgentDevice(['snapshot'], {});
56+
if (called !== 1) throw new Error('override not invoked on first call');
57+
58+
// Set back to null — fuse has NOT blown (no production dispatch occurred).
59+
mod._setRunAgentDeviceForTest(null);
60+
// Subsequent installs should still work
61+
let called2 = 0;
62+
mod._setRunAgentDeviceForTest(async () => { called2++; return { content: [{ type: 'text', text: '{"ok":true}' }] }; });
63+
await mod.runAgentDevice(['tap'], {});
64+
if (called2 !== 1) throw new Error('second override not invoked');
65+
`);
66+
assert.match(stdout, /SCENARIO_OK/, `expected SCENARIO_OK, got: ${stdout}`);
67+
});
68+
69+
test('fuse: a production runAgentDevice call (no override) blows the fuse', () => {
70+
// The production tiers will fail (no booted device / fast-runner) but
71+
// the fuse must still be sealed regardless of the dispatch outcome.
72+
const { stdout } = runScenario(`
73+
// Direct call with no override installed → production dispatch
74+
// begins → fuse blows immediately (before any tier returns).
75+
let prodThrew = false;
76+
try {
77+
await mod.runAgentDevice(['list-devices'], { skipSession: true });
78+
} catch {
79+
prodThrew = true;
80+
}
81+
// Whether the production tier returned cleanly or threw, the fuse
82+
// is locked. Attempting to install an override now must throw.
83+
let fuseThrew = false;
84+
try {
85+
mod._setRunAgentDeviceForTest(async () => ({ content: [{ type: 'text', text: '{"ok":true}' }] }));
86+
} catch (e) {
87+
fuseThrew = true;
88+
if (!String(e.message).includes('blown fuse')) throw new Error('wrong error: ' + e.message);
89+
if (!String(e.message).includes('list-devices')) throw new Error('error should mention the trigger cliArgs[0]');
90+
}
91+
if (!fuseThrew) throw new Error('expected fuse to throw on re-arm');
92+
`);
93+
assert.match(stdout, /SCENARIO_OK/, `expected SCENARIO_OK, got: ${stdout}`);
94+
});
95+
96+
test('fuse: error message includes GH #110 reference and remediation hint', () => {
97+
const { stdout, stderr, status } = runScenario(`
98+
// Production tier may throw (no booted device in test env) — that's
99+
// fine, the fuse must still seal. Use list-devices because it dodges
100+
// the slow fast-runner path that snapshot would take.
101+
try { await mod.runAgentDevice(['list-devices'], { skipSession: true }); } catch {}
102+
let threw = false;
103+
try {
104+
mod._setRunAgentDeviceForTest(null);
105+
} catch (e) {
106+
threw = true;
107+
if (!String(e.message).includes('GH #110')) throw new Error('error must reference GH #110');
108+
if (!String(e.message).includes('--test-isolation=process')) throw new Error('error must include remediation hint');
109+
console.log('GOOD_ERROR');
110+
}
111+
if (!threw) throw new Error('expected fuse to throw on re-arm');
112+
`);
113+
assert.match(stdout, /GOOD_ERROR[\s\S]*SCENARIO_OK/, `expected GOOD_ERROR then SCENARIO_OK\nstdout: ${stdout}\nstderr: ${stderr}\nstatus: ${status}`);
114+
});
115+
116+
test('fuse: setting null to clear override does not block when fuse has NOT blown', () => {
117+
// Edge: install an override, run it, then clear it back to null
118+
// BEFORE any production dispatch. This is the normal afterEach
119+
// cleanup case — must not throw.
120+
const { stdout } = runScenario(`
121+
mod._setRunAgentDeviceForTest(async () => ({ content: [{ type: 'text', text: '{"ok":true}' }] }));
122+
await mod.runAgentDevice(['snapshot'], {});
123+
// Standard cleanup — must not throw because no production dispatch happened.
124+
mod._setRunAgentDeviceForTest(null);
125+
// Installing a new override after clean cleanup must still work.
126+
mod._setRunAgentDeviceForTest(async () => ({ content: [{ type: 'text', text: '{"ok":true,"data":"second"}' }] }));
127+
const r = await mod.runAgentDevice(['snapshot'], {});
128+
if (!r.content[0].text.includes('second')) throw new Error('second override not active');
129+
`);
130+
assert.match(stdout, /SCENARIO_OK/, `expected SCENARIO_OK, got: ${stdout}`);
131+
});

0 commit comments

Comments
 (0)