Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/core/command-descriptor/__tests__/command-result.test.ts
Original file line number Diff line number Diff line change
@@ -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<Name>` 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<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;

test('seeded CommandResult entries resolve to their existing contract result types', () => {
const press: Equal<CommandResult<'press'>, PressCommandResult> = true;
const fill: Equal<CommandResult<'fill'>, FillCommandResult> = true;
const longPress: Equal<CommandResult<'longpress'>, 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<CommandResult<'__unmigrated__'>, Record<string, unknown>> = true;
// A seeded name narrows away from the bare Record bag.
const seededIsNotRecord: Equal<CommandResult<'press'>, Record<string, unknown>> = false;
expect([unknown, seededIsNotRecord]).toEqual([true, false]);
});

test('CommandResultMap is seeded only from already-existing contract result types', () => {
const keys: Equal<keyof CommandResultMap, 'press' | 'fill' | 'longpress'> = true;
expect(keys).toBe(true);
});
37 changes: 37 additions & 0 deletions src/core/command-descriptor/command-result.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>` bag. That default
* branch is what keeps the mapping total over every command name, so consumers
* can switch to `CommandResult<Name>` without first migrating every command.
*/
export type CommandResult<N extends string> = N extends keyof CommandResultMap
? CommandResultMap[N]
: Record<string, unknown>;
29 changes: 16 additions & 13 deletions src/core/command-descriptor/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,7 +90,7 @@ const APP_INSTALL_CAPABILITY = {
// live only in the capability/batch hand tables).
// ---------------------------------------------------------------------------

const RAW_COMMAND_DESCRIPTORS: readonly Omit<CommandDescriptor, 'mcpExposed'>[] = [
const RAW_COMMAND_DESCRIPTORS = [
// -- lease (route: lease) --
{
name: INTERNAL_COMMANDS.leaseAllocate,
Expand Down Expand Up @@ -536,19 +536,22 @@ const RAW_COMMAND_DESCRIPTORS: readonly Omit<CommandDescriptor, 'mcpExposed'>[]
capability: APP_INSTALL_CAPABILITY,
batchable: true,
},
];
] as const satisfies readonly Omit<CommandDescriptor, 'mcpExposed'>[];

const MCP_EXPOSED_COMMAND_NAMES = new Set<string>(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'];
208 changes: 133 additions & 75 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown> | void>;

/**
* Registry-driven exhaustive dispatch table. The `Record<DispatchCommand, …>`
* 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<DispatchCommand, DispatchHandler> = {
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,
Expand All @@ -97,81 +220,16 @@ async function dispatchKnownCommand(
context: DispatchContext | undefined,
runnerCtx: RunnerContext,
): Promise<Record<string, unknown> | 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 });
}

// ---------------------------------------------------------------------------
Expand Down
Loading