mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
480e3883b1
* fix(daemon): reject unarmed close --save-script before teardown Live evidence (2026-08-02) showed a plain `open` followed by `close --save-script` silently published a script: the close request armed authoring at record time and published moments later in the same request, folding the never-armed case into the ADR 0016 authoring lifecycle. The resulting .ad carries selector fallback chains but no recording-time target-v1 evidence, and nothing told the caller evidence capture never ran — degraded replay verification with no signal beats a loud refusal. `assertTerminalRecordingCloseAllowed` (src/daemon/handlers/session-close.ts) now rejects an unarmed `close --save-script` with INVALID_ARGS before any teardown or filesystem work runs, the same seam that already rejected ABORTED/PUBLISHED terminal recordings. The rejection does not tear the session down, so a plain `close` retry still completes cleanly; recovery names `open --save-script` since evidence can only be captured from action zero. Repair transactions (ADR 0012) are a disjoint lifecycle and are explicitly unaffected. This is distinct from #1533 (an already-armed-then-aborted session whose flag ingress re-enables recordSession and lets a *bare* close publish); that case remains open. * fix: review follow-ups for #1558 (help text, test strength, docs) - Give replay --save-script its own help text instead of the shared open/close "arm on open, publish on close" description: replay's flag arms an ADR 0012 repair transaction, a disjoint lifecycle. Adds CommandSchema.flagDescriptionOverrides so a command can swap a shared flag's usageDescription without duplicating the FlagDefinition entry (which would have shown --save-script twice in `help replay`). Pinned in src/cli/parser/__tests__/cli-help-command-usage.test.ts (open/close keep the shared text unchanged; replay gets the new one). - Strengthen the never-armed close --save-script regression test in session-close-shutdown.test.ts: the fixture now carries real cleanup-bearing state (an active iOS simulator recording, reusing makeIosSimulatorRecordingSession/recordingKillMock) with spies proving no teardown hook (recorder kill, runner stop) runs on the rejected request, then that a follow-up plain close does tear it down. The prior fixture had nothing for teardown to observably touch, so moving the guard after stopBestEffortSessionResources would have passed it silently. Also fixes a latent test-isolation leak this exposed: an earlier test set a persistent mockStopIosRunnerSession rejection (vi.clearAllMocks() clears call history, not implementations), which would have poisoned any later Apple-platform close test; scoped it to mockRejectedValueOnce. - Point the migration guide (website/docs/docs/migrating-gestures.md) at `open --save-script` → interact → `close` instead of the now-rejected `open` → interact → `close --save-script`, matching the new guard and the corrected help text. _Generated by [Claude Code](https://claude.ai/code)_
396 lines
18 KiB
TypeScript
396 lines
18 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { emitDiagnostic } from '../../../src/utils/diagnostics.ts';
|
|
import { test } from 'vitest';
|
|
import { assertRpcError, assertRpcOk } from './assertions.ts';
|
|
import { androidSettingsXml, createAndroidSettingsWorld } from './android-world.ts';
|
|
import { withProviderScenarioResource } from './harness.ts';
|
|
|
|
/** The authoring lifecycle status of the world's live session, or `undefined` outside authoring. */
|
|
function authoringPublicationStatus(world: {
|
|
daemon: { session: () => { scriptPublication?: { kind: string; status?: string } } | undefined };
|
|
}): string | undefined {
|
|
const publication = world.daemon.session()?.scriptPublication;
|
|
return publication?.kind === 'authoring' ? publication.status : undefined;
|
|
}
|
|
|
|
test('provider route publishes and replays an open-to-destination script with a live handoff', async () => {
|
|
await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-active-script-provider-'));
|
|
const scriptPath = path.join(root, 'settings-search.ad');
|
|
const client = world.daemon.client();
|
|
try {
|
|
await client.apps.open({
|
|
app: 'settings',
|
|
saveScript: scriptPath,
|
|
...world.selection,
|
|
});
|
|
await client.command.wait({ selector: 'label="Search"', ...world.selection });
|
|
|
|
const published = await client.sessions.saveScript({ path: scriptPath });
|
|
assert.equal(published.savedScript, scriptPath);
|
|
assert.equal(published.session, 'default');
|
|
assert.equal(published.actionCount, 2);
|
|
assert.equal(authoringPublicationStatus(world), 'published');
|
|
|
|
const liveSnapshot = await client.capture.snapshot({ interactiveOnly: true });
|
|
assert.ok(liveSnapshot.nodes.some((node) => node.label === 'Search'));
|
|
const flaggedClose = await world.daemon.callCommand('close', [], { saveScript: scriptPath });
|
|
assertRpcError(flaggedClose, 'INVALID_ARGS', /cannot re-publish/);
|
|
assert.ok(world.daemon.session(), 'flagged close must preserve the published live session');
|
|
await client.sessions.close();
|
|
|
|
const replay = await world.daemon.callCommand('replay', [scriptPath], world.selection);
|
|
const replayData = assertRpcOk<{ session?: string; sessionActive?: boolean }>(replay);
|
|
assert.equal(replayData.session, 'default');
|
|
// ADR 0016 / #1384: the published script has no terminal `close`, so the
|
|
// real (not test-fixture-injected) daemon response reports the session
|
|
// still active — this is the exact producer line the client-lifecycle
|
|
// tests in daemon-client-lifecycle.test.ts assume but cannot themselves
|
|
// exercise, since they inject sessionActive via a fake HTTP fixture.
|
|
assert.equal(replayData.sessionActive, true);
|
|
assert.ok(world.daemon.session(), 'a close-less replay must not tear down the session');
|
|
const replaySnapshot = await client.capture.snapshot({ interactiveOnly: true });
|
|
assert.ok(replaySnapshot.nodes.some((node) => node.label === 'Search'));
|
|
await client.sessions.close();
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
}, 20_000);
|
|
|
|
test('provider route can suppress an authored terminal close for a live replay handoff', async () => {
|
|
await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-keep-session-provider-'));
|
|
const scriptPath = path.join(root, 'settings-with-close.ad');
|
|
fs.writeFileSync(scriptPath, ['open "settings"', 'close', ''].join('\n'));
|
|
const client = world.daemon.client();
|
|
try {
|
|
const replay = await client.replay.run({
|
|
path: scriptPath,
|
|
keepSession: true,
|
|
...world.selection,
|
|
});
|
|
assert.equal(replay.session, 'default');
|
|
assert.equal(replay.sessionActive, true);
|
|
assert.ok(world.daemon.session(), 'keepSession must preserve the replay-opened session');
|
|
|
|
const takeoverSnapshot = await client.capture.snapshot({ interactiveOnly: true });
|
|
assert.ok(takeoverSnapshot.nodes.some((node) => node.label === 'Search'));
|
|
|
|
await client.sessions.close();
|
|
assert.equal(world.daemon.session(), undefined);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
}, 20_000);
|
|
|
|
// #1349 / ADR 0016 reshuffled-screen false-pass regression: the destination
|
|
// guard must prove recorded landmark IDENTITY, not selector existence. The
|
|
// replay lands on a reshuffled screen that still contains a node labeled
|
|
// "Search" — under a different id and ancestry — so the selector alone would
|
|
// have passed the old guard. With landmark verification the wait must fail
|
|
// closed through the bounded REPLAY_DIVERGENCE contract instead of
|
|
// reporting the destination ready.
|
|
test('replaying a published script against a reshuffled screen fails closed instead of false-passing the guard', async () => {
|
|
let phase: 'record' | 'replay' = 'record';
|
|
const reshuffledXml = () =>
|
|
[
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<hierarchy rotation="0">',
|
|
' <node index="0" text="" resource-id="com.android.settings:id/results_panel" class="android.widget.LinearLayout" package="com.android.settings" content-desc="" bounds="[0,0][390,600]" clickable="false" enabled="true">',
|
|
' <node index="0" text="Search" resource-id="android:id/summary" class="android.widget.TextView" package="com.android.settings" content-desc="" bounds="[24,124][152,178]" clickable="false" enabled="true" />',
|
|
' </node>',
|
|
'</hierarchy>',
|
|
].join('\n');
|
|
await withProviderScenarioResource(
|
|
() =>
|
|
createAndroidSettingsWorld({
|
|
snapshotXml: () => (phase === 'record' ? androidSettingsXml('') : reshuffledXml()),
|
|
}),
|
|
async (world) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reshuffle-guard-provider-'));
|
|
const scriptPath = path.join(root, 'settings-search.ad');
|
|
const client = world.daemon.client();
|
|
try {
|
|
await client.apps.open({
|
|
app: 'settings',
|
|
saveScript: scriptPath,
|
|
...world.selection,
|
|
});
|
|
await client.command.wait({
|
|
selector: 'label="Search"',
|
|
timeoutMs: 1500,
|
|
...world.selection,
|
|
});
|
|
const published = await client.sessions.saveScript({ path: scriptPath });
|
|
assert.equal(published.savedScript, scriptPath);
|
|
// The published guard carries recorded landmark identity.
|
|
assert.match(fs.readFileSync(scriptPath, 'utf8'), /agent-device:target-v1/);
|
|
await client.sessions.close();
|
|
|
|
phase = 'replay';
|
|
const replay = await world.daemon.callCommand('replay', [scriptPath], world.selection);
|
|
const errorData = assertRpcError(replay, 'REPLAY_DIVERGENCE', /wait/i);
|
|
const divergence = (errorData.details as Record<string, unknown> | undefined)?.divergence as
|
|
| { kind?: string; targetBinding?: { classification?: string; matchCount?: number } }
|
|
| undefined;
|
|
assert.equal(divergence?.kind, 'identity-mismatch');
|
|
// matchCount >= 1 is the false-pass proof: the selector alone DID
|
|
// match the reshuffled screen; only identity refused it.
|
|
assert.ok((divergence?.targetBinding?.matchCount ?? 0) >= 1);
|
|
await world.daemon.callCommand('close');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
}, 30_000);
|
|
|
|
test('a second successful open aborts publication and terminal save flags fail before close', async () => {
|
|
await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-aborted-script-provider-'));
|
|
const scriptPath = path.join(root, 'aborted.ad');
|
|
try {
|
|
const first = await world.daemon.callCommand('open', ['settings'], {
|
|
...world.selection,
|
|
saveScript: scriptPath,
|
|
});
|
|
assertRpcOk(first);
|
|
|
|
const rearm = await world.daemon.callCommand('open', ['settings'], {
|
|
...world.selection,
|
|
relaunch: true,
|
|
saveScript: scriptPath,
|
|
});
|
|
assertRpcError(rearm, 'INVALID_ARGS', /only arm a fresh session/);
|
|
assert.equal(authoringPublicationStatus(world), 'armed');
|
|
|
|
const second = await world.daemon.callCommand('open', ['settings'], {
|
|
...world.selection,
|
|
relaunch: true,
|
|
});
|
|
const secondData = assertRpcOk<{ warnings?: string[] }>(second);
|
|
assert.match(String(secondData.warnings), /publication was aborted/i);
|
|
assert.equal(authoringPublicationStatus(world), 'aborted');
|
|
|
|
const publication = await world.daemon.callCommand('session_save_script', [scriptPath]);
|
|
assertRpcError(publication, 'COMMAND_FAILED', /aborted by a second successful open/);
|
|
|
|
const flaggedClose = await world.daemon.callCommand('close', [], { saveScript: scriptPath });
|
|
assertRpcError(flaggedClose, 'INVALID_ARGS', /terminal recording/);
|
|
assert.ok(world.daemon.session(), 'flagged close must not tear down the session');
|
|
assert.equal(fs.existsSync(scriptPath), false);
|
|
|
|
const plainClose = await world.daemon.callCommand('close');
|
|
assertRpcOk(plainClose);
|
|
assert.equal(world.daemon.session(), undefined);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
}, 20_000);
|
|
|
|
// Live evidence (2026-08-02): a plain `open` (no arming) followed by `close --save-script` used to
|
|
// silently publish anyway — the close request armed authoring at record time and published moments
|
|
// later in the same request, producing a script with selector fallback chains but NO recording-time
|
|
// `target-v1` evidence and no signal to the caller that the evidence was missing. The daemon now
|
|
// rejects this before any teardown or filesystem work, and the session stays open so a plain close
|
|
// still completes cleanly (it just does not publish).
|
|
test('an unarmed session refuses close --save-script and closes cleanly on plain close', async () => {
|
|
await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-unarmed-close-provider-'));
|
|
const scriptPath = path.join(root, 'unarmed.ad');
|
|
try {
|
|
const opened = await world.daemon.callCommand('open', ['settings'], { ...world.selection });
|
|
assertRpcOk(opened);
|
|
assert.equal(authoringPublicationStatus(world), undefined);
|
|
|
|
const flaggedClose = await world.daemon.callCommand('close', [], { saveScript: scriptPath });
|
|
assertRpcError(flaggedClose, 'INVALID_ARGS', /not armed/);
|
|
assert.ok(world.daemon.session(), 'flagged close must not tear down the unarmed session');
|
|
assert.equal(fs.existsSync(scriptPath), false);
|
|
|
|
const plainClose = await world.daemon.callCommand('close');
|
|
assertRpcOk(plainClose);
|
|
assert.equal(world.daemon.session(), undefined);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
}, 20_000);
|
|
|
|
// The counterpart to the unarmed refusal above: an `open --save-script`-armed session must still
|
|
// publish through the ordinary close-time route (not just through `session save-script`), and the
|
|
// published script must carry the same recording-time `target-v1` evidence.
|
|
test('an armed session still publishes target-v1 evidence through close --save-script', async () => {
|
|
await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-armed-close-provider-'));
|
|
const scriptPath = path.join(root, 'armed-close.ad');
|
|
const client = world.daemon.client();
|
|
try {
|
|
await client.apps.open({
|
|
app: 'settings',
|
|
saveScript: scriptPath,
|
|
...world.selection,
|
|
});
|
|
assert.equal(authoringPublicationStatus(world), 'armed');
|
|
|
|
const snapshot = await client.capture.snapshot({ interactiveOnly: true, ...world.selection });
|
|
const search = snapshot.nodes.find((node) => node.label === 'Search');
|
|
assert.ok(search?.ref, JSON.stringify(snapshot.nodes));
|
|
await client.interactions.click({ ref: `@${search.ref}`, ...world.selection });
|
|
|
|
const close = await world.daemon.callCommand('close', [], { saveScript: scriptPath });
|
|
assertRpcOk(close);
|
|
assert.equal(fs.existsSync(scriptPath), true);
|
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
|
assert.match(script, /agent-device:target-v1/);
|
|
assert.equal(world.daemon.session(), undefined);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
}, 20_000);
|
|
|
|
test('parameterized fill publishes only ${VAR} and replay resolves it immediately before fill', async () => {
|
|
const secret = 'OpaqueProviderValue1348';
|
|
let injectedText: string | undefined;
|
|
let capturesAfterInjection = 0;
|
|
await withProviderScenarioResource(
|
|
async () =>
|
|
await createAndroidSettingsWorld({
|
|
nativeTextInjection: true,
|
|
onTextInjection: (request) => {
|
|
injectedText = request.text;
|
|
capturesAfterInjection = 0;
|
|
emitDiagnostic({
|
|
phase: 'provider_text_echo_regression',
|
|
data: { text: request.text },
|
|
});
|
|
},
|
|
snapshotXml: () => {
|
|
if (injectedText === undefined) return androidSettingsXml('');
|
|
capturesAfterInjection += 1;
|
|
const visibleText =
|
|
capturesAfterInjection <= 3 ? injectedText : `prefix${injectedText}suffix`;
|
|
return androidSettingsXml(visibleText);
|
|
},
|
|
}),
|
|
async (world) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-parameterized-script-'));
|
|
const scriptPath = path.join(root, 'parameterized-search.ad');
|
|
const client = world.daemon.client();
|
|
try {
|
|
const opened = await client.apps.open({
|
|
app: 'settings',
|
|
saveScript: scriptPath,
|
|
...world.selection,
|
|
});
|
|
const snapshot = await client.capture.snapshot({
|
|
interactiveOnly: true,
|
|
...world.selection,
|
|
});
|
|
const search = snapshot.nodes.find((node) => node.label === 'Search');
|
|
assert.ok(search?.ref);
|
|
|
|
const invalidName = await world.daemon.callCommand('fill', [`@${search.ref}`, secret], {
|
|
...world.selection,
|
|
recordAs: 'password',
|
|
});
|
|
assertRpcError(invalidName, 'INVALID_ARGS', /Invalid --record-as variable/);
|
|
assert.equal(world.textInjectionCalls.length, 0);
|
|
|
|
const contradictory = await world.daemon.callCommand('fill', [`@${search.ref}`, secret], {
|
|
...world.selection,
|
|
recordAs: 'SEARCH_TERM',
|
|
noRecord: true,
|
|
});
|
|
assertRpcError(contradictory, 'INVALID_ARGS', /cannot be combined with --no-record/);
|
|
assert.equal(world.textInjectionCalls.length, 0);
|
|
|
|
const fill = await client.interactions.fill({
|
|
ref: `@${search.ref}`,
|
|
text: secret,
|
|
recordAs: 'SEARCH_TERM',
|
|
settle: true,
|
|
settleQuietMs: 1,
|
|
timeoutMs: 1_000,
|
|
...world.selection,
|
|
});
|
|
assert.equal(fill.text, '${SEARCH_TERM}');
|
|
const settleOutput = JSON.stringify(fill.settle);
|
|
assert.equal(settleOutput.includes(secret), false, settleOutput);
|
|
assert.match(settleOutput, /prefix\$\{SEARCH_TERM\}suffix/);
|
|
await client.command.wait({
|
|
selector: 'id=android:id/title',
|
|
...world.selection,
|
|
});
|
|
|
|
const recordedState = JSON.stringify(world.daemon.session()?.actions);
|
|
assert.equal(recordedState.includes(secret), false, recordedState);
|
|
assert.match(recordedState, /\$\{SEARCH_TERM\}/);
|
|
await client.sessions.saveScript({ path: scriptPath });
|
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
|
assert.equal(script.includes(secret), false);
|
|
assert.match(script, /\$\{SEARCH_TERM\}/);
|
|
assert.equal(world.textInjectionCalls.at(-1)?.text, secret);
|
|
await client.sessions.close();
|
|
|
|
const callsBeforeMissingValue = world.textInjectionCalls.length;
|
|
const missingValue = await world.daemon.callCommand(
|
|
'replay',
|
|
[scriptPath],
|
|
world.selection,
|
|
);
|
|
assertRpcError(missingValue, 'INVALID_ARGS', /Unresolved variable \$\{SEARCH_TERM\}/);
|
|
assert.equal(world.textInjectionCalls.length, callsBeforeMissingValue);
|
|
await client.sessions.close();
|
|
|
|
const replay = await world.daemon.callCommand(
|
|
'replay',
|
|
[scriptPath],
|
|
{
|
|
...world.selection,
|
|
replayEnv: [`SEARCH_TERM=${secret}`],
|
|
verbose: true,
|
|
},
|
|
{
|
|
meta: { debug: true, requestId: 'parameterized-replay-diagnostics' },
|
|
},
|
|
);
|
|
assertRpcOk(replay);
|
|
assert.equal(world.textInjectionCalls.at(-1)?.text, secret);
|
|
const replayState = JSON.stringify(world.daemon.session()?.actions);
|
|
assert.equal(replayState.includes(secret), false, replayState);
|
|
assert.match(replayState, /\$\{SEARCH_TERM\}/);
|
|
assert.ok(opened.sessionStateDir);
|
|
const requestLog = fs.readFileSync(
|
|
path.join(opened.sessionStateDir, 'requests', 'parameterized-replay-diagnostics.ndjson'),
|
|
'utf8',
|
|
);
|
|
assert.match(requestLog, /provider_text_echo_regression/);
|
|
assert.match(requestLog, /\[REDACTED\]/);
|
|
assert.equal(requestLog.includes(secret), false);
|
|
await client.sessions.close();
|
|
|
|
await client.apps.open({ app: 'settings', ...world.selection });
|
|
const callsBeforeUnarmed = world.textInjectionCalls.length;
|
|
const unarmed = await world.daemon.callCommand(
|
|
'fill',
|
|
['id=com.android.settings:id/search', secret],
|
|
{ ...world.selection, recordAs: 'SEARCH_TERM' },
|
|
);
|
|
assertRpcError(unarmed, 'INVALID_ARGS', /requires an armed script recording/);
|
|
assert.equal(world.textInjectionCalls.length, callsBeforeUnarmed);
|
|
await client.sessions.close();
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
}, 20_000);
|