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
6 changes: 4 additions & 2 deletions docs/adr/0012-interactive-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,14 +279,16 @@ A recorded `id` never matches a node without that id.
> verify against the live tree's real value at replay time, so the evidence is dropped rather than
> published unverified. See ADR 0017's session-scoped echo protection amendment for the mechanism.
> - **`get` — unchanged**; already covered by the pre-dispatch path and the post-resolution guard.
> - **`is` (all predicates except `exists`) — covered, `pre-dispatch`, the `get` pattern end-to-end.**
> - **`is` (all predicates except `exists` and `absent`) — covered, `pre-dispatch`, the `get` pattern end-to-end.**
> `is` resolves a unique node immediately, so pre-action verification is semantically valid; the
> resolved node/tree feed record-time evidence, and dispatch threads `replayTargetGuard` into
> `assertExpectedResolvedTarget` exactly like `get`. The direct-iOS `is`/`wait` fast paths are gated
> off during recording and guarded replays, mirroring `get`'s existing recording gate.
> - **Intentionally deferred, with tests proving no annotation is recorded and no identity check runs:**
> `is exists` (existence assertion with no unique winner; wait-like semantics without the
> guard-critical role), every read-only `find` variant (fuzzy-locator resolution has no
> 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
> 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
2 changes: 1 addition & 1 deletion examples/test-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ These are the main case families this app can support without adding more screen
- `fill` on single-line and multiline fields
- `type` after focus for append flows
- `get text` on headings, badges, summaries, and accordion content
- `is visible` and `is exists` assertions
- `is visible`, `is exists`, and `is absent` assertions
- `wait` for async loading and success states
- `diff snapshot` after dismissals and submits
- long-list scrolling and `scrollintoview`
Expand Down
1 change: 1 addition & 0 deletions packages/ad-script/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
export {
parseTargetAnnotationV1Payload,
serializeTargetAnnotationV1,
truncateToUtf8Bytes,
utf8ByteLength,
TARGET_ANNOTATION_MAX_ANCESTRY,
TARGET_ANNOTATION_MAX_FIELD_BYTES,
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@
"types": "./src/interaction-error.ts",
"default": "./src/interaction-error.ts"
},
"./is-predicate": {
"types": "./src/is-predicate.ts",
"default": "./src/is-predicate.ts"
},
"./interaction-guarantees": {
"types": "./src/interaction-guarantees.ts",
"default": "./src/interaction-guarantees.ts"
Expand Down
5 changes: 3 additions & 2 deletions packages/contracts/src/client-selector-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from './client-capture.ts';
import type { DeviceCommandBaseOptions } from './client-connection.ts';
import type { ElementTarget } from './client-target.ts';
import type { IsPredicate } from './is-predicate.ts';

/**
* #1271 stage 2 (ADR 0012 amendment): `get`/`is`/`find` are observation-only
Expand All @@ -29,15 +30,15 @@ export type GetOptions = DeviceCommandBaseOptions &
export type IsTextPredicateOptions = DeviceCommandBaseOptions &
SelectorSnapshotCommandOptions &
RecordControlOptions & {
predicate: 'text';
predicate: Extract<IsPredicate, 'text'>;
selector: string;
value: string;
};

export type IsStatePredicateOptions = DeviceCommandBaseOptions &
SelectorSnapshotCommandOptions &
RecordControlOptions & {
predicate: 'visible' | 'hidden' | 'exists' | 'editable' | 'selected' | 'focused';
predicate: Exclude<IsPredicate, 'text'>;
selector: string;
value?: never;
};
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/interaction-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** Machine-readable `error.details.reason` values shared by interaction producers and adapters. */
export const INTERACTION_ERROR_REASONS = {
selectorNotFound: 'selector_not_found',
predicateFailed: 'predicate_failed',
} as const;
13 changes: 13 additions & 0 deletions packages/contracts/src/is-predicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** The complete predicate vocabulary accepted by the `is` command. */
export const IS_PREDICATES = [
'visible',
'hidden',
'exists',
'absent',
'editable',
'selected',
'focused',
'text',
] as const;

export type IsPredicate = (typeof IS_PREDICATES)[number];
1 change: 1 addition & 0 deletions packages/selectors/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from './internal/replay.ts';

export type { FindAction, FindLocator } from './internal/find.ts';
export { IS_PREDICATES } from '@agent-device/contracts/is-predicate';
export type { IsPredicate } from './internal/predicates.ts';
export type {
PolicyResolutionOutcome,
Expand Down
23 changes: 11 additions & 12 deletions packages/selectors/src/internal/predicates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { refuse, type SelectorArgumentRefusal } from './argument-refusal.ts';

import { IS_PREDICATES, type IsPredicate } from '@agent-device/contracts/is-predicate';
import type { Platform, PublicPlatform } from '@agent-device/kernel/device';
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
Expand All @@ -12,23 +13,15 @@ import {
import { isNodeEditable, isNodeVisible } from './node.ts';
import { tryParseSelectorChain } from './parse.ts';

export type IsPredicate =
| 'visible'
| 'hidden'
| 'exists'
| 'editable'
| 'selected'
| 'focused'
| 'text';
export type { IsPredicate } from '@agent-device/contracts/is-predicate';

// Module-private since `checkIsPredicate` became the admission API: a caller that tests the
// vocabulary without going through admission is how the case-normalization drift started.
function isSupportedPredicate(input: string): input is IsPredicate {
return ['visible', 'hidden', 'exists', 'editable', 'selected', 'focused', 'text'].includes(input);
return (IS_PREDICATES as readonly string[]).includes(input);
}

export const IS_PREDICATE_REQUIRED_MESSAGE =
'is requires predicate: visible|hidden|exists|editable|selected|focused|text';
export const IS_PREDICATE_REQUIRED_MESSAGE = `is requires predicate: ${IS_PREDICATES.join('|')}`;

/**
* The one `is` predicate admission check. Three call sites used to state this rule
Expand Down Expand Up @@ -67,7 +60,7 @@ export function normalizeIsPositionals(positionals: string[]): string[] {
}

export function evaluateIsPredicate(params: {
predicate: Exclude<IsPredicate, 'exists'>;
predicate: Exclude<IsPredicate, 'exists' | 'absent'>;
node: SnapshotState['nodes'][number];
nodes: SnapshotState['nodes'];
expectedText?: string;
Expand Down Expand Up @@ -102,6 +95,8 @@ export function evaluateIsPredicate(params: {
case 'text':
pass = actualText === (expectedText ?? '');
break;
default:
return assertNever(predicate);
}
const details =
predicate === 'text'
Expand All @@ -115,6 +110,10 @@ export function evaluateIsPredicate(params: {
return { pass, actualText, details };
}

function assertNever(value: never): never {
throw new Error(`Unhandled is predicate: ${String(value)}`);
}

function isAssertionVisible(
node: SnapshotState['nodes'][number],
visibility: SnapshotVisibility,
Expand Down
4 changes: 2 additions & 2 deletions packages/selectors/src/internal/resolution-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ export const SELECTOR_RESOLUTION_POLICIES = {
ambiguity: 'disambiguate',
requireRect: false,
},
/** `is` non-exists predicates and `get attrs` — ties reject, never guess. */
/** `is` predicates other than `exists`/`absent`, and `get attrs` — ties reject, never guess. */
readUnique: {
ambiguity: 'fail-closed',
requireRect: false,
},
/** `exists` and find's read-only actions — presence is the question. */
/** `exists`/`absent` and find's read-only actions — presence is the question. */
readAny: {
ambiguity: 'first-match',
requireRect: false,
Expand Down
11 changes: 6 additions & 5 deletions scripts/__tests__/eager-closure-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function discoverFacadeEntryFiles(repoRoot: string): string[] {
*/
export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
// --- @agent-device/ad-replay ---
'packages/ad-replay/src/index.ts': 61,
'packages/ad-replay/src/index.ts': 62,

// --- @agent-device/ad-script ---
'packages/ad-script/src/index.ts': 41,
Expand Down Expand Up @@ -233,6 +233,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
'packages/contracts/src/interactor-types.ts': 1,
// #2190's iOS snapshot vocabulary has type-only imports and remains a one-module entry.
'packages/contracts/src/ios-snapshot.ts': 1,
'packages/contracts/src/is-predicate.ts': 1,
'packages/contracts/src/keyboard.ts': 1,
'packages/contracts/src/logs-runtime-plan.ts': 5,
'packages/contracts/src/managed-web-backend.ts': 1,
Expand Down Expand Up @@ -300,7 +301,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
'packages/kernel/src/snapshot.ts': 1,

// --- @agent-device/maestro ---
'packages/maestro/src/index.ts': 110,
'packages/maestro/src/index.ts': 111,

// --- @agent-device/platform-*: ADR-0019's metadata-eager/implementation-lazy façades. Each
// evaluates only itself; every implementation sits behind a function-scoped `await import`.
Expand Down Expand Up @@ -351,7 +352,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
// --- @agent-device/selectors ---
'packages/selectors/src/ast.ts': 16,
'packages/selectors/src/engine.ts': 19,
'packages/selectors/src/index.ts': 54,
'packages/selectors/src/index.ts': 55,

// --- @agent-device/xml ---
'packages/xml/src/index.ts': 3,
Expand Down Expand Up @@ -410,9 +411,9 @@ 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': 379,
'src/cli.ts': 380,
'src/platform-runtime.ts': 47,
'src/core/command-descriptor/registry.ts': 71,
'src/core/command-descriptor/registry.ts': 72,
'src/core/command-descriptor/platform-execution-entry.ts': 3,
'src/core/interactors/register-builtins.ts': 6,
// R64 removes the perf plugin facet and keeps collector binding behind the selected runtime
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/package-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const CONTRACT_EXPORTS = [
'@agent-device/contracts/interactor-operation-catalog',
'@agent-device/contracts/interactor-types',
'@agent-device/contracts/ios-snapshot',
'@agent-device/contracts/is-predicate',
'@agent-device/contracts/keyboard',
'@agent-device/contracts/keyboard-runtime',
'@agent-device/contracts/local-interactor-operation-set',
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/cli-grammar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ test('is grammar explains the predicate/selector-key collision on invalid predic
assert.equal(err.code, 'INVALID_ARGS');
assert.match(
err.message,
/is requires predicate: visible\|hidden\|exists\|editable\|selected\|focused\|text/,
/is requires predicate: visible\|hidden\|exists\|absent\|editable\|selected\|focused\|text/,
);
assert.match(err.details?.hint ?? '', /is <selector> <predicate>/);
assert.match(err.details?.hint ?? '', /visible=true/);
Expand Down
73 changes: 71 additions & 2 deletions src/__tests__/is-argument-surface-parity.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { checkIsArgs, checkIsPredicate, IS_PREDICATE_USAGE_HINT } from '@agent-device/selectors';
import {
IS_PREDICATES,
checkIsArgs,
checkIsPredicate,
IS_PREDICATE_USAGE_HINT,
} from '@agent-device/selectors';
import { interactionCommandMetadata } from '../commands/interaction/metadata.ts';
import { readInputFromCli } from '../commands/cli-grammar.ts';
import type { CliFlags } from '@agent-device/contracts/command';

Expand All @@ -10,7 +18,7 @@ import type { CliFlags } from '@agent-device/contracts/command';
// Three surfaces validate the same rule: the daemon handler (via `checkIsArgs`), the CLI
// grammar (`readInputFromCli`), and the runtime command that receives already-parsed
// options (`isCommand`, covered on its own production route in
// commands/interaction/runtime/selector-read.test.ts). They used to state the rule three
// commands/interaction/runtime/selector-is.test.ts). They used to state the rule three
// times, and all three had drifted: one inlined its own predicate list, one compared the raw
// token so it rejected input the daemon accepts, and one dropped the usage hint.
//
Expand All @@ -19,6 +27,16 @@ import type { CliFlags } from '@agent-device/contracts/command';
// helper-only test could not catch.

const BASE_FLAGS = {} as CliFlags;
const EXPECTED_IS_PREDICATES = [
'visible',
'hidden',
'exists',
'absent',
'editable',
'selected',
'focused',
'text',
] as const;

type Verdict = { ok: true; predicate: string } | { ok: false; message: string; hint?: string };

Expand Down Expand Up @@ -77,6 +95,18 @@ const CASES: readonly {
expect: 'accept',
predicate: 'text',
},
{
name: 'absence predicate first',
positionals: ['absent', 'id="gone"'],
expect: 'accept',
predicate: 'absent',
},
{
name: 'absence predicate after selector',
positionals: ['id="gone"', 'absent'],
expect: 'accept',
predicate: 'absent',
},
{ name: 'unknown predicate', positionals: ['shiny', 'id="ok"'], expect: 'refuse' },
{ name: 'no predicate at all', positionals: [], expect: 'refuse' },
];
Expand Down Expand Up @@ -120,3 +150,42 @@ test('an unsupported predicate is refused with the same message and hint everywh
assert.equal(daemon.hint, IS_PREDICATE_USAGE_HINT);
assert.equal(cli.hint, IS_PREDICATE_USAGE_HINT);
});

test('command docs list every is predicate', () => {
const docs = fs.readFileSync(
path.resolve(import.meta.dirname, '../..', 'website/docs/docs/commands.md'),
'utf8',
);
const predicateList = docs.match(/Supported predicates are ([^.]+)\./)?.[1] ?? '';
assert.deepEqual(IS_PREDICATES, EXPECTED_IS_PREDICATES);
for (const predicate of EXPECTED_IS_PREDICATES) {
assert.match(predicateList, new RegExp(`\\b${predicate}\\b`));
}

const metadata = interactionCommandMetadata.find((entry) => entry.name === 'is');
const predicateSchema = metadata?.inputSchema.properties?.predicate;
assert.deepEqual(predicateSchema?.enum, IS_PREDICATES);
});

test('the CLI refuses scoped and depth-limited absence captures as INVALID_ARGS', () => {
for (const [flag, value] of [
['snapshotScope', 'Login'],
['snapshotDepth', 2],
] as const) {
assert.throws(
() =>
readInputFromCli('is', ['absent', 'label="Gone"'], {
...BASE_FLAGS,
[flag]: value,
} as CliFlags),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_ARGS');
assert.equal(error.details?.command, 'is');
assert.equal(error.details?.predicate, 'absent');
assert.equal(error.details?.rejectedOption, flag === 'snapshotScope' ? 'scope' : 'depth');
return true;
},
);
}
});
2 changes: 1 addition & 1 deletion src/cli-schema/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ test('usageForCommand resolves web help topic', async () => {
assert.match(help, /agent-device screenshot \.\/artifacts\/web-home\.png --platform web/);
assert.match(help, /agent-device close --platform web/);
assert.match(help, /open <url>, snapshot -i, get text\/attrs/);
assert.match(help, /is visible\/exists\/text, find text\/selector/);
assert.match(help, /is visible\/hidden\/exists\/absent\/focused\/text, find text\/selector/);
assert.match(help, /click\/press @ref or selector/);
assert.match(help, /network dump/);
assert.match(help, /audio probe/);
Expand Down
2 changes: 1 addition & 1 deletion src/cli-schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,7 @@ First-slice loop:
agent-device close --platform web

Supported in agent-device web sessions:
open <url>, snapshot -i, get text/attrs, is visible/exists/text, find text/selector, click/press @ref or selector, hover @ref or selector, fill/type @ref or selector, wait text/selector, network dump, audio probe, screenshot, record start/stop with WebM output, close, and replay scripts made from those commands.
open <url>, snapshot -i, get text/attrs, is visible/hidden/exists/absent/focused/text, find text/selector, click/press @ref or selector, hover @ref or selector, fill/type @ref or selector, wait text/selector, network dump, audio probe, screenshot, record start/stop with WebM output, close, and replay scripts made from those commands.
hover moves the pointer without pressing so hover-gated UI (row toolbars, menus) appears; use --settle to read what it revealed, then act on the fresh refs. hover @ref hovers the browser element handle directly; pair --settle with a selector or coordinates (web refs carry no geometry, as with click @ref --settle). Web only: touch platforms have no hover state, so hover-gated flows there need a different entry point.

Out of scope for agent-device web support:
Expand Down
7 changes: 3 additions & 4 deletions src/commands/interaction/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
readGesturePayload,
} from '@agent-device/contracts/gesture-input';
import { SCROLL_DURATION_MAX_MS } from '@agent-device/contracts/scroll-command';
import { IS_PREDICATES } from '@agent-device/contracts/is-predicate';
import {
SCROLL_DIRECTIONS,
SWIPE_PATTERNS,
Expand Down Expand Up @@ -69,7 +70,7 @@ const interactionCommandDescriptions = {
scroll:
'Scroll in a direction, or toward the top/bottom edge of scrollable content. The optional amount is the finger-path fraction of the viewport axis; app scroll physics determine the final content offset.',
get: 'Read text or accessibility attributes from a snapshot ref or selector without changing the app. Use format text for visible content or attrs for the element attribute map.',
is: 'Check whether a selector satisfies a UI predicate such as visible, hidden, editable, selected, focused, or text. Use wait when the condition may appear asynchronously.',
is: 'Check whether a selector satisfies a UI predicate such as visible, hidden, exists, absent, editable, selected, focused, or text. `absent` passes only when one readable, complete, unscoped, full-depth accessibility capture has zero matches. Use wait when the condition may appear asynchronously.',
find: 'Find by text/label/value/role/id and run action',
gesture:
'Perform a structured pan, fling, swipe, pinch, rotate, transform, or drag gesture. Select the gesture kind, then provide only the inputs that apply to that kind.',
Expand Down Expand Up @@ -171,9 +172,7 @@ const getFields = {
};

const isFields = {
predicate: requiredField(
enumField(['visible', 'hidden', 'exists', 'editable', 'selected', 'focused', 'text'] as const),
),
predicate: requiredField(enumField(IS_PREDICATES)),
selector: requiredField(stringField()),
value: stringField(),
...selectorSnapshotFields(),
Expand Down
Loading
Loading