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
66 changes: 66 additions & 0 deletions src/contracts/__tests__/interaction-contract-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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',
);
});
14 changes: 12 additions & 2 deletions src/core/dispatch-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`,
),
};
}

Expand Down
8 changes: 8 additions & 0 deletions src/core/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 12 additions & 10 deletions src/daemon/handlers/interaction-touch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -363,7 +372,7 @@ function directIosSelectorFallbackDetails(
data: Record<string, unknown>,
): Record<string, unknown> {
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,
Expand Down Expand Up @@ -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(
Expand Down
80 changes: 80 additions & 0 deletions test/integration/interaction-contract/coordinate.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions test/integration/interaction-contract/coordinate.coverage.ts
Original file line number Diff line number Diff line change
@@ -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',
});
55 changes: 55 additions & 0 deletions test/integration/interaction-contract/coverage-manifest.ts
Original file line number Diff line number Diff line change
@@ -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 `<path>.contract.test.ts` has a
* sibling `<path>.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<Record<InteractionGuarantee, string | readonly string[]>>,
): 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]!;
}
86 changes: 86 additions & 0 deletions test/integration/interaction-contract/daemon-harness.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
): Promise<void> {
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<string, unknown>): 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,
};
}
Loading
Loading