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
1 change: 1 addition & 0 deletions packages/ad-script/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
localIdentitiesEqual,
matchesAncestryPrefix,
matchesLocalIdentity,
nodeRectByIdentity,
readNodeLocalIdentity,
readNodeStructuralDenotation,
siblingOrdinal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
idMatchCountInTree,
matchesAncestryPrefix,
matchesLocalIdentity,
nodeRectByIdentity,
readNodeLocalIdentity,
} from '../target-annotation-identity.ts';

Expand Down Expand Up @@ -124,3 +125,48 @@ test('demoteNonUniqueLocalIdentity passes through identities without an id', ()
assert.equal(identity.id, undefined);
assert.deepEqual(demoteNonUniqueLocalIdentity(identity, nodes), identity);
});

// ---------------------------------------------------------------------------
// nodeRectByIdentity: the fail-closed same-node lookup #3471's gate screens with
// ---------------------------------------------------------------------------

const RECT = { x: 10, y: 240, width: 120, height: 50 };

test('nodeRectByIdentity reports the position a list names once', () => {
const nodes: RawSnapshotNode[] = [{ index: 0, type: 'Button', label: 'Continue', rect: RECT }];

assert.deepEqual(nodeRectByIdentity(nodes, nodes[0]!), RECT);
});

test('nodeRectByIdentity answers nothing for an absent or unmeasurable node', () => {
const target: RawSnapshotNode = { index: 0, type: 'Button', label: 'Continue', rect: RECT };
const frameless: RawSnapshotNode = { index: 0, type: 'Button', label: 'Continue' };

assert.equal(nodeRectByIdentity([], target), undefined);
assert.equal(nodeRectByIdentity([frameless], target), undefined);
});

test('nodeRectByIdentity answers nothing when an identity names several nodes', () => {
// Local identity is role/id/label, so two sibling rows both reading `label="Item"` are ONE
// identity. Guessing between them is positional coincidence — the same fail-closed direction
// `demoteNonUniqueLocalIdentity` takes for a non-unique id.
const rows: RawSnapshotNode[] = [
{ index: 0, type: 'Cell', label: 'Item', rect: { x: 0, y: 100, width: 400, height: 60 } },
{ index: 1, type: 'Cell', label: 'Item', rect: { x: 0, y: 160, width: 400, height: 60 } },
];

assert.equal(nodeRectByIdentity(rows, rows[0]!), undefined);
});

test('nodeRectByIdentity keys on the exact identity triple, so a relabel is a different node', () => {
const before: RawSnapshotNode = {
index: 0,
type: 'Button',
identifier: 'save-action',
label: 'Save',
rect: RECT,
};
const after: RawSnapshotNode = { ...before, label: 'Saved' };

assert.equal(nodeRectByIdentity([before], after), undefined);
});
23 changes: 22 additions & 1 deletion packages/ad-script/src/internal/target-annotation-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* here, not through `@agent-device/ad-replay`'s façade (#1555 review).
*/

import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
import { normalizeType } from '@agent-device/contracts/snapshot';
import type {
LocalIdentity,
Expand Down Expand Up @@ -120,6 +120,27 @@ export function demoteNonUniqueLocalIdentity(
return { role, ...(label !== undefined ? { label } : {}) };
}

/**
* Where `nodes` puts the node carrying `node`'s local identity, or nothing when the list cannot
* answer: absent, named by more than one node, or named by a node carrying no rect. Local identity is
* role/id/label, NOT element identity, so two sibling rows both reading `label="Item"` are one
* identity — and an ambiguous identity answers nothing, the same fail-closed direction
* `demoteNonUniqueLocalIdentity` takes for a non-unique id. `#3471`'s moving-target gate pairs this
* with `movingTargetRectsStable` to ask whether one node held its position between two trees.
*/
export function nodeRectByIdentity(
nodes: readonly IdentityPositionedNode[],
node: IdentityPositionedNode,
): Rect | undefined {
const identity = readNodeLocalIdentity(node);
const carriers = nodes.filter((candidate) =>
localIdentitiesEqual(identity, readNodeLocalIdentity(candidate)),
);
return carriers.length === 1 ? carriers[0]?.rect : undefined;
}

type IdentityPositionedNode = IdentityTreeNode & { rect?: Rect };

type StructuralNode = Pick<RawSnapshotNode, 'index' | 'parentIndex'>;

/** Zero-based ordinal among the node's own parent's children, in document order. */
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@
"types": "./src/managed-web-backend.ts",
"default": "./src/managed-web-backend.ts"
},
"./moving-target-gate": {
"types": "./src/moving-target-gate.ts",
"default": "./src/moving-target-gate.ts"
},
"./navigation": {
"types": "./src/navigation.ts",
"default": "./src/navigation.ts"
Expand Down
183 changes: 183 additions & 0 deletions packages/contracts/src/moving-target-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
MOVING_TARGET_GATE_RECT_TOLERANCE_PX,
MOVING_TARGET_GATE_SAMPLE_GAP_MS,
MOVING_TARGET_GATE_WINDOW_MS,
movingTargetGateDecision,
movingTargetRectsStable,
targetStillMovingHint,
} from './moving-target-gate.ts';

// #3471: the two judgements the moving-target gate makes, isolated from both callers so the window
// and the stability rule have exactly one reading. The runtime half (what a press does with these
// answers) is `src/commands/interaction/runtime/moving-target-gate.ts`; the Layer-3 scenarios behind
// the ADR 0011 `targetStability` cells drive the whole press.

const RECT = { x: 100, y: 200, width: 120, height: 44 };

function shifted(over: Partial<typeof RECT> = {}) {
return { ...RECT, ...over };
}

// --- is a second observation owed? ---

test('a session with no mutation clock is idle and owes nothing', () => {
// `targetShownAtRest` is deliberately the disturbing kind here: with no clock there is nothing to
// wait out, and an absent clock must not read as "a mutation just finished".
const decision = movingTargetGateDecision({
baselineFinishedAtMs: undefined,
targetShownAtRest: false,
nowMs: 10_000,
});

assert.deepEqual(decision, { kind: 'idle' });
});

test('a target the previous action left in place is answered without a re-observation', () => {
const decision = movingTargetGateDecision({
baselineFinishedAtMs: 9_900,
targetShownAtRest: true,
nowMs: 10_000,
});

assert.deepEqual(decision, { kind: 'stationary' });
});

test('the window is measured from the mutation END, so only what is LEFT of it is waited out', () => {
// The action finished 400 ms ago; a full window of waiting would be a 900 ms pause on a press
// whose surface has already been quiet for 400 ms.
const decision = movingTargetGateDecision({
baselineFinishedAtMs: 9_600,
targetShownAtRest: false,
nowMs: 10_000,
});

assert.deepEqual(decision, { kind: 'recheck', waitMs: 100 });
});

test('the declared window is the 500 ms transition budget, and its edge decides the branch', () => {
// The edge is exclusive: at exactly the budget the resolved point already stands, because an
// action that finished a full window ago has no claim on this press.
assert.equal(MOVING_TARGET_GATE_WINDOW_MS, 500);
const atEdge = movingTargetGateDecision({
baselineFinishedAtMs: 10_000 - MOVING_TARGET_GATE_WINDOW_MS,
targetShownAtRest: false,
nowMs: 10_000,
});
const inside = movingTargetGateDecision({
baselineFinishedAtMs: 9_600,
targetShownAtRest: false,
nowMs: 10_000,
});
const outside = movingTargetGateDecision({
baselineFinishedAtMs: 9_400,
targetShownAtRest: false,
nowMs: 10_000,
});

assert.deepEqual(atEdge, { kind: 'not-recent' });
assert.equal(inside.kind, 'recheck');
assert.equal(outside.kind, 'not-recent');
});

test('inside the window, only a tree that shows the target at rest excuses the re-observation', () => {
// One bit carries the whole screening verdict, because that is all this branch can use: absent,
// ambiguous, and moved all mean the same thing here — the session cannot show where the target
// came to rest, so it owes the look.
for (const targetShownAtRest of [false, true] as const) {
const decision = movingTargetGateDecision({
baselineFinishedAtMs: 9_950,
targetShownAtRest,
nowMs: 10_000,
});

assert.equal(
decision.kind,
targetShownAtRest ? 'stationary' : 'recheck',
`targetShownAtRest ${String(targetShownAtRest)}`,
);
}
});

test('the wait never drops below one sample gap, even as the window closes', () => {
// Re-observing immediately would compare two captures the same animation frame could produce,
// and agreement would then be a timing accident rather than the target having stopped.
const decision = movingTargetGateDecision({
baselineFinishedAtMs: 9_520,
targetShownAtRest: false,
nowMs: 10_000,
});

assert.deepEqual(decision, { kind: 'recheck', waitMs: MOVING_TARGET_GATE_SAMPLE_GAP_MS });
});

test('a clock skew ahead of the mutation does not produce a negative wait', () => {
const decision = movingTargetGateDecision({
baselineFinishedAtMs: 10_100,
targetShownAtRest: false,
nowMs: 10_000,
});

assert.ok(decision.kind === 'recheck');
assert.ok(decision.waitMs >= MOVING_TARGET_GATE_SAMPLE_GAP_MS);
});

// --- has the target stopped? ---

test('two observations at the same rect are a resting position', () => {
assert.equal(movingTargetRectsStable(RECT, shifted()), true);
});

test('capture rounding within the declared tolerance is not motion', () => {
assert.equal(MOVING_TARGET_GATE_RECT_TOLERANCE_PX, 1);
assert.equal(movingTargetRectsStable(RECT, shifted({ x: RECT.x + 1 })), true);
assert.equal(movingTargetRectsStable(RECT, shifted({ height: RECT.height + 1 })), true);
});

test('one axis moving is enough to still be moving', () => {
// The slide-in case from the #3471 repro (ADB Android): a translate animation keeps width and
// height constant, so only one axis says the target moved.
assert.equal(movingTargetRectsStable(RECT, shifted({ x: RECT.x + 2 })), false);
assert.equal(movingTargetRectsStable(RECT, shifted({ y: RECT.y + 2 })), false);
assert.equal(movingTargetRectsStable(RECT, shifted({ width: RECT.width + 2 })), false);
assert.equal(movingTargetRectsStable(RECT, shifted({ height: RECT.height + 2 })), false);
});

test('an observation that could not measure the target never agrees with one that could', () => {
// Absence is the surface having changed under the point, which is the exposure the gate exists
// to catch — so it can never be the observation that certifies rest.
assert.equal(movingTargetRectsStable(RECT, undefined), false);
assert.equal(movingTargetRectsStable(undefined, RECT), false);
assert.equal(movingTargetRectsStable(undefined, undefined), false);
assert.equal(movingTargetRectsStable(RECT, shifted({ width: Number.NaN })), false);
});

test('the stability comparison is symmetric', () => {
assert.equal(
movingTargetRectsStable(shifted({ y: 205 }), RECT),
movingTargetRectsStable(RECT, shifted({ y: 205 })),
);
});

// --- the refusal's published recovery ---

test('a refused selector press names the wait that recovers it', () => {
const hint = targetStillMovingHint({ selector: 'label="Slide target"' });

assert.match(hint, /wait 'label="Slide target"'/);
assert.match(hint, /same selector/);
assert.match(hint, /--settle/);
assert.match(hint, /nothing was tapped/);
});

test('a refused @ref press is sent to a selector, because the wait would retire its frame', () => {
// ADR 0014: the capture a `wait` takes expires the ref frame, so telling the caller to retry the
// same ref would trade a motion refusal for a stale-ref refusal.
const hint = targetStillMovingHint({ selector: undefined });

assert.match(hint, /snapshot -i/);
assert.match(hint, /selector/);
assert.match(hint, /ref frame/);
assert.doesNotMatch(hint, /wait '/);
});
Loading
Loading