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
141 changes: 141 additions & 0 deletions COMMAND_OWNERSHIP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Command Ownership Inventory

This inventory keeps the public boundary stable while command semantics move into
the runtime layer. New integrations should prefer the runtime, backend, and IO
interfaces over helper subpaths.

## Portable Command Runtime

These commands describe device, app, capture, selector, or interaction behavior.
Their semantics should live in `agent-device/commands` as they migrate.

- `alert`
- `app-switcher`
- `apps`
- `appstate`
- `back`
- `click`
- `clipboard`
- `close`
- `diff`
- `fill`
- `find`
- `focus`
- `get`
- `home`
- `is`
- `keyboard`
- `longpress`
- `open`
- `pinch`
- `press`
- `push`
- `rotate`
- `screenshot`
- `scroll`
- `settings`
- `snapshot`
- `swipe`
- `trigger-app-event`
- `type`
- `wait`

## Runtime Migration Status

- `screenshot`: runtime command implemented; daemon screenshot dispatch calls the runtime.
- `diff screenshot`: runtime command implemented; CLI screenshot diff dispatch calls the runtime.
- `snapshot`: runtime command implemented; daemon snapshot dispatch calls the runtime.
- `diff snapshot`: runtime command implemented; daemon snapshot diff dispatch calls the runtime.
- `find`: read-only runtime actions implemented for `exists`, `wait`, `get text`,
and `get attrs`; mutating find actions remain on the existing interaction path.
- `get`: runtime command implemented; daemon get dispatch calls the runtime.
- `is`: runtime command implemented; daemon is dispatch calls the runtime.
- `wait`: runtime command implemented for sleep, text, ref, and selector waits;
daemon wait dispatch calls the runtime.
- `click`: runtime command implemented for point, ref, and selector targets; the
daemon click dispatch calls the runtime.
- `press`: runtime command implemented for point, ref, and selector targets; the
daemon press dispatch calls the runtime.
- `fill`: runtime command implemented for point, ref, and selector targets; the
daemon fill dispatch calls the runtime.
- `type`: runtime command implemented; daemon type dispatch calls the runtime.

## Boundary Requirements

- Public command APIs expose only implemented commands. Planned commands belong
in `commandCatalog`, not as methods that throw at runtime.
- Runtime services default to `restrictedCommandPolicy()`. Local input and
output paths require an explicit local policy or adapter decision.
- File inputs and outputs cross the runtime boundary through `agent-device/io`
refs and artifact descriptors; command implementations should not accept
ad-hoc path strings for new file contracts.
- Image-producing or image-reading commands must preserve `maxImagePixels`
enforcement before decoding or comparing untrusted images.
- Backend escape hatches must be named capabilities with a policy gate. Do not
add a freeform backend command bag.
- Command options should carry `session`, `requestId`, `signal`, and `metadata`
through `CommandContext` so hosted adapters can enforce request scope,
cancellation, and audit correlation consistently.
- Runtime command modules should depend on shared `src/utils/*` helpers, not
daemon-only modules. Keep daemon paths as compatibility shims when older
handlers still import them.
- New backend adapters should run `agent-device/testing/conformance` suites for
the command families they claim to support.

## Backend And Admin Capabilities

These commands manage devices or app installation. Keep them explicit backend
capabilities so hosted adapters can decide what is supported.

- `boot`
- `devices`
- `ensure-simulator`
- `install`
- `install-from-source`
- `reinstall`

## Transport And Session Orchestration

These are daemon, CLI, or transport concerns. They can construct or call the
runtime, but they are not portable command semantics.

- `session`
- lease allocation, heartbeat, and release daemon commands

## Environment Preparation

These prepare local or remote development environment state. Keep them outside
the portable command runtime.

- `connect`
- `connection`
- `disconnect`
- `metro`

## Later Capability-Gated Runtime Commands

These commands should migrate only after the runtime, backend capability, and IO
contracts are established for their behavior.

- `batch`
- `logs`
- `network`
- `perf`
- `record`
- `replay`
- `test`
- `trace`

## Compatibility Helper Subpaths

These subpaths remain available during migration, but they should not be the
primary boundary for new command behavior:

- `agent-device/contracts`
- `agent-device/selectors`
- `agent-device/finders`
- `agent-device/install-source`
- `agent-device/android-apps`
- `agent-device/artifacts`
- `agent-device/metro`
- `agent-device/remote-config`
16 changes: 16 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@
"import": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
},
"./commands": {
"import": "./dist/src/commands/index.js",
"types": "./dist/src/commands/index.d.ts"
},
"./backend": {
"import": "./dist/src/backend.js",
"types": "./dist/src/backend.d.ts"
},
"./io": {
"import": "./dist/src/io.js",
"types": "./dist/src/io.d.ts"
},
"./testing/conformance": {
"import": "./dist/src/testing/conformance.js",
"types": "./dist/src/testing/conformance.d.ts"
},
"./artifacts": {
"import": "./dist/src/artifacts.js",
"types": "./dist/src/artifacts.d.ts"
Expand Down
4 changes: 4 additions & 0 deletions rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export default defineConfig({
source: {
entry: {
index: 'src/index.ts',
'commands/index': 'src/commands/index.ts',
backend: 'src/backend.ts',
io: 'src/io.ts',
'testing/conformance': 'src/testing/conformance.ts',
artifacts: 'src/artifacts.ts',
metro: 'src/metro.ts',
'remote-config': 'src/remote-config.ts',
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/cli-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ async function runCliCapture(
const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
const originalStateDir = process.env.AGENT_DEVICE_STATE_DIR;
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-batch-'));
process.env.AGENT_DEVICE_STATE_DIR = stateDir;

(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
Expand Down Expand Up @@ -61,6 +64,9 @@ async function runCliCapture(
if (error instanceof ExitSignal) code = error.code;
else throw error;
} finally {
if (originalStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
else process.env.AGENT_DEVICE_STATE_DIR = originalStateDir;
fs.rmSync(stateDir, { recursive: true, force: true });
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { PNG } from 'pngjs';
import { tryRunClientBackedCommand } from '../cli/commands/router.ts';
import type {
AgentDeviceClient,
Expand Down Expand Up @@ -204,6 +205,59 @@ test('screenshot forwards --overlay-refs to the client capture API', async () =>
});
});

test('diff screenshot forwards --surface to live client screenshot capture', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-diff-surface-'));
const baseline = path.join(dir, 'baseline.png');
const out = path.join(dir, 'diff.png');
fs.writeFileSync(baseline, solidPngBuffer(4, 4, { r: 0, g: 0, b: 0 }));
let observed: Parameters<AgentDeviceClient['capture']['screenshot']>[0] | undefined;

try {
const client = createStubClient({
installFromSource: async () => {
throw new Error('unexpected install call');
},
screenshot: async (options) => {
if (!options?.path) {
throw new Error('expected runtime to request a live screenshot path');
}
observed = options;
fs.writeFileSync(options.path, solidPngBuffer(4, 4, { r: 255, g: 255, b: 255 }));
return {
path: options.path,
identifiers: { session: options.session ?? 'default' },
};
},
});

await captureStdout(async () => {
const handled = await tryRunClientBackedCommand({
command: 'diff',
positionals: ['screenshot'],
flags: {
json: true,
help: false,
version: false,
baseline,
out,
platform: 'macos',
session: 'surface-session',
surface: 'menubar',
threshold: '0',
},
client,
});
assert.equal(handled, true);
});

assert.equal(observed?.session, 'surface-session');
assert.equal(observed?.surface, 'menubar');
assert.equal(fs.existsSync(out), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

test('open forwards macOS surface to the client apps API', async () => {
let observed: AppOpenOptions | undefined;
const client = createStubClient({
Expand Down Expand Up @@ -630,3 +684,18 @@ function createThrowingMethodGroup<T extends object>(): T {
get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall,
}) as T;
}

function solidPngBuffer(
width: number,
height: number,
color: { r: number; g: number; b: number },
): Buffer {
const png = new PNG({ width, height });
for (let i = 0; i < png.data.length; i += 4) {
png.data[i] = color.r;
png.data[i + 1] = color.g;
png.data[i + 2] = color.b;
png.data[i + 3] = 255;
}
return PNG.sync.write(png);
}
6 changes: 6 additions & 0 deletions src/__tests__/cli-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ async function runCliCapture(
const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
const originalStateDir = process.env.AGENT_DEVICE_STATE_DIR;
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-diagnostics-'));
process.env.AGENT_DEVICE_STATE_DIR = stateDir;

(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
Expand All @@ -59,6 +62,9 @@ async function runCliCapture(
if (error instanceof ExitSignal) code = error.code;
else throw error;
} finally {
if (originalStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
else process.env.AGENT_DEVICE_STATE_DIR = originalStateDir;
fs.rmSync(stateDir, { recursive: true, force: true });
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
Expand Down
Loading
Loading