mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
c93dcdbc90
* feat: disclose selector resolution in interaction responses Implements ADR-0012 migration step 1 (decision 2). Adds an additive `resolution` field to press/click/fill/longpress responses: runtime-selector carries the full pre-action diagnostic shape (unique or disambiguated with matchCount/winnerDiagnostic/tiebreak/bounded alternatives), runtime-ref and native-ref carry the exact ref-provenance shape, direct-ios-selector carries the explicit not-observed marker, and coordinate/maestro-non-hittable-fallback stay inapplicable (no field). The comparator in selectors-resolve.ts now records which criterion (visible/deepest/smallest-area) decided each disambiguation without changing resolveSelectorChain's winner. Extends the ADR-0011 guarantee matrix with the resolutionDisclosure guarantee across all six dispatch paths, wires the shared response builder and MCP output schema, adds digest-level trimming (drops alternatives, keeps the verdict/counts), and proves via contract tests that resolution diagnostics are never ref-issued or MCP-pinned and cannot be reused as @ref targets. * fix: address resolution disclosure review findings * refactor: make resolution-disclosure choices self-evident Replace the direct-iOS/maestro message-sniffing (and its justification paragraph) with an explicit maestroFallback flag passed from the dispatch site that already owns the path decision, and shrink every why-this-is-OK paragraph to one-line constraint statements per the maintainer directive. * fix: usage-based maestro fallback disclosure + spec label-fallback Blocker 1: the runner-payload source now carries maestroFallbackUsed derived from the runner's actual execution outcome (the usedNonHittableFallback message bit RunnerTests+CommandExecution.swift reports, the same signal directIosSelectorFallbackDetails already keys on) instead of the permission flag. A fallback-allowed dispatch that hit its element normally discloses direct-ios/not-observed; only an actually-executed coordinate fallback is the inapplicable maestro cell. Contract tests cover both sides. Blocker 2: ADR-0012 decision 2 now defines the ref/label-fallback disclosure (runtime-ref trailing-label recovery via tryResolveRefNode's fallbackLabel; native-ref stays exact because the backend receives only the ref handle), amends the matrix-cell enumeration, layer-3 coverage list, and validation bullet, and the runtime-ref contract suite proves the label-fallback shape. * fix: honest runtime-ref registry cells for label recovery The disambiguation cell no longer claims refs identify exactly one node by construction — trailing-label recovery is a first-match lookup without the ranking, now an intentional waiver whose outcome the label-fallback disclosure surfaces per-response. resolutionDisclosure.via points at tryResolveRefNode (now exported), the resolver producing both exact and label-fallback, with direct unit coverage of both outcomes. * docs: correct native-ref exactness rationale and tiebreak doc Native-ref forwards fallbackLabel to the backend; exact is justified by non-observability of any backend-side label recovery, not by non-forwarding. The tiebreak doc now states the derived winner-vs-runner-up decisive margin. * fix: disclose Maestro fill fallback usage
68 lines
2.6 KiB
TypeScript
68 lines
2.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'vitest';
|
|
import { ref, selector } from '../../../src/commands/index.ts';
|
|
import { drawerWithVisibleTwinSnapshot } from './fixtures.ts';
|
|
import { createContractDevice } from './runtime-harness.ts';
|
|
|
|
// ADR 0012 mutation contract, daemon half: diagnosticRef tokens are not refs.
|
|
// The MCP half (never ref-issued/pinned) lives in src/mcp/__tests__/command-tools.test.ts.
|
|
|
|
test('resolution mutation contract: a diagnosticRef is not a resolvable @ref target', async () => {
|
|
const device = createContractDevice(drawerWithVisibleTwinSnapshot(), {
|
|
tap: async () => ({ ok: true }),
|
|
});
|
|
|
|
const result = await device.interactions.click(selector('label=Profile'), {
|
|
session: 'default',
|
|
});
|
|
|
|
assert.equal(result.kind, 'selector');
|
|
const resolution = result.resolution;
|
|
assert.equal(resolution?.kind, 'disambiguated');
|
|
if (resolution?.kind !== 'disambiguated') return;
|
|
const alternativeDiagnosticRef = resolution.alternatives[0]?.diagnosticRef;
|
|
assert.ok(
|
|
alternativeDiagnosticRef,
|
|
'the disambiguated resolution must carry a losing alternative',
|
|
);
|
|
|
|
// Acting on it without a fresh snapshot gets the ordinary unknown-ref refusal.
|
|
await assert.rejects(
|
|
() => device.interactions.press(ref(`@${alternativeDiagnosticRef}`), { session: 'default' }),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof Error);
|
|
assert.equal((error as { code?: string }).code, 'COMMAND_FAILED');
|
|
assert.match(error.message, new RegExp(`Ref @${alternativeDiagnosticRef} not found`));
|
|
const details = (error as { details?: Record<string, unknown> }).details;
|
|
assert.match(String(details?.hint), /refs expire/i);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('resolution mutation contract: the winner diagnosticRef is also not a resolvable @ref target', async () => {
|
|
const device = createContractDevice(drawerWithVisibleTwinSnapshot(), {
|
|
tap: async () => ({ ok: true }),
|
|
});
|
|
|
|
const result = await device.interactions.click(selector('label=Profile'), {
|
|
session: 'default',
|
|
});
|
|
|
|
assert.equal(result.kind, 'selector');
|
|
const resolution = result.resolution;
|
|
assert.equal(resolution?.kind, 'disambiguated');
|
|
if (resolution?.kind !== 'disambiguated') return;
|
|
const winnerDiagnosticRef = resolution.winnerDiagnostic.diagnosticRef;
|
|
|
|
// The winner's token is equally non-actionable.
|
|
await assert.rejects(
|
|
() => device.interactions.press(ref(`@${winnerDiagnosticRef}`), { session: 'default' }),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof Error);
|
|
assert.equal((error as { code?: string }).code, 'COMMAND_FAILED');
|
|
return true;
|
|
},
|
|
);
|
|
});
|