diff --git a/src/core/command-descriptor/__tests__/command-result.test.ts b/src/core/command-descriptor/__tests__/command-result.test.ts new file mode 100644 index 000000000..380f64c6d --- /dev/null +++ b/src/core/command-descriptor/__tests__/command-result.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'vitest'; +import type { + FillCommandResult, + LongPressCommandResult, + PressCommandResult, +} from '../../../contracts/interaction.ts'; +import type { CommandResult, CommandResultMap } from '../command-result.ts'; + +/** + * Exact-equality type predicate (invariant in both `A` and `B`). A seeded + * `CommandResult` must resolve to *exactly* its contract result type — not + * merely a one-directional assignable supertype — so these assertions are what + * `tsc --noEmit` enforces; the `expect`s below only keep vitest's runner green. + */ +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +test('seeded CommandResult entries resolve to their existing contract result types', () => { + const press: Equal, PressCommandResult> = true; + const fill: Equal, FillCommandResult> = true; + const longPress: Equal, LongPressCommandResult> = true; + expect([press, fill, longPress]).toEqual([true, true, true]); +}); + +test('unmigrated commands fall back to the untyped Record bag, keeping the union total', () => { + const unknown: Equal, Record> = true; + // A seeded name narrows away from the bare Record bag. + const seededIsNotRecord: Equal, Record> = false; + expect([unknown, seededIsNotRecord]).toEqual([true, false]); +}); + +test('CommandResultMap is seeded only from already-existing contract result types', () => { + const keys: Equal = true; + expect(keys).toBe(true); +}); diff --git a/src/core/command-descriptor/command-result.ts b/src/core/command-descriptor/command-result.ts new file mode 100644 index 000000000..38f2817b6 --- /dev/null +++ b/src/core/command-descriptor/command-result.ts @@ -0,0 +1,37 @@ +import type { + FillCommandResult, + LongPressCommandResult, + PressCommandResult, +} from '../../contracts/interaction.ts'; + +/** + * The additive typed-result spine (ADR-0008, Phase 1 step 6). + * + * Maps a command name to the *already-existing* per-command result type from + * `src/contracts/*`. It is SEEDED, not exhaustive: only commands whose accurate + * result shape already lives in the contracts layer are listed here. Today that + * is the interaction trio (`press` / `fill` / `longpress`); screenshot, perf, + * logs and friends have no contracts-layer result type yet, so they are + * deliberately omitted rather than given an invented shape. + * + * This map is dormant: nothing reads it yet. It exists as the foundation that + * later slices consume to derive `client-types.ts` and delete the hand-authored + * `*Result` mirror — the same dormant-but-proven pattern as the #906 descriptor + * registry and the #910 dispatch map this slice is stacked on. + */ +export interface CommandResultMap { + press: PressCommandResult; + fill: FillCommandResult; + longpress: LongPressCommandResult; +} + +/** + * The typed result for a command named `N`. Seeded commands resolve to their + * contract result type from {@link CommandResultMap}; every other (unmigrated) + * command falls back to the untyped `Record` bag. That default + * branch is what keeps the mapping total over every command name, so consumers + * can switch to `CommandResult` without first migrating every command. + */ +export type CommandResult = N extends keyof CommandResultMap + ? CommandResultMap[N] + : Record; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index d0b75a24d..fa539b450 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -6,7 +6,7 @@ import { import type { CommandCapability } from '../capabilities.ts'; import type { DaemonRequest } from '../../daemon/types.ts'; import type { DeviceInfo } from '../../utils/device.ts'; -import { type CommandDescriptor, defineCommandDescriptor } from './types.ts'; +import type { CommandDescriptor } from './types.ts'; // --------------------------------------------------------------------------- // Daemon request-policy trait bundles — copied VERBATIM from @@ -90,7 +90,7 @@ const APP_INSTALL_CAPABILITY = { // live only in the capability/batch hand tables). // --------------------------------------------------------------------------- -const RAW_COMMAND_DESCRIPTORS: readonly Omit[] = [ +const RAW_COMMAND_DESCRIPTORS = [ // -- lease (route: lease) -- { name: INTERNAL_COMMANDS.leaseAllocate, @@ -536,19 +536,22 @@ const RAW_COMMAND_DESCRIPTORS: readonly Omit[] capability: APP_INSTALL_CAPABILITY, batchable: true, }, -]; +] as const satisfies readonly Omit[]; const MCP_EXPOSED_COMMAND_NAMES = new Set(listMcpExposedCommandNames()); /** - * The additive single source of truth (ADR-0008, Phase 1 step 1). Dormant: no - * consumer reads it yet. Proven byte-equal to the live hand tables by - * `__tests__/parity.test.ts`. + * The additive single source of truth (ADR-0008, Phase 1 step 1). Proven + * byte-equal to the live hand tables by `__tests__/parity.test.ts`. + * + * The `as const` on {@link RAW_COMMAND_DESCRIPTORS} flows through this `.map`, + * so each entry keeps its literal `name`. That is what makes the {@link Command} + * union below a precise set of command-name literals rather than `string`. */ -export const commandDescriptors: readonly CommandDescriptor[] = RAW_COMMAND_DESCRIPTORS.map( - (descriptor) => - defineCommandDescriptor({ - ...descriptor, - mcpExposed: MCP_EXPOSED_COMMAND_NAMES.has(descriptor.name), - }), -); +export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => ({ + ...descriptor, + mcpExposed: MCP_EXPOSED_COMMAND_NAMES.has(descriptor.name), +})) satisfies readonly CommandDescriptor[]; + +/** The literal union of every registered command name. */ +export type Command = (typeof commandDescriptors)[number]['name']; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 88b80c060..a9a50b418 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -87,7 +87,130 @@ export async function dispatchCommand( ); } -// fallow-ignore-next-line complexity +/** + * The exact set of commands routed by {@link dispatchKnownCommand}. Hand-authored + * to match the former `switch` cases verbatim: it is NOT the registry's `generic` + * daemon route (that set is both narrower — e.g. it has no `open`/`type`/`read` — + * and includes `gesture`, which dispatch never handled), and `swipe-preset` / + * `read` are not registry command names at all. Keeping it explicit makes the + * dispatch surface self-describing and lets the `Record` below enforce coverage. + */ +type DispatchCommand = + | 'open' + | 'close' + | 'press' + | 'swipe' + | 'swipe-preset' + | 'pan' + | 'fling' + | 'longpress' + | 'focus' + | 'type' + | 'fill' + | 'scroll' + | 'pinch' + | 'rotate-gesture' + | 'transform-gesture' + | 'trigger-app-event' + | 'screenshot' + | 'viewport' + | 'back' + | 'home' + | 'rotate' + | 'app-switcher' + | 'clipboard' + | 'keyboard' + | 'settings' + | 'push' + | 'snapshot' + | 'read'; + +type DispatchHandlerArgs = { + device: DeviceInfo; + interactor: Interactor; + positionals: string[]; + outPath: string | undefined; + context: DispatchContext | undefined; + runnerCtx: RunnerContext; +}; + +type DispatchHandler = (args: DispatchHandlerArgs) => Promise | void>; + +/** + * Registry-driven exhaustive dispatch table. The `Record` + * type forces every 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 = { + open: ({ device, interactor, positionals, context }) => + handleOpenCommand(device, interactor, positionals, context), + close: async ({ device, interactor, positionals }) => { + const app = positionals[0]; + if (!app) { + if (device.platform === 'web') { + await interactor.close(''); + } + return { closed: 'session', ...successText('Closed session') }; + } + await interactor.close(app); + return { app, ...successText(`Closed: ${app}`) }; + }, + press: ({ device, interactor, positionals, context }) => + handlePressCommand(device, interactor, positionals, context), + swipe: ({ device, interactor, positionals, context }) => + handleSwipeCommand(device, interactor, positionals, context), + 'swipe-preset': ({ device, interactor, positionals, context }) => + handleSwipePresetCommand(device, interactor, positionals, context), + pan: ({ interactor, positionals }) => handlePanCommand(interactor, positionals), + fling: ({ interactor, positionals }) => handleFlingCommand(interactor, positionals), + longpress: ({ interactor, positionals }) => handleLongPressCommand(interactor, positionals), + focus: ({ interactor, positionals }) => handleFocusCommand(interactor, positionals), + type: ({ interactor, positionals, context }) => + handleTypeCommand(interactor, positionals, context), + fill: ({ interactor, positionals, context }) => + handleFillCommand(interactor, positionals, context), + scroll: ({ interactor, positionals, context }) => + handleScrollCommand(interactor, positionals, context), + pinch: ({ device, interactor, positionals, context }) => + handlePinchCommand(device, interactor, positionals, context), + 'rotate-gesture': ({ device, interactor, positionals }) => + handleRotateGestureCommand(device, interactor, positionals), + 'transform-gesture': ({ device, interactor, positionals }) => + handleTransformGestureCommand(device, interactor, positionals), + 'trigger-app-event': ({ device, interactor, positionals, context }) => + handleTriggerAppEventCommand(device, interactor, positionals, context), + screenshot: ({ interactor, positionals, outPath, context }) => + handleScreenshotCommand(interactor, positionals, outPath, context), + viewport: ({ interactor, positionals }) => handleViewportCommand(interactor, positionals), + back: async ({ interactor, context }) => { + await interactor.back(context?.backMode); + return { action: 'back', mode: context?.backMode ?? 'in-app', ...successText('Back') }; + }, + home: async ({ interactor }) => { + await interactor.home(); + return { action: 'home', ...successText('Home') }; + }, + rotate: async ({ interactor, positionals }) => { + const orientation = parseDeviceRotation(positionals[0]); + await interactor.rotate(orientation); + return { action: 'rotate', orientation, ...successText(`Rotated to ${orientation}`) }; + }, + 'app-switcher': async ({ interactor }) => { + await interactor.appSwitcher(); + return { action: 'app-switcher', ...successText('Opened app switcher') }; + }, + clipboard: ({ interactor, positionals }) => handleClipboardCommand(interactor, positionals), + keyboard: ({ device, positionals, context, runnerCtx }) => + handleKeyboardCommand(device, positionals, context, runnerCtx), + settings: ({ device, interactor, positionals, context }) => + handleSettingsCommand(device, interactor, positionals, context), + push: ({ device, positionals, context }) => handlePushCommand(device, positionals, context), + snapshot: ({ interactor, context }) => handleSnapshotCommand(interactor, context), + read: ({ device, positionals, context }) => handleReadCommand(device, positionals, context), +}; + async function dispatchKnownCommand( device: DeviceInfo, interactor: Interactor, @@ -97,81 +220,16 @@ async function dispatchKnownCommand( context: DispatchContext | undefined, runnerCtx: RunnerContext, ): Promise | void> { - switch (command) { - case 'open': - return await handleOpenCommand(device, interactor, positionals, context); - case 'close': { - const app = positionals[0]; - if (!app) { - if (device.platform === 'web') { - await interactor.close(''); - } - return { closed: 'session', ...successText('Closed session') }; - } - await interactor.close(app); - return { app, ...successText(`Closed: ${app}`) }; - } - case 'press': - return await handlePressCommand(device, interactor, positionals, context); - case 'swipe': - return await handleSwipeCommand(device, interactor, positionals, context); - case 'swipe-preset': - return await handleSwipePresetCommand(device, interactor, positionals, context); - case 'pan': - return await handlePanCommand(interactor, positionals); - case 'fling': - return await handleFlingCommand(interactor, positionals); - case 'longpress': - return await handleLongPressCommand(interactor, positionals); - case 'focus': - return await handleFocusCommand(interactor, positionals); - case 'type': - return await handleTypeCommand(interactor, positionals, context); - case 'fill': - return await handleFillCommand(interactor, positionals, context); - case 'scroll': - return await handleScrollCommand(interactor, positionals, context); - case 'pinch': - return await handlePinchCommand(device, interactor, positionals, context); - case 'rotate-gesture': - return await handleRotateGestureCommand(device, interactor, positionals); - case 'transform-gesture': - return await handleTransformGestureCommand(device, interactor, positionals); - case 'trigger-app-event': - return await handleTriggerAppEventCommand(device, interactor, positionals, context); - case 'screenshot': - return await handleScreenshotCommand(interactor, positionals, outPath, context); - case 'viewport': - return await handleViewportCommand(interactor, positionals); - case 'back': - await interactor.back(context?.backMode); - return { action: 'back', mode: context?.backMode ?? 'in-app', ...successText('Back') }; - case 'home': - await interactor.home(); - return { action: 'home', ...successText('Home') }; - case 'rotate': { - const orientation = parseDeviceRotation(positionals[0]); - await interactor.rotate(orientation); - return { action: 'rotate', orientation, ...successText(`Rotated to ${orientation}`) }; - } - case 'app-switcher': - await interactor.appSwitcher(); - return { action: 'app-switcher', ...successText('Opened app switcher') }; - case 'clipboard': - return await handleClipboardCommand(interactor, positionals); - case 'keyboard': - return await handleKeyboardCommand(device, positionals, context, runnerCtx); - case 'settings': - return await handleSettingsCommand(device, interactor, positionals, context); - case 'push': - return await handlePushCommand(device, positionals, context); - case 'snapshot': - return await handleSnapshotCommand(interactor, context); - case 'read': - return await handleReadCommand(device, positionals, context); - default: - throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); + // `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 }); } // ---------------------------------------------------------------------------