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
21 changes: 21 additions & 0 deletions src/__tests__/contracts-schema-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import fs from 'node:fs';
import path from 'node:path';
import {
AppError,
type AppStateCommandResult,
type BackCommandResult,
type BootCommandResult,
type ClipboardCommandResult,
type CommandResult,
type RotateCommandResult,
type ShutdownCommandResult,
Expand Down Expand Up @@ -142,11 +144,30 @@ test('public root exports typed command result contracts', () => {
} satisfies RotateCommandResult;
const rotateFromMap: CommandResult<'rotate'> = rotate;

const clipboard = {
action: 'write',
textLength: 11,
message: 'Clipboard updated',
} satisfies ClipboardCommandResult;
const clipboardFromMap: CommandResult<'clipboard'> = clipboard;

const appstate = {
platform: 'android',
package: 'com.example.demo',
activity: 'com.example.demo.MainActivity',
} satisfies AppStateCommandResult;
const appstateFromMap: CommandResult<'appstate'> = appstate;

assert.equal(bootFromMap.booted, true);
assert.equal(shutdownFromMap.shutdown.success, true);
assert.equal(viewportFromMap.width, 390);
assert.equal(backFromMap.mode, 'in-app');
assert.equal(rotateFromMap.orientation, 'portrait');
assert.equal(clipboardFromMap.action === 'write' ? clipboardFromMap.textLength : -1, 11);
assert.equal(
appstateFromMap.platform === 'android' ? appstateFromMap.package : '',
'com.example.demo',
);
});

test('public daemon request schema accepts GitHub Actions artifact install sources', () => {
Expand Down
26 changes: 4 additions & 22 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export type {
HomeCommandResult,
RotateCommandResult,
} from './contracts/navigation.ts';
export type { ClipboardCommandResult } from './contracts/clipboard.ts';
export type { AppStateCommandResult } from './contracts/app-state.ts';

export type AgentDeviceDaemonTransport = (
req: Omit<DaemonRequest, 'token'>,
Expand Down Expand Up @@ -473,16 +475,6 @@ export type AlertCommandResult = DaemonResponseData & {
items?: string[];
};

export type AppStateCommandResult = DaemonResponseData & {
platform?: Platform;
appName?: string;
appBundleId?: string;
package?: string;
activity?: string;
source?: 'session';
surface?: SessionSurface;
};

export type KeyboardCommandResult = DaemonResponseData & {
platform?: 'android' | 'ios';
action?: 'status' | 'dismiss' | 'enter';
Expand All @@ -498,16 +490,6 @@ export type KeyboardCommandResult = DaemonResponseData & {
attempts?: number;
};

export type ClipboardCommandResult =
| (DaemonResponseData & {
action: 'read';
text: string;
})
| (DaemonResponseData & {
action: 'write';
textLength: number;
});

export type ReactNativeCommandOptions = DeviceCommandBaseOptions & {
action: 'dismiss-overlay';
};
Expand All @@ -525,13 +507,13 @@ export type ViewportCommandOptions = DeviceCommandBaseOptions & {
export type AgentDeviceCommandClient = {
wait: (options: WaitCommandOptions) => Promise<WaitCommandResult>;
alert: (options?: AlertCommandOptions) => Promise<AlertCommandResult>;
appState: (options?: AppStateCommandOptions) => Promise<AppStateCommandResult>;
appState: (options?: AppStateCommandOptions) => Promise<CommandResult<'appstate'>>;
back: (options?: BackCommandOptions) => Promise<CommandResult<'back'>>;
home: (options?: HomeCommandOptions) => Promise<CommandResult<'home'>>;
rotate: (options: RotateCommandOptions) => Promise<CommandResult<'rotate'>>;
appSwitcher: (options?: AppSwitcherCommandOptions) => Promise<CommandResult<'app-switcher'>>;
keyboard: (options?: KeyboardCommandOptions) => Promise<KeyboardCommandResult>;
clipboard: (options: ClipboardCommandOptions) => Promise<ClipboardCommandResult>;
clipboard: (options: ClipboardCommandOptions) => Promise<CommandResult<'clipboard'>>;
reactNative: (options: ReactNativeCommandOptions) => Promise<CommandRequestResult>;
prepare: (options: PrepareCommandOptions) => Promise<CommandRequestResult>;
viewport: (options: ViewportCommandOptions) => Promise<CommandResult<'viewport'>>;
Expand Down
6 changes: 4 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,16 @@ export function createAgentDeviceClient(
command: {
wait: async (options) => await executeCommand('wait', options),
alert: async (options = {}) => await executeCommand('alert', options),
appState: async (options = {}) => await executeCommand('appstate', options),
appState: async (options = {}) =>
await executeCommand<CommandResult<'appstate'>>('appstate', options),
back: async (options = {}) => await executeCommand<CommandResult<'back'>>('back', options),
home: async (options = {}) => await executeCommand<CommandResult<'home'>>('home', options),
rotate: async (options) => await executeCommand<CommandResult<'rotate'>>('rotate', options),
appSwitcher: async (options = {}) =>
await executeCommand<CommandResult<'app-switcher'>>('app-switcher', options),
keyboard: async (options = {}) => await executeCommand('keyboard', options),
clipboard: async (options) => await executeCommand('clipboard', options),
clipboard: async (options) =>
await executeCommand<CommandResult<'clipboard'>>('clipboard', options),
reactNative: async (options) => await executeCommand('react-native', options),
prepare: async (options) => await executeCommand('prepare', options),
viewport: async (options) =>
Expand Down
30 changes: 30 additions & 0 deletions src/contracts/app-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { SessionSurface } from '../core/session-surface.ts';

/**
* Closed result of the `appstate` command, grounded in the daemon handler's
* success returns (src/daemon/handlers/session-state.ts `handleAppStateCommand`).
* A discriminated union on `platform`:
* - Apple (`ios` / `macos`) session state, with iOS-only device locators that
* the previous hand-written mirror omitted; and
* - Android foreground `package` / `activity`.
*
* The handler returns one of these fixed objects (errors take the `ok: false`
* path), so each branch is closed.
*/
export type AppStateCommandResult =
| {
platform: 'ios' | 'macos';
appName: string;
appBundleId?: string;
source: 'session';
surface: SessionSurface;
/** iOS only — the session device's UDID. */
device_udid?: string;
/** iOS only — the simulator set path, or `null` when unknown. */
ios_simulator_device_set?: string | null;
}
| {
platform: 'android';
package: string;
activity: string;
};
17 changes: 17 additions & 0 deletions src/contracts/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Closed result of the `clipboard` command. Mirrors the dispatch handler's
* literal return EXACTLY (src/core/dispatch.ts `handleClipboardCommand`): a
* discriminated union on `action`. `read` returns the clipboard `text`; `write`
* reports the written `textLength` plus the `successText` message. The handler
* spreads nothing else, so each branch is closed.
*/
export type ClipboardCommandResult =
| {
action: 'read';
text: string;
}
| {
action: 'write';
textLength: number;
message: string;
};
10 changes: 9 additions & 1 deletion src/core/command-descriptor/__tests__/command-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
HomeCommandResult,
RotateCommandResult,
} from '../../../contracts/navigation.ts';
import type { ClipboardCommandResult } from '../../../contracts/clipboard.ts';
import type { AppStateCommandResult } from '../../../contracts/app-state.ts';
import type { CommandResult, CommandResultMap } from '../command-result.ts';

/**
Expand All @@ -34,6 +36,8 @@ test('seeded CommandResult entries resolve to their existing contract result typ
const back: Equal<CommandResult<'back'>, BackCommandResult> = true;
const rotate: Equal<CommandResult<'rotate'>, RotateCommandResult> = true;
const appSwitcher: Equal<CommandResult<'app-switcher'>, AppSwitcherCommandResult> = true;
const clipboard: Equal<CommandResult<'clipboard'>, ClipboardCommandResult> = true;
const appstate: Equal<CommandResult<'appstate'>, AppStateCommandResult> = true;
expect([
press,
fill,
Expand All @@ -45,7 +49,9 @@ test('seeded CommandResult entries resolve to their existing contract result typ
back,
rotate,
appSwitcher,
]).toEqual([true, true, true, true, true, true, true, true, true, true]);
clipboard,
appstate,
]).toEqual([true, true, true, true, true, true, true, true, true, true, true, true]);
});

test('unmigrated commands fall back to the untyped Record bag, keeping the union total', () => {
Expand All @@ -68,6 +74,8 @@ test('CommandResultMap is seeded only from already-existing contract result type
| 'back'
| 'rotate'
| 'app-switcher'
| 'clipboard'
| 'appstate'
> = true;
expect(keys).toBe(true);
});
15 changes: 10 additions & 5 deletions src/core/command-descriptor/command-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
HomeCommandResult,
RotateCommandResult,
} from '../../contracts/navigation.ts';
import type { ClipboardCommandResult } from '../../contracts/clipboard.ts';
import type { AppStateCommandResult } from '../../contracts/app-state.ts';

/**
* The additive typed-result spine (ADR-0008, Phase 1 step 6).
Expand All @@ -22,11 +24,12 @@ import type {
* visualization, perf, logs, …) are deliberately omitted rather than given an
* invented shape.
*
* Batch 1 wired `boot` / `shutdown` / `viewport` alongside the seed interaction
* trio (`press` / `fill` / `longpress`). Batch 2 adds the closed navigation/
* action commands `home` / `back` / `rotate` / `app-switcher` (each a fixed
* `{ action, …, message }`). Each entry is grounded in a re-read of the handler's
* literal return; see the per-type docstrings for the file source.
* Batches 1-2 wired `boot` / `shutdown` / `viewport` and the navigation/action
* commands `home` / `back` / `rotate` / `app-switcher` alongside the seed
* interaction trio. Batch 3 adds `clipboard` (a closed `read`/`write` union) and
* `appstate` (a closed `platform` union — Apple session state with the iOS-only
* device locators, or Android package/activity). Each entry is grounded in a
* re-read of the handler's literal return; see the per-type docstrings.
*/
export interface CommandResultMap {
press: PressCommandResult;
Expand All @@ -39,6 +42,8 @@ export interface CommandResultMap {
back: BackCommandResult;
rotate: RotateCommandResult;
'app-switcher': AppSwitcherCommandResult;
clipboard: ClipboardCommandResult;
appstate: AppStateCommandResult;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions test/integration/provider-scenarios/android-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,19 +533,22 @@ async function runAndroidSetupAndInstallWorkflow(
assert.equal(braceFilePush.json.result.data.extrasCount, 1);

const clipboard = await client.command.clipboard({ action: 'read', ...selection });
if (clipboard.action !== 'read') throw new Error('expected clipboard read result');
assert.equal(clipboard.text, 'hello');

const clipboardWrite = await client.command.clipboard({
action: 'write',
text: 'android otp',
...selection,
});
if (clipboardWrite.action !== 'write') throw new Error('expected clipboard write result');
assert.equal(clipboardWrite.textLength, 11);

const clipboardAfterWrite = await client.command.clipboard({
action: 'read',
...selection,
});
if (clipboardAfterWrite.action !== 'read') throw new Error('expected clipboard read result');
assert.equal(clipboardAfterWrite.text, 'android otp');

const keyboard = await client.command.keyboard({ action: 'status', ...selection });
Expand Down
Loading