diff --git a/packages/command-registry/src/flag-definitions-action.ts b/packages/command-registry/src/flag-definitions-action.ts index ceb6766d2b..fe7fcb190d 100644 --- a/packages/command-registry/src/flag-definitions-action.ts +++ b/packages/command-registry/src/flag-definitions-action.ts @@ -210,7 +210,8 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'int', min: 0, usageLabel: '--settle-quiet ', - usageDescription: 'Settle: quiet window the UI must hold to count as settled (default 500ms)', + usageDescription: + 'Settle: quiet window the UI must hold to count as settled (default 500ms). On iOS, a broad screen replacement such as a modal dismissal is confirmed for at least 1500ms whatever window is asked for, within the --timeout settle deadline', projectConfig: true, recorded: false, }, diff --git a/src/commands/interaction/runtime/settle-confirmation-floor.test.ts b/src/commands/interaction/runtime/settle-confirmation-floor.test.ts new file mode 100644 index 0000000000..26dd9887aa --- /dev/null +++ b/src/commands/interaction/runtime/settle-confirmation-floor.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { ref } from './selector-read-utils.ts'; +import { + buttonSnapshot, + createSettleDevice, + welcomeSnapshot, +} from './__tests__/settle-device-fixtures.ts'; + +// #3470: the broad-transition confirmation owns the WAIT, not just the verdict, and it used to +// switch itself off whenever the caller asked for a window under the default. On the interaction +// commands' shared settle path that meant a `settleQuietMs: 150` caller (tester-army/e2e) got no +// guard at all while the default got a 1.5 s one. The same dismissed surface now settles at both +// requested windows: the response still reports the window the caller asked for, and the confirmed +// replacement sets the wait. `stable-capture.test.ts` pins that floor at the loop; this pins it on +// the observation the caller reads. +// +// Budgets are injected (fake clock) — no real waiting. + +function pressThatDismissesTheWholeTree() { + const before = buttonSnapshot(); + let captures = 0; + const device = createSettleDevice({ + stored: before, + captureSnapshot: () => { + captures += 1; + // A ref press resolves against the tree the session already holds and captures nothing before + // the action, so every call here is the settle loop's own sample: capture 1 still shows the + // pre-action tree, captures 2+ the tree the dismissal reveals. + return { snapshot: captures === 1 ? before : welcomeSnapshot() }; + }, + }); + return device; +} + +for (const requestedQuietMs of [500, 150]) { + test(`a broad replacement on the interaction route holds the floor at ${requestedQuietMs}ms requested`, async () => { + const result = await pressThatDismissesTheWholeTree().interactions.press(ref('@e1'), { + session: 'default', + settle: { quietMs: requestedQuietMs, timeoutMs: 5_000 }, + }); + + const settle = result.settle; + assert.ok(settle); + assert.equal(settle.settled, true); + // The window the caller asked for is what the response reports… + assert.equal(settle.quietMs, requestedQuietMs); + // …while the confirmed replacement sets the wait's floor (#3470). + assert.ok( + settle.waitedMs >= 1_500, + `confirmation did not extend the settle: waitedMs=${settle.waitedMs}`, + ); + assert.ok(settle.diff); + }); +} diff --git a/src/commands/interaction/runtime/settle.test.ts b/src/commands/interaction/runtime/settle.test.ts index de5e1afb53..d50a8cd797 100644 --- a/src/commands/interaction/runtime/settle.test.ts +++ b/src/commands/interaction/runtime/settle.test.ts @@ -307,7 +307,7 @@ test('longpress --settle rides the same observation path', async () => { const result = await device.interactions.longPress(ref('@e1'), { session: 'default', durationMs: 400, - settle: { quietMs: 100, timeoutMs: 1_000 }, + settle: { quietMs: 100, timeoutMs: 2_000 }, }); assert.equal(result.kind, 'ref'); @@ -315,7 +315,7 @@ test('longpress --settle rides the same observation path', async () => { assert.ok(settle); assert.equal(settle.settled, true); assert.equal(settle.quietMs, 100); - assert.equal(settle.timeoutMs, 1_000); + assert.equal(settle.timeoutMs, 2_000); assert.ok(settle.diff); }); diff --git a/src/commands/interaction/runtime/stable-capture.test.ts b/src/commands/interaction/runtime/stable-capture.test.ts index a714cc5491..7e3dbc05e4 100644 --- a/src/commands/interaction/runtime/stable-capture.test.ts +++ b/src/commands/interaction/runtime/stable-capture.test.ts @@ -22,6 +22,149 @@ const BROAD_TRANSITION_PARAMS = { broadTransitionBaselineNodes: elementThreadsNoticeSnapshot.nodes, }; +// Each #1831 case below runs at BOTH windows. The confirmation used to switch itself off for a +// requested window under the default, so `--settle-quiet 150` — what tester-army/e2e passes — got no +// guard at all while 500 got a 1.5 s one (#3470). +for (const requestedQuietMs of [500, 150]) { + const confirmationFloor = Math.max(requestedQuietMs, 1_500); + + test(`settle confirms a broad screen replacement beyond a self-consistent transitional tree (quiet ${requestedQuietMs}ms)`, async () => { + const { runtime } = transitionRuntime(); + + const outcome = await runStableCaptureLoop( + runtime, + { session: 'default' }, + { ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs }, + ); + + assert.equal(outcome.settled, true); + assert.equal( + outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'), + false, + ); + assert.ok( + outcome.waitedMs >= confirmationFloor, + `settled transitional tree after ${outcome.waitedMs}ms`, + ); + }); + + test(`settle confirms a broad screen replacement when capture recovers to private AX (quiet ${requestedQuietMs}ms)`, async () => { + const { runtime } = transitionRuntime({ captureBackend: 'private-ax' }); + + const outcome = await runStableCaptureLoop( + runtime, + { session: 'default' }, + { ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs }, + ); + + assert.equal(outcome.settled, true); + assert.equal( + outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'), + false, + ); + assert.ok( + outcome.waitedMs >= confirmationFloor, + `settled transitional tree after ${outcome.waitedMs}ms`, + ); + }); + + test(`settle confirms a broad replacement that begins after the first post-action capture (quiet ${requestedQuietMs}ms)`, async () => { + const { runtime } = transitionRuntime({ + captureBackend: 'private-ax', + firstCaptureKeepsBaseline: true, + settledAtMs: 1_200, + }); + + const outcome = await runStableCaptureLoop( + runtime, + { session: 'default' }, + { ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs }, + ); + + assert.equal(outcome.settled, true); + assert.equal( + outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'), + false, + ); + assert.ok( + outcome.waitedMs >= confirmationFloor, + `settled delayed transition after ${outcome.waitedMs}ms`, + ); + }); + + test(`settle confirms against the pre-action tree when the stored snapshot already advanced (quiet ${requestedQuietMs}ms)`, async () => { + const { runtime } = transitionRuntime({ + sessionSnapshot: { ...elementTransientRoomSnapshot, backend: undefined, producer: undefined }, + settledAtMs: 1_200, + }); + + const outcome = await runStableCaptureLoop( + runtime, + { session: 'default' }, + { ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs }, + ); + + assert.equal(outcome.settled, true); + assert.equal( + outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'), + false, + ); + assert.ok( + outcome.waitedMs >= confirmationFloor, + `settled advanced-session transition after ${outcome.waitedMs}ms`, + ); + }); + + test(`settle confirms an iOS broad replacement when capture omits snapshot backend provenance (quiet ${requestedQuietMs}ms)`, async () => { + const { runtime } = transitionRuntime({ + omitSnapshotBackend: true, + settledAtMs: 1_200, + }); + + const outcome = await runStableCaptureLoop( + runtime, + { session: 'default' }, + { + ...BROAD_TRANSITION_PARAMS, + quietMs: requestedQuietMs, + // Presented iOS modals can be complete interaction captures without retaining the + // Application root in their projection. + broadTransitionBaselineNodes: elementThreadsNoticeSnapshot.nodes.slice(1), + }, + ); + + assert.equal(outcome.settled, true); + assert.equal( + outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'), + false, + ); + assert.ok( + outcome.waitedMs >= confirmationFloor, + `settled backend-less transition after ${outcome.waitedMs}ms`, + ); + }); + + test(`settle confirms a tiny modal replacement through a partial post-action projection (quiet ${requestedQuietMs}ms)`, async () => { + const { runtime } = transitionRuntime({ omitViewportRoot: true, settledAtMs: 1_200 }); + + const outcome = await runStableCaptureLoop( + runtime, + { session: 'default' }, + { ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs }, + ); + + assert.equal(outcome.settled, true); + assert.equal( + outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'), + false, + ); + assert.ok( + outcome.waitedMs >= confirmationFloor, + `settled partial transitional tree after ${outcome.waitedMs}ms`, + ); + }); +} + test('regular captures settle from visible semantics while offscreen rows churn', async () => { let elapsedMs = 0; const clock = { @@ -203,8 +346,11 @@ test('settle keeps the default quiet window for a larger partial projection', as assert.ok(outcome.waitedMs < 800, `partial projection settled after ${outcome.waitedMs}ms`); }); -test('settle honors an explicitly shorter quiet window across a broad replacement', async () => { - const { runtime } = transitionRuntime(); +test('settle confirms a broad replacement on a caller window shorter than the confirmation floor', async () => { + // The counterpart of the loop above at the tightest window a caller realistically asks for. The + // 25 ms cadence still applies — the loop keeps sampling at that grid — but the confirmation floor, + // not the window, decides when a broad replacement is trusted (#3470). + const { runtime } = transitionRuntime({ settledAtMs: 1_200 }); const outcome = await runStableCaptureLoop( runtime, @@ -213,8 +359,21 @@ test('settle honors an explicitly shorter quiet window across a broad replacemen ); assert.equal(outcome.settled, true); - assert.ok(outcome.captures <= 3, `short quiet window spent ${outcome.captures} captures`); - assert.ok(outcome.waitedMs < 800, `short quiet window settled after ${outcome.waitedMs}ms`); + assert.equal( + outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'), + false, + ); + assert.ok( + outcome.waitedMs >= 1_500, + `short quiet window switched the confirmation off: settled after ${outcome.waitedMs}ms`, + ); + // Cadence follows the window in force, so the extended wait is sampled on the default grid: + // 10 captures here, 109 when the loop keeps polling at the caller's 25 ms. Each of those is a + // capture round trip, so the tight grid would turn a 1.5 s confirmation into ~1.5 s of reads. + assert.ok( + outcome.captures <= 12, + `confirmation sampled on the caller's tight grid: ${outcome.captures} captures`, + ); }); test('settle keeps the default quiet window for a backend-less overlapping local mutation', async () => { diff --git a/src/commands/interaction/runtime/stable-capture.ts b/src/commands/interaction/runtime/stable-capture.ts index 0451e3a4fc..ce39bcf2a7 100644 --- a/src/commands/interaction/runtime/stable-capture.ts +++ b/src/commands/interaction/runtime/stable-capture.ts @@ -33,6 +33,12 @@ import { withoutSettleChrome } from '@agent-device/capture-kit/snapshot-chrome'; const STABLE_POLL_INTERVAL_MS = 300; const STABLE_MIN_POLL_MS = 25; +// Never slower than the default poll, never faster than the minimum: a tight window gets a cadence +// that can measure it, a window the confirmation extends gets one that does not sample the whole +// extended wait at the caller's original grid. +function stableCapturePollMs(quietMs: number): number { + return Math.min(STABLE_POLL_INTERVAL_MS, Math.max(STABLE_MIN_POLL_MS, quietMs)); +} // Wake PAST the quiet deadline rather than exactly on it. `setTimeout(n)` can // advance `Date.now()` by only n-1 — libuv times the sleep on the monotonic // loop clock while `now()` reads the wall clock — so a capture landing on the @@ -41,6 +47,12 @@ const QUIET_DEADLINE_EPSILON_MS = 2; // Broad replacements (for example, dismissing a modal into a room) can expose a coherent but // transitional AX tree for the default quiet window. Confirm those transitions longer without // adding latency to local mutations whose pre/post trees substantially overlap. +// +// The confirmation is decided by the transition alone: a caller asking for a shorter quiet window +// asked for a tighter verdict, not for a different set of transitions to distrust. It used to arm +// only at `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS`, which made `--settle-quiet 499` behave +// differently from 500 and left `settleQuietMs: 150` callers (tester-army/e2e) with no guard at all +// (#3470). A confirmed replacement is held for the caller's own window, floored at 1.5 s. const BROAD_TRANSITION_CONFIRMATION_QUIET_MS = 1_500; export const DEFAULT_STABLE_QUIET_MS = 500; export const DEFAULT_STABLE_TIMEOUT_MS = 10_000; @@ -82,10 +94,12 @@ export async function runStableCaptureLoop( const session = await runtime.sessions.get(options.session ?? 'default'); let transitionBaseline: StableCaptureSignal | undefined; let preferredBackend = preferredSnapshotBackendForVerdict(session?.snapshot?.snapshotQuality); - // Cadence derives from the quiet window (never slower than the default + // Cadence derives from the quiet window in force (never slower than the default // poll): a caller asking for a 50ms quiet window should not be forced onto a - // 300ms grid — and tests inject the budget instead of waiting real time. - const pollMs = Math.min(STABLE_POLL_INTERVAL_MS, Math.max(STABLE_MIN_POLL_MS, quietMs)); + // 300ms grid, and a run the confirmation has extended to 1.5s should not spend + // that wait on the caller's tight grid — every sample is a capture round trip. + // Tests inject the budget instead of waiting real time. + let pollMs = stableCapturePollMs(quietMs); let captures = 0; let lastSignal: StableCaptureSignal | undefined; let lastNodeCount = 0; @@ -125,6 +139,9 @@ export async function runStableCaptureLoop( requestedQuietMs: quietMs, currentQuietMs: requiredQuietMs, }); + // Recomputed from the window in force rather than only when it changed: the window is a + // monotonic max, so this is the same value, and the cadence cannot drift from it. + pollMs = stableCapturePollMs(requiredQuietMs); preferredBackend ??= preferredSnapshotBackendForVerdict(capture.snapshot.snapshotQuality); const nowMs = now(runtime); const recoveredDeadlineMs = extendedDeadlineAfterPrivateAxRecovery({ @@ -252,7 +269,7 @@ function stableCaptureNextQuietWindowMs(params: { requestedQuietMs: number; currentQuietMs: number; }): number { - if (params.requestedQuietMs < DEFAULT_STABLE_QUIET_MS || !params.baseline) { + if (!params.baseline) { return params.currentQuietMs; } const replacementSignal = stableCaptureReplacementSignal(params.snapshot, params.appBundleId); diff --git a/src/commands/post-action-observation-grammar.ts b/src/commands/post-action-observation-grammar.ts index 45b6eb0612..dd1b250d35 100644 --- a/src/commands/post-action-observation-grammar.ts +++ b/src/commands/post-action-observation-grammar.ts @@ -33,7 +33,10 @@ const settleFields = () => ({ settle: booleanField( 'After the action, wait for the UI to go quiet and return the settled diff vs the pre-action tree in the same response. Best-effort; never fails the action.', ), - settleQuietMs: integerField('Settle: quiet window in milliseconds (default 500).', { min: 0 }), + settleQuietMs: integerField( + 'Settle: quiet window in milliseconds (default 500). On iOS, a broad screen replacement such as a modal dismissal is confirmed for at least 1500 whatever window is asked for, within the timeoutMs settle deadline.', + { min: 0 }, + ), timeoutMs: integerField('Settle: wait deadline in milliseconds (default 10000).', { min: 1 }), }); diff --git a/test/integration/provider-scenarios/settle-observation.test.ts b/test/integration/provider-scenarios/settle-observation.test.ts index 555faa56b7..e37a59c67a 100644 --- a/test/integration/provider-scenarios/settle-observation.test.ts +++ b/test/integration/provider-scenarios/settle-observation.test.ts @@ -9,6 +9,7 @@ import { simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript, type ProviderScenarioProviderEntry } from './transcript.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts'; const APP = 'com.example.app'; const DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id; @@ -172,112 +173,124 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry { }; } -test('Provider-backed integration press --settle returns the settled diff and fresh refs', async () => { - const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues refs; the first capture after open settles: one extra read - snapshotEntry(BEFORE_NODES), - snapshotEntry(BEFORE_NODES), - // press label=Continue --settle: resolution capture, tap, settle captures - snapshotEntry(BEFORE_NODES), - tapEntry(200, 322), - quietSnapshotEntry(SETTLED_NODES), - // press @e2 (the Done ref from the settled diff): tap on the stored tree - tapEntry(200, 522), - ]); - const appleRunnerProvider = createAppleRunnerProviderFromTranscript( - runnerTranscript, - 'ios.runner', - ); - const appleTool = createRecordingAppleToolProvider({ - simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ - { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, - ]), - }); - - await withProviderScenarioResource( - async () => - await createProviderScenarioHarness({ - appleRunnerProvider: () => appleRunnerProvider, - appleToolProvider: () => appleTool.provider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], - }), - async (daemon) => { - const open = await daemon.callCommand('open', [APP], { - platform: 'ios', - udid: DEVICE_ID, - }); - assertRpcOk(open); - - const snapshot = await daemon.callCommand('snapshot', [], { - snapshotInteractiveOnly: true, - }); - assertRpcOk(snapshot); - - const press = await daemon.callCommand('press', ['label=Continue'], { - settle: true, - settleQuietMs: 25, - // Coverage instrumentation can slow provider-backed daemon dispatch - // enough that a 2s wall-clock budget expires before the two quiet - // captures complete. The contract under test is the settled response, - // not timeout pressure. - timeoutMs: 10_000, - }); - const pressData = assertRpcOk(press); - const settle = pressData.settle as { - settled: boolean; - captures: number; - refsGeneration?: number; - diff?: { - summary: { additions: number; removals: number; unchanged: number }; - lines: Array<{ kind: string; text: string; ref?: string }>; +test( + 'Provider-backed integration press --settle returns the settled diff and fresh refs', + { timeout: PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS }, + async () => { + const runnerTranscript = createProviderTranscript([ + // snapshot -i: issues refs; the first capture after open settles: one extra read + snapshotEntry(BEFORE_NODES), + snapshotEntry(BEFORE_NODES), + // press label=Continue --settle: resolution capture, tap, settle captures + snapshotEntry(BEFORE_NODES), + tapEntry(200, 322), + quietSnapshotEntry(SETTLED_NODES), + // press @e2 (the Done ref from the settled diff): tap on the stored tree + tapEntry(200, 522), + ]); + const appleRunnerProvider = createAppleRunnerProviderFromTranscript( + runnerTranscript, + 'ios.runner', + ); + const appleTool = createRecordingAppleToolProvider({ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, + ]), + }); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + appleRunnerProvider: () => appleRunnerProvider, + appleToolProvider: () => appleTool.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + }), + async (daemon) => { + const open = await daemon.callCommand('open', [APP], { + platform: 'ios', + udid: DEVICE_ID, + }); + assertRpcOk(open); + + const snapshot = await daemon.callCommand('snapshot', [], { + snapshotInteractiveOnly: true, + }); + assertRpcOk(snapshot); + + const press = await daemon.callCommand('press', ['label=Continue'], { + settle: true, + settleQuietMs: 25, + // Coverage instrumentation can slow provider-backed daemon dispatch + // enough that a 2s wall-clock budget expires before the two quiet + // captures complete. The contract under test is the settled response, + // not timeout pressure. + timeoutMs: 10_000, + }); + const pressData = assertRpcOk(press); + const settle = pressData.settle as { + settled: boolean; + captures: number; + waitedMs: number; + refsGeneration?: number; + diff?: { + summary: { additions: number; removals: number; unchanged: number }; + lines: Array<{ kind: string; text: string; ref?: string }>; + }; + tail?: Array<{ ref: string; role: string; label?: string }>; + hint?: string; }; - tail?: Array<{ ref: string; role: string; label?: string }>; - hint?: string; - }; - assert.ok(settle, 'press --settle must return a settle observation'); - assert.equal(settle.settled, true); - // Snapshot-floor economy: a quiet UI settles on the two identical - // captures the verdict needs, or three when the second lands a hair short - // of the quiet window. Anything above that is a regression in what settle - // spends; pinning a single number would assert the runner's speed. - assert.ok( - settle.captures >= 2 && settle.captures <= 3, - `settle should cost 2-3 captures, got ${settle.captures}`, - ); - assert.equal(typeof settle.refsGeneration, 'number'); - assert.deepEqual(settle.diff?.summary, { additions: 1, removals: 2, unchanged: 1 }); - const added = settle.diff?.lines.find((line) => line.kind === 'added'); - assert.match(added?.text ?? '', /Done/); - assert.equal(added?.ref, 'e2'); - // The added line already hands back a fresh target: the diff's own refs - // are the actionable payload, so the tail stays off. - assert.equal(settle.tail, undefined); - // The settled tree is never serialized into the response. - assert.equal(pressData.nodes, undefined); - - // ADR 0014: the settle issued a PARTIAL frame authorizing exactly its - // emitted ref, so the follow-up consumes it in pinned form — acting - // directly on the stored settled tree with no fresh snapshot round trip - // and no stale-refs warning. The plain `@e2` would require a complete - // frame. - const callsBeforeFollowUp = runnerTranscript.calls.length; - const followUp = await daemon.callCommand('press', [`@e2~s${settle.refsGeneration}`], {}); - const followUpData = assertRpcOk(followUp); - assert.equal(followUpData.warning, undefined); - assert.equal(followUpData.x, 200); - assert.equal(followUpData.y, 522); - // The round trip settle exists to remove: the follow-up spends a tap and - // nothing else. Asserted directly, since a quiet-UI entry would happily - // serve a stray capture. - assert.deepEqual( - runnerTranscript.calls.slice(callsBeforeFollowUp).map((call) => call.command), - ['ios.runner.tap'], - ); - - runnerTranscript.assertComplete(); - }, - ); -}); + assert.ok(settle, 'press --settle must return a settle observation'); + assert.equal(settle.settled, true); + // The pressed dialog's own button disappears with it, so only the + // Application root survives: fewer than half the node identities carry + // over, which is the broad replacement #1831 confirms for 1.5s — at this + // 25ms window too, since #3470. That wait is the contract below, so the + // capture count is the confirmation's, not the quiet UI's 2-3. + assert.ok( + settle.waitedMs >= 1_500, + `short quiet window switched the dismissal confirmation off: ${settle.waitedMs}ms`, + ); + // Same reasoning one level down: pinning a single number would assert the + // runner's speed against the confirmation's own deadline arithmetic. + assert.ok( + settle.captures >= 2, + `confirmation settled on fewer than two captures, got ${settle.captures}`, + ); + assert.equal(typeof settle.refsGeneration, 'number'); + assert.deepEqual(settle.diff?.summary, { additions: 1, removals: 2, unchanged: 1 }); + const added = settle.diff?.lines.find((line) => line.kind === 'added'); + assert.match(added?.text ?? '', /Done/); + assert.equal(added?.ref, 'e2'); + // The added line already hands back a fresh target: the diff's own refs + // are the actionable payload, so the tail stays off. + assert.equal(settle.tail, undefined); + // The settled tree is never serialized into the response. + assert.equal(pressData.nodes, undefined); + + // ADR 0014: the settle issued a PARTIAL frame authorizing exactly its + // emitted ref, so the follow-up consumes it in pinned form — acting + // directly on the stored settled tree with no fresh snapshot round trip + // and no stale-refs warning. The plain `@e2` would require a complete + // frame. + const callsBeforeFollowUp = runnerTranscript.calls.length; + const followUp = await daemon.callCommand('press', [`@e2~s${settle.refsGeneration}`], {}); + const followUpData = assertRpcOk(followUp); + assert.equal(followUpData.warning, undefined); + assert.equal(followUpData.x, 200); + assert.equal(followUpData.y, 522); + // The round trip settle exists to remove: the follow-up spends a tap and + // nothing else. Asserted directly, since a quiet-UI entry would happily + // serve a stray capture. + assert.deepEqual( + runnerTranscript.calls.slice(callsBeforeFollowUp).map((call) => call.command), + ['ios.runner.tap'], + ); + + runnerTranscript.assertComplete(); + }, + ); + }, +); test('Provider-backed integration never-settled press --settle does not issue diff refs', async () => { const loadingNodes = (label: string) => [