Skip to content

Commit 444a986

Browse files
committed
fix: distinguish Maestro direct selector dispatch
1 parent d8e700a commit 444a986

10 files changed

Lines changed: 121 additions & 40 deletions

packages/contracts/src/interaction-guarantees.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export const INTERACTION_PATH_IDS = [
8080
'target-drag',
8181
'native-ref',
8282
'coordinate',
83+
'maestro-direct-selector',
8384
'maestro-non-hittable-fallback',
8485
] as const;
8586

@@ -410,6 +411,63 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
410411
},
411412
},
412413
},
414+
'maestro-direct-selector': {
415+
description:
416+
'An explicit Maestro-compatible simple-selector click completed as an XCTest element tap. This path is selected only when the runner was allowed to use the non-hittable coordinate fallback but reported that it did not use it.',
417+
commands: ['click'],
418+
guarantees: {
419+
disambiguation: {
420+
kind: 'waived',
421+
reason:
422+
'Intentional: Maestro replay uses its expected-point compatibility scan rather than runtime structural-equivalence-or-reject semantics.',
423+
},
424+
occlusion: {
425+
kind: 'waived',
426+
reason:
427+
'Intentional: the direct element-tap outcome relies on XCTest hittability instead of the daemon snapshot occlusion classifier.',
428+
},
429+
parentOwnedTouchPoint: {
430+
kind: 'waived',
431+
reason:
432+
'gap: the runner has the matched element but no daemon snapshot tree from which to exclude independently interactive descendants.',
433+
trackingIssue: PARENT_OWNED_TOUCH_POINT_GAP_ISSUE,
434+
},
435+
offscreen: {
436+
kind: 'waived',
437+
reason:
438+
'Intentional: successful direct element taps rely on XCTest hittability instead of the daemon viewport rule.',
439+
},
440+
nonHittable: {
441+
kind: 'inapplicable',
442+
reason:
443+
'A non-hittable candidate that succeeds does so through the separate maestro-non-hittable-fallback path.',
444+
},
445+
responseConstruction: SHARED_RESPONSE_CONSTRUCTION,
446+
responseIdentity: {
447+
kind: 'waived',
448+
reason:
449+
'gap: the fused runner request does not return daemon refLabel or selectorChain fields.',
450+
trackingIssue: GAPS_UMBRELLA_ISSUE,
451+
},
452+
verifyEvidence: {
453+
kind: 'inapplicable',
454+
reason: 'The eligibility gate excludes --verify from this replay-only route.',
455+
},
456+
settleObservation: {
457+
kind: 'inapplicable',
458+
reason: 'The eligibility gate excludes --settle from this replay-only route.',
459+
},
460+
errorTaxonomy: {
461+
kind: 'waived',
462+
reason: 'gap: Maestro preserves the runner-native direct-selector error shapes.',
463+
trackingIssue: GAPS_UMBRELLA_ISSUE,
464+
},
465+
resolutionDisclosure: {
466+
kind: 'runtime',
467+
via: 'src/daemon/handlers/interaction-touch-response.ts#buildInteractionResponseData',
468+
},
469+
},
470+
},
413471
'maestro-non-hittable-fallback': {
414472
description:
415473
'Replay-only coordinate fallback for non-hittable elements (allowNonHittableCoordinateFallback), matching Maestro semantics.',

src/__tests__/contracts/interaction-guarantees.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ test('every dispatch path classifies every guarantee', () => {
2626
}
2727
});
2828

29-
test('the Maestro non-hittable fallback classifies its reachable direct click command', () => {
29+
test('direct Maestro clicks and executed coordinate fallbacks are distinct narrow paths', () => {
30+
assert.deepEqual(INTERACTION_DISPATCH_PATHS['maestro-direct-selector'].commands, ['click']);
3031
assert.ok(INTERACTION_DISPATCH_PATHS['maestro-non-hittable-fallback'].commands.includes('click'));
3132
});
3233

@@ -162,6 +163,9 @@ test('acknowledged gaps are visible and bounded', () => {
162163
// updates it here with a linked issue. It is the diffable debt list
163164
// (umbrella: https://github.com/callstack/agent-device/issues/1081).
164165
assert.deepEqual(gaps.sort(), [
166+
'maestro-direct-selector/errorTaxonomy',
167+
'maestro-direct-selector/parentOwnedTouchPoint',
168+
'maestro-direct-selector/responseIdentity',
165169
'maestro-non-hittable-fallback/errorTaxonomy',
166170
'maestro-non-hittable-fallback/parentOwnedTouchPoint',
167171
]);

src/__tests__/eager-closure-budgets.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
144144
'packages/contracts/src/facades/divergence.ts': 3,
145145
'packages/contracts/src/facades/interaction.ts': 25,
146146
'packages/contracts/src/facades/observability.ts': 7,
147-
'packages/contracts/src/facades/platform.ts': 47,
147+
'packages/contracts/src/facades/platform.ts': 48,
148148
'packages/contracts/src/facades/progress.ts': 1,
149149
'packages/contracts/src/facades/recording.ts': 3,
150150
'packages/contracts/src/facades/remote.ts': 2,
@@ -166,7 +166,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
166166
'packages/contracts/src/platform-module.ts': 5,
167167
'packages/contracts/src/platform-runtime-host.ts': 1,
168168
'packages/contracts/src/platform-runtime-operations.ts': 2,
169-
'packages/contracts/src/platform-runtime-unavailable.ts': 20,
169+
'packages/contracts/src/platform-runtime-unavailable.ts': 21,
170170
'packages/contracts/src/platform-runtime.ts': 6,
171171
'packages/contracts/src/record-runtime-cutover.ts': 7,
172172
'packages/contracts/src/screen-recording-runtime-plan.ts': 5,
@@ -180,6 +180,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
180180
'packages/contracts/src/startup-recovery-fence.ts': 1,
181181
'packages/contracts/src/tv-remote.ts': 3,
182182
'packages/contracts/src/type-text-runtime.ts': 4,
183+
'packages/contracts/src/touch-runtime.ts': 4,
183184
'packages/contracts/src/viewport-runtime.ts': 1,
184185
'packages/contracts/src/wait-runtime-plan.ts': 1,
185186
'packages/contracts/src/wait.ts': 1,
@@ -260,8 +261,8 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
260261
*/
261262
export const HUB_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
262263
'src/cli.ts': 362,
263-
'src/platform-runtime.ts': 36,
264-
'src/core/dispatch.ts': 94,
264+
'src/platform-runtime.ts': 37,
265+
'src/core/dispatch.ts': 88,
265266
'src/core/capabilities.ts': 76,
266267
'src/core/command-descriptor/registry.ts': 66,
267268
'src/core/command-descriptor/platform-execution-entry.ts': 3,

src/daemon/handlers/interaction-touch-direct-ios.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ export async function dispatchDirectIosSelectorTap(
6262
data,
6363
publicData,
6464
point,
65-
maestroCoordinateFallbackDispatched: maestroFallback.used,
65+
dispatchPath: maestroFallback.used
66+
? 'maestro-non-hittable-fallback'
67+
: 'maestro-direct-selector',
6668
},
6769
referenceFrame: readReferenceFrameFromDirectSelectorTapResult(data),
6870
extra: {

src/daemon/handlers/interaction-touch-fill.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ function buildFillResponsePayloads(params: {
169169
flags: params.flags,
170170
data: result.backendResult,
171171
}),
172-
maestroCoordinateFallbackDispatched: maestroFallback.used,
172+
...(maestroFallback.used ? { dispatchPath: 'maestro-non-hittable-fallback' as const } : {}),
173173
},
174174
referenceFrame,
175175
extra: { text: params.text, ...maestroFallback.extra },

src/daemon/handlers/interaction-touch-response.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
RecordingTargetOverride,
99
ResolutionDisclosure,
1010
SettleObservation,
11+
InteractionPathId,
1112
} from '@agent-device/contracts/interaction';
1213
import { isApplePlatform } from '@agent-device/kernel/device';
1314
import {
@@ -45,20 +46,19 @@ type InteractionRuntimeResult =
4546
| LongPressCommandResult
4647
| HoverCommandResult;
4748

49+
const MAESTRO_DIRECT_SELECTOR_PATH = 'maestro-direct-selector' satisfies InteractionPathId;
50+
const MAESTRO_COORDINATE_FALLBACK_PATH =
51+
'maestro-non-hittable-fallback' satisfies InteractionPathId;
52+
4853
type InteractionResponseSourceBase = {
4954
publicData?: Record<string, unknown>;
50-
/**
51-
* The runner EXECUTED its Maestro non-hittable coordinate fallback, never the
52-
* mere permission to. Names the dispatch path that ran, not a request flag —
53-
* see {@link suppressesResolutionDisclosure}.
54-
*/
55-
maestroCoordinateFallbackDispatched?: boolean;
5655
};
5756

5857
export type InteractionResponseSource =
5958
| (InteractionResponseSourceBase & {
6059
kind: 'runtime';
6160
result: InteractionRuntimeResult;
61+
dispatchPath?: typeof MAESTRO_COORDINATE_FALLBACK_PATH;
6262
})
6363
| (InteractionResponseSourceBase & {
6464
// Direct iOS selector dispatch: no runtime result exists, only the raw
@@ -67,6 +67,8 @@ export type InteractionResponseSource =
6767
targetKind: InteractionRuntimeResult['kind'];
6868
data: Record<string, unknown>;
6969
point: { x: number; y: number };
70+
/** The runner outcome, not the request's fallback permission. */
71+
dispatchPath: typeof MAESTRO_DIRECT_SELECTOR_PATH | typeof MAESTRO_COORDINATE_FALLBACK_PATH;
7072
})
7173
| (InteractionResponseSourceBase & {
7274
// An XCTest mutation failure was corroborated by a changed same-scope
@@ -111,7 +113,7 @@ function applyResolutionDisclosurePolicy<TExtra extends { resolution?: unknown }
111113
}
112114

113115
function suppressesResolutionDisclosure(source: InteractionResponseSource): boolean {
114-
return source.maestroCoordinateFallbackDispatched === true;
116+
return 'dispatchPath' in source && source.dispatchPath === MAESTRO_COORDINATE_FALLBACK_PATH;
115117
}
116118

117119
export type InteractionResponsePayloads = {

test/integration/interaction-contract/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ContractCoverageEntry } from './coverage-manifest.ts';
22
import { COORDINATE_COVERAGE } from './coordinate.coverage.ts';
3+
import { MAESTRO_DIRECT_SELECTOR_COVERAGE } from './maestro-direct-selector.coverage.ts';
34
import { MAESTRO_FALLBACK_COVERAGE } from './maestro-fallback.coverage.ts';
45
import { NATIVE_REF_COVERAGE } from './native-ref.coverage.ts';
56
import { RUNTIME_REF_COVERAGE } from './runtime-ref.coverage.ts';
@@ -18,6 +19,7 @@ export const CONTRACT_COVERAGE: readonly ContractCoverageEntry[] = [
1819
...TARGET_DRAG_COVERAGE,
1920
...NATIVE_REF_COVERAGE,
2021
...COORDINATE_COVERAGE,
22+
...MAESTRO_DIRECT_SELECTOR_COVERAGE,
2123
...MAESTRO_FALLBACK_COVERAGE,
2224
];
2325

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { assertRpcOk } from '../provider-scenarios/assertions.ts';
4+
import { runnerTapEntry, withIosContractDaemon } from './daemon-harness.ts';
5+
6+
const MAESTRO_FLAGS = { maestro: { allowNonHittableCoordinateFallback: true } };
7+
8+
// Permission is not usage: when the runner hits the element normally, the
9+
// completed dispatch is the direct-selector path, not the coordinate fallback.
10+
test('maestro-direct-selector responseConstruction and resolutionDisclosure: allowed fallback not taken keeps direct-iOS resolution', async () => {
11+
await withIosContractDaemon(
12+
[runnerTapEntry({ x: 50, y: 60, message: 'tapped' })],
13+
async (daemon, transcript) => {
14+
const data = assertRpcOk(await daemon.callCommand('click', ['label=Pin'], MAESTRO_FLAGS));
15+
16+
const tapRequest = transcript.calls[0]?.request as Record<string, unknown> | undefined;
17+
assert.equal(tapRequest?.allowNonHittableCoordinateFallback, true);
18+
assert.equal(data.maestroNonHittableCoordinateFallbackAllowed, true);
19+
assert.equal(data.maestroNonHittableCoordinateFallbackUsed, false);
20+
assert.deepEqual(data.resolution, { source: 'direct-ios', kind: 'not-observed' });
21+
},
22+
);
23+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { definePathCoverage } from './coverage-manifest.ts';
2+
3+
const DIRECT_MAESTRO_CLICK_SCENARIO =
4+
'maestro-direct-selector responseConstruction and resolutionDisclosure: allowed fallback not taken keeps direct-iOS resolution';
5+
6+
export const MAESTRO_DIRECT_SELECTOR_COVERAGE = definePathCoverage('maestro-direct-selector', {
7+
responseConstruction: DIRECT_MAESTRO_CLICK_SCENARIO,
8+
resolutionDisclosure: DIRECT_MAESTRO_CLICK_SCENARIO,
9+
});

test/integration/interaction-contract/maestro-fallback.contract.test.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -63,32 +63,12 @@ test(
6363
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
6464
);
6565

66-
// Permission is not usage: with the fallback allowed but the runner hitting
67-
// the element normally ("tapped"), the dispatch is the direct-ios path and
68-
// must disclose not-observed, not classify as the maestro-fallback cell.
69-
test('maestro-non-hittable-fallback resolutionDisclosure: allowed-but-not-taken discloses direct-ios not-observed', async () => {
70-
await withIosContractDaemon(
71-
[runnerTapEntry({ x: 50, y: 60, message: 'tapped' })],
72-
async (daemon, transcript) => {
73-
const click = await daemon.callCommand('click', ['label=Pin'], MAESTRO_FLAGS);
74-
const data = assertRpcOk(click);
75-
76-
const tapRequest = transcript.calls[0]?.request as Record<string, unknown> | undefined;
77-
assert.equal(tapRequest?.allowNonHittableCoordinateFallback, true);
78-
79-
assert.equal(data.maestroNonHittableCoordinateFallbackAllowed, true);
80-
assert.equal(data.maestroNonHittableCoordinateFallbackUsed, false);
81-
assert.deepEqual(data.resolution, { source: 'direct-ios', kind: 'not-observed' });
82-
},
83-
);
84-
});
85-
86-
// The fourth cell of the resolution-suppression matrix. The other three are
87-
// above: runner-payload/taken (no resolution), runner-payload/not-taken
88-
// (direct-ios not-observed), runtime/taken (no resolution). Suppression is
89-
// keyed on the dispatch that RAN — a runtime fill the fallback never touched
90-
// still discloses how the daemon resolved it, whether or not the request
91-
// carried the Maestro permission.
66+
// The fourth cell of the resolution-suppression matrix. Runner-payload/taken
67+
// and runtime/taken are covered here; runner-payload/not-taken belongs to the
68+
// sibling maestro-direct-selector contract. Suppression is keyed on the
69+
// dispatch that RAN — a runtime fill the fallback never touched still
70+
// discloses how the daemon resolved it, whether or not the request carried the
71+
// Maestro permission.
9272
test('runtime fill the coordinate fallback did not execute keeps its resolution disclosure', async () => {
9373
await withIosContractDaemon(
9474
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTypeEntry({ x: 200, y: 322 })],

0 commit comments

Comments
 (0)