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
22 changes: 22 additions & 0 deletions packages/contracts/src/wait.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
/**
* Machine-readable wait failure taxonomy. These values are carried in
* `error.details.reason`; callers should branch on them instead of parsing
* wait error messages.
*
* `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
* stability and replay-landmark refusals.
*/
export const WAIT_REASONS = {
captureStalled: 'wait_capture_stalled',
deadlineExceeded: 'wait_deadline_exceeded',
targetAbsent: 'wait_target_absent',
stableTimeout: 'wait_stable_timeout',
landmarkIdentityMismatch: 'wait_landmark_identity_mismatch',
} as const;

export type WaitReason = (typeof WAIT_REASONS)[keyof typeof WAIT_REASONS];

/**
* Public daemon result for `wait`. The runtime-local result carries a `kind`
* discriminant, but `toDaemonWaitData` intentionally projects the normal daemon
Expand Down
7 changes: 7 additions & 0 deletions src/cli/parser/__tests__/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ test('usageForCommand resolves workflow help topic', async () => {
assert.match(help, /published script contain only \$\{PASSWORD\}/);
assert.match(help, /Do not record passwords, tokens, or other secrets without --record-as/);
assert.match(help, /Read-only visible\/state question: use snapshot\/get\/is\/find/);
assert.match(help, /wait_target_absent means at least one readable capture/);
assert.match(help, /wait_capture_stalled means no readable capture/);
assert.match(help, /wait_deadline_exceeded means a later capture/);
assert.match(help, /wait_landmark_identity_mismatch means a replay destination guard/);
assert.match(help, /wait_stable_timeout means wait stable/);
assert.match(help, /Use snapshot -i only when refs are needed/);
assert.match(help, /install-from-source --github-actions-artifact org\/repo:app-debug/);
assert.match(help, /Discovery is not enough when the task asks to open\/start/);
Expand Down Expand Up @@ -528,6 +533,8 @@ test('usageForCommand resolves manual QA help topic', async () => {
assert.match(help, /use fill <target> <text> --settle to replace/);
assert.match(help, /use type only to append to an already-focused field/);
assert.match(help, /Do not use placeholders such as @ref/);
assert.match(help, /wait_target_absent means at least one readable capture/);
assert.match(help, /wait_capture_stalled means no readable capture/);
});

test('usageForCommand resolves validate help topic', async () => {
Expand Down
14 changes: 13 additions & 1 deletion src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ const EXAMPLE_LINES = [
'agent-device test ./suite --platform android',
] as const;

const WAIT_FAILURE_CONTRACT = `Wait failure contract:
Read wait failures from error.details.reason in --json output; do not infer the verdict from the message.
wait_target_absent means at least one readable capture saw no matching target. It includes readableCaptures and waitedMs, and may include currentSurface details.
wait_capture_stalled means no readable capture established an observation before the deadline. It is retriable; retry or use screenshot to inspect the surface.
wait_deadline_exceeded means a later capture consumed the remaining budget after an earlier readable capture; it includes captureTruncated and readableCaptures.
wait_landmark_identity_mismatch means a replay destination guard found the selector but not the recorded target identity.
wait_stable_timeout means wait stable did not observe a stable UI; it is not an element-absence verdict.
`;

const HELP_TOPICS = {
'manual-qa': {
summary: 'Follow manual test scripts with exact interactions and verification',
Expand Down Expand Up @@ -180,7 +189,9 @@ Recovery:
Network/typeahead result missing: wait text "Expected result" or wait <selector>.
Keyboard visible over the next target: the on-screen keyboard usually does not block presses, so press the target directly instead of dismissing. If the press fails or reports no visible effect, scroll the target into view or use keyboard enter when submission is wanted.
Sparse or recovered accessibility snapshot: use screenshot as visual truth, leave the bad screen if needed, then retry snapshot -i.
Non-hittable success hint: verify with the settled diff or snapshot; retarget by a better ref/selector if the UI did not change.`,
Non-hittable success hint: verify with the settled diff or snapshot; retarget by a better ref/selector if the UI did not change.

${WAIT_FAILURE_CONTRACT}`,
},
maestro: {
summary: 'Supported Maestro YAML commands, grammar, and runtime boundaries',
Expand Down Expand Up @@ -298,6 +309,7 @@ Session ordering:
It is fine to parallelize independent read-only collection or commands that use different sessions/devices.

Read-only and waits:
${WAIT_FAILURE_CONTRACT}
Read-only visible/state question: use snapshot/get/is/find.
agent-device snapshot
agent-device get text 'id="product-title"'
Expand Down
22 changes: 15 additions & 7 deletions src/commands/interaction/runtime/selector-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ test('runtime wait rethrows the capture verdict when the screen never became rea
);
});

test('runtime wait keeps the plain timeout when readable polls simply never matched', async () => {
test('runtime wait classifies readable no-match polls as target absent', async () => {
const empty = () => makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]);
const device = waitDeviceWithCaptures([
empty,
Expand All @@ -595,11 +595,18 @@ test('runtime wait keeps the plain timeout when readable polls simply never matc
session: 'default',
target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 1000 },
}),
/wait timed out for selector/,
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal(error.message, 'wait timed out for selector: label="Screen X"');
const details = (error as { details?: Record<string, unknown> }).details;
assert.equal(details?.reason, 'wait_target_absent');
assert.equal((details?.readableCaptures as number) > 0, true);
return true;
},
);
});

test('runtime wait reports a deadline-truncated final capture over an earlier unreadable verdict', async () => {
test('runtime wait reports a stalled final capture after earlier unreadable verdicts', async () => {
let captureCount = 0;
const initial = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Initial' }]);
const sessions = createMemorySessionStore([{ name: 'default', snapshot: initial }]);
Expand Down Expand Up @@ -642,10 +649,11 @@ test('runtime wait reports a deadline-truncated final capture over an earlier un
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal(error.message, 'wait timed out for selector: label="Screen X"');
assert.equal(
(error as { details?: Record<string, unknown> }).details?.reason,
'wait_deadline_exceeded',
);
const details = (error as { details?: Record<string, unknown> }).details;
assert.equal(details?.reason, 'wait_capture_stalled');
assert.equal(details?.retriable, true);
assert.equal(details?.readableCaptures, 0);
assert.equal(typeof details?.waitedMs, 'number');
return true;
},
);
Expand Down
16 changes: 2 additions & 14 deletions src/commands/interaction/runtime/selector-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,7 @@ import {
} from './selector-read-shared.ts';
import { findSnapshotScope, sparseSelectorSnapshotError } from './selector-read-utils.ts';
import { deriveSelectorCapturePolicy } from './selector-capture-policy.ts';
import {
createWaitPolling,
type WaitPollDeadline,
waitCaptureStalledError,
waitDeadlineExceededError,
} from './wait-polling.ts';
import { createWaitPolling, type WaitPollDeadline, waitTimeoutError } from './wait-polling.ts';
import {
createSelectorWaitCommands,
type WaitCommandOptions,
Expand Down Expand Up @@ -413,14 +408,7 @@ async function waitForFindMatch(
}
await polling.sleepUntilNextPoll();
}
if (deadline === 'capture-stalled') {
throw waitCaptureStalledError('find wait timed out', polling.timeoutMs);
}
if (deadline === 'capture-truncated') {
throw waitDeadlineExceededError('find wait timed out', polling.timeoutMs, true);
}
polling.rethrowIfNeverReadable();
throw waitDeadlineExceededError('find wait timed out', polling.timeoutMs, false);
throw waitTimeoutError('find wait timed out', polling, deadline);
}

async function findFirstLocatorMatch(
Expand Down
22 changes: 21 additions & 1 deletion src/commands/interaction/runtime/selector-wait.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,27 @@ test('runtime wait with a recorded landmark keeps the plain timeout when the sel
(thrown: unknown) => {
assert.ok(thrown instanceof AppError);
assert.match(thrown.message, /wait timed out for selector/);
assert.equal(thrown.details?.reason, undefined);
assert.equal(thrown.details?.reason, 'wait_target_absent');
assert.equal((thrown.details?.readableCaptures as number) > 0, true);
assert.equal(typeof thrown.details?.waitedMs, 'number');
return true;
},
);
});

test('runtime wait with no capture evidence never reports target absence', async () => {
const device = landmarkWaitDevice([landmarkScreen('Detail Screen')]);

await assert.rejects(
device.selectors.wait({
session: 'default',
target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 0 },
}),
(thrown: unknown) => {
assert.ok(thrown instanceof AppError);
assert.equal(thrown.details?.reason, 'wait_capture_stalled');
assert.equal(thrown.details?.retriable, true);
assert.equal(thrown.details?.readableCaptures, 0);
return true;
},
);
Expand Down
37 changes: 6 additions & 31 deletions src/commands/interaction/runtime/selector-wait.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppError } from '@agent-device/kernel/errors';
import { WAIT_REASONS } from '@agent-device/contracts/interaction';
import { findNodeByRef, normalizeRef, type SnapshotNode } from '@agent-device/kernel/snapshot';
import {
readNodeLocalIdentity,
Expand All @@ -23,8 +24,7 @@ import {
createWaitPolling,
DEFAULT_WAIT_TIMEOUT_MS,
type WaitPollDeadline,
waitCaptureStalledError,
waitDeadlineExceededError,
waitTimeoutError,
} from './wait-polling.ts';

type WaitCommandContext = {
Expand Down Expand Up @@ -268,32 +268,14 @@ async function waitForSelector<Runtime extends SelectorWaitRuntime>(
}
await polling.sleepUntilNextPoll();
}
if (deadline === 'capture-stalled') {
throw waitCaptureStalledError(
`wait timed out for selector: ${selectorExpression}`,
polling.timeoutMs,
);
}
if (landmarkMismatch) {
if (deadline !== 'capture-stalled' && landmarkMismatch) {
throw new AppError(
'COMMAND_FAILED',
`wait matched selector ${selectorExpression} but no candidate carried the recorded landmark identity`,
{ reason: WAIT_LANDMARK_MISMATCH_REASON, ...landmarkMismatch },
);
}
if (deadline === 'capture-truncated') {
throw waitDeadlineExceededError(
`wait timed out for selector: ${selectorExpression}`,
polling.timeoutMs,
true,
);
}
polling.rethrowIfNeverReadable();
throw waitDeadlineExceededError(
`wait timed out for selector: ${selectorExpression}`,
polling.timeoutMs,
false,
);
throw waitTimeoutError(`wait timed out for selector: ${selectorExpression}`, polling, deadline);
}

type LandmarkMatchOutcome =
Expand Down Expand Up @@ -361,14 +343,7 @@ async function waitForText<Runtime extends SelectorWaitRuntime>(
if (found) return { kind: 'text', text, waitedMs: polling.waitedMs() };
await polling.sleepUntilNextPoll();
}
if (deadline === 'capture-stalled') {
throw waitCaptureStalledError(`wait timed out for text: ${text}`, polling.timeoutMs);
}
if (deadline === 'capture-truncated') {
throw waitDeadlineExceededError(`wait timed out for text: ${text}`, polling.timeoutMs, true);
}
polling.rethrowIfNeverReadable();
throw waitDeadlineExceededError(`wait timed out for text: ${text}`, polling.timeoutMs, false);
throw waitTimeoutError(`wait timed out for text: ${text}`, polling, deadline);
}

async function snapshotContainsText<Runtime extends SelectorWaitRuntime>(
Expand Down Expand Up @@ -403,7 +378,7 @@ async function waitForStable<Runtime extends SelectorWaitRuntime>(
});
if (!outcome.settled) {
throw new AppError('COMMAND_FAILED', 'wait timed out waiting for a stable UI', {
reason: 'wait_stable_timeout',
reason: WAIT_REASONS.stableTimeout,
...(outcome.stalled ? { captureStalled: true } : {}),
quietMs: quiet,
timeoutMs: timeout,
Expand Down
Loading
Loading