Skip to content

Commit a4f625c

Browse files
authored
feat: add strict wait absent polling (#2236) (#2264)
* feat: add strict wait absent polling * fix: keep wait absent coverage gates green * fix: preserve wait absent restart diagnostics
1 parent ed76c9c commit a4f625c

50 files changed

Lines changed: 1747 additions & 764 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Added strict `wait absent <selector> [timeoutMs]` polling for zero selector matches. Incomplete,
6+
sparse, truncated, scoped, depth-limited, and Android unreadable captures cannot prove absence;
7+
deadline diagnostics retain typed capture evidence and stable first-match details (#2236).
58
- Fixed: `settings airplane on|off` now takes an Android device offline. It is applied through
69
the connectivity service (`cmd connectivity airplane-mode`), which drives the radios, instead of
710
writing `airplane_mode_on` and broadcasting `ACTION_AIRPLANE_MODE_CHANGED` — a broadcast Android

‎docs/adr/0012-interactive-replay.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,10 @@ A recorded `id` never matches a node without that id.
288288
> `is exists` (existence assertion with no unique winner; wait-like semantics without the
289289
> guard-critical role), `is absent` (a strict one-capture absence observation has no resolved
290290
> winner; it records as an ordinary observation and its `predicate_failed` failure is always an
291-
> action-failure, never an identity mismatch), every read-only `find` variant (fuzzy-locator resolution has no
291+
> action-failure, never an identity mismatch), `wait absent` (strict zero-candidate polling has no
292+
> resolved winner; its no-match success carries no `target-v1` or landmark annotation, and a
293+
> `wait_target_present` deadline is an ordinary action-failure, never an identity mismatch or an
294+
> ADR 0016 destination guard), every read-only `find` variant (fuzzy-locator resolution has no
292295
> selector-chain identity token for the classifier, and publication already refuses mutating `find`
293296
> as non-verifiable), and `wait text`/`wait stable`/duration waits/`wait @ref` (no element target, or
294297
> a session-local ref that ADR 0016 already refuses to publish; `wait @ref` is rejected rather than

‎packages/ad-replay/src/internal/__tests__/target-verification.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ test('planPostResolutionTargetVerification: a non-selector wait form is inert (s
4646
);
4747
});
4848

49+
test('planPostResolutionTargetVerification: wait absent is not a landmark or identity guard', () => {
50+
assert.deepEqual(
51+
planPostResolutionTargetVerification({
52+
recorded: recorded(),
53+
isSelectorWait: false,
54+
}),
55+
{ kind: 'skip' },
56+
);
57+
});
58+
4959
test('planPostResolutionTargetVerification: a selector wait with a recorded-unverifiable annotation refuses up front', () => {
5060
assert.deepEqual(
5161
planPostResolutionTargetVerification({

‎packages/contracts/src/client-system.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type WaitCommandTarget =
1010
text?: never;
1111
ref?: never;
1212
selector?: never;
13+
absent?: never;
1314
stable?: never;
1415
quietMs?: never;
1516
timeoutMs?: never;
@@ -19,6 +20,7 @@ export type WaitCommandTarget =
1920
durationMs?: never;
2021
ref?: never;
2122
selector?: never;
23+
absent?: never;
2224
stable?: never;
2325
quietMs?: never;
2426
timeoutMs?: number;
@@ -28,6 +30,7 @@ export type WaitCommandTarget =
2830
durationMs?: never;
2931
text?: never;
3032
selector?: never;
33+
absent?: never;
3134
stable?: never;
3235
quietMs?: never;
3336
timeoutMs?: number;
@@ -37,6 +40,17 @@ export type WaitCommandTarget =
3740
durationMs?: never;
3841
text?: never;
3942
ref?: never;
43+
absent?: never;
44+
stable?: never;
45+
quietMs?: never;
46+
timeoutMs?: number;
47+
})
48+
| (SelectorSnapshotCommandOptions & {
49+
absent: string;
50+
durationMs?: never;
51+
text?: never;
52+
ref?: never;
53+
selector?: never;
4054
stable?: never;
4155
quietMs?: never;
4256
timeoutMs?: number;
@@ -47,6 +61,7 @@ export type WaitCommandTarget =
4761
text?: never;
4862
ref?: never;
4963
selector?: never;
64+
absent?: never;
5065
quietMs?: number;
5166
timeoutMs?: number;
5267
});

‎packages/contracts/src/wait-runtime-plan.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** The normalized wait target, independent of the positional grammar that produced it. */
2-
export type WaitRuntimeTarget = 'sleep' | 'text' | 'ref' | 'selector' | 'stable';
2+
export type WaitRuntimeTarget = 'sleep' | 'text' | 'ref' | 'selector' | 'absent' | 'stable';
33

44
/**
55
* Which wait shapes reach a device at all. A duration wait observes nothing, so it never asks

‎packages/contracts/src/wait.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66
* `wait_capture_stalled` means no readable capture established an observation
77
* before the deadline and is retriable. `wait_deadline_exceeded` means a later
88
* capture consumed the remaining budget after at least one readable capture.
9-
* `wait_target_absent` is the only ordinary absence verdict and therefore
10-
* always carries readable-capture evidence. The remaining reasons describe
9+
* `wait_target_absent` is the ordinary timeout reason for a selector that was
10+
* never found. `wait_target_present` is the strict-absence timeout reason when
11+
* valid captures still contain matches. The remaining reasons describe
1112
* stability and replay-landmark refusals.
1213
*/
1314
export const WAIT_REASONS = {
1415
captureStalled: 'wait_capture_stalled',
1516
deadlineExceeded: 'wait_deadline_exceeded',
1617
runnerRestartExhausted: 'wait_runner_restart_exhausted',
1718
targetAbsent: 'wait_target_absent',
19+
targetPresent: 'wait_target_present',
1820
stableTimeout: 'wait_stable_timeout',
1921
landmarkIdentityMismatch: 'wait_landmark_identity_mismatch',
2022
} as const;

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ test('keeps compound selectors that include label as hard export errors', () =>
4040
).toThrow(AppError);
4141
});
4242

43+
test('does not export strict wait absent as Maestro notVisible', () => {
44+
let thrown: unknown;
45+
try {
46+
exportReplayActionsToMaestro([action('wait', ['absent', 'label="Removed"', '1000'])], {
47+
resolveSelector: (expression) =>
48+
projectSelectorExpression(expression, MAESTRO_SELECTOR_PROJECTION),
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);
58+
});
59+
4360
function action(command: string, positionals: string[]): SessionAction {
4461
return { ts: 0, command, positionals, flags: {} };
4562
}

‎packages/maestro/src/internal/export-flow.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,12 @@ function convertWaitAction(
322322
],
323323
};
324324
}
325+
if (first === 'absent') {
326+
return {
327+
kind: 'unsupported',
328+
message: 'strict wait absent requires zero selector matches and is unsupported by Maestro',
329+
};
330+
}
325331
if (first === 'text' && second) {
326332
return {
327333
kind: 'commands',

‎scripts/__tests__/eager-closure-budgets.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,11 @@ export const HUB_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
411411
// #2148 moves output-only CLI dependencies behind call-time imports and reduces the entry
412412
// closure by two modules.
413413
// #2146 splits one eagerly reached URL utility into its client and Metro owners.
414-
'src/cli.ts': 380,
414+
// #2236 adds the typed pre-admission --scope/--depth refusal for `wait absent` to the existing
415+
// wait command reader. That deliberately keeps the shared absence option contract and error
416+
// modules on the CLI path; the measured two-module growth is the contract being loaded, not
417+
// implementation or platform machinery being pulled in eagerly.
418+
'src/cli.ts': 382,
415419
'src/platform-runtime.ts': 47,
416420
'src/core/command-descriptor/registry.ts': 72,
417421
'src/core/command-descriptor/platform-execution-entry.ts': 3,

‎src/__tests__/client-wait.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { createAgentDeviceClient } from '../agent-device-client.ts';
4+
import { createTransport } from './client-transport-fixture.ts';
5+
6+
test('client.command.wait round-trips strict wait absent positionals', async () => {
7+
const setup = createTransport(async () => ({ ok: true, data: { waitedMs: 0 } }));
8+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
9+
10+
await client.command.wait({ absent: 'label="Removed"', timeoutMs: 2500 });
11+
12+
assert.equal(setup.calls[0]?.command, 'wait');
13+
assert.deepEqual(setup.calls[0]?.positionals, ['absent', 'label="Removed"', '2500']);
14+
15+
await assert.rejects(
16+
async () => await client.command.wait({ absent: 'label="Removed"', depth: 2 }),
17+
/wait absent does not support --depth/,
18+
);
19+
await assert.rejects(
20+
async () => await client.command.wait({ absent: 'label="Removed"', scope: 'Root' }),
21+
/wait absent does not support --scope/,
22+
);
23+
});

0 commit comments

Comments
 (0)