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
46 changes: 45 additions & 1 deletion src/__tests__/contracts-schema-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { AppError } from '../index.ts';
import {
AppError,
type BootCommandResult,
type CommandResult,
type ShutdownCommandResult,
type ViewportCommandResult,
} from '../index.ts';
import {
defaultHintForCode,
daemonCommandRequestSchema,
Expand Down Expand Up @@ -87,6 +93,44 @@ test('public contract schemas validate daemon requests and lease payloads', () =
assert.equal(node.ref, 'e1');
});

test('public root exports typed command result contracts', () => {
const boot = {
platform: 'ios',
target: 'mobile',
device: 'iPhone 17',
id: 'booted-device',
kind: 'simulator',
booted: true,
} satisfies BootCommandResult;
const bootFromMap: CommandResult<'boot'> = boot;

const shutdown = {
platform: 'ios',
target: 'mobile',
device: 'iPhone 17',
id: 'shutdown-device',
kind: 'simulator',
shutdown: {
success: true,
exitCode: 0,
stdout: '',
stderr: '',
},
} satisfies ShutdownCommandResult;
const shutdownFromMap: CommandResult<'shutdown'> = shutdown;

const viewport = {
width: 390,
height: 844,
message: 'Viewport is 390x844',
} satisfies ViewportCommandResult;
const viewportFromMap: CommandResult<'viewport'> = viewport;

assert.equal(bootFromMap.booted, true);
assert.equal(shutdownFromMap.shutdown.success, true);
assert.equal(viewportFromMap.width, 390);
});

test('public daemon request schema accepts GitHub Actions artifact install sources', () => {
const artifactIdRequest = daemonCommandRequestSchema.parse({
command: 'install_source',
Expand Down
14 changes: 6 additions & 8 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,15 @@ import type { PerfAction, PerfArea, PerfKind, PerfSubject } from './contracts/pe
import type { AlertAction, AlertInfo } from './alert-contract.ts';
import type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts';
import type { RemoteConnectionProfileFields } from './remote-config-schema.ts';
import type { CommandResult } from './core/command-descriptor/command-result.ts';

export type { FindLocator } from './utils/finders.ts';
export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts';
export type { AppsFilter } from './contracts/app-inventory.ts';
export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from './alert-contract.ts';
export type { DebugSymbolsOptions, DebugSymbolsResult } from './contracts/debug-symbols.ts';
export type { BootCommandResult, ShutdownCommandResult } from './contracts/device.ts';
export type { ViewportCommandResult } from './contracts/viewport.ts';

export type AgentDeviceDaemonTransport = (
req: Omit<DaemonRequest, 'token'>,
Expand Down Expand Up @@ -520,11 +523,6 @@ export type ViewportCommandOptions = DeviceCommandBaseOptions & {
height: number;
};

export type ViewportCommandResult = CommandRequestResult & {
width: number;
height: number;
};

export type AgentDeviceCommandClient = {
wait: (options: WaitCommandOptions) => Promise<WaitCommandResult>;
alert: (options?: AlertCommandOptions) => Promise<AlertCommandResult>;
Expand All @@ -537,7 +535,7 @@ export type AgentDeviceCommandClient = {
clipboard: (options: ClipboardCommandOptions) => Promise<ClipboardCommandResult>;
reactNative: (options: ReactNativeCommandOptions) => Promise<CommandRequestResult>;
prepare: (options: PrepareCommandOptions) => Promise<CommandRequestResult>;
viewport: (options: ViewportCommandOptions) => Promise<ViewportCommandResult>;
viewport: (options: ViewportCommandOptions) => Promise<CommandResult<'viewport'>>;
};

type SelectorSnapshotCommandOptions = Pick<CaptureSnapshotOptions, 'depth' | 'scope' | 'raw'>;
Expand Down Expand Up @@ -942,8 +940,8 @@ export type AgentDeviceClient = {
list: (
options?: AgentDeviceRequestOverrides & AgentDeviceSelectionOptions,
) => Promise<AgentDeviceDevice[]>;
boot: (options?: DeviceBootOptions) => Promise<CommandRequestResult>;
shutdown: (options?: DeviceShutdownOptions) => Promise<CommandRequestResult>;
boot: (options?: DeviceBootOptions) => Promise<CommandResult<'boot'>>;
shutdown: (options?: DeviceShutdownOptions) => Promise<CommandResult<'shutdown'>>;
};
sessions: {
list: (options?: AgentDeviceRequestOverrides) => Promise<AgentDeviceSession[]>;
Expand Down
10 changes: 6 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ import type {
Lease,
MaterializationReleaseOptions,
MetroPrepareOptions,
ViewportCommandResult,
} from './client-types.ts';
import type { CommandResult } from './core/command-descriptor/command-result.ts';
import { readSerializedSnapshotCaptureAnnotations } from './snapshot-capture-annotations.ts';
import { readSnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts';
import type { CommandFlags } from './core/dispatch-context.ts';
Expand Down Expand Up @@ -111,16 +111,18 @@ export function createAgentDeviceClient(
clipboard: async (options) => await executeCommand('clipboard', options),
reactNative: async (options) => await executeCommand('react-native', options),
prepare: async (options) => await executeCommand('prepare', options),
viewport: async (options) => await executeCommand<ViewportCommandResult>('viewport', options),
viewport: async (options) =>
await executeCommand<CommandResult<'viewport'>>('viewport', options),
},
devices: {
list: async (options = {}) => {
const data = await executeCommand<Record<string, unknown>>('devices', options);
const devices = Array.isArray(data.devices) ? data.devices : [];
return devices.map(normalizeDevice);
},
boot: async (options = {}) => await executeCommand('boot', options),
shutdown: async (options = {}) => await executeCommand('shutdown', options),
boot: async (options = {}) => await executeCommand<CommandResult<'boot'>>('boot', options),
shutdown: async (options = {}) =>
await executeCommand<CommandResult<'shutdown'>>('shutdown', options),
},
sessions: {
list: async (options = {}) => await listSessions(options),
Expand Down
37 changes: 37 additions & 0 deletions src/contracts/device.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { DeviceKind, DeviceTarget, Platform } from '../utils/device.ts';
import type { TargetShutdownResult } from '../target-shutdown-contract.ts';

/**
* Closed result of the `boot` command. Mirrors the daemon handler's only
* success return EXACTLY (src/daemon/handlers/session-state.ts) — the fixed
* object literal `{ platform, target, device, id, kind, booted }`. The handler
* spreads nothing, so this shape is intentionally closed.
*/
export type BootCommandResult = {
platform: Platform;
target: DeviceTarget;
/** Human-readable device name (`device.name`). */
device: string;
/** Stable device id (`device.id`). */
id: string;
kind: DeviceKind;
/** Always `true` on the success path. */
booted: true;
};

/**
* Closed result of the `shutdown` command. Mirrors the daemon handler's success
* return EXACTLY (src/daemon/handlers/session-state.ts) — the fixed object
* literal `{ platform, target, device, id, kind, shutdown }`. The `shutdown`
* field is the raw {@link TargetShutdownResult} from `shutdownDeviceTarget`.
*/
export type ShutdownCommandResult = {
platform: Platform;
target: DeviceTarget;
/** Human-readable device name (`device.name`). */
device: string;
/** Stable device id (`device.id`). */
id: string;
kind: DeviceKind;
shutdown: TargetShutdownResult;
};
12 changes: 12 additions & 0 deletions src/contracts/viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Closed result of the `viewport` command. Mirrors the dispatch handler's return
* EXACTLY (src/core/dispatch.ts `handleViewportCommand`) — `{ width, height }`
* plus the always-present `successText` message. The generic dispatch path
* returns this object unchanged (viewport has no Android dialog guard, so no
* `warning` is ever appended), so the shape is intentionally closed.
*/
export type ViewportCommandResult = {
width: number;
height: number;
message: string;
};
19 changes: 17 additions & 2 deletions src/core/command-descriptor/__tests__/command-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {
LongPressCommandResult,
PressCommandResult,
} from '../../../contracts/interaction.ts';
import type { BootCommandResult, ShutdownCommandResult } from '../../../contracts/device.ts';
import type { ViewportCommandResult } from '../../../contracts/viewport.ts';
import type { CommandResult, CommandResultMap } from '../command-result.ts';

/**
Expand All @@ -19,7 +21,17 @@ test('seeded CommandResult entries resolve to their existing contract result typ
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]);
const boot: Equal<CommandResult<'boot'>, BootCommandResult> = true;
const shutdown: Equal<CommandResult<'shutdown'>, ShutdownCommandResult> = true;
const viewport: Equal<CommandResult<'viewport'>, ViewportCommandResult> = true;
expect([press, fill, longPress, boot, shutdown, viewport]).toEqual([
true,
true,
true,
true,
true,
true,
]);
});

test('unmigrated commands fall back to the untyped Record bag, keeping the union total', () => {
Expand All @@ -30,6 +42,9 @@ test('unmigrated commands fall back to the untyped Record bag, keeping the union
});

test('CommandResultMap is seeded only from already-existing contract result types', () => {
const keys: Equal<keyof CommandResultMap, 'press' | 'fill' | 'longpress'> = true;
const keys: Equal<
keyof CommandResultMap,
'press' | 'fill' | 'longpress' | 'boot' | 'shutdown' | 'viewport'
> = true;
expect(keys).toBe(true);
});
26 changes: 16 additions & 10 deletions src/core/command-descriptor/command-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,32 @@ import type {
LongPressCommandResult,
PressCommandResult,
} from '../../contracts/interaction.ts';
import type { BootCommandResult, ShutdownCommandResult } from '../../contracts/device.ts';
import type { ViewportCommandResult } from '../../contracts/viewport.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.
* Maps a command name to the per-command result type from `src/contracts/*`. It
* is SEEDED, not exhaustive: a command is listed here only once its accurate,
* closed result shape lives in the contracts layer. Commands whose daemon
* handler spreads dynamic/Record data (screenshot overlays, gesture
* visualization, perf, logs, …) 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.
* Phase 2 batch 1 wires the first map entries into the public client return
* types: `boot` / `shutdown` (closed device-lifecycle results) and `viewport`
* (closed `{ width, height, message }`) join the seed interaction trio
* (`press` / `fill` / `longpress`). Each entry is grounded in a re-read of the
* handler's literal return; see the per-type docstrings for the file source.
*/
export interface CommandResultMap {
press: PressCommandResult;
fill: FillCommandResult;
longpress: LongPressCommandResult;
boot: BootCommandResult;
shutdown: ShutdownCommandResult;
viewport: ViewportCommandResult;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export type {

export type { AppErrorCode, NormalizedError } from './utils/errors.ts';

export type { CommandResult } from './core/command-descriptor/command-result.ts';
export type { BootCommandResult, ShutdownCommandResult } from './contracts/device.ts';
export type { ViewportCommandResult } from './contracts/viewport.ts';

export type {
AgentDeviceClient,
AgentDeviceClientConfig,
Expand Down
Loading