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
4 changes: 4 additions & 0 deletions src/commands/interaction/runtime/gestures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type { LongPressCommandResult } from '../../../contracts/interaction.ts';
import {
assertSupportedInteractionSurface,
captureInteractionSnapshot,
type ExpectedResolvedTarget,
type InteractionTarget,
type ResolvedInteractionTarget,
resolveInteractionTarget,
Expand All @@ -55,6 +56,8 @@ export type FocusCommandResult = ResolvedInteractionTarget & BackendResultEnvelo
export type LongPressCommandOptions = CommandContext & {
target: InteractionTarget;
durationMs?: number;
/** ADR 0012 step 4: replay-only post-resolution guard; see resolution.ts. */
expectedResolvedTarget?: ExpectedResolvedTarget;
} & SettlePostActionObservationOptions;

export type { LongPressCommandResult };
Expand Down Expand Up @@ -166,6 +169,7 @@ export const longPressCommand: RuntimeCommand<
requireInteractive: true,
promoteToHittableAncestor: true,
captureEvidenceBaseline: observation.needsPreActionBaseline,
expectedResolvedTarget: options.expectedResolvedTarget,
});
if (!runtime.backend.longPress) {
throw new AppError('UNSUPPORTED_OPERATION', 'longPress is not supported by this backend');
Expand Down
12 changes: 12 additions & 0 deletions src/commands/interaction/runtime/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import type { RepeatedInput } from '../../command-input.ts';
import {
EXACT_REF_RESOLUTION,
type ExpectedResolvedTarget,
type InteractionTarget,
preflightNativeRefInteraction,
resolveInteractionTarget,
Expand Down Expand Up @@ -58,6 +59,8 @@ export type PressCommandOptions = CommandContext &
RepeatedInput & {
target: InteractionTarget;
button?: ClickButton;
/** ADR 0012 step 4: replay-only post-resolution guard; see resolution.ts. */
expectedResolvedTarget?: ExpectedResolvedTarget;
} & PostActionObservationOptions;

export type ClickCommandOptions = PressCommandOptions;
Expand All @@ -68,6 +71,8 @@ export type FillCommandOptions = CommandContext & {
target: InteractionTarget;
text: string;
delayMs?: number;
/** ADR 0012 step 4: replay-only post-resolution guard; see resolution.ts. */
expectedResolvedTarget?: ExpectedResolvedTarget;
} & PostActionObservationOptions;

export type TypeTextCommandOptions = CommandContext & {
Expand Down Expand Up @@ -107,6 +112,7 @@ export const fillCommand: RuntimeCommand<FillCommandOptions, FillCommandResult>
requireInteractive: true,
promoteToHittableAncestor: false,
captureEvidenceBaseline: observation.needsPreActionBaseline,
expectedResolvedTarget: options.expectedResolvedTarget,
});
if (!runtime.backend.fill) {
throw new AppError('UNSUPPORTED_OPERATION', 'fill is not supported by this backend');
Expand Down Expand Up @@ -187,6 +193,7 @@ async function tapCommand(
requireInteractive: true,
promoteToHittableAncestor: true,
captureEvidenceBaseline: observation.needsPreActionBaseline,
expectedResolvedTarget: options.expectedResolvedTarget,
});
if (!runtime.backend.tap) {
throw new AppError('UNSUPPORTED_OPERATION', 'tap is not supported by this backend');
Expand Down Expand Up @@ -229,6 +236,9 @@ async function maybeTapRefTarget(
return null;
}
if (hasNonDefaultTapOptions(options)) return null;
// ADR 0012 step 4: a guarded replay action needs the runtime resolution
// path so the post-resolution identity guard actually runs.
if (options.expectedResolvedTarget) return null;
// ADR 0011 native-ref preflight: the shared occlusion/offscreen guards run
// against the stored session snapshot node before the backend call (a
// backend fast path can silently "succeed", so errors must be raised here).
Expand All @@ -254,6 +264,8 @@ async function maybeFillRefTarget(
options: FillCommandOptions,
): Promise<FillCommandResult | null> {
if (options.target.kind !== 'ref' || !runtime.backend.fillTarget) return null;
// ADR 0012 step 4: guarded replay actions take the runtime path — see maybeTapRefTarget.
if (options.expectedResolvedTarget) return null;
// ADR 0011 native-ref preflight — see maybeTapRefTarget.
const preflight = await preflightNativeRefInteraction(runtime, options, options.target, 'fill');
const backendResult = await runtime.backend.fillTarget(
Expand Down
75 changes: 75 additions & 0 deletions src/commands/interaction/runtime/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,70 @@ import type {
} from '../../../contracts/interaction.ts';
import { now, toBackendContext } from '../../runtime-common.ts';
import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts';
import {
localIdentitiesEqual,
readNodeLocalIdentity,
readNodeStructuralDenotation,
structuralDenotationsEqual,
REPLAY_TARGET_GUARD_MISMATCH_REASON,
type ReplayTargetGuardDenotation,
} from '../../../replay/target-identity-node.ts';

export type { InteractionTarget, PointTarget, ResolvedInteractionTarget };

/**
* ADR 0012 migration step 4, post-resolution guard: the LOCAL identity AND
* the STRUCTURAL denotation (pre-order document index + same-parent sibling
* ordinal) of the element replay's pre-action verification isolated. Set ONLY
* by the replay step loop (via `DaemonRequest.internal.replayTargetGuard`) for
* annotated verified actions — never on live interactive commands.
*
* Local identity alone is insufficient: ADR path 6 isolates ONE member among
* several nodes that share the same `{id, role, label}` using sibling /
* region-scoped viewportOrder. If verification isolates duplicate A but
* dispatch's occlusion/visibility filtering selects duplicate B with the same
* local identity, a local-identity-only guard would pass and tap the wrong
* element. The structural denotation is the discriminator that catches that
* split BEFORE the device action.
*/
export type ExpectedResolvedTarget = ReplayTargetGuardDenotation;

/**
* Compares the resolution winner (pre-promotion: hittable-ancestor promotion
* deliberately retargets to the same LEAF's actionable container and must not
* trip the guard — duplicates are distinct leaves with distinct structural
* denotations, so comparing the leaf is exactly right) against the verified
* member's local identity AND structural denotation; throws pre-action when
* EITHER differs.
*/
export function assertExpectedResolvedTarget(
node: SnapshotNode,
nodes: SnapshotState['nodes'],
expected: ExpectedResolvedTarget | undefined,
action: string,
): void {
if (!expected) return;
const observedIdentity = readNodeLocalIdentity(node);
const observedStructural = readNodeStructuralDenotation(node, nodes);
if (
localIdentitiesEqual(observedIdentity, expected.identity) &&
structuralDenotationsEqual(observedStructural, expected.structural)
) {
return;
}
throw new AppError(
'COMMAND_FAILED',
`${action} resolved to a different element than replay verification isolated; the action was not sent`,
{
reason: REPLAY_TARGET_GUARD_MISMATCH_REASON,
observed: observedIdentity,
observedStructural,
expected: expected.identity,
expectedStructural: expected.structural,
},
);
}

export type InteractionAction =
| 'click'
| 'press'
Expand Down Expand Up @@ -63,6 +124,8 @@ type ResolveInteractionTargetParams = {
* when the caller explicitly asked for verify evidence. Defaults to false.
*/
captureEvidenceBaseline?: boolean;
/** ADR 0012 step 4 post-resolution guard; see `ExpectedResolvedTarget`. */
expectedResolvedTarget?: ExpectedResolvedTarget;
};

export async function resolveInteractionTarget(
Expand Down Expand Up @@ -149,6 +212,12 @@ async function resolveRefInteractionTarget(
): Promise<ResolvedInteractionTarget> {
const capture = await resolveSnapshotForRef(runtime, options, target);
const resolved = capture.resolved;
assertExpectedResolvedTarget(
resolved.node,
capture.snapshot.nodes,
params.expectedResolvedTarget,
params.action,
);
const node = params.promoteToHittableAncestor
? resolveActionableNodeOrThrow(capture.snapshot.nodes, resolved.node, {
action: params.action,
Expand Down Expand Up @@ -216,6 +285,12 @@ async function resolveSelectorInteractionTarget(
{ hint: selectorFailureHint(resolved?.diagnostics ?? []) },
);
}
assertExpectedResolvedTarget(
resolved.node,
capture.snapshot.nodes,
params.expectedResolvedTarget,
params.action,
);
const node = params.promoteToHittableAncestor
? resolveActionableNodeOrThrow(capture.snapshot.nodes, resolved.node, {
action: params.action,
Expand Down
15 changes: 15 additions & 0 deletions src/commands/interaction/runtime/selector-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
SelectorTarget,
} from '../../../contracts/interaction.ts';
import type { RuntimeCommand } from '../../runtime-types.ts';
import { assertExpectedResolvedTarget, type ExpectedResolvedTarget } from './resolution.ts';
import {
type CapturedSnapshot,
type SelectorSnapshotOptions,
Expand Down Expand Up @@ -69,6 +70,8 @@ export type GetCommandOptions = CommandContext &
SelectorSnapshotOptions & {
property: 'text' | 'attrs';
target: ElementTarget;
/** ADR 0012 step 4: replay-only post-resolution guard; see resolution.ts. */
expectedResolvedTarget?: ExpectedResolvedTarget;
};

export type GetCommandResult =
Expand Down Expand Up @@ -199,6 +202,12 @@ export const getCommand: RuntimeCommand<GetCommandOptions, GetCommandResult> = a
invalidRefMessage: 'get text requires a ref like @e2',
notFoundMessage: `Ref ${options.target.ref} not found`,
});
assertExpectedResolvedTarget(
resolved.node,
capture.snapshot.nodes,
options.expectedResolvedTarget,
'get',
);
const selectorChain = buildSelectorChainForNode(resolved.node, runtime.backend.platform, {
action: 'get',
});
Expand All @@ -215,6 +224,12 @@ export const getCommand: RuntimeCommand<GetCommandOptions, GetCommandResult> = a
selector: options.target.selector,
disambiguateAmbiguous: options.property === 'text',
});
assertExpectedResolvedTarget(
resolved.node,
resolved.capture.snapshot.nodes,
options.expectedResolvedTarget,
'get',
);

const selectorChain = buildSelectorChainForNode(resolved.node, runtime.backend.platform, {
action: 'get',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import type { RawSnapshotNode, SnapshotNode } from '../../../kernel/snapshot.ts';
import { computeTargetEvidence } from '../../session-target-evidence.ts';
import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts';

export function toSnapshotNodes(raw: RawSnapshotNode[]): SnapshotNode[] {
return raw.map((node, position) => ({ ...node, ref: `e${position + 1}` }));
}

export function bottomTabsRealCaptureFixture(): SnapshotNode[] {
return toSnapshotNodes([
{
index: 0,
type: 'Application',
label: 'React Navigation Example',
rect: { x: 0, y: 0, width: 402, height: 874 },
enabled: true,
hittable: true,
depth: 0,
},
{
index: 1,
type: 'Window',
rect: { x: 0, y: 0, width: 402, height: 874 },
enabled: true,
hittable: true,
depth: 1,
parentIndex: 0,
},
{
index: 2,
type: 'ScrollView',
label: 'Contacts',
rect: { x: 0, y: 116, width: 402, height: 675 },
enabled: true,
hittable: false,
hiddenContentBelow: true,
depth: 2,
parentIndex: 1,
},
{
index: 3,
type: 'StaticText',
label: 'Marissa Castillo',
rect: { x: 52, y: 132, width: 110, height: 17 },
enabled: true,
depth: 3,
parentIndex: 2,
},
{
index: 4,
type: 'Other',
rect: { x: 0, y: 791, width: 402, height: 83 },
enabled: true,
hittable: false,
depth: 2,
parentIndex: 1,
},
{
index: 5,
type: 'Button',
label: 'Article, unselected',
identifier: 'article',
rect: { x: 0, y: 791, width: 101, height: 49 },
enabled: true,
hittable: false,
depth: 3,
parentIndex: 4,
},
{
index: 6,
type: 'Button',
label: 'Chat, unselected',
identifier: 'chat',
rect: { x: 101, y: 791, width: 100, height: 49 },
enabled: true,
hittable: false,
depth: 3,
parentIndex: 4,
},
]);
}

export function recordArticleEvidence(): TargetAnnotationV1 {
const nodes = bottomTabsRealCaptureFixture();
const winner = nodes.find((node) => node.label === 'Article, unselected');
if (!winner) throw new Error('fixture missing Article tab');
const evidence = computeTargetEvidence({ node: winner, preActionNodes: nodes });
assert.ok(evidence);
assert.equal(evidence.verification, 'verified');
return evidence;
}
Loading
Loading