mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
aeb2ff8402
#1959 granularized the contracts entry surface but left the wide platform/interaction façades in place as a compatibility surface for ~490 type-only importers. This mechanically moves every importer (~350 files) onto the granular subpath each symbol actually lives on, deletes the two façade files, and removes the fallow ignoreExports entries and eager-closure-budget rows that existed only to cover them. Eight previously-unexported source files needed new package.json subpaths (clipboard, keyboard, network-traffic, platform-plugin, platform-providers, runner-lease-context, screen-recording-runtime-host), and ./interaction now points directly at src/interaction.ts instead of the deleted barrel. .oxlintrc.json's no-restricted-imports rules for the two façades are removed since there's no wide facade left to warn against value-importing.
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'vitest';
|
|
import type { InteractionGuarantee } from '@agent-device/contracts/interaction-guarantees';
|
|
import type { Point } from '@agent-device/kernel/snapshot';
|
|
import { selector } from '../../../src/commands/interaction/runtime/selector-read-utils.ts';
|
|
import { assertRpcOk } from '../provider-scenarios/assertions.ts';
|
|
import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts';
|
|
import { scenarioName, scenarioNames } from './coverage-manifest.ts';
|
|
import { RUNTIME_SELECTOR_COVERAGE } from './runtime-selector.coverage.ts';
|
|
import {
|
|
closedDrawerSnapshot,
|
|
continueButtonSnapshot,
|
|
coveredButtonSnapshot,
|
|
drawerWithVisibleTwinSnapshot,
|
|
equivalentWrapperChainSnapshot,
|
|
edgeGrazingDrawerSnapshot,
|
|
fullyTiledParentSnapshot,
|
|
nonHittableButtonSnapshot,
|
|
RUNNER_CONTINUE_NODES,
|
|
settledWelcomeSnapshot,
|
|
} 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(scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'disambiguation')[0]!, async () => {
|
|
const taps: Point[] = [];
|
|
const device = createContractDevice(equivalentWrapperChainSnapshot(), {
|
|
tap: async (_context, point) => {
|
|
taps.push(point);
|
|
},
|
|
});
|
|
|
|
const result = await device.interactions.click(selector('label=Chat'), {
|
|
session: 'default',
|
|
});
|
|
|
|
assert.equal(result.kind, 'selector');
|
|
assert.equal(result.node?.ref, 'e3');
|
|
assert.equal(result.resolution?.kind, 'disambiguated');
|
|
if (result.resolution?.kind !== 'disambiguated') {
|
|
assert.fail('expected structural-equivalence disclosure');
|
|
}
|
|
assert.equal(result.resolution.tiebreak, 'structural-equivalence');
|
|
assert.deepEqual(taps, [{ x: 70, y: 765 }]);
|
|
});
|
|
|
|
test(scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'disambiguation')[1]!, async () => {
|
|
const taps: Point[] = [];
|
|
const device = createContractDevice(drawerWithVisibleTwinSnapshot(), {
|
|
tap: async (_context, point) => {
|
|
taps.push(point);
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => device.interactions.click(selector('label=Profile'), { session: 'default' }),
|
|
(error: unknown) => {
|
|
const typed = error as { code?: unknown; details?: Record<string, unknown> };
|
|
assert.equal(typed.code, 'AMBIGUOUS_MATCH');
|
|
assert.equal(typed.details?.matches, 2);
|
|
return true;
|
|
},
|
|
);
|
|
assert.deepEqual(taps, []);
|
|
});
|
|
|
|
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<string, unknown> }).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<string, unknown> }).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('parentOwnedTouchPoint'), async () => {
|
|
const taps: Point[] = [];
|
|
const device = createContractDevice(fullyTiledParentSnapshot(), {
|
|
tap: async (_context, point) => {
|
|
taps.push(point);
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => device.interactions.click(selector('label=Card'), { session: 'default' }),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof Error);
|
|
assert.equal((error as { code?: unknown }).code, 'COMMAND_FAILED');
|
|
assert.match(error.message, /Selector label=Card has no parent-owned touch point/);
|
|
const details = (error as { details?: Record<string, unknown> }).details;
|
|
assert.equal(details?.reason, 'covered_by_interactive_descendants');
|
|
assert.equal(details?.selector, 'label=Card');
|
|
assert.equal(details?.ref, undefined);
|
|
assert.deepEqual(details?.competitorRefs, ['@e3', '@e4', '@e5', '@e6', '@e7']);
|
|
assert.equal(details?.competitorCount, 10);
|
|
assert.match(String(details?.hint), /more specific selector/);
|
|
return true;
|
|
},
|
|
);
|
|
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('settleObservation'), async () => {
|
|
const before = continueButtonSnapshot();
|
|
const after = settledWelcomeSnapshot();
|
|
let captures = 0;
|
|
const device = createContractDevice(before, {
|
|
// Resolution capture sees the pre-action tree; every settle capture sees
|
|
// the (already stable) post-action tree.
|
|
captureSnapshot: async () => ({ snapshot: captures++ === 0 ? before : after }),
|
|
tap: async () => ({ ok: true }),
|
|
});
|
|
|
|
const result = await device.interactions.press(selector('label=Continue'), {
|
|
session: 'default',
|
|
settle: { quietMs: 25, timeoutMs: 2_000 },
|
|
});
|
|
|
|
assert.equal(result.kind, 'selector');
|
|
const settle = result.settle;
|
|
assert.ok(settle, 'press --settle must return a settle observation');
|
|
assert.equal(settle.settled, true);
|
|
assert.ok(settle.captures >= 2);
|
|
assert.deepEqual(settle.diff?.summary, { additions: 1, removals: 1, unchanged: 0 });
|
|
const added = settle.diff?.lines.find((line) => line.kind === 'added');
|
|
assert.match(added?.text ?? '', /Welcome!/);
|
|
// Fresh refs ride the diff: the added line's ref resolves on the stored
|
|
// settled tree.
|
|
assert.equal(added?.ref, 'e1');
|
|
});
|
|
|
|
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<string, unknown> }).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));
|
|
},
|
|
);
|
|
},
|
|
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
|
|
);
|
|
|
|
test(scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'resolutionDisclosure')[0]!, 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.resolution, { source: 'runtime', phase: 'pre-action', kind: 'unique' });
|
|
});
|
|
|
|
test(scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'resolutionDisclosure')[1]!, async () => {
|
|
const device = createContractDevice(equivalentWrapperChainSnapshot(), {
|
|
tap: async () => ({ ok: true }),
|
|
});
|
|
|
|
const result = await device.interactions.click(selector('label=Chat'), {
|
|
session: 'default',
|
|
});
|
|
|
|
assert.equal(result.kind, 'selector');
|
|
const resolution = result.resolution;
|
|
assert.equal(resolution?.kind, 'disambiguated');
|
|
if (resolution?.kind !== 'disambiguated') return;
|
|
assert.equal(resolution.source, 'runtime');
|
|
assert.equal(resolution.phase, 'pre-action');
|
|
assert.equal(resolution.matchCount, 3);
|
|
assert.equal(resolution.tiebreak, 'structural-equivalence');
|
|
assert.equal(resolution.winnerDiagnostic.diagnosticRef, 'diag-e3');
|
|
assert.equal(resolution.winnerDiagnostic.label, 'Chat');
|
|
assert.equal(resolution.alternatives.length, 2);
|
|
// The winner never appears among its own alternatives.
|
|
assert.ok(!resolution.alternatives.some((entry) => entry.diagnosticRef === 'diag-e3'));
|
|
});
|