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
51 changes: 51 additions & 0 deletions packages/contracts/src/interaction-guarantees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ export const INTERACTION_GUARANTEES = [
// a settled diff). Distinct from the opt-in verifyEvidence/settleObservation features; this is the
// path's baseline, always-on claim.
'outcomeObservation',
// The position the path dispatches at is one the target is confirmed to be at REST in (#3471).
// Distinct from every guard above: those judge a tree, this judges the tree against the previous
// action's tree. A capture is one instant's photograph of a surface that may still be rearranging
// itself — the ADB-backed Android helper is measured reporting frames in flight — so a press
// issued right after an action that animated the UI can resolve a point the target has already
// vacated and report success on the vacancy. The gate is backend-neutral: it re-observes past the
// window and refuses rather than dispatching at a position two observations disagree about.
'targetStability',
] as const;

export type InteractionGuarantee = (typeof INTERACTION_GUARANTEES)[number];
Expand Down Expand Up @@ -151,6 +159,21 @@ export type InteractionPathContract = {
const GAPS_UMBRELLA_ISSUE = 'https://github.com/callstack/agent-device/issues/1081';
const TAP_OUTCOME_NOT_OBSERVED_GAP_ISSUE = 'https://github.com/callstack/agent-device/issues/3335';

/**
* The moving-target gate's shared cell (#3471). Scoped to the two commands that dispatch a tap the
* daemon aimed at a rect it captured: `fill` aims at a field it expects to be found wherever it
* is, and its own staleness failure mode is a wrong focus rather than a silent miss, which the
* guard's re-resolve would not fix. `longpress` aims the same way and is deliberately left out for
* now: a hold that begins one frame early is a gesture the recognizer still owns, the measured
* failure and the caller-visible recovery are both tap-shaped, and widening this list is one
* declaration plus one scenario per added command.
*/
const RUNTIME_TREE_TARGET_STABILITY: GuaranteeEnforcement = {
kind: 'runtime',
via: 'src/commands/interaction/runtime/moving-target-gate.ts#guardResolvedTargetAgainstMotion',
appliesTo: ['press', 'click'],
};

// Every path shares the SAME cell by construction: response payloads have one
// construction site (ADR 0011 Layer 2), and the hand-rolled-literal guard test
// (interaction-response-construction-guard.test.ts) keeps new branches on it.
Expand Down Expand Up @@ -193,6 +216,16 @@ const DIRECT_IOS_OUTCOME_NOT_OBSERVED_GAP: GuaranteeEnforcement = {
trackingIssue: TAP_OUTCOME_NOT_OBSERVED_GAP_ISSUE,
};

/**
* Shares the SAME reasoning as DIRECT_IOS_SINGLE_QUERY_READINESS: the fused request is one XCTest
* round trip, so it also cannot carry a stale daemon-captured position into the tap.
*/
const DIRECT_IOS_TARGET_STABILITY: GuaranteeEnforcement = {
kind: 'waived',
reason:
"Intentional: the fused runner request queries the element and taps it inside one XCTest request, so the runner aims at the element's own live frame at tap time rather than at a rect the daemon captured earlier and carried across a round trip — the capture-to-dispatch distance #3471 closes does not exist on this route. Its hittability semantics stay the runner's, as the offscreen cell above already records.",
};

// The two runtime tree paths (selector and ref resolution) run the SAME shared
// guard/observation implementations; only how the target is found
// (disambiguation) and how failures are described (errorTaxonomy) differ.
Expand Down Expand Up @@ -260,6 +293,7 @@ const RUNTIME_TREE_SHARED_GUARANTEES = {
kind: 'runtime',
via: 'src/commands/interaction/runtime/settle.ts#settleAfterInteraction',
},
targetStability: RUNTIME_TREE_TARGET_STABILITY,
} satisfies Partial<Record<InteractionGuarantee, GuaranteeEnforcement>>;

export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPathContract> = {
Expand Down Expand Up @@ -390,6 +424,11 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
'gap: target-authored drag has no --settle/--verify observation of the destination; the tap-shaped runtime paths are the only ones with an opt-in evidence route today.',
trackingIssue: GAPS_UMBRELLA_ISSUE,
},
targetStability: {
kind: 'inapplicable',
reason:
"Drag endpoints name contact coordinates for one uninterrupted pointer plan (ADR 0013); re-pointing an endpoint after resolution would split the plan the gesture's integrity depends on, and a drag whose surface moved mid-gesture is the surface behavior, not a misaimed tap. #3471 guards the tap-shaped paths, where one captured point is the whole action.",
},
},
},
'native-ref': {
Expand Down Expand Up @@ -475,6 +514,11 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
'gap: the fast path observes no post-action outcome by default; --settle/--verify disable it and delegate to runtime-ref instead of running here.',
trackingIssue: GAPS_UMBRELLA_ISSUE,
},
targetStability: {
kind: 'inapplicable',
reason:
'The fast path activates a provider-owned semantic handle rather than a point the daemon aimed from a capture, so there is no daemon-captured position that could have gone stale — the exposure #3471 guards is created by capture-to-dispatch distance and this path has none. Where the daemon does read a rect on this path, its preflight reads the same stored node the other guards judge.',
},
},
},
coordinate: {
Expand Down Expand Up @@ -538,6 +582,11 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
'Coordinates name a point, not an element; there is no target existence to wait for.',
},
outcomeObservation: TAP_OUTCOME_NOT_OBSERVED_GAP,
targetStability: {
kind: 'inapplicable',
reason:
'Coordinates are the escape hatch that dispatches the point it was given; there is no resolved element whose position could be confirmed, and re-resolving a caller-authored coordinate would replace the instruction rather than verify it.',
},
},
},
'maestro-direct-selector': {
Expand Down Expand Up @@ -601,6 +650,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
},
targetReadiness: DIRECT_IOS_SINGLE_QUERY_READINESS,
outcomeObservation: DIRECT_IOS_OUTCOME_NOT_OBSERVED_GAP,
targetStability: DIRECT_IOS_TARGET_STABILITY,
},
},
'maestro-non-hittable-fallback': {
Expand Down Expand Up @@ -664,6 +714,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
},
targetReadiness: DIRECT_IOS_SINGLE_QUERY_READINESS,
outcomeObservation: DIRECT_IOS_OUTCOME_NOT_OBSERVED_GAP,
targetStability: DIRECT_IOS_TARGET_STABILITY,
},
},
};
5 changes: 4 additions & 1 deletion packages/contracts/src/moving-target-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ export type MovingTargetBaseline = Readonly<{
* clock's time too. Production installs none, so both sides read the wall clock.
*/
finishedAt: number;
/** The pre-action tree — the observation the action's own resolution came out of. */
/**
* The tree the action acted through: its resolution's own observation, or the settled one its
* `--settle` published as rest.
*/
tree: SurfaceScopedNodes;
}>;

Expand Down
5 changes: 4 additions & 1 deletion scripts/layering/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ export const SESSION_STATE_FIELD_OWNERS: Readonly<Record<string, readonly string
pendingRecordAndHeal: ['src/daemon/session-replay-coordinator.ts'],

trace: ['src/daemon/handlers/trace-runtime.ts'],
postGestureStabilization: ['src/daemon/deferred-interaction-outcome.ts'],
// #3471 and #1542 share one field: what the session still owes after its last mutation. The two
// variants are mutually exclusive, and every transition between them is owned by
// `deferred-interaction-outcome.ts`, so neither mechanism can overwrite the other's claim.
deferredSurface: ['src/daemon/deferred-interaction-outcome.ts'],

// App identity follows open, deployment and observation through named patches.
appName: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,12 @@ test('snapshot annotations survive a deferred post-gesture capture into CLI JSON
];
const snapshotQuality = { state: 'healthy', backend: 'tree' };
session.snapshot = { nodes: baselineNodes, createdAt: Date.now(), backend: 'android' };
session.postGestureStabilization = { action: 'swipe', positionals: [], markedAt: Date.now() };
session.deferredSurface = {
kind: 'stabilize',
action: 'swipe',
positionals: [],
markedAt: Date.now(),
};
sessionStore.publish(sessionName, session);

legacyDispatchCapture.mockResolvedValue({
Expand Down
4 changes: 2 additions & 2 deletions src/commands/interaction/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ const FIND_ACTION_VALUES = [

const interactionCommandDescriptions = {
click:
'Activate a UI target by snapshot ref, selector, or coordinates. Prefer a ref or selector after a snapshot; use coordinates only when semantic targeting is unavailable. This can change app state. Success means the click was dispatched, not that it landed: use settle or verify to confirm the result without a follow-up snapshot, or wait for the expected screen.',
"Activate a UI target by snapshot ref, selector, or coordinates. Prefer a ref or selector after a snapshot; use coordinates only when semantic targeting is unavailable. This can change app state. Success means the click was dispatched, not that it landed: use settle or verify to confirm the result without a follow-up snapshot, or wait for the expected screen. A ref or selector target is re-checked for motion before dispatch when the session's previous action just animated the screen, and is dispatched at its resting position; one that has not stopped refuses with target_still_moving rather than acting where it was.",
press:
'Short-press a UI target by snapshot ref, selector, or coordinates. Use longpress instead when the target requires a context-menu or hold gesture. Success means the press was dispatched, not that it landed: use settle or verify to confirm the result, or wait for the expected screen.',
"Short-press a UI target by snapshot ref, selector, or coordinates. Use longpress instead when the target requires a context-menu or hold gesture. Success means the press was dispatched, not that it landed: use settle or verify to confirm the result, or wait for the expected screen. A ref or selector target is re-checked for motion before dispatch when the session's previous action just animated the screen, and is dispatched at its resting position; one that has not stopped refuses with target_still_moving rather than acting where it was.",
fill: 'Replace text in a UI input selected by snapshot ref, selector, or coordinates. Pass an empty text to clear the field. Inspect unconfirmed evidence and assert the expected value or resulting screen before continuing. Prefer refs or selectors after snapshot; use recordAs to keep sensitive text out of a recorded replay while sending it to the live app.',
longpress:
'Hold a UI target by snapshot ref, selector, or coordinates to open a context menu or perform another hold gesture. Set durationMs when the default hold duration is unsuitable.',
Expand Down
3 changes: 3 additions & 0 deletions src/commands/interaction/runtime/moving-target-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@ test('a ref the second observation cannot re-measure is refused, never self-agre
// hint's only test was the contracts unit suite, and the hint now lives beside this refusal.
assert.match(String(details?.hint), /snapshot -i/);
assert.match(String(details?.hint), /ref frame/);
// And no motion claim: this is the shape that measured NOTHING.
assert.match(String(details?.hint), /no confirmed resting position/);
assert.doesNotMatch(String(details?.hint), /was still moving/);
return true;
},
);
Expand Down
10 changes: 5 additions & 5 deletions src/commands/interaction/runtime/moving-target-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,9 @@ function targetStillMovingHint(params: { selector: string | undefined }): string
? `Run wait ${shellQuoteIfNeeded(params.selector)} and retry with the same selector`
: 'Run snapshot -i and retry with a selector (text=/id=) rather than this @ref — re-observing retires the ref frame';
return (
'The target was still moving when the transition window after the previous action ran out, so no resting ' +
`position could be confirmed and nothing was tapped. ${reobserve}; or pass --settle to have the action itself ` +
'wait for the UI to go quiet.'
'The target had no confirmed resting position when the transition window after the previous action ran ' +
`out, so nothing was dispatched. ${reobserve}; or pass --settle to have the action itself wait for the ` +
'UI to go quiet.'
);
}

Expand All @@ -301,8 +301,8 @@ function motionRefusal(
new AppError(
'COMMAND_FAILED',
selector
? `Selector ${selector} is still moving, so no resting position could be confirmed to press`
: `Ref ${ref} is still moving, so no resting position could be confirmed to press`,
? `Selector ${selector} had no confirmed resting position when the transition window ran out`
: `Ref ${ref} had no confirmed resting position when the transition window ran out`,
{
reason: INTERACTION_ERROR_REASONS.targetStillMoving,
...(selector !== undefined ? { selector } : { ref }),
Expand Down
5 changes: 3 additions & 2 deletions src/daemon/__tests__/deferred-interaction-outcome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, test, vi } from 'vitest';
import {
isPostGestureStabilizationPending,
markDeferredInteractionOutcome,
pendingSurfaceStabilization,
markPostOpenStabilization,
resolveDeferredInteractionOutcome,
stripInternalInteractionFlags,
Expand Down Expand Up @@ -105,8 +106,8 @@ test('an Apple open that could not observe its app marks stabilization without a
markPostOpenStabilization(session, 'unobservable');

assert.equal(isPostGestureStabilizationPending(session), true);
assert.equal(session.postGestureStabilization?.action, 'open');
assert.equal(session.postGestureStabilization?.baselineSignature, undefined);
assert.equal(pendingSurfaceStabilization(session)?.action, 'open');
assert.equal(pendingSurfaceStabilization(session)?.baselineSignature, undefined);
});

test('every open verdict other than observable marks stabilization', () => {
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/__tests__/direct-ios-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ test('isLocalIosRunnerSession: iOS local sessions are eligible, Android and unde

test('isLocalIosRunnerSession: skipPendingPostGestureStabilization:true excludes a pending session (the tap fast path)', () => {
const pending = makeSession('ios', {
postGestureStabilization: { action: 'scroll', positionals: [], markedAt: Date.now() },
deferredSurface: { kind: 'stabilize', action: 'scroll', positionals: [], markedAt: Date.now() },
});
assert.equal(
isLocalIosRunnerSession(pending, { skipPendingPostGestureStabilization: true }),
Expand All @@ -129,7 +129,7 @@ test('isLocalIosRunnerSession: skipPendingPostGestureStabilization:true excludes

test('isLocalIosRunnerSession: skipPendingPostGestureStabilization:false keeps a pending session eligible (the offscreen double-check)', () => {
const pending = makeSession('ios', {
postGestureStabilization: { action: 'scroll', positionals: [], markedAt: Date.now() },
deferredSurface: { kind: 'stabilize', action: 'scroll', positionals: [], markedAt: Date.now() },
});
assert.equal(
isLocalIosRunnerSession(pending, { skipPendingPostGestureStabilization: false }),
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/__tests__/generic-settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ vi.mock('../interaction/index.ts', async (importOriginal) => {

import { captureSnapshotForSession } from '../interaction/index.ts';
import { dispatchGenericCommand } from '../request-generic-dispatch.ts';
import { pendingSurfaceStabilization } from '../deferred-interaction-outcome.ts';

const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession);

Expand Down Expand Up @@ -98,7 +99,7 @@ async function emulateCaptureSnapshotForSession(
) {
captureObservations.push({
postGestureStabilizationPending:
sessionStore.requireCurrent(ref).postGestureStabilization !== undefined,
pendingSurfaceStabilization(sessionStore.requireCurrent(ref)) !== undefined,
});
const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly };
// Same rule the real seam applies: a request-bound capture is the tree this request reads, and
Expand Down
Loading
Loading