Skip to content

Commit b3c898a

Browse files
committed
feat: add strict native absence assertion
1 parent e624ef9 commit b3c898a

30 files changed

Lines changed: 1029 additions & 101 deletions

docs/adr/0012-interactive-replay.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,14 +279,16 @@ A recorded `id` never matches a node without that id.
279279
> verify against the live tree's real value at replay time, so the evidence is dropped rather than
280280
> published unverified. See ADR 0017's session-scoped echo protection amendment for the mechanism.
281281
> - **`get` — unchanged**; already covered by the pre-dispatch path and the post-resolution guard.
282-
> - **`is` (all predicates except `exists`) — covered, `pre-dispatch`, the `get` pattern end-to-end.**
282+
> - **`is` (all predicates except `exists` and `absent`) — covered, `pre-dispatch`, the `get` pattern end-to-end.**
283283
> `is` resolves a unique node immediately, so pre-action verification is semantically valid; the
284284
> resolved node/tree feed record-time evidence, and dispatch threads `replayTargetGuard` into
285285
> `assertExpectedResolvedTarget` exactly like `get`. The direct-iOS `is`/`wait` fast paths are gated
286286
> off during recording and guarded replays, mirroring `get`'s existing recording gate.
287287
> - **Intentionally deferred, with tests proving no annotation is recorded and no identity check runs:**
288288
> `is exists` (existence assertion with no unique winner; wait-like semantics without the
289-
> guard-critical role), every read-only `find` variant (fuzzy-locator resolution has no
289+
> guard-critical role), `is absent` (a strict one-capture absence observation has no resolved
290+
> 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
290292
> selector-chain identity token for the classifier, and publication already refuses mutating `find`
291293
> as non-verifiable), and `wait text`/`wait stable`/duration waits/`wait @ref` (no element target, or
292294
> a session-local ref that ADR 0016 already refuses to publish; `wait @ref` is rejected rather than

examples/test-app/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ These are the main case families this app can support without adding more screen
3939
- `fill` on single-line and multiline fields
4040
- `type` after focus for append flows
4141
- `get text` on headings, badges, summaries, and accordion content
42-
- `is visible` and `is exists` assertions
42+
- `is visible`, `is exists`, and `is absent` assertions
4343
- `wait` for async loading and success states
4444
- `diff snapshot` after dismissals and submits
4545
- long-list scrolling and `scrollintoview`

packages/contracts/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@
239239
"types": "./src/interaction-error.ts",
240240
"default": "./src/interaction-error.ts"
241241
},
242+
"./is-predicate": {
243+
"types": "./src/is-predicate.ts",
244+
"default": "./src/is-predicate.ts"
245+
},
242246
"./interaction-guarantees": {
243247
"types": "./src/interaction-guarantees.ts",
244248
"default": "./src/interaction-guarantees.ts"

packages/contracts/src/client-selector-read.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
} from './client-capture.ts';
88
import type { DeviceCommandBaseOptions } from './client-connection.ts';
99
import type { ElementTarget } from './client-target.ts';
10+
import type { IsPredicate } from './is-predicate.ts';
1011

1112
/**
1213
* #1271 stage 2 (ADR 0012 amendment): `get`/`is`/`find` are observation-only
@@ -29,15 +30,15 @@ export type GetOptions = DeviceCommandBaseOptions &
2930
export type IsTextPredicateOptions = DeviceCommandBaseOptions &
3031
SelectorSnapshotCommandOptions &
3132
RecordControlOptions & {
32-
predicate: 'text';
33+
predicate: Extract<IsPredicate, 'text'>;
3334
selector: string;
3435
value: string;
3536
};
3637

3738
export type IsStatePredicateOptions = DeviceCommandBaseOptions &
3839
SelectorSnapshotCommandOptions &
3940
RecordControlOptions & {
40-
predicate: 'visible' | 'hidden' | 'exists' | 'editable' | 'selected' | 'focused';
41+
predicate: Exclude<IsPredicate, 'text'>;
4142
selector: string;
4243
value?: never;
4344
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** Machine-readable `error.details.reason` values shared by interaction producers and adapters. */
22
export const INTERACTION_ERROR_REASONS = {
33
selectorNotFound: 'selector_not_found',
4+
predicateFailed: 'predicate_failed',
45
} as const;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/** The complete predicate vocabulary accepted by the `is` command. */
2+
export const IS_PREDICATES = [
3+
'visible',
4+
'hidden',
5+
'exists',
6+
'absent',
7+
'editable',
8+
'selected',
9+
'focused',
10+
'text',
11+
] as const;
12+
13+
export type IsPredicate = (typeof IS_PREDICATES)[number];

packages/selectors/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from './internal/replay.ts';
4545

4646
export type { FindAction, FindLocator } from './internal/find.ts';
47+
export { IS_PREDICATES } from '@agent-device/contracts/is-predicate';
4748
export type { IsPredicate } from './internal/predicates.ts';
4849
export type {
4950
PolicyResolutionOutcome,

packages/selectors/src/internal/predicates.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { refuse, type SelectorArgumentRefusal } from './argument-refusal.ts';
22

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

15-
export type IsPredicate =
16-
| 'visible'
17-
| 'hidden'
18-
| 'exists'
19-
| 'editable'
20-
| 'selected'
21-
| 'focused'
22-
| 'text';
16+
export type { IsPredicate } from '@agent-device/contracts/is-predicate';
2317

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

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

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

6962
export function evaluateIsPredicate(params: {
70-
predicate: Exclude<IsPredicate, 'exists'>;
63+
predicate: Exclude<IsPredicate, 'exists' | 'absent'>;
7164
node: SnapshotState['nodes'][number];
7265
nodes: SnapshotState['nodes'];
7366
expectedText?: string;
@@ -102,6 +95,8 @@ export function evaluateIsPredicate(params: {
10295
case 'text':
10396
pass = actualText === (expectedText ?? '');
10497
break;
98+
default:
99+
return assertNever(predicate);
105100
}
106101
const details =
107102
predicate === 'text'
@@ -115,6 +110,10 @@ export function evaluateIsPredicate(params: {
115110
return { pass, actualText, details };
116111
}
117112

113+
function assertNever(value: never): never {
114+
throw new Error(`Unhandled is predicate: ${String(value)}`);
115+
}
116+
118117
function isAssertionVisible(
119118
node: SnapshotState['nodes'][number],
120119
visibility: SnapshotVisibility,

packages/selectors/src/internal/resolution-policy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,12 @@ export const SELECTOR_RESOLUTION_POLICIES = {
6161
ambiguity: 'disambiguate',
6262
requireRect: false,
6363
},
64-
/** `is` non-exists predicates and `get attrs` — ties reject, never guess. */
64+
/** `is` predicates other than `exists`/`absent`, and `get attrs` — ties reject, never guess. */
6565
readUnique: {
6666
ambiguity: 'fail-closed',
6767
requireRect: false,
6868
},
69-
/** `exists` and find's read-only actions — presence is the question. */
69+
/** `exists`/`absent` and find's read-only actions — presence is the question. */
7070
readAny: {
7171
ambiguity: 'first-match',
7272
requireRect: false,

scripts/layering/package-boundaries.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const CONTRACT_EXPORTS = [
108108
'@agent-device/contracts/interactor-operation-catalog',
109109
'@agent-device/contracts/interactor-types',
110110
'@agent-device/contracts/ios-snapshot',
111+
'@agent-device/contracts/is-predicate',
111112
'@agent-device/contracts/keyboard',
112113
'@agent-device/contracts/keyboard-runtime',
113114
'@agent-device/contracts/local-interactor-operation-set',

0 commit comments

Comments
 (0)