Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Added strict `wait absent <selector> [timeoutMs]` polling for zero selector matches. Incomplete,
sparse, truncated, scoped, depth-limited, and Android unreadable captures cannot prove absence;
deadline diagnostics retain typed capture evidence and stable first-match details (#2236).
- Fixed: `settings airplane on|off` now takes an Android device offline. It is applied through
the connectivity service (`cmd connectivity airplane-mode`), which drives the radios, instead of
writing `airplane_mode_on` and broadcasting `ACTION_AIRPLANE_MODE_CHANGED` — a broadcast Android
Expand Down
5 changes: 4 additions & 1 deletion docs/adr/0012-interactive-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,10 @@ A recorded `id` never matches a node without that id.
> `is exists` (existence assertion with no unique winner; wait-like semantics without the
> guard-critical role), `is absent` (a strict one-capture absence observation has no resolved
> winner; it records as an ordinary observation and its `predicate_failed` failure is always an
> action-failure, never an identity mismatch), every read-only `find` variant (fuzzy-locator resolution has no
> action-failure, never an identity mismatch), `wait absent` (strict zero-candidate polling has no
> resolved winner; its no-match success carries no `target-v1` or landmark annotation, and a
> `wait_target_present` deadline is an ordinary action-failure, never an identity mismatch or an
> ADR 0016 destination guard), every read-only `find` variant (fuzzy-locator resolution has no
> selector-chain identity token for the classifier, and publication already refuses mutating `find`
> as non-verifiable), and `wait text`/`wait stable`/duration waits/`wait @ref` (no element target, or
> a session-local ref that ADR 0016 already refuses to publish; `wait @ref` is rejected rather than
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ test('planPostResolutionTargetVerification: a non-selector wait form is inert (s
);
});

test('planPostResolutionTargetVerification: wait absent is not a landmark or identity guard', () => {
assert.deepEqual(
planPostResolutionTargetVerification({
recorded: recorded(),
isSelectorWait: false,
}),
{ kind: 'skip' },
);
});

test('planPostResolutionTargetVerification: a selector wait with a recorded-unverifiable annotation refuses up front', () => {
assert.deepEqual(
planPostResolutionTargetVerification({
Expand Down
15 changes: 15 additions & 0 deletions packages/contracts/src/client-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type WaitCommandTarget =
text?: never;
ref?: never;
selector?: never;
absent?: never;
stable?: never;
quietMs?: never;
timeoutMs?: never;
Expand All @@ -19,6 +20,7 @@ export type WaitCommandTarget =
durationMs?: never;
ref?: never;
selector?: never;
absent?: never;
stable?: never;
quietMs?: never;
timeoutMs?: number;
Expand All @@ -28,6 +30,7 @@ export type WaitCommandTarget =
durationMs?: never;
text?: never;
selector?: never;
absent?: never;
stable?: never;
quietMs?: never;
timeoutMs?: number;
Expand All @@ -37,6 +40,17 @@ export type WaitCommandTarget =
durationMs?: never;
text?: never;
ref?: never;
absent?: never;
stable?: never;
quietMs?: never;
timeoutMs?: number;
})
| (SelectorSnapshotCommandOptions & {
absent: string;
durationMs?: never;
text?: never;
ref?: never;
selector?: never;
stable?: never;
quietMs?: never;
timeoutMs?: number;
Expand All @@ -47,6 +61,7 @@ export type WaitCommandTarget =
text?: never;
ref?: never;
selector?: never;
absent?: never;
quietMs?: number;
timeoutMs?: number;
});
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/wait-runtime-plan.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** The normalized wait target, independent of the positional grammar that produced it. */
export type WaitRuntimeTarget = 'sleep' | 'text' | 'ref' | 'selector' | 'stable';
export type WaitRuntimeTarget = 'sleep' | 'text' | 'ref' | 'selector' | 'absent' | 'stable';

/**
* Which wait shapes reach a device at all. A duration wait observes nothing, so it never asks
Expand Down
6 changes: 4 additions & 2 deletions packages/contracts/src/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
* `wait_capture_stalled` means no readable capture established an observation
* before the deadline and is retriable. `wait_deadline_exceeded` means a later
* capture consumed the remaining budget after at least one readable capture.
* `wait_target_absent` is the only ordinary absence verdict and therefore
* always carries readable-capture evidence. The remaining reasons describe
* `wait_target_absent` is the ordinary timeout reason for a selector that was
* never found. `wait_target_present` is the strict-absence timeout reason when
* valid captures still contain matches. The remaining reasons describe
* stability and replay-landmark refusals.
*/
export const WAIT_REASONS = {
captureStalled: 'wait_capture_stalled',
deadlineExceeded: 'wait_deadline_exceeded',
runnerRestartExhausted: 'wait_runner_restart_exhausted',
targetAbsent: 'wait_target_absent',
targetPresent: 'wait_target_present',
stableTimeout: 'wait_stable_timeout',
landmarkIdentityMismatch: 'wait_landmark_identity_mismatch',
} as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ test('keeps compound selectors that include label as hard export errors', () =>
).toThrow(AppError);
});

test('does not export strict wait absent as Maestro notVisible', () => {
let thrown: unknown;
try {
exportReplayActionsToMaestro([action('wait', ['absent', 'label="Removed"', '1000'])], {
resolveSelector: (expression) =>
projectSelectorExpression(expression, MAESTRO_SELECTOR_PROJECTION),
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(AppError);
if (!(thrown instanceof AppError)) return;
expect(thrown.message).toMatch(/unsupported|strict absence/i);
expect(thrown.message).not.toMatch(/notVisible/i);
});

function action(command: string, positionals: string[]): SessionAction {
return { ts: 0, command, positionals, flags: {} };
}
6 changes: 6 additions & 0 deletions packages/maestro/src/internal/export-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ function convertWaitAction(
],
};
}
if (first === 'absent') {
return {
kind: 'unsupported',
message: 'strict wait absent requires zero selector matches and is unsupported by Maestro',
};
}
if (first === 'text' && second) {
return {
kind: 'commands',
Expand Down
6 changes: 5 additions & 1 deletion scripts/__tests__/eager-closure-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,11 @@ export const HUB_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
// #2148 moves output-only CLI dependencies behind call-time imports and reduces the entry
// closure by two modules.
// #2146 splits one eagerly reached URL utility into its client and Metro owners.
'src/cli.ts': 380,
// #2236 adds the typed pre-admission --scope/--depth refusal for `wait absent` to the existing
// wait command reader. That deliberately keeps the shared absence option contract and error
// modules on the CLI path; the measured two-module growth is the contract being loaded, not
// implementation or platform machinery being pulled in eagerly.
'src/cli.ts': 382,
'src/platform-runtime.ts': 47,
'src/core/command-descriptor/registry.ts': 72,
'src/core/command-descriptor/platform-execution-entry.ts': 3,
Expand Down
23 changes: 23 additions & 0 deletions src/__tests__/client-wait.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { createAgentDeviceClient } from '../agent-device-client.ts';
import { createTransport } from './client-transport-fixture.ts';

test('client.command.wait round-trips strict wait absent positionals', async () => {
const setup = createTransport(async () => ({ ok: true, data: { waitedMs: 0 } }));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

await client.command.wait({ absent: 'label="Removed"', timeoutMs: 2500 });

assert.equal(setup.calls[0]?.command, 'wait');
assert.deepEqual(setup.calls[0]?.positionals, ['absent', 'label="Removed"', '2500']);

await assert.rejects(
async () => await client.command.wait({ absent: 'label="Removed"', depth: 2 }),
/wait absent does not support --depth/,
);
await assert.rejects(
async () => await client.command.wait({ absent: 'label="Removed"', scope: 'Root' }),
/wait absent does not support --scope/,
);
});
7 changes: 7 additions & 0 deletions src/__tests__/command-doc-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ describe('command reference doc coverage', () => {
);
});

test('commands.md publishes the wait absence unreadable-observation reason', () => {
assert.match(
markdown,
/`?predicate_failed`? means strict `wait absent` could not prove absence because no valid capture arrived/,
);
});

test('no stale waivers', () => {
assert.deepEqual(
findStaleUndocumentedWaivers(
Expand Down
7 changes: 7 additions & 0 deletions src/cli-schema/cli-help-command-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ test('usageForCommand documents open --launch-args', async () => {
assert.match(help, /--launch-console artifacts\/launch-console\.log/);
});

test('usageForCommand documents strict wait absent', async () => {
const help = await usageForCommand('wait');
if (help === null) throw new Error('Expected wait help text');
assert.match(help, /absent <selector> \[timeoutMs\]/);
assert.match(help, /strictly absent|zero selector matches/);
});

test('usageForCommand documents screenshot web aliases and stabilization flags', async () => {
const help = await usageForCommand('screenshot');
if (help === null) throw new Error('Expected screenshot help text');
Expand Down
2 changes: 1 addition & 1 deletion src/cli-schema/cli-help-overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Loop:
scroll <direction|top|bottom> [amount] --settle; back --settle
acts, waits for quiet, and prints the UI diff. Continue from that diff.
Run snapshot -i only when the diff lacks the next target or did not settle.
Verify a named expectation with the diff, wait text "...", wait <selector>,
Verify a named expectation with the diff, wait text "...", wait <selector>, wait absent <selector>,
is, get, or find. A bare screenshot is not verification.
End with: agent-device close

Expand Down
12 changes: 12 additions & 0 deletions src/cli-schema/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ test('usageForCommand resolves workflow help topic', async () => {
);
assert.match(help, /open -> snapshot -i -> settle -> verify -> close loop/);
assert.match(help, /type never takes --settle/);
assert.match(help, /snapshot\/get\/is\/find answer read-only questions/);
assert.match(help, /--settle confirms local UI quieted/);
assert.match(help, /wait text "Expected result" or wait <selector> instead of polling/);
assert.match(help, /strict disappearance uses wait absent <selector>/);
assert.match(
help,
/Chain confident consecutive steps with &&: press 'label="Search"' --settle && fill 'label="Search"' "query" --settle/,
Expand Down Expand Up @@ -234,6 +238,11 @@ test('usageForCommand resolves workflow help topic', async () => {
assert.match(help, /run serially within one session/);
assert.match(help, /Wait failure contract:/);
assert.match(help, /wait_target_absent: a readable capture ran and found no match/);
assert.match(help, /wait_target_present: wait absent timed out with matches/);
assert.match(
help,
/predicate_failed: wait absent had no valid capture; final observation\/diagnostic is preserved/,
);
assert.match(help, /wait_capture_stalled: no readable capture finished before the deadline/);
assert.match(help, /wait_deadline_exceeded: a later capture used the remaining budget/);
assert.match(help, /wait_landmark_identity_mismatch: a replay destination guard/);
Expand Down Expand Up @@ -547,7 +556,10 @@ test('usageForCommand resolves manual QA help topic', async () => {
assert.match(help, /label="Email" editable=true/);
assert.match(help, /press 'label="Follow"' --settle/);
assert.match(help, /Do not use placeholders such as @ref/);
assert.match(help, /wait text\/selector\/absent/);
assert.match(help, /wait absent 'label="Loading\.\.\."' 3000/);
assert.match(help, /wait_target_absent: a readable capture ran and found no match/);
assert.match(help, /wait_target_present: wait absent timed out with matches/);
assert.match(help, /wait_capture_stalled: no readable capture finished before the deadline/);
});

Expand Down
Loading
Loading