From 9e3be407e5480c43823d8fe1aaeaac7083b44b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 4 Jul 2026 15:40:33 +0200 Subject: [PATCH 1/2] fix: preserve the runner's non-hittable fallback marker through direct selector press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct iOS selector press handler spread successText('Tapped ') after the runner payload, clobbering the 'tapped via non-hittable coordinate fallback' message that directIosSelectorFallbackDetails keys on — so maestroNonHittableCoordinateFallbackUsed could never be true end-to-end (the existing unit test passes because it mocks dispatchCommand above this layer). Found by the ADR 0011 Layer-3 maestro-fallback contract scenario. Share the marker string as MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE and keep it as the success message when the runner reports fallback usage. --- src/core/dispatch-interactions.ts | 14 ++++++++++++-- src/core/interactor-types.ts | 8 ++++++++ src/daemon/handlers/interaction-touch.ts | 22 ++++++++++++---------- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index 1776abb4a..fc10695e4 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -49,7 +49,11 @@ import { } from './dispatch-series.ts'; import type { RunnerSequenceStep } from '../platforms/apple/core/runner/runner-contract.ts'; import type { DispatchContext } from './dispatch-context.ts'; -import type { Interactor, RunnerCallOptions } from './interactor-types.ts'; +import { + MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE, + type Interactor, + type RunnerCallOptions, +} from './interactor-types.ts'; type ScrollTarget = { direction: ScrollDirection; @@ -185,10 +189,16 @@ async function handleDirectElementSelectorPress( throw new AppError('UNSUPPORTED_OPERATION', 'direct element selector tap is not supported'); } const result = await interactor.tapElementSelector(selector); + // Keep the runner's non-hittable-fallback marker: the daemon reports + // maestroNonHittableCoordinateFallbackUsed from this message, so replacing + // it with the generic success text would silently hide fallback usage. + const usedNonHittableFallback = result?.message === MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE; return { selector: selector.raw, ...(result ?? {}), - ...successText(`Tapped ${selector.raw}`), + ...successText( + usedNonHittableFallback ? MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE : `Tapped ${selector.raw}`, + ), }; } diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index ecd129935..ecaa8f76d 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -55,6 +55,14 @@ export type ElementSelectorTapOptions = { allowNonHittableCoordinateFallback?: boolean; }; +/** + * Wire marker the XCTest runner puts in `message` when a selector tap used the + * Maestro non-hittable coordinate fallback (RunnerTests+CommandExecution.swift). + * The direct-selector press handler must preserve it so the daemon can report + * `maestroNonHittableCoordinateFallbackUsed` truthfully. + */ +export const MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE = 'tapped via non-hittable coordinate fallback'; + export type SnapshotOptions = BaseSnapshotOptions & { appBundleId?: string; includeRects?: boolean; diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index b69446b81..4aa96e515 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -40,6 +40,7 @@ import { import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; +import { MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE } from '../../core/interactor-types.ts'; import { isDirectIosSelectorFallbackError, readSimpleIosSelectorTarget, @@ -253,7 +254,15 @@ function readDirectIosSelectorTapTarget(params: { if (target.kind !== 'selector') return null; if (hasNonDefaultClickOptions(flags)) return null; if (flags?.verify === true) return null; - const selector = readSimpleIosSelectorTarget({ session, selectorExpression: target.selector }); + return readDirectSelectorWithMaestroFallback(session, target.selector, flags); +} + +function readDirectSelectorWithMaestroFallback( + session: SessionState, + selectorExpression: string, + flags: CommandFlags | undefined, +): DirectIosSelectorTarget | null { + const selector = readSimpleIosSelectorTarget({ session, selectorExpression }); if (!selector) return null; return { ...selector, @@ -363,7 +372,7 @@ function directIosSelectorFallbackDetails( data: Record, ): Record { if (!selector.allowNonHittableCoordinateFallback) return {}; - const used = data.message === 'tapped via non-hittable coordinate fallback'; + const used = data.message === MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE; return { maestroNonHittableCoordinateFallbackAllowed: true, maestroNonHittableCoordinateFallbackUsed: used, @@ -463,14 +472,7 @@ function readDirectIosSelectorFillTarget(params: { const { session, target, flags } = params; if (target.kind !== 'selector') return null; if (flags?.verify === true) return null; - const selector = readSimpleIosSelectorTarget({ session, selectorExpression: target.selector }); - if (!selector) return null; - return { - ...selector, - ...(flags?.maestro?.allowNonHittableCoordinateFallback - ? { allowNonHittableCoordinateFallback: true } - : {}), - }; + return readDirectSelectorWithMaestroFallback(session, target.selector, flags); } async function dispatchDirectIosSelectorFill( From 52422924212af32afa6678a598ba091f40b9894a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 4 Jul 2026 15:40:43 +0200 Subject: [PATCH 2/2] test: interaction contract suite with registry-driven coverage gate (ADR 0011 Layer 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/integration/interaction-contract/ holds one scenario file per dispatch path, each with a sibling .coverage.ts manifest declaring which guarantee matrix cells it proves (scenario strings double as the vitest test titles). index.ts aggregates the manifests statically, and a new Layer-3 gate in src/contracts/__tests__/interaction-contract-coverage.test.ts fails when any enforced (runtime/runner/delegated) cell lacks a scenario or when a scenario claims a waived/inapplicable cell — coverage of the matrix is by construction in both directions. Path forcing is natural (no test-only env switch needed): selector/ref targets take the runtime path, a tapTarget backend takes the native-ref fast path, x/y takes the coordinate path, and simple-selector clicks on an iOS provider transcript take the direct runner path (the transcript itself proves which path ran via assertComplete). Fixtures are the permanent Bluesky shapes: closed drawer, drawer + visible twin, edge-grazing container, covered button, non-hittable cell. Refs #1081 --- .../interaction-contract-coverage.test.ts | 66 +++++ .../coordinate.contract.test.ts | 80 ++++++ .../coordinate.coverage.ts | 10 + .../interaction-contract/coverage-manifest.ts | 55 ++++ .../interaction-contract/daemon-harness.ts | 86 ++++++ .../direct-ios-selector.contract.test.ts | 101 +++++++ .../direct-ios-selector.coverage.ts | 10 + .../interaction-contract/fixtures.ts | 251 ++++++++++++++++++ .../integration/interaction-contract/index.ts | 24 ++ .../maestro-fallback.contract.test.ts | 75 ++++++ .../maestro-fallback.coverage.ts | 8 + .../native-ref.contract.test.ts | 145 ++++++++++ .../native-ref.coverage.ts | 17 ++ .../interaction-contract/runtime-harness.ts | 42 +++ .../runtime-ref.contract.test.ts | 142 ++++++++++ .../runtime-ref.coverage.ts | 13 + .../runtime-selector.contract.test.ts | 182 +++++++++++++ .../runtime-selector.coverage.ts | 19 ++ vitest.config.ts | 7 + 19 files changed, 1333 insertions(+) create mode 100644 src/contracts/__tests__/interaction-contract-coverage.test.ts create mode 100644 test/integration/interaction-contract/coordinate.contract.test.ts create mode 100644 test/integration/interaction-contract/coordinate.coverage.ts create mode 100644 test/integration/interaction-contract/coverage-manifest.ts create mode 100644 test/integration/interaction-contract/daemon-harness.ts create mode 100644 test/integration/interaction-contract/direct-ios-selector.contract.test.ts create mode 100644 test/integration/interaction-contract/direct-ios-selector.coverage.ts create mode 100644 test/integration/interaction-contract/fixtures.ts create mode 100644 test/integration/interaction-contract/index.ts create mode 100644 test/integration/interaction-contract/maestro-fallback.contract.test.ts create mode 100644 test/integration/interaction-contract/maestro-fallback.coverage.ts create mode 100644 test/integration/interaction-contract/native-ref.contract.test.ts create mode 100644 test/integration/interaction-contract/native-ref.coverage.ts create mode 100644 test/integration/interaction-contract/runtime-harness.ts create mode 100644 test/integration/interaction-contract/runtime-ref.contract.test.ts create mode 100644 test/integration/interaction-contract/runtime-ref.coverage.ts create mode 100644 test/integration/interaction-contract/runtime-selector.contract.test.ts create mode 100644 test/integration/interaction-contract/runtime-selector.coverage.ts diff --git a/src/contracts/__tests__/interaction-contract-coverage.test.ts b/src/contracts/__tests__/interaction-contract-coverage.test.ts new file mode 100644 index 000000000..c58ae7cd3 --- /dev/null +++ b/src/contracts/__tests__/interaction-contract-coverage.test.ts @@ -0,0 +1,66 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + INTERACTION_DISPATCH_PATHS, + INTERACTION_GUARANTEES, + INTERACTION_PATH_IDS, +} from '../interaction-guarantees.ts'; +import { CONTRACT_COVERAGE } from '../../../test/integration/interaction-contract/index.ts'; + +// ADR 0011 Layer-3 gate: the contract scenario suite is registry-driven. Every +// matrix cell that claims enforcement (runtime, runner, or delegated) must be +// proven by at least one scenario in test/integration/interaction-contract/, +// and no scenario may claim a waived or inapplicable cell — coverage of the +// matrix is by construction, not reviewer memory, in both directions. + +const ENFORCED_KINDS: ReadonlySet = new Set(['runtime', 'runner', 'delegated']); + +function cellKey(pathId: string, guarantee: string): string { + return `${pathId}/${guarantee}`; +} + +test('contract coverage entries reference real matrix cells and named scenarios', () => { + for (const entry of CONTRACT_COVERAGE) { + assert.ok( + (INTERACTION_PATH_IDS as readonly string[]).includes(entry.path), + `coverage entry references unknown path "${entry.path}"`, + ); + assert.ok( + (INTERACTION_GUARANTEES as readonly string[]).includes(entry.guarantee), + `coverage entry references unknown guarantee "${entry.guarantee}"`, + ); + assert.ok( + entry.scenario.trim().length > 10, + `${cellKey(entry.path, entry.guarantee)}: scenario name must describe what it proves`, + ); + } +}); + +test('contract coverage never claims a waived or inapplicable cell', () => { + for (const entry of CONTRACT_COVERAGE) { + const enforcement = INTERACTION_DISPATCH_PATHS[entry.path].guarantees[entry.guarantee]; + // Overclaiming is an error too: a scenario tagged onto a waived cell would + // make the debt list look repaid without flipping the matrix cell. + assert.ok( + ENFORCED_KINDS.has(enforcement.kind), + `${cellKey(entry.path, entry.guarantee)} is "${enforcement.kind}" — drop the coverage entry or flip the matrix cell in the same PR`, + ); + } +}); + +test('every enforced matrix cell has at least one contract scenario', () => { + const covered = new Set(CONTRACT_COVERAGE.map((entry) => cellKey(entry.path, entry.guarantee))); + const missing: string[] = []; + for (const pathId of INTERACTION_PATH_IDS) { + for (const guarantee of INTERACTION_GUARANTEES) { + const enforcement = INTERACTION_DISPATCH_PATHS[pathId].guarantees[guarantee]; + if (!ENFORCED_KINDS.has(enforcement.kind)) continue; + if (!covered.has(cellKey(pathId, guarantee))) missing.push(cellKey(pathId, guarantee)); + } + } + assert.deepEqual( + missing, + [], + 'enforced matrix cells without a contract scenario — add one under test/integration/interaction-contract/ and register its manifest in index.ts', + ); +}); diff --git a/test/integration/interaction-contract/coordinate.contract.test.ts b/test/integration/interaction-contract/coordinate.contract.test.ts new file mode 100644 index 000000000..1ebea0178 --- /dev/null +++ b/test/integration/interaction-contract/coordinate.contract.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; +import { makeSnapshotState } from '../../../src/__tests__/test-utils/index.ts'; +import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { scenarioName } from './coverage-manifest.ts'; +import { COORDINATE_COVERAGE } from './coordinate.coverage.ts'; +import { viewportOnlySnapshot } from './fixtures.ts'; +import { createContractDevice } from './runtime-harness.ts'; +import { runnerTapEntry, runnerTapErrorEntry, withIosContractDaemon } from './daemon-harness.ts'; + +// ADR 0011 Layer 3, coordinate path: raw x/y tap, intentionally minimal +// semantics. Path forcing is natural: a point target never resolves nodes. + +const scenario = (guarantee: InteractionGuarantee): string => + scenarioName(COORDINATE_COVERAGE, guarantee); + +test(scenario('offscreen'), async () => { + const device = createContractDevice(viewportOnlySnapshot(), { + tap: async () => ({ ok: true }), + }); + + const result = await device.interactions.click( + { kind: 'point', x: 500, y: 500 }, + { session: 'default' }, + ); + + // Coordinate semantics: the escape hatch forwards the tap but must warn — + // never a silent out-of-viewport no-op, never a refusal. + assert.equal(result.kind, 'point'); + assert.match(result.warning ?? '', /outside the last-known viewport \(400x800\)/); +}); + +test(scenario('verifyEvidence'), async () => { + let captureCount = 0; + const device = createContractDevice(viewportOnlySnapshot(), { + captureSnapshot: async () => { + captureCount += 1; + if (captureCount === 1) return { snapshot: viewportOnlySnapshot() }; + return { snapshot: makeSnapshotState([]) }; + }, + tap: async () => ({ ok: true }), + }); + + const result = await device.interactions.click( + { kind: 'point', x: 10, y: 20 }, + { session: 'default', verify: true }, + ); + + assert.equal(result.kind, 'point'); + assert.ok(result.evidence); + assert.equal(result.evidence?.changedFromBefore, true); +}); + +test(scenario('errorTaxonomy'), async () => { + await withIosContractDaemon( + [runnerTapErrorEntry(new Error('runner tap crashed'))], + async (daemon) => { + const press = await daemon.callCommand('press', ['100', '200']); + // An unclassified backend failure gets the shared fallback classification + // from normalizeError: stable code, original message, actionable hint. + const error = assertRpcError(press, 'UNKNOWN', /runner tap crashed/); + assert.ok(typeof error.hint === 'string' && error.hint.length > 0); + assert.ok(typeof error.diagnosticId === 'string'); + }, + ); +}); + +test(scenario('responseConstruction'), async () => { + await withIosContractDaemon([runnerTapEntry({ x: 100, y: 200 })], async (daemon) => { + const press = await daemon.callCommand('press', ['100', '200']); + const data = assertRpcOk(press); + // Canonical point response set: the tapped coordinates, no fabricated + // identity fields (the path has no resolved node by design). + assert.equal(data.x, 100); + assert.equal(data.y, 200); + assert.equal(data.ref, undefined); + assert.equal(data.selector, undefined); + }); +}); diff --git a/test/integration/interaction-contract/coordinate.coverage.ts b/test/integration/interaction-contract/coordinate.coverage.ts new file mode 100644 index 000000000..e5ea5de53 --- /dev/null +++ b/test/integration/interaction-contract/coordinate.coverage.ts @@ -0,0 +1,10 @@ +import { definePathCoverage } from './coverage-manifest.ts'; + +export const COORDINATE_COVERAGE = definePathCoverage('coordinate', { + offscreen: 'coordinate offscreen: out-of-viewport point is forwarded with a viewport warning', + responseConstruction: + 'coordinate responseConstruction: daemon press x y response carries the canonical point field set', + verifyEvidence: + 'coordinate verifyEvidence: point click --verify returns a digest with change detection', + errorTaxonomy: 'coordinate errorTaxonomy: backend failure surfaces as a normalized daemon error', +}); diff --git a/test/integration/interaction-contract/coverage-manifest.ts b/test/integration/interaction-contract/coverage-manifest.ts new file mode 100644 index 000000000..d0570e735 --- /dev/null +++ b/test/integration/interaction-contract/coverage-manifest.ts @@ -0,0 +1,55 @@ +import type { + InteractionGuarantee, + InteractionPathId, +} from '../../../src/contracts/interaction-guarantees.ts'; + +/** + * ADR 0011 Layer 3: the machine-readable claim of which interaction guarantee + * matrix cells a scenario file proves. Every `.contract.test.ts` has a + * sibling `.coverage.ts` exporting one of these manifests (kept out of + * the test file so the coverage gate can import it without re-registering the + * scenarios), and `index.ts` aggregates them statically for the gate test in + * `src/contracts/__tests__/interaction-contract-coverage.test.ts`. + * + * Scenario strings double as the vitest test titles — the test files derive + * their titles from the manifest (via `scenarioName`/`scenarioNames`), so a + * claim without a matching scenario cannot be written by accident. + */ +export type ContractCoverageEntry = { + path: InteractionPathId; + guarantee: InteractionGuarantee; + scenario: string; +}; + +export function definePathCoverage( + path: InteractionPathId, + scenarios: Partial>, +): readonly ContractCoverageEntry[] { + return Object.entries(scenarios).flatMap(([guarantee, names]) => + (typeof names === 'string' ? [names] : (names ?? [])).map((scenario) => ({ + path, + guarantee: guarantee as InteractionGuarantee, + scenario, + })), + ); +} + +export function scenarioNames( + coverage: readonly ContractCoverageEntry[], + guarantee: InteractionGuarantee, +): string[] { + const names = coverage + .filter((entry) => entry.guarantee === guarantee) + .map((entry) => entry.scenario); + if (names.length === 0) { + throw new Error(`no contract scenario declared for guarantee "${guarantee}"`); + } + return names; +} + +export function scenarioName( + coverage: readonly ContractCoverageEntry[], + guarantee: InteractionGuarantee, +): string { + return scenarioNames(coverage, guarantee)[0]!; +} diff --git a/test/integration/interaction-contract/daemon-harness.ts b/test/integration/interaction-contract/daemon-harness.ts new file mode 100644 index 000000000..d12bbaeb0 --- /dev/null +++ b/test/integration/interaction-contract/daemon-harness.ts @@ -0,0 +1,86 @@ +import { assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { PROVIDER_SCENARIO_IOS_SIMULATOR } from '../provider-scenarios/fixtures.ts'; +import { + createProviderScenarioHarness, + withProviderScenarioResource, + type ProviderScenarioHarness, +} from '../provider-scenarios/harness.ts'; +import { + createAppleRunnerProviderFromTranscript, + createRecordingAppleToolProvider, + simctlListDevicesHandler, +} from '../provider-scenarios/providers.ts'; +import { + createProviderTranscript, + type ProviderScenarioProviderEntry, + type ProviderScenarioTranscript, +} from '../provider-scenarios/transcript.ts'; + +const CONTRACT_APP = 'com.example.app'; +const CONTRACT_DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id; + +/** + * Provider-transcript harness for contract scenarios whose path involves the + * iOS runner (direct-ios-selector, maestro-non-hittable-fallback) or that + * prove daemon-level response construction. The transcript is the proof + * vehicle: `assertComplete` after `run` guarantees exactly the scripted + * runner conversation happened — a path that dispatched differently either + * consumes an unexpected entry or leaves one behind. + */ +export async function withIosContractDaemon( + entries: readonly ProviderScenarioProviderEntry[], + run: (daemon: ProviderScenarioHarness, transcript: ProviderScenarioTranscript) => Promise, +): Promise { + const transcript = createProviderTranscript(entries); + const appleRunnerProvider = createAppleRunnerProviderFromTranscript(transcript, 'ios.runner'); + const appleTool = createRecordingAppleToolProvider({ + simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: CONTRACT_DEVICE_ID }, + ]), + }); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + appleRunnerProvider: () => appleRunnerProvider, + appleToolProvider: () => appleTool.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + }), + async (daemon) => { + const open = await daemon.callCommand('open', [CONTRACT_APP], { + platform: 'ios', + udid: CONTRACT_DEVICE_ID, + }); + assertRpcOk(open); + await run(daemon, transcript); + transcript.assertComplete(); + }, + ); +} + +export function runnerSnapshotEntry(nodes: readonly unknown[]): ProviderScenarioProviderEntry { + return { + command: 'ios.runner.snapshot', + deviceId: CONTRACT_DEVICE_ID, + platform: 'apple', + result: { nodes, truncated: false }, + }; +} + +export function runnerTapEntry(result: Record): ProviderScenarioProviderEntry { + return { + command: 'ios.runner.tap', + deviceId: CONTRACT_DEVICE_ID, + platform: 'apple', + result, + }; +} + +export function runnerTapErrorEntry(error: Error): ProviderScenarioProviderEntry { + return { + command: 'ios.runner.tap', + deviceId: CONTRACT_DEVICE_ID, + platform: 'apple', + error, + }; +} diff --git a/test/integration/interaction-contract/direct-ios-selector.contract.test.ts b/test/integration/interaction-contract/direct-ios-selector.contract.test.ts new file mode 100644 index 000000000..8dd7586f0 --- /dev/null +++ b/test/integration/interaction-contract/direct-ios-selector.contract.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; +import { AppError } from '../../../src/kernel/errors.ts'; +import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { scenarioName } from './coverage-manifest.ts'; +import { DIRECT_IOS_SELECTOR_COVERAGE } from './direct-ios-selector.coverage.ts'; +import { + RUNNER_CHANGED_NODES, + RUNNER_CLOSED_DRAWER_NODES, + RUNNER_CONTINUE_NODES, +} from './fixtures.ts'; +import { + runnerSnapshotEntry, + runnerTapEntry, + runnerTapErrorEntry, + withIosContractDaemon, +} from './daemon-harness.ts'; + +// ADR 0011 Layer 3, direct-ios-selector path: a simple selector click on an +// iOS session is sent to the XCTest runner without a daemon tree capture. +// Path forcing is natural: `click ` with default flags +// takes the direct path; the transcript proves it (the runner receives a +// selector-keyed tap, not a coordinate one). + +const scenario = (guarantee: InteractionGuarantee): string => + scenarioName(DIRECT_IOS_SELECTOR_COVERAGE, guarantee); + +test(scenario('responseConstruction'), async () => { + await withIosContractDaemon([runnerTapEntry({ x: 150, y: 200 })], async (daemon, transcript) => { + const click = await daemon.callCommand('click', ['label=Continue']); + const data = assertRpcOk(click); + + // The direct path really ran: the single runner call is a selector-keyed + // tap, with no snapshot capture before it. + const tapRequest = transcript.calls[0]?.request as Record | undefined; + assert.equal(transcript.calls[0]?.command, 'ios.runner.tap'); + assert.equal(tapRequest?.selectorKey, 'label'); + assert.equal(tapRequest?.selectorValue, 'Continue'); + + // Canonical runner-payload response set from the shared construction site. + assert.equal(data.x, 150); + assert.equal(data.y, 200); + assert.equal(data.selector, 'label=Continue'); + assert.match(String(data.message), /Tapped label=Continue/); + }); +}); + +test(scenario('offscreen'), async () => { + await withIosContractDaemon( + [ + // The runner refuses a hittable match whose frame lies outside the app + // frame (TapPointPolicy); the daemon must fall back to the runtime path, + // which refuses with the actionable offscreen shape instead of tapping. + runnerTapErrorEntry( + new AppError('ELEMENT_OFFSCREEN', 'Element frame is outside the app frame'), + ), + runnerSnapshotEntry(RUNNER_CLOSED_DRAWER_NODES), + ], + async (daemon) => { + const click = await daemon.callCommand('click', ['label=Explore']); + const error = assertRpcError( + click, + 'COMMAND_FAILED', + /off-screen element and is not safe to click/, + ); + const details = error.details as Record | undefined; + assert.equal(details?.reason, 'offscreen_selector'); + assert.ok(typeof error.hint === 'string'); + }, + ); +}); + +test(scenario('verifyEvidence'), async () => { + await withIosContractDaemon( + [ + runnerSnapshotEntry(RUNNER_CONTINUE_NODES), + runnerTapEntry({ x: 200, y: 322 }), + runnerSnapshotEntry(RUNNER_CHANGED_NODES), + ], + async (daemon, transcript) => { + const click = await daemon.callCommand('click', ['label=Continue'], { verify: true }); + const data = assertRpcOk(click); + + // --verify disables the direct path: the first runner call is the + // runtime path's tree capture, and the tap is coordinate-keyed. + assert.equal(transcript.calls[0]?.command, 'ios.runner.snapshot'); + const tapRequest = transcript.calls.find((call) => call.command === 'ios.runner.tap') + ?.request as Record | undefined; + assert.equal(tapRequest?.selectorKey, undefined); + assert.equal(tapRequest?.x, 200); + + const evidence = data.evidence as Record | undefined; + assert.ok(evidence, 'click --verify must return evidence'); + assert.equal(evidence.changedFromBefore, true); + assert.equal(typeof evidence.digest, 'string'); + // The verify capture's tree must never be serialized into the response. + assert.equal(data.nodes, undefined); + }, + ); +}); diff --git a/test/integration/interaction-contract/direct-ios-selector.coverage.ts b/test/integration/interaction-contract/direct-ios-selector.coverage.ts new file mode 100644 index 000000000..4cb7b2622 --- /dev/null +++ b/test/integration/interaction-contract/direct-ios-selector.coverage.ts @@ -0,0 +1,10 @@ +import { definePathCoverage } from './coverage-manifest.ts'; + +export const DIRECT_IOS_SELECTOR_COVERAGE = definePathCoverage('direct-ios-selector', { + offscreen: + 'direct-ios-selector offscreen: runner ELEMENT_OFFSCREEN falls back to the runtime refusal', + responseConstruction: + 'direct-ios-selector responseConstruction: runner payload response carries the canonical selector field set', + verifyEvidence: + 'direct-ios-selector verifyEvidence: --verify disables the direct path and returns runtime evidence', +}); diff --git a/test/integration/interaction-contract/fixtures.ts b/test/integration/interaction-contract/fixtures.ts new file mode 100644 index 000000000..48ae932d0 --- /dev/null +++ b/test/integration/interaction-contract/fixtures.ts @@ -0,0 +1,251 @@ +import type { SnapshotState } from '../../../src/kernel/snapshot.ts'; +import { makeSnapshotState } from '../../../src/__tests__/test-utils/index.ts'; + +/** + * The permanent contract fixture trees (ADR 0011 Layer 3): the real + * Bluesky-shaped snapshots that found the offscreen/occlusion/non-hittable + * bugs, kept as the shapes every dispatch path is proven against. + */ + +// Closed drawer: the only "Explore" match sits fully left of the Application +// viewport. Tapping it would silently press out-of-viewport coordinates. +export function closedDrawerSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 400, height: 800 }, + hittable: true, + }, + { + index: 1, + depth: 2, + parentIndex: 0, + type: 'Button', + label: 'Explore', + rect: { x: -320, y: 240, width: 300, height: 50 }, + hittable: true, + }, + ]); +} + +// Closed drawer item plus a visible bottom-tab twin: both match +// `label=Profile`, and the on-screen candidate must win disambiguation. +export function drawerWithVisibleTwinSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 400, height: 800 }, + hittable: true, + }, + { + index: 1, + depth: 2, + parentIndex: 0, + type: 'Button', + label: 'Profile', + rect: { x: 20, y: 740, width: 200, height: 50 }, + hittable: true, + }, + { + index: 2, + depth: 3, + parentIndex: 0, + type: 'Button', + label: 'Profile', + rect: { x: -320, y: 240, width: 100, height: 20 }, + hittable: false, + }, + ]); +} + +// Bluesky regression: the closed drawer's overlay container pokes a fraction +// of a pixel into the viewport (float rounding), but every tap point is far +// off-screen. Edge overlap must not count as on-screen. +export function edgeGrazingDrawerSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 402, height: 874 }, + hittable: true, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'Other', + label: 'Explore', + rect: { x: -321.6, y: 0, width: 321.67, height: 874 }, + hittable: false, + }, + { + index: 2, + depth: 3, + parentIndex: 1, + type: 'Button', + label: 'Explore', + rect: { x: -321.6, y: 240, width: 321.33, height: 50 }, + hittable: false, + }, + ]); +} + +// A button flagged covered by a floating tab bar overlay. +export function coveredButtonSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'Button', + label: 'Save draft', + rect: { x: 16, y: 790, width: 140, height: 44 }, + hittable: false, + interactionBlocked: 'covered', + presentationHints: ['covered'], + }, + { + index: 2, + depth: 1, + parentIndex: 0, + type: 'TabBar', + rect: { x: 0, y: 760, width: 390, height: 84 }, + hittable: true, + }, + ]); +} + +// A visible list cell that iOS reports as non-hittable (#1037 shape): the +// interaction must proceed but be annotated. +export function nonHittableCellSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'XCUIElementTypeOther', + label: 'Settings list', + rect: { x: 10, y: 20, width: 300, height: 80 }, + hittable: true, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'XCUIElementTypeCell', + label: 'Account', + rect: { x: 20, y: 10, width: 100, height: 40 }, + hittable: false, + }, + ]); +} + +// Baseline happy-path tree: one hittable button. +export function continueButtonSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Button', + label: 'Continue', + value: 'Continue', + rect: { x: 10, y: 20, width: 100, height: 40 }, + hittable: true, + }, + ]); +} + +// Same button, reported non-hittable. +export function nonHittableButtonSnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Button', + label: 'Continue', + rect: { x: 10, y: 20, width: 100, height: 40 }, + hittable: false, + }, + ]); +} + +// Viewport-only tree for coordinate scenarios. +export function viewportOnlySnapshot(): SnapshotState { + return makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 400, height: 800 }, + hittable: true, + }, + ]); +} + +/** + * Runner-side node payloads (the shape `ios.runner.snapshot` returns) for the + * provider-transcript scenarios. + */ + +export const RUNNER_CONTINUE_NODES = [ + { + index: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 400, height: 800 }, + }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Continue', + hittable: true, + rect: { x: 100, y: 300, width: 200, height: 44 }, + }, +] as const; + +export const RUNNER_CHANGED_NODES = [ + { + index: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 400, height: 800 }, + }, + { + index: 1, + parentIndex: 0, + type: 'StaticText', + label: 'Welcome!', + rect: { x: 100, y: 300, width: 200, height: 44 }, + }, +] as const; + +// Runner-side closed drawer: the only match is off-screen, so the runtime +// fallback path must refuse it instead of tapping out-of-viewport coordinates. +export const RUNNER_CLOSED_DRAWER_NODES = [ + { + index: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 400, height: 800 }, + }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Explore', + hittable: true, + rect: { x: -320, y: 240, width: 300, height: 50 }, + }, +] as const; diff --git a/test/integration/interaction-contract/index.ts b/test/integration/interaction-contract/index.ts new file mode 100644 index 000000000..b80b28bf6 --- /dev/null +++ b/test/integration/interaction-contract/index.ts @@ -0,0 +1,24 @@ +import type { ContractCoverageEntry } from './coverage-manifest.ts'; +import { COORDINATE_COVERAGE } from './coordinate.coverage.ts'; +import { DIRECT_IOS_SELECTOR_COVERAGE } from './direct-ios-selector.coverage.ts'; +import { MAESTRO_FALLBACK_COVERAGE } from './maestro-fallback.coverage.ts'; +import { NATIVE_REF_COVERAGE } from './native-ref.coverage.ts'; +import { RUNTIME_REF_COVERAGE } from './runtime-ref.coverage.ts'; +import { RUNTIME_SELECTOR_COVERAGE } from './runtime-selector.coverage.ts'; + +/** + * Static aggregation of every scenario file's coverage manifest (no dynamic + * globbing — a new scenario file must be added here, and the coverage gate in + * src/contracts/__tests__/interaction-contract-coverage.test.ts fails when an + * enforced matrix cell has no entry). + */ +export const CONTRACT_COVERAGE: readonly ContractCoverageEntry[] = [ + ...RUNTIME_SELECTOR_COVERAGE, + ...RUNTIME_REF_COVERAGE, + ...NATIVE_REF_COVERAGE, + ...COORDINATE_COVERAGE, + ...DIRECT_IOS_SELECTOR_COVERAGE, + ...MAESTRO_FALLBACK_COVERAGE, +]; + +export type { ContractCoverageEntry } from './coverage-manifest.ts'; diff --git a/test/integration/interaction-contract/maestro-fallback.contract.test.ts b/test/integration/interaction-contract/maestro-fallback.contract.test.ts new file mode 100644 index 000000000..ebc2fcc5b --- /dev/null +++ b/test/integration/interaction-contract/maestro-fallback.contract.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; +import { AppError } from '../../../src/kernel/errors.ts'; +import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { scenarioName } from './coverage-manifest.ts'; +import { MAESTRO_FALLBACK_COVERAGE } from './maestro-fallback.coverage.ts'; +import { RUNNER_CLOSED_DRAWER_NODES } from './fixtures.ts'; +import { + runnerSnapshotEntry, + runnerTapEntry, + runnerTapErrorEntry, + withIosContractDaemon, +} from './daemon-harness.ts'; + +// ADR 0011 Layer 3, maestro-non-hittable-fallback path: replay-only +// coordinate fallback for non-hittable elements, Maestro semantics. Path +// forcing is natural: the maestro.allowNonHittableCoordinateFallback flag on +// a simple-selector click forwards the fallback permission to the runner. + +const scenario = (guarantee: InteractionGuarantee): string => + scenarioName(MAESTRO_FALLBACK_COVERAGE, guarantee); + +const MAESTRO_FLAGS = { maestro: { allowNonHittableCoordinateFallback: true } }; + +test(scenario('responseConstruction'), async () => { + await withIosContractDaemon( + [ + runnerTapEntry({ + x: 50, + y: 60, + message: 'tapped via non-hittable coordinate fallback', + }), + ], + async (daemon, transcript) => { + const click = await daemon.callCommand('click', ['label=Pin'], MAESTRO_FLAGS); + const data = assertRpcOk(click); + + // The runner received the fallback permission on the selector tap. + const tapRequest = transcript.calls[0]?.request as Record | undefined; + assert.equal(tapRequest?.selectorValue, 'Pin'); + assert.equal(tapRequest?.allowNonHittableCoordinateFallback, true); + + // Canonical field set plus the fallback markers the replay layer keys on. + assert.equal(data.x, 50); + assert.equal(data.y, 60); + assert.equal(data.selector, 'label=Pin'); + assert.equal(data.maestroNonHittableCoordinateFallbackAllowed, true); + assert.equal(data.maestroNonHittableCoordinateFallbackUsed, true); + assert.equal(data.maestroFallbackReason, 'non-hittable-coordinate'); + }, + ); +}); + +test(scenario('offscreen'), async () => { + await withIosContractDaemon( + [ + // hasTappableFrame refuses empty/out-of-app frames runner-side; the + // daemon then falls back to the runtime path, which refuses with the + // actionable offscreen shape instead of tapping blind coordinates. + runnerTapErrorEntry(new AppError('ELEMENT_OFFSCREEN', 'Element has no tappable frame')), + runnerSnapshotEntry(RUNNER_CLOSED_DRAWER_NODES), + ], + async (daemon) => { + const click = await daemon.callCommand('click', ['label=Explore'], MAESTRO_FLAGS); + const error = assertRpcError( + click, + 'COMMAND_FAILED', + /off-screen element and is not safe to click/, + ); + const details = error.details as Record | undefined; + assert.equal(details?.reason, 'offscreen_selector'); + }, + ); +}); diff --git a/test/integration/interaction-contract/maestro-fallback.coverage.ts b/test/integration/interaction-contract/maestro-fallback.coverage.ts new file mode 100644 index 000000000..50fe03c71 --- /dev/null +++ b/test/integration/interaction-contract/maestro-fallback.coverage.ts @@ -0,0 +1,8 @@ +import { definePathCoverage } from './coverage-manifest.ts'; + +export const MAESTRO_FALLBACK_COVERAGE = definePathCoverage('maestro-non-hittable-fallback', { + offscreen: + 'maestro-non-hittable-fallback offscreen: runner ELEMENT_OFFSCREEN falls back to the runtime refusal', + responseConstruction: + 'maestro-non-hittable-fallback responseConstruction: fallback tap response carries the canonical field set and fallback markers', +}); diff --git a/test/integration/interaction-contract/native-ref.contract.test.ts b/test/integration/interaction-contract/native-ref.contract.test.ts new file mode 100644 index 000000000..c3eaf62af --- /dev/null +++ b/test/integration/interaction-contract/native-ref.contract.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; +import type { SnapshotState } from '../../../src/kernel/snapshot.ts'; +import { ref } from '../../../src/commands/index.ts'; +import { scenarioName } from './coverage-manifest.ts'; +import { NATIVE_REF_COVERAGE } from './native-ref.coverage.ts'; +import { + closedDrawerSnapshot, + continueButtonSnapshot, + coveredButtonSnapshot, + nonHittableCellSnapshot, +} from './fixtures.ts'; +import { createContractDevice } from './runtime-harness.ts'; + +// ADR 0011 Layer 3, native-ref path: click @ref / fill @ref dispatch straight +// to backend.tapTarget/fillTarget. Path forcing is natural: the backend +// declares tapTarget, so an @ref click takes the fast path by construction. +// The zero-round-trip preflight (preflightNativeRefInteraction) must run the +// shared guards against the stored session snapshot node first. + +const scenario = (guarantee: InteractionGuarantee): string => + scenarioName(NATIVE_REF_COVERAGE, guarantee); + +function createNativeRefDevice( + snapshot: SnapshotState, + calls: string[], +): ReturnType { + return createContractDevice(snapshot, { + platform: 'web', + captureSnapshot: async () => { + throw new Error('native ref preflight must not capture a snapshot'); + }, + tapTarget: async (_context, target) => { + calls.push(target.ref); + return { ref: target.ref.replace(/^@/, '') }; + }, + }); +} + +test(scenario('occlusion'), async () => { + const calls: string[] = []; + const device = createNativeRefDevice(coveredButtonSnapshot(), calls); + + await assert.rejects( + () => device.interactions.click(ref('@e2'), { session: 'default' }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /Ref @e2 is covered by another visible element/); + const details = (error as { details?: Record }).details; + assert.equal(details?.interactionBlocked, 'covered'); + return true; + }, + ); + assert.deepEqual(calls, []); +}); + +test(scenario('offscreen'), async () => { + const calls: string[] = []; + const device = createNativeRefDevice(closedDrawerSnapshot(), calls); + + await assert.rejects( + () => device.interactions.click(ref('@e2'), { session: 'default' }), + (error: unknown) => { + assert.ok(error instanceof Error); + // Also the errorTaxonomy claim: the preflight raises the runtime path's + // exact shared shape — code, offscreen_ref reason, and hint. + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.match(error.message, /Ref @e2 is off-screen and not safe to click/); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'offscreen_ref'); + assert.ok(typeof details?.hint === 'string'); + return true; + }, + ); + assert.deepEqual(calls, []); +}); + +test(scenario('nonHittable'), async () => { + const calls: string[] = []; + const device = createNativeRefDevice(nonHittableCellSnapshot(), calls); + + const result = await device.interactions.click(ref('@e2'), { session: 'default' }); + + // Annotation only: the backend still acts on the ref (no promotion on the + // fast path) and the result carries the runtime path's annotation fields. + assert.deepEqual(calls, ['@e2']); + assert.equal(result.kind, 'ref'); + assert.equal(result.targetHittable, false); + assert.match(result.hint ?? '', /hittable: false/); +}); + +test(scenario('verifyEvidence'), async () => { + const calls: string[] = []; + const snapshot = continueButtonSnapshot(); + const device = createContractDevice(snapshot, { + platform: 'web', + captureSnapshot: async () => ({ snapshot }), + tap: async () => ({ ok: true }), + tapTarget: async (_context, target) => { + calls.push(target.ref); + return {}; + }, + }); + + const result = await device.interactions.click(ref('@e1'), { + session: 'default', + verify: true, + }); + + // --verify delegates to the runtime-ref path: the fast path is skipped so a + // baseline and post-action digest can be captured. + assert.deepEqual(calls, []); + assert.equal(result.kind, 'ref'); + assert.ok(result.evidence); + assert.ok(result.evidence?.digest.startsWith('ax1:')); +}); + +test(scenario('responseIdentity'), async () => { + const calls: string[] = []; + const device = createNativeRefDevice(continueButtonSnapshot(), calls); + + const result = await device.interactions.click(ref('@e1'), { session: 'default' }); + + assert.deepEqual(calls, ['@e1']); + assert.equal(result.kind, 'ref'); + assert.deepEqual(result.target, { kind: 'ref', ref: '@e1' }); + assert.deepEqual(result.backendResult, { ref: 'e1' }); +}); + +test(scenario('responseConstruction'), async () => { + const calls: string[] = []; + const device = createNativeRefDevice(continueButtonSnapshot(), calls); + + const result = await device.interactions.click(ref('@e1'), { session: 'default' }); + + // Canonical native-ref field set feeding the shared construction site: the + // ref target and backend result are present, geometry fields the path + // cannot provide stay absent instead of being hand-filled. + assert.equal(result.kind, 'ref'); + assert.deepEqual(result.target, { kind: 'ref', ref: '@e1' }); + assert.deepEqual(result.backendResult, { ref: 'e1' }); + assert.equal(result.point, undefined); + assert.equal(result.node, undefined); +}); diff --git a/test/integration/interaction-contract/native-ref.coverage.ts b/test/integration/interaction-contract/native-ref.coverage.ts new file mode 100644 index 000000000..5cf6c04ba --- /dev/null +++ b/test/integration/interaction-contract/native-ref.coverage.ts @@ -0,0 +1,17 @@ +import { definePathCoverage } from './coverage-manifest.ts'; + +export const NATIVE_REF_COVERAGE = definePathCoverage('native-ref', { + occlusion: 'native-ref occlusion: preflight refuses a covered ref before the backend call', + offscreen: 'native-ref offscreen: preflight refuses an off-screen ref before the backend call', + nonHittable: + 'native-ref nonHittable: preflight annotates a non-hittable ref and still calls the backend', + responseConstruction: + 'native-ref responseConstruction: fast-path result carries the canonical ref field set', + responseIdentity: + 'native-ref responseIdentity: fast-path result echoes the ref target and backend result', + verifyEvidence: 'native-ref verifyEvidence: --verify skips the fast path and returns evidence', + // The preflight raises the runtime path's exact offscreen_ref shape (code, + // reason, hint), which is the shared taxonomy on this path. + errorTaxonomy: + 'native-ref offscreen: preflight refuses an off-screen ref before the backend call', +}); diff --git a/test/integration/interaction-contract/runtime-harness.ts b/test/integration/interaction-contract/runtime-harness.ts new file mode 100644 index 000000000..322c13691 --- /dev/null +++ b/test/integration/interaction-contract/runtime-harness.ts @@ -0,0 +1,42 @@ +import type { AgentDeviceBackend } from '../../../src/backend.ts'; +import type { SnapshotState } from '../../../src/kernel/snapshot.ts'; +import { createLocalArtifactAdapter } from '../../../src/io.ts'; +import { + createAgentDevice, + createMemorySessionStore, + localCommandPolicy, +} from '../../../src/runtime.ts'; + +type ContractBackendOverrides = Partial< + Pick +> & { + platform?: AgentDeviceBackend['platform']; +}; + +/** + * The plain runtime harness for contract scenarios on the paths that never + * touch the runner: runtime-selector, runtime-ref, native-ref (backend + * tapTarget/fillTarget present) and coordinate. Path forcing is natural: + * selector/ref targets pick the runtime path, a `tapTarget`/`fillTarget` + * backend picks the native-ref fast path, x/y picks the coordinate path. + */ +export function createContractDevice( + snapshot: SnapshotState, + overrides: ContractBackendOverrides = {}, +): ReturnType { + return createAgentDevice({ + backend: { + platform: overrides.platform ?? 'ios', + captureSnapshot: async (...args) => + overrides.captureSnapshot ? await overrides.captureSnapshot(...args) : { snapshot }, + tap: async (...args) => await overrides.tap?.(...args), + tapTarget: overrides.tapTarget, + fill: async (...args) => await overrides.fill?.(...args), + fillTarget: overrides.fillTarget, + typeText: async () => {}, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions: createMemorySessionStore([{ name: 'default', snapshot }]), + policy: localCommandPolicy(), + }); +} diff --git a/test/integration/interaction-contract/runtime-ref.contract.test.ts b/test/integration/interaction-contract/runtime-ref.contract.test.ts new file mode 100644 index 000000000..ee642b066 --- /dev/null +++ b/test/integration/interaction-contract/runtime-ref.contract.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; +import type { Point } from '../../../src/kernel/snapshot.ts'; +import { ref } from '../../../src/commands/index.ts'; +import { assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { scenarioName } from './coverage-manifest.ts'; +import { RUNTIME_REF_COVERAGE } from './runtime-ref.coverage.ts'; +import { + closedDrawerSnapshot, + continueButtonSnapshot, + coveredButtonSnapshot, + nonHittableCellSnapshot, + RUNNER_CONTINUE_NODES, +} from './fixtures.ts'; +import { createContractDevice } from './runtime-harness.ts'; +import { runnerSnapshotEntry, runnerTapEntry, withIosContractDaemon } from './daemon-harness.ts'; + +// ADR 0011 Layer 3, runtime-ref path: session snapshot ref lookup, guarded +// coordinate tap. The backend has no tapTarget/fillTarget, so @ref targets +// resolve through the runtime path by construction. + +const scenario = (guarantee: InteractionGuarantee): string => + scenarioName(RUNTIME_REF_COVERAGE, guarantee); + +test(scenario('occlusion'), async () => { + const taps: Point[] = []; + const device = createContractDevice(coveredButtonSnapshot(), { + tap: async (_context, point) => { + taps.push(point); + }, + }); + + await assert.rejects( + () => device.interactions.click(ref('@e2'), { session: 'default' }), + /Ref @e2 is covered by another visible element/, + ); + assert.deepEqual(taps, []); +}); + +test(scenario('offscreen'), async () => { + const taps: Point[] = []; + const device = createContractDevice(closedDrawerSnapshot(), { + tap: async (_context, point) => { + taps.push(point); + }, + }); + + await assert.rejects( + () => device.interactions.click(ref('@e2'), { session: 'default' }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /Ref @e2 is off-screen and not safe to click/); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'offscreen_ref'); + assert.ok(typeof details?.hint === 'string'); + return true; + }, + ); + assert.deepEqual(taps, []); +}); + +test(scenario('nonHittable'), async () => { + const taps: Point[] = []; + const device = createContractDevice(nonHittableCellSnapshot(), { + tap: async (_context, point) => { + taps.push(point); + }, + }); + + const result = await device.interactions.press(ref('@e2'), { session: 'default' }); + + assert.equal(taps.length, 1); + assert.equal(result.kind, 'ref'); + assert.equal(result.targetHittable, false); + assert.match(result.hint ?? '', /hittable: false/); +}); + +test(scenario('verifyEvidence'), async () => { + const device = createContractDevice(continueButtonSnapshot(), { + tap: async () => ({ ok: true }), + }); + + const result = await device.interactions.click(ref('@e1'), { + session: 'default', + verify: true, + }); + + assert.equal(result.kind, 'ref'); + assert.ok(result.evidence); + assert.equal(result.evidence?.changedFromBefore, false); + assert.ok(result.evidence?.digest.startsWith('ax1:')); +}); + +test(scenario('errorTaxonomy'), async () => { + const device = createContractDevice(continueButtonSnapshot(), { + tap: async () => ({ ok: true }), + }); + + await assert.rejects( + () => device.interactions.click(ref('@e9'), { session: 'default' }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.match(error.message, /Ref @e9 not found or has no bounds/); + const details = (error as { details?: Record }).details; + assert.match(String(details?.hint), /refs expire/i); + return true; + }, + ); +}); + +test(scenario('responseIdentity'), async () => { + const device = createContractDevice(continueButtonSnapshot(), { + tap: async () => ({ ok: true }), + }); + + const result = await device.interactions.click(ref('@e1'), { session: 'default' }); + + assert.equal(result.kind, 'ref'); + assert.deepEqual(result.target, { kind: 'ref', ref: '@e1' }); + assert.equal(result.node?.label, 'Continue'); + assert.ok(Array.isArray(result.selectorChain) && result.selectorChain.length > 0); +}); + +test(scenario('responseConstruction'), async () => { + await withIosContractDaemon( + [runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })], + async (daemon) => { + const snapshot = await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true }); + assertRpcOk(snapshot); + + const press = await daemon.callCommand('press', ['@e1']); + const data = assertRpcOk(press); + // Canonical ref response set from the shared construction site. + assert.equal(data.ref, 'e1'); + assert.equal(data.x, 200); + assert.equal(data.y, 322); + assert.match(String(data.message), /Tapped @e1/); + }, + ); +}); diff --git a/test/integration/interaction-contract/runtime-ref.coverage.ts b/test/integration/interaction-contract/runtime-ref.coverage.ts new file mode 100644 index 000000000..323696084 --- /dev/null +++ b/test/integration/interaction-contract/runtime-ref.coverage.ts @@ -0,0 +1,13 @@ +import { definePathCoverage } from './coverage-manifest.ts'; + +export const RUNTIME_REF_COVERAGE = definePathCoverage('runtime-ref', { + occlusion: 'runtime-ref occlusion: covered ref is refused', + offscreen: 'runtime-ref offscreen: closed-drawer ref refused with offscreen_ref', + nonHittable: 'runtime-ref nonHittable: non-hittable ref is annotated but still tapped', + responseConstruction: + 'runtime-ref responseConstruction: daemon press @ref response carries the canonical ref field set', + responseIdentity: 'runtime-ref responseIdentity: result echoes the ref target and resolved node', + verifyEvidence: + 'runtime-ref verifyEvidence: click @ref --verify returns a digest with change detection', + errorTaxonomy: 'runtime-ref errorTaxonomy: unknown ref fails with the stale-ref hint', +}); diff --git a/test/integration/interaction-contract/runtime-selector.contract.test.ts b/test/integration/interaction-contract/runtime-selector.contract.test.ts new file mode 100644 index 000000000..37c1cd8c0 --- /dev/null +++ b/test/integration/interaction-contract/runtime-selector.contract.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; +import type { Point } from '../../../src/kernel/snapshot.ts'; +import { selector } from '../../../src/commands/index.ts'; +import { assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { scenarioName, scenarioNames } from './coverage-manifest.ts'; +import { RUNTIME_SELECTOR_COVERAGE } from './runtime-selector.coverage.ts'; +import { + closedDrawerSnapshot, + continueButtonSnapshot, + coveredButtonSnapshot, + drawerWithVisibleTwinSnapshot, + edgeGrazingDrawerSnapshot, + nonHittableButtonSnapshot, + RUNNER_CONTINUE_NODES, +} from './fixtures.ts'; +import { createContractDevice } from './runtime-harness.ts'; +import { runnerSnapshotEntry, runnerTapEntry, withIosContractDaemon } from './daemon-harness.ts'; + +// ADR 0011 Layer 3, runtime-selector path: daemon tree capture, selector +// chain resolution, guarded coordinate tap. One behavior assertion per +// claimed matrix cell — the deep rule semantics stay in the unit suites. + +const scenario = (guarantee: InteractionGuarantee): string => + scenarioName(RUNTIME_SELECTOR_COVERAGE, guarantee); + +test(scenario('disambiguation'), async () => { + const taps: Point[] = []; + const device = createContractDevice(drawerWithVisibleTwinSnapshot(), { + tap: async (_context, point) => { + taps.push(point); + }, + }); + + const result = await device.interactions.click(selector('label=Profile'), { + session: 'default', + }); + + assert.equal(result.kind, 'selector'); + assert.equal(result.node?.ref, 'e2'); + assert.deepEqual(taps, [{ x: 120, y: 765 }]); +}); + +test(scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'offscreen')[0]!, async () => { + const taps: Point[] = []; + const device = createContractDevice(closedDrawerSnapshot(), { + tap: async (_context, point) => { + taps.push(point); + }, + }); + + await assert.rejects( + () => device.interactions.press(selector('label=Explore'), { session: 'default' }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /off-screen element and is not safe to press/); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'offscreen_selector'); + assert.ok(typeof details?.hint === 'string'); + return true; + }, + ); + assert.deepEqual(taps, []); +}); + +test(scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'offscreen')[1]!, async () => { + const taps: Point[] = []; + const device = createContractDevice(edgeGrazingDrawerSnapshot(), { + tap: async (_context, point) => { + taps.push(point); + }, + }); + + await assert.rejects( + () => device.interactions.press(selector('label=Explore'), { session: 'default' }), + (error: unknown) => { + assert.ok(error instanceof Error); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'offscreen_selector'); + return true; + }, + ); + assert.deepEqual(taps, []); +}); + +test(scenario('occlusion'), async () => { + const taps: Point[] = []; + const device = createContractDevice(coveredButtonSnapshot(), { + tap: async (_context, point) => { + taps.push(point); + }, + }); + + await assert.rejects( + () => device.interactions.click(selector('label="Save draft"'), { session: 'default' }), + /covered by another visible element/, + ); + assert.deepEqual(taps, []); +}); + +test(scenario('nonHittable'), async () => { + const taps: Point[] = []; + const device = createContractDevice(nonHittableButtonSnapshot(), { + tap: async (_context, point) => { + taps.push(point); + }, + }); + + const result = await device.interactions.press(selector('label=Continue'), { + session: 'default', + }); + + assert.equal(taps.length, 1); + assert.equal(result.kind, 'selector'); + assert.equal(result.targetHittable, false); + assert.match(result.hint ?? '', /hittable: false/); +}); + +test(scenario('verifyEvidence'), async () => { + const device = createContractDevice(continueButtonSnapshot(), { + tap: async () => ({ ok: true }), + }); + + const result = await device.interactions.press(selector('label=Continue'), { + session: 'default', + verify: true, + }); + + assert.equal(result.kind, 'selector'); + assert.ok(result.evidence); + assert.equal(result.evidence?.changedFromBefore, false); + assert.ok(result.evidence?.digest.startsWith('ax1:')); +}); + +test(scenario('errorTaxonomy'), async () => { + const device = createContractDevice(continueButtonSnapshot(), { + tap: async () => ({ ok: true }), + }); + + await assert.rejects( + () => device.interactions.press(selector('label=Missing'), { session: 'default' }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.match(error.message, /Selector did not match/); + const details = (error as { details?: Record }).details; + assert.match(String(details?.hint), /snapshot -i/); + return true; + }, + ); +}); + +test(scenario('responseIdentity'), async () => { + const device = createContractDevice(continueButtonSnapshot(), { + tap: async () => ({ ok: true }), + }); + + const result = await device.interactions.press(selector('label=Continue'), { + session: 'default', + }); + + assert.equal(result.kind, 'selector'); + assert.deepEqual(result.target, { kind: 'selector', selector: 'label=Continue' }); + assert.equal(result.node?.label, 'Continue'); + assert.ok(Array.isArray(result.selectorChain) && result.selectorChain.length > 0); +}); + +test(scenario('responseConstruction'), async () => { + await withIosContractDaemon( + [runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })], + async (daemon) => { + const press = await daemon.callCommand('press', ['label=Continue']); + const data = assertRpcOk(press); + // Canonical selector response set from the shared construction site. + assert.equal(data.x, 200); + assert.equal(data.y, 322); + assert.equal(data.selector, 'label=Continue'); + assert.ok(Array.isArray(data.selectorChain)); + }, + ); +}); diff --git a/test/integration/interaction-contract/runtime-selector.coverage.ts b/test/integration/interaction-contract/runtime-selector.coverage.ts new file mode 100644 index 000000000..27e1db9d1 --- /dev/null +++ b/test/integration/interaction-contract/runtime-selector.coverage.ts @@ -0,0 +1,19 @@ +import { definePathCoverage } from './coverage-manifest.ts'; + +export const RUNTIME_SELECTOR_COVERAGE = definePathCoverage('runtime-selector', { + disambiguation: 'runtime-selector disambiguation: visible tab wins over the closed-drawer twin', + occlusion: 'runtime-selector occlusion: covered button is refused', + offscreen: [ + 'runtime-selector offscreen: closed drawer refused with offscreen_selector', + 'runtime-selector offscreen: edge-grazing container is still refused', + ], + nonHittable: 'runtime-selector nonHittable: non-hittable match is annotated but still tapped', + responseConstruction: + 'runtime-selector responseConstruction: daemon press response carries the canonical selector field set', + responseIdentity: + 'runtime-selector responseIdentity: result echoes selectorChain and the resolved node', + verifyEvidence: + 'runtime-selector verifyEvidence: press --verify returns a digest with change detection', + errorTaxonomy: + 'runtime-selector errorTaxonomy: no-match failure carries the shared code and hint', +}); diff --git a/vitest.config.ts b/vitest.config.ts index c8c844ae4..daaf03c07 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,13 @@ export default defineConfig({ setupFiles: ['src/__tests__/process-memo-setup.ts'], }, }, + { + test: { + name: 'interaction-contract', + include: ['test/integration/interaction-contract/**/*.test.ts'], + setupFiles: ['src/__tests__/process-memo-setup.ts'], + }, + }, ], coverage: { provider: 'v8',