Skip to content

Commit 03d2a9e

Browse files
committed
test: restore daemon composition wait branch coverage
1 parent 00e44c6 commit 03d2a9e

7 files changed

Lines changed: 691 additions & 16 deletions

File tree

‎packages/daemon/package.json‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,6 @@
164164
"types": "./src/core/session-store.ts",
165165
"default": "./src/core/session-store.ts"
166166
},
167-
"./testing/snapshot-interactor-capture": {
168-
"types": "./src/core/snapshot-interactor-capture.ts",
169-
"default": "./src/core/snapshot-interactor-capture.ts"
170-
},
171167
"./testing/web-session-names": {
172168
"types": "./src/sessions/web-session-names.ts",
173169
"default": "./src/sessions/web-session-names.ts"

‎packages/daemon/src/execution/wait-runtime.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,31 @@ test('a text wait returns a native owner hit without capturing a snapshot', asyn
342342
expect(harness.captureSnapshot).not.toHaveBeenCalled();
343343
});
344344

345+
test('the native text reading runs in the same runner context as the capture', async () => {
346+
let nativeReads = 0;
347+
const harness = waitRuntimeHarness({
348+
findText: available,
349+
findTextAnswers: () => nativeReads++ > 0,
350+
nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]],
351+
});
352+
353+
const { response } = await runWait(['text', 'Ready', '4000'], harness, [], {
354+
debug: true,
355+
iosXctestrunFile: '/tmp/override.xctestrun',
356+
iosXctestDerivedDataPath: '/tmp/derived',
357+
});
358+
359+
expect(response.ok).toBe(true);
360+
const captureExecution = harness.captureSnapshot.mock.calls[0]?.[0]?.execution;
361+
const findTextExecution = harness.findText.mock.calls[0]?.[0]?.execution;
362+
expect(captureExecution).toMatchObject({
363+
requestId: 'wait-runtime-test',
364+
iosXctestrunFile: '/tmp/override.xctestrun',
365+
iosXctestDerivedDataPath: '/tmp/derived',
366+
});
367+
expect(findTextExecution).toEqual(captureExecution);
368+
});
369+
345370
test('native text failure cannot hide a canonical capture failure', async () => {
346371
const harness = waitRuntimeHarness({ findText: available });
347372
harness.findText.mockRejectedValueOnce(new Error('native observation failed'));
@@ -375,6 +400,20 @@ test('a selector wait binds the capture use once and polls through the bound ope
375400
expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse);
376401
});
377402

403+
test('an unavailable conditional native reading preserves the capture-backed wait path', async () => {
404+
const harness = waitRuntimeHarness({
405+
nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }]],
406+
});
407+
408+
const { response } = await runWait(['text', 'Ready'], harness);
409+
410+
expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } });
411+
expect(harness.inspectFacts).toHaveBeenCalledTimes(1);
412+
expect(harness.bindDevice).toHaveBeenCalledTimes(1);
413+
expect(harness.findText).not.toHaveBeenCalled();
414+
expect(harness.captureSnapshot).toHaveBeenCalledTimes(1);
415+
});
416+
378417
test('a @ref wait binds the capture use once and polls through the bound operation', async () => {
379418
const harness = waitRuntimeHarness();
380419

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { SnapshotState } from '@agent-device/kernel/snapshot';
4+
import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures';
5+
import { surfaceScopedNodes } from '@agent-device/capture-kit/snapshot-baseline';
6+
import {
7+
buttonSnapshot,
8+
createSettleDevice,
9+
welcomeSnapshot,
10+
} from './__tests__/settle-device-fixtures.ts';
11+
import { settleObservationCommand } from './settle.ts';
12+
13+
const SHEET_BUNDLE_ID = 'com.apple.SafariViewService';
14+
15+
function signInSheetSnapshot(): SnapshotState {
16+
return {
17+
...makeSnapshotState(
18+
['Continue', 'Sign in with Example', 'Email', 'Password', 'Cancel'].map((label, index) => ({
19+
index,
20+
depth: 0,
21+
type: 'Button',
22+
label,
23+
rect: { x: 10, y: 20 + index * 50, width: 200, height: 40 },
24+
hittable: true,
25+
})),
26+
),
27+
iosSystemSurfaceBundleId: SHEET_BUNDLE_ID,
28+
};
29+
}
30+
31+
async function genericSettle(baseline: SnapshotState, after: SnapshotState) {
32+
const device = createSettleDevice({
33+
stored: baseline,
34+
captureSnapshot: () => ({ snapshot: after }),
35+
});
36+
const settle = await settleObservationCommand(device, {
37+
session: 'default',
38+
baseline: surfaceScopedNodes(baseline),
39+
quietMs: 25,
40+
timeoutMs: 5_000,
41+
});
42+
return { device, settle };
43+
}
44+
45+
const app = welcomeSnapshot();
46+
const sheet = signInSheetSnapshot();
47+
48+
for (const route of ['scroll', 'back'] as const) {
49+
for (const transition of [
50+
{ name: 'app-to-sheet', baseline: app, after: sheet, from: 'app', to: SHEET_BUNDLE_ID },
51+
{ name: 'sheet-to-app', baseline: sheet, after: app, from: SHEET_BUNDLE_ID, to: 'app' },
52+
]) {
53+
test(`targetless ${route} settle discloses the ${transition.name} surface change`, async () => {
54+
const { device, settle } = await genericSettle(transition.baseline, transition.after);
55+
56+
assert.equal(settle.settled, true);
57+
assert.equal(settle.surfaceChange?.from, transition.from);
58+
assert.equal(settle.surfaceChange?.to, transition.to);
59+
assert.ok(settle.surfaceChange?.disclosure);
60+
assert.equal(settle.diff, undefined);
61+
assert.equal(settle.tail, undefined);
62+
assert.match(settle.hint ?? '', /different surfaces/);
63+
assert.match(settle.hint ?? '', /take a snapshot/i);
64+
const stored = (await device.sessions.get('default')) as { snapshot?: SnapshotState };
65+
assert.equal(
66+
stored.snapshot?.iosSystemSurfaceBundleId,
67+
transition.after.iosSystemSurfaceBundleId,
68+
);
69+
});
70+
}
71+
}
72+
73+
function presentedSheetBaseline(nodeCount: number): SnapshotState {
74+
return {
75+
...makeSnapshotState(
76+
Array.from({ length: nodeCount }, (_, index) => ({
77+
index,
78+
depth: 0,
79+
type: 'Button',
80+
label: `Sheet action ${index}`,
81+
})),
82+
),
83+
presentedSurface: 'presented',
84+
};
85+
}
86+
87+
for (const [flavour, nodeCount] of [
88+
['interactive', 4],
89+
['full', 6],
90+
] as const) {
91+
test(`targetless back settle confirms a dismissed sheet from the ${flavour} baseline`, async () => {
92+
const { settle } = await genericSettle(presentedSheetBaseline(nodeCount), welcomeSnapshot());
93+
94+
assert.equal(settle.settled, true);
95+
assert.ok(settle.waitedMs >= 1_500, `confirmation ended after ${settle.waitedMs}ms`);
96+
});
97+
}
98+
99+
test('targetless back settle keeps the plain window when the baseline presented nothing', async () => {
100+
const absent = { ...buttonSnapshot(), presentedSurface: 'absent' as const };
101+
const { settle } = await genericSettle(absent, welcomeSnapshot());
102+
103+
assert.equal(settle.settled, true);
104+
assert.ok(settle.waitedMs < 1_500, `plain window ended after ${settle.waitedMs}ms`);
105+
});
106+
107+
test('targetless back settle confirms a broad replacement when the baseline fact is unknown', async () => {
108+
const { settle } = await genericSettle(buttonSnapshot(), welcomeSnapshot());
109+
110+
assert.equal(settle.settled, true);
111+
assert.ok(settle.waitedMs >= 1_500, `confirmation ended after ${settle.waitedMs}ms`);
112+
});

‎src/commands/interaction/runtime/wait-absent.test.ts‎

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import assert from 'node:assert/strict';
2-
import { test } from 'vitest';
3-
import type { AgentDeviceBackend, BackendSnapshotResult } from '@agent-device/contracts/backend';
2+
import { test, vi } from 'vitest';
3+
import type {
4+
AgentDeviceBackend,
5+
BackendCommandContext,
6+
BackendSnapshotResult,
7+
} from '@agent-device/contracts/backend';
48
import { createLocalArtifactAdapter } from '../../../io.ts';
59
import { createAgentDevice } from '../../../runtime.ts';
610
import { localCommandPolicy } from '@agent-device/contracts/command-policy';
711
import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures';
812
import { createFakeClock } from './__tests__/test-utils/index.ts';
913
import { AppError } from '@agent-device/kernel/errors';
14+
import { WAIT_REASONS } from '@agent-device/contracts/wait';
1015
import { createMemorySessionStore } from '../../../command-runtime/runtime-factory.ts';
1116

1217
const SELECTOR = 'label="Removed"';
@@ -52,6 +57,36 @@ function absentDevice(
5257
});
5358
}
5459

60+
function cancelledAbsentDevice(params: {
61+
firstSnapshot?: BackendSnapshotResult;
62+
cancellation: AppError;
63+
}) {
64+
let captures = 0;
65+
const captureSnapshot = vi.fn(async (context: BackendCommandContext) => {
66+
captures += 1;
67+
if (captures === 1 && params.firstSnapshot) return params.firstSnapshot;
68+
const signal = context.signal;
69+
if (!signal) throw new Error('the wait deadline never reached the capture');
70+
return await new Promise<never>((_resolve, reject) => {
71+
const cancel = () => reject(params.cancellation);
72+
if (signal.aborted) cancel();
73+
else signal.addEventListener('abort', cancel, { once: true });
74+
});
75+
});
76+
const clock = { now: () => 0, sleep: async (_durationMs: number) => {} };
77+
const device = createAgentDevice({
78+
backend: {
79+
platform: 'ios',
80+
captureSnapshot,
81+
} satisfies AgentDeviceBackend,
82+
artifacts: createLocalArtifactAdapter(),
83+
sessions: createMemorySessionStore([{ name: 'default' }]),
84+
policy: localCommandPolicy(),
85+
clock,
86+
});
87+
return { device, captureSnapshot };
88+
}
89+
5590
async function waitAbsent(device: ReturnType<typeof createAgentDevice>, timeoutMs = 1000) {
5691
return await device.selectors.wait({
5792
session: 'default',
@@ -222,3 +257,129 @@ test('wait absent preserves a final Android unreadable diagnostic when no valid
222257
return true;
223258
});
224259
});
260+
261+
test('strict wait absent reports capture-stalled when every capture stalls', async () => {
262+
vi.useFakeTimers();
263+
try {
264+
const { device, captureSnapshot } = cancelledAbsentDevice({
265+
cancellation: new AppError('COMMAND_FAILED', 'capture cancelled'),
266+
});
267+
const pending = waitAbsent(device, 20);
268+
const failure = assert.rejects(pending, (error: unknown) => {
269+
assert.ok(error instanceof AppError);
270+
assert.equal(error.details?.reason, WAIT_REASONS.captureStalled);
271+
assert.equal(error.details?.captureStalled, true);
272+
assert.equal(error.details?.readableCaptures, 0);
273+
return true;
274+
});
275+
276+
await vi.advanceTimersByTimeAsync(20);
277+
await failure;
278+
assert.equal(captureSnapshot.mock.calls.length, 1);
279+
} finally {
280+
vi.useRealTimers();
281+
}
282+
});
283+
284+
test('strict wait absent keeps target-present evidence when a steady-state capture is cancelled', async () => {
285+
vi.useFakeTimers();
286+
try {
287+
const { device, captureSnapshot } = cancelledAbsentDevice({
288+
firstSnapshot: snapshot('Removed'),
289+
cancellation: new AppError('COMMAND_FAILED', 'capture cancelled'),
290+
});
291+
const pending = waitAbsent(device, 20);
292+
const failure = assert.rejects(pending, (error: unknown) => {
293+
assert.ok(error instanceof AppError);
294+
assert.equal(error.details?.reason, WAIT_REASONS.targetPresent);
295+
assert.equal(error.details?.readableCaptures, 1);
296+
assert.equal(error.details?.readinessPhase, undefined);
297+
return true;
298+
});
299+
300+
await vi.advanceTimersByTimeAsync(20);
301+
await failure;
302+
assert.equal(captureSnapshot.mock.calls.length, 2);
303+
} finally {
304+
vi.useRealTimers();
305+
}
306+
});
307+
308+
test('strict wait absent preserves runner-restart exhaustion over an earlier present capture', async () => {
309+
vi.useFakeTimers();
310+
try {
311+
const { device, captureSnapshot } = cancelledAbsentDevice({
312+
firstSnapshot: snapshot('Removed'),
313+
cancellation: new AppError('COMMAND_FAILED', 'request cancelled', {
314+
runnerRestarted: true,
315+
runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send',
316+
}),
317+
});
318+
const pending = waitAbsent(device, 20);
319+
const failure = assert.rejects(pending, (error: unknown) => {
320+
assert.ok(error instanceof AppError);
321+
assert.equal(error.details?.reason, WAIT_REASONS.runnerRestartExhausted);
322+
assert.equal(error.details?.runnerRestarted, true);
323+
assert.equal(error.details?.readableCaptures, 1);
324+
assert.equal(error.details?.captureStalled, undefined);
325+
return true;
326+
});
327+
328+
await vi.advanceTimersByTimeAsync(20);
329+
await failure;
330+
assert.equal(captureSnapshot.mock.calls.length, 2);
331+
} finally {
332+
vi.useRealTimers();
333+
}
334+
});
335+
336+
test('strict wait absent keeps runner-restart exhaustion as the deadline reason', async () => {
337+
vi.useFakeTimers();
338+
try {
339+
const { device, captureSnapshot } = cancelledAbsentDevice({
340+
cancellation: new AppError('COMMAND_FAILED', 'request cancelled', {
341+
runnerRestarted: true,
342+
runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send',
343+
}),
344+
});
345+
const pending = waitAbsent(device, 20);
346+
const failure = assert.rejects(pending, (error: unknown) => {
347+
assert.ok(error instanceof AppError);
348+
assert.equal(error.details?.reason, WAIT_REASONS.runnerRestartExhausted);
349+
assert.equal(error.details?.readableCaptures, 0);
350+
return true;
351+
});
352+
353+
await vi.advanceTimersByTimeAsync(20);
354+
await failure;
355+
assert.equal(captureSnapshot.mock.calls.length, 1);
356+
} finally {
357+
vi.useRealTimers();
358+
}
359+
});
360+
361+
test('strict wait absent preserves readiness exhaustion over an earlier present capture', async () => {
362+
vi.useFakeTimers();
363+
try {
364+
const { device, captureSnapshot } = cancelledAbsentDevice({
365+
firstSnapshot: snapshot('Removed'),
366+
cancellation: new AppError('COMMAND_FAILED', 'request cancelled', {
367+
readinessPhase: 'target-discovery',
368+
}),
369+
});
370+
const pending = waitAbsent(device, 20);
371+
const failure = assert.rejects(pending, (error: unknown) => {
372+
assert.ok(error instanceof AppError);
373+
assert.equal(error.details?.reason, WAIT_REASONS.readinessExhausted);
374+
assert.equal(error.details?.readinessPhase, 'target-discovery');
375+
assert.equal(error.details?.readableCaptures, 1);
376+
return true;
377+
});
378+
379+
await vi.advanceTimersByTimeAsync(20);
380+
await failure;
381+
assert.equal(captureSnapshot.mock.calls.length, 2);
382+
} finally {
383+
vi.useRealTimers();
384+
}
385+
});

0 commit comments

Comments
 (0)