Skip to content

Commit 711920f

Browse files
committed
fix: preserve wait absent restart diagnostics
1 parent 0631e84 commit 711920f

16 files changed

Lines changed: 408 additions & 315 deletions

File tree

packages/maestro/src/internal/__tests__/export-label-projection.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,20 @@ test('keeps compound selectors that include label as hard export errors', () =>
4141
});
4242

4343
test('does not export strict wait absent as Maestro notVisible', () => {
44-
expect(() =>
44+
let thrown: unknown;
45+
try {
4546
exportReplayActionsToMaestro([action('wait', ['absent', 'label="Removed"', '1000'])], {
4647
resolveSelector: (expression) =>
4748
projectSelectorExpression(expression, MAESTRO_SELECTOR_PROJECTION),
48-
}),
49-
).toThrow(/wait absent.*unsupported|strict.*absence|notVisible/i);
49+
});
50+
} catch (error) {
51+
thrown = error;
52+
}
53+
54+
expect(thrown).toBeInstanceOf(AppError);
55+
if (!(thrown instanceof AppError)) return;
56+
expect(thrown.message).toMatch(/unsupported|strict absence/i);
57+
expect(thrown.message).not.toMatch(/notVisible/i);
5058
});
5159

5260
function action(command: string, positionals: string[]): SessionAction {

src/__tests__/command-doc-coverage.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ describe('command reference doc coverage', () => {
184184
);
185185
});
186186

187+
test('commands.md publishes the wait absence unreadable-observation reason', () => {
188+
assert.match(
189+
markdown,
190+
/`?predicate_failed`? means strict `wait absent` could not prove absence because no valid capture arrived/,
191+
);
192+
});
193+
187194
test('no stale waivers', () => {
188195
assert.deepEqual(
189196
findStaleUndocumentedWaivers(

src/cli-schema/cli-help-topics.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ test('usageForCommand resolves workflow help topic', async () => {
185185
);
186186
assert.match(help, /open -> snapshot -i -> settle -> verify -> close loop/);
187187
assert.match(help, /type never takes --settle/);
188+
assert.match(help, /snapshot\/get\/is\/find answer read-only questions/);
189+
assert.match(help, /--settle confirms local UI quieted/);
190+
assert.match(help, /wait text "Expected result" or wait <selector> instead of polling/);
191+
assert.match(help, /strict disappearance uses wait absent <selector>/);
188192
assert.match(
189193
help,
190194
/Chain confident consecutive steps with &&: press 'label="Search"' --settle && fill 'label="Search"' "query" --settle/,
@@ -235,6 +239,10 @@ test('usageForCommand resolves workflow help topic', async () => {
235239
assert.match(help, /Wait failure contract:/);
236240
assert.match(help, /wait_target_absent: a readable capture ran and found no match/);
237241
assert.match(help, /wait_target_present: wait absent timed out with matches/);
242+
assert.match(
243+
help,
244+
/predicate_failed: wait absent had no valid capture; final observation\/diagnostic is preserved/,
245+
);
238246
assert.match(help, /wait_capture_stalled: no readable capture finished before the deadline/);
239247
assert.match(help, /wait_deadline_exceeded: a later capture used the remaining budget/);
240248
assert.match(help, /wait_landmark_identity_mismatch: a replay destination guard/);

src/cli-schema/cli-help.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@ const EXAMPLE_LINES = [
5454
] as const;
5555

5656
const WAIT_FAILURE_CONTRACT = `Wait failure contract:
57-
Read the verdict from error.details.reason in --json, not the message text.
57+
Read error.details.reason in --json, not the message text.
5858
wait_target_absent: a readable capture ran and found no match.
5959
wait_target_present: wait absent timed out with matches; details include matches and firstMatch.
60+
predicate_failed: wait absent had no valid capture; final observation/diagnostic is preserved.
6061
wait_capture_stalled: no readable capture finished before the deadline -- retriable.
6162
wait_deadline_exceeded: a later capture used the remaining budget after an earlier readable one.
6263
wait_landmark_identity_mismatch: a replay destination guard found the selector but not the recorded identity.
@@ -129,10 +130,10 @@ Focused compatibility request: ${MAESTRO_COMPATIBILITY_ISSUE_URL}`,
129130
summary: 'Normal agent-device bootstrap, exploration, and validation loop',
130131
body: `agent-device help workflow
131132
132-
Command shapes, refs, selectors, waits, recovery, and platform limits for the default open -> snapshot -i -> settle -> verify -> close loop.
133+
Command shapes, refs, selectors, waits, recovery, and platform limits for open -> snapshot -i -> settle -> verify -> close loop.
133134
134135
Command shape:
135-
Command lines only -- no prose, numbering, fences, pipes, or grep/head/tail/jq on agent-device output; raw output carries the refs/hints the next step needs. Subcommand first, then positionals, then flags: agent-device open com.example.app --session checkout --platform android --relaunch
136+
Command lines only -- no prose, numbering, fences, pipes, or grep/head/tail/jq; raw output carries refs/hints for the next step. Order: subcommand, positionals, flags: agent-device open com.example.app --session checkout --platform android --relaunch
136137
Chain confident consecutive steps with &&: press 'label="Search"' --settle && fill 'label="Search"' "query" --settle. Fall back to one command at a time when a step is uncertain (ambiguous match, network-backed result, unseen screen).
137138
Refs look like @e12; use the exact ref from the latest snapshot -i, never a placeholder (@ref, @eN, @Label_Name). Pin with ~s<n> (press @e12~s4); iOS rejects a stale pinned ref -- refresh with snapshot -i or use a selector.
138139
close = agent-device close. App back is back; system back is back --system. Taps are press/click. type never takes --settle: run type, then diff snapshot to verify. Known flow: batch --steps-file ./steps.json (help batch).
@@ -145,11 +146,11 @@ Bootstrap:
145146
Apple CI: prepare ios-runner after boot/install, before replay/test (help prepare). Remote/cloud: connect -> open -> commands -> close -> disconnect (help remote). Reusable scripts, secret-safe fills, replay repair: help scripting.
146147
147148
Snapshots and refs:
148-
snapshot reads visible state; snapshot -i gets current interactive refs only -- the fast path before an interaction. Default text is agent-facing and token-efficient; --raw/--json only for the full provider tree.
149+
snapshot reads visible state; snapshot -i gets current interactive refs only -- fast path before interaction. Default text is token-efficient; --raw/--json for full provider tree.
149150
Legend: @e12 [button] label="Add to cart" enabled hittable -> press @e12. [off-screen below] -> scroll down (a hint, not a ref).
150151
Refs stay valid until you press/click/fill/type/scroll/back/wait-for-async-UI, or otherwise change app state; open/--relaunch clears the stored snapshot outright.
151-
Prefer --settle and continue from its settled diff when it shows the next target; refresh with snapshot -i only when you did not settle, it reported not settled, or its output lacks what you need. A known selector/label after a mutation is often enough, since interaction commands refresh state internally.
152-
Truncated preview: snapshot -s @e12 (the current concrete ref), not get text. Missing target in a list: scroll down/up (not bottom/top unless the task wants the edge), then snapshot -i. TV/D-pad focus: help tv.
152+
Prefer --settle and its diff when it shows next target; refresh with snapshot -i only when you did not settle, it reported not settled, or output lacks what you need. A known selector/label after a mutation is often enough, since interaction commands refresh state internally.
153+
Truncated preview: snapshot -s @e12 (the current concrete ref), not get text. Missing list target: scroll down/up then snapshot -i. TV/D-pad focus: help tv.
153154
154155
Selectors:
155156
id="field-email", label="Allow", role=button label="Search" -- not bare role keys (button="Search"); no CSS selectors/--selector/--text/raw x-y when refs/selectors exist.
@@ -168,7 +169,7 @@ Session ordering:
168169
169170
Read-only and waits:
170171
${WAIT_FAILURE_CONTRACT}
171-
Use wait text/selector for delayed results; use wait absent <selector> for strict disappearance.
172+
snapshot/get/is/find answer read-only questions; snapshot -i is for refs. --settle confirms local UI quieted; delayed results use wait text "Expected result" or wait <selector> instead of polling; strict disappearance uses wait absent <selector>.
172173
wait stable [quietMs] [timeoutMs] (defaults 500/10000) is the fallback for open/relaunch/navigation, or an intentionally-unsettled mutation -- not after a --settle whose diff already shows the change. Ambiguous find: add --first or --last.
173174
174175
Navigation:

src/commands/interaction/runtime/selector-read.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,8 @@ test('runtime selector convenience methods use explicit target helpers', async (
351351
// mid-transition Android helper content verdicts) instead of aborting the
352352
// wait — the live-validated destination-guard gap from #1349's PR review.
353353
// (#1349's own in-loop landmark identity verification tests — the
354-
// `target.recordedLandmark` cases — moved to `selector-wait.test.ts`, the
355-
// 1:1 topology location for `selector-wait.ts`; #1478 P5 step 2 cell 7.)
354+
// `target.recordedLandmark` cases — moved to `wait-selector.test.ts`, the
355+
// 1:1 topology location for `wait-selector.ts`; #1478 P5 step 2 cell 7.)
356356
// ---------------------------------------------------------------------------
357357

358358
function landmarkScreen(parentLabel: string) {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ export async function waitForAbsent<Runtime extends SelectorWaitRuntime>(
8787
await polling.sleepUntilNextPoll();
8888
}
8989

90+
// A runner restart is the authoritative deadline cause even when an earlier
91+
// readable poll saw the target. Returning stale target-present evidence would
92+
// hide the retriable restart and make callers stop retrying the wrong reason.
93+
if (deadline === 'runner-restart-exhausted') {
94+
throw waitTimeoutError(
95+
`wait absent timed out for selector: ${selectorExpression}`,
96+
polling,
97+
deadline,
98+
);
99+
}
90100
if (present) throw waitTargetPresentError(selectorExpression, present, polling.failureEvidence());
91101
throw waitTimeoutError(
92102
`wait absent timed out for selector: ${selectorExpression}`,

src/commands/interaction/runtime/selector-wait.test.ts renamed to src/commands/interaction/runtime/wait-selector.test.ts

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@ import {
88
localCommandPolicy,
99
} from '../../../runtime.ts';
1010
import { makeSnapshotState } from '../../../__tests__/test-utils/snapshot-builders.ts';
11-
import {
12-
createFakeClock,
13-
createSelectorDevice,
14-
selectorReadSnapshot,
15-
} from './__tests__/test-utils/index.ts';
11+
import { createFakeClock } from './__tests__/test-utils/index.ts';
1612
import { computeTargetEvidence } from '../../../daemon/session-target-evidence.ts';
1713
import { WAIT_LANDMARK_MISMATCH_REASON } from '@agent-device/contracts/replay';
1814
import { AppError } from '@agent-device/kernel/errors';
@@ -52,36 +48,9 @@ test('runtime focused selector waits against a full snapshot', async () => {
5248
assert.equal(captureOptions?.interactiveOnly, false);
5349
});
5450

55-
// A text wait has exactly one source of truth: the polled capture. The backend `findText` seam
56-
// that short-circuited it on Apple was wait's second platform-execution path and retired with
57-
// wait's ADR 0019 cutover, so the tree answer is the only answer — in both directions.
58-
test('runtime wait resolves text from the polled snapshot', async () => {
59-
const device = createSelectorDevice(selectorReadSnapshot(), { now: 10 });
60-
61-
const result = await device.selectors.wait({
62-
session: 'default',
63-
target: { kind: 'text', text: 'Continue', timeoutMs: 100 },
64-
});
65-
66-
assert.deepEqual(result, { kind: 'text', text: 'Continue', waitedMs: 0 });
67-
});
68-
69-
test('runtime wait times out on text the polled snapshot does not carry', async () => {
70-
const device = createSelectorDevice(selectorReadSnapshot(), { clock: createFakeClock() });
71-
72-
await assert.rejects(
73-
async () =>
74-
await device.selectors.wait({
75-
session: 'default',
76-
target: { kind: 'text', text: 'Ready', timeoutMs: 100 },
77-
}),
78-
(error: Error) => error.message.includes('wait timed out for text: Ready'),
79-
);
80-
});
81-
8251
// ---------------------------------------------------------------------------
8352
// #1349 (relocated from `selector-read.test.ts` — this is the 1:1 topology
84-
// location for `selector-wait.ts`, and #1478 P5 step 2 cell 7's pin):
53+
// location for `wait-selector.ts`, and #1478 P5 step 2 cell 7's pin):
8554
// wait's in-loop landmark identity verification, threaded as
8655
// `target.recordedLandmark`. Polling semantics are preserved — a
8756
// same-selector impostor never aborts the wait; only the deadline turns

src/commands/interaction/runtime/selector-wait-stable.test.ts renamed to src/commands/interaction/runtime/wait-stable.test.ts

File renamed without changes.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import {
4+
createFakeClock,
5+
createSelectorDevice,
6+
selectorReadSnapshot,
7+
} from './__tests__/test-utils/index.ts';
8+
9+
// A text wait has exactly one source of truth: the polled capture. The backend `findText` seam
10+
// that short-circuited it on Apple was wait's second platform-execution path and retired with
11+
// wait's ADR 0019 cutover, so the tree answer is the only answer — in both directions.
12+
test('runtime wait resolves text from the polled snapshot', async () => {
13+
const device = createSelectorDevice(selectorReadSnapshot(), { now: 10 });
14+
15+
const result = await device.selectors.wait({
16+
session: 'default',
17+
target: { kind: 'text', text: 'Continue', timeoutMs: 100 },
18+
});
19+
20+
assert.deepEqual(result, { kind: 'text', text: 'Continue', waitedMs: 0 });
21+
});
22+
23+
test('runtime wait times out on text the polled snapshot does not carry', async () => {
24+
const device = createSelectorDevice(selectorReadSnapshot(), { clock: createFakeClock() });
25+
26+
await assert.rejects(
27+
async () =>
28+
await device.selectors.wait({
29+
session: 'default',
30+
target: { kind: 'text', text: 'Ready', timeoutMs: 100 },
31+
}),
32+
(error: Error) => error.message.includes('wait timed out for text: Ready'),
33+
);
34+
});

src/daemon/__tests__/system-surface-disclosure.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { test, expect, vi, beforeEach } from 'vitest';
22
import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts';
33
import { handleFindCommands } from '../interaction/index.ts';
44
import { getRuntimeBindings } from './interaction-get-runtime-fixture.ts';
5-
import { dispatchFindReadOnlyViaRuntime, dispatchWaitViaRuntime } from '../selector-runtime.ts';
5+
import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts';
6+
import { dispatchWaitViaRuntime } from '../wait-runtime.ts';
67
import type { DaemonRequest, DaemonResponse } from '../types.ts';
78
import { ANDROID_SYSTEM_SURFACE_DISCLOSURE } from '../../core/android-system-surface-disclosure.ts';
89
import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts';

0 commit comments

Comments
 (0)