diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift index e2069072cf..6b77e18683 100644 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift @@ -287,9 +287,14 @@ struct AgentDeviceMacOSHelper { let surface = optionValue(arguments: Array(arguments.dropFirst()), name: "--surface") let app = try resolveTargetApplication(bundleId: bundleId, surface: surface) guard let alertElement = findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) else { + // `reason` is the typed channel the host retries on; the message is for humans only. throw HelperError.commandFailed( "alert not found", - details: ["bundleId": app.bundleIdentifier ?? "", "appName": app.localizedName ?? ""] + details: [ + "reason": "alert-not-found", + "bundleId": app.bundleIdentifier ?? "", + "appName": app.localizedName ?? "", + ] ) } let buttons = collectButtons(root: alertElement) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 18e3a66792..ae140fc47c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -2190,7 +2190,12 @@ extension RunnerTests { Self.alertCommandTimeout(timeoutMs: command.timeoutMs) ) guard let alert = resolveAlert(app: activeApp, deadline: deadline) else { - return Response(ok: false, error: ErrorPayload(message: "alert not found")) + // Typed so the host retries on absence alone: a transport or runner failure carries no + // code and must not be mistaken for "no alert yet" (ALERT_NOT_FOUND_RUNNER_CODE). + return Response( + ok: false, + error: ErrorPayload(code: "ALERT_NOT_FOUND", message: "alert not found") + ) } return handleAlert(alert, action: action, deadline: deadline) case .gesture: diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 8d9fccd2d9..11ab1259f7 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -15,6 +15,14 @@ "types": "./src/alert-contract.ts", "default": "./src/alert-contract.ts" }, + "./alert-runtime": { + "types": "./src/alert-runtime.ts", + "default": "./src/alert-runtime.ts" + }, + "./android-clipboard-support": { + "types": "./src/android-clipboard-support.ts", + "default": "./src/android-clipboard-support.ts" + }, "./android-input-ownership": { "types": "./src/android-input-ownership.ts", "default": "./src/android-input-ownership.ts" @@ -35,6 +43,10 @@ "types": "./src/app-deployment-runtime-plan.ts", "default": "./src/app-deployment-runtime-plan.ts" }, + "./app-event-runtime": { + "types": "./src/app-event-runtime.ts", + "default": "./src/app-event-runtime.ts" + }, "./app-inventory-runtime": { "types": "./src/app-inventory-runtime.ts", "default": "./src/app-inventory-runtime.ts" @@ -47,6 +59,10 @@ "types": "./src/app-state-runtime.ts", "default": "./src/app-state-runtime.ts" }, + "./app-switcher-runtime": { + "types": "./src/app-switcher-runtime.ts", + "default": "./src/app-switcher-runtime.ts" + }, "./apple-multitouch-support": { "types": "./src/apple-multitouch-support.ts", "default": "./src/apple-multitouch-support.ts" @@ -95,6 +111,10 @@ "types": "./src/facades/client.ts", "default": "./src/facades/client.ts" }, + "./clipboard-runtime": { + "types": "./src/clipboard-runtime.ts", + "default": "./src/clipboard-runtime.ts" + }, "./command": { "types": "./src/facades/command.ts", "default": "./src/facades/command.ts" @@ -187,6 +207,10 @@ "types": "./src/keyboard-runtime.ts", "default": "./src/keyboard-runtime.ts" }, + "./local-interactor-operation-set": { + "types": "./src/local-interactor-operation-set.ts", + "default": "./src/local-interactor-operation-set.ts" + }, "./logs-runtime-plan": { "types": "./src/logs-runtime-plan.ts", "default": "./src/logs-runtime-plan.ts" @@ -295,6 +319,10 @@ "types": "./src/settings.ts", "default": "./src/settings.ts" }, + "./settings-runtime": { + "types": "./src/settings-runtime.ts", + "default": "./src/settings-runtime.ts" + }, "./snapshot": { "types": "./src/facades/snapshot.ts", "default": "./src/facades/snapshot.ts" diff --git a/packages/contracts/src/alert-contract.ts b/packages/contracts/src/alert-contract.ts index d0ccd3d9a0..018d5ceb3c 100644 --- a/packages/contracts/src/alert-contract.ts +++ b/packages/contracts/src/alert-contract.ts @@ -2,6 +2,16 @@ export const ALERT_POLL_INTERVAL_MS = 300; export const DEFAULT_ALERT_TIMEOUT_MS = 10_000; export const ALERT_ACTION_RETRY_MS = 2_000; +/** + * The one alert failure the family retries on: the backend looked and there was no alert *yet*. + * Both Apple backends state it in typed form — the XCTest runner as this `ErrorPayload.code` + * (surfacing as `details.runnerErrorCode`, like `RUNNER_BUSY`, without changing the wire error + * code), the macOS helper as `details.reason`. Retry and the fallback hint key on these, never on + * the message text: a transport, runner or helper failure must never read as an absent alert. + */ +export const ALERT_NOT_FOUND_RUNNER_CODE = 'ALERT_NOT_FOUND'; +export const ALERT_NOT_FOUND_REASON = 'alert-not-found'; + export const ALERT_ACTIONS = ['get', 'accept', 'dismiss', 'wait'] as const; export type AlertAction = (typeof ALERT_ACTIONS)[number]; diff --git a/packages/contracts/src/alert-runtime.test.ts b/packages/contracts/src/alert-runtime.test.ts new file mode 100644 index 0000000000..a24cd6ec96 --- /dev/null +++ b/packages/contracts/src/alert-runtime.test.ts @@ -0,0 +1,142 @@ +import { expect, test, vi } from 'vitest'; +import { alertRuntimeOperationFacts, bindAlertLeg } from './alert-runtime.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import type { AlertInteractorOptions, Interactor } from './interactor-types.ts'; + +const device = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, +} as const; + +// The composition the interactor catalog performs, spelled out so each assertion below +// still exercises one facet executor reached through one interactor source. +const bindLocalAlertReadInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindAlertLeg('readAlert', params.signal, localInteractorSource(params)); +const bindLocalAlertWaitInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindAlertLeg('awaitAlert', params.signal, localInteractorSource(params)); +const bindLocalAlertAcceptInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindAlertLeg('acceptAlert', params.signal, localInteractorSource(params)); +const bindLocalAlertDismissInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindAlertLeg('dismissAlert', params.signal, localInteractorSource(params)); +const bindProviderAlertAcceptInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindAlertLeg( + 'acceptAlert', + params.signal, + providerInteractorSource({ ...params, operation: 'alert accept' }), + ); + +test('builds the exact alert operation fact catalog', () => { + const read = { available: true } as const; + const wait = { available: false, reason: 'owner-capability-missing' } as const; + const accept = { available: true } as const; + const dismiss = { available: true } as const; + + expect(alertRuntimeOperationFacts({ read, wait, accept, dismiss })).toEqual({ + readAlert: read, + awaitAlert: wait, + acceptAlert: accept, + dismissAlert: dismiss, + }); +}); + +// The whole input is the window this request allows and the session's own target; the owner +// decides how to spend the window and which backend answers. +test('each local leg forwards the window and the session target to its own owner method', async () => { + const legs = { + readAlert: vi.fn(async () => ({ title: 'Camera Access' })), + awaitAlert: vi.fn(async () => ({ title: 'Camera Access' })), + acceptAlert: vi.fn(async () => ({ accepted: true })), + dismissAlert: vi.fn(async () => ({ dismissed: true })), + }; + const resolveInteractor = vi.fn(async () => legs as unknown as Interactor); + const signal = new AbortController().signal; + const params = { device, signal, resolveInteractor }; + const input = { + timeoutMs: 37, + appBundleId: 'com.example.app', + surface: 'app' as const, + execution: { logPath: '/tmp/daemon.log', requestId: 'alert-1' }, + }; + + await bindLocalAlertReadInteractor(params).readAlert(input); + await bindLocalAlertWaitInteractor(params).awaitAlert(input); + await bindLocalAlertAcceptInteractor(params).acceptAlert(input); + await bindLocalAlertDismissInteractor(params).dismissAlert(input); + + const expectedOptions = { timeoutMs: 37, appBundleId: 'com.example.app', surface: 'app' }; + expect(legs.readAlert).toHaveBeenCalledWith(expectedOptions); + expect(legs.awaitAlert).toHaveBeenCalledWith(expectedOptions); + expect(legs.acceptAlert).toHaveBeenCalledWith(expectedOptions); + expect(legs.dismissAlert).toHaveBeenCalledWith(expectedOptions); + expect(resolveInteractor).toHaveBeenLastCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'alert-1', + appBundleId: 'com.example.app', + signal, + }); +}); + +// A frontmost-app session carries no bundle at all, and the option object must not invent one. +test('an absent target field never reaches the owner as an explicit undefined', async () => { + const readAlert = vi.fn(async (_options?: AlertInteractorOptions) => ({})); + const operations = bindLocalAlertReadInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: async () => ({ readAlert }) as unknown as Interactor, + }); + + await operations.readAlert({ surface: 'frontmost-app' }); + + expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app' }); + expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface']); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderAlertAcceptInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.acceptAlert({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const readAlert = vi.fn(async () => ({})); + const resolveInteractor = vi.fn(async () => ({ readAlert }) as unknown as Interactor); + + const operations = bindLocalAlertReadInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.readAlert({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(readAlert).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/alert-runtime.ts b/packages/contracts/src/alert-runtime.ts new file mode 100644 index 0000000000..59773a58e8 --- /dev/null +++ b/packages/contracts/src/alert-runtime.ts @@ -0,0 +1,118 @@ +import type { AlertInteractorOptions, Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SessionSurface } from './session-surface.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one alert leg. Nothing command-shaped travels here: the daemon has already + * parsed the subcommand, chosen which of the four operations to bind, and turned the CLI's + * optional timeout positional into a millisecond window. + * + * `appBundleId` and `surface` are the session's own target, forwarded as the pair the owner + * needs — a macOS frontmost-app session deliberately carries no bundle, and collapsing the two + * into one field would lose that. + */ +export type AlertRuntimeInput = Readonly<{ + /** The whole window this request allows the owner; absent means the owner's own default. */ + timeoutMs?: number; + appBundleId?: string; + surface?: SessionSurface; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * Owners answer with their own alert payload — Android's discriminated `alertStatus`/`alertWait`/ + * `alertHandled` records, the XCTest runner's alert fields, the macOS helper's. The daemon + * composes the response around whichever it gets, exactly as the retired leaf did. + */ +export type AlertReadRuntimeOperations = Readonly<{ + readAlert(input: AlertRuntimeInput): Promise>; +}>; + +export type AlertWaitRuntimeOperations = Readonly<{ + awaitAlert(input: AlertRuntimeInput): Promise>; +}>; + +export type AlertAcceptRuntimeOperations = Readonly<{ + acceptAlert(input: AlertRuntimeInput): Promise>; +}>; + +export type AlertDismissRuntimeOperations = Readonly<{ + dismissAlert(input: AlertRuntimeInput): Promise>; +}>; + +export type AlertRuntimeOperations = AlertReadRuntimeOperations & + AlertWaitRuntimeOperations & + AlertAcceptRuntimeOperations & + AlertDismissRuntimeOperations; + +export type AlertRuntimeOperationFacts = Readonly<{ + readAlert: RuntimeOperationFact; + awaitAlert: RuntimeOperationFact; + acceptAlert: RuntimeOperationFact; + dismissAlert: RuntimeOperationFact; +}>; + +/** + * Four cells rather than one, because the daemon binds exactly the leg the parsed subcommand + * names (ADR 0019 §9). No owner today observes an alert it cannot act on, so every owner passes + * the same fact four times — but a partial owner would then refuse only the legs it lacks, rather + * than taking the whole command down with it. + */ +export function alertRuntimeOperationFacts( + input: Readonly<{ + read: RuntimeOperationFact; + wait: RuntimeOperationFact; + accept: RuntimeOperationFact; + dismiss: RuntimeOperationFact; + }>, +): AlertRuntimeOperationFacts { + return Object.freeze({ + readAlert: input.read, + awaitAlert: input.wait, + acceptAlert: input.accept, + dismissAlert: input.dismiss, + }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what all four legs share: the runner context and the target. + */ +async function resolveAlertInteractor( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, + input: AlertRuntimeInput, +): Promise { + signal.throwIfAborted(); + return await resolveInteractor({ + ...input.execution, + appBundleId: input.appBundleId, + signal, + }); +} + +function alertInteractorOptions(input: AlertRuntimeInput): AlertInteractorOptions { + return { + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }), + ...(input.surface === undefined ? {} : { surface: input.surface }), + }; +} + +type AlertLeg = 'readAlert' | 'awaitAlert' | 'acceptAlert' | 'dismissAlert'; + +export function bindAlertLeg( + leg: Leg, + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): Readonly Promise>>> { + return Object.freeze({ + [leg]: async (input: AlertRuntimeInput) => { + const interactor = await resolveAlertInteractor(signal, resolveInteractor, input); + return await interactor[leg](alertInteractorOptions(input)); + }, + }) as Readonly Promise>>>; +} diff --git a/packages/contracts/src/android-clipboard-support.ts b/packages/contracts/src/android-clipboard-support.ts new file mode 100644 index 0000000000..81b293c5bd --- /dev/null +++ b/packages/contracts/src/android-clipboard-support.ts @@ -0,0 +1,13 @@ +/** + * What an Android build's clipboard shell service answered when the owner asked. + * + * Three states, not two, because "we could not ask" is not "it works". `cmd clipboard` has no + * shell implementation on every build, and admission has to distinguish a build that said so from + * a probe that never got an answer — equating unknown with supported is how `capabilities` comes + * to advertise a clipboard that execution then refuses. + * + * Contracts carry the typed verdict only. Turning raw adb output into it is Android tool + * knowledge and stays with the Android owner (ADR 0019: platform output parsing belongs to the + * owning family, never to shared vocabulary). + */ +export type AndroidClipboardShellSupport = 'supported' | 'unsupported' | 'probe-failed'; diff --git a/packages/contracts/src/app-event-runtime.test.ts b/packages/contracts/src/app-event-runtime.test.ts new file mode 100644 index 0000000000..1d481549e9 --- /dev/null +++ b/packages/contracts/src/app-event-runtime.test.ts @@ -0,0 +1,85 @@ +import { expect, test, vi } from 'vitest'; +import { appEventRuntimeOperationFacts, bindAppEvent } from './app-event-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +const bindAppEventLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindAppEvent(params.signal, localInteractorSource(params)); +const bindAppEventProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => + bindAppEvent( + params.signal, + providerInteractorSource({ ...params, operation: 'trigger-app-event' }), + ); + +test('builds the exact app-event operation fact catalog', () => { + const triggerAppEvent = { available: true } as const; + expect(appEventRuntimeOperationFacts({ triggerAppEvent })).toEqual({ triggerAppEvent }); +}); + +// The URL is resolved daemon-side from the event name, payload, and per-platform template; what +// the owner receives is that URL and the app to open it against, nothing command-shaped. +test('a local binding opens the resolved event URL against the session app', async () => { + const open = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ open }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindAppEventLocal({ device, signal, resolveInteractor }); + await operations.triggerAppEvent({ + eventUrl: 'myapp://agent-device/event?name=checkout', + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'event-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'event-1', + appBundleId: 'com.example.app', + signal, + }); + expect(open).toHaveBeenCalledWith('myapp://agent-device/event?name=checkout', { + appBundleId: 'com.example.app', + }); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindAppEventProvider({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.triggerAppEvent({ eventUrl: 'myapp://x' })).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const open = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ open }) as unknown as Interactor); + + const operations = bindAppEventLocal({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.triggerAppEvent({ eventUrl: 'myapp://x' })).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/app-event-runtime.ts b/packages/contracts/src/app-event-runtime.ts new file mode 100644 index 0000000000..20b4d2a6dd --- /dev/null +++ b/packages/contracts/src/app-event-runtime.ts @@ -0,0 +1,57 @@ +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one app-event delivery. `eventUrl` arrives already resolved: the event name, + * its payload, and the per-platform URL template are the daemon's policy (env-configured + * templates, size limits, name validation), and none of that is device mechanics. What reaches + * the owner is a URL to open on the device. + */ +export type AppEventInput = Readonly<{ + eventUrl: string; + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * Delivery returns nothing. The retired leaf discarded whatever the interactor answered and + * reported only the event name and URL it sent, so a result type here would be a surface the + * command never had. + */ +export type AppEventRuntimeOperations = Readonly<{ + triggerAppEvent(input: AppEventInput): Promise; +}>; + +export type AppEventRuntimeOperationFacts = Readonly<{ + triggerAppEvent: RuntimeOperationFact; +}>; + +export function appEventRuntimeOperationFacts( + input: Readonly<{ triggerAppEvent: RuntimeOperationFact }>, +): AppEventRuntimeOperationFacts { + return Object.freeze({ triggerAppEvent: input.triggerAppEvent }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the delivery itself. + */ +export function bindAppEvent( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): AppEventRuntimeOperations { + return Object.freeze({ + triggerAppEvent: async (input: AppEventInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + await interactor.open(input.eventUrl, { appBundleId: input.options?.appBundleId }); + }, + }); +} diff --git a/packages/contracts/src/app-switcher-runtime.test.ts b/packages/contracts/src/app-switcher-runtime.test.ts new file mode 100644 index 0000000000..d44edae9b3 --- /dev/null +++ b/packages/contracts/src/app-switcher-runtime.test.ts @@ -0,0 +1,80 @@ +import { expect, test, vi } from 'vitest'; +import { appSwitcherRuntimeOperationFacts, bindAppSwitcher } from './app-switcher-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +const bindAppSwitcherLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindAppSwitcher(params.signal, localInteractorSource(params)); +const bindAppSwitcherProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => + bindAppSwitcher( + params.signal, + providerInteractorSource({ ...params, operation: 'app-switcher' }), + ); + +test('builds the exact app-switcher operation fact catalog', () => { + const appSwitcher = { available: true } as const; + expect(appSwitcherRuntimeOperationFacts({ appSwitcher })).toEqual({ appSwitcher }); +}); + +test('a local binding drives the interactor with the request runner context', async () => { + const appSwitcher = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindAppSwitcherLocal({ device, signal, resolveInteractor }); + await operations.appSwitcher({ + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'switcher-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'switcher-1', + appBundleId: 'com.example.app', + signal, + }); + expect(appSwitcher).toHaveBeenCalledOnce(); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindAppSwitcherProvider({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.appSwitcher({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const appSwitcher = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); + + const operations = bindAppSwitcherLocal({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.appSwitcher({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(appSwitcher).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/app-switcher-runtime.ts b/packages/contracts/src/app-switcher-runtime.ts new file mode 100644 index 0000000000..0852ab5183 --- /dev/null +++ b/packages/contracts/src/app-switcher-runtime.ts @@ -0,0 +1,50 @@ +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one app-switcher reveal: no arguments, so only runner metadata travels — + * the same shape `home` carries, because it is the same springboard surface. + */ +export type AppSwitcherInput = Readonly<{ + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** The reveal returns nothing; the retired leaf discarded whatever the interactor answered. */ +export type AppSwitcherRuntimeOperations = Readonly<{ + appSwitcher(input: AppSwitcherInput): Promise; +}>; + +export type AppSwitcherRuntimeOperationFacts = Readonly<{ + appSwitcher: RuntimeOperationFact; +}>; + +export function appSwitcherRuntimeOperationFacts( + input: Readonly<{ appSwitcher: RuntimeOperationFact }>, +): AppSwitcherRuntimeOperationFacts { + return Object.freeze({ appSwitcher: input.appSwitcher }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the reveal itself. + */ +export function bindAppSwitcher( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): AppSwitcherRuntimeOperations { + return Object.freeze({ + appSwitcher: async (input: AppSwitcherInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + await interactor.appSwitcher(); + }, + }); +} diff --git a/packages/contracts/src/back-runtime.test.ts b/packages/contracts/src/back-runtime.test.ts index 2a33277ec3..9c83e0639e 100644 --- a/packages/contracts/src/back-runtime.test.ts +++ b/packages/contracts/src/back-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalBackInteractor, - bindProviderBackInteractor, - backRuntimeOperationFacts, -} from './back-runtime.ts'; +import { bindBack, backRuntimeOperationFacts } from './back-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -14,6 +11,15 @@ const device = { booted: true, } as const; +const bindBackLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindBack(params.signal, localInteractorSource(params)); +const bindBackProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => bindBack(params.signal, providerInteractorSource({ ...params, operation: 'back' })); + test('builds the exact back operation fact catalog', () => { const back = { available: true } as const; expect(backRuntimeOperationFacts({ back })).toEqual({ back }); @@ -24,7 +30,7 @@ test('a local binding drives the interactor with the requested mode', async () = const resolveInteractor = vi.fn(async () => ({ back }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalBackInteractor({ device, signal, resolveInteractor }); + const operations = bindBackLocal({ device, signal, resolveInteractor }); await operations.back({ mode: 'system', options: { appBundleId: 'com.example.app' }, @@ -45,7 +51,7 @@ test('a provider binding drives its own resolved interactor', async () => { const resolveInteractor = vi.fn(() => ({ back }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindProviderBackInteractor({ device, signal, resolveInteractor }); + const operations = bindBackProvider({ device, signal, resolveInteractor }); await operations.back({ execution: { requestId: 'back-2' } }); expect(resolveInteractor).toHaveBeenCalledWith({ @@ -57,7 +63,7 @@ test('a provider binding drives its own resolved interactor', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderBackInteractor({ + const operations = bindBackProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -75,7 +81,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const back = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ back }) as unknown as Interactor); - const operations = bindLocalBackInteractor({ + const operations = bindBackLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/back-runtime.ts b/packages/contracts/src/back-runtime.ts index 10ea2e65d7..04a5a6e472 100644 --- a/packages/contracts/src/back-runtime.ts +++ b/packages/contracts/src/back-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { BackMode, Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -43,7 +36,7 @@ export function backRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the navigation itself. */ -function bindBack( +export function bindBack( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): BackRuntimeOperations { @@ -59,28 +52,3 @@ function bindBack( }, }); } - -export type LocalBackInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalBackInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalBackInteractorResolver; - }>, -): BackRuntimeOperations { - return bindBack(params.signal, localInteractorSource(params)); -} - -export type ProviderBackInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderBackInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderBackInteractorResolver; - }>, -): BackRuntimeOperations { - return bindBack(params.signal, providerInteractorSource({ ...params, operation: 'back' })); -} diff --git a/packages/contracts/src/clipboard-runtime.test.ts b/packages/contracts/src/clipboard-runtime.test.ts new file mode 100644 index 0000000000..a681602ba7 --- /dev/null +++ b/packages/contracts/src/clipboard-runtime.test.ts @@ -0,0 +1,145 @@ +import { expect, test, vi } from 'vitest'; +import { + bindClipboardRead, + bindClipboardWrite, + clipboardRuntimeOperationFacts, +} from './clipboard-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +// The composition the interactor catalog performs, spelled out so each assertion below +// still exercises one facet executor reached through one interactor source. +const bindLocalClipboardReadInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindClipboardRead(params.signal, localInteractorSource(params)); +const bindLocalClipboardWriteInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindClipboardWrite(params.signal, localInteractorSource(params)); +const bindProviderClipboardReadInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindClipboardRead( + params.signal, + providerInteractorSource({ ...params, operation: 'clipboard read' }), + ); +const bindProviderClipboardWriteInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindClipboardWrite( + params.signal, + providerInteractorSource({ ...params, operation: 'clipboard write' }), + ); + +test('builds the exact clipboard operation fact catalog', () => { + const read = { available: true } as const; + const write = { available: false, reason: 'owner-capability-missing' } as const; + expect(clipboardRuntimeOperationFacts({ read, write })).toEqual({ + readClipboard: read, + writeClipboard: write, + }); +}); + +test('a local read binding returns the interactor pasteboard text verbatim', async () => { + const readClipboard = vi.fn(async () => 'copied\ntext'); + const resolveInteractor = vi.fn(async () => ({ readClipboard }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalClipboardReadInteractor({ device, signal, resolveInteractor }); + await expect( + operations.readClipboard({ + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'clipboard-1' }, + }), + ).resolves.toBe('copied\ntext'); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'clipboard-1', + appBundleId: 'com.example.app', + signal, + }); +}); + +test('a local write binding hands the interactor the already-joined text', async () => { + const writeClipboard = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ writeClipboard }) as unknown as Interactor); + + const operations = bindLocalClipboardWriteInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor, + }); + await operations.writeClipboard({ text: 'hello world' }); + + expect(writeClipboard).toHaveBeenCalledWith('hello world'); +}); + +test('a provider binding drives its own resolved interactor', async () => { + const readClipboard = vi.fn(async () => 'provider text'); + const resolveInteractor = vi.fn(() => ({ readClipboard }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindProviderClipboardReadInteractor({ device, signal, resolveInteractor }); + await expect(operations.readClipboard({ execution: { requestId: 'clipboard-2' } })).resolves.toBe( + 'provider text', + ); + + expect(resolveInteractor).toHaveBeenCalledWith({ + requestId: 'clipboard-2', + appBundleId: undefined, + signal, + }); +}); + +test.each([ + { half: 'read', bind: bindProviderClipboardReadInteractor }, + { half: 'write', bind: bindProviderClipboardWriteInteractor }, +])('a provider $half binding fails closed with no owner interactor', async ({ bind }) => { + const operations = bind({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + const invoke = + 'readClipboard' in operations + ? operations.readClipboard({}) + : operations.writeClipboard({ text: '' }); + await expect(invoke).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const readClipboard = vi.fn(async () => ''); + const resolveInteractor = vi.fn(async () => ({ readClipboard }) as unknown as Interactor); + + const operations = bindLocalClipboardReadInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.readClipboard({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(readClipboard).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/clipboard-runtime.ts b/packages/contracts/src/clipboard-runtime.ts new file mode 100644 index 0000000000..dcbb033981 --- /dev/null +++ b/packages/contracts/src/clipboard-runtime.ts @@ -0,0 +1,94 @@ +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one clipboard read. The operation names no command, request, session, or CLI + * flag: `clipboard read`'s whole input is the runner metadata every request-bound operation + * forwards, which is why the read and the write share this base. + */ +export type ClipboardReadInput = Readonly<{ + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * One clipboard write. `text` is already joined and validated by the caller (`clipboard write` + * accepts `""` to clear), so the owner receives content, never argv. + */ +export type ClipboardWriteInput = ClipboardReadInput & Readonly<{ text: string }>; + +export type ClipboardReadRuntimeOperations = Readonly<{ + readClipboard(input: ClipboardReadInput): Promise; +}>; + +/** + * The write returns nothing. The retired leaf discarded whatever the interactor answered and + * reported only the length of the text it sent, so a result type here would be a surface the + * command never had. + */ +export type ClipboardWriteRuntimeOperations = Readonly<{ + writeClipboard(input: ClipboardWriteInput): Promise; +}>; + +export type ClipboardRuntimeOperations = ClipboardReadRuntimeOperations & + ClipboardWriteRuntimeOperations; + +export type ClipboardRuntimeOperationFacts = Readonly<{ + readClipboard: RuntimeOperationFact; + writeClipboard: RuntimeOperationFact; +}>; + +/** + * Read and write are separate cells because an owner can genuinely have one without the other — + * a WebDriver provider whose Appium clipboard extension exposes only a getter is the real case — + * and `clipboard read` must not be refused because the write half is missing. + */ +export function clipboardRuntimeOperationFacts( + input: Readonly<{ read: RuntimeOperationFact; write: RuntimeOperationFact }>, +): ClipboardRuntimeOperationFacts { + return Object.freeze({ readClipboard: input.read, writeClipboard: input.write }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both operations share: the runner context. + */ +async function resolveClipboardInteractor( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, + input: ClipboardReadInput, +): Promise { + signal.throwIfAborted(); + return await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); +} + +export function bindClipboardRead( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): ClipboardReadRuntimeOperations { + return Object.freeze({ + readClipboard: async (input: ClipboardReadInput) => { + const interactor = await resolveClipboardInteractor(signal, resolveInteractor, input); + return await interactor.readClipboard(); + }, + }); +} + +export function bindClipboardWrite( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): ClipboardWriteRuntimeOperations { + return Object.freeze({ + writeClipboard: async (input: ClipboardWriteInput) => { + const interactor = await resolveClipboardInteractor(signal, resolveInteractor, input); + await interactor.writeClipboard(input.text); + }, + }); +} diff --git a/packages/contracts/src/facades/interaction.ts b/packages/contracts/src/facades/interaction.ts index 826ea4bd85..d6928680f3 100644 --- a/packages/contracts/src/facades/interaction.ts +++ b/packages/contracts/src/facades/interaction.ts @@ -1,6 +1,8 @@ export { ALERT_ACTIONS, ALERT_ACTION_RETRY_MS, + ALERT_NOT_FOUND_REASON, + ALERT_NOT_FOUND_RUNNER_CODE, ALERT_POLL_INTERVAL_MS, DEFAULT_ALERT_TIMEOUT_MS, } from '../alert-contract.ts'; @@ -117,6 +119,7 @@ export { TEXT_ENTRY_ROUTES, } from '../interactor-types.ts'; export type { + AlertInteractorOptions, CloudTextEntryReadiness, ElementSelectorKey, ElementSelectorTapOptions, diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index bafcb39b72..85b3f9f38e 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -245,6 +245,17 @@ export { keyboardStatusUse, keyboardDismissUse, keyboardEnterUse, + appEventRuntimeUse, + settingsRuntimeUse, + alertRuntimePlanUses, + alertReadUse, + alertWaitUse, + alertAcceptUse, + alertDismissUse, + appSwitcherRuntimeUse, + clipboardRuntimePlanUses, + clipboardReadUse, + clipboardWriteUse, } from '../platform-runtime-operations.ts'; export type { GestureRuntimePlan, @@ -373,62 +384,34 @@ export type { TypeTextRuntimeOperationFacts, TypeTextRuntimeOperations, } from '../type-text-runtime.ts'; -export { - bindLocalBackInteractor, - bindProviderBackInteractor, - backRuntimeOperationFacts, -} from '../back-runtime.ts'; +export { backRuntimeOperationFacts, bindBack } from '../back-runtime.ts'; export type { BackInput, BackRuntimeOperationFacts, BackRuntimeOperations, - LocalBackInteractorResolver, - ProviderBackInteractorResolver, } from '../back-runtime.ts'; -export { - bindLocalHomeInteractor, - bindProviderHomeInteractor, - homeRuntimeOperationFacts, -} from '../home-runtime.ts'; +export { bindHome, homeRuntimeOperationFacts } from '../home-runtime.ts'; export type { HomeInput, HomeRuntimeOperationFacts, HomeRuntimeOperations, - LocalHomeInteractorResolver, - ProviderHomeInteractorResolver, } from '../home-runtime.ts'; -export { - bindLocalOrientationInteractor, - bindProviderOrientationInteractor, - orientationRuntimeOperationFacts, -} from '../orientation-runtime.ts'; +export { bindOrientation, orientationRuntimeOperationFacts } from '../orientation-runtime.ts'; export type { - LocalOrientationInteractorResolver, OrientationRuntimeOperationFacts, OrientationRuntimeOperations, - ProviderOrientationInteractorResolver, SetOrientationInput, SetOrientationResult, } from '../orientation-runtime.ts'; -export { - bindLocalTvRemoteInteractor, - bindProviderTvRemoteInteractor, - tvRemoteRuntimeOperationFacts, -} from '../tv-remote-runtime.ts'; +export { bindTvRemote, tvRemoteRuntimeOperationFacts } from '../tv-remote-runtime.ts'; export type { - LocalTvRemoteInteractorResolver, - ProviderTvRemoteInteractorResolver, TvRemoteInput, TvRemoteRuntimeOperationFacts, TvRemoteRuntimeOperations, } from '../tv-remote-runtime.ts'; export { - bindLocalKeyboardStatusInteractor, - bindProviderKeyboardStatusInteractor, - bindLocalKeyboardDismissInteractor, - bindProviderKeyboardDismissInteractor, - bindLocalKeyboardEnterInteractor, - bindProviderKeyboardEnterInteractor, + KEYBOARD_ACTION_LABELS, + bindKeyboardAction, keyboardRuntimeOperationFacts, } from '../keyboard-runtime.ts'; export type { @@ -441,8 +424,6 @@ export type { KeyboardRuntimeOperations, KeyboardStatusResult, KeyboardStatusRuntimeOperations, - LocalKeyboardInteractorResolver, - ProviderKeyboardInteractorResolver, } from '../keyboard-runtime.ts'; export { APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS } from '../apple-multitouch-support.ts'; export { viewportRuntimeOperationFacts } from '../viewport-runtime.ts'; diff --git a/packages/contracts/src/home-runtime.test.ts b/packages/contracts/src/home-runtime.test.ts index ac95afce8d..e1860561ef 100644 --- a/packages/contracts/src/home-runtime.test.ts +++ b/packages/contracts/src/home-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalHomeInteractor, - bindProviderHomeInteractor, - homeRuntimeOperationFacts, -} from './home-runtime.ts'; +import { bindHome, homeRuntimeOperationFacts } from './home-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -14,6 +11,15 @@ const device = { booted: true, } as const; +const bindHomeLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindHome(params.signal, localInteractorSource(params)); +const bindHomeProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => bindHome(params.signal, providerInteractorSource({ ...params, operation: 'home' })); + test('builds the exact home operation fact catalog', () => { const home = { available: true } as const; expect(homeRuntimeOperationFacts({ home })).toEqual({ home }); @@ -24,7 +30,7 @@ test('a local binding drives the interactor with no arguments', async () => { const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalHomeInteractor({ device, signal, resolveInteractor }); + const operations = bindHomeLocal({ device, signal, resolveInteractor }); await operations.home({ options: { appBundleId: 'com.example.app' }, execution: { logPath: '/tmp/daemon.log', requestId: 'home-1' }, @@ -44,7 +50,7 @@ test('a provider binding drives its own resolved interactor', async () => { const resolveInteractor = vi.fn(() => ({ home }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindProviderHomeInteractor({ device, signal, resolveInteractor }); + const operations = bindHomeProvider({ device, signal, resolveInteractor }); await operations.home({ execution: { requestId: 'home-2' } }); expect(resolveInteractor).toHaveBeenCalledWith({ @@ -56,7 +62,7 @@ test('a provider binding drives its own resolved interactor', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderHomeInteractor({ + const operations = bindHomeProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -74,7 +80,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const home = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); - const operations = bindLocalHomeInteractor({ + const operations = bindHomeLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/home-runtime.ts b/packages/contracts/src/home-runtime.ts index e34d9bde28..006bd2b810 100644 --- a/packages/contracts/src/home-runtime.ts +++ b/packages/contracts/src/home-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -36,7 +29,7 @@ export function homeRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the navigation itself. */ -function bindHome( +export function bindHome( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): HomeRuntimeOperations { @@ -52,28 +45,3 @@ function bindHome( }, }); } - -export type LocalHomeInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalHomeInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalHomeInteractorResolver; - }>, -): HomeRuntimeOperations { - return bindHome(params.signal, localInteractorSource(params)); -} - -export type ProviderHomeInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderHomeInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderHomeInteractorResolver; - }>, -): HomeRuntimeOperations { - return bindHome(params.signal, providerInteractorSource({ ...params, operation: 'home' })); -} diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 26a7d88595..b787dd3b9c 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -1,92 +1,104 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { bindLocalBackInteractor, bindProviderBackInteractor } from './back-runtime.ts'; -import { bindLocalHomeInteractor, bindProviderHomeInteractor } from './home-runtime.ts'; +import { bindAlertLeg } from './alert-runtime.ts'; +import { bindAppEvent } from './app-event-runtime.ts'; +import { bindAppSwitcher } from './app-switcher-runtime.ts'; +import { bindBack } from './back-runtime.ts'; +import { bindClipboardRead, bindClipboardWrite } from './clipboard-runtime.ts'; +import { bindHome } from './home-runtime.ts'; +import { KEYBOARD_ACTION_LABELS, bindKeyboardAction } from './keyboard-runtime.ts'; +import { bindOrientation } from './orientation-runtime.ts'; +import { bindSetSetting } from './settings-runtime.ts'; +import { bindTvRemote } from './tv-remote-runtime.ts'; import { - bindLocalKeyboardDismissInteractor, - bindLocalKeyboardEnterInteractor, - bindLocalKeyboardStatusInteractor, - bindProviderKeyboardDismissInteractor, - bindProviderKeyboardEnterInteractor, - bindProviderKeyboardStatusInteractor, -} from './keyboard-runtime.ts'; -import { - bindLocalOrientationInteractor, - bindProviderOrientationInteractor, -} from './orientation-runtime.ts'; -import type { - LocalInteractorOperationResolver, - ProviderInteractorOperationResolver, + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, } from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; -import { - bindLocalTvRemoteInteractor, - bindProviderTvRemoteInteractor, -} from './tv-remote-runtime.ts'; /** - * The seven navigation/keyboard operations every owner admits from the same shape: one fact, - * one bind call, no owner mechanics in between. This tuple is the single canonical declaration: - * the {@link NavigationInteractorOperation} union type is derived from it below, and - * `LOCAL_BINDERS`/`PROVIDER_BINDERS`'s `Record` types are then - * checked against that derived union — so a member can never be added to one and silently missing - * from another. Not caller-supplied: `facts[operation]` is the only thing that decides whether an - * operation binds — a key this tuple names but the caller's facts never define is simply never - * available (`facts[operation]?.available` reads `undefined`), which is how a caller passing a - * narrower, dedicated facts object (e.g. Limrun's keyboard-less navigation facts) opts a subset out. + * How a facet turns one resolved interactor into its own typed operations. Every catalog member + * shares this shape, which is what lets one adapter drive all of them: the facet owns what the + * operation *does*, and this module owns only which interactor it reaches and how a refusal reads. */ -const NAVIGATION_INTERACTOR_OPERATIONS = [ - 'back', - 'home', - 'setOrientation', - 'tvRemote', - 'keyboardStatus', - 'keyboardDismiss', - 'keyboardEnter', -] as const; +type InteractorOperationBinder = ( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +) => Partial; -export type NavigationInteractorOperation = (typeof NAVIGATION_INTERACTOR_OPERATIONS)[number]; - -type LocalBinderParams = Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalInteractorOperationResolver; -}>; -type ProviderBinderParams = Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderInteractorOperationResolver; +type InteractorOperationDefinition = Readonly<{ + /** The facts key that admits it, and the operations key it binds. */ + operation: keyof PlatformRuntimeOperations; + /** How a provider fail-closed refusal names this operation to the caller. */ + label: string; + bind: InteractorOperationBinder; }>; -const LOCAL_BINDERS: Readonly< - Record< - NavigationInteractorOperation, - (params: LocalBinderParams) => Partial - > -> = Object.freeze({ - back: bindLocalBackInteractor, - home: bindLocalHomeInteractor, - setOrientation: bindLocalOrientationInteractor, - tvRemote: bindLocalTvRemoteInteractor, - keyboardStatus: bindLocalKeyboardStatusInteractor, - keyboardDismiss: bindLocalKeyboardDismissInteractor, - keyboardEnter: bindLocalKeyboardEnterInteractor, -}); +/** + * Every operation whose whole binding is "one fact, one bind call, no owner mechanics in between" + * — the navigation/keyboard leaves and the system-surface leaves that joined them in Wave 6. + * + * One row per operation, declared once. It used to take three parallel declarations — a name + * tuple, a local binder map, and a provider binder map — plus a mirrored + * `bindLocal…Interactor`/`bindProvider…Interactor` pair in each facet whose only difference was + * which interactor source to use and which label to name in a refusal. Both of those now live + * here, in the two adapters below, so adding an operation is adding one row. + * + * Not caller-supplied: `facts[operation]` alone decides whether an operation binds, so a row whose + * key the caller's facts never define is simply never available — which is how an owner with a + * narrower, dedicated facts object (Limrun's keyboard-less navigation facts) opts a subset out. + */ +const INTERACTOR_OPERATIONS = [ + { operation: 'back', label: 'back', bind: bindBack }, + { operation: 'home', label: 'home', bind: bindHome }, + { operation: 'setOrientation', label: 'orientation', bind: bindOrientation }, + { operation: 'tvRemote', label: 'tv-remote', bind: bindTvRemote }, + { + operation: 'keyboardStatus', + label: KEYBOARD_ACTION_LABELS.keyboardStatus, + bind: (signal, resolve) => bindKeyboardAction('keyboardStatus', signal, resolve), + }, + { + operation: 'keyboardDismiss', + label: KEYBOARD_ACTION_LABELS.keyboardDismiss, + bind: (signal, resolve) => bindKeyboardAction('keyboardDismiss', signal, resolve), + }, + { + operation: 'keyboardEnter', + label: KEYBOARD_ACTION_LABELS.keyboardEnter, + bind: (signal, resolve) => bindKeyboardAction('keyboardEnter', signal, resolve), + }, + { operation: 'readClipboard', label: 'clipboard read', bind: bindClipboardRead }, + { operation: 'writeClipboard', label: 'clipboard write', bind: bindClipboardWrite }, + { operation: 'appSwitcher', label: 'app-switcher', bind: bindAppSwitcher }, + { operation: 'triggerAppEvent', label: 'trigger-app-event', bind: bindAppEvent }, + { operation: 'setSetting', label: 'settings', bind: bindSetSetting }, + { + operation: 'readAlert', + label: 'alert get', + bind: (signal, resolve) => bindAlertLeg('readAlert', signal, resolve), + }, + { + operation: 'awaitAlert', + label: 'alert wait', + bind: (signal, resolve) => bindAlertLeg('awaitAlert', signal, resolve), + }, + { + operation: 'acceptAlert', + label: 'alert accept', + bind: (signal, resolve) => bindAlertLeg('acceptAlert', signal, resolve), + }, + { + operation: 'dismissAlert', + label: 'alert dismiss', + bind: (signal, resolve) => bindAlertLeg('dismissAlert', signal, resolve), + }, +] as const satisfies readonly InteractorOperationDefinition[]; -const PROVIDER_BINDERS: Readonly< - Record< - NavigationInteractorOperation, - (params: ProviderBinderParams) => Partial - > -> = Object.freeze({ - back: bindProviderBackInteractor, - home: bindProviderHomeInteractor, - setOrientation: bindProviderOrientationInteractor, - tvRemote: bindProviderTvRemoteInteractor, - keyboardStatus: bindProviderKeyboardStatusInteractor, - keyboardDismiss: bindProviderKeyboardDismissInteractor, - keyboardEnter: bindProviderKeyboardEnterInteractor, -}); +export type CatalogInteractorOperation = (typeof INTERACTOR_OPERATIONS)[number]['operation']; /** * The operation-facts slice every caller already holds: an owner's full `RuntimeFacts.operations` @@ -95,67 +107,56 @@ const PROVIDER_BINDERS: Readonly< * `limrunNavigationOperationFacts`), that flat map directly — which may cover only the subset of * operations the owner admits at all, so a missing key here is simply never bindable. */ -type NavigationOperationFacts = Readonly< - Partial> +type CatalogOperationFacts = Readonly< + Partial> >; -/** Walks every navigation operation against one binder table, binding each the facts admitted. */ +type CatalogBindParams = Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: Resolver; + facts: CatalogOperationFacts; +}>; + +/** + * Walks the catalog once, binding each operation the facts admitted through whichever interactor + * source the caller's entry point supplies. The source is the only thing that differs between a + * local owner and a provider. + */ function bindAdmittedInteractorOperations( - binders: Readonly< - Record< - NavigationInteractorOperation, - (params: { - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: Resolver; - }) => Partial - > - >, - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: Resolver; - facts: NavigationOperationFacts; - }>, + params: CatalogBindParams, + source: ( + definition: InteractorOperationDefinition, + ) => (runner: RunnerContext) => Promise, ): Partial { - const { device, signal, resolveInteractor, facts } = params; const bound: Partial = {}; - for (const operation of NAVIGATION_INTERACTOR_OPERATIONS) { - if (facts[operation]?.available) { - Object.assign(bound, binders[operation]({ device, signal, resolveInteractor })); - } + for (const definition of INTERACTOR_OPERATIONS) { + if (!params.facts[definition.operation]?.available) continue; + Object.assign(bound, definition.bind(params.signal, source(definition))); } return bound; } /** * Binds whichever local operations the owner's own facts admitted. The owner keeps full - * authority — its facts alone decide what binds — this only removes the seven-times-repeated + * authority — its facts alone decide what binds — this only removes the once-per-operation * ternary that read them. */ export function bindAdmittedLocalInteractorOperations( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalInteractorOperationResolver; - facts: NavigationOperationFacts; - }>, + params: CatalogBindParams, ): Partial { - return bindAdmittedInteractorOperations(LOCAL_BINDERS, params); + return bindAdmittedInteractorOperations(params, () => localInteractorSource(params)); } /** - * Binds whichever provider operations the owner's own facts admitted. Provider bindings fail - * closed when their exact owner no longer exposes its interactor (see the individual - * `bindProvider…Interactor` functions this table dispatches to). + * Binds whichever provider operations the owner's own facts admitted, failing closed when the + * exact owner no longer exposes its interactor. Each refusal names the operation the caller asked + * for, which is what the per-operation `label` carries. */ export function bindAdmittedProviderInteractorOperations( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderInteractorOperationResolver; - facts: NavigationOperationFacts; - }>, + params: CatalogBindParams, ): Partial { - return bindAdmittedInteractorOperations(PROVIDER_BINDERS, params); + return bindAdmittedInteractorOperations(params, (definition) => + providerInteractorSource({ ...params, operation: definition.label }), + ); } diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 1242668861..cd761470b9 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -347,4 +347,25 @@ export type Interactor = { appId?: string, options?: SettingOptions, ): Promise | void>; + /** + * The four alert legs. Each owner runs its own observation and, where it needs one, its own + * poll: an alert is a transient device surface, and how long to look for it — and how to press + * its buttons — is family mechanics, not something a caller can supply. `timeoutMs` is the + * whole window the caller allows; the owner spends it however its backend requires. + */ + readAlert(options?: AlertInteractorOptions): Promise>; + awaitAlert(options?: AlertInteractorOptions): Promise>; + acceptAlert(options?: AlertInteractorOptions): Promise>; + dismissAlert(options?: AlertInteractorOptions): Promise>; +}; + +/** + * The session-derived target one alert leg acts on. `appBundleId` is separate from the runner + * context's because the macOS helper reads a frontmost-app surface with no bundle at all, and + * the two must not collapse into one field that means both. + */ +export type AlertInteractorOptions = { + timeoutMs?: number; + appBundleId?: string; + surface?: SessionSurface; }; diff --git a/packages/contracts/src/keyboard-runtime.test.ts b/packages/contracts/src/keyboard-runtime.test.ts index c1876a9053..f34dc01703 100644 --- a/packages/contracts/src/keyboard-runtime.test.ts +++ b/packages/contracts/src/keyboard-runtime.test.ts @@ -1,14 +1,11 @@ import { expect, test, vi } from 'vitest'; import { - bindLocalKeyboardDismissInteractor, - bindLocalKeyboardEnterInteractor, - bindLocalKeyboardStatusInteractor, - bindProviderKeyboardDismissInteractor, - bindProviderKeyboardEnterInteractor, - bindProviderKeyboardStatusInteractor, + KEYBOARD_ACTION_LABELS, + bindKeyboardAction, keyboardRuntimeOperationFacts, } from './keyboard-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -18,6 +15,54 @@ const device = { booted: true, } as const; +// The composition the interactor catalog performs, spelled out so each assertion below +// still exercises one facet executor reached through one interactor source. +const bindLocalKeyboardStatusInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindKeyboardAction('keyboardStatus', params.signal, localInteractorSource(params)); +const bindLocalKeyboardDismissInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindKeyboardAction('keyboardDismiss', params.signal, localInteractorSource(params)); +const bindLocalKeyboardEnterInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindKeyboardAction('keyboardEnter', params.signal, localInteractorSource(params)); +const bindProviderKeyboardStatusInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindKeyboardAction( + 'keyboardStatus', + params.signal, + providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardStatus }), + ); +const bindProviderKeyboardDismissInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindKeyboardAction( + 'keyboardDismiss', + params.signal, + providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardDismiss }), + ); +const bindProviderKeyboardEnterInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindKeyboardAction( + 'keyboardEnter', + params.signal, + providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardEnter }), + ); + test('builds the exact keyboard operation fact catalog', () => { const status = { available: true } as const; const dismiss = { available: false, reason: 'unsupported-platform-leaf' } as const; diff --git a/packages/contracts/src/keyboard-runtime.ts b/packages/contracts/src/keyboard-runtime.ts index f621a6a156..6f96627a05 100644 --- a/packages/contracts/src/keyboard-runtime.ts +++ b/packages/contracts/src/keyboard-runtime.ts @@ -1,11 +1,4 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, KeyboardDismissResult, @@ -100,7 +93,7 @@ async function resolveKeyboardInteractor( }); } -const KEYBOARD_ACTION_LABELS = { +export const KEYBOARD_ACTION_LABELS = { keyboardStatus: 'keyboard status', keyboardDismiss: 'keyboard dismiss', keyboardEnter: 'keyboard enter', @@ -111,7 +104,7 @@ const KEYBOARD_ACTION_LABELS = { * differ only by which `Interactor` method they call and what it returns — both read off `key` * itself, so one generic body replaces three copies that differed by nothing else. */ -function bindKeyboardAction( +export function bindKeyboardAction( key: Key, signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, @@ -123,93 +116,3 @@ function bindKeyboardAction( }; return Object.freeze({ [key]: action }) as Pick; } - -export type LocalKeyboardInteractorResolver = LocalInteractorOperationResolver; -export type ProviderKeyboardInteractorResolver = ProviderInteractorOperationResolver; - -function bindLocalKeyboardAction( - key: Key, - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalKeyboardInteractorResolver; - }>, -): Pick { - return bindKeyboardAction(key, params.signal, localInteractorSource(params)); -} - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -function bindProviderKeyboardAction( - key: Key, - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderKeyboardInteractorResolver; - }>, -): Pick { - return bindKeyboardAction( - key, - params.signal, - providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS[key] }), - ); -} - -export function bindLocalKeyboardStatusInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalKeyboardInteractorResolver; - }>, -): KeyboardStatusRuntimeOperations { - return bindLocalKeyboardAction('keyboardStatus', params); -} - -export function bindProviderKeyboardStatusInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderKeyboardInteractorResolver; - }>, -): KeyboardStatusRuntimeOperations { - return bindProviderKeyboardAction('keyboardStatus', params); -} - -export function bindLocalKeyboardDismissInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalKeyboardInteractorResolver; - }>, -): KeyboardDismissRuntimeOperations { - return bindLocalKeyboardAction('keyboardDismiss', params); -} - -export function bindProviderKeyboardDismissInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderKeyboardInteractorResolver; - }>, -): KeyboardDismissRuntimeOperations { - return bindProviderKeyboardAction('keyboardDismiss', params); -} - -export function bindLocalKeyboardEnterInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalKeyboardInteractorResolver; - }>, -): KeyboardEnterRuntimeOperations { - return bindLocalKeyboardAction('keyboardEnter', params); -} - -export function bindProviderKeyboardEnterInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderKeyboardInteractorResolver; - }>, -): KeyboardEnterRuntimeOperations { - return bindProviderKeyboardAction('keyboardEnter', params); -} diff --git a/packages/contracts/src/local-interactor-operation-set.ts b/packages/contracts/src/local-interactor-operation-set.ts new file mode 100644 index 0000000000..9d624783e0 --- /dev/null +++ b/packages/contracts/src/local-interactor-operation-set.ts @@ -0,0 +1,47 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { bindElementTextRuntime } from './element-text-runtime.ts'; +import { bindLocalFocusInteractor } from './focus-runtime.ts'; +import { bindLocalGestureInteractor } from './gesture-runtime.ts'; +import { bindAdmittedLocalInteractorOperations } from './interactor-operation-catalog.ts'; +import type { LocalInteractorOperationResolver } from './interactor-operation-binding.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; +import { whenAdmitted, type RuntimeFacts } from './platform-runtime.ts'; +import { bindLocalScreenshotInteractor } from './screenshot-runtime.ts'; +import { bindLocalScrollInteractor } from './scroll-runtime.ts'; +import { bindLocalTouchInteractor } from './touch-runtime.ts'; +import { bindLocalTypeTextInteractor } from './type-text-runtime.ts'; + +/** + * The whole local interactor-backed operation set a pointer-driving family binds: the uniform + * one-fact-one-bind leaves the interactor catalog walks, plus the four that carry extra input of their own — gesture and touch read the + * fact map, and touch needs a pause clock. Android and Linux each held a byte-identical copy of + * this list; it is one list now, so neither family can drift from the other by forgetting a + * member. Capture stays out: an owner's snapshot mechanics are its own (Linux captures a surface + * through its host, Android drives the interactor), so there is no shared reading to make. + */ +export function bindLocalInteractorOperationSet( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalInteractorOperationResolver; + facts: RuntimeFacts['operations']; + pause: (milliseconds: number) => Promise; + }>, +): Partial { + const { facts, pause } = params; + const resolver = { + device: params.device, + signal: params.signal, + resolveInteractor: params.resolveInteractor, + }; + return { + ...(facts.captureScreenshot.available ? bindLocalScreenshotInteractor(resolver) : {}), + ...(facts.focusPoint.available ? bindLocalFocusInteractor(resolver) : {}), + ...bindLocalGestureInteractor({ ...resolver, facts }), + ...(facts.scrollDirection.available ? bindLocalScrollInteractor(resolver) : {}), + ...(facts.typeText.available ? bindLocalTypeTextInteractor(resolver) : {}), + ...(facts.readTextAtPoint.available ? bindElementTextRuntime(resolver) : {}), + ...whenAdmitted(facts.tapPoint, () => bindLocalTouchInteractor({ ...resolver, facts, pause })), + ...bindAdmittedLocalInteractorOperations({ ...resolver, facts }), + }; +} diff --git a/packages/contracts/src/orientation-runtime.test.ts b/packages/contracts/src/orientation-runtime.test.ts index 1c1b6445ab..893c37718a 100644 --- a/packages/contracts/src/orientation-runtime.test.ts +++ b/packages/contracts/src/orientation-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalOrientationInteractor, - bindProviderOrientationInteractor, - orientationRuntimeOperationFacts, -} from './orientation-runtime.ts'; +import { bindOrientation, orientationRuntimeOperationFacts } from './orientation-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -14,6 +11,16 @@ const device = { booted: true, } as const; +const bindOrientationLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindOrientation(params.signal, localInteractorSource(params)); +const bindOrientationProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => + bindOrientation(params.signal, providerInteractorSource({ ...params, operation: 'orientation' })); + test('builds the exact orientation operation fact catalog', () => { const orientation = { available: true } as const; expect(orientationRuntimeOperationFacts({ orientation })).toEqual({ @@ -26,7 +33,7 @@ test('a local binding drives the interactor with the requested rotation and retu const resolveInteractor = vi.fn(async () => ({ setOrientation }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalOrientationInteractor({ device, signal, resolveInteractor }); + const operations = bindOrientationLocal({ device, signal, resolveInteractor }); const result = await operations.setOrientation({ rotation: 'landscape-left', options: { appBundleId: 'com.example.app' }, @@ -48,7 +55,7 @@ test('a provider binding drives its own resolved interactor', async () => { const resolveInteractor = vi.fn(() => ({ setOrientation }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindProviderOrientationInteractor({ device, signal, resolveInteractor }); + const operations = bindOrientationProvider({ device, signal, resolveInteractor }); await operations.setOrientation({ rotation: 'portrait', execution: { requestId: 'orientation-2' }, @@ -63,7 +70,7 @@ test('a provider binding drives its own resolved interactor', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderOrientationInteractor({ + const operations = bindOrientationProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -81,7 +88,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const setOrientation = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ setOrientation }) as unknown as Interactor); - const operations = bindLocalOrientationInteractor({ + const operations = bindOrientationLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/orientation-runtime.ts b/packages/contracts/src/orientation-runtime.ts index 601c17627a..93ed61c606 100644 --- a/packages/contracts/src/orientation-runtime.ts +++ b/packages/contracts/src/orientation-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { DeviceRotation } from './device-rotation.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; @@ -46,7 +39,7 @@ export function orientationRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the rotation itself. */ -function bindOrientation( +export function bindOrientation( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): OrientationRuntimeOperations { @@ -62,31 +55,3 @@ function bindOrientation( }, }); } - -export type LocalOrientationInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalOrientationInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalOrientationInteractorResolver; - }>, -): OrientationRuntimeOperations { - return bindOrientation(params.signal, localInteractorSource(params)); -} - -export type ProviderOrientationInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderOrientationInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderOrientationInteractorResolver; - }>, -): OrientationRuntimeOperations { - return bindOrientation( - params.signal, - providerInteractorSource({ ...params, operation: 'orientation' }), - ); -} diff --git a/packages/contracts/src/platform-runtime-host.ts b/packages/contracts/src/platform-runtime-host.ts index b2bb8c06a2..8951e3afc7 100644 --- a/packages/contracts/src/platform-runtime-host.ts +++ b/packages/contracts/src/platform-runtime-host.ts @@ -1,5 +1,6 @@ import type { DeviceInfo, Platform } from '@agent-device/kernel/device'; import type { JsonObject, JsonValue } from './json.ts'; +import type { AndroidClipboardShellSupport } from './android-clipboard-support.ts'; export type HostCommandRequest = Readonly<{ executable: string; @@ -68,6 +69,22 @@ export type AppleToolHost = Readonly<{ /** Device-scoped Android transport selected by root composition; packages own all adb arguments. */ export type AndroidToolHost = Readonly<{ + /** + * Asks this device whether its clipboard service answers shell commands, as typed evidence. + * + * A host method rather than a `runAdb` call the caller classifies itself: normalizing adb's + * output is Android tool knowledge, and it happens once here rather than in every owner that + * needs the verdict. + * + * Optional, and its absence is not permission to assume support: a host that cannot probe + * leaves the owner unable to establish the capability, so the clipboard is refused rather than + * admitted. Only fabricated availability is forbidden — a refusal on incomplete information is + * the safe answer. + */ + probeClipboardShellSupport?( + device: DeviceInfo, + signal?: AbortSignal, + ): Promise; runAdb( device: DeviceInfo, args: readonly string[], diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 43aaa3e1f2..505147bf16 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -28,6 +28,11 @@ import type { HomeRuntimeOperations } from './home-runtime.ts'; import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; +import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; +import type { AppSwitcherRuntimeOperations } from './app-switcher-runtime.ts'; +import type { AppEventRuntimeOperations } from './app-event-runtime.ts'; +import type { SettingsRuntimeOperations } from './settings-runtime.ts'; +import type { AlertRuntimeOperations } from './alert-runtime.ts'; import type { TouchRuntimeOperations } from './touch-runtime.ts'; import type { DeviceReadinessRuntimeHost, @@ -72,6 +77,11 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & OrientationRuntimeOperations & TvRemoteRuntimeOperations & KeyboardRuntimeOperations & + ClipboardRuntimeOperations & + AppSwitcherRuntimeOperations & + AppEventRuntimeOperations & + SettingsRuntimeOperations & + AlertRuntimeOperations & TouchRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & @@ -101,6 +111,15 @@ export const tvRemoteRuntimeUse = defineUse({ required: ['tvRemote'] }); export const keyboardStatusUse = defineUse({ required: ['keyboardStatus'] }); export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); +export const appSwitcherRuntimeUse = defineUse({ required: ['appSwitcher'] }); +export const appEventRuntimeUse = defineUse({ required: ['triggerAppEvent'] }); +export const settingsRuntimeUse = defineUse({ required: ['setSetting'] }); +export const alertReadUse = defineUse({ required: ['readAlert'] }); +export const alertWaitUse = defineUse({ required: ['awaitAlert'] }); +export const alertAcceptUse = defineUse({ required: ['acceptAlert'] }); +export const alertDismissUse = defineUse({ required: ['dismissAlert'] }); +export const clipboardReadUse = defineUse({ required: ['readClipboard'] }); +export const clipboardWriteUse = defineUse({ required: ['writeClipboard'] }); export const tapPointUse = defineUse({ required: ['tapPoint'] }); export const capturedTapUse = defineUse({ required: ['captureSnapshot', 'tapPoint'], @@ -623,6 +642,28 @@ export const appStateRuntimeUses = Object.freeze([appStateUse] as const); export const shutdownTargetUse = defineUse({ required: ['shutdownTarget'] }); +/** + * `clipboard`'s action-selected uses (ADR 0019 §9: one bind per handler). `read` and `write` are + * separate cells because an owner can genuinely have one without the other, so the daemon's + * `session-clipboard.ts` admits and binds exactly the one the parsed subcommand names. + */ +export const clipboardRuntimePlanUses = Object.freeze([ + clipboardReadUse, + clipboardWriteUse, +] as const); + +/** + * `alert`'s action-selected uses (ADR 0019 §9: one bind per handler). The four legs differ in + * what they do to the device — one observes, one waits, two press a button — so the daemon's + * `snapshot-alert.ts` admits and binds exactly the one the parsed subcommand names. + */ +export const alertRuntimePlanUses = Object.freeze([ + alertReadUse, + alertWaitUse, + alertAcceptUse, + alertDismissUse, +] as const); + /** * `keyboard`'s action-selected uses (ADR 0019 §9: one bind per handler). `status` is Android-only * (parity with the retired leaf's per-family rejection of `status`/`get` everywhere else); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index ad023a0710..1ff23a27de 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -44,6 +44,15 @@ test('generic unavailable binding preserves exact provider ownership and mode', keyboardStatus: { available: false, reason: 'unsupported-provider-mode' }, keyboardDismiss: { available: false, reason: 'unsupported-provider-mode' }, keyboardEnter: { available: false, reason: 'unsupported-provider-mode' }, + readClipboard: { available: false, reason: 'unsupported-provider-mode' }, + writeClipboard: { available: false, reason: 'unsupported-provider-mode' }, + appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, + triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, + setSetting: { available: false, reason: 'unsupported-provider-mode' }, + readAlert: { available: false, reason: 'unsupported-provider-mode' }, + awaitAlert: { available: false, reason: 'unsupported-provider-mode' }, + acceptAlert: { available: false, reason: 'unsupported-provider-mode' }, + dismissAlert: { available: false, reason: 'unsupported-provider-mode' }, lifecycle, }); diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 88f8b3ad9f..97176c1ded 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -24,6 +24,11 @@ import { homeRuntimeOperationFacts } from './home-runtime.ts'; import { orientationRuntimeOperationFacts } from './orientation-runtime.ts'; import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; +import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; +import { appSwitcherRuntimeOperationFacts } from './app-switcher-runtime.ts'; +import { appEventRuntimeOperationFacts } from './app-event-runtime.ts'; +import { settingsRuntimeOperationFacts } from './settings-runtime.ts'; +import { alertRuntimeOperationFacts } from './alert-runtime.ts'; import { touchRuntimeOperationFacts } from './touch-runtime.ts'; /** @@ -53,6 +58,15 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ keyboardStatus: RuntimeOperationUnavailability; keyboardDismiss: RuntimeOperationUnavailability; keyboardEnter: RuntimeOperationUnavailability; + readClipboard: RuntimeOperationUnavailability; + writeClipboard: RuntimeOperationUnavailability; + appSwitcher: RuntimeOperationUnavailability; + triggerAppEvent: RuntimeOperationUnavailability; + setSetting: RuntimeOperationUnavailability; + readAlert: RuntimeOperationUnavailability; + awaitAlert: RuntimeOperationUnavailability; + acceptAlert: RuntimeOperationUnavailability; + dismissAlert: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -109,6 +123,15 @@ export function createUnavailablePlatformRuntimeFacts( keyboardStatus, keyboardDismiss, keyboardEnter, + readClipboard, + writeClipboard, + appSwitcher, + triggerAppEvent, + setSetting, + readAlert, + awaitAlert, + acceptAlert, + dismissAlert, readiness, shutdown, lifecycle, @@ -178,6 +201,16 @@ export function createUnavailablePlatformRuntimeFacts( dismiss: keyboardDismiss, enter: keyboardEnter, }), + ...clipboardRuntimeOperationFacts({ read: readClipboard, write: writeClipboard }), + ...appSwitcherRuntimeOperationFacts({ appSwitcher }), + ...appEventRuntimeOperationFacts({ triggerAppEvent }), + ...settingsRuntimeOperationFacts({ setSetting }), + ...alertRuntimeOperationFacts({ + read: readAlert, + wait: awaitAlert, + accept: acceptAlert, + dismiss: dismissAlert, + }), ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, @@ -225,6 +258,24 @@ function freezeUnavailableFacts( keyboardStatus: Object.freeze({ ...unavailable.keyboardStatus }), keyboardDismiss: Object.freeze({ ...unavailable.keyboardDismiss }), keyboardEnter: Object.freeze({ ...unavailable.keyboardEnter }), + // Clipboard cells are stated by their owner for the same reason: the surface differs by leaf + // and kind (an Apple simulator has one, a physical non-macOS Apple device does not), and read + // and write can diverge on a provider whose extension exposes only one half. + readClipboard: Object.freeze({ ...unavailable.readClipboard }), + writeClipboard: Object.freeze({ ...unavailable.writeClipboard }), + // The app switcher is the springboard surface `home` drives, and differs by owner the same + // way: an owner states it for its exact leaf rather than inheriting a sibling's gap. + appSwitcher: Object.freeze({ ...unavailable.appSwitcher }), + // App-event delivery opens a URL on the device, which is not something a transport gap can + // speak for: each owner states whether it can open one at all. + triggerAppEvent: Object.freeze({ ...unavailable.triggerAppEvent }), + // Device settings differ by leaf and kind the way the pasteboard does, and a provider can + // own a device without exposing any settings API at all, so each owner states its own cell. + setSetting: Object.freeze({ ...unavailable.setSetting }), + readAlert: Object.freeze({ ...unavailable.readAlert }), + awaitAlert: Object.freeze({ ...unavailable.awaitAlert }), + acceptAlert: Object.freeze({ ...unavailable.acceptAlert }), + dismissAlert: Object.freeze({ ...unavailable.dismissAlert }), lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle), }); } diff --git a/packages/contracts/src/settings-runtime.test.ts b/packages/contracts/src/settings-runtime.test.ts new file mode 100644 index 0000000000..686473f2ed --- /dev/null +++ b/packages/contracts/src/settings-runtime.test.ts @@ -0,0 +1,88 @@ +import { expect, test, vi } from 'vitest'; +import { bindSetSetting, settingsRuntimeOperationFacts } from './settings-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; + +const device = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, +} as const; + +const bindSetSettingLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindSetSetting(params.signal, localInteractorSource(params)); +const bindSetSettingProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => bindSetSetting(params.signal, providerInteractorSource({ ...params, operation: 'settings' })); + +test('builds the exact settings operation fact catalog', () => { + const setSetting = { available: true } as const; + expect(settingsRuntimeOperationFacts({ setSetting })).toEqual({ setSetting }); +}); + +// The daemon has already parsed the CLI form, resolved the target app, and typed the coordinates +// by the time a binding runs, so what reaches the owner is its own settings vocabulary. +test('a local binding forwards the neutral mutation to its owner', async () => { + const setSetting = vi.fn(async () => ({ message: 'Location updated' })); + const resolveInteractor = vi.fn(async () => ({ setSetting }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindSetSettingLocal({ device, signal, resolveInteractor }); + const result = await operations.setSetting({ + setting: 'location', + state: 'set', + appBundleId: 'com.example.app', + options: { latitude: 37.33, longitude: -122.03 }, + execution: { logPath: '/tmp/daemon.log', requestId: 'settings-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'settings-1', + appBundleId: 'com.example.app', + signal, + }); + expect(setSetting).toHaveBeenCalledWith('location', 'set', 'com.example.app', { + latitude: 37.33, + longitude: -122.03, + }); + expect(result).toEqual({ message: 'Location updated' }); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindSetSettingProvider({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect( + operations.setSetting({ setting: 'appearance', state: 'dark' }), + ).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const setSetting = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ setSetting }) as unknown as Interactor); + + const operations = bindSetSettingLocal({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.setSetting({ setting: 'appearance', state: 'dark' })).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(setSetting).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/settings-runtime.ts b/packages/contracts/src/settings-runtime.ts new file mode 100644 index 0000000000..aee9386126 --- /dev/null +++ b/packages/contracts/src/settings-runtime.ts @@ -0,0 +1,65 @@ +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SettingOptions } from './settings.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one settings mutation. `setting` and `state` are the device-settings + * vocabulary the owner's own API is keyed by (`interactor.setSetting`), not a command payload: + * the daemon has already parsed the CLI form, validated it, resolved the target app, and typed + * the coordinates in `options`, so nothing command-shaped or argv-shaped travels here. + */ +export type SetSettingInput = Readonly<{ + setting: string; + state: string; + /** The app the setting targets, already resolved from the request or the session. */ + appBundleId?: string; + options?: SettingOptions; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * Owners answer with their own settings payload or nothing at all; the daemon composes the + * response text around whichever it gets, exactly as the retired leaf did. + */ +export type SettingsRuntimeOperations = Readonly<{ + setSetting(input: SetSettingInput): Promise | void>; +}>; + +export type SettingsRuntimeOperationFacts = Readonly<{ + setSetting: RuntimeOperationFact; +}>; + +export function settingsRuntimeOperationFacts( + input: Readonly<{ setSetting: RuntimeOperationFact }>, +): SettingsRuntimeOperationFacts { + return Object.freeze({ setSetting: input.setSetting }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the mutation itself. + */ +export function bindSetSetting( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): SettingsRuntimeOperations { + return Object.freeze({ + setSetting: async (input: SetSettingInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.appBundleId, + signal, + }); + return await interactor.setSetting( + input.setting, + input.state, + input.appBundleId, + input.options, + ); + }, + }); +} diff --git a/packages/contracts/src/tv-remote-runtime.test.ts b/packages/contracts/src/tv-remote-runtime.test.ts index 367b290134..4b7e005048 100644 --- a/packages/contracts/src/tv-remote-runtime.test.ts +++ b/packages/contracts/src/tv-remote-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalTvRemoteInteractor, - bindProviderTvRemoteInteractor, - tvRemoteRuntimeOperationFacts, -} from './tv-remote-runtime.ts'; +import { bindTvRemote, tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'vega', @@ -14,6 +11,15 @@ const device = { booted: true, } as const; +const bindTvRemoteLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindTvRemote(params.signal, localInteractorSource(params)); +const bindTvRemoteProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => bindTvRemote(params.signal, providerInteractorSource({ ...params, operation: 'tv-remote' })); + test('builds the exact tv-remote operation fact catalog', () => { const tvRemote = { available: true } as const; expect(tvRemoteRuntimeOperationFacts({ tvRemote })).toEqual({ tvRemote }); @@ -24,7 +30,7 @@ test('a local binding drives the interactor with the button and duration', async const resolveInteractor = vi.fn(async () => ({ tvRemote }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalTvRemoteInteractor({ device, signal, resolveInteractor }); + const operations = bindTvRemoteLocal({ device, signal, resolveInteractor }); await operations.tvRemote({ button: 'down', durationMs: 250, @@ -48,7 +54,7 @@ test('a provider binding drives its own resolved interactor', async () => { const resolveInteractor = vi.fn(() => ({ tvRemote }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindProviderTvRemoteInteractor({ device, signal, resolveInteractor }); + const operations = bindTvRemoteProvider({ device, signal, resolveInteractor }); await operations.tvRemote({ button: 'select', execution: { requestId: 'tv-remote-2' } }); expect(resolveInteractor).toHaveBeenCalledWith({ @@ -60,7 +66,7 @@ test('a provider binding drives its own resolved interactor', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderTvRemoteInteractor({ + const operations = bindTvRemoteProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -78,7 +84,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const tvRemote = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ tvRemote }) as unknown as Interactor); - const operations = bindLocalTvRemoteInteractor({ + const operations = bindTvRemoteLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/tv-remote-runtime.ts b/packages/contracts/src/tv-remote-runtime.ts index 8112bdd75e..b5801525e7 100644 --- a/packages/contracts/src/tv-remote-runtime.ts +++ b/packages/contracts/src/tv-remote-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { TvRemoteButton } from './tv-remote.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; @@ -47,7 +40,7 @@ export function tvRemoteRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the button press itself. */ -function bindTvRemote( +export function bindTvRemote( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): TvRemoteRuntimeOperations { @@ -63,31 +56,3 @@ function bindTvRemote( }, }); } - -export type LocalTvRemoteInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalTvRemoteInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalTvRemoteInteractorResolver; - }>, -): TvRemoteRuntimeOperations { - return bindTvRemote(params.signal, localInteractorSource(params)); -} - -export type ProviderTvRemoteInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderTvRemoteInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderTvRemoteInteractorResolver; - }>, -): TvRemoteRuntimeOperations { - return bindTvRemote( - params.signal, - providerInteractorSource({ ...params, operation: 'tv-remote' }), - ); -} diff --git a/packages/platform-android/src/deployment/native.test.ts b/packages/platform-android/src/deployment/native.test.ts index d5bc2459a7..fce1035405 100644 --- a/packages/platform-android/src/deployment/native.test.ts +++ b/packages/platform-android/src/deployment/native.test.ts @@ -28,6 +28,7 @@ function hostFixture(options: { bundletool?: boolean; bundletoolJar?: string } = }, androidDeployment: { bundletoolJar: options.bundletoolJar }, androidTools: { + probeClipboardShellSupport: async () => 'supported' as const, runAdb: async (_device, args, commandOptions, signal) => await run({ executable: 'adb', args, ...commandOptions }, signal), installPackage: async (_device, packagePath, options, signal) => diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 067ca37806..c3ab55885e 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; import type { DeviceBinding, PlatformRuntimeHost, @@ -32,6 +33,7 @@ test.each([ stdout: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}', })); const host = { + androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, commands: { which: async () => 'tool', run: async () => ({ stdout: '1', stderr: '', exitCode: 0 }), @@ -156,6 +158,7 @@ test.each([ test('rejects the non-discovered Android simulator cell for appstate', async () => { const runtimeDevice = { ...device, kind: 'simulator' as const }; const host = { + androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, localInteractors: { resolve: async () => ({}) }, appState: { @@ -196,8 +199,14 @@ test('rejects the non-discovered Android simulator cell for appstate', async () expect(binding.operations.appState).toBeUndefined(); }); -function androidNavigationHostFixture() { +function androidNavigationHostFixture( + probeClipboardShellSupport: () => Promise = async () => 'supported', +) { return { + androidTools: { + probeClipboardShellSupport, + runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, appInventory: { apple: { listApps: async () => [] }, @@ -289,6 +298,42 @@ test('admits Android tv-remote only for a real TV target', async () => { expect(binding.operations.tvRemote).toBeTypeOf('function'); }); +// R55 parity: the retired `clipboard` bucket was `ANDROID_ALL` (emulator/device/unknown) with no +// Android admission closure, so `cmd clipboard get/set text` is admitted on every real kind and +// refused only on the synthetic `simulator` row the bucket never listed. (`unknown` is the +// bucket's name for a device with no declared kind, which `DeviceKind` cannot express.) +test('admits both clipboard halves and the app switcher on every real Android kind', async () => { + for (const kind of ['emulator', 'device'] as const) { + const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ + device: { ...device, id: `android-${kind}`, kind }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.facts.operations.readClipboard).toEqual({ available: true }); + expect(binding.facts.operations.writeClipboard).toEqual({ available: true }); + expect(binding.operations.readClipboard).toBeTypeOf('function'); + expect(binding.operations.writeClipboard).toBeTypeOf('function'); + // R56: `app-switcher` shares `home`'s cell — one `input keyevent` on every real kind. + expect(binding.facts.operations.appSwitcher).toEqual({ available: true }); + expect(binding.operations.appSwitcher).toBeTypeOf('function'); + // R57: the deep link opens through `am start` on the same cell. + expect(binding.facts.operations.triggerAppEvent).toEqual({ available: true }); + expect(binding.operations.triggerAppEvent).toBeTypeOf('function'); + // R58: settings run over adb (`appops`, `settings put`, `pm clear`, …) on that cell too. + expect(binding.facts.operations.setSetting).toEqual({ available: true }); + expect(binding.operations.setSetting).toBeTypeOf('function'); + // R59: all four alert legs read the same dump and tap with the same `input tap`. + for (const operation of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(binding.facts.operations[operation]).toEqual({ available: true }); + expect(binding.operations[operation]).toBeTypeOf('function'); + } + } +}); + test('the synthetic Android simulator cell refuses back/home/orientation/keyboard like every other touch operation', async () => { const simulatorDevice = { ...device, id: 'android-simulator', kind: 'simulator' as const }; const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ @@ -310,6 +355,15 @@ test('the synthetic Android simulator cell refuses back/home/orientation/keyboar 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', + 'readClipboard', + 'writeClipboard', + 'appSwitcher', + 'triggerAppEvent', + 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', ] as const) { expect(facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); @@ -362,6 +416,7 @@ test.each([ 'classifies the Android %s lifecycle denominator against the legacy dispatch cell', async (_name, runtimeDevice, legacy) => { const host = { + androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, appInventory: { apple: { listApps: async () => [] }, @@ -513,6 +568,7 @@ test('binds only the Android gesture tiers the target admitted', async () => { function gestureHost(): PlatformRuntimeHost { return { + androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, appInventory: { apple: { listApps: async () => [] }, @@ -523,3 +579,103 @@ function gestureHost(): PlatformRuntimeHost { screenRecording: { android: { resolve: async () => ({ mode: 'local' as const }) } }, } as unknown as PlatformRuntimeHost; } + +// R55 defect, found on a Pixel 9 Pro XL / Android 36 emulator: the retired bucket admitted both +// clipboard halves on every real Android kind, `capabilities` advertised `clipboard`, and +// `clipboard read` then failed with `UNSUPPORTED_OPERATION` from the leaf. Admission now probes +// the same condition the leaf checks, so a build with no clipboard shell command refuses up front. +test('refuses both clipboard halves when the build reports no clipboard shell', async () => { + const binding = await createAndroidPlatformRuntime( + androidNavigationHostFixture(async () => 'unsupported'), + ).bind({ + device: { ...device, id: 'android-no-clipboard-shell', kind: 'device' }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + for (const key of ['readClipboard', 'writeClipboard'] as const) { + expect(binding.facts.operations[key]).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); + // Never admitted, so never bound: nothing can throw `unsupported` after the fact. + expect(binding.operations[key]).toBeUndefined(); + } + // The probe is scoped to the clipboard; neighbouring adb-driven cells stay admitted. + expect(binding.facts.operations.appSwitcher).toEqual({ available: true }); +}); + +test.each([['supported'], ['unsupported']] as const)( + 'caches a definitive %s verdict instead of re-probing per inspection', + async (verdict) => { + const probe = vi.fn(async () => verdict); + const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe)); + const target = { ...device, id: `android-probe-cache-${verdict}`, kind: 'device' as const }; + + await runtime.inspectFacts(target); + await runtime.inspectFacts(target); + + expect(probe).toHaveBeenCalledTimes(1); + }, +); + +// The failure path is the one that recreates the defect if it guesses. A probe that never got an +// answer must not report the clipboard available — execution would then refuse the very capability +// `capabilities` advertised — and must not be remembered, or one transport blip decides the +// question for the owner's whole life. +test('a failed probe refuses rather than fabricating availability', async () => { + const binding = await createAndroidPlatformRuntime( + androidNavigationHostFixture(async () => 'probe-failed'), + ).bind({ + device: { ...device, id: 'android-probe-failed', kind: 'device' }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + for (const key of ['readClipboard', 'writeClipboard'] as const) { + expect(binding.facts.operations[key]).toMatchObject({ available: false }); + expect(binding.operations[key]).toBeUndefined(); + } + // The refusal says it could not determine support, not that the build lacks it. + const fact = binding.facts.operations.readClipboard; + expect(fact.available === false && String(fact.hint)).toMatch(/could not determine/i); +}); + +test('a failed probe is not cached, so the next inspection asks again', async () => { + const probe = vi + .fn<() => Promise>() + .mockResolvedValueOnce('probe-failed') + .mockResolvedValue('supported'); + const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe)); + const target = { ...device, id: 'android-probe-retry', kind: 'device' as const }; + + const first = await runtime.inspectFacts(target); + const second = await runtime.inspectFacts(target); + + expect(first.operations.readClipboard.available).toBe(false); + expect(second.operations.readClipboard.available).toBe(true); + expect(probe).toHaveBeenCalledTimes(2); +}); + +test('a host with no clipboard probe refuses rather than assuming support', async () => { + const host = androidNavigationHostFixture(); + const withoutProbe = { ...host, androidTools: {} } as unknown as PlatformRuntimeHost; + + const facts = await createAndroidPlatformRuntime(withoutProbe).inspectFacts({ + ...device, + id: 'android-no-probe', + kind: 'device', + }); + + // Absence of a probe is absence of evidence, not evidence of support. + expect(facts.operations.readClipboard.available).toBe(false); + expect(facts.operations.writeClipboard.available).toBe(false); +}); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index c834d3f7ea..82615087f3 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -14,48 +14,32 @@ import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; -import { - bindElementTextRuntime, - elementTextRuntimeOperationFacts, -} from '@agent-device/contracts/element-text-runtime'; -import { - bindLocalFocusInteractor, - focusRuntimeOperationFacts, -} from '@agent-device/contracts/focus-runtime'; +import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; +import { focusRuntimeOperationFacts } from '@agent-device/contracts/focus-runtime'; import { ANDROID_TV_MULTI_TOUCH_UNSUPPORTED_HINT, TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT, } from '@agent-device/contracts/gesture-admission'; -import { - bindLocalGestureInteractor, - gestureRuntimeOperationFacts, -} from '@agent-device/contracts/gesture-runtime'; -import { - bindLocalScrollInteractor, - scrollRuntimeOperationFacts, -} from '@agent-device/contracts/scroll-runtime'; -import { localRuntimeOwner, whenAdmitted } from '@agent-device/contracts/platform-runtime'; -import { - bindLocalScreenshotInteractor, - screenshotRuntimeOperationFacts, -} from '@agent-device/contracts/screenshot-runtime'; +import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; +import { scrollRuntimeOperationFacts } from '@agent-device/contracts/scroll-runtime'; +import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; import { selectorObservationRuntimeOperationFacts } from '@agent-device/contracts/selector-observation-runtime'; import { bindLocalSnapshotInteractor, snapshotRuntimeOperationFacts, } from '@agent-device/contracts/snapshot-runtime'; -import { - bindLocalTypeTextInteractor, - typeTextRuntimeOperationFacts, -} from '@agent-device/contracts/type-text-runtime'; -import { - bindLocalTouchInteractor, - touchRuntimeOperationFacts, -} from '@agent-device/contracts/touch-runtime'; +import { typeTextRuntimeOperationFacts } from '@agent-device/contracts/type-text-runtime'; +import { touchRuntimeOperationFacts } from '@agent-device/contracts/touch-runtime'; import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; -import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { bindLocalInteractorOperationSet } from '@agent-device/contracts/local-interactor-operation-set'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; @@ -66,6 +50,7 @@ import { bindAndroidScreenRecordingRuntime } from './recording/runtime.ts'; import { ensureAndroidReady } from './readiness/runtime.ts'; import { readAndroidAppState } from './app-state.ts'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; +import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; import { androidAppDeploymentFacts, createAndroidAppDeploymentOperations, @@ -219,6 +204,39 @@ function androidTouchFact(device: DeviceInfo) { return device.kind === 'simulator' ? focusKindUnavailable : available; } +const clipboardShellUnavailable = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'This Android build ships no shell implementation for the clipboard service, so adb cannot read or write the clipboard on it.', +} as const); + +/** + * The probe could not reach the device, so this owner does not know whether the build supports a + * shell clipboard. Refusing is the conservative answer and the only honest one: reporting + * available would hand the caller a capability execution may immediately reject, which is the + * exact failure fact-based admission exists to prevent. Deliberately not cached — the next + * inspection asks again. + */ +const clipboardShellUnknown = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'Could not determine whether this Android build supports a shell clipboard: the adb probe did not complete. Retry once the device is reachable.', +} as const); + +/** + * `probe-failed` covers both ways this owner can end up without an answer: the probe ran and could + * not reach the device, or the host exposes no probe at all. Neither is evidence of support, and + * both refuse rather than guess. + */ +async function probeClipboardShellSupport( + host: PlatformRuntimeHost, + device: DeviceInfo, +): Promise { + const probe = host.androidTools?.probeClipboardShellSupport; + if (!probe) return 'probe-failed'; + return await probe.call(host.androidTools, device); +} + const tvRemoteUnavailable = Object.freeze({ available: false, reason: 'unsupported-device-kind', @@ -235,9 +253,31 @@ function androidTvRemoteFact(device: DeviceInfo): RuntimeOperationFact { export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createAndroidAppLogRuntime(host); + /** + * `cmd clipboard` is not implemented on every Android build. The retired capability bucket + * admitted the clipboard on every real Android kind and the leaf discovered the refusal only + * once a read or write had already run — a device `capabilities` advertised and execution then + * rejected. ADR 0019 §2 requires the opposite, so support is a fact, and the fact needs a probe. + * + * Cached per device for this owner's lifetime: a build's shell command set cannot change while + * the device is up, and admission would otherwise pay an adb round trip per request. + */ + const clipboardShell = new Map(); + const clipboardFact = async (device: DeviceInfo): Promise => { + if (device.kind === 'simulator') return clipboardShellUnavailable; + const support = + clipboardShell.get(device.id) ?? (await probeClipboardShellSupport(host, device)); + // Only a definitive answer is worth keeping: a build's shell command set cannot change while + // the device is up, but a failed probe says nothing about the build and must not become a + // verdict this owner repeats for the rest of its life. + if (support !== 'probe-failed') clipboardShell.set(device.id, support); + if (support === 'supported') return available; + return support === 'unsupported' ? clipboardShellUnavailable : clipboardShellUnknown; + }; const inspectFacts = async (device: Parameters[0]) => { const logs = await appLogs.inspectFacts(device); const deployment = androidAppDeploymentFacts(device); + const clipboardCell = await clipboardFact(device); return Object.freeze({ device: logs.device, operations: { @@ -293,6 +333,24 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor }), ...backRuntimeOperationFacts({ back: androidTouchFact(device) }), ...homeRuntimeOperationFacts({ home: androidTouchFact(device) }), + // `app-switcher` shares `home`'s cell: one `input keyevent`, admitted wherever the + // retired `ANDROID_ALL` bucket admitted it. + ...appSwitcherRuntimeOperationFacts({ appSwitcher: androidTouchFact(device) }), + // The deep link opens through `am start`, admitted wherever the retired `ANDROID_ALL` + // bucket admitted it. + ...appEventRuntimeOperationFacts({ triggerAppEvent: androidTouchFact(device) }), + // Settings run over adb (`appops`, `settings put`, `pm clear`, …) on every real kind, so + // the cell is the retired `ANDROID_ALL` bucket verbatim. + ...settingsRuntimeOperationFacts({ setSetting: androidTouchFact(device) }), + // R59: Android reads alerts out of the same accessibility dump every interaction cell + // depends on and presses their buttons with the same `input tap`, so all four legs take + // that cell — the retired `ANDROID_ALL` bucket verbatim. + ...alertRuntimeOperationFacts({ + read: androidTouchFact(device), + wait: androidTouchFact(device), + accept: androidTouchFact(device), + dismiss: androidTouchFact(device), + }), ...orientationRuntimeOperationFacts({ orientation: androidTouchFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: androidTvRemoteFact(device) }), // The only owner with a live IME status read; dismiss/enter share every other @@ -302,6 +360,9 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor dismiss: androidTouchFact(device), enter: androidTouchFact(device), }), + // Read and write share one cell: `cmd clipboard` either has a shell implementation on this + // build or it has none, and no Android build ships one half of it. + ...clipboardRuntimeOperationFacts({ read: clipboardCell, write: clipboardCell }), ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, @@ -349,12 +410,6 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor await dumpAndroidNetworkTraffic(host, request.device, input, request.scope.signal), ...recording, ...androidInteractionOperations(host, request, facts), - ...bindAdmittedLocalInteractorOperations({ - device: request.device, - signal: request.scope.signal, - resolveInteractor: host.localInteractors.resolve, - facts: facts.operations, - }), ensureReady: async (input: EnsureReadyInput) => await ensureAndroidReady( host, @@ -428,20 +483,10 @@ function androidInteractionOperations( }; return { ...(facts.operations.captureSnapshot.available ? bindLocalSnapshotInteractor(resolver) : {}), - ...(facts.operations.captureScreenshot.available - ? bindLocalScreenshotInteractor(resolver) - : {}), - ...(facts.operations.focusPoint.available ? bindLocalFocusInteractor(resolver) : {}), - ...bindLocalGestureInteractor({ ...resolver, facts: facts.operations }), - ...(facts.operations.scrollDirection.available ? bindLocalScrollInteractor(resolver) : {}), - ...(facts.operations.typeText.available ? bindLocalTypeTextInteractor(resolver) : {}), - ...whenAdmitted(facts.operations.tapPoint, () => - bindLocalTouchInteractor({ - ...resolver, - facts: facts.operations, - pause: async (milliseconds) => await host.clock.sleep(milliseconds, request.scope.signal), - }), - ), - ...(facts.operations.readTextAtPoint.available ? bindElementTextRuntime(resolver) : {}), + ...bindLocalInteractorOperationSet({ + ...resolver, + facts: facts.operations, + pause: async (milliseconds) => await host.clock.sleep(milliseconds, request.scope.signal), + }), }; } diff --git a/packages/platform-apple/src/navigation/runtime.ts b/packages/platform-apple/src/navigation/runtime.ts index 4825cef422..d7898a50ef 100644 --- a/packages/platform-apple/src/navigation/runtime.ts +++ b/packages/platform-apple/src/navigation/runtime.ts @@ -1,3 +1,4 @@ +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; @@ -42,8 +43,22 @@ const homeLifecycleUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', } as const); -function appleHomeFact(device: DeviceInfo): RuntimeOperationFact { - if (device.kind !== 'simulator' && device.kind !== 'device') return homeKindUnavailable; +const appSwitcherKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'app-switcher is supported on Apple simulators and physical devices.', +} as const); +/** + * `home` and `app-switcher` are one springboard reading, and sharing it here is parity rather + * than convenience: the retired `supportsAppAndDeviceLifecycle` closure gated both off the same + * per-AppleOS `appAndDeviceLifecycle` row. Only the kind-refusal hint differs, so each caller + * supplies its own. + */ +function appleSpringboardFact( + device: DeviceInfo, + kindUnavailable: RuntimeOperationFact, +): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return kindUnavailable; const os = resolveDeviceAppleOs(device); return os === 'macos' || os === 'watchos' ? homeLifecycleUnavailable : available; } @@ -109,11 +124,17 @@ function appleKeyboardEnterFact(device: DeviceInfo): RuntimeOperationFact { return appleMobileInputEligible(device) ? available : keyboardCellUnavailable; } -/** The navigation cells: back, home, orientation, tv-remote, and keyboard status/dismiss/enter. */ +/** + * The navigation cells: back, home, app-switcher, orientation, tv-remote, and keyboard + * status/dismiss/enter. + */ export function appleNavigationFacts(device: DeviceInfo) { return Object.freeze({ ...backRuntimeOperationFacts({ back: appleBackFact(device) }), - ...homeRuntimeOperationFacts({ home: appleHomeFact(device) }), + ...homeRuntimeOperationFacts({ home: appleSpringboardFact(device, homeKindUnavailable) }), + ...appSwitcherRuntimeOperationFacts({ + appSwitcher: appleSpringboardFact(device, appSwitcherKindUnavailable), + }), ...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: appleTvRemoteFact(device) }), ...keyboardRuntimeOperationFacts({ diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 58d726bd1a..9af0ec905d 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -154,7 +154,7 @@ function expectAppleSnapshotAvailability( } test.each(Object.entries(leaves))( - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', async (_name, device) => { const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ device, @@ -192,13 +192,12 @@ function expectNavigationAndKeyboardFacts( // constructibility. expectOperationAvailability(binding, 'back', device.appleOs !== 'watchos'); - // home is unavailable on macOS, which drives an already-running app with no springboard, and - // on watchOS. - expectOperationAvailability( - binding, - 'home', - device.appleOs !== 'macos' && device.appleOs !== 'watchos', - ); + // home and app-switcher share one springboard reading (R56): both are unavailable on macOS, + // which drives an already-running app with no springboard, and on watchOS. That is parity, not + // convenience — the retired `supportsAppAndDeviceLifecycle` closure gated both off the same row. + const springboard = device.appleOs !== 'macos' && device.appleOs !== 'watchos'; + expectOperationAvailability(binding, 'home', springboard); + expectOperationAvailability(binding, 'appSwitcher', springboard); // orientation and keyboard dismiss/enter share mobile-input eligibility: unavailable on tvOS // (focus-only XCUIRemote navigation), macOS (an AppKit desktop host), and watchOS. diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index a80e2cdbb7..0c69dabc38 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -56,6 +56,7 @@ import { createAppleAppDeploymentOperations, } from './deployment/runtime.ts'; import { appleNavigationFacts, createAppleNavigationOperations } from './navigation/runtime.ts'; +import { appleSystemFacts, createAppleSystemOperations } from './system/runtime.ts'; import { bindAppleFindSelectorRuntime, bindAppleFindTextRuntime, @@ -301,6 +302,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }), ...appleNavigationFacts(device), + ...appleSystemFacts(device), ensureReady: readiness, bootTarget: boot, bootTargetHeadless: headlessUnavailable, @@ -409,6 +411,11 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR device: request.device, signal: request.scope.signal, }), + ...createAppleSystemOperations({ + host, + device: request.device, + signal: request.scope.signal, + }), ...whenAdmitted(facts.operations.ensureReady, () => ({ ensureReady: async () => await ensureAppleReady(host, request.device, request.scope.signal), diff --git a/packages/platform-apple/src/system/runtime.test.ts b/packages/platform-apple/src/system/runtime.test.ts new file mode 100644 index 0000000000..a52d4bb489 --- /dev/null +++ b/packages/platform-apple/src/system/runtime.test.ts @@ -0,0 +1,176 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor } from '@agent-device/contracts/interaction'; +import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; +import { appleSystemFacts, createAppleSystemOperations } from './runtime.ts'; + +function appleDevice( + appleOs: AppleOS, + kind: DeviceInfo['kind'], + overrides: Partial = {}, +): DeviceInfo { + return { + platform: 'apple', + appleOs, + id: `${appleOs}-${kind}`, + name: `${appleOs} ${kind}`, + kind, + booted: true, + ...overrides, + }; +} + +/** + * The R55 parity cell table, restated as facts. The retired admission was the `clipboard` + * capability bucket (`{ simulator: true, device: true }`) intersected with the Apple plugin's + * `supportsHostOrSimulatorSurface` closure — `macos || simulator`. Every row below is that + * verdict; the one deliberate narrowing is watchOS, which the bucket admitted as a simulator but + * for which no XCUITest interactor can be constructed at all (the same reading `appleBackFact` + * takes). + */ +test.each([ + { appleOs: 'ios', kind: 'simulator', expected: true }, + { appleOs: 'ios', kind: 'device', expected: false }, + { appleOs: 'ipados', kind: 'simulator', expected: true }, + { appleOs: 'ipados', kind: 'device', expected: false }, + { appleOs: 'tvos', kind: 'simulator', expected: true }, + { appleOs: 'tvos', kind: 'device', expected: false }, + { appleOs: 'visionos', kind: 'simulator', expected: true }, + { appleOs: 'visionos', kind: 'device', expected: false }, + { appleOs: 'macos', kind: 'device', expected: true }, + { appleOs: 'watchos', kind: 'simulator', expected: false }, +] as const)( + 'clipboard on an Apple $appleOs $kind is available: $expected', + ({ appleOs, kind, expected }) => { + const facts = appleSystemFacts(appleDevice(appleOs, kind)); + expect(facts.readClipboard.available).toBe(expected); + expect(facts.writeClipboard.available).toBe(expected); + }, +); + +/** + * `trigger-app-event` carried no Apple admission closure at all: its retired bucket was + * `{ simulator, device }` flat, so every leaf with a constructible interactor admits it — macOS + * included, unlike clipboard. + */ +test.each([ + { appleOs: 'ios', kind: 'simulator', expected: true }, + { appleOs: 'ios', kind: 'device', expected: true }, + { appleOs: 'macos', kind: 'device', expected: true }, + { appleOs: 'tvos', kind: 'simulator', expected: true }, + { appleOs: 'watchos', kind: 'simulator', expected: false }, +] as const)( + 'trigger-app-event on an Apple $appleOs $kind is available: $expected', + ({ appleOs, kind, expected }) => { + expect(appleSystemFacts(appleDevice(appleOs, kind)).triggerAppEvent.available).toBe(expected); + }, +); + +/** + * `settings` shares clipboard's exact reading, and that sharing is the parity claim: the retired + * admission intersected the `settings` bucket (`{ simulator: true, device: true }`) with the same + * `supportsHostOrSimulatorSurface` closure. Only the refusal wording differs, so the table is + * clipboard's verbatim — including the watchOS narrowing. + */ +test.each([ + { appleOs: 'ios', kind: 'simulator', expected: true }, + { appleOs: 'ios', kind: 'device', expected: false }, + { appleOs: 'ipados', kind: 'simulator', expected: true }, + { appleOs: 'ipados', kind: 'device', expected: false }, + { appleOs: 'tvos', kind: 'simulator', expected: true }, + { appleOs: 'tvos', kind: 'device', expected: false }, + { appleOs: 'visionos', kind: 'simulator', expected: true }, + { appleOs: 'visionos', kind: 'device', expected: false }, + { appleOs: 'macos', kind: 'device', expected: true }, + { appleOs: 'watchos', kind: 'simulator', expected: false }, +] as const)( + 'settings on an Apple $appleOs $kind is available: $expected', + ({ appleOs, kind, expected }) => { + expect(appleSystemFacts(appleDevice(appleOs, kind)).setSetting.available).toBe(expected); + }, +); + +// Same cell, different sentence: a physical iOS device is refused as a platform leaf, and the +// hint names the two surfaces that do work rather than clipboard's shorter phrasing. +test('a physical non-macOS Apple device refuses settings as a platform leaf', () => { + expect(appleSystemFacts(appleDevice('ios', 'device')).setSetting).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', + }); +}); + +/** + * `alert`'s retired admission was the host-or-simulator closure widened by one leaf: physical + * iOS, whose XCTest alert path is device-verified. Every other physical Apple leaf stays closed + * exactly as `supportsAlertSurface` left it, and watchOS narrows for want of an interactor. + */ +test.each([ + { appleOs: 'ios', kind: 'simulator', expected: true }, + { appleOs: 'ios', kind: 'device', expected: true }, + { appleOs: 'ipados', kind: 'simulator', expected: true }, + { appleOs: 'ipados', kind: 'device', expected: false }, + { appleOs: 'tvos', kind: 'simulator', expected: true }, + { appleOs: 'tvos', kind: 'device', expected: false }, + { appleOs: 'visionos', kind: 'simulator', expected: true }, + { appleOs: 'visionos', kind: 'device', expected: false }, + { appleOs: 'macos', kind: 'device', expected: true }, + { appleOs: 'watchos', kind: 'simulator', expected: false }, +] as const)( + 'alert on an Apple $appleOs $kind is available: $expected', + ({ appleOs, kind, expected }) => { + const facts = appleSystemFacts(appleDevice(appleOs, kind)); + // One cell, four legs: an Apple backend that can read a sheet can press its buttons too. + for (const leg of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(facts[leg].available).toBe(expected); + } + }, +); + +test('a non-simulator, non-device Apple kind is refused by kind, with its own reason', () => { + const facts = appleSystemFacts(appleDevice('ios', 'emulator')); + expect(facts.readClipboard).toEqual({ + available: false, + reason: 'unsupported-device-kind', + hint: 'clipboard is supported on Apple simulators and the macOS host.', + }); + expect(facts.setSetting).toEqual({ + available: false, + reason: 'unsupported-device-kind', + hint: 'settings is supported on Apple simulators and the macOS host.', + }); + expect(facts.readAlert).toEqual({ + available: false, + reason: 'unsupported-device-kind', + hint: 'alert is supported on Apple simulators and physical devices.', + }); +}); + +test('binds the system operations for an admitted cell and none for a refused one', () => { + const resolve = vi.fn(async () => ({}) as unknown as Interactor); + const host = { localInteractors: { resolve } }; + const signal = new AbortController().signal; + + const admitted = createAppleSystemOperations({ + host, + device: appleDevice('ios', 'simulator'), + signal, + }); + expect(admitted.readClipboard).toBeTypeOf('function'); + expect(admitted.writeClipboard).toBeTypeOf('function'); + expect(admitted.setSetting).toBeTypeOf('function'); + expect(admitted.readAlert).toBeTypeOf('function'); + expect(admitted.acceptAlert).toBeTypeOf('function'); + + const refused = createAppleSystemOperations({ + host, + device: appleDevice('ios', 'device'), + signal, + }); + expect(refused.readClipboard).toBeUndefined(); + expect(refused.writeClipboard).toBeUndefined(); + expect(refused.setSetting).toBeUndefined(); + // The refused cell here is clipboard's and settings' — a physical iOS device, which `alert` + // deliberately still admits, so its legs stay bound. + expect(refused.readAlert).toBeTypeOf('function'); + expect(resolve).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/system/runtime.ts b/packages/platform-apple/src/system/runtime.ts new file mode 100644 index 0000000000..4aaab146ce --- /dev/null +++ b/packages/platform-apple/src/system/runtime.ts @@ -0,0 +1,149 @@ +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import { resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; + +const available = Object.freeze({ available: true } as const); + +const clipboardKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'clipboard is supported on Apple simulators and the macOS host.', +} as const); +const settingsKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'settings is supported on Apple simulators and the macOS host.', +} as const); +const settingsLeafUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', +} as const); +/** + * Parity with the retired `supportsHostOrSimulatorSurface` closure: the Apple pasteboard is + * reachable through `simctl pbpaste`/`pbcopy` on any simulator, and directly on the macOS host; + * a physical iOS/iPadOS/tvOS/visionOS device has neither route. + */ +const clipboardLeafUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'clipboard is supported on Apple simulators and the macOS host, not on physical devices of this OS.', +} as const); +/** + * watchOS has no XCUITest-driveable UI (ADR-0009), so no Apple interactor can be constructed for + * it and every interactor-backed operation stays unavailable there — the same reading + * `appleBackFact` takes, and for the same reason: facts are the support authority, not a mirror + * of a capability table that never modeled interactor constructibility. + */ +const appleWatchOsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +/** + * The one host-or-simulator reading `clipboard` and `settings` share, and sharing it is parity + * rather than convenience: the retired `supportsHostOrSimulatorSurface` closure gated both off + * the same per-AppleOS `physicalDeviceSurfaces` row. Only the refusal wording differs, so each + * caller supplies its own pair. + */ +function appleHostOrSimulatorFact( + device: DeviceInfo, + kindUnavailable: RuntimeOperationFact, + leafUnavailable: RuntimeOperationFact, +): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return kindUnavailable; + const os = resolveDeviceAppleOs(device); + if (os === 'watchos') return appleWatchOsUnavailable; + if (device.kind === 'simulator') return available; + return os === 'macos' ? available : leafUnavailable; +} + +/** + * Read and write share one cell: both routes (`simctl pbpaste`/`pbcopy`, and the macOS host + * pasteboard) expose the pair or neither, so splitting them here would invent a cell no Apple + * owner can actually be in. + */ +function appleClipboardFact(device: DeviceInfo): RuntimeOperationFact { + return appleHostOrSimulatorFact(device, clipboardKindUnavailable, clipboardLeafUnavailable); +} + +const appEventKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'trigger-app-event is supported on Apple simulators and physical devices.', +} as const); + +const alertKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'alert is supported on Apple simulators and physical devices.', +} as const); +const alertLeafUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'alert is supported on Apple simulators, the macOS host, and physical iOS devices.', +} as const); + +/** + * Parity with the retired `supportsAlertSurface` closure, which was the host-or-simulator reading + * widened by one leaf: physical iOS, whose XCTest alert path is device-verified. iPadOS, + * tvOS and visionOS devices stay closed exactly as that closure left them — not because the + * runner could not reach them, but because nobody has verified it there. + */ +function appleAlertFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return alertKindUnavailable; + const os = resolveDeviceAppleOs(device); + if (os === 'watchos') return appleWatchOsUnavailable; + if (device.kind === 'simulator' || os === 'ios' || os === 'macos') return available; + return alertLeafUnavailable; +} + +/** + * No apple-family closure ever gated `trigger-app-event` beyond its capability bucket + * (`{ simulator, device }`): the deep link opens through the same interactor `open` every Apple + * leaf drives. watchOS is the one narrowing, for want of a constructible interactor at all. + */ +function appleAppEventFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return appEventKindUnavailable; + return resolveDeviceAppleOs(device) === 'watchos' ? appleWatchOsUnavailable : available; +} + +/** The system-surface cells: clipboard read/write, app-event delivery, settings, and alerts. */ +export function appleSystemFacts(device: DeviceInfo) { + const clipboard = appleClipboardFact(device); + // The four alert legs share one cell: an Apple leaf whose backend can read an alert can also + // press its buttons, so splitting them would invent a cell no Apple owner is ever in. + const alert = appleAlertFact(device); + return Object.freeze({ + ...clipboardRuntimeOperationFacts({ read: clipboard, write: clipboard }), + ...alertRuntimeOperationFacts({ read: alert, wait: alert, accept: alert, dismiss: alert }), + ...appEventRuntimeOperationFacts({ triggerAppEvent: appleAppEventFact(device) }), + ...settingsRuntimeOperationFacts({ + setSetting: appleHostOrSimulatorFact( + device, + settingsKindUnavailable, + settingsLeafUnavailable, + ), + }), + }); +} + +/** Binds whichever system operations {@link appleSystemFacts} admitted. */ +export function createAppleSystemOperations(params: { + host: Pick; + device: DeviceInfo; + signal: AbortSignal; +}) { + const { host, device, signal } = params; + return bindAdmittedLocalInteractorOperations({ + device, + signal, + resolveInteractor: host.localInteractors.resolve, + facts: appleSystemFacts(device), + }); +} diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 7148c03450..93c07e6d93 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -82,7 +82,15 @@ test.each([ expect(binding.operations.focusPoint).toBeTypeOf('function'); expect(binding.operations.typeText).toBeTypeOf('function'); // back/home/keyboard dismiss+enter share focus's hdc-driven gate on both real kinds. - for (const operation of ['back', 'home', 'keyboardDismiss', 'keyboardEnter'] as const) { + for (const operation of [ + 'back', + 'home', + // R56 parity: the retired `HARMONYOS_SUPPORTED_COMMANDS` overlay listed `app-switcher` for + // both HarmonyOS kinds, and it rides the same hdc key input `home` does. + 'appSwitcher', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { expect(facts.operations[operation]).toEqual({ available: true }); expect(binding.operations[operation]).toBeTypeOf('function'); } @@ -105,6 +113,28 @@ test.each([ hint: 'keyboard status/get is not available through the public HarmonyOS HDC API; use keyboard dismiss or enter', }); expect(binding.operations.keyboardStatus).toBeUndefined(); + // R55: HarmonyOS never carried a `clipboard` bucket and is absent from the HarmonyOS overlay + // set, so neither half was ever admitted here. + for (const operation of [ + 'readClipboard', + 'writeClipboard', + 'triggerAppEvent', + // R59: `alert` never had a HarmonyOS leaf either — hdc exposes no dialog surface. + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', + ] as const) { + expect(facts.operations[operation]).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations[operation]).toBeUndefined(); + } + // R58: `settings` is the other way round — the retired overlay set listed it for both HarmonyOS + // kinds, so a real kind admits it off the same hdc gate the interaction leaves use. + expect(facts.operations.setSetting).toEqual({ available: true }); + expect(binding.operations.setSetting).toBeTypeOf('function'); await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ booted: true }); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), @@ -150,6 +180,15 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async ( 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', + 'readClipboard', + 'writeClipboard', + 'appSwitcher', + 'triggerAppEvent', + 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', ] as const) { expect(binding.facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 97c06dd2e7..286acec82b 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -25,6 +25,11 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { localRuntimeOwner, whenAdmitted } from '@agent-device/contracts/platform-runtime'; @@ -270,6 +275,23 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), ...backRuntimeOperationFacts({ back: harmonyFocusFact(device) }), ...homeRuntimeOperationFacts({ home: harmonyFocusFact(device) }), + // Parity with the retired `HARMONYOS_SUPPORTED_COMMANDS` overlay, which listed + // `app-switcher` for both HarmonyOS kinds: it rides the same hdc-driven key input `home` + // does, so it shares that cell. + ...appSwitcherRuntimeOperationFacts({ appSwitcher: harmonyFocusFact(device) }), + // HarmonyOS never carried a `trigger-app-event` bucket and is absent from the overlay. + ...appEventRuntimeOperationFacts({ triggerAppEvent: harmonyPlatformLeafUnavailable }), + // Parity with the retired `HARMONYOS_SUPPORTED_COMMANDS` overlay, which listed `settings` + // for both HarmonyOS kinds; the hdc-driven settings surface shares the interaction gate. + ...settingsRuntimeOperationFacts({ setSetting: harmonyFocusFact(device) }), + // R59: the retired `alert` descriptor declared no HarmonyOS leaf and the overlay set + // never listed it, so no HarmonyOS cell was ever admitted. + ...alertRuntimeOperationFacts({ + read: harmonyPlatformLeafUnavailable, + wait: harmonyPlatformLeafUnavailable, + accept: harmonyPlatformLeafUnavailable, + dismiss: harmonyPlatformLeafUnavailable, + }), ...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }), ...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }), ...keyboardRuntimeOperationFacts({ @@ -277,6 +299,12 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor dismiss: harmonyFocusFact(device), enter: harmonyFocusFact(device), }), + // HarmonyOS never carried a `clipboard` capability bucket and is absent from the + // HarmonyOS overlay set, so no clipboard cell was ever admitted here. + ...clipboardRuntimeOperationFacts({ + read: harmonyPlatformLeafUnavailable, + write: harmonyPlatformLeafUnavailable, + }), ensureReady: available, bootTarget: unavailable, bootTargetHeadless: unavailable, diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index d6b863d7c4..60b73074b7 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -177,8 +177,8 @@ function expectLifecycleFacts( } /** - * back/home parity with the retired capability bucket: the desktop is the only Linux cell with a - * target to drive. orientation/tv-remote/keyboard never carried a Linux bucket at all. + * back/home/clipboard parity with the retired capability buckets: the desktop is the only Linux + * cell with a target to drive. orientation/tv-remote/keyboard never carried a Linux bucket at all. */ function expectLinuxNavigationAndKeyboardFacts( binding: DeviceBinding, @@ -189,12 +189,29 @@ function expectLinuxNavigationAndKeyboardFacts( expect(binding.facts.operations.home.available).toBe(desktop); expect(binding.operations.back).toBeTypeOf(desktop ? 'function' : 'undefined'); expect(binding.operations.home).toBeTypeOf(desktop ? 'function' : 'undefined'); + // R55: `clipboard`'s retired bucket was `{ device: true }` too — wl-clipboard/xclip/xsel drive + // the desktop session's selection, and no other Linux cell has one. + expect(binding.facts.operations.readClipboard.available).toBe(desktop); + expect(binding.facts.operations.writeClipboard.available).toBe(desktop); + expect(binding.operations.readClipboard).toBeTypeOf(desktop ? 'function' : 'undefined'); + expect(binding.operations.writeClipboard).toBeTypeOf(desktop ? 'function' : 'undefined'); for (const operation of [ 'setOrientation', 'tvRemote', 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', + // R56: the Linux interactor's own `appSwitcher` throws, and the retired descriptor declared + // `linux: {}`, so no Linux cell was ever admitted. + 'appSwitcher', + // R57: the retired `trigger-app-event` descriptor declared `linux: {}` too. + 'triggerAppEvent', + // R58/R59: and so did `settings` and `alert`. + 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 5f8f43949c..4e14abebbb 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -14,48 +14,24 @@ import { availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; -import { - bindElementTextRuntime, - elementTextRuntimeOperationFacts, -} from '@agent-device/contracts/element-text-runtime'; -import { - bindLocalFocusInteractor, - focusRuntimeOperationFacts, -} from '@agent-device/contracts/focus-runtime'; +import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; +import { focusRuntimeOperationFacts } from '@agent-device/contracts/focus-runtime'; import { TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT } from '@agent-device/contracts/gesture-admission'; -import { - bindLocalGestureInteractor, - gestureRuntimeOperationFacts, -} from '@agent-device/contracts/gesture-runtime'; -import { - bindLocalScrollInteractor, - scrollRuntimeOperationFacts, -} from '@agent-device/contracts/scroll-runtime'; +import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; +import { scrollRuntimeOperationFacts } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; -import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; -import { - localRuntimeOwner, - sameRuntimeOwner, - whenAdmitted, -} from '@agent-device/contracts/platform-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { bindLocalInteractorOperationSet } from '@agent-device/contracts/local-interactor-operation-set'; +import { localRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; -import { - bindLocalScreenshotInteractor, - screenshotRuntimeOperationFacts, -} from '@agent-device/contracts/screenshot-runtime'; +import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; import { captureSnapshotSignal, snapshotRuntimeOperationFacts, type CaptureSnapshotInput, } from '@agent-device/contracts/snapshot-runtime'; -import { - bindLocalTypeTextInteractor, - typeTextRuntimeOperationFacts, -} from '@agent-device/contracts/type-text-runtime'; -import { - bindLocalTouchInteractor, - touchRuntimeOperationFacts, -} from '@agent-device/contracts/touch-runtime'; +import { typeTextRuntimeOperationFacts } from '@agent-device/contracts/type-text-runtime'; +import { touchRuntimeOperationFacts } from '@agent-device/contracts/touch-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { bindLinuxApplicationLifecycle } from './lifecycle.ts'; @@ -133,6 +109,10 @@ const homeKindUnavailable = unavailableLinuxRuntimeFact( 'unsupported-device-kind', 'home is supported only for the Linux desktop device.', ); +const clipboardKindUnavailable = unavailableLinuxRuntimeFact( + 'unsupported-device-kind', + 'clipboard is supported only for the Linux desktop device.', +); // `orientation`, `tv-remote`, and every keyboard action never carried a Linux capability bucket // at all (the retired descriptors declared `linux: {}`), so they are unavailable unconditionally. const linuxPlatformLeafUnavailable = unsupportedPlatformLeaf; @@ -167,16 +147,6 @@ export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformR ? linuxSnapshotOperations(host, request) : {}), ...linuxInteractionOperations(host, request, facts), - ...whenAdmitted(facts.operations.tapPoint, () => - bindLocalTouchInteractor({ - facts: facts.operations, - device: request.device, - signal: request.scope.signal, - resolveInteractor: host.localInteractors.resolve, - pause: async (milliseconds) => - await host.clock.sleep(milliseconds, request.scope.signal), - }), - ), }), [Symbol.asyncDispose]: async () => undefined, }) satisfies DeviceBinding; @@ -196,20 +166,11 @@ function linuxInteractionOperations( signal: request.scope.signal, resolveInteractor: host.localInteractors.resolve, }; - return { - ...(facts.operations.captureScreenshot.available - ? bindLocalScreenshotInteractor(resolver) - : {}), - ...(facts.operations.focusPoint.available ? bindLocalFocusInteractor(resolver) : {}), - ...bindLocalGestureInteractor({ ...resolver, facts: facts.operations }), - ...(facts.operations.scrollDirection.available ? bindLocalScrollInteractor(resolver) : {}), - ...(facts.operations.typeText.available ? bindLocalTypeTextInteractor(resolver) : {}), - ...(facts.operations.readTextAtPoint.available ? bindElementTextRuntime(resolver) : {}), - ...bindAdmittedLocalInteractorOperations({ - ...resolver, - facts: facts.operations, - }), - }; + return bindLocalInteractorOperationSet({ + ...resolver, + facts: facts.operations, + pause: async (milliseconds) => await host.clock.sleep(milliseconds, request.scope.signal), + }); } function linuxFacts(device: DeviceInfo): RuntimeFacts { @@ -231,6 +192,20 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts home: homeKindUnavailable, orientation: linuxPlatformLeafUnavailable, tvRemote: linuxPlatformLeafUnavailable, + readClipboard: clipboardKindUnavailable, + writeClipboard: clipboardKindUnavailable, + // The Linux interactor's own `appSwitcher` throws unsupported, and the retired descriptor + // declared `linux: {}`, so no Linux cell was ever admitted. + appSwitcher: linuxPlatformLeafUnavailable, + // The retired `trigger-app-event` descriptor declared `linux: {}`. + triggerAppEvent: linuxPlatformLeafUnavailable, + // The retired `settings` descriptor declared `linux: {}` too. + setSetting: linuxPlatformLeafUnavailable, + // R59: `alert` declared `linux: {}` too — AT-SPI exposes no dialog affordance to act on. + readAlert: linuxPlatformLeafUnavailable, + awaitAlert: linuxPlatformLeafUnavailable, + acceptAlert: linuxPlatformLeafUnavailable, + dismissAlert: linuxPlatformLeafUnavailable, keyboardStatus: linuxPlatformLeafUnavailable, keyboardDismiss: linuxPlatformLeafUnavailable, keyboardEnter: linuxPlatformLeafUnavailable, @@ -285,6 +260,12 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts // is the only Linux cell with a target to drive. ...backRuntimeOperationFacts({ back: linuxDesktopFact(device, backKindUnavailable) }), ...homeRuntimeOperationFacts({ home: linuxDesktopFact(device, homeKindUnavailable) }), + // Parity with the retired `clipboard` capability bucket (`{ device: true }`): wl-clipboard + // / xclip / xsel drive the desktop session's selection, and no other Linux cell has one. + ...clipboardRuntimeOperationFacts({ + read: linuxDesktopFact(device, clipboardKindUnavailable), + write: linuxDesktopFact(device, clipboardKindUnavailable), + }), }, }); } diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index d967c7bf0b..6949992398 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -179,6 +179,41 @@ test.each([ }); expect(binding.operations[operation]).toBeUndefined(); } + // R55/R56: Vega never carried a `clipboard` or `app-switcher` bucket either. + for (const operation of ['readClipboard', 'writeClipboard'] as const) { + expect(binding.facts.operations[operation]).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'clipboard is not supported on Vega OS.', + }); + expect(binding.operations[operation]).toBeUndefined(); + } + expect(binding.facts.operations.appSwitcher).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'app-switcher is not supported on Vega OS.', + }); + expect(binding.operations.appSwitcher).toBeUndefined(); + expect(binding.facts.operations.triggerAppEvent).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'trigger-app-event is not supported on Vega OS.', + }); + expect(binding.operations.triggerAppEvent).toBeUndefined(); + expect(binding.facts.operations.setSetting).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'settings is not supported on Vega OS.', + }); + expect(binding.operations.setSetting).toBeUndefined(); + for (const operation of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(binding.facts.operations[operation]).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'alert is not supported on Vega OS.', + }); + expect(binding.operations[operation]).toBeUndefined(); + } expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 84ecbee93e..6efb9fe1f2 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -131,6 +131,26 @@ const keyboardUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'keyboard is not supported on Vega OS.', ); +const alertUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'alert is not supported on Vega OS.', +); +const settingsUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'settings is not supported on Vega OS.', +); +const appEventUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'trigger-app-event is not supported on Vega OS.', +); +const appSwitcherUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'app-switcher is not supported on Vega OS.', +); +const clipboardUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'clipboard is not supported on Vega OS.', +); const backUnavailable = vegaUnavailable( 'unsupported-device-kind', 'back currently supports only Vega Virtual Devices.', @@ -167,6 +187,15 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts home: homeUnavailable, orientation: orientationUnavailable, tvRemote: tvRemoteUnavailable, + readClipboard: clipboardUnavailable, + writeClipboard: clipboardUnavailable, + appSwitcher: appSwitcherUnavailable, + triggerAppEvent: appEventUnavailable, + setSetting: settingsUnavailable, + readAlert: alertUnavailable, + awaitAlert: alertUnavailable, + acceptAlert: alertUnavailable, + dismissAlert: alertUnavailable, keyboardStatus: keyboardUnavailable, keyboardDismiss: keyboardUnavailable, keyboardEnter: keyboardUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index accb5f5ca3..746e861880 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -199,6 +199,32 @@ test.each([ }, ); +test('clipboard, the app switcher, app events, settings and alerts carry no web bucket', async () => { + const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ + device, + intent: { kind: 'ordinary' }, + scope: scope(), + }); + for (const operation of [ + 'readClipboard', + 'writeClipboard', + 'appSwitcher', + 'triggerAppEvent', + // R58/R59: the retired `settings` and `alert` descriptors declared no web leaf either. + 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', + ] as const) { + expect(binding.facts.operations[operation]).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations[operation]).toBeUndefined(); + } +}); + test('back/home/orientation/tv-remote/keyboard never carried a web capability bucket', async () => { const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ device, @@ -568,8 +594,11 @@ test('diff shares the admitted captureSnapshot fact that live snapshot and diff }); // #1900: `pressRuntimeUses` is literally `clickRuntimeUses` (`platform-runtime-operations.ts`), -// so the admitted fact backing the live `click` command also backs `press`. -test('press shares the admitted tapPoint fact that live click and press both require', async () => { +// so the admitted fact backing the live `click` command also backs `press`. R61 added a third +// consumer of that same cell: `react-native dismiss-overlay` executes one bound `tapPoint`, so a +// browser admits it and answers truthfully that no React Native overlay is present — the widening +// the retired capability bucket had been hiding. +test('press shares the admitted tapPoint fact that live click, press and react-native require', async () => { const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ device, intent: { kind: 'ordinary' }, diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index a26c02bf0b..3dfb206868 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -48,6 +48,11 @@ import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime' import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -395,6 +400,21 @@ function webRuntimeFacts( dismiss: navigationUnavailable, enter: navigationUnavailable, }), + // The web backend never carried a `clipboard` capability bucket (`WEB_QUERY_COMMANDS` + // lists `audio` alone), so no clipboard cell was ever admitted here. + ...clipboardRuntimeOperationFacts({ + read: navigationUnavailable, + write: navigationUnavailable, + }), + ...appSwitcherRuntimeOperationFacts({ appSwitcher: navigationUnavailable }), + ...appEventRuntimeOperationFacts({ triggerAppEvent: navigationUnavailable }), + ...settingsRuntimeOperationFacts({ setSetting: navigationUnavailable }), + ...alertRuntimeOperationFacts({ + read: navigationUnavailable, + wait: navigationUnavailable, + accept: navigationUnavailable, + dismiss: navigationUnavailable, + }), ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index b6506f4109..fada5de279 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -107,6 +107,15 @@ export function createLimrunPlatformRuntimeOwner( keyboardStatus: liveSessionUnavailable, keyboardDismiss: liveSessionUnavailable, keyboardEnter: liveSessionUnavailable, + readClipboard: liveSessionUnavailable, + writeClipboard: liveSessionUnavailable, + appSwitcher: liveSessionUnavailable, + triggerAppEvent: liveSessionUnavailable, + setSetting: liveSessionUnavailable, + readAlert: liveSessionUnavailable, + awaitAlert: liveSessionUnavailable, + acceptAlert: liveSessionUnavailable, + dismissAlert: liveSessionUnavailable, readiness: liveSessionUnavailable, shutdown: liveSessionUnavailable, lifecycle: limrunLifecycleFacts(device, false), diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 9f793f463a..844c45c14d 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -15,6 +15,11 @@ import { import { limrunInteractionOperationFacts, limrunKeyboardOperationFacts, + limrunAppEventOperationFacts, + limrunSettingsOperationFacts, + limrunAlertOperationFacts, + limrunAppSwitcherOperationFacts, + limrunClipboardOperationFacts, limrunNavigationOperationFacts, } from './interaction-operations.ts'; @@ -185,6 +190,11 @@ export function limrunAppLogFacts( ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), ...limrunNavigationOperationFacts(device), ...limrunKeyboardOperationFacts(device), + ...limrunClipboardOperationFacts(device), + ...limrunAppSwitcherOperationFacts(device), + ...limrunAppEventOperationFacts(device), + ...limrunSettingsOperationFacts(device), + ...limrunAlertOperationFacts(device), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -232,6 +242,11 @@ export function limrunAppLogRecoveryFacts( ...limrunInteractionOperationFacts(device, liveSessionUnavailable), ...limrunNavigationOperationFacts(device, liveSessionUnavailable), ...limrunKeyboardOperationFacts(device, liveSessionUnavailable), + ...limrunClipboardOperationFacts(device, liveSessionUnavailable), + ...limrunAppSwitcherOperationFacts(device, liveSessionUnavailable), + ...limrunAppEventOperationFacts(device, liveSessionUnavailable), + ...limrunSettingsOperationFacts(device, liveSessionUnavailable), + ...limrunAlertOperationFacts(device, liveSessionUnavailable), ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index ad0291234e..be51bbfcd9 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -2,7 +2,14 @@ import type { Interactor, RunnerContext } from '@agent-device/contracts/interact import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; -import { limrunNavigationOperationFacts } from './interaction-operations.ts'; +import { + limrunAppEventOperationFacts, + limrunAlertOperationFacts, + limrunSettingsOperationFacts, + limrunAppSwitcherOperationFacts, + limrunClipboardOperationFacts, + limrunNavigationOperationFacts, +} from './interaction-operations.ts'; const iosDevice: DeviceInfo = { platform: 'apple', @@ -48,6 +55,85 @@ test('the Android leg admits back/home/orientation and gates tv-remote on a real expect(tv.tvRemote).toEqual({ available: true }); }); +// R55: the Android leg reuses the local family's `createAndroidInteractor`, so `cmd clipboard +// get/set text` reaches the device exactly as it does locally; the iOS leg's own clipboard +// methods throw, so both halves stay unavailable there. +test('clipboard follows the same Android-reuse / iOS-refusal split its siblings do', () => { + const android = limrunClipboardOperationFacts(androidMobileDevice); + expect(android.readClipboard).toEqual({ available: true }); + expect(android.writeClipboard).toEqual({ available: true }); + + const ios = limrunClipboardOperationFacts(iosDevice); + const refusal = { + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose clipboard access yet.', + }; + expect(ios.readClipboard).toEqual(refusal); + expect(ios.writeClipboard).toEqual(refusal); + + const stale = limrunClipboardOperationFacts(androidMobileDevice, liveSessionUnavailable); + expect(stale.readClipboard).toEqual(liveSessionUnavailable); + expect(stale.writeClipboard).toEqual(liveSessionUnavailable); +}); + +// R56: same Android-reuse / iOS-refusal split. +test('app-switcher rides the Android interactor and is refused on the iOS leg', () => { + expect(limrunAppSwitcherOperationFacts(androidMobileDevice).appSwitcher).toEqual({ + available: true, + }); + expect(limrunAppSwitcherOperationFacts(iosDevice).appSwitcher).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose app switcher yet.', + }); + expect( + limrunAppSwitcherOperationFacts(androidMobileDevice, liveSessionUnavailable).appSwitcher, + ).toEqual(liveSessionUnavailable); +}); + +// R57: app-event delivery is the one system leaf BOTH direct-session legs serve, because each +// implements `open` and a deep link is exactly what that method routes. +test('app-event delivery is admitted on both direct-session legs', () => { + expect(limrunAppEventOperationFacts(androidMobileDevice).triggerAppEvent).toEqual({ + available: true, + }); + expect(limrunAppEventOperationFacts(iosDevice).triggerAppEvent).toEqual({ available: true }); + expect(limrunAppEventOperationFacts(iosDevice, liveSessionUnavailable).triggerAppEvent).toEqual( + liveSessionUnavailable, + ); +}); + +// R58: back to the app-switcher split — the Android leg reuses the local family's interactor, +// and the iOS direct session's own `setSetting` throws. +test('settings ride the Android interactor and are refused on the iOS leg', () => { + expect(limrunSettingsOperationFacts(androidMobileDevice).setSetting).toEqual({ available: true }); + expect(limrunSettingsOperationFacts(iosDevice).setSetting).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose settings changes yet.', + }); + expect( + limrunSettingsOperationFacts(androidMobileDevice, liveSessionUnavailable).setSetting, + ).toEqual(liveSessionUnavailable); +}); + +// R59: same split again — the Android leg reuses the local family's interactor, and the iOS +// direct session has no XCUITest runner to read a sheet from. +test('alert legs ride the Android interactor and are refused on the iOS leg', () => { + for (const leg of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(limrunAlertOperationFacts(androidMobileDevice)[leg]).toEqual({ available: true }); + expect(limrunAlertOperationFacts(iosDevice)[leg]).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose alert inspection yet.', + }); + expect(limrunAlertOperationFacts(androidMobileDevice, liveSessionUnavailable)[leg]).toEqual( + liveSessionUnavailable, + ); + } +}); + test('the iOS leg admits back/orientation but explicitly refuses home and tv-remote', () => { const facts = limrunNavigationOperationFacts(iosDevice); expect(facts.back).toEqual({ available: true }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 3a104a843c..2c4082f953 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -17,6 +17,11 @@ import { scrollRuntimeOperationFacts, } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { bindProviderScreenshotInteractor } from '@agent-device/contracts/screenshot-runtime'; @@ -120,6 +125,21 @@ const keyboardUnavailableIos = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose keyboard actions.', } as const); +const clipboardUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose clipboard access yet.', +} as const); +const settingsUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose settings changes yet.', +} as const); +const appSwitcherUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose app switcher yet.', +} as const); /** * The interactor-backed interaction cells a live Limrun session serves: everything here rides @@ -237,6 +257,87 @@ export function limrunNavigationOperationFacts( * interactor factory `limrunNavigationOperationFacts` above describes; the iOS leg has no tested * provider keyboard behavior, so it stays unavailable. */ +/** + * `clipboard` shares the split its siblings have: the Android leg rides + * `session.dependencies.android.createInteractor` — the SAME factory the local Android family + * binds, so `cmd clipboard get/set text` reaches the device exactly as it does locally — while + * the iOS leg's own `readClipboard`/`writeClipboard` throw, so both cells stay unavailable there + * and carry the interactor's wording. + */ +export function limrunClipboardOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? (device.platform === 'android' ? available : clipboardUnavailableIos); + return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: cell, write: cell }) }); +} + +const alertUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose alert inspection yet.', +} as const); + +/** + * `alert` splits like every other interaction leaf: the Android leg rides the local family's own + * interactor factory, which reads the same accessibility dump it always did; the iOS direct + * session has no XCUITest runner to read a sheet from, so all four legs refuse together. + */ +export function limrunAlertOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? (device.platform === 'android' ? available : alertUnavailableIos); + return Object.freeze({ + ...alertRuntimeOperationFacts({ read: cell, wait: cell, accept: cell, dismiss: cell }), + }); +} + +/** + * `app-switcher` splits the same way its siblings do: the Android leg rides + * `session.dependencies.android.createInteractor` -- the SAME factory the local Android family + * binds -- while the iOS leg's own `appSwitcher` throws. + */ +export function limrunAppSwitcherOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? + (device.platform === 'android' ? available : appSwitcherUnavailableIos); + return Object.freeze({ ...appSwitcherRuntimeOperationFacts({ appSwitcher: cell }) }); +} + +/** + * `trigger-app-event` is the one system leaf both direct-session legs genuinely serve: each + * implements `open`, and a deep link is exactly what that method routes (`openUrl` on iOS, the + * local Android interactor's `am start` on Android). + */ +export function limrunAppEventOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + void device; + return Object.freeze({ + ...appEventRuntimeOperationFacts({ triggerAppEvent: liveSessionUnavailable ?? available }), + }); +} + +/** + * `settings` splits the same way `app-switcher` does: the Android leg rides the local family's + * own interactor factory, while the iOS leg's `setSetting` throws. + */ +export function limrunSettingsOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? (device.platform === 'android' ? available : settingsUnavailableIos); + return Object.freeze({ ...settingsRuntimeOperationFacts({ setSetting: cell }) }); +} + export function limrunKeyboardOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index 596b434594..1f0417f60e 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -293,6 +293,22 @@ class LimrunIosInteractor implements Interactor { throw unsupported('tv-remote', 'Limrun iOS direct sessions do not expose tv remote control.'); } + async readAlert(): Promise { + throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED); + } + + async awaitAlert(): Promise { + throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED); + } + + async acceptAlert(): Promise { + throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED); + } + + async dismissAlert(): Promise { + throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED); + } + async readClipboard(): Promise { throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard read yet.'); } @@ -392,6 +408,10 @@ export function isUserInstalledIosApp(app: LimrunIosApp): boolean { ); } +/** One sentence for all four alert legs: this session has no XCUITest runner to read a sheet. */ +const LIMRUN_IOS_ALERT_UNSUPPORTED = + 'Limrun iOS direct sessions do not expose alert inspection yet.'; + function unsupported(command: string, message: string): never { throw new AppError('UNSUPPORTED_OPERATION', message, { command }); } diff --git a/packages/provider-webdriver/src/capabilities.ts b/packages/provider-webdriver/src/capabilities.ts index 5052307997..0df933e220 100644 --- a/packages/provider-webdriver/src/capabilities.ts +++ b/packages/provider-webdriver/src/capabilities.ts @@ -22,6 +22,7 @@ export type CloudWebDriverOperation = | 'clipboard.read' | 'clipboard.write' | 'settings' + | 'alert' | 'pinch' | 'rotateGesture' | 'transformGesture' @@ -106,6 +107,7 @@ const BASE_WEBDRIVER_CAPABILITIES: CloudWebDriverCapabilityMap = { note: 'Uses provider/Appium clipboard extension support where available.', }, settings: unsupported, + alert: unsupported, pinch: unsupported, rotateGesture: unsupported, transformGesture: unsupported, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 4abf1453c2..6a7b3ee254 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -4,6 +4,23 @@ import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor } from '@agent-device/contracts/interaction'; import { createWebDriverPlatformRuntimeOwner } from './platform-runtime.ts'; +import { + createCloudWebDriverCapabilities, + type CloudWebDriverCapabilityOverrides, +} from './capabilities.ts'; +import type { CloudWebDriverPlatform } from './runtime.ts'; + +/** The declared map fact generation now reads. Defaults to a provider with no overrides. */ +function capabilities( + platform: CloudWebDriverPlatform = 'android', + overrides?: CloudWebDriverCapabilityOverrides, +) { + return createCloudWebDriverCapabilities({ + provider: 'browserstack', + platform, + ...(overrides ? { overrides } : {}), + }); +} const device: DeviceInfo = { platform: 'android', @@ -20,6 +37,7 @@ test('direct WebDriver network uses only the canonical session log and preserves host: host(run), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), }); const binding = await owner.bind({ device, @@ -79,6 +97,7 @@ test.each([ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', String(_name).toLowerCase()), ownsDevice: () => true, + capabilities: capabilities(), }); const binding = await owner.bind({ device: runtimeDevice, @@ -162,6 +181,7 @@ test.each([ runtimeDevice.platform === 'apple' ? 'ios' : 'android', ), ownsDevice: () => true, + capabilities: capabilities(), deployment: { fact: () => ({ available: true }), deployApp, @@ -219,6 +239,7 @@ test('captures through only the active exact WebDriver interactor', async () => host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), snapshotAvailable: true, getInteractor, }); @@ -251,15 +272,29 @@ test('captures through only the active exact WebDriver interactor', async () => reason: 'unsupported-provider-mode', }); expect(binding.operations.readTextAtPoint).toBeUndefined(); - // back/home/orientation ride the same reachable interactor focus/type do. - for (const operation of ['back', 'home', 'setOrientation'] as const) { + // back/home/orientation and both clipboard halves ride the same reachable interactor + // focus/type do; the declared-capability gate stays inside the interactor. + for (const operation of [ + 'back', + 'home', + 'setOrientation', + 'readClipboard', + 'writeClipboard', + 'appSwitcher', + 'triggerAppEvent', + ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: true }); expect(binding.operations[operation]).toBeTypeOf('function'); } - // tv-remote and every keyboard action always throw unsupported in this interactor regardless - // of reachability: no capability ever declared them. + // tv-remote, settings, and every keyboard action always throw unsupported in this interactor + // regardless of reachability: no capability ever declared them. for (const operation of [ 'tvRemote', + 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', @@ -295,6 +330,7 @@ test.each([ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), ...state, getInteractor, }); @@ -315,8 +351,17 @@ test.each([ expect(facts.operations.back.available).toBe(state.isSessionActive()); expect(facts.operations.home.available).toBe(state.isSessionActive()); expect(facts.operations.setOrientation.available).toBe(state.isSessionActive()); + expect(facts.operations.readClipboard.available).toBe(state.isSessionActive()); + expect(facts.operations.writeClipboard.available).toBe(state.isSessionActive()); + expect(facts.operations.appSwitcher.available).toBe(state.isSessionActive()); + expect(facts.operations.triggerAppEvent.available).toBe(state.isSessionActive()); for (const operation of [ 'tvRemote', + 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', @@ -465,6 +510,7 @@ test.each([ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), getInteractor: () => ({}) as unknown as Interactor, }); const facts = await owner.inspectFacts(owned); @@ -486,6 +532,7 @@ test('closes every WebDriver gesture and scroll cell when the interactor is unre host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), getInteractor: undefined, }); const facts = await owner.inspectFacts(device); @@ -503,3 +550,54 @@ test('closes every WebDriver gesture and scroll cell when the interactor is unre }); } }); + +// The defect this pins: a provider configured with `capabilityOverrides` used to be admitted from +// interactor reachability alone, so `capabilities` advertised the operation and the interactor's +// own `requireSupport` threw `UNSUPPORTED_OPERATION` after binding. Admission and execution now +// read the same declared map (ADR 0019 §2). +test.each([ + ['clipboard.read', 'readClipboard'], + ['clipboard.write', 'writeClipboard'], + ['appSwitcher', 'appSwitcher'], + ['back', 'back'], + ['home', 'home'], + ['orientation', 'setOrientation'], +] as const)( + 'an unsupported %s override is refused at admission, not after binding', + async (operation, factKey) => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + capabilities: capabilities('android', { [operation]: 'unsupported' }), + getInteractor: () => ({}) as unknown as Interactor, + }); + + const facts = await owner.inspectFacts(device); + + expect(facts.operations[factKey]).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); + // The refusal carries the capability map author's own wording. + const fact = facts.operations[factKey]; + expect(fact.available === false && String(fact.hint)).toContain(operation); + }, +); + +test('a reachable provider with no overrides still admits its declared operations', async () => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + capabilities: capabilities(), + getInteractor: () => ({}) as unknown as Interactor, + }); + + const facts = await owner.inspectFacts(device); + + // `partial` counts as supported, matching `capabilitySupported` in the interactor. + for (const key of ['readClipboard', 'writeClipboard', 'appSwitcher', 'back', 'home'] as const) { + expect(facts.operations[key].available).toBe(true); + } +}); diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 44f7723610..6fb504337c 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -24,6 +24,11 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; @@ -65,6 +70,12 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { WebDriverDeploymentRuntime } from './runtime-deployment.ts'; import { bindWebDriverApplicationLifecycle } from './lifecycle.ts'; +import { + capabilitySupported, + unsupportedCapabilityMessage, + type CloudWebDriverOperation, + type CloudWebDriverProviderCapabilities, +} from './capabilities.ts'; type WebDriverPlatformDeploymentRuntime = Pick< WebDriverDeploymentRuntime, @@ -197,6 +208,57 @@ const keyboardUnavailable = Object.freeze({ hint: 'WebDriver provider runtimes do not expose keyboard actions.', } as const); +/** + * The interactor's own `readClipboard`/`writeClipboard` call `requireSupport('clipboard.read')` / + * `('clipboard.write')`, so a provider whose declared capability map refuses the extension still + * refuses at call time. This cell states the seam the same way `back`/`home` do: what the fact + * answers is whether this runtime has a reachable interactor to ask at all. + */ +const clipboardUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose clipboard access for this device.', +} as const); + +/** + * `appSwitcher` calls `requireSupport('appSwitcher')` inside the interactor, so a provider whose + * declared capability map refuses the button still refuses at call time. This cell states the + * seam the same way `back`/`home` do: whether this runtime has a reachable interactor to ask. + */ +const appSwitcherUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose the app switcher for this device.', +} as const); + +/** + * The WebDriver interactor's own `setSetting` always throws unsupported (its capability map + * declares `settings: unsupported`), so this cell is unavailable unconditionally rather than + * gated by interactor reachability — the same shape `tvRemote` takes. + */ +/** + * Same shape as `settings`: the WebDriver interactor's own alert legs always throw unsupported + * (its capability map declares `alert: unsupported`), so this cell is unavailable unconditionally + * rather than gated by interactor reachability. + */ +const alertUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'WebDriver provider runtimes do not expose native alert handling.', +} as const); + +const settingsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'WebDriver provider runtimes do not expose device settings.', +} as const); + +const appEventUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose app-event delivery for this device.', +} as const); + const appStateUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -256,6 +318,13 @@ export type WebDriverPlatformRuntimeOptions = Readonly<{ ownsDevice(device: DeviceInfo): boolean; isSessionActive?(device: DeviceInfo): boolean; deployment?: WebDriverPlatformDeploymentRuntime; + /** + * The provider's declared capability map — the same one `webdriver-interactor.ts` refuses + * against at call time. Fact generation reads it so an operation this provider declares + * unsupported is stated unavailable up front instead of admitted and then thrown out of + * (ADR 0019 §2: no stubs that throw `unsupported` after binding). + */ + capabilities: CloudWebDriverProviderCapabilities; screenshotAvailable?: boolean; snapshotAvailable?: boolean; getInteractor?(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; @@ -396,6 +465,32 @@ function interactorCell( return reachable ? available : whenUnreachable; } +/** + * Reachability AND the provider's own declaration. `webdriver-interactor.ts` refuses at call time + * through `capabilitySupported`, so admission reads the same predicate and the same map: a + * provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }` now refuses + * at admission, where the caller can see it in `capabilities`, instead of binding and throwing. + * + * The refusal carries the provider's own note, so the message a caller sees is the one the + * capability map author wrote. + */ +function declaredCapabilityCell( + params: Readonly<{ + reachable: boolean; + capabilities: CloudWebDriverProviderCapabilities; + operation: CloudWebDriverOperation; + whenUnreachable: RuntimeOperationUnavailability; + }>, +): RuntimeOperationFact { + if (!params.reachable) return params.whenUnreachable; + if (capabilitySupported(params.capabilities, params.operation)) return available; + return Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: unsupportedCapabilityMessage(params.capabilities, params.operation), + } as const); +} + function webDriverFacts( options: Omit, device: DeviceInfo, @@ -421,6 +516,15 @@ function webDriverFacts( keyboardStatus: inactiveSession, keyboardDismiss: inactiveSession, keyboardEnter: inactiveSession, + readClipboard: inactiveSession, + writeClipboard: inactiveSession, + appSwitcher: inactiveSession, + triggerAppEvent: inactiveSession, + setSetting: inactiveSession, + readAlert: inactiveSession, + awaitAlert: inactiveSession, + acceptAlert: inactiveSession, + dismissAlert: inactiveSession, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: inactiveSession, prepareApplicationOpen: inactiveSession, @@ -455,6 +559,15 @@ function webDriverFacts( keyboardStatus: keyboardUnavailable, keyboardDismiss: keyboardUnavailable, keyboardEnter: keyboardUnavailable, + readClipboard: clipboardUnavailable, + writeClipboard: clipboardUnavailable, + appSwitcher: appSwitcherUnavailable, + triggerAppEvent: appEventUnavailable, + setSetting: settingsUnavailable, + readAlert: alertUnavailable, + awaitAlert: alertUnavailable, + acceptAlert: alertUnavailable, + dismissAlert: alertUnavailable, lifecycle: webDriverLifecycleFacts(device), }); // Both capture cells need the same reachability: an interactor this provider can drive, on a @@ -464,6 +577,17 @@ function webDriverFacts( reachable && options.snapshotAvailable !== false ? available : snapshotUnavailable; const screenshotCell = reachable && options.screenshotAvailable !== false ? available : screenshotUnavailable; + // One binding of the shared predicate, so every keyed operation below reads the same way. + const declared = ( + operation: CloudWebDriverOperation, + whenUnreachable: RuntimeOperationUnavailability, + ) => + declaredCapabilityCell({ + reachable, + capabilities: options.capabilities, + operation, + whenUnreachable, + }); return Object.freeze({ device: unavailable.device, operations: { @@ -483,14 +607,14 @@ function webDriverFacts( // Focus rides the same provider interactor the captures do, so it needs the same // reachability and nothing more: this provider drives touch wherever it can drive a capture. ...focusRuntimeOperationFacts({ focus: interactorCell(reachable, focusUnavailable) }), - ...typeTextRuntimeOperationFacts({ type: interactorCell(reachable, typeUnavailable) }), + ...typeTextRuntimeOperationFacts({ type: declared('type', typeUnavailable) }), ...touchRuntimeOperationFacts({ - tap: interactorCell(reachable, focusUnavailable), + tap: declared('tap', focusUnavailable), tapRef: focusUnavailable, - longPress: interactorCell(reachable, focusUnavailable), + longPress: declared('longPress', focusUnavailable), hover: focusUnavailable, hoverRef: focusUnavailable, - fill: interactorCell(reachable, typeUnavailable), + fill: declared('fill', typeUnavailable), fillRef: typeUnavailable, tapElementSelector: focusUnavailable, }), @@ -505,13 +629,13 @@ function webDriverFacts( targetAuthoredDrag: interactorCell(reachable, gestureUnavailable), viewport: interactorCell(reachable, gestureUnavailable), }), - ...scrollRuntimeOperationFacts({ scroll: interactorCell(reachable, scrollUnavailable) }), + ...scrollRuntimeOperationFacts({ scroll: declared('scroll', scrollUnavailable) }), // `back`/`home`/`orientation` ride the same reachable interactor; `tvRemote` always throws // unsupported in this interactor regardless of reachability (no capability declares it). - ...backRuntimeOperationFacts({ back: interactorCell(reachable, backUnavailable) }), - ...homeRuntimeOperationFacts({ home: interactorCell(reachable, homeUnavailable) }), + ...backRuntimeOperationFacts({ back: declared('back', backUnavailable) }), + ...homeRuntimeOperationFacts({ home: declared('home', homeUnavailable) }), ...orientationRuntimeOperationFacts({ - orientation: interactorCell(reachable, orientationUnavailable), + orientation: declared('orientation', orientationUnavailable), }), ...tvRemoteRuntimeOperationFacts({ tvRemote: tvRemoteUnavailable }), ...keyboardRuntimeOperationFacts({ @@ -519,6 +643,38 @@ function webDriverFacts( dismiss: keyboardUnavailable, enter: keyboardUnavailable, }), + // Clipboard rides the same reachable interactor `back`/`home` do; the declared-capability + // gate stays inside the interactor, where it already lives. + // + // R55 cell delta, deliberate: the retired `supportsHostOrSimulatorSurface` closure refused + // `clipboard` on every provider-owned physical Apple device, because it was a LOCAL-Apple + // predicate (host helper or simulator) being applied to a device this provider drives over + // Appium — which does expose the clipboard extension. The refusal moves to where it can be + // true: the interactor, per session. + ...clipboardRuntimeOperationFacts({ + read: declared('clipboard.read', clipboardUnavailable), + write: declared('clipboard.write', clipboardUnavailable), + }), + ...appSwitcherRuntimeOperationFacts({ + appSwitcher: declared('appSwitcher', appSwitcherUnavailable), + }), + // The deep link opens through the same reachable interactor `open` every lifecycle command + // drives on this provider. + ...appEventRuntimeOperationFacts({ + triggerAppEvent: interactorCell(reachable, appEventUnavailable), + }), + ...settingsRuntimeOperationFacts({ setSetting: settingsUnavailable }), + // R59 cell delta, deliberate: the retired `supportsAlertSurface` closure ADMITTED `alert` on + // a provider-owned physical iOS device (it keyed on `appleOs === 'ios'` alone), and the + // handler then drove the LOCAL XCTest runner against a device living in someone else's + // cloud. Nothing this provider owns can serve an alert leg, so it states that up front + // instead of admitting and failing mid-execution (ADR 0019 §2). + ...alertRuntimeOperationFacts({ + read: alertUnavailable, + wait: alertUnavailable, + accept: alertUnavailable, + dismiss: alertUnavailable, + }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ensureReady: available, bootTarget: available, diff --git a/packages/provider-webdriver/src/runtime.ts b/packages/provider-webdriver/src/runtime.ts index d7977ce9fd..2daaba237c 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -165,6 +165,7 @@ class CloudWebDriverRuntimeImplementation implements CloudWebDriverRuntime { ownsDevice: (device) => this.ownsDevice(device), isSessionActive: (device) => this.sessions.findSessionForDevice(device) !== undefined, deployment: this.deployment, + capabilities: this.capabilities, snapshotAvailable: this.capabilities.operations.snapshot.support !== 'unsupported', screenshotAvailable: this.capabilities.operations.screenshot.support !== 'unsupported', getInteractor: (device) => this.getInteractor(device), diff --git a/packages/provider-webdriver/src/webdriver-interactor.ts b/packages/provider-webdriver/src/webdriver-interactor.ts index 1edd2a114b..7ee48accc5 100644 --- a/packages/provider-webdriver/src/webdriver-interactor.ts +++ b/packages/provider-webdriver/src/webdriver-interactor.ts @@ -333,6 +333,24 @@ class WebDriverInteractor implements Interactor { this.unsupported('settings'); } + // The four alert legs share one declared capability: a driver that cannot read a native alert + // cannot press its buttons either, and no provider in this family declares either half. + async readAlert(): Promise> { + this.unsupported('alert'); + } + + async awaitAlert(): Promise> { + this.unsupported('alert'); + } + + async acceptAlert(): Promise> { + this.unsupported('alert'); + } + + async dismissAlert(): Promise> { + this.unsupported('alert'); + } + /** * The cloud twin of the local Apple runner's focus -> readiness -> type * pipeline (RunnerTests+TextEntry.swift). A WebView input does not take first diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index 4079619082..ce03f6a768 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -34,9 +34,9 @@ const TRIPWIRE_LINES = 1_000; // Exact current lengths. Lower a pin when its file shrinks; never raise one — extract instead. const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/__tests__/remote-connection.test.ts': 2973, - 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2310, + 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2284, 'src/commands/interaction/runtime/settle.test.ts': 2359, - 'src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts': 2024, + 'src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts': 2020, 'src/platforms/apple/core/__tests__/runner-session.test.ts': 2001, 'src/utils/__tests__/daemon-client.test.ts': 1873, 'src/utils/__tests__/output.test.ts': 1861, @@ -48,13 +48,13 @@ const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/platforms/apple/core/__tests__/runner-command-retry.test.ts': 1327, 'src/__tests__/cli-client-commands.test.ts': 1317, 'src/__tests__/cli-config.test.ts': 1282, - 'src/daemon/handlers/__tests__/find.test.ts': 1202, + 'src/daemon/handlers/__tests__/find.test.ts': 1199, 'src/platforms/apple/core/__tests__/perf.test.ts': 1222, 'src/mcp/__tests__/command-tools.test.ts': 1216, - 'src/daemon/handlers/__tests__/session-replay-divergence.test.ts': 1137, + 'src/daemon/handlers/__tests__/session-replay-divergence.test.ts': 1136, 'src/platforms/apple/core/__tests__/apps.test.ts': 1210, 'src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts': 1208, - 'src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts': 1183, + 'src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts': 1182, 'src/__tests__/client-metro.test.ts': 1105, 'src/__tests__/cli-network.test.ts': 1092, 'src/platforms/android/__tests__/snapshot-helper.test.ts': 1002, diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index b7f41161fd..2a7a5a3d02 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -73,8 +73,8 @@ test('daemon modularity baseline records the measured R7 ownership pressure', () Object.values(SESSION_STATE_FIELD_OWNERS).reduce((sum, owners) => sum + owners.length, 0), DAEMON_MODULARITY_BASELINE.sessionState.ownerFileClaims, ); - assert.equal(TYPE_CYCLE_BASELINE, 25); - assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 14); + assert.equal(TYPE_CYCLE_BASELINE, 20); + assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 11); assert.equal('daemon' in DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers, false); }); @@ -239,7 +239,7 @@ test('R10 zone overflow lists the whole zone so the joining member is visible', const [violation] = violations; assert.equal(violation!.rule, 'R10 daemon-modularity'); assert.equal(violation!.file, 'scripts/layering/daemon-modularity.ts'); - assert.match(violation!.message, /contains 15 daemon-server file\(s\) \(baseline 14\)/); + assert.match(violation!.message, /contains 12 daemon-server file\(s\) \(baseline 11\)/); for (const member of daemonMembers) { assert.ok(violation!.message.includes(member), `${member} missing from: ${violation!.message}`); } @@ -257,6 +257,6 @@ test('R9 rejects a baseline left above the measured cycle', () => { assert.equal(violations.length, 1); assert.match(violations[0]!.rule, /^R9 /); - assert.match(violations[0]!.message, /dropped to 24 files \(baseline 25\)/); + assert.match(violations[0]!.message, /dropped to 19 files \(baseline 20\)/); assert.match(violations[0]!.message, /Lower LARGEST_TYPE_CYCLE_ZONE_CEILINGS by the same 1/); }); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 648f468732..770ad65f5b 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -4,8 +4,15 @@ import { SESSION_STATE_FIELD_OWNERS } from './session-state.ts'; const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly> = { '(root)': 2, - core: 8, - 'daemon-server': 14, + // R58 retired the legacy command dispatcher, taking `core/dispatch.ts` and the + // `core/interactors.ts` registry it pulled in out of the cycle with it. + core: 6, + // Same move, daemon side: with no dispatcher to re-fire a tap through, the pending-outcome + // retry declares its own callback seam instead of importing runtime admission, so + // `interaction-outcome-policy.ts` and `deferred-interaction-outcome.ts` both left the cycle. + // R63 then deleted `session-install-capability-projection.ts` outright — the general + // fact-owned projection subsumes it — taking a third member with it. + 'daemon-server': 11, // R42/R43/R45 deleted `vega/plugin.ts`'s `PUBLIC_COMMANDS` import (the retired // back/home/tv-remote closures were its only consumer), dropping it out of the cycle and // leaving `apple/plugin.ts` as the platforms zone's sole remaining member. diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 2e27769f8d..b463cf9009 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -48,14 +48,18 @@ const contracts: WorkspacePackage = { const ALL = [kernel, contracts]; const CONTRACT_EXPORTS = [ '@agent-device/contracts/alert-contract', + '@agent-device/contracts/alert-runtime', + '@agent-device/contracts/android-clipboard-support', '@agent-device/contracts/android-input-ownership', '@agent-device/contracts/android-snapshot-quality', '@agent-device/contracts/android-system-chrome', '@agent-device/contracts/app-deployment-runtime', '@agent-device/contracts/app-deployment-runtime-plan', + '@agent-device/contracts/app-event-runtime', '@agent-device/contracts/app-inventory-runtime', '@agent-device/contracts/app-log-runtime', '@agent-device/contracts/app-state-runtime', + '@agent-device/contracts/app-switcher-runtime', '@agent-device/contracts/apple-multitouch-support', '@agent-device/contracts/application-lifecycle-interaction', '@agent-device/contracts/application-lifecycle-runtime', @@ -68,6 +72,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/capture', '@agent-device/contracts/click-button', '@agent-device/contracts/client', + '@agent-device/contracts/clipboard-runtime', '@agent-device/contracts/command', '@agent-device/contracts/command-platform-execution', '@agent-device/contracts/device', @@ -91,6 +96,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/interactor-operation-catalog', '@agent-device/contracts/interactor-types', '@agent-device/contracts/keyboard-runtime', + '@agent-device/contracts/local-interactor-operation-set', '@agent-device/contracts/logs-runtime-plan', '@agent-device/contracts/navigation', '@agent-device/contracts/network-runtime', @@ -118,6 +124,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/selector-observation-runtime', '@agent-device/contracts/session', '@agent-device/contracts/settings', + '@agent-device/contracts/settings-runtime', '@agent-device/contracts/snapshot', '@agent-device/contracts/snapshot-presentation', '@agent-device/contracts/snapshot-runtime', diff --git a/scripts/layering/runtime-command-cutover-extensions.ts b/scripts/layering/runtime-command-cutover-extensions.ts index ac1fe61e2a..e7a6e8037f 100644 --- a/scripts/layering/runtime-command-cutover-extensions.ts +++ b/scripts/layering/runtime-command-cutover-extensions.ts @@ -1,3 +1,4 @@ +import { retiredDispatchProjectionViolations } from './runtime-command-cutover-descriptor.ts'; import { parseSync } from 'oxc-parser'; import { propertyName, visitAst, type ProductionSource } from './cutover-policy-ast.ts'; import { lineOf } from './runtime-command-cutover-ast.ts'; @@ -11,6 +12,17 @@ export { runtimeLifecycleRouteBindingViolations, } from './runtime-command-cutover-lifecycle.ts'; +/** + * Every migrated row proves the same thing about its own command: the retired dispatch projection + * is gone. One factory carries the command, so a row states its name once in the row literal + * rather than adding another identically-shaped wrapper beside the table. + */ +export function retiredDispatchProjectionProof( + command: string, +): (sources: ReadonlyMap) => UnruledViolation[] { + return (sources) => retiredDispatchProjectionViolations(sources, command); +} + type AstNode = Record; const DEVICES_HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts'; diff --git a/scripts/layering/runtime-command-cutover-table-wave6.ts b/scripts/layering/runtime-command-cutover-table-wave6.ts new file mode 100644 index 0000000000..256383bb3f --- /dev/null +++ b/scripts/layering/runtime-command-cutover-table-wave6.ts @@ -0,0 +1,198 @@ +import type { MigratedCommandCutover } from './runtime-command-cutover-model.ts'; +import { retiredDispatchProjectionProof } from './runtime-command-cutover-extensions.ts'; + +/** + * Wave 6's rows (#1739): the seven named command units that left `platformExecution: legacy` for + * request-bound runtimes, plus `react-native`. + * + * They live beside `runtime-command-cutover-table.ts` rather than inside it because that file had + * grown past the point where one read covers it. The split is by wave, which is how the tracker + * retires these rows: a wave's rows are deleted together once this ADR declares its commands' + * migrations closed, and a whole-file deletion is a cleaner end than excising a run of literals + * from the middle of a larger table. + */ +export const WAVE_6_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ + { + rule: 'R55 clipboard-runtime-cutover', + command: 'clipboard', + subject: 'device clipboard', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm and its `core/dispatch.ts` handler. The daemon route function was + // renamed to `handleSessionClipboardCommand` when it moved out of the over-budget + // `handlers/session.ts`, so this name is now genuinely absent from production rather than + // shadowed by a surviving namesake. + routeNames: ['handleClipboardCommand'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy clipboard support or hint closure', + }, + runtimeTypeNames: ['ClipboardRuntimeOperations'], + operations: { names: ['readClipboard', 'writeClipboard'] }, + singularExecution: { + // `clipboard` is action-selected (R35's lesson): the session route resolves exactly one of + // the two operations per request, binds once, and never both together. + routes: ['resolveBoundClipboardRuntime'], + operations: ['readClipboard', 'writeClipboard'], + operationOwners: { + readClipboard: ['executeClipboardRead'], + writeClipboard: ['executeClipboardWrite'], + }, + }, + extensions: [retiredDispatchProjectionProof('clipboard')], + }, + { + rule: 'R56 app-switcher-runtime-cutover', + command: 'app-switcher', + subject: 'app switcher reveal', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm; `app-switcher` had no dedicated named handler function to retire + // (its legacy body lived inline in the `DISPATCH_HANDLERS` literal). It also leaves the + // HarmonyOS overlay that granted it a capability bucket the descriptor never listed. + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy app-switcher support or hint closure', + }, + runtimeTypeNames: ['AppSwitcherRuntimeOperations'], + operations: { names: ['appSwitcher'] }, + singularExecution: { + routes: ['dispatchGenericCommand'], + operations: ['appSwitcher'], + operationOwners: { appSwitcher: ['executeAppSwitcher'] }, + }, + extensions: [retiredDispatchProjectionProof('app-switcher')], + }, + { + rule: 'R57 trigger-app-event-runtime-cutover', + command: 'trigger-app-event', + subject: 'app-event delivery', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm and its `core/dispatch.ts` handler, plus the session route's + // last capability-gate-then-`dispatchCommand` thunk: with `trigger-app-event` bound, every + // leaf on that route supplies a bind-and-execute thunk instead. The daemon route function + // is `handleAppEventCommand` now, so the retired name is genuinely absent rather than + // shadowed by a surviving namesake. + routeNames: ['handleTriggerAppEventCommand', 'legacySessionDispatchExecute'], + }, + runtimeTypeNames: ['AppEventRuntimeOperations'], + operations: { names: ['triggerAppEvent'] }, + singularExecution: { + routes: ['resolveBoundAppEventRuntime'], + operations: ['triggerAppEvent'], + operationOwners: { triggerAppEvent: ['executeAppEvent'] }, + }, + extensions: [retiredDispatchProjectionProof('trigger-app-event')], + }, + { + rule: 'R58 settings-runtime-cutover', + command: 'settings', + subject: 'device settings mutation', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // `settings` was the last `DISPATCH_HANDLERS` arm, so this row retires the legacy command + // dispatcher whole — its table, its three entry points, its name enumerator, and the + // request-router fallback that reached for it when no runtime route claimed a command. + // Every one of these names is now absent from production rather than shadowed. + routeNames: [ + 'dispatchCommand', + 'dispatchWithInteractor', + 'dispatchKnownCommand', + 'DISPATCH_HANDLERS', + 'listRegisteredDispatchCommandNames', + 'executeGenericPlatformCommand', + ], + // Settings also leaves the HarmonyOS overlay that granted it a bucket membership the + // descriptor never listed; the set still exists for `perf` and must no longer name it. + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy settings support or hint closure', + }, + runtimeTypeNames: ['SettingsRuntimeOperations'], + operations: { names: ['setSetting'] }, + singularExecution: { + routes: ['handleSettingsCommand'], + operations: ['setSetting'], + operationOwners: { setSetting: ['executeSetSetting'] }, + }, + extensions: [retiredDispatchProjectionProof('settings')], + }, + { + rule: 'R59 alert-runtime-cutover', + command: 'alert', + subject: 'native alert handling', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // `alert` never had a dispatch-table arm: its daemon route called the Apple runner, the + // macOS helper and the Android alert module directly, and owned their poll and retry + // windows itself. Those four names are what R59 retires, and with the Apple closure gone + // the per-AppleOS capability table lost its last reader and went too. + routeNames: [ + 'handleNativeAlertCommand', + 'waitForNativeAlert', + 'handleNativeAlertAction', + 'supportsAlertSurface', + ], + modulePaths: ['src/platforms/apple/capabilities.ts'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy alert support or hint closure', + }, + runtimeTypeNames: ['AlertRuntimeOperations'], + operations: { names: ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] }, + singularExecution: { + // Action-selected (R35's lesson): the snapshot route resolves exactly one of the four legs + // per request, binds once, and never two together. + routes: ['resolveBoundAlertRuntime'], + operations: ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'], + operationOwners: { + readAlert: ['executeReadAlert'], + awaitAlert: ['executeAwaitAlert'], + acceptAlert: ['executeAcceptAlert'], + dismissAlert: ['executeDismissAlert'], + }, + }, + extensions: [retiredDispatchProjectionProof('alert')], + }, + { + rule: 'R61 react-native-runtime-cutover', + command: 'react-native', + subject: 'React Native overlay dismissal', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The command's device work moved onto a bound `tapPoint` with R48; what R61 retires is the + // capability gate that still stood in front of it (proved by this table's own admission + // check) and the resolve-then-execute shape that gate implied. The dismissal function was + // renamed to `executeReactNativeOverlayDismiss` to record that: it no longer resolves + // anything, so the old name is genuinely absent rather than shadowed by a namesake. + routeNames: ['dismissReactNativeOverlayTarget'], + }, + runtimeTypeNames: ['BoundTouchRuntime'], + operations: { names: ['tapPoint'] }, + singularExecution: { + // Admission happens once, before the observing capture; the dismissal reuses that binding + // rather than admitting a second time after the overlay is known. + routes: ['executeReactNativeOverlayDismiss'], + operations: ['tapPoint'], + operationOwners: { tapPoint: ['createTapTouchExecutor'] }, + }, + extensions: [retiredDispatchProjectionProof('react-native')], + }, +]; diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 05dfeac696..25e27b0827 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -9,9 +9,10 @@ import { prepareLifecycleRouteBindingViolations, runtimeLifecycleRouteBindingViolations, sourceExecutedUsingDeclarationViolations, + retiredDispatchProjectionProof, } from './runtime-command-cutover-extensions.ts'; import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; -import { retiredDispatchProjectionViolations } from './runtime-command-cutover-descriptor.ts'; +import { WAVE_6_COMMAND_CUTOVERS } from './runtime-command-cutover-table-wave6.ts'; /** * One row per migrated command (ADR 0019 §8). A new command unit adds a row here; the @@ -30,6 +31,9 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d * leaves follow: back at R42, home at R43, orientation at R44, tv-remote at R45, and the * action-selected keyboard at R46. * The gesture cluster follows the touch leaves: gesture at R52, scroll at R53, swipe at R54. + * Wave 6 closure starts at R55 clipboard, then R56 app-switcher, R57 trigger-app-event, + * R58 settings — the arm that retires the legacy dispatcher itself — R59 alert and + * R61 react-native. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -474,7 +478,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'], }, }, - extensions: [snapshotRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('snapshot')], }, { rule: 'R33 diff-runtime-cutover', @@ -508,7 +512,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'], }, }, - extensions: [diffRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('diff')], }, { rule: 'R35 find-runtime-cutover', @@ -1075,20 +1079,9 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ }, }, }, + ...WAVE_6_COMMAND_CUTOVERS, ]; -function snapshotRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'snapshot'); -} - -function diffRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'diff'); -} - /** The record mechanics policy predates the row model and reports `path: message`. */ function recordDaemonMechanicsProof(sources: ReadonlyMap): UnruledViolation[] { const production = [...sources].map(([path, source]) => ({ path, source })); diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index 1a9d9a3a19..f3ffea0dc6 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -75,7 +75,7 @@ test('help workflow prints the compact workflow card with a version header and s assert.equal(result.calls.length, 0); assert.match(result.stdout, /^agent-device \S+ — workflow/); assert.ok( - Buffer.byteLength(result.stdout, 'utf8') < 9000, + Buffer.byteLength(result.stdout, 'utf8') < 9100, `help workflow should stay close to the compact-card size target, was ${Buffer.byteLength(result.stdout, 'utf8')} bytes`, ); assert.match(result.stdout, /open -> snapshot -i -> settle -> verify -> close loop/); diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-capability-closure-parity.test.ts similarity index 65% rename from src/__tests__/contracts/apple-os-capability-table-parity.test.ts rename to src/__tests__/contracts/apple-capability-closure-parity.test.ts index fc329f821d..7410246ca5 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-capability-closure-parity.test.ts @@ -2,11 +2,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { isAudioProbeSupportedDevice } from '@agent-device/contracts/audio-probe-support'; import { - isMacOs, resolveDeviceAppleOs, DEVICE_TARGETS, PLATFORMS, - type AppleOS, type DeviceInfo, type DeviceKind, type DeviceTarget, @@ -23,33 +21,24 @@ import { VISIONOS_SIMULATOR, WEB_DESKTOP_DEVICE, } from '../test-utils/device-fixtures.ts'; -import { APPLE_OS_CAPABILITIES } from '../../platforms/apple/capabilities.ts'; import { getPlugin } from '../../core/platform-plugin-registry.ts'; import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; -// Phase 3 step d.5 table-equivalence gate. The AppleOS-axis predicates -// (`target !== 'tv'` / `platform !== 'macos'` / `isTvOsDevice`) that used to be -// open-coded in the Apple capability closures now READ the per-`AppleOS` data table -// (`apple-os-capabilities.ts`). This test pins that the swap is byte-for-byte -// behaviorless: the closures now living on the Apple plugin return an identical -// boolean / identical hint STRING to an INDEPENDENT verbatim copy of the ORIGINAL -// predicates plus intentional backend-specific gates, across the full -// {command x sample-device} matrix — real discovery shapes -// for iOS/iPadOS/tvOS/macOS/visionOS plus the exhaustive synthetic cross-product. +// The equivalence gate for whatever Apple capability closures still exist. It began as the +// ADR-0009 step d.5 table-equivalence test, pinning closures that had been rewritten to read a +// per-`AppleOS` data table; R59 retired that table with its last reader (`alert`), so what +// remains are the two closures that were never table-driven. The shape of the check is unchanged: +// each closure on the Apple plugin must return an identical boolean / identical hint STRING to an +// INDEPENDENT verbatim copy of its contract, across the full {command x sample-device} matrix — +// real discovery shapes for iOS/iPadOS/tvOS/macOS/visionOS plus the exhaustive synthetic +// cross-product. registerBuiltinPlatformPlugins(); // --------------------------------------------------------------------------- -// Independent copies of the command capability contracts, including the original -// AppleOS predicates and current backend-specific gates. This oracle stays independent -// of the table it pins (mirrors capability-plugin-routing-parity.test.ts). +// Independent copies of the command capability contracts. This oracle stays independent of the +// closures it pins (mirrors capability-plugin-routing-parity.test.ts). // --------------------------------------------------------------------------- -const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device); -const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => - isMacOs(device) || device.kind === 'simulator'; -const isIosOs = (device: DeviceInfo): boolean => - device.platform === 'apple' && - (device.appleOs ? device.appleOs === 'ios' : device.target !== 'tv'); const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => device.platform !== 'apple' || device.kind !== 'device' || @@ -58,25 +47,16 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef supportsCoreDevicePhysicalOperation(device) ? undefined : 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.'; -// `home`/`keyboard`/`orientation`/`tv-remote` are gone from this table (R42/R43/R44/R45/R46 -// retired their AppleOS-table-reading closures along with their descriptor capability buckets); -// their per-AppleOS admission now lives as owner facts in `packages/platform-apple/src/runtime.ts`. +// `home`/`keyboard`/`orientation`/`tv-remote` left with R42-R46, `clipboard` with R55, +// `app-switcher` with R56, `settings` with R58 and `alert` with R59 — each cutover retiring its +// AppleOS-table-reading closure along with its descriptor capability bucket. `alert` was the +// table's last reader, so the table went with it; per-AppleOS admission now lives as owner facts +// in `packages/platform-apple/src/runtime.ts` and its `system/`, `navigation/` siblings. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, - 'app-switcher': isNotMacOs, - clipboard: (device) => - device.platform === 'android' || - device.platform === 'linux' || - isMacOs(device) || - device.kind === 'simulator', - alert: (device) => - device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), - settings: (device) => - device.platform === 'android' || isMacOs(device) || device.kind === 'simulator', - // `audio` is NOT part of the AppleOS-table relocation — it stays the standalone - // `isAudioProbeSupportedDevice` predicate. Included here only so the key-set - // assertion stays strict (catches a dropped command) and confirms the rebase - // did not alter it. + // `audio` was never part of the AppleOS-table relocation — it is the standalone + // `isAudioProbeSupportedDevice` predicate. Included here so the key-set assertion stays strict + // (it catches a dropped command) and confirms no rebase altered it. audio: isAudioProbeSupportedDevice, }; const HINT_REF: Record string | undefined> = { @@ -136,15 +116,6 @@ const SAMPLE_DEVICES: DeviceInfo[] = [ ...buildSyntheticMatrix(), ]; -test('the per-AppleOS capability table row keys are exhaustive', () => { - const rows: AppleOS[] = ['ios', 'ipados', 'tvos', 'watchos', 'visionos', 'macos']; - for (const os of rows) { - assert.ok(APPLE_OS_CAPABILITIES[os], `capability row present for ${os}`); - } - // iOS/iPadOS share the same platform capability profile. - assert.equal(APPLE_OS_CAPABILITIES.ios, APPLE_OS_CAPABILITIES.ipados); -}); - test('resolveDeviceAppleOs prefers the stored discriminant, else infers from target', () => { // Stored `appleOs` wins. assert.equal(resolveDeviceAppleOs(IPADOS_SIMULATOR), 'ipados'); @@ -156,7 +127,7 @@ test('resolveDeviceAppleOs prefers the stored discriminant, else infers from tar assert.equal(resolveDeviceAppleOs(MACOS_DEVICE), 'macos'); }); -test('table-driven Apple supports() closures match the independent command contracts', () => { +test('Apple supports() closures match the independent command contracts', () => { const appleSupports = getPlugin('apple').capability.supportsByDefault; assert.ok(appleSupports, 'the Apple plugin carries supportsByDefault'); // Every command that had an original predicate must still carry one, keyed the same. @@ -174,7 +145,7 @@ test('table-driven Apple supports() closures match the independent command contr } }); -test('table-driven Apple unsupportedHint() closures match the independent contracts', () => { +test('Apple unsupportedHint() closures match the independent contracts', () => { const appleHints = getPlugin('apple').capability.unsupportedHintByDefault; assert.ok(appleHints, 'the Apple plugin carries unsupportedHintByDefault'); assert.deepEqual(Object.keys(appleHints).sort(), Object.keys(HINT_REF).sort()); diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index 23c9c4159d..a2fb69b7fb 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -114,6 +114,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ // --- @agent-device/contracts --- 'packages/contracts/src/alert-contract.ts': 1, + 'packages/contracts/src/android-clipboard-support.ts': 1, 'packages/contracts/src/android-input-ownership.ts': 1, 'packages/contracts/src/android-snapshot-quality.ts': 1, 'packages/contracts/src/android-system-chrome.ts': 1, @@ -144,7 +145,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/facades/divergence.ts': 3, 'packages/contracts/src/facades/interaction.ts': 25, 'packages/contracts/src/facades/observability.ts': 7, - 'packages/contracts/src/facades/platform.ts': 51, + 'packages/contracts/src/facades/platform.ts': 56, 'packages/contracts/src/facades/progress.ts': 1, 'packages/contracts/src/facades/recording.ts': 3, 'packages/contracts/src/facades/remote.ts': 2, @@ -168,7 +169,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/platform-module.ts': 5, 'packages/contracts/src/platform-runtime-host.ts': 1, 'packages/contracts/src/platform-runtime-operations.ts': 2, - 'packages/contracts/src/platform-runtime-unavailable.ts': 23, + 'packages/contracts/src/platform-runtime-unavailable.ts': 28, 'packages/contracts/src/platform-runtime.ts': 6, 'packages/contracts/src/record-runtime-cutover.ts': 7, 'packages/contracts/src/react-native-overlay.ts': 1, @@ -242,12 +243,23 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/xml/src/index.ts': 3, // Added by #1993 (device-inventory context moved out of core). - 'packages/contracts/src/back-runtime.ts': 4, - 'packages/contracts/src/home-runtime.ts': 4, - 'packages/contracts/src/interactor-operation-catalog.ts': 9, - 'packages/contracts/src/keyboard-runtime.ts': 4, - 'packages/contracts/src/orientation-runtime.ts': 4, - 'packages/contracts/src/tv-remote-runtime.ts': 4, + 'packages/contracts/src/back-runtime.ts': 1, + // Added by Wave 6 R55/R56/R57/R58/R59: the clipboard, app-switcher, app-event, settings and + // alert facets, + // plus the local interaction set Android and Linux used to hold a byte-identical copy of each. + // The set is its own module rather than part of the interactor catalog so the catalog's closure + // stays leaf-thin -- every module the set pulls in is one its two consumers already evaluate. + 'packages/contracts/src/alert-runtime.ts': 1, + 'packages/contracts/src/app-event-runtime.ts': 1, + 'packages/contracts/src/app-switcher-runtime.ts': 1, + 'packages/contracts/src/clipboard-runtime.ts': 1, + 'packages/contracts/src/settings-runtime.ts': 1, + 'packages/contracts/src/local-interactor-operation-set.ts': 26, + 'packages/contracts/src/home-runtime.ts': 1, + 'packages/contracts/src/interactor-operation-catalog.ts': 14, + 'packages/contracts/src/keyboard-runtime.ts': 3, + 'packages/contracts/src/orientation-runtime.ts': 1, + 'packages/contracts/src/tv-remote-runtime.ts': 1, }); /** @@ -275,12 +287,12 @@ export const HUB_BUDGETS: Readonly> = Object.freeze({ // the same PR added and only a source checkout reaches, and which therefore loads on demand // (`resolveLocalDaemonCodeSignature`) rather than appearing here. 'src/cli.ts': 365, - 'src/platform-runtime.ts': 39, - 'src/core/dispatch.ts': 83, - 'src/core/capabilities.ts': 75, + 'src/platform-runtime.ts': 44, + 'src/core/dispatch.ts': 79, + 'src/core/capabilities.ts': 74, 'src/core/command-descriptor/registry.ts': 66, 'src/core/command-descriptor/platform-execution-entry.ts': 3, - 'src/core/interactors/register-builtins.ts': 73, + 'src/core/interactors/register-builtins.ts': 72, 'src/daemon/session-teardown.ts': 90, }); diff --git a/src/__tests__/platform-runtime-android-clipboard-probe.test.ts b/src/__tests__/platform-runtime-android-clipboard-probe.test.ts new file mode 100644 index 0000000000..a025863edf --- /dev/null +++ b/src/__tests__/platform-runtime-android-clipboard-probe.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createAndroidToolHost } from '../platform-runtime-android-tool-host.ts'; + +const runAndroidAdb = vi.hoisted(() => vi.fn()); + +vi.mock('../platforms/android/adb.ts', async (importOriginal) => ({ + ...(await importOriginal()), + runAndroidAdb, +})); + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel 9 Pro XL', + kind: 'emulator', + booted: true, +}; + +function adbResult(exitCode: number, stdout = '', stderr = '') { + return { exitCode, stdout, stderr, stdoutBuffer: Buffer.from(stdout) }; +} + +async function probe() { + const host = createAndroidToolHost(); + return await host.probeClipboardShellSupport?.(device); +} + +// The probe runs with `allowFailure`, so every adb outcome short of a transport throw arrives as an +// ordinary result. Anything the adapter reports as `supported` is cached for the runtime owner's +// lifetime, so a wrong admission here is not a single bad answer -- it advertises a clipboard the +// build may not have until the daemon restarts. +describe('android clipboard shell probe: what each adb result is allowed to prove', () => { + test('a clean exit is the only thing that proves support', async () => { + runAndroidAdb.mockResolvedValueOnce(adbResult(0, 'clipboard contents')); + await expect(probe()).resolves.toBe('supported'); + }); + + test('an empty clipboard on a clean exit still proves support', async () => { + runAndroidAdb.mockResolvedValueOnce(adbResult(0, '')); + await expect(probe()).resolves.toBe('supported'); + }); + + test('the missing-shell prose proves the build ships no clipboard command', async () => { + runAndroidAdb.mockResolvedValueOnce( + adbResult(255, '', 'Error: no shell command implementation.'), + ); + await expect(probe()).resolves.toBe('unsupported'); + }); + + // The planted red: before the fix every non-zero result that lacked the missing-shell prose fell + // through to `supported`, so an offline device advertised a working clipboard. + test.each([ + ['a device that dropped off the bridge', 'error: device offline'], + ['an unauthorized device', 'error: device unauthorized.'], + ['a bridge that never found the device', "error: device '(null)' not found"], + ['a generic adb failure', 'error: closed'], + ])('%s refuses rather than admitting support', async (_case, stderr) => { + runAndroidAdb.mockResolvedValueOnce(adbResult(1, '', stderr)); + await expect(probe()).resolves.toBe('probe-failed'); + }); + + test('a transport throw refuses rather than admitting support', async () => { + runAndroidAdb.mockRejectedValueOnce(new Error('spawn adb ENOENT')); + await expect(probe()).resolves.toBe('probe-failed'); + }); + + // adb reports the missing shell command non-zero, so the prose is only ever evidence about a + // call that failed -- which is exactly why it must not be read on a call that succeeded. + test.each([ + ['stderr', '', 'Unknown command: clipboard'], + ['stdout', 'No shell command implementation.', ''], + ])('missing-shell prose on %s of a non-zero exit reads as unsupported', async (_c, out, err) => { + runAndroidAdb.mockResolvedValueOnce(adbResult(1, out, err)); + await expect(probe()).resolves.toBe('unsupported'); + }); + + // The second planted red: on a clean exit stdout is the clipboard's *contents*, so reading the + // prose first let a user who had copied one of these phrases -- from a terminal, a bug report, + // this very file -- brand their own working clipboard `unsupported` for the owner's lifetime. + test.each([ + ['unknown command'], + ['no shell command implementation'], + ['adb said: Unknown command: clipboard'], + ['No shell command implementation.'], + ])('a clipboard holding %j is still supported', async (contents) => { + runAndroidAdb.mockResolvedValueOnce(adbResult(0, contents)); + await expect(probe()).resolves.toBe('supported'); + }); +}); diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 2af8e143b2..7c2a1d0a7b 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -62,6 +62,15 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, + appSwitcher: unavailable, + triggerAppEvent: unavailable, + setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, }); /** Default facts for tests that are unrelated to application lifecycle commands. */ diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index fcd23401a6..c8a76d91cc 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -119,7 +119,7 @@ test('usageForCommand resolves workflow help topic', async () => { if (help === null) throw new Error('Expected workflow help text'); assert.match(help, /^agent-device \S+ — workflow/); assert.ok( - Buffer.byteLength(help, 'utf8') < 9000, + Buffer.byteLength(help, 'utf8') < 9100, `workflow help topic should stay close to the compact-card size target, was ${Buffer.byteLength(help, 'utf8')} bytes`, ); assert.match(help, /open -> snapshot -i -> settle -> verify -> close loop/); diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index 2da57dc224..9e5bc550c1 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -143,7 +143,7 @@ Snapshots and refs: snapshot reads visible state; snapshot -i gets current interactive refs only -- the fast path before an interaction. Default text is agent-facing and token-efficient; --raw/--json only for the full provider tree. Legend: @e12 [button] label="Add to cart" enabled hittable -> press @e12. [off-screen below] -> scroll down (a hint, not a ref). Refs stay valid until you press/click/fill/type/scroll/back/wait-for-async-UI, or otherwise change app state; open/--relaunch clears the stored snapshot outright. - Prefer --settle and continue from its settled diff when it shows the next target; refresh with snapshot -i only when you did not settle, settle reported not settled, or its output lacks what you need. A known selector/label after a mutation is often enough, since interaction commands refresh state internally. + Prefer --settle and continue from its settled diff when it shows the next target; refresh with snapshot -i only when you did not settle, it reported not settled, or its output lacks what you need. A known selector/label after a mutation is often enough, since interaction commands refresh state internally. Truncated preview: snapshot -s @e12 (the current concrete ref), not get text. Missing target in a list: scroll down/up (not bottom/top unless the task wants the edge), then snapshot -i. TV/D-pad focus: help tv. Selectors: diff --git a/src/commands/__tests__/command-explain.test.ts b/src/commands/__tests__/command-explain.test.ts index 43706f9c82..b9deac2f68 100644 --- a/src/commands/__tests__/command-explain.test.ts +++ b/src/commands/__tests__/command-explain.test.ts @@ -72,7 +72,6 @@ describe('explainCommand', () => { exposure: { batchable: true, mcp: true, - dispatch: false, postActionObservation: 'settle-and-verify', }, cli: { usage: 'click ' }, diff --git a/src/commands/command-explain.ts b/src/commands/command-explain.ts index 9fab6fe4a0..103a107b27 100644 --- a/src/commands/command-explain.ts +++ b/src/commands/command-explain.ts @@ -40,7 +40,6 @@ export type CommandExplanation = { exposure: { batchable: boolean; mcp: boolean; - dispatch: boolean; postActionObservation?: string; }; timeout: { @@ -126,7 +125,6 @@ function buildCommandExplanation( family?.name, 'daemon' in descriptor ? descriptor.daemon?.route : undefined, Boolean('capability' in descriptor && descriptor.capability), - Boolean('dispatch' in descriptor && descriptor.dispatch), fileExists, daemonRouteOwnerFiles, ), @@ -146,7 +144,7 @@ export function formatCommandExplanation( explanation.daemon ? `daemon: ${explanation.daemon.route}${explanation.daemon.traits.length ? ` (${explanation.daemon.traits.join(', ')})` : ''}` : 'daemon: none', - `exposure: batch=${yesNo(explanation.exposure.batchable)}, mcp=${yesNo(explanation.exposure.mcp)}, dispatch=${yesNo(explanation.exposure.dispatch)}${explanation.exposure.postActionObservation ? `, observe=${explanation.exposure.postActionObservation}` : ''}`, + `exposure: batch=${yesNo(explanation.exposure.batchable)}, mcp=${yesNo(explanation.exposure.mcp)}${explanation.exposure.postActionObservation ? `, observe=${explanation.exposure.postActionObservation}` : ''}`, `timeout: envelope=${formatEnvelope(explanation.timeout.envelopeMs)}, on-timeout=${explanation.timeout.onTimeout}, budget=${explanation.timeout.budget}`, ]; if (explanation.capability) { @@ -229,7 +227,6 @@ function describeCommandExposure(descriptor: CommandDescriptor): CommandExplanat return { batchable: descriptor.batchable, mcp: descriptor.mcpExposed, - dispatch: 'dispatch' in descriptor && descriptor.dispatch !== undefined, ...('postActionObservation' in descriptor && descriptor.postActionObservation ? { postActionObservation: descriptor.postActionObservation } : {}), @@ -320,7 +317,6 @@ function commandFiles( family: string | undefined, daemonRoute: DaemonCommandRoute | undefined, hasCapability: boolean, - hasDispatch: boolean, fileExists: FileExists | undefined, daemonRouteOwnerFiles: Readonly>, ): string[] { @@ -336,7 +332,6 @@ function commandFiles( derived.push('src/cli-schema/command-overrides.ts'); } if (daemonRoute) derived.push(daemonRouteOwnerFiles[daemonRoute]); - if (hasDispatch) derived.push('src/core/dispatch.ts'); if (hasCapability) derived.push('src/core/capabilities.ts'); const present = fileExists ? opportunistic.filter(fileExists) : opportunistic; return [...new Set([...derived, ...ownerFiles, ...present])]; diff --git a/src/core/__tests__/dispatch-trigger-app-event.test.ts b/src/core/__tests__/app-event-delivery.test.ts similarity index 85% rename from src/core/__tests__/dispatch-trigger-app-event.test.ts rename to src/core/__tests__/app-event-delivery.test.ts index 63c52454a9..65baea6dbd 100644 --- a/src/core/__tests__/dispatch-trigger-app-event.test.ts +++ b/src/core/__tests__/app-event-delivery.test.ts @@ -2,7 +2,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { dispatchCommand } from '../dispatch.ts'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { parseTriggerAppEventArgs, resolveAppEventUrl } from '../app-events.ts'; +import { getInteractor } from '../interactors.ts'; import { AppError } from '@agent-device/kernel/errors'; import { ANDROID_EMULATOR, @@ -11,6 +14,30 @@ import { } from '../../__tests__/test-utils/device-fixtures.ts'; import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; +/** + * The composed `trigger-app-event` path as R57 left it: the daemon's own policy resolves the + * event URL, and the bound `triggerAppEvent` operation opens it through the selected owner's + * interactor. This helper is the same composition `daemon/app-event-runtime.ts` performs, minus + * the request plumbing, so these tests keep asserting the real shell invocation the retired + * `dispatchCommand('trigger-app-event', …)` used to reach. + */ +async function triggerAppEvent( + device: DeviceInfo, + positionals: string[], + options: { appBundleId?: string } = {}, +): Promise<{ event: string; eventUrl: string; transport: 'deep-link' }> { + const { eventName, payload } = parseTriggerAppEventArgs(positionals); + const eventUrl = resolveAppEventUrl(device, eventName, payload); + const operations = bindAdmittedLocalInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor: async (owned: DeviceInfo, runner) => await getInteractor(owned, runner), + facts: { triggerAppEvent: { available: true } }, + }); + await operations.triggerAppEvent?.({ eventUrl, options }); + return { event: eventName, eventUrl, transport: 'deep-link' }; +} + test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', async () => { const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; const previousAndroidTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; @@ -19,7 +46,7 @@ test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', try { await assert.rejects( - () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['screenshot_taken']), + () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION'); @@ -43,11 +70,7 @@ test('trigger-app-event validates payload JSON', async () => { 'myapp://agent-device/event?name={event}&payload={payload}'; try { await assert.rejects( - () => - dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ - 'screenshot_taken', - '{invalid-json', - ]), + () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken', '{invalid-json']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -82,7 +105,7 @@ test('trigger-app-event opens deep link with encoded event payload', async () => 'myapp://agent-device/event?name={event}&payload={payload}&platform={platform}'; try { - const result = await dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ + const result = await triggerAppEvent(ANDROID_EMULATOR, [ 'screenshot_taken', '{"source":"qa","count":2}', ]); @@ -127,9 +150,7 @@ test('trigger-app-event prefers platform-specific template over global template' process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'myapp://android?name={event}'; try { - const result = await dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ - 'screenshot_taken', - ]); + const result = await triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']); assert.equal(result?.eventUrl, 'myapp://android?name=screenshot_taken'); } finally { process.env.PATH = previousPath; @@ -167,13 +188,9 @@ test('trigger-app-event supports iOS device path and prefers iOS template', asyn 'myapp://ios?name={event}&payload={payload}'; try { - const result = await dispatchCommand( - IOS_DEVICE, - 'trigger-app-event', - ['screenshot_taken', '{"source":"ios"}'], - undefined, - { appBundleId: 'com.example.app' }, - ); + const result = await triggerAppEvent(IOS_DEVICE, ['screenshot_taken', '{"source":"ios"}'], { + appBundleId: 'com.example.app', + }); const expectedUrl = 'myapp://ios?name=screenshot_taken&payload=%7B%22source%22%3A%22ios%22%7D'; assert.equal(result?.eventUrl, expectedUrl); const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); @@ -224,7 +241,7 @@ test('trigger-app-event supports macOS and prefers macOS template', async () => 'myapp://macos?name={event}&payload={payload}&platform={platform}'; try { - const result = await dispatchCommand(MACOS_DEVICE, 'trigger-app-event', [ + const result = await triggerAppEvent(MACOS_DEVICE, [ 'screenshot_taken', '{"source":"desktop"}', ]); @@ -253,7 +270,7 @@ test('trigger-app-event rejects invalid event names', async () => { 'myapp://agent-device/event?name={event}'; try { await assert.rejects( - () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['bad event']), + () => triggerAppEvent(ANDROID_EMULATOR, ['bad event']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -275,11 +292,7 @@ test('trigger-app-event rejects payloads that exceed size limits', async () => { const oversizedPayload = JSON.stringify({ value: 'x'.repeat(9000) }); try { await assert.rejects( - () => - dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ - 'screenshot_taken', - oversizedPayload, - ]), + () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken', oversizedPayload]), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -299,7 +312,7 @@ test('trigger-app-event rejects event URLs that exceed length limits', async () process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = `myapp://${'a'.repeat(5000)}?name={event}`; try { await assert.rejects( - () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['screenshot_taken']), + () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 07f4eb4f48..74ef80f7ab 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -24,14 +24,6 @@ const xctestIosDevice: DeviceInfo = { iosPhysicalDeviceBackend: 'xctest', }; -const iPadOsDevice: DeviceInfo = { - platform: 'apple', - appleOs: 'ipados', - id: 'ipad-dev-1', - name: 'iPad', - kind: 'device', -}; - const androidDevice: DeviceInfo = { platform: 'android', id: 'and-1', @@ -93,25 +85,6 @@ function assertCommandSupport(commands: string[], checks: SupportCheck[]): void test('device capability matrix stays consistent across shared command groups', () => { const scenarios: Array<{ commands: string[]; checks: SupportCheck[] }> = [ - { - commands: ['alert'], - checks: [ - { device: iosSimulator, expected: true, label: 'on iOS sim' }, - { device: iosDevice, expected: true, label: 'on iOS device' }, - { device: iPadOsDevice, expected: false, label: 'on iPadOS device' }, - { device: androidDevice, expected: true, label: 'on Android' }, - { device: macOsDevice, expected: true, label: 'on macOS' }, - ], - }, - { - commands: ['settings', 'clipboard'], - checks: [ - { device: iosSimulator, expected: true, label: 'on iOS sim' }, - { device: iosDevice, expected: false, label: 'on iOS device' }, - { device: androidDevice, expected: true, label: 'on Android' }, - { device: macOsDevice, expected: true, label: 'on macOS' }, - ], - }, { commands: ['gesture', 'swipe'], checks: [ @@ -199,7 +172,6 @@ test('capabilities reject CoreDevice-only commands for XCTest-backed devices', ( test('macOS supports the Apple runner interaction core but excludes mobile-only commands', () => { assertCommandSupport( [ - 'alert', 'back', 'click', 'fill', @@ -210,7 +182,6 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only 'perf', 'press', 'record', - 'settings', 'screenshot', 'scroll', 'snapshot', @@ -222,9 +193,11 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only ], [{ device: macOsDevice, expected: true, label: 'on macOS' }], ); + // R56 moved `app-switcher` off this matrix onto the Apple owner's springboard fact, which is + // where the macOS refusal now lives (`platform-apple/src/runtime.test.ts` pins that cell). assertCommandSupport( ['app-switcher'], - [{ device: macOsDevice, expected: false, label: 'on macOS' }], + [{ device: macOsDevice, expected: true, label: 'through runtime admission on macOS' }], ); }); @@ -249,17 +222,19 @@ test('tvOS follows iOS capability matrix by device kind', () => { ], [{ device: tvOsSimulator, expected: true, label: 'on tvOS' }], ); - assertCommandSupport( - ['settings', 'alert'], - [{ device: tvOsSimulator, expected: true, label: 'on tvOS simulator' }], - ); }); -test('Linux supports desktop interaction commands and blocks mobile/unsupported ones', () => { +test('the residual capability matrix leaves Linux desktop interaction admitted', () => { // Runtime-backed network admission is proven from operation facts in - // session-capabilities.test.ts, not through this legacy matrix projection. + // session-capabilities.test.ts, not through this legacy matrix projection. `alert` (R59), + // `clipboard` (R55), `settings` (R58) and `trigger-app-event` (R57) sit in the admitted list for + // the same reason the already-migrated commands do: a command whose admission comes from exact + // owner facts carries no capability-matrix row, and a command with no row is not decided by this + // matrix at all. `platform-linux/src/runtime.test.ts` is where the real Linux cells are pinned — + // and it is the one that refuses the mobile-only legs this test used to list. assertCommandSupport( [ + 'alert', 'back', 'click', 'clipboard', @@ -273,16 +248,15 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'screenshot', 'scroll', 'snapshot', + 'settings', 'swipe', + 'trigger-app-event', 'type', 'wait', ], [{ device: linuxDevice, expected: true, label: 'on Linux' }], ); - assertCommandSupport( - ['alert', 'app-switcher', 'perf', 'settings', 'trigger-app-event'], - [{ device: linuxDevice, expected: false, label: 'on Linux' }], - ); + assertCommandSupport(['perf'], [{ device: linuxDevice, expected: false, label: 'on Linux' }]); }); test('web supports only the initial browser interaction slice', () => { @@ -293,11 +267,17 @@ test('web supports only the initial browser interaction slice', () => { 'fill', 'focus', 'find', - // `gesture` and `swipe` (R42/R44) join the migrated commands here for the same reason - // `focus`, `find`, `screenshot`, `scroll`, `snapshot`, `type` and `wait` already do: a - // command whose admission comes from exact owner facts carries no capability-matrix row, - // and a command with no row is not decided by this matrix at all. The web owner refuses - // every gesture tier — `platform-web/src/runtime.test.ts` is where that cell is pinned. + // `gesture`/`swipe` (R42/R44), `clipboard` (R55), `app-switcher` (R56), + // `trigger-app-event` (R57), `settings` (R58) and `alert` (R59) join the migrated commands + // here for the same reason `focus`, `find`, `screenshot`, `scroll`, `snapshot`, `type` and + // `wait` already do: a command whose admission comes from exact owner facts carries no + // capability-matrix row, and a command with no row is not decided by this matrix at all. The + // web owner refuses every gesture tier, both clipboard halves, the springboard, the app-event + // leg, every setting and all four alert legs — `platform-web/src/runtime.test.ts` is where + // those cells are pinned. + 'alert', + 'app-switcher', + 'clipboard', 'gesture', 'get', 'hover', @@ -306,16 +286,15 @@ test('web supports only the initial browser interaction slice', () => { 'screenshot', 'scroll', 'snapshot', + 'settings', 'swipe', + 'trigger-app-event', 'type', 'wait', ], [{ device: webDevice, expected: true, label: 'on web' }], ); - assertCommandSupport( - ['alert', 'app-switcher', 'clipboard', 'perf', 'settings', 'trigger-app-event'], - [{ device: webDevice, expected: false, label: 'on web' }], - ); + assertCommandSupport(['perf'], [{ device: webDevice, expected: false, label: 'on web' }]); assertCommandSupport( ['longpress'], [{ device: webDevice, expected: true, label: 'through runtime admission on web' }], diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 107d229087..39047d0aa6 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -100,12 +100,6 @@ const SAMPLE_DEVICES: DeviceInfo[] = [ // (b.2) Independent copies of the per-command supports()/unsupportedHint() // contracts. Kept in sync by hand so this oracle stays independent of production. // --------------------------------------------------------------------------- -const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device); -const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => - isMacOs(device) || device.kind === 'simulator'; -const isIosOs = (device: DeviceInfo): boolean => - device.platform === 'apple' && - (device.appleOs ? device.appleOs === 'ios' : device.target !== 'tv'); const supportsHostAudioProbe = (device: DeviceInfo): boolean => device.platform === 'web' || (process.platform === 'darwin' && @@ -125,16 +119,8 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef // gains/loses a closure (or whose closure body changes) breaks parity. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, - 'app-switcher': isNotMacOs, - clipboard: (device) => - device.platform === 'android' || - device.platform === 'linux' || - isMacOs(device) || - device.kind === 'simulator', - alert: (device) => - device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), - settings: (device) => - device.platform === 'android' || isMacOs(device) || device.kind === 'simulator', + // `alert`'s closure left with R59, whose cutover made the owner's own alert facts the whole + // admission; the per-leaf verdicts it encoded are pinned in `platform-apple/src/system/`. audio: supportsHostAudioProbe, }; const HINT_REF: Record string | undefined> = { @@ -238,7 +224,7 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', () // Runtime-backed navigation, keyboard, and touch commands dropped out of the matrix entirely: // capability buckets), so they are absent here — not because HarmonyOS admission changed, but // because there is no bucket left for `isCommandSupportedOnDevice` to consult at all. - assert.deepEqual(availableCommands, ['app-switcher', 'perf', 'settings']); + assert.deepEqual(availableCommands, ['perf']); }); test('(b.2) unsupportedHint closures are verbatim across the full device matrix', () => { diff --git a/src/core/__tests__/dispatch-back.test.ts b/src/core/__tests__/dispatch-back.test.ts deleted file mode 100644 index f7eca6bfb9..0000000000 --- a/src/core/__tests__/dispatch-back.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { dispatchCommand } from '../dispatch.ts'; -import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; - -// R42 retired `back` from `DISPATCH_HANDLERS`; its ADB-command-level parity pin now lives on -// `backAndroid` directly (`src/platforms/android/__tests__/input-actions.test.ts`), and its -// admitted-runtime behavior in `src/daemon/__tests__/back-runtime.test.ts`. -test('legacy dispatch no longer reaches the back leaf', async () => { - await withMockedAdb('agent-device-dispatch-back-retired-', async (argsLogPath) => { - await assert.rejects( - dispatchCommand(ANDROID_EMULATOR, 'back', [], undefined, { backMode: 'in-app' }), - { code: 'INVALID_ARGS', message: 'Unknown command: back' }, - ); - - const { promises: fs } = await import('node:fs'); - await assert.rejects( - fs.readFile(argsLogPath, 'utf8'), - { code: 'ENOENT' }, - 'no adb command was ever issued, so the args log was never created', - ); - }); -}); diff --git a/src/core/__tests__/dispatch-keyboard.test.ts b/src/core/__tests__/dispatch-keyboard.test.ts deleted file mode 100644 index deffa930cd..0000000000 --- a/src/core/__tests__/dispatch-keyboard.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { test, vi } from 'vitest'; -import assert from 'node:assert/strict'; - -vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn() }; -}); - -import { dispatchCommand } from '../dispatch.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { ANDROID_EMULATOR, IOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; - -const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); - -// R46 retired `keyboard` from `DISPATCH_HANDLERS` and its dedicated -// `handleAndroidKeyboardCommand`/`handleHarmonyKeyboardCommand`/`handleIosKeyboardCommand` -// helpers. Its ADB ENTER-keyevent parity pin now lives on `pressAndroidEnter` directly -// (`src/platforms/android/__tests__/input-actions.test.ts`); its iOS runner routing (including -// the dismiss-mechanism disclosure and degradation cases) is covered by the Apple interactor's -// own runner-provider suite (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`) -// and by `src/daemon/__tests__/keyboard-runtime.test.ts`, which also covers the admitted-runtime -// action-selection and per-platform response shapes. -test('legacy dispatch no longer reaches the keyboard leaf on Android', async () => { - await withMockedAdb('agent-device-dispatch-keyboard-retired-android-', async (argsLogPath) => { - await assert.rejects(dispatchCommand(ANDROID_EMULATOR, 'keyboard', ['enter']), { - code: 'INVALID_ARGS', - message: 'Unknown command: keyboard', - }); - - const { promises: fs } = await import('node:fs'); - await assert.rejects( - fs.readFile(argsLogPath, 'utf8'), - { code: 'ENOENT' }, - 'no adb command was ever issued, so the args log was never created', - ); - }); -}); - -test('legacy dispatch no longer reaches the keyboard leaf on iOS', async () => { - await assert.rejects( - dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, { - appBundleId: 'com.example.app', - }), - { - code: 'INVALID_ARGS', - message: 'Unknown command: keyboard', - }, - ); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); -}); diff --git a/src/core/__tests__/dispatch-orientation.test.ts b/src/core/__tests__/dispatch-orientation.test.ts deleted file mode 100644 index c1825fe1b8..0000000000 --- a/src/core/__tests__/dispatch-orientation.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { test, vi } from 'vitest'; -import assert from 'node:assert/strict'; - -vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn() }; -}); - -import { dispatchCommand } from '../dispatch.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { ANDROID_EMULATOR, IOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; - -const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); - -// R44 retired `orientation` from `DISPATCH_HANDLERS`. Its ADB-command-level parity pin now lives -// on `setAndroidOrientation` directly (`src/platforms/android/__tests__/input-actions.test.ts`); -// its iOS runner routing (including the mismatched-readback rejection) is covered by the Apple -// interactor's own runner-provider suite -// (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`); its admitted-runtime -// behavior is covered in `src/daemon/__tests__/orientation-runtime.test.ts`. -test('legacy dispatch no longer reaches the orientation leaf on Android', async () => { - await withMockedAdb('agent-device-dispatch-orientation-retired-android-', async () => { - await assert.rejects(dispatchCommand(ANDROID_EMULATOR, 'orientation', ['left']), { - code: 'INVALID_ARGS', - message: 'Unknown command: orientation', - }); - }); -}); - -test('legacy dispatch no longer reaches the orientation leaf on iOS', async () => { - await assert.rejects(dispatchCommand(IOS_DEVICE, 'orientation', ['left']), { - code: 'INVALID_ARGS', - message: 'Unknown command: orientation', - }); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); -}); diff --git a/src/core/__tests__/dispatch-screenshot.test.ts b/src/core/__tests__/dispatch-screenshot.test.ts deleted file mode 100644 index fc5ca7829b..0000000000 --- a/src/core/__tests__/dispatch-screenshot.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { dispatchCommand } from '../dispatch.ts'; -import { withWebProvider, type WebProvider } from '../../platforms/web/provider.ts'; - -const webDevice = { - id: 'web', - name: 'Web', - platform: 'web', - kind: 'device', - booted: true, -} as const; - -test('legacy dispatch no longer reaches an interactor screenshot operation', async () => { - const screenshot = vi.fn(async () => undefined); - - await expect( - withWebProvider( - makeWebProvider({ screenshot }), - async () => await dispatchCommand(webDevice, 'screenshot', ['/tmp/out.png']), - ), - ).rejects.toMatchObject({ - code: 'INVALID_ARGS', - message: 'Unknown command: screenshot', - }); - - expect(screenshot).not.toHaveBeenCalled(); -}); - -function makeWebProvider(overrides: Partial = {}): WebProvider { - return { - open: async () => {}, - close: async () => {}, - snapshot: async () => ({ nodes: [] }), - screenshot: async () => {}, - setViewport: async () => {}, - click: async () => {}, - fill: async () => {}, - typeText: async () => {}, - scroll: async () => {}, - ...overrides, - }; -} diff --git a/src/core/__tests__/dispatch-tv-remote.test.ts b/src/core/__tests__/dispatch-tv-remote.test.ts deleted file mode 100644 index bb09d9fd19..0000000000 --- a/src/core/__tests__/dispatch-tv-remote.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { test, vi } from 'vitest'; -import assert from 'node:assert/strict'; - -vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn() }; -}); - -import { dispatchCommand } from '../dispatch.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { ANDROID_TV_DEVICE, TVOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; - -const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); - -// R45 retired `tv-remote` from `DISPATCH_HANDLERS` and its dedicated handler function. Its -// ADB D-pad-keyevent parity pin now lives on `pressAndroidTvRemote` directly -// (`src/platforms/android/__tests__/input-actions.test.ts`); its tvOS runner routing is covered -// by the Apple interactor's own runner-provider suite -// (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`); its TV-target admission -// (formerly an in-handler check, now an owner fact) and admitted-runtime behavior are covered in -// `src/daemon/__tests__/tv-remote-runtime.test.ts`. -test('legacy dispatch no longer reaches the tv-remote leaf on Android TV', async () => { - await withMockedAdb('agent-device-dispatch-tv-remote-retired-android-', async () => { - await assert.rejects(dispatchCommand(ANDROID_TV_DEVICE, 'tv-remote', ['right']), { - code: 'INVALID_ARGS', - message: 'Unknown command: tv-remote', - }); - }); -}); - -test('legacy dispatch no longer reaches the tv-remote leaf on tvOS', async () => { - await assert.rejects(dispatchCommand(TVOS_SIMULATOR, 'tv-remote', ['back']), { - code: 'INVALID_ARGS', - message: 'Unknown command: tv-remote', - }); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); -}); diff --git a/src/core/__tests__/dispatch-viewport.test.ts b/src/core/__tests__/dispatch-viewport.test.ts deleted file mode 100644 index c8b3057382..0000000000 --- a/src/core/__tests__/dispatch-viewport.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { dispatchCommand } from '../dispatch.ts'; -import { withWebProvider, type WebProvider } from '../../platforms/web/provider.ts'; - -const webDevice = { - id: 'web', - name: 'Web', - platform: 'web', - kind: 'device', - booted: true, -} as const; - -test('legacy dispatch no longer reaches the web interactor viewport operation', async () => { - const setViewport = vi.fn(async () => undefined); - const provider = makeWebProvider({ setViewport }); - - await expect( - withWebProvider( - provider, - async () => await dispatchCommand(webDevice, 'viewport', ['1280', '900']), - ), - ).rejects.toMatchObject({ - code: 'INVALID_ARGS', - message: 'Unknown command: viewport', - }); - - expect(setViewport).not.toHaveBeenCalled(); -}); - -function makeWebProvider(overrides: Partial = {}): WebProvider { - return { - open: async () => {}, - close: async () => {}, - snapshot: async () => ({ nodes: [] }), - screenshot: async () => {}, - setViewport: async () => {}, - click: async () => {}, - fill: async () => {}, - typeText: async () => {}, - scroll: async () => {}, - ...overrides, - }; -} diff --git a/src/daemon/__tests__/snapshot-state.test.ts b/src/core/__tests__/snapshot-state.test.ts similarity index 100% rename from src/daemon/__tests__/snapshot-state.test.ts rename to src/core/__tests__/snapshot-state.test.ts diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 189e64e2c2..057b6642c3 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -30,7 +30,7 @@ export type CommandCapability = { const WEB_DEVICE: KindMatrix = { device: true }; const HARMONYOS_ALL: KindMatrix = { emulator: true, device: true }; -const HARMONYOS_SUPPORTED_COMMANDS = new Set(['perf', 'app-switcher', 'settings']); +const HARMONYOS_SUPPORTED_COMMANDS = new Set(['perf']); const WEB_QUERY_COMMANDS = ['audio'] as const; const WEB_SUPPORTED_COMMANDS = new Set(WEB_QUERY_COMMANDS); // Built from the additive command-descriptor registry (ADR-0008, Phase 1 step 3). @@ -110,6 +110,30 @@ export function unsupportedHintForDevice(command: string, device: DeviceInfo): s return tryGetPlugin(device.platform)?.capability.unsupportedHintByDefault?.[command]?.(device); } +/** + * The operation sets a command's declared runtime uses require, or `undefined` for a command + * whose admission is not fact-owned (it still carries a capability bucket, or it reaches no + * device at all). + * + * R63 derives this from the descriptors themselves rather than a hand-written map, which is what + * makes the capability projection self-maintaining: a unit that migrates a command declares its + * uses in one place, and the projection follows in the same commit. The alternative — a second + * list — is exactly how the projection drifted into reading "no bucket" as "supported + * everywhere" for every command Wave 4-6 migrated. + * + * An action-selected command answers with several sets. A request names exactly one action, so + * the command is available when ANY set is fully admitted. + */ +export function commandRuntimeUseRequirements( + command: string, +): readonly (readonly string[])[] | undefined { + const descriptor = commandDescriptors.find((candidate) => candidate.name === command); + const execution = descriptor?.platformExecution; + if (execution?.kind !== 'device-runtime') return undefined; + const uses = 'uses' in execution ? execution.uses : [execution.use]; + return uses.map((use) => use.required); +} + export function listCapabilityCommands(): string[] { return commandDescriptors .filter( diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 7ef469a2a6..ed1727d208 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -13,7 +13,6 @@ import { type DaemonCommandDescriptor, } from '../../../daemon/daemon-command-registry.ts'; import type { DaemonRequest } from '../../../daemon/types.ts'; -import { listRegisteredDispatchCommandNames } from '../../dispatch.ts'; import { deriveDaemonCommandDescriptors, deriveStructuredBatchCommandNames } from '../derive.ts'; import { commandDescriptors, @@ -46,13 +45,16 @@ const UNROUTED_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSou // pure control-plane or always-admitted commands; migrated runtime commands are admitted from // exact runtime facts and therefore belong to the capability catalog without matrix rows. const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ + PUBLIC_COMMANDS.alert, PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.apps, + PUBLIC_COMMANDS.appSwitcher, PUBLIC_COMMANDS.artifacts, PUBLIC_COMMANDS.back, PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.boot, PUBLIC_COMMANDS.capabilities, + PUBLIC_COMMANDS.clipboard, PUBLIC_COMMANDS.close, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.diff, @@ -73,16 +75,19 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.orientation, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.push, + PUBLIC_COMMANDS.reactNative, PUBLIC_COMMANDS.record, PUBLIC_COMMANDS.reinstall, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.settings, PUBLIC_COMMANDS.shutdown, PUBLIC_COMMANDS.screenshot, PUBLIC_COMMANDS.snapshot, PUBLIC_COMMANDS.swipe, PUBLIC_COMMANDS.test, PUBLIC_COMMANDS.trace, + PUBLIC_COMMANDS.triggerAppEvent, PUBLIC_COMMANDS.tvRemote, PUBLIC_COMMANDS.type, PUBLIC_COMMANDS.viewport, @@ -125,10 +130,6 @@ function isDescriptorOnlyCommand(descriptor: TestCommandDescriptor): boolean { return !hasDaemonFacet(descriptor) && !hasCapabilityFacet(descriptor) && !descriptor.batchable; } -function readDaemonRouteForTest(descriptor: TestCommandDescriptor): string | undefined { - return 'daemon' in descriptor ? descriptor.daemon?.route : undefined; -} - test('derived daemon registry holds its routing invariants', () => { // The daemon registry is now BUILT from these derived descriptors (the // hand-authored literal was deleted after #906 proved byte-equality), so a @@ -199,51 +200,6 @@ test('descriptor-only commands explicitly declare a non-public catalog group', ( } }); -test('platform dispatch command list is built from descriptor dispatch facets', () => { - const dispatchCommands = commandDescriptors - .filter((descriptor) => 'dispatch' in descriptor && descriptor.dispatch !== undefined) - .map((descriptor) => descriptor.name) - .sort(); - - assert.deepEqual(listRegisteredDispatchCommandNames(), dispatchCommands); - assert.equal( - dispatchCommands.includes('read' as never), - false, - 'the read dispatch alias retired with the selector element-read cutover (#1739)', - ); - assert.equal( - dispatchCommands.includes(PUBLIC_COMMANDS.gesture), - false, - 'gesture executes through the typed runtime/backend seam', - ); -}); - -test('generic route commands that reach platform dispatch declare the dispatch facet', () => { - const nonDispatchGenericCommands = new Set([ - PUBLIC_COMMANDS.gesture, - PUBLIC_COMMANDS.focus, - PUBLIC_COMMANDS.screenshot, - // R43 retired scroll's dispatch leaf with its capability bucket: the bound - // `scrollDirection` operation is its only execution. - PUBLIC_COMMANDS.scroll, - PUBLIC_COMMANDS.viewport, - PUBLIC_COMMANDS.back, - PUBLIC_COMMANDS.home, - PUBLIC_COMMANDS.orientation, - PUBLIC_COMMANDS.tvRemote, - ]); - - for (const descriptor of commandDescriptors) { - const route = readDaemonRouteForTest(descriptor); - if (route !== 'generic' || nonDispatchGenericCommands.has(descriptor.name)) continue; - - assert.ok( - 'dispatch' in descriptor && descriptor.dispatch !== undefined, - `${descriptor.name} declares dispatch coverage`, - ); - } -}); - test('capability matrix holds its admission invariants', () => { // BASE_COMMAND_CAPABILITY_MATRIX is now BUILT from these derived descriptors // (the hand-authored literal was deleted after #906 proved byte-equality, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 093feb946a..52ebc5f334 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -38,6 +38,12 @@ import { gestureRuntimePlanUses, homeRuntimeUse, hoverRuntimeUses, + appEventRuntimeUse, + settingsRuntimeUse, + alertRuntimePlanUses, + appSwitcherRuntimeUse, + tapPointUse, + clipboardRuntimePlanUses, keyboardRuntimePlanUses, longPressRuntimeUses, orientationRuntimeUse, @@ -108,18 +114,10 @@ export type DescriptorCatalogRecord = { ]: Descriptor['name']; }; -export type DescriptorDispatchCommandName = - Extract<(typeof commandDescriptors)[number], { dispatch: object }> extends infer Descriptor - ? Descriptor extends { name: infer Name extends string } - ? Name - : never - : never; - /** * The literal union of every command whose `daemon.route` is `'session'`. - * Drives `SESSION_COMMAND_HANDLER_IMPLS` in `src/daemon/handlers/session.ts` - * (mirrors `DescriptorDispatchCommandName` above): adding a session-routed - * descriptor without a matching handler table entry is a compile error rather + * Drives `SESSION_COMMAND_HANDLER_IMPLS` in `src/daemon/handlers/session.ts`: adding a + * session-routed descriptor without a matching handler table entry is a compile error rather * than a runtime routing gap caught only by `expectHandlerResponse`. */ export type DescriptorSessionRouteCommandName = @@ -240,7 +238,6 @@ function readOnlySubactionRecordingEffect( const APPLE_SIM_AND_DEVICE = { simulator: true, device: true }; const ANDROID_ALL = { emulator: true, device: true, unknown: true }; -const LINUX_DEVICE = { device: true }; const LINUX_NONE = {}; // --------------------------------------------------------------------------- @@ -537,7 +534,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // R63: `capabilities` executes nothing on a device. It reads one side-effect-free facts + // inspection and projects each command's own declared uses against it, so it has no platform + // execution path of its own to migrate — which is what `none` states. It is deliberately last + // among the command units: it projects the union of every migrated command's facts, so it is + // only truthful once that surface is complete. + platformExecution: NO_PLATFORM_EXECUTION, }, { name: 'doctor', @@ -654,7 +656,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // Wave 6 residue: `events` flushes and reads the session's own event log. It touches no + // device at all, so it has no platform execution path to migrate. + platformExecution: NO_PLATFORM_EXECUTION, }, { name: 'network', @@ -747,18 +751,15 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', + // R55 retires this command's capability bucket and its `dispatch` leaf together: admission is + // whichever action-selected fact (`readClipboard`/`writeClipboard`) the parsed subcommand + // names, and the only execution is that one bound operation (ADR 0019 §9). recordsSessionAction: true, recordingEffect: 'observes-app', daemon: { route: 'session', refFrameEffect: 'preserve' }, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_DEVICE, - }, - dispatch: {}, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: clipboardRuntimePlanUses }, }, { name: 'keyboard', @@ -852,14 +853,16 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public', key: 'triggerAppEvent' }, frameworkTier: 'extended', + // R57 retires this command's capability bucket and its `dispatch` leaf together: admission is + // the owner's `triggerAppEvent` fact, and the only execution is that one bound operation. The + // event name, payload, and URL template stay daemon policy (ADR 0019 §2 — a facet input names + // no command, request, or CLI flag), so the owner receives a URL to open. recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [appEventRuntimeUse] }, }, { name: 'open', @@ -907,7 +910,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'session', refFrameEffect: 'delegated' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // Wave 6 residue: every step runs as its own daemon request under its own descriptor, which + // is what `refFrameEffect: 'delegated'` already says. `batch` itself reaches no device. + platformExecution: NO_PLATFORM_EXECUTION, }, { name: 'close', @@ -997,17 +1002,16 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/alert.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', + // R59 retires this command's capability bucket and its Apple `supportsAlertSurface` closure + // together: admission is the owner's own alert facts, and the only execution is the one bound + // leg the parsed subcommand names. The poll and retry windows moved to the owners with it — + // how long a transient sheet takes to appear is family mechanics, not request policy. recordsSessionAction: true, recordingEffect: alertRecordingEffect, daemon: { route: 'snapshot', refFrameEffect: alertRefFrameEffect }, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: alertRuntimePlanUses }, }, { name: 'settings', @@ -1015,18 +1019,16 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/settings.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', + // R58 retires this command's capability bucket, its `dispatch` leaf, and its HarmonyOS + // overlay membership together: admission is the owner's `setSetting` fact, and the only + // execution is that one bound operation. The macOS setting-name gate stays daemon-side — + // it keys on the requested setting, which is not a device fact. recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate' }, - dispatch: {}, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [settingsRuntimeUse] }, }, // -- specialized routes -- @@ -1036,13 +1038,15 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/react-native/index.ts'] as const } : {}), catalog: { group: 'public', key: 'reactNative' }, frameworkTier: 'extended', + // R61 retires this command's capability bucket: admission is the owner's own `tapPoint` fact, + // which is the one device operation the command executes. The overlay analysis and its + // verification capture are daemon policy over an already-migrated snapshot route. recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate' }, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [tapPointUse] }, }, { name: 'record', @@ -1370,6 +1374,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appSwitcher' }, frameworkTier: 'extended', + // R56 retires this command's capability bucket, its `dispatch` leaf, and its HarmonyOS + // overlay membership together: admission is the owner's `appSwitcher` fact, and the only + // execution is that one bound operation (ADR 0019 §9). recordsSessionAction: true, recordingEffect: 'mutates-app', // ADR 0014: app-switcher previously reached the generic daemon leaf via the @@ -1378,15 +1385,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ // covered by the completeness gate; this is the escape hatch the ADR calls // out, not a new specialized route. daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, - dispatch: {}, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [appSwitcherRuntimeUse] }, }, { name: 'install-from-source', @@ -1410,7 +1411,10 @@ export const RAW_COMMAND_DESCRIPTORS = [ recordsSessionAction: false, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // Wave 6 residue: this route reads local diagnostics files and has no daemon route, no + // platform import, and no injected dispatch — it was `legacy` only because the discriminator + // pass had nothing better to say about it. + platformExecution: NO_PLATFORM_EXECUTION, }, { name: 'daemon', @@ -1701,18 +1705,6 @@ export function commandSupportsVerifyEvidence(command: string | undefined): bool return resolveCommandPostActionObservationSupport(command) === 'settle-and-verify'; } -/** - * Whether a command's platform behavior comes from a request-bound device runtime (ADR 0019). - * Admission for those commands is the owner's exact operation facts, so a route must never also - * consult a capability bucket for them — and a migrated command has no bucket to consult. Reading - * the discriminator here rather than naming commands at each route means the next unit's - * descriptor flip is the whole change. - */ -export function commandUsesDeviceRuntimeExecution(command: string | undefined): boolean { - if (command === undefined) return false; - return COMMAND_DESCRIPTOR_BY_NAME.get(command)?.platformExecution.kind === 'device-runtime'; -} - /** * The declared timeout policy for a command (ADR 0008). Command names outside * the registry (internal probes, unknown commands) fall back to diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index 1be0713408..97c673255a 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -136,14 +136,6 @@ export type CommandCatalogFacet = { key?: string; }; -export type CommandDispatchFacet = { - /** - * Platform dispatch command handled by src/core/dispatch.ts. The descriptor - * name is the dispatched command; dispatch-only names such as `read` are - * modeled as named descriptors rather than hidden aliases. - */ -} & Record; - /** * ADR 0016: whether a recorded request changes app-visible state or only * observes it. The resolver form keeps subcommand-sensitive decisions on the @@ -220,7 +212,6 @@ type CommandDescriptorBase = { catalog: CommandCatalogFacet; /** Required iff `catalog.group === 'public'`; see {@link CommandFrameworkTier}. */ frameworkTier?: CommandFrameworkTier; - dispatch?: CommandDispatchFacet; /** Internal-only ADR 0019 cutover discriminant; public projections must ignore it. */ platformExecution: CommandPlatformExecution; /** ADR 0012 / #1349: present iff this command's recorded steps can carry `target-v1` evidence. */ diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 8012791884..988ebaa944 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -1,76 +1,19 @@ -import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; +import type { RunnerContext } from '@agent-device/contracts/interaction'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; -import { emitDiagnostic, withDiagnosticTimer } from '../utils/diagnostics.ts'; -import { readLocationCoordinate } from '../utils/location-coordinates.ts'; -import { successText, withSuccessText } from '../utils/success-text.ts'; -import { parseTriggerAppEventArgs, resolveAppEventUrl } from './app-events.ts'; -import type { DescriptorDispatchCommandName } from './command-descriptor/registry.ts'; import type { DispatchContext } from './dispatch-context.ts'; import { getInteractor } from './interactors.ts'; export type { DispatchContext } from './dispatch-context.ts'; export { resolveTargetDevice, resolveTargetDeviceSelection } from './dispatch-resolve.ts'; -export async function dispatchCommand( - device: DeviceInfo, - command: string, - positionals: string[], - outPath?: string, - context?: DispatchContext, -): Promise | void> { - const runnerCtx = runnerContextFromDispatchContext(context); - const interactor = await getInteractor(device, runnerCtx); - return await dispatchWithInteractor( - device, - interactor, - command, - positionals, - outPath, - context, - runnerCtx, - ); -} - -async function dispatchWithInteractor( - device: DeviceInfo, - interactor: Interactor, - command: string, - positionals: string[], - outPath: string | undefined, - context: DispatchContext | undefined, - runnerCtx: RunnerContext, -): Promise | void> { - emitDiagnostic({ - level: 'debug', - phase: 'platform_command_prepare', - data: { - command, - platform: device.platform, - kind: device.kind, - }, - }); - return await withDiagnosticTimer( - 'platform_command', - async () => { - return await dispatchKnownCommand( - device, - interactor, - command, - positionals, - outPath, - context, - runnerCtx, - ); - }, - { - command, - platform: device.platform, - }, - ); -} - +/** + * The legacy command dispatcher retired with R58: `settings` was its last arm, and every command + * that once routed through it now admits its exact owner's facts and executes one bound + * operation. What remains here is the single frame read that is not a command at all — + * `replay`/`test` still consume it through `session-replay-maestro-runtime.ts` (ADR 0019 §6: a + * shared helper stays in place until its last consumer can move). + */ export async function dispatchGestureViewport( device: DeviceInfo, context?: DispatchContext, @@ -93,261 +36,3 @@ function runnerContextFromDispatchContext(context?: DispatchContext): RunnerCont runnerLeaseContext: context?.runnerLeaseContext, }; } - -type DispatchCommand = DescriptorDispatchCommandName; - -type DispatchHandlerArgs = { - device: DeviceInfo; - interactor: Interactor; - positionals: string[]; - outPath: string | undefined; - context: DispatchContext | undefined; - runnerCtx: RunnerContext; -}; - -type DispatchHandler = (args: DispatchHandlerArgs) => Promise | void>; - -/** - * Descriptor-driven exhaustive dispatch table. The `Record` - * type forces every descriptor-declared dispatch command to have a handler — a - * missing entry is a COMPILE error, which replaces the former runtime `default: - * throw` as the coverage safety net. Each entry routes to the IDENTICAL handler - * with the IDENTICAL arguments the `switch` used, so dispatch stays strictly - * behaviorless. - */ -const DISPATCH_HANDLERS: Record = { - 'trigger-app-event': ({ device, interactor, positionals, context }) => - handleTriggerAppEventCommand(device, interactor, positionals, context), - 'app-switcher': async ({ interactor }) => { - await interactor.appSwitcher(); - return { action: 'app-switcher', ...successText('Opened app switcher') }; - }, - clipboard: ({ interactor, positionals }) => handleClipboardCommand(interactor, positionals), - settings: ({ device, interactor, positionals, context }) => - handleSettingsCommand(device, interactor, positionals, context), -}; - -/** - * @internal Introspection helper used by parity tests. - */ -export function listRegisteredDispatchCommandNames(): string[] { - return Object.keys(DISPATCH_HANDLERS).sort(); -} - -async function dispatchKnownCommand( - device: DeviceInfo, - interactor: Interactor, - command: string, - positionals: string[], - outPath: string | undefined, - context: DispatchContext | undefined, - runnerCtx: RunnerContext, -): Promise | void> { - // `Object.hasOwn` keeps the lookup behaviorless: any unknown command — - // including inherited keys like `toString` — falls through to the same - // `INVALID_ARGS` error the former `default:` branch threw. - const handler = Object.hasOwn(DISPATCH_HANDLERS, command) - ? DISPATCH_HANDLERS[command as DispatchCommand] - : undefined; - if (!handler) { - throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); - } - return await handler({ device, interactor, positionals, outPath, context, runnerCtx }); -} - -// --------------------------------------------------------------------------- -// Command handlers -// --------------------------------------------------------------------------- - -async function handleTriggerAppEventCommand( - device: DeviceInfo, - interactor: Interactor, - positionals: string[], - context: DispatchContext | undefined, -): Promise> { - const { eventName, payload } = parseTriggerAppEventArgs(positionals); - const eventUrl = resolveAppEventUrl(device, eventName, payload); - await interactor.open(eventUrl, { appBundleId: context?.appBundleId }); - return { - event: eventName, - eventUrl, - transport: 'deep-link', - ...successText(`Triggered app event: ${eventName}`), - }; -} - -async function handleClipboardCommand( - interactor: Interactor, - positionals: string[], -): Promise> { - const action = (positionals[0] ?? '').toLowerCase(); - if (action !== 'read' && action !== 'write') { - throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); - } - if (action === 'read') { - if (positionals.length !== 1) { - throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments'); - } - const text = await interactor.readClipboard(); - return { action, text }; - } - if (positionals.length < 2) { - throw new AppError('INVALID_ARGS', 'clipboard write requires text (use "" to clear clipboard)'); - } - const text = positionals.slice(1).join(' '); - await interactor.writeClipboard(text); - return { - action, - textLength: Array.from(text).length, - ...successText('Clipboard updated'), - }; -} - -async function handleSettingsCommand( - device: DeviceInfo, - interactor: Interactor, - positionals: string[], - context: DispatchContext | undefined, -): Promise> { - const [setting, state, target, mode] = positionals; - if (!setting || (!state && setting !== 'clear-app-state')) { - throw new AppError('INVALID_ARGS', 'settings requires setting state'); - } - if (setting === 'clear-app-state') { - return await handleClearAppStateSetting(device, interactor, state, target, context); - } - if (!state) { - throw new AppError('INVALID_ARGS', 'settings requires setting state'); - } - return await handleStandardSetting( - device, - interactor, - setting, - state, - target, - mode, - positionals, - context, - ); -} - -async function handleClearAppStateSetting( - device: DeviceInfo, - interactor: Interactor, - state: string | undefined, - target: string | undefined, - context: DispatchContext | undefined, -): Promise> { - const appBundleId = (state === 'clear' ? target : state) ?? context?.appBundleId; - if (!appBundleId) { - throw new AppError( - 'INVALID_ARGS', - 'settings clear-app-state requires an app id or an active app session.', - ); - } - emitDiagnostic({ - level: 'debug', - phase: 'settings_apply', - data: { setting: 'clear-app-state', state: 'clear', appBundleId, platform: device.platform }, - }); - const result = await interactor.setSetting('clear-app-state', 'clear', appBundleId); - return result && typeof result === 'object' - ? withSuccessText( - { setting: 'clear-app-state', state: 'clear', ...result }, - readResultMessage(result) ?? `Cleared user data for ${appBundleId}`, - ) - : { - setting: 'clear-app-state', - state: 'clear', - ...successText(`Cleared user data for ${appBundleId}`), - }; -} - -async function handleStandardSetting( - device: DeviceInfo, - interactor: Interactor, - setting: string, - state: string, - target: string | undefined, - mode: string | undefined, - positionals: string[], - context: DispatchContext | undefined, -): Promise> { - const isLocationSet = setting === 'location' && state === 'set'; - const usesPayloadAppBundleSlot = setting === 'permission' || isLocationSet; - const appBundleId = - (usesPayloadAppBundleSlot ? positionals[4] : positionals[2]) ?? context?.appBundleId; - emitDiagnostic({ - level: 'debug', - phase: 'settings_apply', - data: buildSettingsDiagnosticPayload( - device, - setting, - state, - target, - mode, - appBundleId, - isLocationSet, - ), - }); - const result = await interactor.setSetting( - setting, - state, - appBundleId, - buildSettingOptions(setting, target, mode, isLocationSet), - ); - return result && typeof result === 'object' - ? withSuccessText( - { setting, state, ...result }, - readResultMessage(result) ?? `Updated setting: ${setting}`, - ) - : { setting, state, ...successText(`Updated setting: ${setting}`) }; -} - -function buildSettingOptions( - setting: string, - target: string | undefined, - mode: string | undefined, - isLocationSet: boolean, -) { - if (setting === 'permission') { - return { permissionTarget: target, permissionMode: mode }; - } - if (isLocationSet) { - return { - latitude: readLocationCoordinate(target, 'latitude'), - longitude: readLocationCoordinate(mode, 'longitude'), - }; - } - return undefined; -} - -function buildSettingsDiagnosticPayload( - device: DeviceInfo, - setting: string, - state: string, - target: string | undefined, - mode: string | undefined, - appBundleId: string | undefined, - isLocationSet: boolean, -): Record { - if (isLocationSet) { - return { setting, state, latitude: target, longitude: mode, platform: device.platform }; - } - if (setting === 'permission') { - return { - setting, - state, - permissionTarget: target, - permissionMode: mode, - platform: device.platform, - }; - } - return { setting, state, appBundleId, platform: device.platform }; -} - -function readResultMessage(result: Record): string | undefined { - return typeof result.message === 'string' && result.message.length > 0 - ? result.message - : undefined; -} diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 339d679817..802ba76e5f 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -39,6 +39,8 @@ import { withMethodScope } from '../../utils/method-scope.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; import { androidSnapshotPublicationInput } from '../../platforms/android/snapshot-capture.ts'; +import { handleAndroidAlert } from '../../platforms/android/alert.ts'; +import { buildSnapshotState } from '../snapshot-state.ts'; /** * `appBundleId` is present exactly for app-backed daemon sessions, whose teardown releases the @@ -55,6 +57,13 @@ export function createAndroidInteractor( runnerContext?: Pick, ): Interactor { const helperSessionScope = androidHelperSessionScope(runnerContext?.appBundleId); + const alertOptions = (timeoutMs?: number) => ({ + ...(timeoutMs === undefined ? {} : { timeoutMs }), + captureNodes: async () => { + const capture = await snapshotAndroid(device, { includeHiddenContentHints: false }); + return buildSnapshotState(androidSnapshotPublicationInput(capture), undefined).nodes; + }, + }); const interactor: Interactor = { open: (app, options) => openAndroidApp(device, app, { @@ -122,6 +131,15 @@ export function createAndroidInteractor( writeClipboard: (text) => writeAndroidClipboardText(device, text), setSetting: (setting, state, appId, options) => setAndroidSetting(device, setting, state, appId, options), + // R59: Android's alert legs read the same presented accessibility tree `snapshot` publishes + // and own their own polling, so the family supplies the node capture rather than the daemon. + // The presentation pass matters: alert candidacy skips occlusion-blocked nodes, and only a + // presented tree carries that annotation. + readAlert: async () => await handleAndroidAlert(device, 'get', alertOptions()), + awaitAlert: async (options) => + await handleAndroidAlert(device, 'wait', alertOptions(options?.timeoutMs)), + acceptAlert: async () => await handleAndroidAlert(device, 'accept', alertOptions()), + dismissAlert: async () => await handleAndroidAlert(device, 'dismiss', alertOptions()), }; if (!provider) return interactor; return withMethodScope(interactor, (task) => diff --git a/src/core/interactors/harmonyos.ts b/src/core/interactors/harmonyos.ts index 8166d77056..2c7cca53f9 100644 --- a/src/core/interactors/harmonyos.ts +++ b/src/core/interactors/harmonyos.ts @@ -63,5 +63,11 @@ export function createHarmonyInteractor(device: DeviceInfo, _runner?: RunnerCont readClipboard: unsupported('clipboard'), writeClipboard: unsupported('clipboard'), setSetting: (setting, state, appId) => setHarmonySetting(device, setting, state, appId), + // R59: the retired `alert` descriptor declared no HarmonyOS leaf, and hdc exposes no dialog + // surface to read one from. + readAlert: unsupported('alert'), + awaitAlert: unsupported('alert'), + acceptAlert: unsupported('alert'), + dismissAlert: unsupported('alert'), }; } diff --git a/src/core/interactors/linux.ts b/src/core/interactors/linux.ts index 7b4ba69b47..7c8cf90f33 100644 --- a/src/core/interactors/linux.ts +++ b/src/core/interactors/linux.ts @@ -24,6 +24,10 @@ import { screenshotLinux } from '../../platforms/linux/screenshot.ts'; import { captureLinuxSurfaceSnapshot } from '../../snapshot/snapshot-desktop-surface.ts'; import type { Interactor } from '@agent-device/contracts/interaction'; +function unsupportedLinuxAlert(): Promise { + throw new AppError('UNSUPPORTED_OPERATION', 'alert not supported on Linux'); +} + export function createLinuxInteractor(): Interactor { return { open: (app) => openLinuxApp(app), @@ -80,5 +84,10 @@ export function createLinuxInteractor(): Interactor { setSetting: () => { throw new AppError('UNSUPPORTED_OPERATION', 'setSetting not supported on Linux'); }, + // R59: the retired `alert` descriptor declared `linux: {}`, so no Linux cell was admitted. + readAlert: unsupportedLinuxAlert, + awaitAlert: unsupportedLinuxAlert, + acceptAlert: unsupportedLinuxAlert, + dismissAlert: unsupportedLinuxAlert, }; } diff --git a/src/daemon/snapshot-state.ts b/src/core/snapshot-state.ts similarity index 100% rename from src/daemon/snapshot-state.ts rename to src/core/snapshot-state.ts diff --git a/src/daemon/__tests__/deferred-interaction-outcome.test.ts b/src/daemon/__tests__/deferred-interaction-outcome.test.ts index 4afecbc437..e588f52f04 100644 --- a/src/daemon/__tests__/deferred-interaction-outcome.test.ts +++ b/src/daemon/__tests__/deferred-interaction-outcome.test.ts @@ -6,6 +6,8 @@ import { resolveDeferredInteractionOutcome, type DeferredOutcomeSnapshotAttempt, } from '../deferred-interaction-outcome.ts'; +import type { InteractionRetryTap } from '../interaction-outcome-policy.ts'; +import { countDiagnosticEventsByPhase, withDiagnosticsScope } from '../../utils/diagnostics.ts'; import type { SessionState } from '../types.ts'; import { deliverySnapshot, @@ -13,21 +15,14 @@ import { pickupSnapshot, } from './post-gesture-stabilization-fixtures.ts'; -// The one device boundary in this cluster: pending-outcome retries re-dispatch -// the recorded tap through core dispatch. Everything else runs real. -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); -const { dispatchCommand } = await import('../../core/dispatch.ts'); -const dispatchMock = vi.mocked(dispatchCommand); +// The one device boundary in this cluster: a pending-outcome retry re-fires the recorded tap +// through the seam its caller supplies (R58). Everything else runs real, and a test that omits +// the seam is exercising a capture route that genuinely cannot tap. +const retryTap = vi.fn(async () => true); afterEach(() => { vi.useRealTimers(); - dispatchMock.mockClear(); + retryTap.mockClear(); }); function scriptedCapture(snapshots: ReturnType[]): { @@ -56,6 +51,7 @@ function resolveParams( logPath: '/tmp/agent-device-test.log', interactiveOnly: false, capture, + retryTap, }; } @@ -158,8 +154,8 @@ test('an unchanged surface retries the recorded tap once, then settles on the ch } const result = await pendingResult; - assert.equal(dispatchMock.mock.calls.length, 1); - assert.equal(dispatchMock.mock.calls[0]?.[1], 'press'); + assert.equal(retryTap.mock.calls.length, 1); + assert.deepEqual(retryTap.mock.calls[0]?.[0]?.point, { x: 100, y: 200 }); assert.equal( result?.snapshot.nodes.some((node) => node.identifier === 'shipping-delivery'), true, @@ -181,12 +177,82 @@ test('a changed surface settles immediately with no retry dispatch', async () => const result = await resolveDeferredInteractionOutcome(resolveParams(session, capture)); - assert.equal(dispatchMock.mock.calls.length, 0); + assert.equal(retryTap.mock.calls.length, 0); assert.equal(calls(), 1); assert.ok(result?.snapshot); assert.equal(session.pendingInteractionOutcome, undefined); }); +// R58: the policy owns whether to retry; its caller owns how the tap reaches the device. A +// capture route that carries no bindings (an internal capture decorating someone else's request) +// therefore hands no seam, and the retry must report that instead of burning an attempt. +test('a capture route with no retry seam reports a skip instead of spending an attempt', async () => { + const session = makeSession('ios'); + session.snapshot = pickupSnapshot(); + markDeferredInteractionOutcome({ + session, + command: 'click', + positionals: ['100', '200'], + flags: { interactionOutcome: { retryOnNoChange: true } }, + scheduleOutcomeRetry: true, + }); + const { capture } = scriptedCapture([pickupSnapshot()]); + + const observed = await withDiagnosticsScope({}, async () => { + const result = await resolveDeferredInteractionOutcome({ + ...resolveParams(session, capture), + retryTap: undefined, + }); + return { + result, + skips: countDiagnosticEventsByPhase(['interaction_no_change_retry_skipped']), + retries: countDiagnosticEventsByPhase(['interaction_no_change_retry']), + }; + }); + + assert.ok(observed.result?.snapshot); + assert.equal(retryTap.mock.calls.length, 0); + // One skip, no retry: nothing was attempted, so no attempt was spent. The emitted reason + // (`device-runtime-unavailable`) is what separates this from a delivered tap the owner refused — + // a route that stops forwarding its bindings lands here, not there. + assert.equal(observed.skips, 1); + assert.equal(observed.retries, 0); +}); + +// A retry that dies on the device is the seam's problem, not the caller's: the capture it was +// decorating still has to answer with the unchanged surface it observed. +test('a retry tap that throws is reported as a skip rather than failing the capture', async () => { + const session = makeSession('android'); + session.snapshot = pickupSnapshot(); + markDeferredInteractionOutcome({ + session, + command: 'click', + positionals: ['100', '200'], + flags: { interactionOutcome: { retryOnNoChange: true } }, + scheduleOutcomeRetry: true, + }); + const attemptsBefore = session.pendingInteractionOutcome?.attemptsRemaining; + const { capture } = scriptedCapture([pickupSnapshot()]); + retryTap.mockRejectedValueOnce(new Error('adb: device offline')); + + const observed = await withDiagnosticsScope({}, async () => { + const result = await resolveDeferredInteractionOutcome(resolveParams(session, capture)); + return { + result, + skips: countDiagnosticEventsByPhase(['interaction_no_change_retry_skipped']), + retries: countDiagnosticEventsByPhase(['interaction_no_change_retry']), + }; + }); + + assert.ok(observed.result?.snapshot); + assert.equal(observed.skips, 1); + assert.equal(observed.retries, 0); + // The attempt is spent before the device work, so a failing owner cannot be re-attempted from a + // full budget by the next capture inside the pending window. + assert.equal(attemptsBefore, 2); + assert.equal(session.pendingInteractionOutcome, undefined); +}); + test('a pending stabilization resolves through the quiet-window loop and clears itself', async () => { vi.useFakeTimers(); const session = makeSession('android'); diff --git a/src/daemon/__tests__/generic-route-runtime-completeness.test.ts b/src/daemon/__tests__/generic-route-runtime-completeness.test.ts new file mode 100644 index 0000000000..5be375696a --- /dev/null +++ b/src/daemon/__tests__/generic-route-runtime-completeness.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'vitest'; +import { commandDescriptors } from '../../core/command-descriptor/registry.ts'; +import { resolveGenericRuntimeExecution } from '../generic-runtime-execution.ts'; + +/** + * The generic route is the router's fallthrough: a descriptor joins it by declaring + * `daemon.route: 'generic'`, not by being listed anywhere the dispatcher can read. R56 migrated + * `app-switcher`, its last legacy leaf, so `resolveGenericRuntimeExecution` is now the route's + * only execution path and `ensureGenericCommandReady` no longer carries a support gate behind it. + * That totality is what these tests pin: a descriptor that joins the route without an arm would + * otherwise reach the dispatcher through a `default` that refuses at request time. + */ +type RegistryDescriptor = (typeof commandDescriptors)[number]; + +function genericRouteDescriptors(): RegistryDescriptor[] { + return commandDescriptors + .filter( + (descriptor) => ('daemon' in descriptor ? descriptor.daemon?.route : undefined) === 'generic', + ) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +test('every generic-route descriptor is runtime-owned', () => { + const legacy = genericRouteDescriptors() + .filter((descriptor) => descriptor.platformExecution.kind !== 'device-runtime') + .map((descriptor) => descriptor.name); + + expect(legacy).toEqual([]); +}); + +async function reachesGenericRoutingGap(command: string): Promise { + // Every arm reaches admission before it can answer, and admission with no bindings throws — so a + // throw proves the arm exists. Only the `default` answers, and it answers with the routing gap. + try { + const resolved = await resolveGenericRuntimeExecution({ + req: { command, token: '', positionals: [], flags: {} }, + session: { device: { id: 'device-1', platform: 'android', kind: 'device' } }, + context: { logPath: '' }, + } as unknown as Parameters[0]); + return ( + !resolved.ok && + resolved.response.ok === false && + resolved.response.error.details?.['reason'] === 'generic-route-runtime-missing' + ); + } catch { + return false; + } +} + +test('every generic-route descriptor has an arm in the runtime execution table', async () => { + const missing: string[] = []; + for (const descriptor of genericRouteDescriptors()) { + if (await reachesGenericRoutingGap(descriptor.name)) missing.push(descriptor.name); + } + + expect(missing).toEqual([]); +}); + +test('the routing gap is what an unrouted command actually reaches', async () => { + // Without this the test above would pass for a table that had no arms at all. + expect(await reachesGenericRoutingGap('not-a-generic-route-command')).toBe(true); +}); diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 2512d6d27b..9619583d96 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -7,7 +7,7 @@ import { activateCompleteRefFrame } from '../ref-frame.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import { buildSnapshotState } from '../../core/snapshot-state.ts'; // #1638 `--settle` on the GENERIC daemon route (scroll/back): the settled diff, // its refs, and the ref-frame/generation dance are the same contract the touch @@ -15,14 +15,6 @@ import { buildSnapshotState } from '../snapshot-state.ts'; // a resolution, and the observation must run after the deferred-outcome // markers. Quiet windows are tuned down so no test waits real time. -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); - vi.mock('../handlers/interaction-snapshot.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -35,14 +27,9 @@ vi.mock('../handlers/interaction-snapshot.ts', async (importOriginal) => { }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { captureSnapshotForSession } from '../handlers/interaction-snapshot.ts'; -import { - dispatchGenericCommand, - executeGenericPlatformCommand, -} from '../request-generic-dispatch.ts'; +import { dispatchGenericCommand } from '../request-generic-dispatch.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); const BEFORE_NODES = [ @@ -92,24 +79,16 @@ async function emulateCaptureSnapshotForSession( session: SessionState, flags: CommandFlags | undefined, sessionStore: SessionStore, - contextFromFlags: ( - flags: CommandFlags | undefined, - appBundleId?: string, - traceLogPath?: string, - ) => Record, options: { interactiveOnly: boolean }, ) { captureObservations.push({ postGestureStabilizationPending: session.postGestureStabilization !== undefined, }); const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; - const snapshotData = (await mockDispatch( - session.device, - 'snapshot', - [], - effectiveFlags.out, - contextFromFlags(effectiveFlags, session.appBundleId, session.trace?.outPath), - )) as { nodes?: never[]; backend?: SnapshotBackend }; + const snapshotData = (await mockDispatch('snapshot')) as { + nodes?: never[]; + backend?: SnapshotBackend; + }; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); setSessionSnapshot(session, snapshot); sessionStore.set(session.name, session); @@ -118,7 +97,7 @@ async function emulateCaptureSnapshotForSession( function mockCommandDispatch(snapshots: Array) { let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { + mockDispatch.mockImplementation(async (command) => { if (command === 'snapshot') { const nodes = snapshots[Math.min(snapshotCalls, snapshots.length - 1)]; snapshotCalls += 1; @@ -130,6 +109,18 @@ function mockCommandDispatch(snapshots: Array) { const contextFromFlags = () => ({}) as never; +/** + * The bound execution `dispatchGenericCommand` runs. R58 retired the legacy dispatcher this file + * used to borrow, so the double lives here: these tests are about the settle/stabilization + * orchestration around a generic leaf, not about which owner performs it. + */ +const platformExecution = vi.fn( + async (params: { command: string }) => await mockDispatch(params.command), +); + +/** Stands in for the device work a bound generic leaf performs, keyed by command name. */ +const mockDispatch = vi.fn<(command: string) => Promise>>(async () => ({})); + function seedSession(sessionName: string, sessionStore: SessionStore): SessionState { const session = makeIosSession(sessionName); setSessionSnapshot(session, buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest' }, {})); @@ -160,7 +151,7 @@ async function dispatchGeneric(params: { logPath: '', sessionStore: params.sessionStore, contextFromFlags, - executePlatformCommand: executeGenericPlatformCommand, + executePlatformCommand: platformExecution, }); } @@ -175,7 +166,10 @@ beforeEach(() => { mockDispatch.mockReset(); mockDispatch.mockResolvedValue({}); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation(emulateCaptureSnapshotForSession); + mockCaptureSnapshotForSession.mockImplementation( + (session, flags, sessionStore, _contextFromFlags, options) => + emulateCaptureSnapshotForSession(session, flags, sessionStore, options), + ); }); test('scroll --settle answers with the settled diff against the stored pre-action tree', async () => { @@ -228,7 +222,7 @@ test('back --settle answers with the settled diff alongside the command result', const sessionStore = makeSessionStore(); const sessionName = 'generic-settle-back'; const session = seedSession(sessionName, sessionStore); - mockDispatch.mockImplementation(async (_device, command) => { + mockDispatch.mockImplementation(async (command) => { if (command === 'snapshot') return { nodes: AFTER_NODES, backend: 'xctest' }; return { action: 'back', mode: 'in-app', message: 'Back' }; }); diff --git a/src/daemon/__tests__/interaction-retry-tap.test.ts b/src/daemon/__tests__/interaction-retry-tap.test.ts new file mode 100644 index 0000000000..543f56bf79 --- /dev/null +++ b/src/daemon/__tests__/interaction-retry-tap.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { TapPointInput } from '@agent-device/contracts/touch-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createInteractionRetryTap } from '../interaction-retry-tap.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +/** One owner cell, parametrized on whether it can tap — the only fact this adapter reads. */ +function bindings( + canTap: boolean, + taps: TapPointInput[], +): Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime }> { + const facts = async () => { + const base = await unavailableDeviceRuntimeGateway.inspectFacts(device); + return Object.freeze({ + device: base.device, + operations: { + ...base.operations, + tapPoint: canTap + ? ({ available: true } as const) + : ({ available: false, reason: 'owner-capability-missing' } as const), + }, + }); + }; + const inspectFacts: InspectDeviceRuntimeFacts = async () => await facts(); + const bindDevice: BindDeviceRuntime = (async (target: DeviceInfo, use) => { + const binding: DeviceBinding = Object.freeze({ + device: target, + owner: localRuntimeOwner('android'), + facts: await facts(), + operations: Object.freeze( + canTap + ? { + tapPoint: async (input: TapPointInput) => { + taps.push(input); + return {}; + }, + } + : {}, + ), + [Symbol.asyncDispose]: async () => undefined, + }) as DeviceBinding; + return narrowDeviceBinding(binding, use); + }) as BindDeviceRuntime; + return { inspectFacts, bindDevice }; +} + +test('a capture route with no runtime bindings has no retry seam to hand the policy', () => { + assert.equal(createInteractionRetryTap({}), undefined); + assert.equal(createInteractionRetryTap({ inspectFacts: async () => ({}) as never }), undefined); +}); + +test('the seam re-fires the recorded point through the owner-bound tapPoint', async () => { + const taps: TapPointInput[] = []; + const retryTap = createInteractionRetryTap(bindings(true, taps)); + assert.ok(retryTap); + + const fired = await retryTap({ + device, + point: { x: 100, y: 200 }, + context: { logPath: '/tmp/daemon.log', requestId: 'retry-1' }, + }); + + assert.equal(fired, true); + assert.equal(taps.length, 1); + assert.deepEqual(taps[0]?.point, { x: 100, y: 200 }); +}); + +// ADR 0019 §9 is one admission per handler, and the outcome policy calls this seam once per retry +// round. The binding underneath is request-cached, so what memoization saves is the repeated facts +// inspection — and what it pins is that the seam admits once, not once per round. +test('the seam admits once no matter how many rounds the policy runs', async () => { + const taps: TapPointInput[] = []; + const admission = bindings(true, taps); + let inspections = 0; + const retryTap = createInteractionRetryTap({ + inspectFacts: async (target) => { + inspections += 1; + return await admission.inspectFacts(target); + }, + bindDevice: admission.bindDevice, + }); + assert.ok(retryTap); + + const context = { logPath: '/tmp/daemon.log', requestId: 'retry-1' }; + await retryTap({ device, point: { x: 10, y: 20 }, context }); + await retryTap({ device, point: { x: 30, y: 40 }, context }); + + assert.equal(inspections, 1); + assert.equal(taps.length, 2); + assert.deepEqual( + taps.map((tap) => tap.point), + [ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ], + ); +}); + +// The policy spends an attempt only on a delivered tap, so a cell that cannot tap must answer +// `false` here rather than throwing out of the capture the retry was decorating. +test('an owner cell that cannot tap answers false instead of throwing', async () => { + const taps: TapPointInput[] = []; + const retryTap = createInteractionRetryTap(bindings(false, taps)); + assert.ok(retryTap); + + const fired = await retryTap({ + device, + point: { x: 100, y: 200 }, + context: { logPath: '/tmp/daemon.log' }, + }); + + assert.equal(fired, false); + assert.equal(taps.length, 0); +}); diff --git a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts index 08c582620f..bece22bd52 100644 --- a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts +++ b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts @@ -1,16 +1,52 @@ +import { vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SnapshotResult } from '@agent-device/contracts/interaction'; -import { dispatchCommand } from '../../core/dispatch.ts'; import type { captureSnapshotWithInteractor } from '../handlers/snapshot-interactor-capture.ts'; type CaptureParams = Parameters[0]; -/** Adapts legacy dispatch mocks while production uses the snapshot-specific interactor seam. */ +/** + * The capture double these suites drive their snapshots through. It kept the retired + * `dispatchCommand` signature when R58 deleted that dispatcher, so every suite that configured + * captures through it — and every assertion about which commands reached a device — reads + * unchanged; what moved is ownership. This is a test seam now, not an adapter over production + * dispatch. + */ +export const legacyDispatchCapture = vi.fn< + ( + device: DeviceInfo, + command: string, + positionals?: string[], + outPath?: string, + context?: Record, + ) => Promise | void> +>(async () => ({})); + +/** + * One reset for the whole seam: clears the double, restores its empty-payload default, and + * re-points the suite's mocked `captureSnapshotWithInteractor` at the adapter below. Every suite + * that drives captures this way wired the same four statements by hand; they are the seam's own + * mechanics, not per-suite policy, so they live with the seam. + */ +export function resetLegacySnapshotCapture( + mockedInteractorCapture: Readonly<{ + mockReset: () => void; + mockImplementation: (fn: typeof captureSnapshotThroughLegacyDispatchFixture) => void; + }>, +): void { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); + mockedInteractorCapture.mockReset(); + mockedInteractorCapture.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); +} + +/** Adapts the capture double into the snapshot-specific interactor seam production uses. */ export async function captureSnapshotThroughLegacyDispatchFixture({ device, runnerContext, options, }: CaptureParams): Promise { - return (await dispatchCommand(device, 'snapshot', [], undefined, { + return (await legacyDispatchCapture(device, 'snapshot', [], undefined, { ...runnerContext, ...options, snapshotInteractiveOnly: options.interactiveOnly, diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index e93cd63f28..6ee6bb2503 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -20,6 +20,7 @@ import { import { unavailableBindDevice, unavailableBindExactDevice, + unavailableDeviceRuntimeGateway, unavailableInspectFacts, } from './test-device-runtime-gateway.ts'; import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; @@ -100,6 +101,31 @@ test('request handler chain routes trace commands to the record-trace family', a assert.equal(response?.data?.trace, 'started'); }); +// R61 put `react-native dismiss-overlay` behind the owner's own `tapPoint` admission, and the +// chain had never forwarded the request's runtime bindings to that route — so the dismissal leg +// had been reaching a missing gateway ever since R48 moved it off the retired dispatcher. Only +// the no-overlay path returned early enough to hide it, which is why no suite caught it. +test('request handler chain forwards the request runtime bindings to react-native', async () => { + const inspected: string[] = []; + const params = makeChainParams(makeRequest('react-native', ['dismiss-overlay'])); + + const response = await runRequestHandlerChain({ + ...params, + inspectFacts: async (device) => { + inspected.push(device.id); + return await unavailableDeviceRuntimeGateway.inspectFacts(device); + }, + }); + + // The bindings reached the route, and the owner's refusal — not a missing gateway — is what + // came back. A chain that dropped them would answer `runtime-gateway-missing` instead. + assert.equal(inspected.length, 1); + assert.equal(response?.ok, false); + if (response?.ok === false) { + assert.match(response.error.message, /react-native dismiss-overlay is not supported/); + } +}); + test('request handler chain leaves generic commands for fallback dispatch', async () => { for (const command of ['back', 'home', 'screenshot', 'scroll']) { const response = await runRequestHandlerChain(makeChainParams(makeRequest(command))); diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index 761e62ae0a..b6e699db2a 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -6,20 +6,7 @@ import { AppError } from '@agent-device/kernel/errors'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; let snapshotCalls = 0; -const dispatchCalls: string[][] = []; let snapshotMode: 'blocking-dialog' | 'throws' = 'blocking-dialog'; -let dispatchResult: Record = {}; - -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async (_device: unknown, command: string, positionals: string[]) => { - dispatchCalls.push([command, ...positionals]); - return dispatchResult; - }), - }; -}); import { createRequestHandler, @@ -139,9 +126,7 @@ function makeAndroidSession(name: string): SessionState { test('generic Android gesture commands dismiss blocking system dialogs during recording', async () => { snapshotCalls = 0; snapshotMode = 'blocking-dialog'; - dispatchResult = {}; execCalls.length = 0; - dispatchCalls.length = 0; gestureRuntimeSpies.scrollDirection.mockClear(); const sessionStore = makeSessionStore('agent-device-router-android-modal-'); @@ -168,8 +153,8 @@ test('generic Android gesture commands dismiss blocking system dialogs during re }); expect(response.ok).toBe(true); - // R43: `scroll` reaches the device through its bound operation, so the dispatcher sees nothing. - expect(dispatchCalls).toEqual([]); + // R43: `scroll` reaches the device through its bound operation, and R58 retired the dispatcher + // it used to reach — the bound call count below is the whole execution record. expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection.mock.calls[0]?.[0]).toMatchObject({ direction: 'down', @@ -187,7 +172,6 @@ test('generic Android gesture commands continue when recording dialog inspection snapshotCalls = 0; snapshotMode = 'throws'; execCalls.length = 0; - dispatchCalls.length = 0; gestureRuntimeSpies.scrollDirection.mockClear(); // The owner's own result is what the readiness warning has to merge with, so the bound // operation carries it now that the dispatcher no longer executes this command. @@ -220,8 +204,8 @@ test('generic Android gesture commands continue when recording dialog inspection }); expect(response.ok).toBe(true); - // R43: `scroll` reaches the device through its bound operation, so the dispatcher sees nothing. - expect(dispatchCalls).toEqual([]); + // R43: `scroll` reaches the device through its bound operation, and R58 retired the dispatcher + // it used to reach — the bound call count below is the whole execution record. expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection.mock.calls[0]?.[0]).toMatchObject({ direction: 'down', @@ -242,9 +226,7 @@ test('generic Android gesture commands continue when recording dialog inspection test('generic Android gesture commands skip local dialog recovery for provider devices', async () => { snapshotCalls = 0; snapshotMode = 'blocking-dialog'; - dispatchResult = {}; execCalls.length = 0; - dispatchCalls.length = 0; gestureRuntimeSpies.scrollDirection.mockClear(); const sessionStore = makeSessionStore('agent-device-router-android-modal-provider-'); @@ -281,8 +263,8 @@ test('generic Android gesture commands skip local dialog recovery for provider d }); expect(response.ok).toBe(true); - // R43: `scroll` reaches the device through its bound operation, so the dispatcher sees nothing. - expect(dispatchCalls).toEqual([]); + // R43: `scroll` reaches the device through its bound operation, and R58 retired the dispatcher + // it used to reach — the bound call count below is the whole execution record. expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection.mock.calls[0]?.[0]).toMatchObject({ direction: 'down', diff --git a/src/daemon/__tests__/request-router-cost.test.ts b/src/daemon/__tests__/request-router-cost.test.ts index 59c38deb09..f5694d8d68 100644 --- a/src/daemon/__tests__/request-router-cost.test.ts +++ b/src/daemon/__tests__/request-router-cost.test.ts @@ -3,11 +3,6 @@ import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -16,20 +11,22 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from './test-device-runtime-gateway.ts'; +import { + createRequestHandler, + gestureDeviceRuntimeGateway, + gestureRuntimeSpies, +} from './test-device-runtime-gateway.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; -const mockDispatch = vi.mocked(dispatchCommand); - -// A representative, structurally rich daemon payload so the parity assertions -// exercise nested objects/arrays rather than a trivial flat record. +// A representative, structurally rich owner payload so the parity assertions exercise nested +// objects/arrays rather than a trivial flat record. `scroll` is the subject because it reaches a +// bound operation (R53) whose return this file controls, and its daemon leaf spreads that return +// into `response.data` — which is what the cost graft reads. const REPRESENTATIVE_PAYLOAD = { - message: 'app-switcher-ok', detail: { nested: true, count: 3 }, items: [1, 2, 3], } as const; @@ -61,6 +58,7 @@ function makeHandler(sessionStore = makeSessionStore('agent-device-router-cost-' leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', + deviceRuntimeGateway: gestureDeviceRuntimeGateway, }), }; } @@ -69,16 +67,18 @@ function baseRequest(overrides: Partial = {}): DaemonRequest { return { token: 'test-token', session: 'cost-session', - command: 'app-switcher', - positionals: [], + command: 'scroll', + positionals: ['down'], flags: {}, ...overrides, }; } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async () => ({ ...REPRESENTATIVE_PAYLOAD })); + gestureRuntimeSpies.scrollDirection.mockReset(); + gestureRuntimeSpies.scrollDirection.mockImplementation(async () => ({ + ...REPRESENTATIVE_PAYLOAD, + })); }); test('(a) flag-off identity: meta.includeCost absent === no meta at all, byte-identical and no cost', async () => { @@ -98,7 +98,8 @@ test('(a) flag-off identity: meta.includeCost absent === no meta at all, byte-id expect('cost' in (respMetaWithoutCost.data ?? {})).toBe(false); } if (respNoMeta.ok) { - expect(respNoMeta.data).toEqual(REPRESENTATIVE_PAYLOAD); + // The owner payload passes through beside scroll's own result fields. + expect(respNoMeta.data).toMatchObject(REPRESENTATIVE_PAYLOAD); } }); @@ -133,9 +134,9 @@ test('(c) runnerRoundTrips counts real iOS-runner round-trip diagnostics in scop const { sessionStore, handler } = makeHandler(); sessionStore.set('cost-session', makeIosSession('cost-session')); - // The mocked dispatch runs inside the request's diagnostics scope, so emitting - // here is equivalent to the runner-session emitting these phases per round-trip. - mockDispatch.mockImplementation(async () => { + // The bound operation runs inside the request's diagnostics scope, so emitting here is + // equivalent to the runner-session emitting these phases per round-trip. + gestureRuntimeSpies.scrollDirection.mockImplementation(async () => { emitDiagnostic({ phase: 'ios_runner_readiness_preflight' }); // real round-trip emitDiagnostic({ phase: 'ios_runner_command_send' }); // real round-trip emitDiagnostic({ phase: 'ios_runner_command_send' }); // real round-trip @@ -158,7 +159,7 @@ test('(c2) nodeCount reports the node-tree size whenever data carries a nodes ar // The nodeCount read is command-agnostic: it triggers on any response.data that // carries a `nodes` array (in production only the snapshot node-tree commands - // do). We drive it through the generic dispatch path with a node-bearing payload. + // do). We drive it through a bound operation returning a node-bearing payload. const nodeTreePayload = { nodes: [ { ref: 'e1', type: 'Button', label: 'A' }, @@ -167,7 +168,9 @@ test('(c2) nodeCount reports the node-tree size whenever data carries a nodes ar ], truncated: false, }; - mockDispatch.mockImplementation(async () => structuredClone(nodeTreePayload)); + gestureRuntimeSpies.scrollDirection.mockImplementation(async () => + structuredClone(nodeTreePayload), + ); const respFlagOff = await handler(baseRequest()); const respFlagOn = await handler(baseRequest({ meta: { includeCost: true } })); @@ -181,14 +184,14 @@ test('(c2) nodeCount reports the node-tree size whenever data carries a nodes ar // a pure read of the existing `nodes` array, never a mutation of the payload. delete respFlagOn.data?.cost; expect(respFlagOn.data).toEqual(respFlagOff.data); - expect(respFlagOff.data).toEqual(nodeTreePayload); + expect(respFlagOff.data).toMatchObject(nodeTreePayload); }); test('(d) error path: a failing request with includeCost:true produces NO cost', async () => { const { sessionStore, handler } = makeHandler(); sessionStore.set('cost-session', makeIosSession('cost-session')); - // Conflicting explicit selector under a reject lock policy fails before dispatch. + // Conflicting explicit selector under a reject lock policy fails before the bound execution. const failingRequest = baseRequest({ flags: { udid: 'SIM-999' }, meta: { lockPolicy: 'reject', includeCost: true }, @@ -208,15 +211,15 @@ test('(d) error path: a failing request with includeCost:true produces NO cost', test('(e) boundary survival: meta.includeCost survives commandRpcParamsSchema parsing', () => { const parsed = commandRpcParamsSchema.parse({ - command: 'app-switcher', - positionals: [], + command: 'scroll', + positionals: ['down'], meta: { includeCost: true }, }); expect(parsed.meta?.includeCost).toBe(true); const parsedOff = commandRpcParamsSchema.parse({ - command: 'app-switcher', - positionals: [], + command: 'scroll', + positionals: ['down'], meta: {}, }); expect(parsedOff.meta?.includeCost).toBeUndefined(); diff --git a/src/daemon/__tests__/request-router-dispatch-mocks.ts b/src/daemon/__tests__/request-router-dispatch-mocks.ts index f98d89eba4..491c6ded1e 100644 --- a/src/daemon/__tests__/request-router-dispatch-mocks.ts +++ b/src/daemon/__tests__/request-router-dispatch-mocks.ts @@ -21,7 +21,6 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { const { selectionFromResolveTargetDevice } = await import('./device-selection-stub.ts'); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: dispatchMocks.resolveTargetDevice, resolveTargetDeviceSelection: vi.fn( selectionFromResolveTargetDevice(dispatchMocks.resolveTargetDevice), diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index 6ba338bc6c..2e01fef201 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -3,14 +3,10 @@ import { createTestDeviceInventoryGatewaysFromProvider, } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -19,8 +15,11 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from './test-device-runtime-gateway.ts'; +import { + createRequestHandler, + lifecycleDeviceRuntimeGateway, + systemRuntimeSpies, +} from './test-device-runtime-gateway.ts'; import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -31,8 +30,6 @@ import { snapshotRuntimePlanUses, } from '@agent-device/contracts/platform-runtime-operations'; -const mockDispatch = vi.mocked(dispatchCommand); - function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { const runtime = snapshotRuntimeFixture(); return { @@ -86,8 +83,8 @@ function makeAndroidSession(name: string, id = 'emulator-5554'): SessionState { } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({ nodes: [] }); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({ nodes: [] }); }); function installGatedDispatch(): { @@ -100,7 +97,7 @@ function installGatedDispatch(): { let active = 0; let maxActive = 0; - mockDispatch.mockImplementation(async (device, command) => { + legacyDispatchCapture.mockImplementation(async (device, command) => { order.push(`start-${command}-${device.id}`); active += 1; maxActive = Math.max(maxActive, active); @@ -149,7 +146,7 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi }, }); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(response.ok).toBe(false); if (!response.ok) { expect(response.error.code).toBe('INVALID_ARGS'); @@ -306,7 +303,7 @@ test('fresh named sessions with the same name serialize first binding before rej 'end-snapshot-emulator-5554', ]); expect(dispatchGate.getMaxActive()).toBe(1); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(1); expect(sessionStore.get('qa-android')?.device.id).toBe('emulator-5554'); }); @@ -450,9 +447,9 @@ test('fresh named sessions reject incompatible selector combinations before bind expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toMatch(testCase.conflict); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(sessionStore.get(testCase.name)).toBeUndefined(); - mockDispatch.mockClear(); + legacyDispatchCapture.mockClear(); } }); @@ -489,7 +486,7 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a }, }); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(response.ok).toBe(false); if (!response.ok) { expect(response.error.code).toBe('INVALID_ARGS'); @@ -503,17 +500,14 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a test('direct daemon requests apply strip lock policy for existing sessions before dispatch', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); sessionStore.set('qa-ios', makeIosSession('qa-ios')); - let dispatchCalls = 0; - mockDispatch.mockImplementation(async () => { - dispatchCalls += 1; - return {}; - }); + systemRuntimeSpies.appSwitcher.mockClear(); const handler = createRequestHandler({ logPath: path.join(os.tmpdir(), 'daemon.log'), token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -532,7 +526,7 @@ test('direct daemon requests apply strip lock policy for existing sessions befor }, }); - expect(dispatchCalls).toBe(1); + expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(response.ok).toBe(true); const action = sessionStore.get('qa-ios')?.actions.at(-1); expect(action?.flags.platform).toBe('ios'); @@ -546,7 +540,7 @@ test('strip lock policy still refuses a request naming a different device, befor const sessionStore = makeSessionStore('agent-device-router-lock-'); sessionStore.set('qa-ios', makeIosSession('qa-ios')); let dispatchCalls = 0; - mockDispatch.mockImplementation(async () => { + legacyDispatchCapture.mockImplementation(async () => { dispatchCalls += 1; return {}; }); @@ -587,17 +581,14 @@ test('batch preserves tenant-scoped session names across nested requests', async tenantId: 'tenant-a', runId: 'run-1', }); - let dispatchCalls = 0; - mockDispatch.mockImplementation(async () => { - dispatchCalls += 1; - return {}; - }); + systemRuntimeSpies.appSwitcher.mockClear(); const handler = createRequestHandler({ logPath: path.join(os.tmpdir(), 'daemon.log'), token: 'test-token', sessionStore, leaseRegistry, + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -619,6 +610,6 @@ test('batch preserves tenant-scoped session names across nested requests', async }); expect(response.ok).toBe(true); - expect(dispatchCalls).toBe(1); + expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(sessionStore.get('tenant-a:default')?.actions.at(-1)?.command).toBe('app-switcher'); }); diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 23ce5a2950..86259f8482 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -1,4 +1,5 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -26,7 +27,6 @@ vi.mock('../../platforms/apple/core/tool-provider.ts', async (importOriginal) => }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler, lifecycleDeviceRuntimeGateway, @@ -43,7 +43,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { inspectDeviceClaims } from '../device-claim-inspection.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); // The open path reaches readiness through its admitted package binding, so router-level @@ -106,8 +105,8 @@ function openRequest( } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockResolveTargetDevice.mockReset(); mockEnsureDeviceReady.mockReset(); mockEnsureDeviceReady.mockResolvedValue(undefined); @@ -362,7 +361,7 @@ test('proxy open without required lease metadata fails before device resolution' expect(response.error.message).toMatch(/Proxy open requires leaseId/); } expect(mockResolveTargetDevice).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('close releases the session lease', async () => { @@ -435,7 +434,7 @@ test('close rejects a different client before cleanup', async () => { expect(response.ok).toBe(false); expect(sessionStore.get('default')).toBeDefined(); expect(leaseRegistry.listActiveLeases()).toHaveLength(1); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('router serializes same-device open requests before first session creation finishes', async () => { diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index 5ed3421d36..4c74c4e599 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -1,18 +1,13 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', () => ({ getRunnerSessionSnapshot: vi.fn(), })); -import { dispatchCommand } from '../../core/dispatch.ts'; import { getRunnerSessionSnapshot } from '../../platforms/apple/core/runner/runner-client.ts'; import { createRequestHandler, @@ -24,12 +19,11 @@ import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockGetRunnerSessionSnapshot = vi.mocked(getRunnerSessionSnapshot); beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); for (const spy of Object.values(gestureRuntimeSpies)) spy.mockClear(); mockGetRunnerSessionSnapshot.mockReset(); }); @@ -82,7 +76,7 @@ test('router blocks non-record commands when recording was invalidated', async ( } expect(response.error.code).toBe('COMMAND_FAILED'); expect(response.error.message).toBe('iOS runner session restarted during recording'); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('router allows canonical iOS simulator gestures during overlay recording after runner restart', async () => { diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index e329461674..303b6a6e59 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -1,4 +1,5 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -28,7 +29,6 @@ vi.mock('../../platforms/apple/core/apps.ts', async (importOriginal) => { }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -37,20 +37,21 @@ import { createRequestHandler, gestureRuntimeSpies, lifecycleDeviceRuntimeGateway, + systemRuntimeSpies, } from './test-device-runtime-gateway.ts'; import { ensureDeviceReady } from '../device-ready.ts'; // Readiness is package-owned; hold the open at the fixture's platform-neutral readiness gate. import { awaitFixtureReadiness } from './application-lifecycle-runtime-fixture.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); beforeEach(() => { gestureRuntimeSpies.scrollDirection.mockClear(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + systemRuntimeSpies.appSwitcher.mockClear(); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockResolveTargetDevice.mockReset(); mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); mockEnsureDeviceReady.mockReset(); @@ -87,9 +88,10 @@ test('replay runs active-session actions inside the parent request provider scop }); expect(response).toMatchObject({ ok: true }); - // `app-switcher` is still a legacy dispatch leaf; `scroll down` reaches its bound operation - // instead (R53), so the flow's two actions land on two different execution paths. - expect(mockDispatch).toHaveBeenCalledTimes(1); + // Both actions now reach bound operations — `app-switcher` through R56, `scroll down` through + // R53 — so the flow drives the runtime gateway twice and the legacy dispatcher not at all. + expect(legacyDispatchCapture).not.toHaveBeenCalled(); + expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(appleRunnerProvider).toHaveBeenCalledTimes(1); }); @@ -122,7 +124,10 @@ test('replay routes session-changing actions through the full request path', asy }); expect(response).toMatchObject({ ok: true }); - expect(mockDispatch).toHaveBeenCalledTimes(1); + // `app-switcher` reaches its bound operation since R56; `runtime set` mutates session state and + // never dispatched, so the legacy dispatcher sees neither action. + expect(legacyDispatchCapture).not.toHaveBeenCalled(); + expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(appleRunnerProvider).toHaveBeenCalledTimes(2); }); diff --git a/src/daemon/__tests__/request-router-response-level.test.ts b/src/daemon/__tests__/request-router-response-level.test.ts index f63b542393..8f32857914 100644 --- a/src/daemon/__tests__/request-router-response-level.test.ts +++ b/src/daemon/__tests__/request-router-response-level.test.ts @@ -3,11 +3,6 @@ import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -16,10 +11,10 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -// Register a test view on a command that flows through the (mocked) generic -// dispatch path, so the router graft mechanics can be exercised end to end -// without the real snapshot handler (the actual snapshot view is unit-tested in -// response-views.test.ts). +// Register a test view on a command whose payload this file controls end to end, so the router +// graft mechanics can be exercised without the real snapshot handler (the actual snapshot view is +// unit-tested in response-views.test.ts). `app-switcher` reaches a bound operation since R56, so +// its payload is the one the daemon leaf builds rather than a mocked dispatcher's. vi.mock('../response-views.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -28,26 +23,25 @@ vi.mock('../response-views.ts', async (importOriginal) => { ...actual.RESPONSE_VIEWS, 'app-switcher': (data: Record, level: string) => level === 'digest' - ? { appSwitcherDigest: true, hadItems: Array.isArray(data.items) } + ? { appSwitcherDigest: true, hadAction: data.action === 'app-switcher' } : data, }, }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler, - gestureDeviceRuntimeGateway, gestureRuntimeSpies, + lifecycleDeviceRuntimeGateway, } from './test-device-runtime-gateway.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; -const mockDispatch = vi.mocked(dispatchCommand); - -const REPRESENTATIVE_PAYLOAD = { message: 'app-switcher-ok', items: [1, 2, 3] } as const; +const REPRESENTATIVE_PAYLOAD = { message: 'scroll-ok', items: [1, 2, 3] } as const; +/** What the bound `app-switcher` leaf answers; this file's registered view digests it. */ +const APP_SWITCHER_PAYLOAD = { action: 'app-switcher', message: 'Opened app switcher' } as const; function makeIosSession(name: string): SessionState { return { @@ -78,9 +72,9 @@ function makeHandler() { leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', - // `scroll` is the view-less subject of case (e), and R53 moved it onto a bound runtime, so - // the handler needs an owner that admits `scrollDirection`. - deviceRuntimeGateway: gestureDeviceRuntimeGateway, + // Both subjects reach bound runtimes now — `app-switcher` through R56 and `scroll` through + // R53 — so the handler needs an owner admitting `appSwitcher` and `scrollDirection` alike. + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, }), }; } @@ -97,8 +91,6 @@ function request(command: string, overrides: Partial = {}): Daemo } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async () => ({ ...REPRESENTATIVE_PAYLOAD })); gestureRuntimeSpies.scrollDirection.mockReset(); gestureRuntimeSpies.scrollDirection.mockResolvedValue({}); }); @@ -113,7 +105,7 @@ test('(a) default identity: responseLevel absent === default === no meta, byte-i expect(JSON.stringify(noMeta)).toBe(JSON.stringify(emptyMeta)); expect(JSON.stringify(noMeta)).toBe(JSON.stringify(explicitDefault)); - if (noMeta.ok) expect(noMeta.data).toEqual(REPRESENTATIVE_PAYLOAD); + if (noMeta.ok) expect(noMeta.data).toEqual(APP_SWITCHER_PAYLOAD); }); test('(b) digest applies the registered view, dropping the full payload', async () => { @@ -121,7 +113,7 @@ test('(b) digest applies the registered view, dropping the full payload', async const resp = await handler(request('app-switcher', { meta: { responseLevel: 'digest' } })); expect(resp.ok).toBe(true); if (!resp.ok) return; - expect(resp.data).toEqual({ appSwitcherDigest: true, hadItems: true }); + expect(resp.data).toEqual({ appSwitcherDigest: true, hadAction: true }); expect('message' in (resp.data ?? {})).toBe(false); }); @@ -139,7 +131,7 @@ test('(d) digest composes with --cost: viewed data plus an additive cost block', ); expect(resp.ok).toBe(true); if (!resp.ok) return; - expect(resp.data).toMatchObject({ appSwitcherDigest: true, hadItems: true }); + expect(resp.data).toMatchObject({ appSwitcherDigest: true, hadAction: true }); expect(typeof resp.data?.cost?.wallClockMs).toBe('number'); }); diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index de999f4c14..7b46191cba 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -1,4 +1,5 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -7,11 +8,6 @@ import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; // `scroll` still executes through legacy platform dispatch; screenshot and click bind their fake // at the facts/bind seam below instead (ADR 0019). -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/android/window-state.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -20,7 +16,6 @@ vi.mock('../../platforms/android/window-state.ts', async (importOriginal) => { }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { screenshotRuntimeFixture, @@ -36,8 +31,6 @@ import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/devi import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeSession as makeBaseSession } from '../../__tests__/test-utils/session-factories.ts'; -const mockDispatch = vi.mocked(dispatchCommand); - function makeSession(name: string): SessionState { return makeBaseSession(name, { device: ANDROID_EMULATOR }); } @@ -66,8 +59,8 @@ function makeMacOsMenubarSession(name: string): SessionState { } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); }); type ScreenshotRouter = Readonly<{ diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index 2aac875041..26201bdfaa 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -1,14 +1,10 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -17,11 +13,11 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -import { dispatchCommand } from '../../core/dispatch.ts'; import { dispatchApplicationLifecycleEffect } from './application-lifecycle-runtime-fixture.ts'; import { createRequestHandler, lifecycleDeviceRuntimeGateway, + systemRuntimeSpies, } from './test-device-runtime-gateway.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -35,7 +31,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, retriableForErrorCode } from '@agent-device/kernel/errors'; import { supportedPlatformsForCommand } from '../../core/capabilities.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockLifecycleEffect = vi.mocked(dispatchApplicationLifecycleEffect); /** @@ -86,7 +81,9 @@ function request(command: string, overrides: Partial = {}): Daemo } beforeEach(() => { - mockDispatch.mockReset(); + legacyDispatchCapture.mockReset(); + systemRuntimeSpies.appSwitcher.mockReset(); + systemRuntimeSpies.appSwitcher.mockResolvedValue(undefined); mockLifecycleEffect.mockReset(); mockLifecycleEffect.mockResolvedValue(undefined); }); @@ -100,23 +97,41 @@ test('retriableForErrorCode is a conservative policy: transient => true, others test('UNSUPPORTED_OPERATION errors carry supportedOn derived from the capability matrix', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('typed-error', makeIosSession('typed-error')); - mockDispatch.mockRejectedValue(new AppError('UNSUPPORTED_OPERATION', 'nope on this platform')); + // `perf` is the subject because the graft reads the CAPABILITY MATRIX, and perf is the command + // that still has a row there: its migration is Wave 2, sequenced behind the next major. Its + // xctrace collector refuses a non-Apple session in production, so no mocking is needed to reach + // a real UNSUPPORTED_OPERATION. + sessionStore.set( + 'typed-error', + makeSession('typed-error', { + ...TENANT_SESSION_DEFAULTS, + device: { + platform: 'linux', + id: 'local', + name: 'Linux Desktop', + kind: 'device', + target: 'desktop', + booted: true, + }, + }), + ); - // `app-switcher` routes through the (mocked) generic dispatch and is platform-restricted. - const response = await handler(request('app-switcher')); + const response = await handler(request('perf', { positionals: ['trace', 'start', 'xctrace'] })); expect(response.ok).toBe(false); if (response.ok) return; - const expected = supportedPlatformsForCommand('app-switcher'); - expect(expected.length).toBeGreaterThan(0); // app-switcher is a platform-restricted command + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + const expected = supportedPlatformsForCommand('perf'); + expect(expected.length).toBeGreaterThan(0); // perf is a platform-restricted command expect(response.error.supportedOn).toBe(expected.join(', ')); }); test('DEVICE_IN_USE errors are flagged retriable; supportedOn stays absent', async () => { const { sessionStore, handler } = makeHandler(); sessionStore.set('typed-error', makeIosSession('typed-error')); - mockDispatch.mockRejectedValue(new AppError('DEVICE_IN_USE', 'device busy')); + // R56 put `app-switcher` on a bound operation, so the failure is raised where the device work + // happens rather than by the retired dispatcher. + systemRuntimeSpies.appSwitcher.mockRejectedValue(new AppError('DEVICE_IN_USE', 'device busy')); const response = await handler(request('app-switcher')); @@ -141,7 +156,7 @@ test('deterministic errors (INVALID_ARGS) are returned with the default shape expect(response.error.code).toBe('INVALID_ARGS'); expect('retriable' in response.error).toBe(false); expect('supportedOn' in response.error).toBe(false); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); // ADR 0012 decision 6, BLOCKER 2 (second follow-up): a repair-armed `close` @@ -222,7 +237,7 @@ test('#1391: an ordinary close-time script-save failure surfaces details.reason/ // Unlike the repair-armed case above, an ordinary session's teardown // never withholds on a failed script save — it is always torn down. expect(sessionStore.get('typed-error')).toBeUndefined(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); } finally { fs.rmSync(targetPath, { force: true }); } diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index ecbad97d46..4715550341 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test, vi } from 'vitest'; +import { afterEach, expect, test } from 'vitest'; import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { @@ -8,34 +8,15 @@ import { } from '../../request/cancel.ts'; import { dispatchSnapshotDiffViaRuntime } from '../snapshot-diff-runtime.ts'; import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; -const dispatchCommandMock = vi.hoisted(() => vi.fn()); - -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: dispatchCommandMock, - }; -}); - -vi.mock('../handlers/snapshot-interactor-capture.ts', () => ({ - captureSnapshotWithInteractor: vi.fn( - async ({ device, runnerContext, options }) => - await dispatchCommandMock(device, 'snapshot', [], undefined, { - ...runnerContext, - ...options, - snapshotInteractiveOnly: options.interactiveOnly, - snapshotPreferredBackend: options.preferredBackend, - snapshotDepth: options.depth, - snapshotScope: options.scope, - snapshotRaw: options.raw, - snapshotCustomActions: options.customActions, - snapshotIncludeHiddenContentHints: options.includeHiddenContentHints, - }), - ), -})); +/** + * The capture double the snapshot runtime fixture's bound operation delegates to. Cancellation is + * what these tests are about, so the double is the one place that can observe the signal the + * request scope handed the binding. + */ +const captureMock = legacyDispatchCapture; type Deferred = { promise: Promise; @@ -51,7 +32,7 @@ function deferred(): Deferred { } afterEach(() => { - dispatchCommandMock.mockReset(); + captureMock.mockReset(); }); for (const command of ['snapshot', 'diff snapshot'] as const) { @@ -66,7 +47,7 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { const releaseDispatch = deferred(); let observedSignal: AbortSignal | undefined; - dispatchCommandMock.mockImplementation(async (...args: unknown[]) => { + captureMock.mockImplementation(async (...args) => { const context = args[4] as { signal?: AbortSignal } | undefined; observedSignal = context?.signal; dispatchEntered.resolve(); diff --git a/src/daemon/__tests__/snapshot-presentation-transitions.test.ts b/src/daemon/__tests__/snapshot-presentation-transitions.test.ts index a4dd4f13ae..7926fb6955 100644 --- a/src/daemon/__tests__/snapshot-presentation-transitions.test.ts +++ b/src/daemon/__tests__/snapshot-presentation-transitions.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts'; import { createInteractionDevice } from '../../commands/interaction/runtime/__tests__/test-utils/index.ts'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import { buildSnapshotState } from '../../core/snapshot-state.ts'; import { presentIosInteractiveSnapshot } from '../../snapshot/snapshot-presentation/ios/index.ts'; import { navigationTitleWithAppProvidedDetailsAffordanceNodes } from '../../snapshot/snapshot-presentation/ios/transitions.fixtures.ts'; diff --git a/src/daemon/__tests__/snapshot-publication-membership.test.ts b/src/daemon/__tests__/snapshot-publication-membership.test.ts index 5cedf996d5..7cc1232f5d 100644 --- a/src/daemon/__tests__/snapshot-publication-membership.test.ts +++ b/src/daemon/__tests__/snapshot-publication-membership.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import { buildSnapshotState } from '../../core/snapshot-state.ts'; // End-to-end publication-membership contract for the acquire/present design (#1797, external // review pass 4 finding 1), exercised through the production interface. Runner presentation owns diff --git a/src/daemon/__tests__/snapshot-quality-latch.test.ts b/src/daemon/__tests__/snapshot-quality-latch.test.ts index 4e19329a3b..bdb7a81049 100644 --- a/src/daemon/__tests__/snapshot-quality-latch.test.ts +++ b/src/daemon/__tests__/snapshot-quality-latch.test.ts @@ -15,18 +15,9 @@ import { dispatchSnapshotDiffViaRuntime } from '../snapshot-diff-runtime.ts'; import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; -const dispatchCommandMock = vi.hoisted(() => vi.fn()); - -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: dispatchCommandMock, - }; -}); - vi.mock('../handlers/snapshot-interactor-capture.ts', async () => { const fixture = await import('./legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; @@ -184,7 +175,7 @@ function scenario() { } function seedCapture(verdict: SnapshotQualityVerdict, label = 'Continue') { - dispatchCommandMock.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', truncated: false, quality: verdict, @@ -293,7 +284,7 @@ test('an empty ref-scoped diff latches on the captured verdict, not the retained ], }; // The runner owns scope publication and returns the healthy empty projection for a miss. - dispatchCommandMock.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', truncated: false, quality: deferredVerdict(), diff --git a/src/daemon/__tests__/snapshot-runtime-fixture.ts b/src/daemon/__tests__/snapshot-runtime-fixture.ts index e86693a385..a68b1b31e1 100644 --- a/src/daemon/__tests__/snapshot-runtime-fixture.ts +++ b/src/daemon/__tests__/snapshot-runtime-fixture.ts @@ -5,6 +5,15 @@ import { providerRuntimeOwner, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; +import { + type AlertRuntimeInput, + alertRuntimeOperationFacts, +} from '@agent-device/contracts/alert-runtime'; +import { + type SetSettingInput, + settingsRuntimeOperationFacts, +} from '@agent-device/contracts/settings-runtime'; import { type CaptureScreenshotInput, screenshotRuntimeOperationFacts, @@ -14,8 +23,15 @@ import { type SnapshotResult, snapshotRuntimeOperationFacts, } from '@agent-device/contracts/snapshot-runtime'; -import { deviceShape, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; -import { dispatchCommand, type DispatchContext } from '../../core/dispatch.ts'; +import { + deviceShape, + isApplePlatform, + isIosFamily, + isMacOs, + type DeviceInfo, +} from '@agent-device/kernel/device'; +import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from '../../platforms/apple/alert.ts'; +import { type DispatchContext } from '../../core/dispatch.ts'; import { getRequestSignal } from '../../request/cancel.ts'; import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; @@ -29,6 +45,19 @@ import { writeSolidPng } from './screenshot-runtime-fixture.ts'; */ export const fixtureScreenshotCaptures: CaptureScreenshotInput[] = []; +/** + * Every mutation the fixture's bound settings operation received, newest last. The neutral input + * is the whole assertion surface now: whether a request reached the owner, and with which setting, + * state and resolved app id, is exactly what the retired positional dispatch used to witness. + */ +export const fixtureSettingsMutations: SetSettingInput[] = []; + +/** Clears both recorders so a suite can assert "the owner was never reached" from a known zero. */ +export function resetSnapshotRuntimeFixture(): void { + fixtureScreenshotCaptures.length = 0; + fixtureSettingsMutations.length = 0; +} + /** Request-scoped snapshot seam for handler tests that mock the legacy leaf dispatch. */ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; @@ -46,6 +75,19 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ fixtureScreenshotCaptures.push(input); writeSolidPng(input.outPath); }; + const setSetting = async (input: SetSettingInput) => { + fixtureSettingsMutations.push(input); + return {}; + }; + // R59: the alert legs delegate to the Apple owner's own module, so the poll and retry windows + // these suites exercise are the shipped ones rather than a fixture's imitation of them. The + // runner underneath is the suite's own mock. + const runnerOptions = { signal: requestSignal }; + const alertOptions = (input: AlertRuntimeInput) => ({ + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }), + ...(input.surface === undefined ? {} : { surface: input.surface }), + }); return narrowDeviceBinding( { device, @@ -58,6 +100,19 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ captureSnapshotWithCustomActions: captureSnapshot, captureSnapshotWithoutActiveApp: captureSnapshot, captureScreenshot, + setSetting, + ...(isApplePlatform(device.platform) + ? { + readAlert: async (input: AlertRuntimeInput) => + await readAppleAlert(device, runnerOptions, alertOptions(input)), + awaitAlert: async (input: AlertRuntimeInput) => + await awaitAppleAlert(device, runnerOptions, alertOptions(input)), + acceptAlert: async (input: AlertRuntimeInput) => + await actOnAppleAlert(device, runnerOptions, 'accept', alertOptions(input)), + dismissAlert: async (input: AlertRuntimeInput) => + await actOnAppleAlert(device, runnerOptions, 'dismiss', alertOptions(input)), + } + : {}), }, [Symbol.asyncDispose]: async () => {}, }, @@ -68,6 +123,27 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ return { inspectFacts, bindDevice }; } +/** A physical Apple device that is not the macOS host: the one settings refusal these suites use. */ +function appleHostOrSimulatorOnly(device: DeviceInfo): boolean { + return device.platform === 'apple' && device.kind === 'device' && !isMacOs(device); +} + +const settingsUnavailable = { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', +} as const; + +const alertUnavailable = { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'This fixture models the Apple alert legs only.', +} as const; + +function alertCell(device: DeviceInfo) { + return isApplePlatform(device.platform) ? ({ available: true } as const) : alertUnavailable; +} + async function snapshotFacts(device: DeviceInfo): Promise> { const base = await unavailableDeviceRuntimeGateway.inspectFacts(device); const providerOwned = isActiveProviderDevice(device); @@ -96,6 +172,22 @@ async function snapshotFacts(device: DeviceInfo): Promise {}, }; @@ -168,6 +172,12 @@ const admittedGestureFamilyFacts = Object.freeze({ scrollDirection: available, }); +const admittedSystemFamilyFacts = Object.freeze({ appSwitcher: available }); + +export const systemRuntimeSpies = { + appSwitcher: vi.fn(async () => undefined), +}; + export const gestureRuntimeSpies = { captureSnapshot: vi.fn(async () => ({ backend: 'xctest' as const, diff --git a/src/daemon/android-system-dialog.ts b/src/daemon/android-system-dialog.ts index 4214533d77..b83c9557ea 100644 --- a/src/daemon/android-system-dialog.ts +++ b/src/daemon/android-system-dialog.ts @@ -12,7 +12,7 @@ import { emitDiagnostic } from '../utils/diagnostics.ts'; import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { centerOfRect, type SnapshotNode } from '@agent-device/kernel/snapshot'; import { sleep } from '../utils/timeouts.ts'; -import { buildSnapshotState } from './snapshot-state.ts'; +import { buildSnapshotState } from '../core/snapshot-state.ts'; import { isSnapshotNodeInteractionBlocked } from '../snapshot/snapshot-occlusion.ts'; import { expireRefFrame } from './ref-frame.ts'; import type { SessionState } from './types.ts'; diff --git a/src/daemon/app-event-runtime.ts b/src/daemon/app-event-runtime.ts new file mode 100644 index 0000000000..0f5cd3ee1f --- /dev/null +++ b/src/daemon/app-event-runtime.ts @@ -0,0 +1,75 @@ +import type { AppEventInput } from '@agent-device/contracts/app-event-runtime'; +import { appEventRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { parseTriggerAppEventArgs, resolveAppEventUrl } from '../core/app-events.ts'; +import { successText } from '../utils/success-text.ts'; +import type { DaemonCommandContext } from './context.ts'; +import { admitRuntimeUse, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; +import type { DaemonFailureResponse } from './handlers/response.ts'; + +/** + * What the admit-then-bind step reports: either the refusal an unadmitted cell produced, or the + * one bound invocation to run once the caller has expired the ref frame (ADR 0014). + */ +export type ResolvedAppEventExecution = + | Readonly<{ ok: false; response: DaemonFailureResponse }> + | Readonly<{ + ok: true; + execute: (context: DaemonCommandContext) => Promise>; + }>; + +/** + * The ONE place a bound `trigger-app-event` executes (R57). Argument parsing and URL resolution + * stay here rather than moving into the owner: the event name pattern, the payload size limit, + * and the per-platform `AGENT_DEVICE_*_APP_EVENT_URL_TEMPLATE` are daemon policy, not device + * mechanics. They also stay downstream of admission, where the retired `dispatchCommand` ran + * them, so an unsupported device still reports the unsupported cell rather than an argument error. + */ +async function executeAppEvent( + runtime: BoundDeviceRuntime, + device: DeviceInfo, + context: DaemonCommandContext, + positionals: readonly string[], +): Promise> { + const { eventName, payload } = parseTriggerAppEventArgs([...positionals]); + const eventUrl = resolveAppEventUrl(device, eventName, payload); + const input: AppEventInput = { + eventUrl, + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; + await runtime.operations.triggerAppEvent(input); + return { + event: eventName, + eventUrl, + transport: 'deep-link', + ...successText(`Triggered app event: ${eventName}`), + }; +} + +/** + * The one place `trigger-app-event` reaches a device (ADR 0019 §9). Admission inspects the exact + * owner's `triggerAppEvent` fact and binds once, before the frame expires and before any + * argument is read. + */ +export async function resolveBoundAppEventRuntime( + params: Readonly<{ device: DeviceInfo; positionals: readonly string[] }> & + RuntimeAdmissionBindings, +): Promise { + const { device, positionals, inspectFacts, bindDevice } = params; + const admission = await admitRuntimeUse({ + command: 'trigger-app-event', + device, + use: appEventRuntimeUse, + inspectFacts, + bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { + ok: true, + execute: (context) => executeAppEvent(runtime, device, context, positionals), + }; +} diff --git a/src/daemon/app-switcher-runtime.ts b/src/daemon/app-switcher-runtime.ts new file mode 100644 index 0000000000..f678bc43a2 --- /dev/null +++ b/src/daemon/app-switcher-runtime.ts @@ -0,0 +1,51 @@ +import type { AppSwitcherInput } from '@agent-device/contracts/app-switcher-runtime'; +import { appSwitcherRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { successText } from '../utils/success-text.ts'; +import type { DaemonCommandContext } from './context.ts'; +import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; +import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; + +/** The neutral intent one `app-switcher` carries, projected from a resolved command context. */ +function appSwitcherInput(context: DaemonCommandContext): AppSwitcherInput { + return { + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * The one place `app-switcher` reaches a device (ADR 0019). Admission inspects the exact owner's + * `appSwitcher` fact and binds once, before the dispatcher runs, so an owner with no springboard + * to reveal is refused rather than discovered mid-execution. + */ +export async function resolveBoundAppSwitcherRuntime( + params: { + device: DeviceInfo; + } & RuntimeAdmissionBindings, +): Promise { + return await resolveBoundGenericRuntime( + { + command: 'app-switcher', + device: params.device, + use: appSwitcherRuntimeUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }, + executeAppSwitcher, + ); +} + +/** + * The ONE place a bound `app-switcher` executes (R56). Typed off `typeof appSwitcherRuntimeUse` + * rather than a hand-restated operations shape, so a change to what it binds can't drift here. + */ +async function executeAppSwitcher( + runtime: BoundDeviceRuntime, + context: DaemonCommandContext, +): Promise> { + await runtime.operations.appSwitcher(appSwitcherInput(context)); + return { action: 'app-switcher', ...successText('Opened app switcher') }; +} diff --git a/src/daemon/deferred-interaction-outcome.ts b/src/daemon/deferred-interaction-outcome.ts index 25c6a8fae7..61886382f5 100644 --- a/src/daemon/deferred-interaction-outcome.ts +++ b/src/daemon/deferred-interaction-outcome.ts @@ -27,6 +27,7 @@ import { summarizeDiscriminatingSurfaceDivergence, markPendingInteractionOutcome, retryPendingInteractionOutcome, + type InteractionRetryTap, } from './interaction-outcome-policy.ts'; import { runPostGestureStabilityLoop } from './post-gesture-stability.ts'; import type { SessionState } from './types.ts'; @@ -151,6 +152,8 @@ type DeferredOutcomeCaptureParams = { session: SessionState | undefined; device: SessionState['device']; logPath: string; + /** How a no-change retry re-fires the recorded tap; absent on captures that cannot tap. */ + retryTap?: InteractionRetryTap; /** Whether the capture the verdict rides on was interactive-only filtered. */ interactiveOnly: boolean; androidFreshnessMode?: SnapshotFreshnessMode; @@ -214,6 +217,7 @@ async function captureInteractionOutcomeAwareSnapshot( pending, logPath: params.logPath, snapshot: latest.snapshot, + retryTap: params.retryTap, }); while (outcome.retried) { @@ -228,6 +232,7 @@ async function captureInteractionOutcomeAwareSnapshot( pending, logPath: params.logPath, snapshot: latest.snapshot, + retryTap: params.retryTap, }); } diff --git a/src/daemon/generic-runtime-execution.ts b/src/daemon/generic-runtime-execution.ts index 9409a45ccf..b7cca5c675 100644 --- a/src/daemon/generic-runtime-execution.ts +++ b/src/daemon/generic-runtime-execution.ts @@ -1,4 +1,5 @@ import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; +import { errorResponse } from './handlers/response.ts'; import { resolveBoundFocusRuntime } from './focus-runtime.ts'; import { resolveScreenshotGenericExecution } from './screenshot-runtime.ts'; import { resolveBoundScrollRuntime } from './scroll-runtime.ts'; @@ -8,13 +9,20 @@ import type { DaemonRequest, SessionState } from './types.ts'; import { resolveBoundViewportRuntime } from './viewport-runtime.ts'; import { resolveBoundBackRuntime } from './back-runtime.ts'; import { resolveBoundHomeRuntime } from './home-runtime.ts'; +import { resolveBoundAppSwitcherRuntime } from './app-switcher-runtime.ts'; import { resolveBoundOrientationRuntime } from './orientation-runtime.ts'; import { resolveBoundTvRemoteRuntime } from './tv-remote-runtime.ts'; /** * The generic route's runtime-owned leaves (ADR 0019). Each one admits its own exact owner facts * and binds once here, before the dispatcher runs, so the dispatcher itself never learns a command - * name. `undefined` means the leaf still executes through legacy platform dispatch. + * name. + * + * Every generic-route descriptor is now runtime-owned (R58 retired the last legacy dispatcher), so + * this is total over that route rather than a partial table with a legacy fallback behind it — + * `generic-route-runtime-completeness.test.ts` derives the denominator from the registry and fails + * if a descriptor joins the route without an arm here. The `default` therefore reports a routing + * gap; it is not a second execution path. */ export async function resolveGenericRuntimeExecution( params: Readonly<{ @@ -23,7 +31,7 @@ export async function resolveGenericRuntimeExecution( context: DaemonCommandContext; }> & ScreenshotRuntimeBindings, -): Promise { +): Promise { switch (params.req.command) { case 'screenshot': return await resolveScreenshotGenericExecution(params); @@ -61,6 +69,12 @@ export async function resolveGenericRuntimeExecution( inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, }); + case 'app-switcher': + return await resolveBoundAppSwitcherRuntime({ + device: params.session.device, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); case 'orientation': return await resolveBoundOrientationRuntime({ device: params.session.device, @@ -77,6 +91,13 @@ export async function resolveGenericRuntimeExecution( bindDevice: params.bindDevice, }); default: - return undefined; + return { + ok: false, + response: errorResponse( + 'COMMAND_FAILED', + `${params.req.command} has no runtime execution on the generic route`, + { reason: 'generic-route-runtime-missing' }, + ), + }; } } diff --git a/src/daemon/handlers/__tests__/find-single-bind.test.ts b/src/daemon/handlers/__tests__/find-single-bind.test.ts index f266c90324..5f06ae4329 100644 --- a/src/daemon/handlers/__tests__/find-single-bind.test.ts +++ b/src/daemon/handlers/__tests__/find-single-bind.test.ts @@ -9,17 +9,7 @@ import { runtimeBindingSpies, } from './interaction-get-runtime-fixture.ts'; import { invokeFindHandler } from './find-handler-fixture.ts'; - -const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); - -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: mockDispatch, - resolveTargetDevice: actual.resolveTargetDevice, - }; -}); +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); @@ -28,7 +18,7 @@ vi.mock('../snapshot-interactor-capture.ts', async () => { beforeEach(() => { resetGetRuntimeFixture(); - mockDispatch.mockReset(); + legacyDispatchCapture.mockReset(); }); async function runMutatingFind(positionals: string[], node: Record) { @@ -36,7 +26,7 @@ async function runMutatingFind(positionals: string[], node: Record + legacyDispatchCapture.mockImplementation(async (_device, command) => command === 'snapshot' ? { nodes: [node] } : {}, ); return await invokeFindHandler({ diff --git a/src/daemon/handlers/__tests__/find-touch-runtime-fixture.ts b/src/daemon/handlers/__tests__/find-touch-runtime-fixture.ts index 7c36e805b1..c54fec8306 100644 --- a/src/daemon/handlers/__tests__/find-touch-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/find-touch-runtime-fixture.ts @@ -1,6 +1,5 @@ -import { vi } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { getRuntimeBindings, mockFillPoint, @@ -11,16 +10,20 @@ import { export { mockFocusPoint }; export const findTouchRuntimeBindings = getRuntimeBindings; -export const mockDispatch = vi.mocked(dispatchCommand); +/** + * Find's delegated touch legs record their calls on the shared capture double, so the suite can + * assert which command each leg re-invoked without a dispatcher to observe (R58). + */ +export const mockDispatch = legacyDispatchCapture; export function resetFindTouchRuntimeFixture(): void { resetGetRuntimeFixture(); - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async (_device: unknown, command: string) => { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { return command === 'snapshot' ? { nodes: [] } : {}; }); mockTapPoint.mockImplementation(async (input) => { - return await dispatchCommand( + return await legacyDispatchCapture( IOS_SIMULATOR, 'press', [String(input.point.x), String(input.point.y)], @@ -29,7 +32,7 @@ export function resetFindTouchRuntimeFixture(): void { ); }); mockFillPoint.mockImplementation(async (input) => { - return await dispatchCommand( + return await legacyDispatchCapture( IOS_SIMULATOR, 'fill', [String(input.point.x), String(input.point.y), input.text], diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index 7d1a80a3ab..33e6e25ae4 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -14,9 +14,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async (_device: unknown, command: string) => { - return command === 'snapshot' ? { nodes: [] } : {}; - }), resolveTargetDevice: actual.resolveTargetDevice, }; }); diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index ba148bfab0..b7bce61623 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -383,6 +383,15 @@ function sourceRuntimeFacts( keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, + appSwitcher: unavailable, + triggerAppEvent: unavailable, + setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, deployApp: unavailable, materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, diff --git a/src/daemon/handlers/__tests__/interaction-common.test.ts b/src/daemon/handlers/__tests__/interaction-common.test.ts index 5a4509325d..9095d13953 100644 --- a/src/daemon/handlers/__tests__/interaction-common.test.ts +++ b/src/daemon/handlers/__tests__/interaction-common.test.ts @@ -1,5 +1,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { beforeEach, expect, test } from 'vitest'; import { makeIosSession, makeAuthoringSession, @@ -16,24 +17,14 @@ import { resetGetRuntimeFixture, } from './interaction-get-runtime-fixture.ts'; -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); - -import { dispatchCommand } from '../../../core/dispatch.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const contextFromFlags = (_flags: CommandFlags | undefined) => ({}); beforeEach(() => { resetGetRuntimeFixture(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockFillPoint.mockImplementation(async (input) => { - return await mockDispatch( + return await legacyDispatchCapture( IOS_SIMULATOR, 'fill', [String(input.point.x), String(input.point.y), input.text], @@ -159,7 +150,7 @@ test('parameterized fill scrubs concatenated backend values and object keys thro backend: 'xctest', }; sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => + legacyDispatchCapture.mockImplementation(async (_device, command) => command === 'fill' ? { message: `prefix${secret}suffix`, @@ -194,8 +185,8 @@ test('parameterized fill scrubs concatenated backend values and object keys thro expect(response.data).not.toHaveProperty(`prefix${placeholder}suffix`); expect(JSON.stringify(response.data)).not.toContain(secret); expect(JSON.stringify(session.actions)).not.toContain(secret); - expect(mockDispatch.mock.calls[0]?.[1]).toBe('fill'); - expect(mockDispatch.mock.calls[0]?.[2]).toContain(secret); + expect(legacyDispatchCapture.mock.calls[0]?.[1]).toBe('fill'); + expect(legacyDispatchCapture.mock.calls[0]?.[2]).toContain(secret); }); test('parameterized fill collapses whitespace-only backend echoes through the handler route', async () => { @@ -220,7 +211,7 @@ test('parameterized fill collapses whitespace-only backend echoes through the ha backend: 'xctest', }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ [`prefix${secret}suffix`]: { [`key${secret}tail`]: `value${secret}tail`, }, @@ -253,7 +244,7 @@ test('parameterized fill collapses whitespace-only backend echoes through the ha expect(session.actions[0]?.result).not.toHaveProperty(placeholder); expect(session.actions[0]?.result?.selectorChain).not.toContain(`value="prefix${secret}suffix"`); expect(JSON.stringify(session.actions)).not.toContain(secret); - expect(mockDispatch.mock.calls[0]?.[2]).toContain(secret); + expect(legacyDispatchCapture.mock.calls[0]?.[2]).toContain(secret); }); test.each([ diff --git a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts index b742438900..c07fa650d6 100644 --- a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts @@ -170,6 +170,15 @@ function elementReadFacts(device: DeviceInfo): RuntimeFacts { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); - vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); return { @@ -46,7 +38,6 @@ vi.mock('../snapshot-interactor-capture.ts', async () => { }; }); -const mockDispatch = vi.mocked(dispatchCommand); const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); const contextFromFlags = (flags: CommandFlags | undefined) => ({ @@ -95,9 +86,9 @@ async function runClick( beforeEach(() => { resetGetRuntimeFixture(); mockCaptureSnapshotForSession.mockClear(); - mockDispatch.mockReset(); + legacyDispatchCapture.mockReset(); mockTapPoint.mockImplementation(async (input) => { - return await mockDispatch( + return await legacyDispatchCapture( IOS_SIMULATOR, 'press', [String(input.point.x), String(input.point.y)], @@ -115,7 +106,7 @@ test('a changed post-action capture corroborates an iOS tap reported as failed', snapshot: snapshot(profileNodes), }); sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -133,7 +124,7 @@ test('a changed post-action capture corroborates an iOS tap reported as failed', expect(response.data?.warning).toMatch(/post-action accessibility capture changed/); expect(response.data?.selector).toBe('id="unfollow"'); } - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'press')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'press')).toHaveLength(1); expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); @@ -145,7 +136,7 @@ test('an unchanged post-action capture keeps a failed iOS tap failed', async () snapshot: snapshot(profileNodes), }); sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -178,19 +169,21 @@ test('a private-ax baseline pins the corroboration probe to private-ax', async ( }), ); const snapshotContexts: Array | undefined> = []; - mockDispatch.mockImplementation(async (_device, command, _positionals, _outPath, context) => { - if (command === 'press') { - throw new AppError( - 'XCTEST_RECORDED_FAILURE', - 'XCTest recorded a failure while executing tap; the action may not have been performed.', - ); - } - if (command === 'snapshot') { - snapshotContexts.push(context as Record | undefined); - return snapshotPayload(imageViewerNodes, 'private-ax'); - } - return {}; - }); + legacyDispatchCapture.mockImplementation( + async (_device, command, _positionals, _outPath, context) => { + if (command === 'press') { + throw new AppError( + 'XCTEST_RECORDED_FAILURE', + 'XCTest recorded a failure while executing tap; the action may not have been performed.', + ); + } + if (command === 'snapshot') { + snapshotContexts.push(context as Record | undefined); + return snapshotPayload(imageViewerNodes, 'private-ax'); + } + return {}; + }, + ); const response = await runClick(sessionStore, sessionName); @@ -216,7 +209,7 @@ test('a canonical selector capture replaces a raw baseline before corroboration' snapshot: snapshot(profileNodes, 'private-ax', { raw: true }), }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -230,7 +223,7 @@ test('a canonical selector capture replaces a raw baseline before corroboration' const response = await runClick(sessionStore, sessionName); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); }); test('a tree baseline does not pin the corroboration probe backend', async () => { @@ -244,19 +237,21 @@ test('a tree baseline does not pin the corroboration probe backend', async () => }), ); const snapshotContexts: Array | undefined> = []; - mockDispatch.mockImplementation(async (_device, command, _positionals, _outPath, context) => { - if (command === 'press') { - throw new AppError( - 'XCTEST_RECORDED_FAILURE', - 'XCTest recorded a failure while executing tap; the action may not have been performed.', - ); - } - if (command === 'snapshot') { - snapshotContexts.push(context as Record | undefined); - return snapshotPayload(imageViewerNodes); - } - return {}; - }); + legacyDispatchCapture.mockImplementation( + async (_device, command, _positionals, _outPath, context) => { + if (command === 'press') { + throw new AppError( + 'XCTEST_RECORDED_FAILURE', + 'XCTest recorded a failure while executing tap; the action may not have been performed.', + ); + } + if (command === 'snapshot') { + snapshotContexts.push(context as Record | undefined); + return snapshotPayload(imageViewerNodes); + } + return {}; + }, + ); const response = await runClick(sessionStore, sessionName); @@ -275,7 +270,7 @@ test('a changed capture from a different iOS backend keeps the tap failure', asy snapshot: snapshot(profileNodes), }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -303,7 +298,7 @@ test('a sparse changed capture keeps the tap failure', async () => { snapshot: snapshot(profileNodes), }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -336,7 +331,7 @@ test('a corroboration capture failure keeps the tap failure', async () => { snapshot: snapshot(profileNodes), }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -366,7 +361,7 @@ test('the canonical selector capture aligns presentation before corroboration', snapshot: baseline, }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -396,7 +391,7 @@ test('corroborates a tap when the request carries no flags and the baseline used }), ); let snapshotCount = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -446,7 +441,7 @@ test('a changed capture after ordinary agent turn latency still corroborates the snapshot: baseline, }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -463,7 +458,7 @@ test('a changed capture after ordinary agent turn latency still corroborates the if (response?.ok) { expect(response.data?.warning).toMatch(/post-action accessibility capture changed/); } - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); @@ -479,7 +474,7 @@ test('the canonical selector capture replaces a stale baseline before corroborat snapshot: baseline, }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -493,7 +488,7 @@ test('the canonical selector capture replaces a stale baseline before corroborat const response = await runClick(sessionStore, sessionName); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); @@ -509,7 +504,7 @@ test('the canonical selector capture replaces a keyless baseline before corrobor snapshot: baseline, }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -523,7 +518,7 @@ test('the canonical selector capture replaces a keyless baseline before corrobor const response = await runClick(sessionStore, sessionName); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); @@ -538,7 +533,7 @@ test('runtime-resolved taps use the same corroboration boundary', async () => { }), ); let snapshotCount = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -572,7 +567,7 @@ test('a corroborated runtime coordinate tap does not schedule a no-change retry' }), ); let pressCount = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { pressCount += 1; if (pressCount === 1) { @@ -630,7 +625,7 @@ test('corroborated runtime taps retain target evidence through save and replay', let recording = true; let snapshotCount = 0; let pressCount = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { pressCount += 1; if (recording) { diff --git a/src/daemon/handlers/__tests__/interaction-settle.test.ts b/src/daemon/handlers/__tests__/interaction-settle.test.ts index e77cac4b4d..d547a9d70e 100644 --- a/src/daemon/handlers/__tests__/interaction-settle.test.ts +++ b/src/daemon/handlers/__tests__/interaction-settle.test.ts @@ -1,10 +1,11 @@ import type { CommandFlags } from '@agent-device/contracts/command'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import { handleInteractionCommands } from '../interaction.ts'; import type { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; import type { SnapshotBackend } from '@agent-device/kernel/snapshot'; -import { buildSnapshotState } from '../../snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { setSessionSnapshot } from '../../session-snapshot.ts'; import { activateCompleteRefFrame } from '../../ref-frame.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; @@ -25,14 +26,6 @@ import { // Quiet windows are tuned down (--settle-quiet 25) so no test waits real time // beyond a few poll ticks. -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); - vi.mock('../interaction-snapshot.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -45,9 +38,7 @@ vi.mock('../interaction-snapshot.ts', async (importOriginal) => { }; }); -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotForSession } from '../interaction-snapshot.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); const BEFORE_NODES = [ @@ -86,7 +77,7 @@ async function emulateCaptureSnapshotForSession( options: { interactiveOnly: boolean }, ) { const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; - const snapshotData = (await mockDispatch( + const snapshotData = (await legacyDispatchCapture( session.device, 'snapshot', [], @@ -140,7 +131,7 @@ test('interaction runtime inherits the registered daemon request signal', () => function mockCommandDispatch(params: { snapshots: Array }) { let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { const nodes = params.snapshots[Math.min(snapshotCalls, params.snapshots.length - 1)]; snapshotCalls += 1; @@ -154,10 +145,10 @@ const contextFromFlags = () => ({}); beforeEach(() => { resetGetRuntimeFixture(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockTapPoint.mockImplementation(async (input) => { - return await mockDispatch( + return await legacyDispatchCapture( IOS_SIMULATOR, 'press', [String(input.point.x), String(input.point.y)], @@ -166,7 +157,7 @@ beforeEach(() => { ); }); mockFillPoint.mockImplementation(async (input) => { - return await mockDispatch( + return await legacyDispatchCapture( IOS_SIMULATOR, 'fill', [String(input.point.x), String(input.point.y), input.text], @@ -328,7 +319,7 @@ test('press --settle rejects an expired-frame ref before dispatch or observation // ADR 0014: a device action since the snapshot expired the ref frame. session.refFrameState = 'expired'; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue( + legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for an expired-frame ref'), ); @@ -362,7 +353,7 @@ test('a settle observation without a diff leaves ref staleness untouched', async const sessionName = 'settle-stalled'; seedSession(sessionName, sessionStore); let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { snapshotCalls += 1; if (snapshotCalls === 1) return { nodes: BEFORE_NODES, backend: 'xctest' }; diff --git a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts index 6b9f8c1e06..46fdc4f26b 100644 --- a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts +++ b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts @@ -1,4 +1,5 @@ import type { CommandFlags } from '@agent-device/contracts/command'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -25,14 +26,6 @@ const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn(), })); -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); - vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; @@ -48,14 +41,11 @@ vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOr }); import { getRuntimeBindings, resetGetRuntimeFixture } from './interaction-get-runtime-fixture.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; -const mockDispatch = vi.mocked(dispatchCommand); - const contextFromFlags = (_flags: CommandFlags | undefined) => ({}); beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); resetGetRuntimeFixture(); @@ -194,7 +184,7 @@ test('get text simple iOS id selector while recording skips the direct runner qu const sessionName = 'recording-get-direct-gate'; const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { backend: 'xctest', @@ -235,7 +225,7 @@ test('get text simple iOS id selector while recording skips the direct runner qu expect.objectContaining({ command: 'querySelector' }), expect.anything(), ); - expect(mockDispatch.mock.calls.map((call) => call[1])).toContain('snapshot'); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toContain('snapshot'); const recordedAction = sessionStore.get(sessionName)?.actions[0]; expect(recordedAction?.targetEvidence).toMatchObject({ @@ -333,7 +323,7 @@ test('press on an identity-empty container: container-based daemon response, des // would burn wall-clock time this unit lane must not spend. const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { backend: 'xctest', nodes: IDENTITY_EMPTY_ROW_NODES }; } @@ -366,7 +356,7 @@ test('press on an identity-empty container: container-based daemon response, des // --------------------------------------------------------------------------- function mockSnapshotWithSaveButton() { - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { backend: 'xctest', nodes: SAVE_BUTTON_NODES }; } diff --git a/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts b/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts index 99f2204a1c..89cdb8c0aa 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts @@ -11,7 +11,7 @@ import type { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; import { handleInteractionCommands } from '../interaction.ts'; import { getRuntimeBindings } from './interaction-get-runtime-fixture.ts'; -import { buildSnapshotState } from '../../snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; /** * Shared factories for the interaction touch handler tests. Named pure diff --git a/src/daemon/handlers/__tests__/interaction-type-android-readiness.test.ts b/src/daemon/handlers/__tests__/interaction-type-android-readiness.test.ts index b44442c1de..5b652c193e 100644 --- a/src/daemon/handlers/__tests__/interaction-type-android-readiness.test.ts +++ b/src/daemon/handlers/__tests__/interaction-type-android-readiness.test.ts @@ -11,13 +11,6 @@ import { resetGetRuntimeFixture, } from './interaction-get-runtime-fixture.ts'; -const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); - -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: mockDispatch }; -}); - vi.mock('../../../platforms/android/snapshot.ts', () => ({ snapshotAndroid: vi.fn() })); vi.mock('../../../platforms/android/window-state.ts', async (importOriginal) => { @@ -34,8 +27,6 @@ import { snapshotAndroid } from '../../../platforms/android/snapshot.ts'; beforeEach(() => { resetGetRuntimeFixture(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); vi.mocked(snapshotAndroid).mockReset(); vi.mocked(snapshotAndroid).mockRejectedValue( new AppError( @@ -72,15 +63,9 @@ test('type continues and composes the recording readiness warning', async () => }); expect(response?.ok).toBe(true); - // R41: the text reaches the device through the bound operation, never through dispatch. + // R41: the text reaches the device through the bound operation. That it reaches no other path + // is R58's claim now — there is no dispatcher left for this suite to watch. expect(mockTypeText).toHaveBeenCalledWith(expect.objectContaining({ text: 'hello' })); - expect(mockDispatch).not.toHaveBeenCalledWith( - expect.anything(), - 'type', - expect.anything(), - expect.anything(), - expect.anything(), - ); if (response?.ok) { expect(response.data?.warning).toMatch( /Android blocking-dialog readiness could not be inspected.*command continued/i, diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 2431972016..932082b723 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -1,4 +1,5 @@ import { test, expect, vi, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { WEB_DESKTOP_DEVICE } from '../../../__tests__/test-utils/device-fixtures.ts'; import { @@ -9,7 +10,7 @@ import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts import { expireRefFrame } from '../../ref-frame.ts'; import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING } from '../../session-snapshot.ts'; import { handleInteractionCommands } from '../interaction.ts'; -import { buildSnapshotState } from '../../snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { contextFromFlags, makeSession, @@ -25,14 +26,6 @@ const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn(), })); -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); - vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; @@ -64,8 +57,6 @@ import { mockReadTextAtPoint, resetGetRuntimeFixture, } from './interaction-get-runtime-fixture.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; -const mockDispatch = vi.mocked(dispatchCommand); import { getAndroidAppState, getAndroidBlockingDialogObservation, @@ -73,8 +64,8 @@ import { const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogObservation = vi.mocked(getAndroidBlockingDialogObservation); beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockGetAndroidAppState.mockReset(); mockGetAndroidAppState.mockResolvedValue({}); mockGetAndroidBlockingDialogObservation.mockReset(); @@ -103,7 +94,7 @@ test('get text prefers underlying value for text surfaces and avoids recording g }; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue( + legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); @@ -172,7 +163,7 @@ test('get text uses backend read expansion when the resolved node has a rect', a }); // The live read now reaches the bound runtime operation, not the legacy `read` dispatch. - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(mockReadTextAtPoint).toHaveBeenCalledTimes(1); expect(mockReadTextAtPoint.mock.calls[0]?.[0].point).toEqual({ x: 80, y: 80 }); expect(response?.ok).toBe(true); @@ -219,7 +210,7 @@ test('get text answers from the captured tree when the bound owner advertises no // Preferred-operation absence is not a failure and not a fallback: the required capture path // answers completely, and nothing reaches the legacy dispatcher. expect(mockReadTextAtPoint).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(response?.ok).toBe(true); if (response?.ok) { expect(response.data?.text).toBe('preview only'); @@ -259,7 +250,7 @@ test('an eligible direct iOS selector cannot operate before admission', async () if (response && !response.ok) expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); // The whole point: the fast path never ran. expect(mockRunAppleRunnerCommand).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); // The direct-iOS shortcut is RETIRED (#1739): `get` declares `device-runtime`, so a simple @@ -270,7 +261,7 @@ test('get text simple iOS id selector resolves through the bound capture, not a const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-direct-selector'; sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', nodes: [ { @@ -327,7 +318,7 @@ test('get text iOS label selector uses snapshot disambiguation instead of runner const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-label-selector'; sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', nodes: [ { @@ -387,8 +378,8 @@ test('get text iOS label selector uses snapshot disambiguation instead of runner expect(response?.ok).toBe(true); expect(mockRunAppleRunnerCommand).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch.mock.calls[0]?.[1]).toBe('snapshot'); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(1); + expect(legacyDispatchCapture.mock.calls[0]?.[1]).toBe('snapshot'); if (response?.ok) { expect(response.data?.text).toBe('General'); expect(response.data?.selector).toBe('label="General"'); @@ -400,7 +391,7 @@ test('is visible preserves CLI snapshot flags during runtime snapshot capture', const sessionName = 'snapshot-flags'; sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); return { nodes: [ @@ -443,7 +434,7 @@ test('is visible preserves CLI snapshot flags during runtime snapshot capture', }); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls[0]?.[4]).toMatchObject({ + expect(legacyDispatchCapture.mock.calls[0]?.[4]).toMatchObject({ snapshotDepth: 2, snapshotScope: 'Login', snapshotRaw: true, @@ -458,7 +449,7 @@ test('is visible reuses fresh cached iOS snapshots with rects', async () => { const session = makeSession(sessionName); session.snapshot = makeVisibleButtonSnapshot('Cached action', 'xctest'); sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('unexpected fresh snapshot')); + legacyDispatchCapture.mockRejectedValue(new Error('unexpected fresh snapshot')); const response = await handleInteractionCommands({ req: { @@ -475,7 +466,7 @@ test('is visible reuses fresh cached iOS snapshots with rects', async () => { }); expect(response?.ok).toBe(true); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('is visible recaptures web snapshots when cached nodes may lack rects', async () => { @@ -491,7 +482,7 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy { snapshotInteractiveOnly: false }, ); sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue(makeVisibleButtonSnapshot('Submit order', 'web')); + legacyDispatchCapture.mockResolvedValue(makeVisibleButtonSnapshot('Submit order', 'web')); const response = await handleInteractionCommands({ req: { @@ -508,7 +499,7 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy }); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls[0]?.[4]).toMatchObject({ + expect(legacyDispatchCapture.mock.calls[0]?.[4]).toMatchObject({ snapshotIncludeRects: true, }); }); @@ -556,7 +547,7 @@ test('a failing is predicate is COMMAND_FAILED, never a zero-exit pass', async ( // The session snapshot has no `id=submit`, so the bound capture reports the typed selector // failure. Nothing can report a failed assertion as a completed command. expect(response?.ok).toBe(false); - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); if (response?.ok === false) { expect(response.error?.code).toBe('COMMAND_FAILED'); } @@ -567,7 +558,7 @@ test('is visible passes for list text that inherits viewport visibility from an const sessionName = 'visible-list-item'; sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); return { nodes: [ @@ -619,7 +610,7 @@ test('is visible fails for nodes outside the current viewport', async () => { const sessionName = 'visible-offscreen'; sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); return { nodes: [ @@ -667,7 +658,7 @@ test('is reports Android permission dialog blocker when app content assertion fa makeBaseAndroidSession(sessionName, { appBundleId: 'com.example.demo' }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); return { nodes: [], backend: 'uiautomator' }; }); @@ -726,7 +717,7 @@ test('ADR 0014 evidence #17: get text @ref reads the retained frame tree, not a backend: 'xctest', }); sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('get text @ref must not recapture')); + legacyDispatchCapture.mockRejectedValue(new Error('get text @ref must not recapture')); // Resolves against the frame tree's @e2 (Continue), never the observation's // positional @e2 (Different) — no fall-through by positional coincidence. @@ -745,7 +736,7 @@ test('ADR 0014 evidence #17: get text @ref reads the retained frame tree, not a expect(missing.error.code).toBe('COMMAND_FAILED'); expect(missing.error.message).toMatch(/not found/i); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('get text @ref warns while the frame is expired (retained evidence still resolves)', async () => { @@ -756,7 +747,7 @@ test('get text @ref warns while the frame is expired (retained evidence still re // retained frame tree and stays fail-open with a warning (ADR 0014). expireRefFrame(session); sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue( + legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); @@ -774,7 +765,7 @@ test('get text with a pinned stale ref gets the precise warning', async () => { const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 4; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue( + legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index 50db0e96e2..c27be4725c 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -2,30 +2,30 @@ import { beforeEach, expect, test, vi } from 'vitest'; import path from 'node:path'; import { handleReactNativeCommands } from '../react-native.ts'; import { captureSnapshot } from '../snapshot-capture.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { + getRuntimeBindings, + mockTapPoint, + resetGetRuntimeFixture, +} from './interaction-get-runtime-fixture.ts'; vi.mock('../snapshot-capture.ts', () => ({ captureSnapshot: vi.fn(), })); -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({ x: 379, y: 820 })), - }; -}); - const mockCaptureSnapshot = vi.mocked(captureSnapshot); -const mockDispatchCommand = vi.mocked(dispatchCommand); +/** + * R58: overlay dismissal taps through the same bound `tapPoint` every other touch leaf uses, so + * this suite watches the bound operation rather than a dispatcher. The recorded point is the + * whole assertion — the coordinate the overlay heuristic picked. + */ +const mockDismissTap = mockTapPoint; beforeEach(() => { mockCaptureSnapshot.mockReset(); - mockDispatchCommand.mockReset(); - mockDispatchCommand.mockResolvedValue({ x: 379, y: 820 }); + resetGetRuntimeFixture(); }); test('react-native dismiss-overlay taps collapsed warning close affordance instead of banner center', async () => { @@ -73,18 +73,15 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); // ADR 0014 side-effect seam: overlay dismissal taps the device, so it expires // the ref frame. expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['379', '820'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 379, y: 820 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -100,7 +97,6 @@ test('react-native dismiss-overlay prefers non-trailing collapsed warning close const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 27, y: 820 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -135,15 +131,12 @@ test('react-native dismiss-overlay prefers non-trailing collapsed warning close logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['27', '820'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 27, y: 820 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -158,7 +151,6 @@ test('react-native dismiss-overlay does not confuse app dismiss buttons with ove const sessionName = 'rn-collapsed-with-app-dismiss-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 379, y: 820 }); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -207,15 +199,12 @@ test('react-native dismiss-overlay does not confuse app dismiss buttons with ove logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['369', '813'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 369, y: 813 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -260,10 +249,11 @@ test('react-native dismiss-overlay rejects unsafe collapsed warning coordinate f logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(false); - expect(mockDispatchCommand).not.toHaveBeenCalled(); + expect(mockDismissTap).not.toHaveBeenCalled(); expect(!response?.ok && response?.error).toMatchObject({ code: 'COMMAND_FAILED', details: { @@ -276,7 +266,6 @@ test('react-native dismiss-overlay dismisses RedBox error overlays instead of mi const sessionName = 'rn-redbox-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 95, y: 752 }); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -322,15 +311,12 @@ test('react-native dismiss-overlay dismisses RedBox error overlays instead of mi logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['95', '752'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 95, y: 752 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -350,7 +336,6 @@ test('react-native dismiss-overlay reports unverified dismiss when RedBox contro const sessionName = 'rn-redbox-still-full-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 95, y: 752 }); const fullRedBoxSnapshot = { snapshot: { nodes: [ @@ -392,6 +377,7 @@ test('react-native dismiss-overlay reports unverified dismiss when RedBox contro logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -411,7 +397,6 @@ test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', const sessionName = 'rn-redbox-dismiss-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 95, y: 752 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -444,15 +429,12 @@ test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['95', '752'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 95, y: 752 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -466,7 +448,6 @@ test('react-native dismiss-overlay accepts RedBox control labels with keyboard s const sessionName = 'rn-redbox-shortcut-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 70, y: 722 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -499,15 +480,12 @@ test('react-native dismiss-overlay accepts RedBox control labels with keyboard s logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['70', '722'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 70, y: 722 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -521,7 +499,6 @@ test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled const sessionName = 'rn-redbox-wrapper-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 201, y: 827 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -562,6 +539,7 @@ test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -578,7 +556,6 @@ test('react-native dismiss-overlay reports verified success after a clean post-d const sessionName = 'rn-verify-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName, 'android')); - mockDispatchCommand.mockResolvedValue({ x: 105, y: 714 }); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -625,6 +602,7 @@ test('react-native dismiss-overlay reports verified success after a clean post-d logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -688,11 +666,12 @@ test('react-native dismiss-overlay reports sparse verdict instead of no overlay logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(false); expect(session.snapshot).toBe(previousSnapshot); - expect(mockDispatchCommand).not.toHaveBeenCalled(); + expect(mockDismissTap).not.toHaveBeenCalled(); expect(!response?.ok && response?.error).toMatchObject({ code: 'COMMAND_FAILED', message: @@ -708,7 +687,6 @@ test('react-native dismiss-overlay reports unverified dismiss when post-dismiss const sessionName = 'rn-verify-sparse-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 105, y: 714 }); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -760,6 +738,7 @@ test('react-native dismiss-overlay reports unverified dismiss when post-dismiss logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -776,7 +755,6 @@ test('react-native dismiss-overlay reports still-visible overlays with recovery const sessionName = 'rn-verify-still-visible-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName, 'android')); - mockDispatchCommand.mockResolvedValue({ x: 105, y: 714 }); const overlaySnapshot = { snapshot: { nodes: [ @@ -810,6 +788,7 @@ test('react-native dismiss-overlay reports still-visible overlays with recovery logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -852,10 +831,11 @@ test('react-native dismiss-overlay ignores app copy that only mentions RN overla logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).not.toHaveBeenCalled(); + expect(mockDismissTap).not.toHaveBeenCalled(); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', detected: false, diff --git a/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts b/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts index 6e864f0ec6..8ef82309ba 100644 --- a/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts @@ -8,10 +8,7 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../request-runtime-binding.ts'; -import { - createCapabilitiesAdmissionRuntime, - legacyCapabilityUses, -} from './session-capabilities.fixtures.ts'; +import { createCapabilitiesAdmissionRuntime } from './session-capabilities.fixtures.ts'; import { handleSessionCommands } from './session-command-harness.ts'; test('capabilities projects the install family from exactly one facts inspection', async () => { @@ -44,10 +41,12 @@ test('capabilities projects the install family from exactly one facts inspection ]), ); expect(runtime.inspections).toHaveLength(1); - expect(runtime.uses).toEqual(legacyCapabilityUses); + // ADR 0019 §6: `capabilities` declares `platformExecution: none`, so it binds nothing. R63 + // retired the three empty-`required` probes that used to answer `logs`/`network`/`record`. + expect(runtime.uses).toEqual([]); }); -test('capabilities keeps legacy runtime commands outside the install-family facts projection', async () => { +test('capabilities projects every fact-owned command from that same inspection', async () => { const { sessionName, sessionStore } = createAndroidCapabilitiesSession('legacy-runtime'); const runtime = createCapabilitiesAdmissionRuntime({ appLogAvailable: true, @@ -68,14 +67,15 @@ test('capabilities keeps legacy runtime commands outside the install-family fact expect(response).toMatchObject({ ok: true }); if (!response?.ok) return; - // `bootTarget` facts are unavailable in this fixture. A global descriptor projection - // would hide boot; only this unit's install family is authorized to consume facts. - expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.boot); + // R63 made the projection global: it reads every migrated command's declared uses, so + // `bootTarget`/`bootTargetHeadless` being unavailable in this fixture now hides `boot` — where + // this test previously pinned the opposite, because only the install family consumed facts. + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.boot); expect(runtime.inspections).toHaveLength(1); - expect(runtime.uses).toEqual(legacyCapabilityUses); + expect(runtime.uses).toEqual([]); }); -test('capabilities fails closed only for install-family projection when facts inspection fails', async () => { +test('capabilities fails closed for every fact-owned command when facts inspection fails', async () => { const { sessionName, sessionStore } = createAndroidCapabilitiesSession('facts-failure'); const runtime = createCapabilitiesAdmissionRuntime({ appLogAvailable: true, @@ -94,13 +94,17 @@ test('capabilities fails closed only for install-family projection when facts in expect(response).toMatchObject({ ok: true }); if (!response?.ok) return; - expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.boot); - expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.logs); + // R63: with no facts at all, every fact-owned command fails closed rather than being advertised + // on faith — `logs` included. It used to survive on a second `bindDevice` probe, but every owner + // composes a binding's facts from the same inspection that just failed, so that probe was + // answering from a path that cannot outlive the inspection it duplicates. + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.boot); + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.logs); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.install); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.reinstall); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.installFromSource); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.push); - expect(runtime.uses).toEqual(legacyCapabilityUses); + expect(runtime.uses).toEqual([]); }); function createAndroidCapabilitiesSession(suffix: string) { diff --git a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts index e1361fd050..cc8540de0f 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts @@ -35,12 +35,6 @@ export type CapabilitiesAdmissionRuntimeOptions = Readonly<{ screenshotAvailable?: boolean; }>; -export const legacyCapabilityUses = [ - { required: [], preferred: ['appLogInspect'] }, - { required: [], preferred: ['networkDump'] }, - { required: [], preferred: ['screenRecordingStart'] }, -]; - export function createCapabilitiesAdmissionRuntime(options: CapabilitiesAdmissionRuntimeOptions) { const uses: Array<{ required: readonly string[]; @@ -101,6 +95,15 @@ function createAdmissionFacts( keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, + appSwitcher: unavailable, + triggerAppEvent: unavailable, + setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, ...touchRuntimeOperationFacts({ tap: unavailable, longPress: unavailable, diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index e30e105e0e..1563e9934c 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -28,15 +28,21 @@ import type { } from '../../request-runtime-binding.ts'; import { handleSessionCommands } from './session-command-harness.ts'; +/** The system leaves this owner refuses: the retired fallback listed them unconditionally. */ +const ANDROID_REFUSED_SYSTEM_COMMANDS = ['clipboard', 'alert', 'settings', 'app-switcher']; + function assertAndroidCapabilityHonesty(availableCommands: unknown): void { + for (const command of ANDROID_REFUSED_SYSTEM_COMMANDS) { + expect(availableCommands).not.toContain(command); + } expect(availableCommands).toContain(PUBLIC_COMMANDS.open); expect(availableCommands).toContain(PUBLIC_COMMANDS.close); expect(availableCommands).not.toContain(PUBLIC_COMMANDS.prepare); expect(availableCommands).not.toContain(PUBLIC_COMMANDS.viewport); } -test('capabilities reports supported commands for the selected session device', async () => { - const sessionName = 'android-capabilities'; +/** The one interaction-capable Android owner both projection tests below read. */ +async function projectAndroidCapabilities(sessionName: string) { const sessionStore = makeSessionStore('agent-device-capabilities-'); sessionStore.set(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ @@ -44,9 +50,9 @@ test('capabilities reports supported commands for the selected session device', ensureReadyAvailable: true, networkAvailable: true, appsAvailable: true, + interactionAvailable: true, providerMode: 'local', }); - const response = await handleSessionCommands({ req: { token: 't', @@ -62,15 +68,21 @@ test('capabilities reports supported commands for the selected session device', bindDevice: runtime.bindDevice, invoke: async () => ({ ok: true, data: {} }), }); - expect(response?.ok).toBe(true); - if (!response?.ok) return; + const data = response?.ok ? response.data : undefined; + return { runtime, device: data?.device, availableCommands: data?.availableCommands }; +} - expect(response.data?.device).toMatchObject({ - platform: 'android', - kind: 'emulator', - }); - expect(response.data?.availableCommands).toEqual( +test('capabilities reports supported commands for the selected session device', async () => { + const { runtime, device, availableCommands } = + await projectAndroidCapabilities('android-capabilities'); + + expect(device).toMatchObject({ platform: 'android', kind: 'emulator' }); + // R63: every one of these comes from a declared use this owner's facts admit — the + // `interactionAvailable` cells are what put `snapshot`, `press`, `fill` and `gesture` in the + // list, and dropping them drops the commands (proved by the stopped-AVD case below). + // `react-native` rides the same admitted `tapPoint`, so it is listed here too. + expect(availableCommands).toEqual( expect.arrayContaining([ 'open', 'screenshot', @@ -83,17 +95,21 @@ test('capabilities reports supported commands for the selected session device', 'perf', PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.reactNative, ]), ); - expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities); - expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.devices); - assertAndroidCapabilityHonesty(response.data?.availableCommands); expect(runtime.inspections).toHaveLength(1); - expect(runtime.uses).toEqual([ - { required: [], preferred: ['appLogInspect'] }, - { required: [], preferred: ['networkDump'] }, - { required: [], preferred: ['screenRecordingStart'] }, - ]); + // ADR 0019 §6: `capabilities` is a `none` descriptor, so the projection binds no device — one + // side-effect-free inspection answers every command, `logs`/`network`/`record` included. + expect(runtime.uses).toEqual([]); +}); + +test('capabilities omits the commands this Android owner does not admit', async () => { + const { availableCommands } = await projectAndroidCapabilities('android-capabilities-honesty'); + + expect(availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities); + expect(availableCommands).not.toContain(PUBLIC_COMMANDS.devices); + assertAndroidCapabilityHonesty(availableCommands); }); test('capabilities excludes logs from an unavailable provider-mode XCTest runtime fact', async () => { @@ -146,11 +162,9 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim expect(availableCommands).not.toContain(PUBLIC_COMMANDS.prepare); expect(availableCommands).not.toContain(PUBLIC_COMMANDS.shutdown); expect(runtime.inspections).toHaveLength(1); - expect(runtime.uses).toEqual([ - { required: [], preferred: ['appLogInspect'] }, - { required: [], preferred: ['networkDump'] }, - { required: [], preferred: ['screenRecordingStart'] }, - ]); + // ADR 0019 §6: `capabilities` is a `none` descriptor, so the projection binds no device — one + // side-effect-free inspection answers every command, `logs`/`network`/`record` included. + expect(runtime.uses).toEqual([]); }); test('capabilities excludes network when the runtime fact is unavailable', async () => { @@ -488,21 +502,20 @@ test('capabilities accepts a stopped Android AVD placeholder for explicit platfo kind: 'emulator', booted: false, }); + // R63: the projection reads each migrated command's declared uses, so a STOPPED AVD no longer + // advertises the interaction commands it cannot run. `open`/`screenshot` stay because the + // Android owner admits them on a placeholder (it boots the AVD first); `snapshot`, `press` and + // `fill` need a live adb connection this device does not have. Before this unit the projection + // read "no capability bucket" as "supported everywhere" and listed all three. expect(response.data?.availableCommands).toEqual( - expect.arrayContaining(['open', 'screenshot', 'snapshot', 'press', 'fill']), + expect.arrayContaining(['open', 'screenshot', PUBLIC_COMMANDS.shutdown]), ); - expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.shutdown); + for (const command of ['snapshot', 'press', 'fill']) { + expect(response.data?.availableCommands).not.toContain(command); + } }); -function createAdmissionRuntime(options: { - appLogAvailable: boolean; - appStateAvailable?: boolean; - ensureReadyAvailable?: boolean; - networkAvailable: boolean; - appsAvailable?: boolean; - screenshotAvailable?: boolean; - providerMode: RuntimeProviderMode; -}) { +function createAdmissionRuntime(options: AdmissionRuntimeOptions) { const uses: Array<{ required: readonly string[]; preferred: readonly string[]; @@ -532,6 +545,13 @@ type AdmissionRuntimeOptions = Readonly<{ appsAvailable?: boolean; /** `screenshot` is fact-owned since R39; the projection reads this cell, not a bucket. */ screenshotAvailable?: boolean; + /** + * R63 made the projection read every migrated command's declared uses, so a fixture that leaves + * the interaction cells unavailable now correctly answers that `snapshot`, `press`, `fill` and + * `gesture` are NOT available — where the retired fallback used to list all four unconditionally. + * Opt in to model an owner that can actually drive them. + */ + interactionAvailable?: boolean; providerMode: RuntimeProviderMode; }>; @@ -592,11 +612,16 @@ function createAdmissionOperationFacts( appsFact: ReturnType, lifecycleAvailable: boolean, ) { + const interaction = options.interactionAvailable ? ({ available: true } as const) : unavailable; return { ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...screenshotRuntimeOperationFacts({ capture: options.screenshotAvailable === false ? unavailable : { available: true as const }, }), + captureSnapshot: interaction, + tapPoint: interaction, + fillPoint: interaction, + performGesturePlan: interaction, appLogInspect: options.appLogAvailable ? { available: true as const } : unavailable, appLogDoctor: unavailable, appLogStart: unavailable, diff --git a/src/daemon/handlers/__tests__/session-clipboard.test.ts b/src/daemon/handlers/__tests__/session-clipboard.test.ts new file mode 100644 index 0000000000..e22c3018e0 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-clipboard.test.ts @@ -0,0 +1,197 @@ +import { expect, test, vi } from 'vitest'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + clipboardReadUse, + clipboardWriteUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../../request-runtime-binding.ts'; +import { makeSession, makeSessionStore, mockResolveTargetDevice } from './session-test-harness.ts'; +import { handleSessionClipboardCommand } from '../session-clipboard.ts'; + +// File-scoped id, not a shared literal: this owner binding's `local-family` kind reaches the real +// on-disk device-claim admission (`require-owner` policy), so a shared id risks a cross-file claim +// collision under parallel test-file execution. +const androidDevice: DeviceInfo = { + id: 'clipboard-runtime-5554', + name: 'Pixel', + platform: 'android', + kind: 'emulator', + target: 'mobile', + booted: true, +}; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf' as const, + hint: 'clipboard is supported on Apple simulators and the macOS host, not on physical devices of this OS.', +}); + +function harness( + facts: Readonly<{ read: RuntimeOperationFact; write: RuntimeOperationFact }>, + device: DeviceInfo = androidDevice, +) { + const readClipboard = vi.fn(async () => 'copied text'); + const writeClipboard = vi.fn(async () => undefined); + const runtimeFacts: RuntimeFacts = { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: clipboardRuntimeOperationFacts( + facts, + ) as RuntimeFacts['operations'], + }; + const binding = { + device, + owner: localRuntimeOwner(device.platform as never), + facts: runtimeFacts, + operations: { readClipboard, writeClipboard }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => runtimeFacts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + return { readClipboard, writeClipboard, inspectFacts, bindDevice }; +} + +function request(positionals: string[]) { + const sessionName = 'clipboard-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + mockResolveTargetDevice.mockResolvedValue(androidDevice); + return { + sessionName, + sessionStore, + req: { + token: 't', + session: sessionName, + command: 'clipboard', + positionals, + flags: {}, + }, + logPath: '/tmp/daemon.log', + } as const; +} + +test('clipboard read admits clipboardReadUse and reports the platform-labelled text', async () => { + const spies = harness({ read: available, write: available }); + const response = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + + expect(response.ok).toBe(true); + expect(response.ok && response.data).toEqual({ + platform: 'android', + action: 'read', + text: 'copied text', + }); + expect(spies.bindDevice).toHaveBeenCalledWith(androidDevice, clipboardReadUse); + expect(spies.writeClipboard).not.toHaveBeenCalled(); +}); + +test('clipboard write joins its positionals and reports the code-point length', async () => { + const spies = harness({ read: available, write: available }); + const response = await handleSessionClipboardCommand({ + ...request(['write', 'hello', 'wörld']), + ...spies, + }); + + expect(response.ok).toBe(true); + expect(spies.writeClipboard).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello wörld' }), + ); + expect(response.ok && response.data).toMatchObject({ + platform: 'android', + action: 'write', + textLength: 11, + }); + expect(spies.bindDevice).toHaveBeenCalledWith(androidDevice, clipboardWriteUse); + expect(spies.readClipboard).not.toHaveBeenCalled(); +}); + +// `clipboard write ""` clears the clipboard, so an empty string is a value the command must +// forward — not the missing argument the length check would otherwise reject. +test('clipboard write forwards an explicit empty string as a clear', async () => { + const spies = harness({ read: available, write: available }); + const response = await handleSessionClipboardCommand({ ...request(['write', '']), ...spies }); + + expect(response.ok).toBe(true); + expect(spies.writeClipboard).toHaveBeenCalledWith(expect.objectContaining({ text: '' })); + expect(response.ok && response.data).toMatchObject({ action: 'write', textLength: 0 }); +}); + +// ADR 0019 §9: the parsed subcommand selects exactly one use, so a request inspects facts once +// and binds once — never both halves, and never a bind per operation. +test.each([{ positionals: ['read'] }, { positionals: ['write', 'text'] }])( + 'clipboard $positionals.0 inspects facts once and binds once', + async ({ positionals }) => { + const spies = harness({ read: available, write: available }); + await handleSessionClipboardCommand({ ...request(positionals), ...spies }); + + expect(spies.inspectFacts).toHaveBeenCalledTimes(1); + expect(spies.bindDevice).toHaveBeenCalledTimes(1); + }, +); + +test('an unadmitted cell refuses with the retired capability gate wording and its owner hint', async () => { + const spies = harness({ read: unavailable, write: unavailable }); + const response = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toBe('clipboard is not supported on this device'); + expect(response.error.hint).toBe(unavailable.hint); + } + expect(spies.bindDevice).not.toHaveBeenCalled(); +}); + +// Read and write are separate cells: a write-only refusal must not take the read down with it. +test('a write-only refusal still admits the read', async () => { + const spies = harness({ read: available, write: unavailable }); + + const write = await handleSessionClipboardCommand({ + ...request(['write', 'text']), + ...spies, + }); + expect(write.ok).toBe(false); + + const read = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + expect(read.ok).toBe(true); +}); + +test('an unknown subcommand fails before any device is resolved', async () => { + const spies = harness({ read: available, write: available }); + const response = await handleSessionClipboardCommand({ ...request(['paste']), ...spies }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toBe('clipboard requires a subcommand: read or write'); + } + expect(spies.inspectFacts).not.toHaveBeenCalled(); +}); + +// The retired leaf validated argument counts inside `dispatchCommand`, downstream of admission, +// so an over-argued read on an admitted device still reaches that same rejection. +test('clipboard read rejects extra arguments', async () => { + const spies = harness({ read: available, write: available }); + await expect( + handleSessionClipboardCommand({ ...request(['read', 'extra']), ...spies }), + ).rejects.toThrow('clipboard read does not accept additional arguments'); +}); + +test('clipboard write with no text argument reports how to clear instead', async () => { + const spies = harness({ read: available, write: available }); + await expect(handleSessionClipboardCommand({ ...request(['write']), ...spies })).rejects.toThrow( + 'clipboard write requires text (use "" to clear clipboard)', + ); +}); diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts b/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts index 8a4edf46a5..7fdbee3618 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts @@ -44,11 +44,6 @@ vi.mock('../../../utils/video.ts', () => ({ waitForStableFile: vi.fn(async () => {}), isPlayableVideo: vi.fn(async () => true), })); -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - import { handleSessionCommands, mockBindDeviceRuntime, diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index e01ca666c7..a3de048cb1 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -175,6 +175,15 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts { const resolveTargetDevice = vi.fn(); return { ...actual, - dispatchCommand: vi.fn(), resolveTargetDevice, resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(resolveTargetDevice)), }; diff --git a/src/daemon/handlers/__tests__/session-open-execution-runtime.test.ts b/src/daemon/handlers/__tests__/session-open-execution-runtime.test.ts index 908c026fde..55d6dc8f58 100644 --- a/src/daemon/handlers/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-open-execution-runtime.test.ts @@ -12,7 +12,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { await import('../../__tests__/device-selection-stub.ts'); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: mockResolveTargetDevice, resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)), }; diff --git a/src/daemon/handlers/__tests__/session-open-runtime.test.ts b/src/daemon/handlers/__tests__/session-open-runtime.test.ts index 579d0b59b3..d2cd499944 100644 --- a/src/daemon/handlers/__tests__/session-open-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-open-runtime.test.ts @@ -10,7 +10,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { await import('../../__tests__/device-selection-stub.ts'); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: mockResolveTargetDevice, resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)), }; diff --git a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts b/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts index 310dace98f..d774210a5f 100644 --- a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts @@ -24,7 +24,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -34,7 +34,6 @@ import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { @@ -45,9 +44,12 @@ import { bottomTabsRealCaptureFixture, recordArticleEvidence, } from './session-replay-target-classification-fixtures.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts index bfd3d30f02..3e75391985 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; import { ANDROID_QS_SHADE_CAPTURE_RAW_NODES, @@ -10,19 +9,22 @@ import { import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; import { SessionStore } from '../../session-store.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { buildReplayFailureDivergence } from '../session-replay-divergence.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts index c6299d8392..a1dd2e2950 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts @@ -3,15 +3,10 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; @@ -22,9 +17,12 @@ import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../session- import { SessionStore } from '../../session-store.ts'; import { captureDivergenceObservation } from '../session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from '../session-replay-divergence-publication.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index 6dd247672a..9e32247b4e 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -4,7 +4,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -23,7 +23,6 @@ vi.mock('../../../utils/timeouts.ts', async (importOriginal) => { return { ...actual, sleep: vi.fn(async () => {}) }; }); -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -40,15 +39,15 @@ import { buildReplayFailureDivergence, captureDivergenceObservation, } from '../session-replay-divergence.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + legacyDispatchCapture, + resetLegacySnapshotCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { - mockDispatchCommand.mockReset(); - mockDispatchCommand.mockResolvedValue({}); - mockCaptureSnapshotWithInteractor.mockReset(); - mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); + resetLegacySnapshotCapture(mockCaptureSnapshotWithInteractor); }); test('buildReplayFailureDivergence dedupes suggestions using the strongest basis', async () => { diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts index 8547ef5490..cb29dc598f 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts @@ -4,7 +4,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -25,14 +25,16 @@ import { } from '../session-replay-maestro-failure.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts index 043bcc36c0..6d58eebcdd 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts @@ -11,7 +11,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -21,7 +21,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import type { DaemonRequest } from '../../types.ts'; @@ -31,9 +30,12 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts index 018038fd67..aadd41810b 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts @@ -27,7 +27,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -37,9 +37,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { repairSessionBoundary } from '../../session-replay-transaction.ts'; import type { SessionState } from '../../types.ts'; @@ -61,7 +63,7 @@ function sessionRepairStatus(session: SessionState | undefined) { : undefined; } -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts index 2a7e025884..9100b9c49d 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts @@ -15,7 +15,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -24,7 +24,6 @@ vi.mock('../snapshot-interactor-capture.ts', () => ({ import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, @@ -38,7 +37,10 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; /** Repair-transaction status, or `undefined` outside a repair publication. */ function sessionRepairStatus(session: SessionState | undefined) { @@ -47,7 +49,7 @@ function sessionRepairStatus(session: SessionState | undefined) { : undefined; } -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts index d9700d5c65..c0f136adbe 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts @@ -46,7 +46,7 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => }); vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -57,7 +57,6 @@ import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { handleCloseCommand as handleProductionCloseCommand } from '../session-close.ts'; import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, @@ -73,9 +72,12 @@ import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, } from './application-lifecycle-runtime-harness.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); function handleCloseCommand( diff --git a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts index c3816e2d77..805924b589 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts @@ -48,7 +48,7 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => }); vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -59,9 +59,11 @@ import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { handleCloseCommand as handleProductionCloseCommand } from '../session-close.ts'; import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + legacyDispatchCapture, + resetLegacySnapshotCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -92,7 +94,7 @@ import { inspectLifecycleRuntimeFacts, } from './application-lifecycle-runtime-harness.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); @@ -124,9 +126,7 @@ function sessionCloseReceipt(session: SessionState | undefined): string | undefi } beforeEach(() => { - mockDispatchCommand.mockReset(); - mockCaptureSnapshotWithInteractor.mockReset(); - mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); + resetLegacySnapshotCapture(mockCaptureSnapshotWithInteractor); mockLifecycleDispatch.mockReset(); mockLifecycleDispatch.mockResolvedValue(undefined); // The "current" app state: "save" was renamed to "save-v2" (why step 2 diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts index 7adb5c1967..1a9c0a456d 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts @@ -12,7 +12,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts index c3952b82b8..718d8344f6 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts @@ -3,7 +3,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -12,16 +12,18 @@ import path from 'node:path'; import { buildReplayDivergenceFailureResponseFromDescriptor } from '../session-replay-runtime-failure-response.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts index 383fc947ef..ff38103fe4 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts @@ -3,7 +3,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -13,18 +13,20 @@ import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonResponse } from '../../types.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; import { maestroScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts index 6e65de7588..9dd31700a0 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts @@ -34,7 +34,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); import fs from 'node:fs'; diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts index 24af8f73b2..0028cecb8d 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts @@ -14,11 +14,10 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; // session-replay-vars.test.ts, plus a handful of generic (non-Maestro) `.ad` // runReplayScriptSource tests that happen to share the same runReplayFixture // helper and mock configuration below. It is a sibling of -// session-replay-runtime.test.ts rather than a merge into it because that -// file mocks '../../../core/dispatch.ts' differently (dispatchCommand -// resolves `{}`, not throws) — vitest allows only one vi.mock per module per -// file, so reconciling the two configurations was out of scope for a pure -// test-file split (see #1460). +// session-replay-runtime.test.ts rather than a merge into it because that file +// mocks '../../../core/dispatch.ts' with its own device resolution — vitest +// allows only one vi.mock per module per file, so reconciling the two +// configurations was out of scope for a pure test-file split (see #1460). vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -41,9 +40,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { booted: true, }, ), - dispatchCommand: vi.fn(async () => { - throw new Error('no device runner available in this test'); - }), dispatchGestureViewport: vi.fn(async () => ({ x: 0, y: 0, width: 400, height: 800 })), }; }); diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts index 405cba8f68..745fd11d62 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts @@ -3,7 +3,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ @@ -15,8 +15,11 @@ import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; -import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { resolveTargetDevice } from '../../../core/dispatch.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeAndroidSession, @@ -27,7 +30,7 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index 003d9ba1dc..9fd9901a87 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -4,7 +4,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ @@ -15,8 +15,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, @@ -27,7 +29,7 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts index 14de3c5dae..08dd8b87eb 100644 --- a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts @@ -10,7 +10,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -20,21 +20,23 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { maestroScriptSourceBundleFor, replayScriptSourceBundleFor, } from '../../../__tests__/test-utils/replay-script-source.ts'; import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../../replay/script-source-bundle.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { - vi.mocked(dispatchCommand).mockReset(); - vi.mocked(dispatchCommand).mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockCaptureSnapshotWithInteractor.mockReset(); mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); diff --git a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts b/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts index a7ebdedb54..f4066615f3 100644 --- a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts @@ -3,23 +3,25 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { SessionStore } from '../../session-store.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest, writeReplayFile } from './session-replay-runtime.fixtures.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts index b81413660f..fa34a0eae3 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts @@ -11,7 +11,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ @@ -32,24 +32,23 @@ import path from 'node:path'; import type { DaemonRequest } from '../../types.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { AppError } from '@agent-device/kernel/errors'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + legacyDispatchCapture, + resetLegacySnapshotCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { - mockDispatchCommand.mockReset(); - mockDispatchCommand.mockResolvedValue({}); - mockCaptureSnapshotWithInteractor.mockReset(); - mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); + resetLegacySnapshotCapture(mockCaptureSnapshotWithInteractor); }); const SAVE_ANNOTATION = diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 12e9885adc..75370a9c47 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -61,6 +61,15 @@ test('boot rejects --headless outside Android directly', async () => { keyboardStatus: { available: false, reason: 'owner-capability-missing' }, keyboardDismiss: { available: false, reason: 'owner-capability-missing' }, keyboardEnter: { available: false, reason: 'owner-capability-missing' }, + readClipboard: { available: false, reason: 'owner-capability-missing' }, + writeClipboard: { available: false, reason: 'owner-capability-missing' }, + appSwitcher: { available: false, reason: 'owner-capability-missing' }, + triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, + setSetting: { available: false, reason: 'owner-capability-missing' }, + readAlert: { available: false, reason: 'owner-capability-missing' }, + awaitAlert: { available: false, reason: 'owner-capability-missing' }, + acceptAlert: { available: false, reason: 'owner-capability-missing' }, + dismissAlert: { available: false, reason: 'owner-capability-missing' }, readiness: { available: false, reason: 'unsupported-device-kind' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, @@ -159,6 +168,15 @@ test('appstate rejects web before Android app-state backend dispatch', async () keyboardStatus: { available: false, reason: 'unsupported-platform-leaf' }, keyboardDismiss: { available: false, reason: 'unsupported-platform-leaf' }, keyboardEnter: { available: false, reason: 'unsupported-platform-leaf' }, + readClipboard: { available: false, reason: 'unsupported-platform-leaf' }, + writeClipboard: { available: false, reason: 'unsupported-platform-leaf' }, + appSwitcher: { available: false, reason: 'unsupported-platform-leaf' }, + triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, + setSetting: { available: false, reason: 'unsupported-platform-leaf' }, + readAlert: { available: false, reason: 'unsupported-platform-leaf' }, + awaitAlert: { available: false, reason: 'unsupported-platform-leaf' }, + acceptAlert: { available: false, reason: 'unsupported-platform-leaf' }, + dismissAlert: { available: false, reason: 'unsupported-platform-leaf' }, readiness: { available: false, reason: 'unsupported-platform-leaf' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts index f1b69bfbb7..d6016d587e 100644 --- a/src/daemon/handlers/__tests__/session-test-harness.ts +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -1,4 +1,5 @@ import { isMacOs } from '@agent-device/kernel/device'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { expect, vi, beforeEach } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import type { AppLogLiveState } from '@agent-device/contracts/app-log-runtime'; @@ -19,7 +20,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const resolveTargetDevice = vi.fn(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice, resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(resolveTargetDevice)), }; @@ -95,7 +95,7 @@ import * as path from 'node:path'; import { cleanupRetainedMaterializedPathsForSession } from '../../materialized-path-registry.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; -import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; +import { resolveTargetDevice } from '../../../core/dispatch.ts'; import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintValues, @@ -118,7 +118,6 @@ import { } from '../../../platforms/apple/core/apps.ts'; import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; -export const mockDispatch = vi.mocked(dispatchCommand); export const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); export const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); export const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); @@ -136,6 +135,8 @@ export const mockCleanupRetainedMaterializedPaths = vi.mocked( cleanupRetainedMaterializedPathsForSession, ); export const mockRunCmd = vi.mocked(runCmd); +/** The retired dispatcher's snapshot leg, now an owned test double (R58). */ +export const mockDispatch = legacyDispatchCapture; export const mockResolveIosApp = vi.mocked(resolveIosApp); export const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked( resolveIosSimulatorDeepLinkBundleId, diff --git a/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts b/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts index 5f8fe4e90f..57e5ad5832 100644 --- a/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts @@ -16,10 +16,10 @@ // // These tests document today's behavior; they do not propose behavior. -// Maestro replay resolves a target device and captures view hierarchies through -// core/dispatch. These fixtures model no runner, so both are stubbed: the device resolves to a -// fixed Android emulator, and `snapshot` returns an empty hierarchy (which is what makes -// `assertNotVisible` pass deterministically without a screen). +// Maestro replay resolves a target device and reads a gesture viewport through core/dispatch. +// These fixtures model no runner, so both are stubbed: the device resolves to a fixed Android +// emulator, and captures return an empty hierarchy (which is what makes `assertNotVisible` pass +// deterministically without a screen). import { expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -34,9 +34,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { kind: 'emulator', booted: true, })), - dispatchCommand: vi.fn(async () => { - throw new Error('no device runner available in this test'); - }), dispatchGestureViewport: vi.fn(async () => ({ x: 0, y: 0, width: 400, height: 800 })), }; }); diff --git a/src/daemon/handlers/__tests__/snapshot-alert-android-occlusion.test.ts b/src/daemon/handlers/__tests__/snapshot-alert-android-occlusion.test.ts index 1d4e8ba3d5..705e048d25 100644 --- a/src/daemon/handlers/__tests__/snapshot-alert-android-occlusion.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-alert-android-occlusion.test.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; vi.mock('../../../platforms/android/snapshot.ts', () => ({ snapshotAndroid: vi.fn() })); @@ -10,11 +9,15 @@ vi.mock('../../../platforms/android/input-actions.ts', async (importOriginal) => import { snapshotAndroid } from '../../../platforms/android/snapshot.ts'; import { pressAndroid } from '../../../platforms/android/input-actions.ts'; -import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { SessionStore } from '../../session-store.ts'; -import { handleAlertCommand } from '../snapshot-alert.ts'; +import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; +import { createAndroidInteractor } from '../../../core/interactors/android.ts'; import { makeAndroidSnapshotCapture } from '../../../__tests__/test-utils/android-snapshot-capture.ts'; -import type { DaemonRequest } from '../../types.ts'; + +// R59 moved the alert legs onto the Android interactor, which is where the occlusion reading +// they depend on lives: the legs capture through the same presentation pass `snapshot` publishes, +// and only a presented tree annotates a candidate as covered. The daemon route above them now +// only admits and forwards, so this suite drives the owner directly. +const alertLegs = () => createAndroidInteractor(ANDROID_EMULATOR); afterEach(() => { vi.useRealTimers(); @@ -25,9 +28,9 @@ afterEach(() => { test('Android alert get does not choose an exactly covered candidate', async () => { vi.mocked(snapshotAndroid).mockResolvedValue(coveredAlertCapture() as never); - const response = await handleAlertCommand(alertParams('get')); + const result = await alertLegs().readAlert(); - expect(response).toMatchObject({ ok: true, data: { action: 'get', alert: null } }); + expect(result).toMatchObject({ action: 'get', alert: null }); expect(pressAndroid).not.toHaveBeenCalled(); }); @@ -35,11 +38,12 @@ test('Android alert accept does not tap an exactly covered candidate', async () vi.useFakeTimers(); vi.mocked(snapshotAndroid).mockResolvedValue(coveredAlertCapture() as never); - const response = handleAlertCommand(alertParams('accept')); - const outcome = response.then( - () => undefined, - (error: unknown) => error, - ); + const outcome = alertLegs() + .acceptAlert() + .then( + () => undefined, + (error: unknown) => error, + ); await vi.advanceTimersByTimeAsync(3_500); const error = await outcome; expect(error).toBeInstanceOf(Error); @@ -48,23 +52,6 @@ test('Android alert accept does not tap an exactly covered candidate', async () expect(pressAndroid).not.toHaveBeenCalled(); }); -function alertParams(action: 'get' | 'accept') { - const session = makeAndroidSession(`covered-alert-${action}`); - const sessionStore = new SessionStore(path.join('/tmp', `covered-alert-${action}`)); - return { - req: { - token: 'test-token', - session: session.name, - command: 'alert', - positionals: [action], - } satisfies DaemonRequest, - logPath: path.join('/tmp', `covered-alert-${action}.log`), - sessionStore, - session, - device: session.device, - }; -} - function coveredAlertCapture() { const nodes = [ { diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 0748eaef2e..fbbc2eab6a 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -1,4 +1,10 @@ import { test, expect, vi, afterEach, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + getRuntimeBindings, + mockTapPoint, + resetGetRuntimeFixture, +} from './interaction-get-runtime-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; @@ -17,37 +23,17 @@ import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { fixtureScreenshotCaptures, + fixtureSettingsMutations, + resetSnapshotRuntimeFixture, snapshotRuntimeFixture, } from '../../__tests__/snapshot-runtime-fixture.ts'; import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; -const dispatchCommandMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({}))); - -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: dispatchCommandMock, - }; +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ - captureSnapshotWithInteractor: vi.fn( - async ({ device, runnerContext, options }) => - await dispatchCommandMock(device, 'snapshot', [], undefined, { - ...runnerContext, - ...options, - snapshotInteractiveOnly: options.interactiveOnly, - snapshotPreferredBackend: options.preferredBackend, - snapshotDepth: options.depth, - snapshotScope: options.scope, - snapshotRaw: options.raw, - snapshotCustomActions: options.customActions, - snapshotIncludeHiddenContentHints: options.includeHiddenContentHints, - }), - ), -})); - vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -74,7 +60,6 @@ vi.mock('../../ios-app-session-hint.ts', () => ({ buildIosOpenCommandHint: vi.fn(async () => undefined), })); -import { dispatchCommand } from '../../../core/dispatch.ts'; import { runAppleRunnerCommand, stopIosRunnerSession, @@ -82,16 +67,18 @@ import { import { closeIosApp } from '../../../platforms/apple/core/apps.ts'; import { buildIosOpenCommandHint } from '../../ios-app-session-hint.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); const mockCloseIosApp = vi.mocked(closeIosApp); const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); +const SNAPSHOT_ROUTE_RUNTIME_COMMANDS = new Set(['snapshot', 'diff', 'settings', 'alert']); + function handleSnapshotCommands( params: Parameters[0], ): ReturnType { - if (params.req.command !== 'snapshot' && params.req.command !== 'diff') { + // R58/R59 bound `settings` and `alert` too, so the fixture serves those commands as well. + if (!SNAPSHOT_ROUTE_RUNTIME_COMMANDS.has(params.req.command)) { return handleProductionSnapshotCommands(params); } const runtime = snapshotRuntimeFixture(params.req.meta?.requestId); @@ -168,8 +155,10 @@ afterEach(() => { }); beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + resetSnapshotRuntimeFixture(); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); + resetGetRuntimeFixture(); mockRunnerCommand.mockReset(); mockRunnerCommand.mockResolvedValue({}); mockStopIosRunnerSession.mockReset(); @@ -203,7 +192,7 @@ function makeAndroidTimeoutEvidenceSession(sessionName: string): SessionStore { } function mockAndroidTimeoutEvidenceDispatch(): void { - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') throw androidSnapshotTimeoutError(); return {}; }); @@ -432,7 +421,7 @@ test('snapshot on iOS rejects sessions without a tracked app', async () => { expect(response.error.details?.reason).toBe('ios_app_session_required'); expect(response.error.details?.hint).toBeUndefined(); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(bindCount).toBe(0); }); @@ -478,7 +467,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { const sessionName = 'ios-cloud-no-app'; sessionStore.set(sessionName, makeSession(sessionName, providerIosDevice)); setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(providerIosDevice)]); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'XCUIElementTypeButton', label: 'Sign in' }], truncated: false, backend: 'xctest', @@ -506,7 +495,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { }); expect(response?.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalled(); + expect(legacyDispatchCapture).toHaveBeenCalled(); // The bypass has to happen before the hint probe (#1662), which shells out to // simctl and can only ever see local simulators — for a hosted device it is a // guaranteed-useless spawn on what is now a success path. @@ -537,7 +526,7 @@ test('diff on local iOS still requires a tracked app', async () => { expect(response.error.code).toBe('SESSION_NOT_FOUND'); expect(response.error.message).toMatch(/iOS diff requires an active app session/i); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('snapshot on iOS runs when the session tracks an app', async () => { @@ -547,7 +536,7 @@ test('snapshot on iOS runs when the session tracks an app', async () => { ...makeSession(sessionName, iosSimulatorDevice), appBundleId: 'org.reactnavigation.playground', }); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Home' }], truncated: false, backend: 'ios', @@ -567,7 +556,7 @@ test('snapshot on iOS runs when the session tracks an app', async () => { }); expect(response?.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalledWith( + expect(legacyDispatchCapture).toHaveBeenCalledWith( iosSimulatorDevice, 'snapshot', [], @@ -588,7 +577,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy // A prior device action expired the frame. session.refFrameState = 'expired'; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], truncated: false, backend: 'android', @@ -650,7 +639,7 @@ async function runVersionedRefsCommand(params: { function makeVersionedRefsScenario(sessionName: string) { const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], truncated: false, backend: 'android', @@ -714,7 +703,7 @@ test('daemon-private snapshot observation advances capture state without publish const publishedGeneration = published?.refFrameGeneration; const publishedTree = published?.refFrameTree; - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Internal' }], truncated: false, backend: 'android', @@ -747,7 +736,7 @@ test('snapshot surfaces filtered-to-zero Android guidance for interactive snapsh const sessionName = 'android-empty-interactive'; sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [], truncated: false, backend: 'android', @@ -812,7 +801,7 @@ test('snapshot annotations survive pending interaction capture into CLI JSON', a }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: changedNodes, truncated: false, backend: 'android', @@ -870,7 +859,7 @@ test('snapshot timeout captures Android screenshot evidence with overlay refs', sessionStore, }); expectAndroidTimeoutEvidence(response); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot']); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot']); expect(fixtureScreenshotCaptures.at(-1)?.options).toMatchObject({ stabilize: false }); }); @@ -891,7 +880,7 @@ test('snapshot warns when recent snapshot node count collapses sharply', async ( }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 8 }, (_, index) => ({ index, depth: 0, @@ -943,7 +932,7 @@ test('snapshot does not warn on expected node drop across presentation modes', a }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 8 }, (_, index) => ({ index, depth: 0, @@ -1004,7 +993,7 @@ test('snapshot automatically retries stale Android trees after recent navigation }; sessionStore.set(sessionName, session); - mockDispatch + legacyDispatchCapture .mockResolvedValueOnce({ nodes: Array.from({ length: 24 }, (_, index) => ({ index, @@ -1046,7 +1035,7 @@ test('snapshot automatically retries stale Android trees after recent navigation expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), ); } - expect(mockDispatch).toHaveBeenCalledTimes(2); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); expect(sessionStore.get(sessionName)?.androidSnapshotFreshness).toBeUndefined(); }); @@ -1076,7 +1065,7 @@ test('snapshot warns when Android freshness retries still return the previous ro }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 24 }, (_, index) => ({ index, depth: 0, @@ -1109,7 +1098,7 @@ test('snapshot warns when Android freshness retries still return the previous ro ), ]); } - expect(mockDispatch).toHaveBeenCalledTimes(4); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(4); }); test('snapshot response includes normalized visibility metadata', async () => { @@ -1117,7 +1106,7 @@ test('snapshot response includes normalized visibility metadata', async () => { const sessionName = 'android-visibility'; sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [ { index: 0, @@ -1192,7 +1181,7 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 24 }, (_, index) => ({ index, depth: 0, @@ -1225,7 +1214,7 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy ), ]); } - expect(mockDispatch).toHaveBeenCalledTimes(4); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(4); }); test('Android ref refresh mode does not retry narrow snapshots as sharp drops', async () => { @@ -1254,7 +1243,7 @@ test('Android ref refresh mode does not retry narrow snapshots as sharp drops', }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 8 }, (_, index) => ({ index, depth: 0, @@ -1274,7 +1263,7 @@ test('Android ref refresh mode does not retry narrow snapshots as sharp drops', }); expect(result.freshness).toBeUndefined(); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(1); expect(session.androidSnapshotFreshness).toBeUndefined(); }); @@ -1318,11 +1307,11 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr }; let pressed = false; - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'press') { - pressed = true; - return { clicked: true }; - } + mockTapPoint.mockImplementation(async () => { + pressed = true; + return { clicked: true }; + }); + legacyDispatchCapture.mockImplementation(async () => { return { nodes: !pressed ? baselineNodes @@ -1353,15 +1342,15 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr session, flags: { snapshotInteractiveOnly: true }, logPath: '/tmp/daemon.log', + ...getRuntimeBindings(), }); expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Feed' })]), ); - expect( - mockDispatch.mock.calls.map((call) => call[1]).filter((command) => command === 'press'), - ).toEqual(['press']); - expect(mockDispatch.mock.calls.find((call) => call[1] === 'press')?.[2]).toEqual(['100', '144']); + // R58: the retry re-fires through the bound `tapPoint`, on the recorded coordinate pair. + expect(mockTapPoint).toHaveBeenCalledTimes(1); + expect(mockTapPoint.mock.calls[0]?.[0]?.point).toEqual({ x: 100, y: 144 }); expect(session.pendingInteractionOutcome).toBeUndefined(); }); @@ -1399,7 +1388,7 @@ test('captureSnapshot does not retry when a tap change appears after a short del }; let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { expect(command).toBe('snapshot'); snapshotCalls += 1; return { @@ -1418,7 +1407,7 @@ test('captureSnapshot does not retry when a tap change appears after a short del expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Albums' })]), ); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); expect(session.pendingInteractionOutcome).toBeUndefined(); }); @@ -1466,11 +1455,11 @@ test('captureSnapshot retries pending tap outcome before post-gesture stabilizat }; let pressed = false; - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'press') { - pressed = true; - return { clicked: true }; - } + mockTapPoint.mockImplementation(async () => { + pressed = true; + return { clicked: true }; + }); + legacyDispatchCapture.mockImplementation(async () => { return { nodes: !pressed ? baselineNodes @@ -1492,15 +1481,15 @@ test('captureSnapshot retries pending tap outcome before post-gesture stabilizat session, flags: { snapshotInteractiveOnly: true }, logPath: '/tmp/daemon.log', + ...getRuntimeBindings(), }); expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Tab Third (3)' })]), ); - expect( - mockDispatch.mock.calls.map((call) => call[1]).filter((command) => command === 'press'), - ).toEqual(['press']); - expect(mockDispatch.mock.calls.find((call) => call[1] === 'press')?.[2]).toEqual(['540', '1356']); + // R58: the retry re-fires through the bound `tapPoint`, on the recorded coordinate pair. + expect(mockTapPoint).toHaveBeenCalledTimes(1); + expect(mockTapPoint.mock.calls[0]?.[0]?.point).toEqual({ x: 540, y: 1356 }); expect(session.pendingInteractionOutcome).toBeUndefined(); expect(session.postGestureStabilization).toBeUndefined(); }); @@ -1541,7 +1530,7 @@ test('captureSnapshot composes post-gesture stabilization with Android freshness markedAt: Date.now(), }; - mockDispatch + legacyDispatchCapture .mockResolvedValueOnce({ nodes: baselineNodes, truncated: false, @@ -1571,7 +1560,7 @@ test('captureSnapshot composes post-gesture stabilization with Android freshness expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'album-0' })]), ); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual([ + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual([ 'snapshot', 'snapshot', 'snapshot', @@ -1613,7 +1602,7 @@ test('captureSnapshot composes pending outcome retry with Android freshness capt preSignature: buildInteractionSurfaceSignature(baselineNodes), }; - mockDispatch + legacyDispatchCapture .mockResolvedValueOnce({ nodes: [], truncated: false, @@ -1651,7 +1640,7 @@ test('captureSnapshot composes pending outcome retry with Android freshness capt staleAfterRetries: false, reason: undefined, }); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); expect(session.pendingInteractionOutcome).toBeUndefined(); expect(session.androidSnapshotFreshness).toBeUndefined(); }); @@ -1682,7 +1671,7 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap }; sessionStore.set(sessionName, session); - mockDispatch + legacyDispatchCapture .mockResolvedValueOnce({ nodes: Array.from({ length: 18 }, (_, index) => ({ index, @@ -1723,7 +1712,7 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap if (response?.ok) { expect(response.data?.text).toBe('Create document'); } - expect(mockDispatch).toHaveBeenCalledTimes(2); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); expect(sessionStore.get(sessionName)?.snapshot?.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), ); @@ -1731,7 +1720,7 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap test('wait text timeout includes compact current-surface labels and buttons', async () => { const sessionName = 'android-wait-timeout-surface'; - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: locationPermissionNodes, truncated: false, backend: 'android', @@ -1754,7 +1743,7 @@ test('wait text timeout includes compact current-surface labels and buttons', as test('wait selector timeout includes compact current-surface details', async () => { const sessionName = 'android-wait-selector-timeout-surface'; - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: locationRequiredNodes, truncated: false, backend: 'android', @@ -1800,12 +1789,14 @@ test('wait selector polling skips hidden-content hint derivation on every poll ( backend: 'android', analysis: { rawNodeCount: 1, maxDepth: 0 }, }; - mockDispatch.mockResolvedValueOnce(withoutBattery).mockResolvedValueOnce(withBattery); + legacyDispatchCapture.mockResolvedValueOnce(withoutBattery).mockResolvedValueOnce(withBattery); const response = await runWaitCommand(sessionName, androidDevice, ['label="Battery"', '8000']); expect(response?.ok).toBe(true); - const snapshotCalls = mockDispatch.mock.calls.filter(([, command]) => command === 'snapshot'); + const snapshotCalls = legacyDispatchCapture.mock.calls.filter( + ([, command]) => command === 'snapshot', + ); expect(snapshotCalls.length).toBe(2); for (const call of snapshotCalls) { const context = call[4] as { snapshotIncludeHiddenContentHints?: boolean } | undefined; @@ -1835,12 +1826,14 @@ test('wait text polling skips hidden-content hint derivation on every poll (#127 backend: 'android', analysis: { rawNodeCount: 1, maxDepth: 0 }, }; - mockDispatch.mockResolvedValueOnce(withoutBattery).mockResolvedValueOnce(withBattery); + legacyDispatchCapture.mockResolvedValueOnce(withoutBattery).mockResolvedValueOnce(withBattery); const response = await runWaitCommand(sessionName, androidDevice, ['Battery', '8000']); expect(response?.ok).toBe(true); - const snapshotCalls = mockDispatch.mock.calls.filter(([, command]) => command === 'snapshot'); + const snapshotCalls = legacyDispatchCapture.mock.calls.filter( + ([, command]) => command === 'snapshot', + ); expect(snapshotCalls.length).toBe(2); for (const call of snapshotCalls) { const context = call[4] as { snapshotIncludeHiddenContentHints?: boolean } | undefined; @@ -1851,7 +1844,7 @@ test('wait text polling skips hidden-content hint derivation on every poll (#127 test('wait timeout summary prefers content labels over chrome and identifier noise', async () => { const sessionName = 'ios-wait-timeout-surface-summary'; mockRunnerCommand.mockResolvedValue({ found: false }); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: iosSurfaceSummaryNodes, truncated: false, backend: 'xctest', @@ -1883,7 +1876,7 @@ test('wait timeout summary prefers content labels over chrome and identifier noi test('wait timeout without readable capture does not inspect the current surface', async () => { const sessionName = 'android-wait-timeout-surface-fails'; - mockDispatch.mockRejectedValue(new Error('snapshot unavailable')); + legacyDispatchCapture.mockRejectedValue(new Error('snapshot unavailable')); const response = await runWaitCommand(sessionName, androidDevice, ['Receipt uploaded', '0']); @@ -1894,7 +1887,7 @@ test('wait timeout without readable capture does not inspect the current surface expect(response.error.details?.retriable).toBe(true); expect(response.error.details?.readableCaptures).toBe(0); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('settings rejects unsupported iOS physical devices', async () => { @@ -1951,13 +1944,11 @@ test('settings clear-app-state dispatches explicit app id without an active app }); expect(response?.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalledWith( - iosSimulatorDevice, - 'settings', - ['clear-app-state', 'clear', 'org.reactnavigation.playground'], - undefined, - expect.objectContaining({ appBundleId: 'org.reactnavigation.playground' }), - ); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ + setting: 'clear-app-state', + state: 'clear', + appBundleId: 'org.reactnavigation.playground', + }); }); test('settings clear-app-state rejects missing app id when no app session is bound', async () => { @@ -1983,7 +1974,7 @@ test('settings clear-app-state rejects missing app id when no app session is bou expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toMatch(/requires an app id/i); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(fixtureSettingsMutations).toHaveLength(0); }); test('settings usage hint documents canonical faceid states', async () => { @@ -2032,7 +2023,7 @@ test('settings on macOS rejects wifi before dispatch with explicit subset guidan expect(response).toBeTruthy(); expect(response?.ok).toBe(false); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(fixtureSettingsMutations).toHaveLength(0); if (response && !response.ok) { expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toMatch(/Unsupported macOS setting: wifi/i); @@ -2109,7 +2100,7 @@ test('wait selector bypasses a fresh matching session snapshot', async () => { ], }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [ { index: 0, @@ -2134,7 +2125,7 @@ test('wait selector bypasses a fresh matching session snapshot', async () => { }); expect(response?.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalledWith( + expect(legacyDispatchCapture).toHaveBeenCalledWith( expect.anything(), 'snapshot', [], @@ -2143,7 +2134,15 @@ test('wait selector bypasses a fresh matching session snapshot', async () => { ); }); -test('alert accept retries on "alert not found" and succeeds on second attempt', async () => { +/** + * Absence as the XCTest runner states it (`ALERT_NOT_FOUND` surfaces as `details.runnerErrorCode`). + * The retry and the fallback hint key on that evidence, never on the message text. + */ +function alertAbsence(message = 'alert not found'): AppError { + return new AppError('COMMAND_FAILED', message, { runnerErrorCode: 'ALERT_NOT_FOUND' }); +} + +test('alert accept retries a typed alert absence and succeeds on the second attempt', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); @@ -2151,7 +2150,7 @@ test('alert accept retries on "alert not found" and succeeds on second attempt', let calls = 0; mockRunnerCommand.mockImplementation(async () => { calls += 1; - if (calls === 1) throw new AppError('COMMAND_FAILED', 'alert not found'); + if (calls === 1) throw alertAbsence(); return { accepted: true }; }); @@ -2172,41 +2171,16 @@ test('alert accept retries on "alert not found" and succeeds on second attempt', }); }); -test('alert accept does not retry on non-alert errors', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); - - let calls = 0; - mockRunnerCommand.mockImplementation(async () => { - calls += 1; - throw new AppError('COMMAND_FAILED', 'runner crashed'); - }); - - await expect( - handleSnapshotCommands({ - req: { - token: 't', - session: sessionName, - command: 'alert', - positionals: ['accept'], - flags: {}, - }, - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }), - ).rejects.toThrow('runner crashed'); - - expect(calls).toBe(1); -}); +// The non-absence case moved to `src/platforms/apple/__tests__/alert.test.ts` with the retry policy +// itself (R59), where it also covers a failure whose message merely reads like an absence — the +// case this daemon-altitude copy could not distinguish. test('alert accept adds a scoped-snapshot hint after retrying alert-not-found failures', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); - mockRunnerCommand.mockRejectedValue(new AppError('COMMAND_FAILED', 'alert not found')); + mockRunnerCommand.mockRejectedValue(alertAbsence()); let thrown: unknown; try { @@ -2231,7 +2205,7 @@ test('alert accept adds a scoped-snapshot hint after retrying alert-not-found fa expect((thrown as AppError).details?.hint).toMatch(/scoped snapshot/i); }); -test('alert dismiss retries on "no alert" message', async () => { +test('alert dismiss retries a typed absence whatever the message says', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); @@ -2239,7 +2213,7 @@ test('alert dismiss retries on "no alert" message', async () => { let calls = 0; mockRunnerCommand.mockImplementation(async () => { calls += 1; - if (calls < 3) throw new AppError('COMMAND_FAILED', 'no alert present'); + if (calls < 3) throw alertAbsence('no alert present'); return { dismissed: true }; }); diff --git a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts index 501741a42d..57ca82d23a 100644 --- a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts @@ -1,26 +1,17 @@ -import { beforeEach, expect, test, vi } from 'vitest'; +import { beforeEach, expect, test } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; import type { RawSnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { expireRefFrame } from '../../ref-frame.ts'; import { snapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts'; -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); - -const mockDispatch = vi.mocked(dispatchCommand); const ANDROID_SCRIPT_ERROR = 'Unable to load script. Make sure you are running Metro.'; beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); }); test('snapshot resolves @ref scope with the stored source after scoped output replaces refs', async () => { @@ -29,7 +20,7 @@ test('snapshot resolves @ref scope with the stored source after scoped output re const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: scopedScriptErrorNodes(), truncated: false, backend: 'android', @@ -42,8 +33,8 @@ test('snapshot resolves @ref scope with the stored source after scoped output re if (response?.ok) expect(response.data?.nodes).toHaveLength(2); } - expect(mockDispatch).toHaveBeenCalledTimes(2); - expect(mockDispatch.mock.calls.map((call) => call[4])).toEqual([ + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); + expect(legacyDispatchCapture.mock.calls.map((call) => call[4])).toEqual([ expect.objectContaining({ snapshotScope: ANDROID_SCRIPT_ERROR }), expect.objectContaining({ snapshotScope: ANDROID_SCRIPT_ERROR }), ]); @@ -57,7 +48,7 @@ test('a mutation clears scoped-snapshot lineage so a repeated snapshot -s @ref c const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: scopedScriptErrorNodes(), truncated: false, backend: 'android', @@ -86,7 +77,7 @@ test('empty @ref-scoped snapshot output does not replace the stored session snap const session = makeAndroidSession(sessionName, { snapshot: currentScreenSnapshot() }); sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [], truncated: false, backend: 'android', diff --git a/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts b/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts index f01085bb23..915176e887 100644 --- a/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts +++ b/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts @@ -1,4 +1,5 @@ import { test, expect, vi, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { handleFindCommands } from '../find.ts'; import { getRuntimeBindings } from './interaction-get-runtime-fixture.ts'; import { dispatchFindReadOnlyViaRuntime, dispatchWaitViaRuntime } from '../../selector-runtime.ts'; @@ -12,7 +13,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn(actual.resolveTargetDevice), }; }); @@ -26,12 +26,10 @@ vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); -import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; +import { resolveTargetDevice } from '../../../core/dispatch.ts'; import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; import { withSystemSurfaceDisclosure } from '../system-surface-disclosure.ts'; -const mockDispatch = vi.mocked(dispatchCommand); - // The occluding-shade capture every scenario below consumes: no application window content, one // active quick-settings surface. The Android capture route stamps systemSurfaceOnly on both the // annotations and the SnapshotState (see snapshot-capture.ts), so selector routes must disclose it. @@ -59,8 +57,8 @@ const SHADE_SNAPSHOT_DATA = { }; beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async (_device: unknown, command: string) => { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { return command === 'snapshot' ? SHADE_SNAPSHOT_DATA : {}; }); }); diff --git a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts index de028b4e63..d8b301b368 100644 --- a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts +++ b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts @@ -12,6 +12,7 @@ * `WAIT_LANDMARK_MISMATCH_REASON` refusal. */ import { test, expect, vi, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { dispatchWaitViaRuntime } from '../../selector-runtime.ts'; import type { DaemonRequest } from '../../types.ts'; import { WAIT_LANDMARK_MISMATCH_REASON } from '@agent-device/contracts/replay'; @@ -27,7 +28,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn(actual.resolveTargetDevice), }; }); @@ -41,10 +41,6 @@ vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); -import { dispatchCommand } from '../../../core/dispatch.ts'; - -const mockDispatch = vi.mocked(dispatchCommand); - function screenSnapshot(parentLabel: string) { return { backend: 'android', @@ -69,8 +65,8 @@ function screenSnapshot(parentLabel: string) { } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async (_device: unknown, command: string) => { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { return command === 'snapshot' ? screenSnapshot('Detail Screen') : {}; }); }); @@ -152,7 +148,7 @@ test('a replayed wait with a landmark guard succeeds when a match carries the re }); test('a replayed wait with a landmark guard refuses at the deadline when only impostors matched', async () => { - mockDispatch.mockImplementation(async (_device: unknown, command: string) => { + legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { return command === 'snapshot' ? screenSnapshot('List Screen') : {}; }); @@ -181,5 +177,5 @@ test('a selector-shaped positional list that fails to parse as a selector is rej if (response.ok) return; expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toContain('label="Open"'); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/interaction-touch-response.ts b/src/daemon/handlers/interaction-touch-response.ts index 1865d48743..116a3011f7 100644 --- a/src/daemon/handlers/interaction-touch-response.ts +++ b/src/daemon/handlers/interaction-touch-response.ts @@ -331,12 +331,12 @@ export function transformTouchResponseData(params: { export function readInteractionResponseDataTransformCommand( requestCommand: string, - dispatchCommand: 'press' | 'fill', + executedCommand: 'press' | 'fill', ): InteractionResponseDataTransformCommand { if (requestCommand === 'click' || requestCommand === 'press' || requestCommand === 'fill') { return requestCommand; } - return dispatchCommand; + return executedCommand; } /** The response fields disclosing the Maestro coordinate fallback's policy and outcome. */ diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index ab562664bc..e02ab5c0e4 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -1,4 +1,8 @@ -import { dispatchCommand } from '../../core/dispatch.ts'; +import { + createBoundTouchExecutor, + resolveBoundTouchRuntime, + type BoundTouchRuntime, +} from '../touch-runtime.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { analyzeReactNativeOverlay, @@ -10,7 +14,7 @@ import { successText } from '../../utils/success-text.ts'; import type { SnapshotQualityVerdict, SnapshotState } from '@agent-device/kernel/snapshot'; import { isSparseSnapshotQualityVerdict } from '../../snapshot-quality/verdict.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; -import { errorResponse, noActiveSessionError, requireCommandSupported } from './response.ts'; +import { errorResponse, noActiveSessionError } from './response.ts'; import { captureSnapshotForSession } from './interaction-snapshot.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; import { expireRefFrame } from '../ref-frame.ts'; @@ -26,10 +30,30 @@ export async function handleReactNativeCommands( const session = sessionStore.get(sessionName); if (!session) return noActiveSessionError(); - const unsupported = requireCommandSupported(PUBLIC_COMMANDS.reactNative, session.device, { - message: 'react-native dismiss-overlay is not supported on this device', + // R61: admission is the owner's own `tapPoint` fact — the one operation this command executes. + // It runs before the observing capture, exactly where the retired capability gate ran, so an + // owner that cannot dismiss an overlay refuses without first spending a snapshot on it. + // + // Deliberate widening: the retired bucket was `{apple, android, linux: {}}`, so Linux desktop, + // web and HarmonyOS were refused by family. All three admit `tapPoint`, so all three now run — + // and answer `detected: false` on a surface with no React Native overlay, which is the truthful + // result. A family cannot be a support authority for a migrated command (ADR 0019 §8); the + // Linux and web coverage manifests record this, and HarmonyOS has no manifest to record it in. + const bound = await resolveBoundTouchRuntime({ + device: session.device, + command: 'press', + requiresCapture: false, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + unavailableResponse: (unavailable) => + errorResponse( + 'UNSUPPORTED_OPERATION', + 'react-native dismiss-overlay is not supported on this device', + undefined, + unavailable.hint ? { hint: unavailable.hint } : undefined, + ), }); - if (unsupported) return unsupported; + if (!bound.ok) return bound.response; try { const snapshot = await captureSnapshotForSession( @@ -47,7 +71,7 @@ export async function handleReactNativeCommands( if (!target) { return responseForMissingReactNativeOverlayTarget(overlay.detected); } - return await dismissReactNativeOverlayTarget(params, session, snapshot, target); + return await executeReactNativeOverlayDismiss(params, session, snapshot, target, bound.runtime); } catch (error) { return { ok: false, error: normalizeError(error) }; } @@ -99,25 +123,38 @@ function responseForSparseReactNativeOverlaySnapshot( ); } -async function dismissReactNativeOverlayTarget( +/** + * R61 renamed this from `dismissReactNativeOverlayTarget` to record the contract change: it no + * longer resolves anything, because admission moved up to the route entry. It receives an + * already-bound runtime and executes the one tap. + */ +async function executeReactNativeOverlayDismiss( params: InteractionHandlerParams, session: SessionState, snapshot: SnapshotState, target: ReactNativeOverlayDismissTarget, + runtime: BoundTouchRuntime, ): Promise { const { req, sessionStore } = params; + // The dismissal press rides the same bound `tapPoint` a user-typed `press` does (R48). The + // binding is the caller's: admission happened before the overlay was even observed. + const context = params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath); + const executor = createBoundTouchExecutor(runtime, context); + // `tapPoint` is this command's one *required* operation, so admission already proved the cell. + // The executor still types every leg optional, and reaching it through `?.` would report + // `dismissed: true` for a dismissal that never touched the device. Refuse instead of lying. + const tapPoint = executor.tapPoint; + if (!tapPoint) { + return errorResponse( + 'UNSUPPORTED_OPERATION', + 'react-native dismiss-overlay is not supported on this device', + ); + } const actionStartedAt = Date.now(); // ADR 0014 side-effect seam: React Native overlay dismissal taps the device; // the target is already resolved, so expire the frame before the press. expireRefFrame(session); - const data = - (await dispatchCommand( - session.device, - 'press', - [String(target.point.x), String(target.point.y)], - req.flags?.out, - params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - )) ?? {}; + const data = await tapPoint(target.point); const actionFinishedAt = Date.now(); const verification = await verifyReactNativeOverlayDismissal(params, session); const responseData = stripUndefined({ diff --git a/src/daemon/handlers/response.ts b/src/daemon/handlers/response.ts index a4a80fbbdc..e18e757a99 100644 --- a/src/daemon/handlers/response.ts +++ b/src/daemon/handlers/response.ts @@ -1,5 +1,3 @@ -import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../../core/capabilities.ts'; -import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonResponse } from '../types.ts'; export type DaemonFailureResponse = Extract; @@ -31,26 +29,3 @@ export function errorResponse( export function noActiveSessionError(): DaemonFailureResponse { return errorResponse('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE); } - -/** - * Capability guard: returns an `UNSUPPORTED_OPERATION` failure when `command` is not - * supported on `device`, otherwise `null`. Pass `message` to override the default - * " is not supported on this device" text, or `hint: true` to attach the - * device-specific unsupported hint (as generic command dispatch does). - */ -export function requireCommandSupported( - command: string, - device: DeviceInfo, - options?: { message?: string; hint?: boolean }, -): DaemonFailureResponse | null { - if (isCommandSupportedOnDevice(command, device)) return null; - const hint = options?.hint ? unsupportedHintForDevice(command, device) : undefined; - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: options?.message ?? `${command} is not supported on this device`, - ...(hint ? { hint } : {}), - }, - }; -} diff --git a/src/daemon/handlers/session-clipboard.ts b/src/daemon/handlers/session-clipboard.ts new file mode 100644 index 0000000000..e275295ae2 --- /dev/null +++ b/src/daemon/handlers/session-clipboard.ts @@ -0,0 +1,167 @@ +import type { + ClipboardReadInput, + ClipboardWriteInput, +} from '@agent-device/contracts/clipboard-runtime'; +import { + clipboardReadUse, + clipboardWriteUse, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { contextFromFlags, type DaemonCommandContext } from '../context.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { admitRuntimeUse, type RuntimeAdmissionBindings } from '../runtime-admission.ts'; +import { runtimeExecutionFromContext } from '../snapshot-runtime-capture-input.ts'; +import { successText } from '../../utils/success-text.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; +import { recordSessionAction } from './handler-utils.ts'; +import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; + +type ClipboardAction = 'read' | 'write'; + +/** + * What the admit-then-bind step reports: either the refusal an unadmitted cell produced — nothing + * has touched the device yet — or the one bound invocation to run. Mirrors + * {@link ResolvedKeyboardExecution}'s shape for the same reason: the plan is chosen from the + * parsed action, and only the chosen leg ever binds. + */ +type ResolvedClipboardExecution = + | Readonly<{ ok: false; response: DaemonFailureResponse }> + | Readonly<{ + ok: true; + execute: (context: DaemonCommandContext) => Promise>; + }>; + +/** + * `clipboard `, on the same parse the retired leaf used. The subcommand is read + * before any device is resolved, exactly as the retired daemon handler did, so a typo still + * fails as `INVALID_ARGS` without waking a device. + */ +function readClipboardAction(positionals: readonly string[]): ClipboardAction | undefined { + const action = (positionals[0] ?? '').toLowerCase(); + return action === 'read' || action === 'write' ? action : undefined; +} + +function clipboardInput(context: DaemonCommandContext): ClipboardReadInput { + return { + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * `clipboard read`. The argument check stays inside the bound execution rather than moving ahead + * of admission: the retired leaf validated it in `dispatchCommand`, downstream of the capability + * gate, so an over-argued read on an unsupported device must still report the unsupported cell. + */ +async function executeClipboardRead( + runtime: BoundDeviceRuntime, + context: DaemonCommandContext, + positionals: readonly string[], +): Promise> { + if (positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments'); + } + const text = await runtime.operations.readClipboard(clipboardInput(context)); + return { action: 'read', text }; +} + +/** `clipboard write `; `""` clears, so an empty string is a value, not a missing argument. */ +async function executeClipboardWrite( + runtime: BoundDeviceRuntime, + context: DaemonCommandContext, + positionals: readonly string[], +): Promise> { + if (positionals.length < 2) { + throw new AppError('INVALID_ARGS', 'clipboard write requires text (use "" to clear clipboard)'); + } + const text = positionals.slice(1).join(' '); + const input: ClipboardWriteInput = { ...clipboardInput(context), text }; + await runtime.operations.writeClipboard(input); + return { + action: 'write', + textLength: Array.from(text).length, + ...successText('Clipboard updated'), + }; +} + +/** + * The one place `clipboard` reaches a device (ADR 0019 §9). Exactly one action-selected use is + * admitted and bound — `read` or `write`, never both. Each branch admits its own literal use, so + * the bound runtime narrows from that instantiation rather than from an assertion. Both name the + * bare command in their refusal: the retired `requireCommandSupported('clipboard', device)` gate + * refused per command, not per subcommand, and the wording is parity-pinned. + */ +async function resolveBoundClipboardRuntime( + params: Readonly<{ + device: DeviceInfo; + action: ClipboardAction; + positionals: readonly string[]; + }> & + RuntimeAdmissionBindings, +): Promise { + const { device, action, positionals, inspectFacts, bindDevice } = params; + if (action === 'read') { + const admission = await admitRuntimeUse({ + command: 'clipboard', + device, + use: clipboardReadUse, + inspectFacts, + bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (context) => executeClipboardRead(runtime, context, positionals) }; + } + const admission = await admitRuntimeUse({ + command: 'clipboard', + device, + use: clipboardWriteUse, + inspectFacts, + bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (context) => executeClipboardWrite(runtime, context, positionals) }; +} + +export async function handleSessionClipboardCommand(params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; +}): Promise { + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; + const session = sessionStore.get(sessionName); + const flags = req.flags ?? {}; + const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.clipboard, session, flags); + if (guard) return guard; + + const positionals = req.positionals ?? []; + const action = readClipboardAction(positionals); + if (!action) { + return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); + } + + const device = await resolveCommandDevice({ session, flags, ensureReady: true }); + const bound = await resolveBoundClipboardRuntime({ + device, + action, + positionals, + inspectFacts, + bindDevice, + }); + if (!bound.ok) return bound.response; + + const result = await bound.execute( + contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + ); + recordSessionAction(sessionStore, session, req, req.command, result); + return { ok: true, data: { platform: publicPlatformString(device), ...result } }; +} diff --git a/src/daemon/handlers/session-install-capability-projection.test.ts b/src/daemon/handlers/session-install-capability-projection.test.ts deleted file mode 100644 index 6f9e59f837..0000000000 --- a/src/daemon/handlers/session-install-capability-projection.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, test } from 'vitest'; -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { commandDescriptors } from '../../core/command-descriptor/registry.ts'; -import { installFamilyCapabilityAvailable } from './session-install-capability-projection.ts'; - -test('keeps the facts-only capability projection narrow to the install family', () => { - const projectedCommands = commandDescriptors - .filter((descriptor) => descriptor.catalog.group === 'public') - .map((descriptor) => descriptor.name) - .filter((command) => installFamilyCapabilityAvailable(command, undefined) !== undefined) - .sort(); - - expect(projectedCommands).toEqual( - [ - PUBLIC_COMMANDS.install, - PUBLIC_COMMANDS.reinstall, - PUBLIC_COMMANDS.installFromSource, - PUBLIC_COMMANDS.push, - ].sort(), - ); -}); - -test('guards the public source-install alias against descriptor-use drift', () => { - expect(runtimeUse(PUBLIC_COMMANDS.installFromSource)).toEqual({ - required: ['ensureReady', 'materializeAppSource', 'deployMaterializedApp'], - preferred: [], - }); - expect(runtimeUse(PUBLIC_COMMANDS.installFromSource)).toEqual( - runtimeUse(INTERNAL_COMMANDS.installSource), - ); - expect(runtimeUse(PUBLIC_COMMANDS.install)).toEqual({ - required: ['deployApp'], - preferred: [], - }); - expect(runtimeUse(PUBLIC_COMMANDS.reinstall)).toEqual({ - required: ['deployApp'], - preferred: [], - }); - expect(runtimeUse(PUBLIC_COMMANDS.push)).toEqual({ - required: ['ensureReady', 'sendPushNotification'], - preferred: [], - }); -}); - -function runtimeUse(command: string) { - const execution = commandDescriptors.find( - (descriptor) => descriptor.name === command, - )?.platformExecution; - if (execution?.kind !== 'device-runtime' || !('use' in execution)) { - throw new Error(`Expected ${command} to declare one exact device-runtime use`); - } - return execution.use; -} diff --git a/src/daemon/handlers/session-install-capability-projection.ts b/src/daemon/handlers/session-install-capability-projection.ts deleted file mode 100644 index cd299cd2fa..0000000000 --- a/src/daemon/handlers/session-install-capability-projection.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { commandDescriptors } from '../../core/command-descriptor/registry.ts'; -import type { - RuntimeOperationFact, - RuntimeUseDeclaration, -} from '@agent-device/contracts/platform-runtime'; -import type { InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; - -type InstallFamilyRuntimeUse = RuntimeUseDeclaration; -type DeviceRuntimeFacts = Awaited>; - -// This is deliberately a narrow transitional projection, not a second capability catalog. The -// public source-install command writes the internal install_source daemon route, so normalize it -// before reading its descriptor. The descriptor owns the required operations in both cases. -const INSTALL_FAMILY_PUBLIC_COMMANDS = new Set([ - PUBLIC_COMMANDS.install, - PUBLIC_COMMANDS.reinstall, - PUBLIC_COMMANDS.installFromSource, - PUBLIC_COMMANDS.push, -]); -const INSTALL_FAMILY_DAEMON_ALIASES = new Map([ - [PUBLIC_COMMANDS.installFromSource, INTERNAL_COMMANDS.installSource], -]); - -/** - * The legacy capabilities handler temporarily projects only this unit's four public - * commands from facts. Wave 6 owns the general capabilities cutover. - */ -export function installFamilyCapabilityAvailable( - command: string, - facts: Awaited> | undefined, -): boolean | undefined { - const use = installFamilyCapabilityUse(command); - if (!use) return undefined; - return ( - facts !== undefined && use.required.every((operation) => hasAvailableFact(facts, operation)) - ); -} - -function installFamilyCapabilityUse(command: string): InstallFamilyRuntimeUse | undefined { - const normalized = normalizeInstallFamilyDaemonCommand(command); - if (!normalized) return undefined; - const execution = commandDescriptors.find( - (descriptor) => descriptor.name === normalized, - )?.platformExecution; - if (execution?.kind !== 'device-runtime' || !('use' in execution)) return undefined; - return execution.use; -} - -function normalizeInstallFamilyDaemonCommand(command: string): string | undefined { - if (!INSTALL_FAMILY_PUBLIC_COMMANDS.has(command)) return undefined; - return INSTALL_FAMILY_DAEMON_ALIASES.get(command) ?? command; -} - -function hasAvailableFact(facts: DeviceRuntimeFacts, operation: string): boolean { - // Descriptor execution metadata is structurally validated as string arrays. Keep the projection - // fail-closed when a future descriptor names an operation that is absent from the concrete facts - // catalog instead of turning an arbitrary property lookup into capability support. - if (!Object.hasOwn(facts.operations, operation)) return false; - const fact = (facts.operations as Readonly>)[operation]; - return fact?.available === true; -} diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 24ffd2b6bd..939be014fe 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -1,4 +1,8 @@ -import { isCommandSupportedOnDevice, listCapabilityCommands } from '../../core/capabilities.ts'; +import { + commandRuntimeUseRequirements, + isCommandSupportedOnDevice, + listCapabilityCommands, +} from '../../core/capabilities.ts'; import { listDeviceInventory } from '../../request/device-inventory-context.ts'; import { assertResolvedAppsFilter } from '@agent-device/contracts/device'; import { AppError, asAppError } from '@agent-device/kernel/errors'; @@ -25,21 +29,17 @@ import { } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; import { resolveImplicitSessionScope, sessionMatchesScope } from '../session-routing.ts'; -import { appLogAdmissionUse } from '@agent-device/contracts/logs-runtime-plan'; -import { networkAdmissionUse } from '@agent-device/contracts/network-runtime-plan'; import type { BoundDeviceRuntime, RuntimeFacts, - RuntimeOperationKey, + RuntimeOperationFact, } from '@agent-device/contracts/platform-runtime'; import { type PlatformRuntimeOperations, appsRuntimeUse, } from '@agent-device/contracts/platform-runtime-operations'; -import { screenRecordingAdmissionUse } from '@agent-device/contracts/screen-recording-runtime-plan'; import { ensureAppsRuntimeReady, listAppsFromRuntime } from '../apps-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { installFamilyCapabilityAvailable } from './session-install-capability-projection.ts'; export async function handleSessionInventoryCommands(params: { req: DaemonRequest; @@ -60,7 +60,6 @@ export async function handleSessionInventoryCommands(params: { sessionName, sessionStore, inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, }); case 'apps': return await handleAppsInventory({ @@ -188,7 +187,6 @@ async function capabilitiesInventoryResponse(params: { sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; - bindDevice?: BindDeviceRuntime; }): Promise { const resolution = await resolveInventoryCommandDevice({ ...params, @@ -203,21 +201,13 @@ async function capabilitiesInventoryResponse(params: { params.req.flags, ); // Capability projection is admission-only: every fact-owned command reads this one - // side-effect-free exact-device snapshot, never a deleted descriptor capability bucket. + // side-effect-free exact-device snapshot, never a deleted descriptor capability bucket, and + // never a binding. R63 retired the three `bindDevice` probes that used to answer + // `logs`/`network`/`record`: every owner composes a binding's facts with the same function + // `inspectFacts` calls, so the probes read back values this snapshot already carries while + // costing a device claim on a read-only query. ADR 0019 §6 requires a `none` descriptor to bind + // nothing, and `capabilities` is one. const facts = await inspectCapabilityFacts(device, params.inspectFacts); - const [logsAvailable, networkAvailable, recordingAvailable] = params.bindDevice - ? await Promise.all([ - params - .bindDevice(device, appLogAdmissionUse) - .then((runtime) => runtime.facts.appLogInspect.available), - params - .bindDevice(device, networkAdmissionUse) - .then((runtime) => runtime.facts.networkDump.available), - params - .bindDevice(device, screenRecordingAdmissionUse) - .then((runtime) => runtime.facts.screenRecordingStart.available), - ]) - : [false, false, false]; return { ok: true, data: { @@ -226,17 +216,9 @@ async function capabilitiesInventoryResponse(params: { // A session that already owns an app identity answers appstate itself, so it stays // available even when the sessionless runtime probe cannot see a foreground app. if (command === 'appstate' && sessionOwnedAppStateAvailable) return true; - const installFamilyAvailable = installFamilyCapabilityAvailable(command, facts); - if (installFamilyAvailable !== undefined) return installFamilyAvailable; const factOwned = factOwnedCapabilityAvailable(command, facts); if (factOwned !== undefined) return factOwned; - return command === 'logs' - ? logsAvailable - : command === 'network' - ? networkAvailable - : command === 'record' - ? recordingAvailable - : isCommandSupportedOnDevice(command, device); + return isCommandSupportedOnDevice(command, device); }), }, }; @@ -270,32 +252,45 @@ function hasMacSessionSurface( } /** - * Every command whose availability is a runtime fact, and the exact operations it needs. A command - * absent from this record is not fact-owned, so its availability is decided elsewhere. + * Whether the exact device admits any of `command`'s declared runtime uses, or `undefined` when + * the command is not fact-owned at all. + * + * R63 reads the requirement straight off the descriptor (`commandRuntimeUseRequirements`), so a + * command cannot be migrated in one place and left projecting from a stale list in another — + * which is precisely how a real Vega VVD came to advertise `snapshot diff get is wait focus` it + * cannot run. */ -const factOwnedCapabilityOperations: Readonly< - Record[] | undefined> -> = Object.freeze({ - apps: ['ensureReady', 'listApps'], - appstate: ['ensureReady', 'appState'], - shutdown: ['shutdownTarget'], - open: ['resolveOpenTarget', 'prepareApplicationOpen', 'openApplication'], - close: ['closeApplication', 'finalizeApplicationClose'], - prepare: ['prepareAppleRunner'], - runtime: ['clearRuntimeHints'], - screenshot: ['captureScreenshot'], - viewport: ['setViewport'], -}); - function factOwnedCapabilityAvailable( command: string, facts: RuntimeFacts | undefined, ): boolean | undefined { - const requiredOperations = factOwnedCapabilityOperations[command]; - if (!requiredOperations) return undefined; - return facts - ? requiredOperations.every((operation) => facts.operations[operation].available) - : false; + const declaredUses = commandRuntimeUseRequirements(command); + if (!declaredUses) return undefined; + if (!facts) return false; + // A request names exactly one action, so one fully admitted use is enough. An empty `required` + // is not one: `[].every` is vacuously true, and a plan that needs no operation must not read as + // proof that the device can run the command. + return declaredUses.some( + (required) => + required.length > 0 && required.every((operation) => hasAvailableFact(facts, operation)), + ); +} + +/** + * Descriptor execution metadata is structurally validated as string arrays, not as keys of the + * concrete facts catalog. Keep the projection fail-closed when a descriptor names an operation the + * inspected owner does not state, instead of letting an arbitrary property lookup throw out of the + * whole `capabilities` response. + */ +function hasAvailableFact( + facts: RuntimeFacts, + operation: string, +): boolean { + if (!Object.hasOwn(facts.operations, operation)) return false; + return ( + (facts.operations as Readonly>)[operation]?.available === + true + ); } async function handleAppsInventory(params: { diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index 556b297f72..111d9686b1 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -1,12 +1,12 @@ -import { dispatchCommand } from '../../core/dispatch.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import type { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; -import { errorResponse, requireCommandSupported } from './response.ts'; +import { errorResponse } from './response.ts'; import { recordSessionAction } from './handler-utils.ts'; +import { resolveBoundAppEventRuntime } from '../app-event-runtime.ts'; import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; @@ -15,6 +15,7 @@ import { resolveSessionAppBundleIdForTarget, } from '../../platform-runtime-open-target.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { DaemonCommandContext } from '../context.ts'; /** * What `runSessionOrSelectorDispatch`'s `prepare` thunk reports: either the early-exit response an @@ -31,10 +32,10 @@ type SessionCommandPrepareOutcome = * The one orchestration every session/selector-route leaf shares: guard, resolve the device, * admit-then-prepare via the caller's own strategy, expire the ref frame if the command mutates * (immediately before the prepared invocation runs, never after), derive and record the next - * session. `prepare` is where the two strategies session-route commands use today diverge — - * {@link legacySessionDispatchExecute} for a still-legacy command (capability gate, then - * `dispatchCommand`), or a bind-and-execute thunk like `keyboard`'s below for a migrated one — - * everything around it is identical either way, so it lives here once instead of once per command. + * session. `prepare` is where each leaf's own admission and binding lives; everything around it + * is identical, so it lives here once instead of once per command. Every leaf on this route now + * supplies a bind-and-execute thunk — R57 retired the last capability-gate-then-`dispatchCommand` + * one with `trigger-app-event`. */ // fallow-ignore-next-line complexity async function runSessionOrSelectorDispatch(params: { @@ -101,33 +102,6 @@ async function runSessionOrSelectorDispatch(params: { return { ok: true, data: result ?? {} }; } -/** The still-legacy `prepare` thunk: the capability gate is admission (nothing mutated yet if it - * refuses); `dispatchCommand` is the invocation, deferred until `runSessionOrSelectorDispatch` has - * expired the ref frame. Every remaining unmigrated session-route command passes this until its - * own migration replaces it with a bind-and-execute thunk, same as `keyboard`'s - * {@link handleKeyboardCommand} below. */ -function legacySessionDispatchExecute( - command: string, - positionals: string[], - req: DaemonRequest, - logPath: string, -): ( - device: DeviceInfo, - session: SessionState | undefined, -) => Promise { - return async (device, session) => { - const unsupported = requireCommandSupported(command, device); - if (unsupported) return { ok: false, response: unsupported }; - return { - ok: true, - execute: () => - dispatchCommand(device, command, positionals, req.flags?.out, { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - }), - }; - }; -} - /** * A dismiss/enter/return sent with no session and no explicit iOS selector would target whatever * app happens to be foreground on a later-resolved device, silently. Refuse it up front rather @@ -147,88 +121,100 @@ function requireForegroundIosKeyboardSession( ); } -/** - * `keyboard`'s migrated `prepare` thunk (ADR 0019 §9): bind whichever action-selected use the - * parsed action names — admission only, no device I/O yet — and defer the bound runtime's own - * `execute` as the invocation `runSessionOrSelectorDispatch` runs after expiring the frame. - * Replaces the resolve-then-record shape `runSessionOrSelectorDispatch` now owns — this only - * supplies what the generic route's `dispatchCommand` cannot: the bound runtime and the - * action-specific execution context. - */ -function keyboardSessionExecute( - positionals: string[], - inspectFacts: InspectDeviceRuntimeFacts | undefined, - bindDevice: BindDeviceRuntime | undefined, - req: DaemonRequest, - logPath: string, -): ( - device: DeviceInfo, - session: SessionState | undefined, -) => Promise { - return async (device, session) => { - const bound = await resolveBoundKeyboardRuntime({ - device, - positionals, - inspectFacts, - bindDevice, - }); - if (!bound.ok) return { ok: false, response: bound.response }; - const dispatchContext = { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - surface: session?.surface, - }; - return { ok: true, execute: () => bound.execute(dispatchContext) }; - }; -} - -export async function handleKeyboardCommand(params: { +/** The params every migrated session-route handler takes; identical across the leaves. */ +type SessionRouteHandlerParams = Readonly<{ req: DaemonRequest; sessionName: string; logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; -}): Promise { - const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; - const positionals = req.positionals ?? []; +}>; - const foregroundGuard = requireForegroundIosKeyboardSession( - sessionStore.get(sessionName), - positionals[0]?.trim().toLowerCase(), - req.flags ?? {}, - ); - if (foregroundGuard) return foregroundGuard; +type SessionRouteRuntimeResolver = ( + params: Readonly<{ + device: DeviceInfo; + positionals: string[]; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; + }>, +) => Promise< + | Readonly<{ ok: false; response: DaemonResponse }> + | Readonly<{ + ok: true; + execute: (context: DaemonCommandContext) => Promise | void>; + }> +>; +/** + * The whole shape a migrated session-route leaf needs: admit and bind through the caller's own + * resolver, then hand `runSessionOrSelectorDispatch` the bound runtime's `execute` to invoke after + * expiring the frame. Only the resolver, the command name and the optional session derivation + * differ per leaf, so one entry point here is what keeps `keyboard` and `trigger-app-event` from + * drifting into two copies of the same wiring. + */ +async function runBoundSessionRoute( + params: SessionRouteHandlerParams & + Readonly<{ + command: string; + /** + * Written as a call at each leaf rather than passed by reference: the resolver call site is + * this command's own single-bind evidence (ADR 0019 §9), and the cutover gate reads it + * lexically. A bare reference would dedupe the wiring and delete the proof with it. + */ + resolveRuntime: SessionRouteRuntimeResolver; + deriveNextSession?: Parameters[0]['deriveNextSession']; + }>, +): Promise { + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; + const positionals = req.positionals ?? []; return await runSessionOrSelectorDispatch({ req, sessionName, sessionStore, - command: PUBLIC_COMMANDS.keyboard, + command: params.command, positionals, - prepare: keyboardSessionExecute(positionals, inspectFacts, bindDevice, req, logPath), + ...(params.deriveNextSession ? { deriveNextSession: params.deriveNextSession } : {}), + prepare: async (device, session) => { + const bound = await params.resolveRuntime({ + device, + positionals, + inspectFacts, + bindDevice, + }); + if (!bound.ok) return { ok: false, response: bound.response }; + const dispatchContext = { + ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + surface: session?.surface, + }; + return { ok: true, execute: () => bound.execute(dispatchContext) }; + }, }); } -export async function handleTriggerAppEventCommand(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; -}): Promise { - const { req, sessionName, logPath, sessionStore } = params; - const positionals = req.positionals ?? []; - return await runSessionOrSelectorDispatch({ - req, - sessionName, - sessionStore, +export async function handleKeyboardCommand( + params: SessionRouteHandlerParams, +): Promise { + const foregroundGuard = requireForegroundIosKeyboardSession( + params.sessionStore.get(params.sessionName), + params.req.positionals?.[0]?.trim().toLowerCase(), + params.req.flags ?? {}, + ); + if (foregroundGuard) return foregroundGuard; + return await runBoundSessionRoute({ + ...params, + command: PUBLIC_COMMANDS.keyboard, + resolveRuntime: (runtimeParams) => resolveBoundKeyboardRuntime(runtimeParams), + }); +} + +export async function handleAppEventCommand( + params: SessionRouteHandlerParams, +): Promise { + return await runBoundSessionRoute({ + ...params, command: PUBLIC_COMMANDS.triggerAppEvent, - positionals, - prepare: legacySessionDispatchExecute( - PUBLIC_COMMANDS.triggerAppEvent, - positionals, - req, - logPath, - ), + resolveRuntime: (runtimeParams) => resolveBoundAppEventRuntime(runtimeParams), deriveNextSession: async (session, result) => { const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; const nextAppBundleId = eventUrl diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 0278088e03..3beb7594fa 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -1,22 +1,12 @@ -import { dispatchCommand } from '../../core/dispatch.ts'; -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; -import { publicPlatformString } from '@agent-device/kernel/device'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { contextFromFlags } from '../context.ts'; import { handleReleaseMaterializedPathsCommand } from './session-app-source-deployment.ts'; -import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; -import { errorResponse, requireCommandSupported } from './response.ts'; -import { recordSessionAction } from './handler-utils.ts'; import { handleRuntimeCommand } from './session-runtime-command.ts'; import { requireRuntimeBinding, requireRuntimeFacts } from './session-runtime-admission.ts'; import { handleOpenCommand } from './session-open.ts'; import { composeOpenWithInitialSnapshot } from './session-open-foreground.ts'; -import { - handleKeyboardCommand, - handleTriggerAppEventCommand, -} from './session-selector-dispatch.ts'; +import { handleKeyboardCommand, handleAppEventCommand } from './session-selector-dispatch.ts'; import { handleCloseCommand } from './session-close.ts'; import { handleSessionAppDeploymentCommand } from './session-app-deployment-route.ts'; import { runBatchCommands } from './session-batch.ts'; @@ -25,6 +15,7 @@ import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; import { handleSessionScriptPublication } from './session-script-publication.ts'; +import { handleSessionClipboardCommand } from './session-clipboard.ts'; import { handleDoctorCommand } from './session-doctor.ts'; import { handlePrepareCommand } from './session-prepare.ts'; import type { DescriptorSessionRouteCommandName } from '../../core/command-descriptor/registry.ts'; @@ -40,45 +31,6 @@ import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; import type { PlatformRequestScope } from '@agent-device/contracts/platform'; -// fallow-ignore-next-line complexity -async function handleClipboardCommand(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; -}): Promise { - const { req, sessionName, logPath, sessionStore } = params; - const session = sessionStore.get(sessionName); - const flags = req.flags ?? {}; - const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.clipboard, session, flags); - if (guard) return guard; - - const action = (req.positionals?.[0] ?? '').toLowerCase(); - if (action !== 'read' && action !== 'write') { - return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); - } - - const device = await resolveCommandDevice({ - session, - flags, - ensureReady: true, - }); - const unsupported = requireCommandSupported(PUBLIC_COMMANDS.clipboard, device); - if (unsupported) return unsupported; - - const result = await dispatchCommand( - device, - PUBLIC_COMMANDS.clipboard, - req.positionals ?? [], - req.flags?.out, - { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - }, - ); - recordSessionAction(sessionStore, session, req, req.command, result ?? {}); - return { ok: true, data: { platform: publicPlatformString(device), ...(result ?? {}) } }; -} - export type SessionCommandInput = { req: DaemonRequest; sessionName: string; @@ -231,8 +183,15 @@ const SESSION_COMMAND_HANDLER_IMPLS = { inspectFacts, bindDevice, }), - clipboard: async ({ req, sessionName, logPath, sessionStore }) => - await handleClipboardCommand({ req, sessionName, logPath, sessionStore }), + clipboard: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => + await handleSessionClipboardCommand({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + }), keyboard: handleKeyboardCommand, perf: handleSessionObservabilityCommandGroup, logs: handleSessionObservabilityCommandGroup, @@ -254,7 +213,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { release_materialized_paths: async ({ req }) => await handleReleaseMaterializedPathsCommand({ req }), push: handleSessionAppDeploymentCommand, - 'trigger-app-event': handleTriggerAppEventCommand, + 'trigger-app-event': handleAppEventCommand, open: async ({ req, sessionName, diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index d35d741e5b..cc17768083 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -1,26 +1,27 @@ -import { isIosFamily, isMacOs } from '@agent-device/kernel/device'; +import type { AlertRuntimeInput } from '@agent-device/contracts/alert-runtime'; import { - ALERT_ACTION_RETRY_MS, - ALERT_POLL_INTERVAL_MS as POLL_INTERVAL_MS, type AlertAction, DEFAULT_ALERT_TIMEOUT_MS as DEFAULT_TIMEOUT_MS, } from '@agent-device/contracts/alert-contract'; -import { sleep } from '../../utils/timeouts.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { runMacOsAlertAction } from '../../platforms/apple/os/macos/helper.ts'; -import { handleAndroidAlert } from '../../platforms/android/alert.ts'; -import { snapshotAndroid } from '../../platforms/android/snapshot.ts'; -import { androidSnapshotPublicationInput } from '../../platforms/android/snapshot-capture.ts'; -import { AppError } from '@agent-device/kernel/errors'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import { + alertAcceptUse, + alertDismissUse, + alertReadUse, + alertWaitUse, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { contextFromFlags } from '../context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts'; import { recordIfSession } from './snapshot-session.ts'; import { parseTimeout } from '../../utils/parse-timeout.ts'; import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; -import { errorResponse, requireCommandSupported } from './response.ts'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import type { DaemonFailureResponse } from './response.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { admitRuntimeUse, type RuntimeAdmissionBindings } from '../runtime-admission.ts'; +import { runtimeExecutionFromContext } from '../snapshot-runtime-capture-input.ts'; type HandleAlertCommandParams = { req: DaemonRequest; @@ -28,163 +29,125 @@ type HandleAlertCommandParams = { sessionStore: SessionStore; session: SessionState | undefined; device: SessionState['device']; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }; -type NativeAlertAction = Exclude; -type NativeAlertRunner = (action: NativeAlertAction, timeoutMs: number) => Promise; - -const ALERT_FALLBACK_HINT = - 'If the permission sheet is visible in snapshot or screenshot but alert reports no alert, take a scoped snapshot around the visible button label and use press @ref.'; +type ResolvedAlertExecution = + | Readonly<{ ok: false; response: DaemonFailureResponse }> + | Readonly<{ ok: true; execute: (input: AlertRuntimeInput) => Promise> }>; -export async function handleAlertCommand( - params: HandleAlertCommandParams, -): Promise { - const { req, logPath, session, device } = params; - const action = normalizeAlertAction(req.positionals?.[0]); - const macOsAlertTarget = (() => { - if (!session) return {}; - if (session.surface === 'frontmost-app') { - return { surface: 'frontmost-app' as const }; - } - return { - bundleId: session.appBundleId, - surface: session.surface, - }; - })(); - const unsupported = requireCommandSupported('alert', device); - if (unsupported) return unsupported; - // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait - // are read-only. The alert resolver returns `may-invalidate` only for the - // acting subactions, so this covers both the Android and native accept/dismiss - // mutations without touching the read paths. - if (session && resolveRefFrameEffect(req) === 'may-invalidate') { - expireRefFrame(session); +/** + * The ONE place `alert` reaches a device (R59). Exactly one action-selected use is admitted and + * bound per request — the leg the parsed subcommand names, never all four. Each branch admits its + * own literal use, so the bound runtime narrows from that instantiation rather than an assertion. + * + * Every branch names the bare command in its refusal: the retired + * `requireCommandSupported('alert', device)` gate refused per command, not per subcommand, and + * that wording is parity-pinned. + */ +async function resolveBoundAlertRuntime( + params: Readonly<{ device: DeviceInfo; action: AlertAction }> & RuntimeAdmissionBindings, +): Promise { + const { device, action, inspectFacts, bindDevice } = params; + const shared = { command: 'alert', device, inspectFacts, bindDevice }; + if (action === 'wait') { + const admission = await admitRuntimeUse({ ...shared, use: alertWaitUse }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (input) => executeAwaitAlert(runtime, input) }; } - if (device.platform === 'android') { - const timeoutMs = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; - return recordAlertResponse( - params, - await handleAndroidAlert(device, action, { - timeoutMs, - captureNodes: async () => { - const capture = await snapshotAndroid(device, { includeHiddenContentHints: false }); - return buildSnapshotState(androidSnapshotPublicationInput(capture), undefined).nodes; - }, - }), - ); + if (action === 'accept') { + const admission = await admitRuntimeUse({ ...shared, use: alertAcceptUse }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (input) => executeAcceptAlert(runtime, input) }; } - if (isMacOs(device)) { - const runAlert: NativeAlertRunner = async (alertAction) => - await runMacOsAlertAction(alertAction, macOsAlertTarget); - return await handleNativeAlertCommand(params, action, runAlert); + if (action === 'dismiss') { + const admission = await admitRuntimeUse({ ...shared, use: alertDismissUse }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (input) => executeDismissAlert(runtime, input) }; } - - const runnerOptions = buildAppleRunnerRequestOptions({ - req, - logPath, - traceLogPath: session?.trace?.outPath, - }); - const runAlert: NativeAlertRunner = async (alertAction, timeoutMs) => - await runAppleRunnerCommand( - device, - { command: 'alert', action: alertAction, appBundleId: session?.appBundleId, timeoutMs }, - runnerOptions, - ); - return await handleNativeAlertCommand(params, action, runAlert); + const admission = await admitRuntimeUse({ ...shared, use: alertReadUse }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (input) => executeReadAlert(runtime, input) }; } -async function handleNativeAlertCommand( - params: HandleAlertCommandParams, - action: AlertAction, - runAlert: NativeAlertRunner, -): Promise { - if (action === 'wait') { - return await waitForNativeAlert(params, runAlert); - } - - const resolvedAction = action === 'accept' || action === 'dismiss' ? action : 'get'; - if (resolvedAction === 'accept' || resolvedAction === 'dismiss') { - return await handleNativeAlertAction(params, resolvedAction, runAlert); - } +async function executeReadAlert( + runtime: BoundDeviceRuntime, + input: AlertRuntimeInput, +): Promise> { + return await runtime.operations.readAlert(input); +} - return recordAlertResponse(params, await runAlert('get', DEFAULT_TIMEOUT_MS)); +async function executeAwaitAlert( + runtime: BoundDeviceRuntime, + input: AlertRuntimeInput, +): Promise> { + return await runtime.operations.awaitAlert(input); } -function normalizeAlertAction(action: string | undefined): AlertAction { - if (action === 'accept' || action === 'dismiss' || action === 'wait') return action; - return 'get'; +async function executeAcceptAlert( + runtime: BoundDeviceRuntime, + input: AlertRuntimeInput, +): Promise> { + return await runtime.operations.acceptAlert(input); } -async function waitForNativeAlert( - params: HandleAlertCommandParams, - runAlert: NativeAlertRunner, -): Promise { - const timeout = parseTimeout(params.req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; - const start = Date.now(); - let firstAttempt = true; - while (Date.now() - start < timeout) { - try { - const budgetMs = firstAttempt ? timeout : remainingBudgetMs(start, timeout); - firstAttempt = false; - return recordAlertResponse(params, await runAlert('get', budgetMs)); - } catch { - // keep waiting - } - await sleep(POLL_INTERVAL_MS); - } - return errorResponse('COMMAND_FAILED', 'alert wait timed out'); +async function executeDismissAlert( + runtime: BoundDeviceRuntime, + input: AlertRuntimeInput, +): Promise> { + return await runtime.operations.dismissAlert(input); } -async function handleNativeAlertAction( +export async function handleAlertCommand( params: HandleAlertCommandParams, - action: 'accept' | 'dismiss', - runAlert: NativeAlertRunner, ): Promise { - const runnerTimeoutMs = isIosFamily(params.device) ? DEFAULT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; - const start = Date.now(); - let lastError: unknown; - let firstAttempt = true; - while (Date.now() - start < ALERT_ACTION_RETRY_MS) { - try { - const budgetMs = firstAttempt - ? runnerTimeoutMs - : remainingBudgetMs(start, ALERT_ACTION_RETRY_MS); - firstAttempt = false; - return recordAlertResponse(params, await runAlert(action, budgetMs)); - } catch (err) { - lastError = err; - const msg = String((err as { message?: unknown })?.message ?? '').toLowerCase(); - if (!msg.includes('alert not found') && !msg.includes('no alert')) break; - } - await sleep(POLL_INTERVAL_MS); + const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const action = normalizeAlertAction(req.positionals?.[0]); + const bound = await resolveBoundAlertRuntime({ device, action, inspectFacts, bindDevice }); + if (!bound.ok) return bound.response; + // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait are read-only. + // The alert resolver returns `may-invalidate` only for the acting subactions, so this covers + // the accept/dismiss mutations on every owner without touching the read paths. + if (session && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); } - throw withAlertFallbackHint(lastError); -} - -function remainingBudgetMs(start: number, timeoutMs: number): number { - return Math.max(1, timeoutMs - (Date.now() - start)); -} - -function recordAlertResponse(params: HandleAlertCommandParams, data: unknown): DaemonResponse { - const responseData = data as Record; - recordIfSession(params.sessionStore, params.session, params.req, responseData); - return { ok: true, data: responseData }; + const context = contextFromFlags( + logPath, + req.flags, + session?.appBundleId, + session?.trace?.outPath, + ); + const data = await bound.execute({ + timeoutMs: parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS, + ...alertTarget(session), + execution: runtimeExecutionFromContext(context), + }); + recordIfSession(sessionStore, session, req, data); + return { ok: true, data }; } -function withAlertFallbackHint(error: unknown): unknown { - if (!(error instanceof AppError)) { - return error; - } - if (!isAlertNotFoundError(error)) { - return error; - } - return new AppError(error.code, error.message, { - ...(error.details ?? {}), - hint: ALERT_FALLBACK_HINT, - }); +/** + * The session's own alert target, forwarded as the session holds it. The two fields stay separate + * because a macOS frontmost-app surface means "whatever is frontmost" and must reach its helper + * with no bundle at all — but that narrowing belongs to the Apple owner, which is the only leg + * that ever applied it. The retired route passed `session?.appBundleId` to the XCTest runner + * unconditionally, and it still does. + */ +function alertTarget( + session: SessionState | undefined, +): Readonly<{ appBundleId?: string; surface?: SessionState['surface'] }> { + return { + ...(session?.appBundleId === undefined ? {} : { appBundleId: session.appBundleId }), + ...(session?.surface === undefined ? {} : { surface: session.surface }), + }; } -function isAlertNotFoundError(error: unknown): boolean { - const message = String((error as { message?: unknown })?.message ?? '').toLowerCase(); - return message.includes('alert not found') || message.includes('no alert'); +function normalizeAlertAction(action: string | undefined): AlertAction { + if (action === 'accept' || action === 'dismiss' || action === 'wait') return action; + return 'get'; } diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 54367ee6b9..eb35cdc27f 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -15,12 +15,14 @@ import { } from '@agent-device/kernel/snapshot'; import { resolveRefLabel } from '../../core/snapshot-node-lookup.ts'; import { captureSnapshotWithInteractor } from './snapshot-interactor-capture.ts'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import { buildSnapshotState } from '../../core/snapshot-state.ts'; import { clearAndroidSnapshotFreshness } from '../session-snapshot-freshness.ts'; import type { SnapshotFreshnessMode } from '../../snapshot/snapshot-freshness/index.ts'; import { contextFromFlags } from '../context.ts'; import { resolveDeferredInteractionOutcome } from '../deferred-interaction-outcome.ts'; +import { createInteractionRetryTap } from '../interaction-retry-tap.ts'; import type { SessionState } from '../types.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; type CaptureSnapshotParams = { @@ -39,6 +41,13 @@ type CaptureSnapshotParams = { * until their own command descriptor cuts over. */ captureData?: () => Promise; + /** + * The pending-outcome retry re-fires a bound `tapPoint` (R48), so a capture that can settle a + * deferred interaction outcome carries the request's own runtime bindings and builds the retry + * seam from them. A caller that has none simply never retries. + */ + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }; type SnapshotData = { @@ -68,6 +77,7 @@ export async function captureSnapshot( interactiveOnly: params.flags?.snapshotInteractiveOnly === true, androidFreshnessMode: params.androidFreshnessMode, capture: () => captureSnapshotAttempt(params), + retryTap: createInteractionRetryTap(params), }); if (deferred) return deferred; diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index b544c0e5ae..ae9ba1f8cd 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -4,19 +4,29 @@ import { isMacOsSettingSupported, SETTINGS_INVALID_ARGS_MESSAGE, } from '@agent-device/contracts/settings'; -import { dispatchCommand } from '../../core/dispatch.ts'; +import type { SettingOptions } from '@agent-device/contracts/settings'; +import type { SetSettingInput } from '@agent-device/contracts/settings-runtime'; +import { settingsRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import { contextFromFlags } from '../context.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { recordIfSession } from './snapshot-session.ts'; -import { errorResponse, requireCommandSupported, type DaemonFailureResponse } from './response.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; import { expireRefFrame } from '../ref-frame.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { readLocationCoordinate } from '../../utils/location-coordinates.ts'; +import { successText, withSuccessText } from '../../utils/success-text.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { admitRuntimeUse } from '../runtime-admission.ts'; +import { runtimeExecutionFromContext } from '../snapshot-runtime-capture-input.ts'; type ParsedSettingsArgs = { setting: string; state: string; appBundleId?: string; permissionTarget?: string; + permissionMode?: string; latitude?: string; longitude?: string; }; @@ -28,6 +38,8 @@ type HandleSettingsCommandParams = { session: SessionState | undefined; device: SessionState['device']; parsed: ParsedSettingsArgs; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }; export function parseSettingsArgs( @@ -61,52 +73,131 @@ export function parseSettingsArgs( setting, state, permissionTarget, + permissionMode: req.positionals?.[3], latitude: req.positionals?.[2], longitude: req.positionals?.[3], }, }; } +/** + * The owner-facing options for one mutation. `permission` and `location set` are the two settings + * whose payload is not just `(setting, state)`; everything else sends none. Coordinate typing + * happens here rather than in the owner: `readLocationCoordinate` is input validation, and it + * throws the same `INVALID_ARGS` the retired dispatcher threw from the same point in the + * sequence — after admission, after the frame expiry and the diagnostic, immediately before the + * device call. + */ +function buildSettingOptions(parsed: ParsedSettingsArgs): SettingOptions | undefined { + if (parsed.setting === 'permission') { + return { permissionTarget: parsed.permissionTarget, permissionMode: parsed.permissionMode }; + } + if (parsed.setting === 'location' && parsed.state === 'set') { + return { + latitude: readLocationCoordinate(parsed.latitude, 'latitude'), + longitude: readLocationCoordinate(parsed.longitude, 'longitude'), + }; + } + return undefined; +} + +/** The `settings_apply` payload the retired dispatcher emitted, kept byte-for-byte. */ +function settingsDiagnosticData( + parsed: ParsedSettingsArgs, + appBundleId: string | undefined, + platform: string, +): Record { + const { setting, state } = parsed; + if (setting === 'clear-app-state') { + return { setting: 'clear-app-state', state: 'clear', appBundleId, platform }; + } + if (setting === 'location' && state === 'set') { + return { setting, state, latitude: parsed.latitude, longitude: parsed.longitude, platform }; + } + if (setting === 'permission') { + return { + setting, + state, + permissionTarget: parsed.permissionTarget, + permissionMode: parsed.permissionMode, + platform, + }; + } + return { setting, state, appBundleId, platform }; +} + +function readResultMessage(result: Record): string | undefined { + return typeof result.message === 'string' && result.message.length > 0 + ? result.message + : undefined; +} + +/** + * The ONE place a bound `settings` executes (R58). The owner answers with its own payload or + * nothing; either way the response carries the requested `setting`/`state` and a message the + * owner may override, exactly as the retired leaf composed it. + */ +async function executeSetSetting( + runtime: BoundDeviceRuntime, + input: SetSettingInput, + fallbackMessage: string, +): Promise> { + const { setting, state } = input; + const result = await runtime.operations.setSetting(input); + return result && typeof result === 'object' + ? withSuccessText({ setting, state, ...result }, readResultMessage(result) ?? fallbackMessage) + : { setting, state, ...successText(fallbackMessage) }; +} + export async function handleSettingsCommand( params: HandleSettingsCommandParams, ): Promise { - const { req, logPath, sessionStore, session, device, parsed } = params; - const { - setting, - state, - appBundleId: parsedAppBundleId, - permissionTarget, - latitude, - longitude, - } = parsed; - const unsupported = requireCommandSupported('settings', device); - if (unsupported) return unsupported; + const { req, logPath, sessionStore, session, device, parsed, inspectFacts, bindDevice } = params; + const { setting, state } = parsed; + const admission = await admitRuntimeUse({ + command: 'settings', + device, + use: settingsRuntimeUse, + inspectFacts, + bindDevice, + }); + if (admission.type === 'response') return admission.response; if (isMacOs(device) && !isMacOsSettingSupported(setting)) { return errorResponse('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); } - const appBundleId = parsedAppBundleId ?? session?.appBundleId; + const appBundleId = parsed.appBundleId ?? session?.appBundleId; if (setting === 'clear-app-state' && !appBundleId) { return errorResponse( 'INVALID_ARGS', 'settings clear-app-state requires an app id when no app is bound to the session', ); } - // Settings positional layout for dispatch: setting, state, command payload, appBundleId. - const positionals = - setting === 'clear-app-state' - ? [setting, state, appBundleId ?? ''] - : setting === 'permission' - ? [setting, state, permissionTarget ?? '', req.positionals?.[3] ?? '', appBundleId ?? ''] - : setting === 'location' && state === 'set' - ? [setting, state, latitude ?? '', longitude ?? '', appBundleId ?? ''] - : [setting, state, appBundleId ?? '']; - // ADR 0014 side-effect seam: a settings mutation changes device state; expire - // the frame before the dispatch (settings is always classified may-invalidate). + // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before + // the bound call (settings is always classified may-invalidate). It runs here, ahead of the + // diagnostic and the coordinate typing, because that is where the retired daemon route expired + // it — a request that later fails on a bad coordinate expired the frame then and expires it now. if (session) expireRefFrame(session); - const data = await dispatchCommand(device, 'settings', positionals, req.flags?.out, { - ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath), + emitDiagnostic({ + level: 'debug', + phase: 'settings_apply', + data: settingsDiagnosticData(parsed, appBundleId, device.platform), }); - recordIfSession(sessionStore, session, req, data ?? { setting, state }); - return { ok: true, data: data ?? { setting, state } }; + const options = buildSettingOptions(parsed); + const context = contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath); + const data = await executeSetSetting( + admission.runtime, + { + setting, + state, + ...(appBundleId === undefined ? {} : { appBundleId }), + ...(options === undefined ? {} : { options }), + execution: runtimeExecutionFromContext(context), + }, + setting === 'clear-app-state' + ? `Cleared user data for ${appBundleId}` + : `Updated setting: ${setting}`, + ); + recordIfSession(sessionStore, session, req, data); + return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 72b2d114dd..b92ba8e435 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -52,7 +52,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { inspectFacts, bindDevice, }), - alert: async ({ req, sessionName, logPath, sessionStore }) => { + alert: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => { const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); return await withSessionlessRunnerCleanup(session, device, async () => { return await handleAlertCommand({ @@ -61,10 +61,12 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, session, device, + inspectFacts, + bindDevice, }); }); }, - settings: async ({ req, sessionName, logPath, sessionStore }) => { + settings: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => { const parsedSettings = parseSettingsArgs(req); if (!parsedSettings.ok) return parsedSettings; const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); @@ -76,6 +78,8 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { session, device, parsed: parsedSettings.parsed, + inspectFacts, + bindDevice, }); }); }, diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 8c8b30a0c0..020b2c65d9 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -1,11 +1,10 @@ import type { CommandFlags } from '@agent-device/contracts/command'; -import { dispatchCommand } from '../core/dispatch.ts'; import { isMobilePlatform } from '@agent-device/kernel/device'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { collectKeyboardChromeRefs } from '../core/snapshot-chrome.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; import { isViewportRootNode } from '@agent-device/contracts/snapshot'; -import { contextFromFlags } from './context.ts'; +import { contextFromFlags, type DaemonCommandContext } from './context.ts'; import type { SessionState } from './types.ts'; const OUTCOME_RETRY_WINDOW_MS = 30_000; @@ -18,6 +17,27 @@ export type InteractionSurfaceSignature = NonNullable< export type InteractionSurfaceChange = 'changed' | 'unchanged' | 'ambiguous'; +/** + * How this policy re-fires a recorded tap. The policy owns *whether* a retry is warranted; the + * request route that already admitted a device cell owns *how* the tap reaches the device, and + * supplies it here. Keeping the seam a plain callback is what stops the policy from importing + * runtime admission — a read of "should we retry?" must stay readable without the binding stack. + * + * Answers `false` when the caller's owner cannot tap, so the policy can report a skipped retry + * instead of burning an attempt. + */ +export type InteractionRetryTap = ( + request: Readonly<{ + device: SessionState['device']; + point: Readonly<{ x: number; y: number }>; + context: InteractionRetryContext; + }>, +) => Promise; + +/** The runner metadata a re-fired tap carries — the same context any bound touch executes on. */ +export type InteractionRetryContext = DaemonCommandContext & + Readonly<{ surface?: SessionState['surface'] }>; + function shouldRetryTouchOnNoChange(flags: CommandFlags | undefined): boolean { return flags?.interactionOutcome?.retryOnNoChange === true; } @@ -74,8 +94,9 @@ export async function retryPendingInteractionOutcome(params: { pending: NonNullable; logPath: string; snapshot: SnapshotState; + retryTap?: InteractionRetryTap; }): Promise<{ retried: boolean; change: InteractionSurfaceChange }> { - const { session, pending, snapshot } = params; + const { pending, snapshot } = params; const change = classifyInteractionSurfaceChange( pending.preSignature, buildInteractionSurfaceSignature(snapshot.nodes), @@ -84,14 +105,30 @@ export async function retryPendingInteractionOutcome(params: { return { retried: false, change }; } + // The retry re-fires the same coordinate tap the original press used (R48). Nothing was + // attempted when this capture path carries no retry seam or the recorded coordinates are + // unreadable, so those leave the pending record whole instead of burning an attempt, and say so + // rather than letting the caller infer it from an unchanged surface. + const retryTap = params.retryTap; + const point = retryTap ? readRetryPoint(pending.positionals) : undefined; + if (!retryTap || !point) { + emitSkippedRetry(pending, retryTap ? 'unreadable-retry-point' : 'device-runtime-unavailable'); + return { retried: false, change }; + } + const startedAt = Date.now(); + // Spent before the device work, matching the retired route: an owner that refuses the tap or + // fails mid-flight has still consumed the attempt, so the next capture inside the pending + // window cannot re-attempt it from a full budget. pending.attemptsRemaining -= 1; // Opt-in Maestro retries intentionally re-fire the same coordinate tap; delayed or // non-visual side effects can duplicate, but unchanged visual taps are the target gap. - await dispatchCommand(session.device, pending.command, pending.positionals, pending.flags?.out, { - ...contextFromFlags(params.logPath, pending.flags, session.appBundleId, session.trace?.outPath), - surface: session.surface, - }); + const fired = await fireRetryTap(retryTap, params); + if (!fired) { + emitSkippedRetry(pending, 'retry-tap-unavailable'); + return { retried: false, change }; + } + emitDiagnostic({ level: 'info', phase: 'interaction_no_change_retry', @@ -104,6 +141,57 @@ export async function retryPendingInteractionOutcome(params: { return { retried: true, change }; } +/** + * The seam is allowed to refuse and allowed to fail; neither may escape into the capture this + * retry decorates. A press whose re-fire dies on the device leaves the caller with the honest + * unchanged surface plus a diagnostic, not a failed `snapshot`. + */ +async function fireRetryTap( + retryTap: InteractionRetryTap, + params: Readonly<{ + session: SessionState; + pending: NonNullable; + logPath: string; + }>, +): Promise { + const { session, pending } = params; + const point = readRetryPoint(pending.positionals); + if (!point) return false; + try { + return await retryTap({ + device: session.device, + point, + context: { + ...contextFromFlags( + params.logPath, + pending.flags, + session.appBundleId, + session.trace?.outPath, + ), + surface: session.surface, + }, + }); + } catch { + return false; + } +} + +/** Never silent: a retry the opt-in flag asked for and this request did not deliver says why. */ +function emitSkippedRetry( + pending: NonNullable, + reason: 'device-runtime-unavailable' | 'unreadable-retry-point' | 'retry-tap-unavailable', +): void { + emitDiagnostic({ + level: 'info', + phase: 'interaction_no_change_retry_skipped', + data: { + action: pending.action, + attemptsRemaining: pending.attemptsRemaining, + reason, + }, + }); +} + export function emitInteractionSettled(params: { pending: NonNullable; change: InteractionSurfaceChange; @@ -369,6 +457,16 @@ function supportsInteractionOutcomePolicy(session: SessionState): boolean { return isMobilePlatform(session.device); } +/** + * The pending record stores the coordinate pair `isCoordinatePair` already validated, so this + * only re-reads it; a record that somehow fails the read is skipped rather than retried blind. + */ +function readRetryPoint(positionals: readonly string[]): { x: number; y: number } | undefined { + const x = Number(positionals[0]); + const y = Number(positionals[1]); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined; +} + function retryCommandForTap(command: string): string | undefined { if (command === 'click') return 'press'; if (command === 'press') return 'press'; diff --git a/src/daemon/interaction-retry-tap.ts b/src/daemon/interaction-retry-tap.ts new file mode 100644 index 0000000000..95231b65c7 --- /dev/null +++ b/src/daemon/interaction-retry-tap.ts @@ -0,0 +1,49 @@ +import type { InteractionRetryTap } from './interaction-outcome-policy.ts'; +import type { RuntimeAdmissionBindings } from './request-runtime-binding.ts'; +import { createBoundTouchExecutor, resolveBoundTouchRuntime } from './touch-runtime.ts'; + +/** + * Supplies the interaction-outcome policy with the one device effect it needs: re-firing a + * recorded coordinate tap through the same bound `tapPoint` the original press used (R48). + * + * It lives beside neither party. The policy decides whether a retry is warranted and must stay + * readable without the binding stack, so it declares the seam and never imports admission; the + * capture route holds the request's bindings but has no business knowing which touch plan a + * re-fired tap needs. This is the adapter between them, and every request route that can retry + * builds it from its own bindings rather than reaching into a module-level one. + * + * Answers `undefined` for a route with no bindings to admit with — an internal capture that + * decorates someone else's request cannot fire a tap, and the policy reports that as a skip. + */ +export function createInteractionRetryTap( + bindings: RuntimeAdmissionBindings, +): InteractionRetryTap | undefined { + const { inspectFacts, bindDevice } = bindings; + if (!inspectFacts || !bindDevice) return undefined; + // ADR 0019 §9 is one admission per handler, and the outcome policy can call this seam once per + // retry round. Memoizing per device keeps the facts inspection and the narrowing to the first + // round: `bindDevice` already caches the underlying binding, so re-resolving would only re-read + // facts the request has already admitted on. + const resolutions = new Map>(); + return async ({ device, point, context }) => { + // Admission runs before the policy spends an attempt: an owner whose cell cannot tap answers + // `false` here and leaves the pending record intact. + let resolution = resolutions.get(device.id); + if (!resolution) { + resolution = resolveBoundTouchRuntime({ + device, + command: 'press', + requiresCapture: false, + inspectFacts, + bindDevice, + }); + resolutions.set(device.id, resolution); + } + const bound = await resolution; + if (!bound.ok) return false; + const executor = createBoundTouchExecutor(bound.runtime, context); + if (!executor.tapPoint) return false; + await executor.tapPoint(point); + return true; + }; +} diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index dde34355a5..81751903c1 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -1,11 +1,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { SettleObservation } from '@agent-device/contracts/interaction'; -import { - commandSupportsSettleObservation, - commandUsesDeviceRuntimeExecution, -} from '../core/command-descriptor/registry.ts'; -import { dispatchCommand } from '../core/dispatch.ts'; -import { requireCommandSupported } from './handlers/response.ts'; +import { commandSupportsSettleObservation } from '../core/command-descriptor/registry.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; @@ -39,9 +34,9 @@ export type GenericPlatformExecutionParams = { }; /** - * What actually performs a generic leaf's platform work. Legacy leaves get - * {@link executeGenericPlatformCommand}; a command migrated onto a request-bound device runtime - * supplies its own already-admitted, already-bound closure instead (ADR 0019). + * What actually performs a generic leaf's platform work: the already-admitted, already-bound + * closure the leaf's own runtime resolution supplied (ADR 0019). R58 retired the legacy + * alternative, so this is the only shape. */ export type GenericPlatformExecution = ( params: GenericPlatformExecutionParams, @@ -288,12 +283,11 @@ async function ensureGenericCommandReady( session: SessionState, platformCommand: string, ): Promise { - // A device-runtime command has no capability bucket: its exact owner facts already admitted it - // (or refused it) before this route was reached. - const unsupported = commandUsesDeviceRuntimeExecution(platformCommand) - ? null - : requireCommandSupported(platformCommand, session.device, { hint: true }); - if (unsupported) return { response: unsupported }; + // No support gate survives here. R56 migrated `app-switcher`, the last generic-route descriptor + // with a capability bucket, so every command that reaches this dispatcher was already admitted + // (or refused) against its exact owner's facts by `resolveGenericRuntimeExecution` — which fails + // closed for anything it has no arm for. A second `requireCommandSupported` call would be a + // support authority beside the facts, which ADR 0019 §8 forbids. if ( session.device.platform !== 'android' || isActiveProviderDevice(session.device) || @@ -317,13 +311,6 @@ async function ensureGenericCommandReady( }; } -export const executeGenericPlatformCommand: GenericPlatformExecution = async (params) => { - const { session, command, positionals, out, dispatchContext } = params; - return await dispatchCommand(session.device, command, positionals, out, { - ...dispatchContext, - }); -}; - function recordVisualizationAndAction(params: { session: SessionState; sessionStore: SessionStore; diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 2b757fc8dc..450ce880f4 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -185,6 +185,11 @@ async function runReactNativeHandler( logPath: params.logPath, sessionStore: params.sessionStore, contextFromFlags: params.contextFromFlags, + // R61: overlay dismissal admits and binds the owner's own `tapPoint`, so the chain now has + // to pass the request's bindings through. Before R61 this leg reached the device through the + // retired dispatcher and needed none, which is why the arm had no bindings to forward. + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }), ); } diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 067a87a632..f3f8e13691 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -451,10 +451,9 @@ async function dispatchGenericForLockedScope(params: { inspectFacts: lockedScope.inspectFacts, bindDevice: lockedScope.bindDevice, }); - if (runtimeExecution && !runtimeExecution.ok) return runtimeExecution.response; + if (!runtimeExecution.ok) return runtimeExecution.response; - const { dispatchGenericCommand, executeGenericPlatformCommand } = - await loadGenericRequestHandlerModule(); + const { dispatchGenericCommand } = await loadGenericRequestHandlerModule(); const dispatchResponse = await dispatchGenericCommand({ req: lockedScope.req, session, @@ -462,8 +461,8 @@ async function dispatchGenericForLockedScope(params: { logPath, sessionStore, contextFromFlags: lockedScope.contextFromFlags, - executePlatformCommand: runtimeExecution?.execute ?? executeGenericPlatformCommand, - ...(runtimeExecution?.recorded ? { recordedRequest: runtimeExecution.recorded } : {}), + executePlatformCommand: runtimeExecution.execute, + ...(runtimeExecution.recorded ? { recordedRequest: runtimeExecution.recorded } : {}), }); return dispatchResponse; } diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts index b17f465b84..afb572b849 100644 --- a/src/daemon/request-runtime-binding.ts +++ b/src/daemon/request-runtime-binding.ts @@ -31,6 +31,18 @@ export type BindDeviceRuntime = < BoundDeviceRuntime> >; +/** + * The two request-scoped seams a caller threads down to whichever route finally admits a device + * cell. It lives here, beside the two function types it is composed of, rather than beside the + * admission entry point that consumes it: callers that only forward the seams (a deferred capture + * handing them to a retry, say) would otherwise have to import the whole admission module and + * close a type cycle through it. + */ +export type RuntimeAdmissionBindings = Readonly<{ + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; +}>; + export type BindExactDeviceRuntime = < const Required extends readonly RuntimeOperationKey[], const Preferred extends readonly Exclude< diff --git a/src/daemon/runtime-admission.ts b/src/daemon/runtime-admission.ts index 7ad73ea1ec..d374787636 100644 --- a/src/daemon/runtime-admission.ts +++ b/src/daemon/runtime-admission.ts @@ -7,7 +7,11 @@ import type { } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, + RuntimeAdmissionBindings, +} from './request-runtime-binding.ts'; import { errorResponse, type DaemonFailureResponse } from './handlers/response.ts'; import type { DaemonCommandContext } from './context.ts'; import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; @@ -25,17 +29,16 @@ export type RuntimeAdmission = | Readonly<{ type: 'response'; response: DaemonFailureResponse }> | Readonly<{ type: 'runtime'; runtime: BoundDeviceRuntime }>; -export type RuntimeAdmissionRequest = Readonly<{ - /** Command wording for the default unsupported message, e.g. `open`, `runtime port-reverse`. */ - command: string; - device: DeviceInfo; - required: readonly RuntimeOperationKey[]; - inspectFacts?: InspectDeviceRuntimeFacts; - bindDevice?: BindDeviceRuntime; - unavailableResponse?: UnavailableRuntimeResponse; -}>; +export type RuntimeAdmissionRequest = RuntimeAdmissionBindings & + Readonly<{ + /** Command wording for the default unsupported message, e.g. `open`, `runtime port-reverse`. */ + command: string; + device: DeviceInfo; + required: readonly RuntimeOperationKey[]; + unavailableResponse?: UnavailableRuntimeResponse; + }>; -export type RuntimeAdmissionBindings = Pick; +export type { RuntimeAdmissionBindings }; /** * The one facts-admission seam every migrated command route shares. It performs exactly one diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index 9da78cba1e..96bfbb8666 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -21,7 +21,7 @@ import { import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; import type { DaemonCommandContext } from './context.ts'; import { captureSnapshotData } from './handlers/snapshot-capture.ts'; -import { buildSnapshotState } from './snapshot-state.ts'; +import { buildSnapshotState } from '../core/snapshot-state.ts'; import type { RecordedGenericRequest, ResolvedGenericExecution, diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index 7cac0cdb30..ab69cd2e5d 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -11,6 +11,7 @@ import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts'; import type { CommandSessionRecord } from '../runtime.ts'; import { createAgentDevice } from '../runtime.ts'; import { getRequestSignal } from '../request/cancel.ts'; +import type { RuntimeAdmissionBindings } from './request-runtime-binding.ts'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; import { captureSnapshot } from './handlers/snapshot-capture.ts'; import { buildSnapshotSession, withSessionlessRunnerCleanup } from './handlers/snapshot-session.ts'; @@ -69,6 +70,8 @@ export async function dispatchSnapshotRuntimeCommand( snapshotScope, capturedQuality, captureSnapshotData: capture.captureSnapshot, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }); let result: Awaited>; try { @@ -105,17 +108,19 @@ export async function dispatchSnapshotRuntimeCommand( }); } -function createSnapshotRuntime(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - session: SessionState | undefined; - device: SessionState['device']; - snapshotScope: string | undefined; - capturedQuality: CapturedSnapshotQuality; - captureSnapshotData: () => Promise; -}) { +function createSnapshotRuntime( + params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + session: SessionState | undefined; + device: SessionState['device']; + snapshotScope: string | undefined; + capturedQuality: CapturedSnapshotQuality; + captureSnapshotData: () => Promise; + } & RuntimeAdmissionBindings, +) { const { req, sessionName, logPath, sessionStore, session, device, snapshotScope } = params; return createAgentDevice({ backend: createDaemonSnapshotBackend({ @@ -126,6 +131,8 @@ function createSnapshotRuntime(params: { snapshotScope, capturedQuality: params.capturedQuality, captureSnapshotData: params.captureSnapshotData, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }), ...createDaemonRuntimePolicy('snapshot'), signal: getRequestSignal(req.meta?.requestId), @@ -221,15 +228,17 @@ function resolveNextSnapshotScopeSource(params: { return current?.snapshotScopeSource ?? current?.snapshot; } -function createDaemonSnapshotBackend(params: { - req: DaemonRequest; - logPath: string; - session: SessionState | undefined; - device: SessionState['device']; - snapshotScope: string | undefined; - capturedQuality: CapturedSnapshotQuality; - captureSnapshotData: () => Promise; -}): AgentDeviceBackend { +function createDaemonSnapshotBackend( + params: { + req: DaemonRequest; + logPath: string; + session: SessionState | undefined; + device: SessionState['device']; + snapshotScope: string | undefined; + capturedQuality: CapturedSnapshotQuality; + captureSnapshotData: () => Promise; + } & RuntimeAdmissionBindings, +): AgentDeviceBackend { const { req, logPath, session, device, snapshotScope } = params; return { platform: publicPlatformString(device), @@ -243,6 +252,10 @@ function createDaemonSnapshotBackend(params: { snapshotScope, signal: context.signal, captureData: params.captureSnapshotData, + // R48's pending-outcome retry re-fires a bound `tapPoint`, so the `snapshot` that settles + // a deferred outcome carries the request's own bindings down to the capture. + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }); const annotations = snapshotCaptureAnnotationsFrom(capture); params.capturedQuality.value = annotations.quality; diff --git a/src/daemon/touch-runtime.ts b/src/daemon/touch-runtime.ts index ccddff328e..02a9675802 100644 --- a/src/daemon/touch-runtime.ts +++ b/src/daemon/touch-runtime.ts @@ -25,12 +25,16 @@ import { requireIntInRange } from '../utils/validation.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DirectIosSelectorTarget } from './direct-ios-selector.ts'; import type { DaemonFailureResponse } from './handlers/response.ts'; -import { admitRuntimeOperations, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { + admitRuntimeOperations, + type RuntimeAdmissionBindings, + type UnavailableRuntimeResponse, +} from './runtime-admission.ts'; import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; export type TouchRuntimeCommand = 'click' | 'press' | 'fill' | 'longpress' | 'hover'; -type BoundTouchRuntime = +export type BoundTouchRuntime = | Readonly<{ kind: 'tap'; captured: false; runtime: BoundDeviceRuntime }> | Readonly<{ kind: 'tap'; captured: true; runtime: BoundDeviceRuntime }> | Readonly<{ @@ -71,6 +75,8 @@ export async function resolveBoundTouchRuntime( device: DeviceInfo; command: TouchRuntimeCommand; requiresCapture: boolean; + /** A caller whose command is not the touch leaf itself supplies its own refusal wording. */ + unavailableResponse?: UnavailableRuntimeResponse; } & RuntimeAdmissionBindings, ): Promise { const shared = { @@ -82,6 +88,7 @@ export async function resolveBoundTouchRuntime( const admission = await admitRuntimeOperations({ command: params.command, required: plan.use.required, + ...(params.unavailableResponse ? { unavailableResponse: params.unavailableResponse } : {}), ...shared, }); if (admission.type === 'response') return { ok: false, response: admission.response }; diff --git a/src/platform-runtime-android-tool-host.ts b/src/platform-runtime-android-tool-host.ts index b64cbcabbc..ae2a9ee5b9 100644 --- a/src/platform-runtime-android-tool-host.ts +++ b/src/platform-runtime-android-tool-host.ts @@ -3,6 +3,34 @@ import type { AndroidToolHost } from '@agent-device/contracts/platform'; /** Provider-aware Android transport. Command semantics and arguments stay package-owned. */ export function createAndroidToolHost(): AndroidToolHost { return Object.freeze({ + /** + * Definitive in both directions only where adb actually answers, and honest everywhere else. + * The probe runs with `allowFailure`, so a device that is offline, unauthorized, timed out or + * otherwise broken comes back as an ordinary non-zero result rather than a throw. + * + * The exit code is read first and settles the answer on its own when it is zero, because on a + * clean exit `stdout` is the clipboard's *contents* -- attacker-free but arbitrary user text, + * which may well quote an error. Only a failed call can carry prose about the call itself, so + * the missing-shell phrases are interpreted on non-zero exits alone. Every remaining result -- + * non-zero without that prose, or a transport throw -- is `probe-failed`, so admission refuses + * instead of caching a verdict the operation would then contradict. + */ + probeClipboardShellSupport: async (device, signal) => { + try { + const { runAndroidAdb, isClipboardShellUnsupported } = + await import('./platforms/android/adb.ts'); + const result = await runAndroidAdb(device, ['shell', 'cmd', 'clipboard', 'get', 'text'], { + allowFailure: true, + signal, + }); + if (result.exitCode === 0) return 'supported'; + return isClipboardShellUnsupported(result.stdout, result.stderr) + ? 'unsupported' + : 'probe-failed'; + } catch { + return 'probe-failed'; + } + }, runAdb: async (device, args, options, signal) => { const { runAndroidAdb } = await import('./platforms/android/adb.ts'); const result = await runAndroidAdb(device, [...args], { diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index 662401f957..28c0166e07 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -58,6 +58,15 @@ describe('composed platform runtime gateway', () => { keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, + appSwitcher: unavailable, + triggerAppEvent: unavailable, + setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, touch: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, @@ -148,6 +157,15 @@ describe('composed platform runtime gateway', () => { keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, + appSwitcher: unavailable, + triggerAppEvent: unavailable, + setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/platform-runtime-gateway.ts b/src/platform-runtime-gateway.ts index 1619a1bdc6..71f8ff05c0 100644 --- a/src/platform-runtime-gateway.ts +++ b/src/platform-runtime-gateway.ts @@ -322,6 +322,15 @@ function unavailableProviderBinding( keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, + appSwitcher: unavailable, + triggerAppEvent: unavailable, + setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }); } @@ -353,6 +362,15 @@ function unavailableProviderFacts(runtime: ProviderDeviceRuntime, device: Device keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, + appSwitcher: unavailable, + triggerAppEvent: unavailable, + setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, readiness: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }, diff --git a/src/platforms/android/__tests__/device-input-state.test.ts b/src/platforms/android/__tests__/device-input-state.test.ts index c9afb65f77..8f47d0aa3c 100644 --- a/src/platforms/android/__tests__/device-input-state.test.ts +++ b/src/platforms/android/__tests__/device-input-state.test.ts @@ -7,6 +7,7 @@ import { dismissAndroidKeyboard, getAndroidKeyboardState, getAndroidKeyboardStatusWithAdb, + readAndroidClipboardWithAdb, writeAndroidClipboardWithAdb, } from '../device-input-state.ts'; import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts'; @@ -232,6 +233,38 @@ test('writeAndroidClipboardWithAdb leaves safe text unquoted', async () => { assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', 'android-otp']]); }); +// A successful `clipboard get text` puts the clipboard's *contents* on stdout, so the missing-shell +// prose is only ever evidence about a call that failed. Reading it on a clean exit turned a user who +// had copied one of these phrases into a device that "does not support" its own working clipboard. +test('readAndroidClipboardWithAdb returns contents that read like an adb refusal', async () => { + for (const contents of ['Unknown command: clipboard', 'No shell command implementation.']) { + const adb: AndroidAdbExecutor = async () => ({ stdout: contents, stderr: '', exitCode: 0 }); + assert.equal(await readAndroidClipboardWithAdb(adb), contents); + } +}); + +test('readAndroidClipboardWithAdb still reports a genuine missing shell command', async () => { + const adb: AndroidAdbExecutor = async () => ({ + stdout: '', + stderr: 'Unknown command: clipboard', + exitCode: 255, + }); + + await assertRejectsAppError(() => readAndroidClipboardWithAdb(adb), { + code: 'UNSUPPORTED_OPERATION', + }); +}); + +test('readAndroidClipboardWithAdb reports a non-zero failure that names no missing command', async () => { + const adb: AndroidAdbExecutor = async () => ({ + stdout: '', + stderr: 'error: device offline', + exitCode: 1, + }); + + await assert.rejects(() => readAndroidClipboardWithAdb(adb)); +}); + test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => { await withFakeAdb( (args) => { diff --git a/src/platforms/android/__tests__/snapshot-occlusion-context.test.ts b/src/platforms/android/__tests__/snapshot-occlusion-context.test.ts index ef7d005047..af5ab174e3 100644 --- a/src/platforms/android/__tests__/snapshot-occlusion-context.test.ts +++ b/src/platforms/android/__tests__/snapshot-occlusion-context.test.ts @@ -3,7 +3,7 @@ import { afterEach, test, vi } from 'vitest'; import { readSnapshotOcclusionContextEvidence } from '@agent-device/contracts/capture'; import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../__tests__/test-utils/android-snapshot-helper.ts'; -import { buildSnapshotState } from '../../../daemon/snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { coveredAndroidReplacementNodeIndexes } from '../../../snapshot/android-replacement-surface-occlusion.ts'; import { resetAndroidSnapshotHelperInstallCache } from '../snapshot-helper-install.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; diff --git a/src/platforms/android/__tests__/ui-hierarchy-scope.test.ts b/src/platforms/android/__tests__/ui-hierarchy-scope.test.ts index b3cbcb727e..86af1c1419 100644 --- a/src/platforms/android/__tests__/ui-hierarchy-scope.test.ts +++ b/src/platforms/android/__tests__/ui-hierarchy-scope.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { buildSnapshotState } from '../../../daemon/snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { parseUiHierarchy } from './ui-hierarchy-fixtures.ts'; import { AndroidSnapshotPresentationFailure, diff --git a/src/platforms/android/__tests__/ui-hierarchy.test.ts b/src/platforms/android/__tests__/ui-hierarchy.test.ts index 95bc1d764c..a29c703a8b 100644 --- a/src/platforms/android/__tests__/ui-hierarchy.test.ts +++ b/src/platforms/android/__tests__/ui-hierarchy.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { buildSnapshotState } from '../../../daemon/snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { isNodeVisibleOnScreen } from '@agent-device/contracts/snapshot'; import { androidUiNodes, parseUiHierarchyTree } from '../ui-hierarchy.ts'; import { parseUiHierarchy } from './ui-hierarchy-fixtures.ts'; diff --git a/src/platforms/android/adb.ts b/src/platforms/android/adb.ts index 4aa86a8f32..fac14c2097 100644 --- a/src/platforms/android/adb.ts +++ b/src/platforms/android/adb.ts @@ -15,6 +15,18 @@ export async function runAndroidAdb( return await resolveAndroidAdbExecutor(device)(args, options); } +/** + * Whether an adb `cmd clipboard` invocation was refused because this build ships no shell + * implementation for the clipboard service, rather than because the call itself failed. + * + * adb reports this condition in its output and nowhere else — no exit code or structured field + * separates "service has no shell command" from any other non-zero result — so this is the one + * place that reads that prose, and it hands every caller a typed answer instead. + * + * Only ever ask this about a call that *failed*. A successful `clipboard get text` returns the + * clipboard's contents on stdout, which is arbitrary user text and may quote these very phrases; + * callers must settle a zero exit as success before reaching for this. + */ export function isClipboardShellUnsupported(stdout: string, stderr: string): boolean { const haystack = `${stdout}\n${stderr}`.toLowerCase(); return ( diff --git a/src/platforms/android/device-input-state.ts b/src/platforms/android/device-input-state.ts index 362cc8bb66..45bade05a3 100644 --- a/src/platforms/android/device-input-state.ts +++ b/src/platforms/android/device-input-state.ts @@ -320,16 +320,17 @@ async function runAndroidClipboardShellCommand( operation: 'read' | 'write', ): Promise { const result = await adb(args, { allowFailure: true }); + // A clean exit settles it before the prose is consulted at all: on a successful read `stdout` is + // the clipboard's contents, and a user who has copied one of the missing-shell phrases must not + // have their own text mistaken for adb refusing the command. + if (result.exitCode === 0) return result.stdout; if (isClipboardShellUnsupported(result.stdout, result.stderr)) { throw new AppError( 'UNSUPPORTED_OPERATION', `Android shell clipboard ${operation} is not supported on this device.`, ); } - if (result.exitCode !== 0) { - throw androidAdbResultError(`Failed to ${operation} Android clipboard text`, result); - } - return result.stdout; + throw androidAdbResultError(`Failed to ${operation} Android clipboard text`, result); } function normalizeAndroidClipboardText(stdout: string): string { diff --git a/src/platforms/apple/__tests__/alert.test.ts b/src/platforms/apple/__tests__/alert.test.ts new file mode 100644 index 0000000000..fc291dac79 --- /dev/null +++ b/src/platforms/apple/__tests__/alert.test.ts @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import { afterEach, test, vi } from 'vitest'; + +vi.mock('../core/runner/runner-client.ts', () => ({ runAppleRunnerCommand: vi.fn() })); +vi.mock('../os/macos/helper.ts', () => ({ runMacOsAlertAction: vi.fn() })); + +import { + ALERT_NOT_FOUND_REASON, + ALERT_NOT_FOUND_RUNNER_CODE, +} from '@agent-device/contracts/alert-contract'; +import { AppError } from '@agent-device/kernel/errors'; +import { IOS_SIMULATOR, MACOS_DEVICE } from '../../../__tests__/test-utils/device-fixtures.ts'; +import { runAppleRunnerCommand } from '../core/runner/runner-client.ts'; +import { runMacOsAlertAction } from '../os/macos/helper.ts'; +import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from '../alert.ts'; + +const mockRunner = vi.mocked(runAppleRunnerCommand); +const mockHelper = vi.mocked(runMacOsAlertAction); +const runnerOptions = {}; + +/** + * Absence as each backend states it. The message is deliberately the same prose the old predicate + * matched on, so a test that passes here is passing on the typed evidence and nothing else. + */ +function runnerAbsence(): AppError { + return new AppError('COMMAND_FAILED', 'alert not found', { + runnerErrorCode: ALERT_NOT_FOUND_RUNNER_CODE, + }); +} + +function helperAbsence(): AppError { + return new AppError('COMMAND_FAILED', 'alert not found', { reason: ALERT_NOT_FOUND_REASON }); +} + +afterEach(() => { + vi.useRealTimers(); + mockRunner.mockReset(); + mockHelper.mockReset(); +}); + +// R59 moved these windows out of the daemon: how long a transient sheet takes to appear, and how +// many times to re-ask a runner that says it is not there yet, are Apple family mechanics. +test('a read spends the family default, not the caller window', async () => { + mockRunner.mockResolvedValue({ title: 'Camera Access' }); + + await readAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 37 }); + + assert.equal(mockRunner.mock.calls.length, 1); + assert.deepEqual(mockRunner.mock.calls[0]?.[1], { + command: 'alert', + action: 'get', + appBundleId: undefined, + timeoutMs: 10_000, + }); +}); + +test('a wait polls until one attempt answers, and the first attempt gets the whole window', async () => { + let calls = 0; + mockRunner.mockImplementation(async () => { + calls += 1; + if (calls === 1) throw runnerAbsence(); + return { title: 'Camera Access' }; + }); + + const result = await awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 }); + + assert.deepEqual(result, { title: 'Camera Access' }); + assert.equal(calls, 2); + const budgets = mockRunner.mock.calls.map((call) => (call[1] as { timeoutMs: number }).timeoutMs); + assert.equal(budgets[0], 5_000); + // Later attempts get only what is left, so a blocking runner cannot outlive the budget. + assert.ok(budgets[1] !== undefined && budgets[1] <= 5_000); +}); + +test('a wait that never sees an alert reports the timeout rather than the last attempt error', async () => { + vi.useFakeTimers(); + mockRunner.mockRejectedValue(runnerAbsence()); + + const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 900 }).then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(2_000); + + assert.match(String(((await outcome) as Error).message), /alert wait timed out/); +}); + +test('an accept retries only while the backend says the alert is not there yet', async () => { + vi.useFakeTimers(); + let calls = 0; + mockRunner.mockImplementation(async () => { + calls += 1; + throw new AppError('COMMAND_FAILED', 'runner crashed'); + }); + + const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(3_500); + + assert.match(String(((await outcome) as Error).message), /runner crashed/); + assert.equal(calls, 1); +}); + +test('an exhausted accept carries the scoped-snapshot fallback the agent needs next', async () => { + vi.useFakeTimers(); + mockRunner.mockRejectedValue(runnerAbsence()); + + const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(3_500); + const error = (await outcome) as AppError; + + assert.equal(error.message, 'alert not found'); + assert.match(String(error.details?.hint), /scoped snapshot/i); +}); + +// The whole point of typing absence: a backend that failed for any other reason must not be +// retried until the window expires and then reported as a timeout. These three pin that the +// evidence — not the message text — is what decides. +test('a wait propagates a non-absence failure instead of spending it as poll budget', async () => { + vi.useFakeTimers(); + let calls = 0; + // The message deliberately says "alert not found"; only the missing typed evidence matters. + mockRunner.mockImplementation(async () => { + calls += 1; + throw new AppError('COMMAND_FAILED', 'runner transport closed: alert not found'); + }); + + const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 }).then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(6_000); + + assert.match(String(((await outcome) as Error).message), /runner transport closed/); + assert.equal(calls, 1); +}); + +test('an action does not retry a failure that only reads like an absence', async () => { + vi.useFakeTimers(); + let calls = 0; + mockRunner.mockImplementation(async () => { + calls += 1; + throw new AppError('COMMAND_FAILED', 'no alert service on this runner'); + }); + + const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'dismiss').then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(3_500); + const error = (await outcome) as AppError; + + assert.equal(calls, 1); + // The fallback hint is absence-only advice, so an untyped failure must not carry it either. + assert.equal(error.details?.hint, undefined); +}); + +test('the macOS helper states absence as a typed reason, and the family retries it', async () => { + vi.useFakeTimers(); + let calls = 0; + mockHelper.mockImplementation(async () => { + calls += 1; + throw helperAbsence(); + }); + + const outcome = actOnAppleAlert(MACOS_DEVICE, runnerOptions, 'accept').then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(3_500); + const error = (await outcome) as AppError; + + assert.ok(calls > 1, 'a typed absence is retried'); + assert.match(String(error.details?.hint), /scoped snapshot/i); +}); + +// The macOS host answers through its helper, and a frontmost-app session names no bundle at all. +test('the macOS host reads through its helper, and a frontmost-app session names no bundle', async () => { + mockHelper.mockResolvedValue({ title: 'Allow access' }); + + await readAppleAlert(MACOS_DEVICE, runnerOptions, { + surface: 'frontmost-app', + appBundleId: 'com.example.app', + }); + + assert.equal(mockRunner.mock.calls.length, 0); + assert.deepEqual(mockHelper.mock.calls[0], ['get', { surface: 'frontmost-app' }]); +}); + +// The narrowing is the macOS helper's alone. A non-macOS Apple leaf keeps the session bundle +// whatever the surface says, exactly as the retired route passed it to the XCTest runner. +test('the XCTest runner keeps the session bundle even on a frontmost-app surface', async () => { + mockRunner.mockResolvedValue({ title: 'Camera Access' }); + + await readAppleAlert(IOS_SIMULATOR, runnerOptions, { + surface: 'frontmost-app', + appBundleId: 'com.example.app', + }); + + assert.deepEqual(mockRunner.mock.calls[0]?.[1], { + command: 'alert', + action: 'get', + appBundleId: 'com.example.app', + timeoutMs: 10_000, + }); +}); + +test('a macOS app session forwards the bundle its surface is scoped to', async () => { + mockHelper.mockResolvedValue({ action: 'dismiss' }); + + await actOnAppleAlert(MACOS_DEVICE, runnerOptions, 'dismiss', { + surface: 'app', + appBundleId: 'com.example.app', + }); + + assert.deepEqual(mockHelper.mock.calls[0], [ + 'dismiss', + { bundleId: 'com.example.app', surface: 'app' }, + ]); +}); diff --git a/src/platforms/apple/__tests__/interactor-runner-provider.test.ts b/src/platforms/apple/__tests__/interactor-runner-provider.test.ts index 5881729fc2..4147b3a65e 100644 --- a/src/platforms/apple/__tests__/interactor-runner-provider.test.ts +++ b/src/platforms/apple/__tests__/interactor-runner-provider.test.ts @@ -70,6 +70,13 @@ const RUNNER_TRANSPORT_METHODS: Record< tvRemote: { invoke: (i) => i.tvRemote('select'), runnerCommand: 'remotePress' }, keyboardDismiss: { invoke: (i) => i.keyboardDismiss!(), runnerCommand: 'keyboardDismiss' }, keyboardEnter: { invoke: (i) => i.keyboardEnter!(), runnerCommand: 'keyboardReturn' }, + // R59: same reading as `readTextAtPoint` — the macOS-helper branch is reachable only for a + // local desktop surface, which a provider-owned mobile device never carries, so every + // provider-backed alert leg rides the runner. Each spends one runner call when it succeeds. + readAlert: { invoke: (i) => i.readAlert(), runnerCommand: 'alert' }, + awaitAlert: { invoke: (i) => i.awaitAlert(), runnerCommand: 'alert' }, + acceptAlert: { invoke: (i) => i.acceptAlert(), runnerCommand: 'alert' }, + dismissAlert: { invoke: (i) => i.dismissAlert(), runnerCommand: 'alert' }, }; const LOCAL_TOOL_METHODS: Record Promise> = { diff --git a/src/platforms/apple/alert.ts b/src/platforms/apple/alert.ts new file mode 100644 index 0000000000..8a917ce39f --- /dev/null +++ b/src/platforms/apple/alert.ts @@ -0,0 +1,152 @@ +import { + ALERT_ACTION_RETRY_MS, + ALERT_NOT_FOUND_REASON, + ALERT_NOT_FOUND_RUNNER_CODE, + ALERT_POLL_INTERVAL_MS, + DEFAULT_ALERT_TIMEOUT_MS, +} from '@agent-device/contracts/alert-contract'; +import type { + AlertInteractorOptions, + RunnerCallOptions, +} from '@agent-device/contracts/interaction'; +import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '../../utils/timeouts.ts'; +import { runAppleRunnerCommand } from './core/runner/runner-client.ts'; +import { runMacOsAlertAction } from './os/macos/helper.ts'; + +/** + * Apple's four alert legs. R59 moved them here from the daemon: how long to look for a transient + * alert, how many times to re-ask a backend that reports the alert is not there yet, and which of + * the two Apple backends answers at all are family mechanics, not request policy. What the caller + * supplies is the window it allows and the session's target; everything else is this family's. + * + * Absence is the one retriable outcome, and both backends state it as typed evidence — the XCTest + * runner as `ALERT_NOT_FOUND`, the macOS helper as `reason: 'alert-not-found'`. Nothing here reads + * an error message: a transport, runner or helper failure that happened to mention an alert would + * otherwise be retried until the window expired and then reported as a timeout, hiding the cause. + */ +type NativeAlertAction = 'get' | 'accept' | 'dismiss'; + +const ALERT_FALLBACK_HINT = + 'If the permission sheet is visible in snapshot or screenshot but alert reports no alert, take a scoped snapshot around the visible button label and use press @ref.'; + +/** + * The one backend split: the macOS host answers through its helper, every other Apple leaf + * through the XCTest runner. The macOS target deliberately carries no bundle on a frontmost-app + * session — that surface means "whatever is frontmost", and naming a bundle would contradict it. + */ +function runAppleAlert( + device: DeviceInfo, + runnerOptions: RunnerCallOptions, + options: AlertInteractorOptions | undefined, +): (action: NativeAlertAction, timeoutMs: number) => Promise> { + if (isMacOs(device)) { + const target = + options?.surface === 'frontmost-app' + ? { surface: options.surface } + : { bundleId: options?.appBundleId, surface: options?.surface }; + return async (action) => (await runMacOsAlertAction(action, target)) as Record; + } + return async (action, timeoutMs) => + (await runAppleRunnerCommand( + device, + { command: 'alert', action, appBundleId: options?.appBundleId, timeoutMs }, + runnerOptions, + )) as Record; +} + +export async function readAppleAlert( + device: DeviceInfo, + runnerOptions: RunnerCallOptions, + options?: AlertInteractorOptions, +): Promise> { + return await runAppleAlert(device, runnerOptions, options)('get', DEFAULT_ALERT_TIMEOUT_MS); +} + +/** + * Poll `get` until one answers or the caller's window runs out. The first attempt gets the whole + * window and later ones only what is left, so a runner that blocks cannot outlive the budget. + */ +export async function awaitAppleAlert( + device: DeviceInfo, + runnerOptions: RunnerCallOptions, + options?: AlertInteractorOptions, +): Promise> { + const runAlert = runAppleAlert(device, runnerOptions, options); + const timeout = options?.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS; + const start = Date.now(); + let firstAttempt = true; + while (Date.now() - start < timeout) { + try { + const budgetMs = firstAttempt ? timeout : remainingBudgetMs(start, timeout); + firstAttempt = false; + return await runAlert('get', budgetMs); + } catch (error) { + // Only a typed absence is worth waiting out. Anything else — a dead runner, an unreachable + // helper, a canceled request — is reported as itself rather than spent as poll budget and + // relabeled `alert wait timed out`. + if (!isAlertNotFoundError(error)) throw error; + } + await sleep(ALERT_POLL_INTERVAL_MS); + } + throw new AppError('COMMAND_FAILED', 'alert wait timed out'); +} + +/** + * Accept and dismiss retry only while the backend keeps reporting a typed absence — a sheet that + * is still animating in. Any other failure is reported on its first occurrence, and an exhausted + * retry window carries the scoped-snapshot fallback the agent needs next. + */ +export async function actOnAppleAlert( + device: DeviceInfo, + runnerOptions: RunnerCallOptions, + action: 'accept' | 'dismiss', + options?: AlertInteractorOptions, +): Promise> { + const runAlert = runAppleAlert(device, runnerOptions, options); + const runnerTimeoutMs = isIosFamily(device) ? DEFAULT_ALERT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; + const start = Date.now(); + let lastError: unknown; + let firstAttempt = true; + while (Date.now() - start < ALERT_ACTION_RETRY_MS) { + try { + const budgetMs = firstAttempt + ? runnerTimeoutMs + : remainingBudgetMs(start, ALERT_ACTION_RETRY_MS); + firstAttempt = false; + return await runAlert(action, budgetMs); + } catch (err) { + lastError = err; + if (!isAlertNotFoundError(err)) break; + } + await sleep(ALERT_POLL_INTERVAL_MS); + } + throw withAlertFallbackHint(lastError); +} + +function remainingBudgetMs(start: number, timeoutMs: number): number { + return Math.max(1, timeoutMs - (Date.now() - start)); +} + +function withAlertFallbackHint(error: unknown): unknown { + if (!(error instanceof AppError) || !isAlertNotFoundError(error)) return error; + return new AppError(error.code, error.message, { + ...(error.details ?? {}), + hint: ALERT_FALLBACK_HINT, + }); +} + +/** + * Absence, as the backend itself classified it. The XCTest runner's `ALERT_NOT_FOUND` arrives as + * `details.runnerErrorCode` (it stays `COMMAND_FAILED` on the wire, like `RUNNER_BUSY`); the macOS + * helper's arrives as `details.reason`, forwarded verbatim from its JSON error envelope. + */ +function isAlertNotFoundError(error: unknown): boolean { + if (!(error instanceof AppError)) return false; + const details = error.details ?? {}; + return ( + details['runnerErrorCode'] === ALERT_NOT_FOUND_RUNNER_CODE || + details['reason'] === ALERT_NOT_FOUND_REASON + ); +} diff --git a/src/platforms/apple/capabilities.ts b/src/platforms/apple/capabilities.ts deleted file mode 100644 index 162cad5e53..0000000000 --- a/src/platforms/apple/capabilities.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - isApplePlatform, - resolveDeviceAppleOs, - type AppleOS, - type DeviceInfo, -} from '@agent-device/kernel/device'; - -// --------------------------------------------------------------------------- -// Per-`AppleOS` capability data table (ADR-0009 "per-AppleOS capability table"). -// This is the capability-axis sibling of the runner -// table `RUNNER_PLATFORM_PROFILES` (src/platforms/apple/core/apple-runner-platform.ts) -// and it encodes the SAME per-OS facts the Swift `#if os()` guards do: keyboard, -// device orientation, app/device lifecycle, and desktop host surfaces. -// -// DISCIPLINE (ADR-0009): the table holds ONLY the AppleOS-axis facts — it -// collapses the scattered `target !== 'tv'` / `platform !== 'macos'` / `isTvOsDevice` -// predicates into one lookup. Gesture synthesis has its own shared policy in -// `contracts/apple-multitouch-support.ts`; keeping it out of this table prevents a -// second source of truth. The predicate rewrite is behaviorless: -// `apple-os-capability-table-parity.test.ts` pins the table-driven -// closures byte-for-byte against a verbatim copy of the original predicates across the -// full {command x sample-device} matrix (iOS/iPadOS/tvOS/macOS/visionOS). -// --------------------------------------------------------------------------- - -export type AppleOsCapabilityProfile = { - /** - * `boot` / `home` / `app-switcher` — the app + device lifecycle and springboard surfaces. `false` - * on the macOS host, which drives an already-running app (nothing to boot, no - * springboard home or app switcher). - */ - readonly appAndDeviceLifecycle: boolean; - /** `keyboard` — hardware/text keyboard input. tvOS (focus-only) and macOS lack it. */ - readonly keyboard: boolean; - /** `orientation` — device orientation. tvOS (focus-only) and macOS lack it. */ - readonly orientation: boolean; - /** - * Whether `clipboard` / `settings` are reachable on a PHYSICAL device of - * this OS. Only the macOS host exposes them without a simulator; the reading closure - * still admits every Apple *simulator* via its own `kind === 'simulator'` check. - * Alert support has a separate command predicate because physical iOS is verified. - */ - readonly physicalDeviceSurfaces: boolean; -}; - -// iOS and iPadOS share this platform capability profile. Gesture synthesis support is -// intentionally owned by the separate shared gesture policy. -const IOS_FAMILY_CAPABILITIES: AppleOsCapabilityProfile = { - appAndDeviceLifecycle: true, - keyboard: true, - orientation: true, - physicalDeviceSurfaces: false, -}; - -export const APPLE_OS_CAPABILITIES: Record = { - ios: IOS_FAMILY_CAPABILITIES, - ipados: IOS_FAMILY_CAPABILITIES, - visionos: { - appAndDeviceLifecycle: true, - keyboard: true, - orientation: true, - physicalDeviceSurfaces: false, - }, - // tvOS: focus-only (XCUIRemote), so no keyboard or orientation; the app + device - // lifecycle (boot/home/app-switcher) is supported like the mobile family. - tvos: { - appAndDeviceLifecycle: true, - keyboard: false, - orientation: false, - physicalDeviceSurfaces: false, - }, - // macOS: an AppKit desktop host driving an already-running app. No app/device - // lifecycle (nothing to boot, no springboard), keyboard, or orientation; - // clipboard/alert/settings are reachable on the host directly (no simulator required). - macos: { - appAndDeviceLifecycle: false, - keyboard: false, - orientation: false, - physicalDeviceSurfaces: true, - }, - // watchOS is reserved in the `AppleOS` type but never produced by discovery or by - // `resolveDeviceAppleOs` inference (XCUITest cannot drive watchOS UI — ADR-0009's - // unsupported sentinel). This row is therefore unreachable today; the all-`false` - // sentinel keeps the `Record` exhaustive without granting anything. - watchos: { - appAndDeviceLifecycle: false, - keyboard: false, - orientation: false, - physicalDeviceSurfaces: false, - }, -}; - -/** - * The {@link AppleOsCapabilityProfile} for `device`, or `undefined` for a non-Apple - * platform (which has no AppleOS row). The capability closures fall back to their - * verbatim non-Apple verdicts when this returns `undefined`, so consulting the table - * only for the Apple family leaves admission for android/linux/web unchanged. - */ -export function appleOsCapabilities( - device: Pick, -): AppleOsCapabilityProfile | undefined { - return isApplePlatform(device.platform) - ? APPLE_OS_CAPABILITIES[resolveDeviceAppleOs(device)] - : undefined; -} diff --git a/src/platforms/apple/core/runner/runner-session.ts b/src/platforms/apple/core/runner/runner-session.ts index 4823fc113f..8f9e0644e8 100644 --- a/src/platforms/apple/core/runner/runner-session.ts +++ b/src/platforms/apple/core/runner/runner-session.ts @@ -1,4 +1,5 @@ import { AppError, toAppErrorCode, createRequestCanceledError } from '@agent-device/kernel/errors'; +import { ALERT_NOT_FOUND_RUNNER_CODE } from '@agent-device/contracts/alert-contract'; import { type ExecResult } from '../../../../utils/exec.ts'; import { withKeyedLock } from '../../../../utils/keyed-lock.ts'; import { Deadline } from '../../../../utils/retry.ts'; @@ -891,8 +892,20 @@ function readRunnerErrorCode(rawCode: unknown): string | undefined { return typeof rawCode === 'string' && rawCode.trim().length > 0 ? rawCode.trim() : undefined; } +/** + * Runner codes that classify a failure for the host without renaming it on the wire. They stay + * `COMMAND_FAILED` and survive as `details.runnerErrorCode`, which is what family policy reads: + * `RUNNER_BUSY` for retriable contention, `ALERT_NOT_FOUND` for an alert that is not there yet. + */ +const DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES: ReadonlySet = new Set([ + 'RUNNER_BUSY', + ALERT_NOT_FOUND_RUNNER_CODE, +]); + function runnerAppErrorCode(runnerErrorCode: string | undefined): AppError['code'] { - if (runnerErrorCode === 'RUNNER_BUSY') return 'COMMAND_FAILED'; + if (runnerErrorCode !== undefined && DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES.has(runnerErrorCode)) { + return 'COMMAND_FAILED'; + } return runnerErrorCode ? toAppErrorCode(runnerErrorCode) : 'COMMAND_FAILED'; } diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 3b477f8ab6..119ca69e64 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -11,6 +11,7 @@ import { captureScreenshotViaRunner } from './core/screenshot.ts'; import { iosRunnerOverrides, resolveAppleBackRunnerCommand } from './interactions.ts'; import { appleRemotePressCommand } from './os/tvos/remote.ts'; import { runMacOsScreenshotAction } from './os/macos/helper.ts'; +import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from './alert.ts'; import { runAppleRunnerCommand } from './core/runner/runner-client.ts'; import { queryAppleRunnerSelector } from './core/runner/runner-selector-query.ts'; import { @@ -194,6 +195,10 @@ export function createAppleInteractor( writeClipboard: (text) => writeIosClipboardText(device, text), setSetting: (setting, state, appId, options) => setIosSetting(device, setting, state, appId, options), + readAlert: (options) => readAppleAlert(device, runnerOpts, options), + awaitAlert: (options) => awaitAppleAlert(device, runnerOpts, options), + acceptAlert: (options) => actOnAppleAlert(device, runnerOpts, 'accept', options), + dismissAlert: (options) => actOnAppleAlert(device, runnerOpts, 'dismiss', options), ...overrides, }; if (!runnerProvider) return interactor; diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 6ca251704a..ef852a7054 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -1,67 +1,30 @@ -import { appleOsCapabilities } from './capabilities.ts'; import type { PlatformPlugin } from '@agent-device/contracts/platform'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { isAudioProbeSupportedDevice } from '@agent-device/contracts/audio-probe-support'; -import { resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerContext } from '@agent-device/contracts/interaction'; // --------------------------------------------------------------------------- -// Apple family per-command capability closures. Originally RELOCATED VERBATIM from -// src/core/command-descriptor/registry.ts (ADR-0009), the -// AppleOS-axis predicates (`target !== 'tv'` / `platform !== 'macos'` / -// `isTvOsDevice`) are now READ from the per-`AppleOS` capability table -// (`apple-os-capabilities.ts`, step d.5) instead of being open-coded. The rewrite is -// behaviorless: the DEVICE-shaped nuance (simulator vs physical device) stays in the -// closure — only the OS-axis facts moved to data — and the non-Apple branches are the -// verbatim verdicts (`appleOsCapabilities` returns `undefined` off the Apple family, so -// each closure is a no-op on android/linux/web). The table-equivalence gate -// (apple-os-capabilities table parity + capability-plugin-routing-parity tests) pins -// every closure byte-for-byte against a verbatim copy of the original predicate across -// the full {command x sample-device} matrix (iOS/iPadOS/tvOS/macOS/visionOS). +// Apple family per-command capability closures for the commands still admitted by a capability +// bucket. Originally RELOCATED VERBATIM from src/core/command-descriptor/registry.ts (ADR-0009). +// +// R59/R63 deleted the per-`AppleOS` capability table (`apple-os-capabilities.ts`) along with the +// closures that read it: every command it served is now admitted from its owner's operation facts +// (ADR 0019 §8), so the OS-axis predicates live in `packages/platform-apple` rather than here. +// What remains is DEVICE-shaped nuance (simulator vs physical device, XCTest vs CoreDevice) for +// unmigrated commands, pinned against the original predicates by +// `capability-plugin-routing-parity` across the full {command x sample-device} matrix. // --------------------------------------------------------------------------- -// `home`/`app-switcher` -// (was `!isMacOs(device)`). Off Apple (caps undefined) the original was -// always true — no non-Apple platform is macOS. -const supportsAppAndDeviceLifecycle = (device: DeviceInfo): boolean => { - const caps = appleOsCapabilities(device); - return caps ? caps.appAndDeviceLifecycle : true; -}; - const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => device.platform !== 'apple' || device.kind !== 'device' || device.iosPhysicalDeviceBackend !== 'xctest'; -// The Apple arm shared by `clipboard`/`settings` (was `macos || simulator`): -// reachable on the macOS host directly, on every other Apple OS only on the simulator. -// Off Apple this preserves the trailing `device.kind === 'simulator'` term verbatim. -const supportsHostOrSimulatorSurface = (device: DeviceInfo): boolean => { - const caps = appleOsCapabilities(device); - return caps - ? caps.physicalDeviceSurfaces || device.kind === 'simulator' - : device.kind === 'simulator'; -}; - -// Alerts use the host/simulator surface plus physical iOS, whose XCTest path is -// device-verified. iPadOS/visionOS remain closed until independently verified. -const supportsAlertSurface = (device: DeviceInfo): boolean => - device.platform === 'android' || - (device.platform === 'apple' && resolveDeviceAppleOs(device) === 'ios') || - supportsHostOrSimulatorSurface(device); - // Per-command support gates the Apple family applies by default, keyed exactly as in // the command-descriptor registry (a command absent here has no Apple gate). const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> = { [PUBLIC_COMMANDS.perf]: supportsCoreDevicePhysicalOperation, - [PUBLIC_COMMANDS.appSwitcher]: supportsAppAndDeviceLifecycle, - [PUBLIC_COMMANDS.clipboard]: (device) => - device.platform === 'android' || - device.platform === 'linux' || - supportsHostOrSimulatorSurface(device), - [PUBLIC_COMMANDS.alert]: supportsAlertSurface, - [PUBLIC_COMMANDS.settings]: (device) => - device.platform === 'android' || supportsHostOrSimulatorSurface(device), [PUBLIC_COMMANDS.audio]: isAudioProbeSupportedDevice, }; diff --git a/src/platforms/unsupported-interactor.ts b/src/platforms/unsupported-interactor.ts index 4781fb1e0a..c0fa6cf557 100644 --- a/src/platforms/unsupported-interactor.ts +++ b/src/platforms/unsupported-interactor.ts @@ -30,5 +30,9 @@ export function createUnsupportedInteractor(platformLabel: string): Interactor { readClipboard: () => unsupported('readClipboard'), writeClipboard: () => unsupported('writeClipboard'), setSetting: () => unsupported('setSetting'), + readAlert: () => unsupported('readAlert'), + awaitAlert: () => unsupported('awaitAlert'), + acceptAlert: () => unsupported('acceptAlert'), + dismissAlert: () => unsupported('dismissAlert'), }; } diff --git a/test/integration/ios-simulator-e2e/coverage-manifest.ts b/test/integration/ios-simulator-e2e/coverage-manifest.ts index 3a48128df8..83572fd29b 100644 --- a/test/integration/ios-simulator-e2e/coverage-manifest.ts +++ b/test/integration/ios-simulator-e2e/coverage-manifest.ts @@ -185,7 +185,7 @@ export const IOS_SIMULATOR_E2E_COVERAGE = { ), [C.tvRemote]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'tv-remote admission is owned by its exact-owner runtime fact: available only for the tvOS leaf, not the iOS mobile simulator', ), [C.type]: live( diff --git a/test/integration/linux-e2e/coverage-manifest.ts b/test/integration/linux-e2e/coverage-manifest.ts index 79facc95a4..06d1b03b90 100644 --- a/test/integration/linux-e2e/coverage-manifest.ts +++ b/test/integration/linux-e2e/coverage-manifest.ts @@ -154,9 +154,10 @@ export const LINUX_PLATFORM_COVERAGE = { [C.install]: gap('No Linux-specific application installation command evidence exists yet'), [C.reinstall]: gap('No Linux-specific application reinstallation command evidence exists yet'), [C.push]: gap('No Linux-specific push delivery command evidence exists yet'), - [C.triggerAppEvent]: denial( - 'trigger-app-event', - 'Linux capability declaration rejects native application event delivery', + [C.triggerAppEvent]: contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact rejects native application event delivery on Linux', ), [C.open]: live('the existing Linux replay opens gnome-calculator'), [C.prepare]: contract( @@ -175,14 +176,23 @@ export const LINUX_PLATFORM_COVERAGE = { 'the command-evidence lane observes a non-empty calculator snapshot mutation', ), [C.wait]: live('the existing Linux replay waits for an observable calculator landmark'), - [C.alert]: denial('alert', 'Linux capability declaration rejects native alert operations'), - [C.settings]: denial( - 'settings', - 'Linux capability declaration rejects native device settings operations', + [C.alert]: contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact rejects native alert handling on Linux', ), - [C.reactNative]: denial( - 'react-native', - 'Linux capability declaration rejects React Native inspection', + [C.settings]: contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact rejects native device settings on Linux', + ), + // R61: no owner fact refuses this command on Linux — its whole device work is one bound + // `tapPoint`, the same cell the live click leg uses — so it now runs and reports truthfully + // that no React Native overlay is present on a GTK desktop. + [C.reactNative]: contract( + LINUX_PROVIDER_EVIDENCE.path, + LINUX_PROVIDER_EVIDENCE.test, + 'React Native overlay dismissal binds the same desktop tap the press leg does', ), [C.record]: gap('No Linux-specific recording command evidence exists yet'), [C.trace]: gap('No Linux-specific trace command evidence exists yet'), @@ -264,9 +274,10 @@ export const LINUX_PLATFORM_COVERAGE = { LINUX_RUNTIME_EVIDENCE.test, 'Linux runtime facts explicitly report viewport changes unavailable', ), - [C.appSwitcher]: denial( - 'app-switcher', - 'Linux capability declaration rejects native app-switcher navigation', + [C.appSwitcher]: contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact rejects native app-switcher navigation on Linux', ), [C.installFromSource]: gap('No Linux-specific source-install command evidence exists yet'), } satisfies Record; diff --git a/test/integration/macos-e2e/coverage-manifest.ts b/test/integration/macos-e2e/coverage-manifest.ts index a8036ed448..596cbc673a 100644 --- a/test/integration/macos-e2e/coverage-manifest.ts +++ b/test/integration/macos-e2e/coverage-manifest.ts @@ -47,11 +47,10 @@ const contract = (path: string, test: string, assertion: string): MacOsPlatformC level: 'command-contract', owner: { path, test }, }); -const denial = (path: string, test: string, assertion: string): MacOsPlatformCoverageEntry => ({ - assertion, - level: 'capability-denial', - owner: { path, test }, -}); +// No `denial` constructor: R56 moved the last macOS capability-denial row (`app-switcher`) onto +// owner facts, so no command is denied on this host by a capability declaration any more. The +// level stays in the entry type because `macOS capability-denial rows match the owning capability +// matrix` still enforces both sides — today it asserts the denied set is empty. const gap = (assertion: string): MacOsPlatformCoverageEntry => ({ assertion, level: 'known-gap', @@ -112,7 +111,7 @@ export const MACOS_PLATFORM_COVERAGE = { ), [C.keyboard]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects keyboard actions on the macOS AppKit desktop leaf', ), [C.install]: contract( @@ -131,7 +130,7 @@ export const MACOS_PLATFORM_COVERAGE = { 'Apple deployment facts close push delivery on the macOS host', ), [C.triggerAppEvent]: contract( - 'src/core/__tests__/dispatch-trigger-app-event.test.ts', + 'src/core/__tests__/app-event-delivery.test.ts', 'trigger-app-event supports macOS and prefers macOS template', 'macOS app-event dispatch selects the macOS URL template', ), @@ -205,17 +204,17 @@ export const MACOS_PLATFORM_COVERAGE = { ), [C.home]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects mobile Home navigation on the macOS leaf, which drives an already-running app with no springboard', ), [C.tvRemote]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact admits TV remote input only for the tvOS leaf, not macOS', ), [C.orientation]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects device orientation changes on the macOS leaf', ), [C.scroll]: live( @@ -233,10 +232,10 @@ export const MACOS_PLATFORM_COVERAGE = { 'classifies the %s leaf explicitly', 'Apple runtime facts reject viewport resizing on the macOS host', ), - [C.appSwitcher]: denial( - 'src/platforms/apple/capabilities.ts', - 'appAndDeviceLifecycle', - 'macOS capability declarations reject mobile app-switcher navigation', + [C.appSwitcher]: contract( + 'packages/platform-apple/src/runtime.test.ts', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', + 'the exact-owner runtime fact rejects app-switcher navigation on the macOS host leaf', ), [C.installFromSource]: contract( 'packages/platform-apple/src/deployment/runtime.test.ts', diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index de6f2d9696..926e6f0fc4 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -13,6 +13,13 @@ import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../.. import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createTestAppLogLiveHandle } from '../../../src/__tests__/test-utils/app-log-live-handle.ts'; +import { setIosSetting } from '../../../src/platforms/apple/core/app-settings.ts'; +import { + actOnAppleAlert, + awaitAppleAlert, + readAppleAlert, +} from '../../../src/platforms/apple/alert.ts'; +import type { AlertRuntimeInput } from '@agent-device/contracts/alert-runtime'; import { assertFlatToolCall } from './assertions.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; import { createProviderScenarioHarness } from './harness.ts'; @@ -279,6 +286,27 @@ function createRecordingPlatformRuntimeGateway(params: { }), ); }, + // R58 put `settings` behind the owner's own `setSetting` fact, so the scenario's + // gateway states and serves that cell like any other. The Apple leg reuses the local + // family's implementation — the same shape Limrun's Android leg takes — so the simctl + // argument mapping this scenario asserts still runs through the recorded tool seam. + setSetting: async (input) => + await setIosSetting( + device, + input.setting, + input.state, + input.appBundleId, + input.options, + ), + // R59 does the same for `alert`: the scenario's gateway states and serves the four + // legs, reusing the Apple family's own module so the runner transcript this scenario + // scripts — including its retry and poll windows — is what actually runs. + readAlert: async (input) => await readAppleAlert(device, {}, alertOptions(input)), + awaitAlert: async (input) => await awaitAppleAlert(device, {}, alertOptions(input)), + acceptAlert: async (input) => + await actOnAppleAlert(device, {}, 'accept', alertOptions(input)), + dismissAlert: async (input) => + await actOnAppleAlert(device, {}, 'dismiss', alertOptions(input)), appLogReattach: async () => ({ status: 'missing' }), appLogCleanup: async () => ({ status: 'already-missing' }), resolveOpenTarget: async (input) => ({ @@ -326,6 +354,11 @@ function recordingRuntimeFacts(device: DeviceInfo): RuntimeFacts runtime.getInteractor(request.device, runner), }), - ...bindProviderKeyboardDismissInteractor({ - device: request.device, - signal: request.scope.signal, - resolveInteractor: (runner) => runtime.getInteractor(request.device, runner), - }), - ...bindProviderKeyboardEnterInteractor({ + // The interactor catalog binds every keyboard leg this provider's facts admit. + ...bindAdmittedProviderInteractorOperations({ device: request.device, signal: request.scope.signal, resolveInteractor: (runner) => runtime.getInteractor(request.device, runner), + facts: facts.operations, }), ...bindProviderTouchInteractor({ device: request.device, diff --git a/test/integration/smoke-linux-coverage.test.ts b/test/integration/smoke-linux-coverage.test.ts index a69d9f096e..291e690770 100644 --- a/test/integration/smoke-linux-coverage.test.ts +++ b/test/integration/smoke-linux-coverage.test.ts @@ -48,10 +48,13 @@ test('Linux coverage report has the expected classification counts', () => { // focus (#1925), click, and type remain live via the existing replay. The separate // command-evidence lane adds nine generic-command rows without changing that replay. Artifact // inventory remains a gap because local Linux screenshot paths are not daemon-downloadable. - // Keyboard, orientation, and tv-remote remain fact-owned command-contract rows, not catalog - // denials. - capabilityDenial: 7, - contract: 21, + // Keyboard, orientation and tv-remote were already fact-owned command-contract rows rather + // than catalog denials; R56 moves app-switcher the same way, for the same reason. + // R57 moves trigger-app-event from capability-denial to command-contract: it is fact-owned now. + // R58 moves settings from capability-denial to command-contract: it is fact-owned now. + // R59/R61 moves alert and react-native from capability-denial to command-contract: it is fact-owned now. + capabilityDenial: 2, + contract: 26, gap: 9, live: 17, total: 54, diff --git a/test/integration/smoke-macos-coverage.test.ts b/test/integration/smoke-macos-coverage.test.ts index b9b4ed4b55..186b36c4cb 100644 --- a/test/integration/smoke-macos-coverage.test.ts +++ b/test/integration/smoke-macos-coverage.test.ts @@ -49,8 +49,8 @@ test('macOS coverage exhaustively classifies the public catalog', () => { test('macOS coverage report counts every manifest classification', () => { assert.deepEqual(MACOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - capabilityDenial: 1, - contract: 20, + capabilityDenial: 0, + contract: 21, gap: 15, live: 18, total: 54, diff --git a/test/integration/smoke-web-platform-coverage.test.ts b/test/integration/smoke-web-platform-coverage.test.ts index 6ac8268f4a..8b5d21b29e 100644 --- a/test/integration/smoke-web-platform-coverage.test.ts +++ b/test/integration/smoke-web-platform-coverage.test.ts @@ -41,8 +41,8 @@ test('web coverage exhaustively classifies the public catalog', () => { test('web coverage report has the expected classification counts', () => { assert.deepEqual(WEB_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - capabilityDenial: 7, - contract: 34, + capabilityDenial: 1, + contract: 40, gap: 1, live: 12, total: 54, diff --git a/test/integration/tvos-e2e/coverage-manifest.ts b/test/integration/tvos-e2e/coverage-manifest.ts index 4b0a49a208..3225497853 100644 --- a/test/integration/tvos-e2e/coverage-manifest.ts +++ b/test/integration/tvos-e2e/coverage-manifest.ts @@ -115,7 +115,7 @@ export const TVOS_PLATFORM_COVERAGE = { [C.clipboard]: gap('No tvOS-specific clipboard command evidence exists yet'), [C.keyboard]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects keyboard input on the tvOS focus-only leaf', ), [C.install]: contract( @@ -184,12 +184,12 @@ export const TVOS_PLATFORM_COVERAGE = { ), [C.tvRemote]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact admits tv-remote for the tvOS leaf, which drives navigation through XCUIRemote presses', ), [C.orientation]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects device orientation changes on the tvOS leaf', ), [C.scroll]: contract( diff --git a/test/integration/web-e2e/coverage-manifest.ts b/test/integration/web-e2e/coverage-manifest.ts index c06b30aac0..b31a7a6702 100644 --- a/test/integration/web-e2e/coverage-manifest.ts +++ b/test/integration/web-e2e/coverage-manifest.ts @@ -138,7 +138,11 @@ export const WEB_PLATFORM_COVERAGE = { [C.test]: gap( "Web test-suite execution has no executable web evidence: ReplayTestPlatform = Exclude structurally excludes web from the declared-platform filter, so `test --platform web` can never select a script (proven by a regression test in session-command-replay.test.ts) — that is evidence of what the command cannot do, not that it works on web", ), - [C.clipboard]: denial('Web capability model rejects native clipboard operations'), + [C.clipboard]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', + 'the exact-owner runtime fact rejects native clipboard operations on the web target', + ), [C.keyboard]: contract( 'packages/platform-web/src/runtime.test.ts', 'back/home/orientation/tv-remote/keyboard never carried a web capability bucket', @@ -159,7 +163,11 @@ export const WEB_PLATFORM_COVERAGE = { 'push reports the runtime-owned unavailable readiness and push facts', 'the web runtime fact rejects native push notification delivery', ), - [C.triggerAppEvent]: denial('Web capability model rejects native app event delivery'), + [C.triggerAppEvent]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', + 'the exact-owner runtime fact rejects native app-event delivery on the web target', + ), [C.open]: live('the managed browser opens the local fixture page'), [C.prepare]: contract( 'packages/platform-web/src/runtime.test.ts', @@ -179,9 +187,23 @@ export const WEB_PLATFORM_COVERAGE = { 'web diff shares the browser-admitted snapshot capture that backs the live snapshot command', ), [C.wait]: live('wait observes ready text and post-interaction fixture state'), - [C.alert]: denial('Web capability model rejects native alert operations'), - [C.settings]: denial('Web capability model rejects native device settings operations'), - [C.reactNative]: denial('Web capability model rejects React Native inspection'), + [C.alert]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', + 'the exact-owner runtime fact rejects native alert handling on the web target', + ), + [C.settings]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', + 'the exact-owner runtime fact rejects native device settings on the web target', + ), + // R61: no owner fact refuses this command on a browser — its whole device work is one bound + // `tapPoint` the web target admits — so it now runs and reports that no overlay is present. + [C.reactNative]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'press shares the admitted tapPoint fact that live click, press and react-native require', + 'React Native overlay dismissal binds the same browser tap the live click command does', + ), [C.record]: contract( 'test/integration/provider-scenarios/web-desktop.test.ts', 'start web recording', @@ -207,7 +229,7 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.press]: contract( 'packages/platform-web/src/runtime.test.ts', - 'press shares the admitted tapPoint fact that live click and press both require', + 'press shares the admitted tapPoint fact that live click, press and react-native require', 'web press shares the browser-admitted tap operation that backs the live click command', ), [C.type]: contract( @@ -261,7 +283,11 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.screenshot]: live('screenshot creates a valid 640x480 PNG artifact'), [C.viewport]: live('viewport resizes the browser and the PNG reports 640x480 dimensions'), - [C.appSwitcher]: denial('Web capability model rejects native app switcher navigation'), + [C.appSwitcher]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', + 'the exact-owner runtime fact rejects native app-switcher navigation on the web target', + ), [C.installFromSource]: contract( 'packages/platform-web/src/runtime.test.ts', 'install-from-source reports the runtime-owned unavailable materialize and deploy facts',