Skip to content

Commit 8bcefb7

Browse files
authored
feat: add runtime command boundary (#412)
* feat: add runtime command boundary * refactor: harden runtime command boundary * fix: address runtime boundary review * fix: preserve selector snapshot flags * fix: preserve selector get and screenshot cleanup * fix: harden runtime boundary follow-ups * fix: close runtime parity gaps * test: harden android replay navigation * fix: close screenshot surface edge cases * test: harden packaged runtime API smoke * fix: close runtime review edge cases * test: isolate CLI state dir in unit helpers
1 parent 5868ace commit 8bcefb7

69 files changed

Lines changed: 7771 additions & 1669 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

COMMAND_OWNERSHIP.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Command Ownership Inventory
2+
3+
This inventory keeps the public boundary stable while command semantics move into
4+
the runtime layer. New integrations should prefer the runtime, backend, and IO
5+
interfaces over helper subpaths.
6+
7+
## Portable Command Runtime
8+
9+
These commands describe device, app, capture, selector, or interaction behavior.
10+
Their semantics should live in `agent-device/commands` as they migrate.
11+
12+
- `alert`
13+
- `app-switcher`
14+
- `apps`
15+
- `appstate`
16+
- `back`
17+
- `click`
18+
- `clipboard`
19+
- `close`
20+
- `diff`
21+
- `fill`
22+
- `find`
23+
- `focus`
24+
- `get`
25+
- `home`
26+
- `is`
27+
- `keyboard`
28+
- `longpress`
29+
- `open`
30+
- `pinch`
31+
- `press`
32+
- `push`
33+
- `rotate`
34+
- `screenshot`
35+
- `scroll`
36+
- `settings`
37+
- `snapshot`
38+
- `swipe`
39+
- `trigger-app-event`
40+
- `type`
41+
- `wait`
42+
43+
## Runtime Migration Status
44+
45+
- `screenshot`: runtime command implemented; daemon screenshot dispatch calls the runtime.
46+
- `diff screenshot`: runtime command implemented; CLI screenshot diff dispatch calls the runtime.
47+
- `snapshot`: runtime command implemented; daemon snapshot dispatch calls the runtime.
48+
- `diff snapshot`: runtime command implemented; daemon snapshot diff dispatch calls the runtime.
49+
- `find`: read-only runtime actions implemented for `exists`, `wait`, `get text`,
50+
and `get attrs`; mutating find actions remain on the existing interaction path.
51+
- `get`: runtime command implemented; daemon get dispatch calls the runtime.
52+
- `is`: runtime command implemented; daemon is dispatch calls the runtime.
53+
- `wait`: runtime command implemented for sleep, text, ref, and selector waits;
54+
daemon wait dispatch calls the runtime.
55+
- `click`: runtime command implemented for point, ref, and selector targets; the
56+
daemon click dispatch calls the runtime.
57+
- `press`: runtime command implemented for point, ref, and selector targets; the
58+
daemon press dispatch calls the runtime.
59+
- `fill`: runtime command implemented for point, ref, and selector targets; the
60+
daemon fill dispatch calls the runtime.
61+
- `type`: runtime command implemented; daemon type dispatch calls the runtime.
62+
63+
## Boundary Requirements
64+
65+
- Public command APIs expose only implemented commands. Planned commands belong
66+
in `commandCatalog`, not as methods that throw at runtime.
67+
- Runtime services default to `restrictedCommandPolicy()`. Local input and
68+
output paths require an explicit local policy or adapter decision.
69+
- File inputs and outputs cross the runtime boundary through `agent-device/io`
70+
refs and artifact descriptors; command implementations should not accept
71+
ad-hoc path strings for new file contracts.
72+
- Image-producing or image-reading commands must preserve `maxImagePixels`
73+
enforcement before decoding or comparing untrusted images.
74+
- Backend escape hatches must be named capabilities with a policy gate. Do not
75+
add a freeform backend command bag.
76+
- Command options should carry `session`, `requestId`, `signal`, and `metadata`
77+
through `CommandContext` so hosted adapters can enforce request scope,
78+
cancellation, and audit correlation consistently.
79+
- Runtime command modules should depend on shared `src/utils/*` helpers, not
80+
daemon-only modules. Keep daemon paths as compatibility shims when older
81+
handlers still import them.
82+
- New backend adapters should run `agent-device/testing/conformance` suites for
83+
the command families they claim to support.
84+
85+
## Backend And Admin Capabilities
86+
87+
These commands manage devices or app installation. Keep them explicit backend
88+
capabilities so hosted adapters can decide what is supported.
89+
90+
- `boot`
91+
- `devices`
92+
- `ensure-simulator`
93+
- `install`
94+
- `install-from-source`
95+
- `reinstall`
96+
97+
## Transport And Session Orchestration
98+
99+
These are daemon, CLI, or transport concerns. They can construct or call the
100+
runtime, but they are not portable command semantics.
101+
102+
- `session`
103+
- lease allocation, heartbeat, and release daemon commands
104+
105+
## Environment Preparation
106+
107+
These prepare local or remote development environment state. Keep them outside
108+
the portable command runtime.
109+
110+
- `connect`
111+
- `connection`
112+
- `disconnect`
113+
- `metro`
114+
115+
## Later Capability-Gated Runtime Commands
116+
117+
These commands should migrate only after the runtime, backend capability, and IO
118+
contracts are established for their behavior.
119+
120+
- `batch`
121+
- `logs`
122+
- `network`
123+
- `perf`
124+
- `record`
125+
- `replay`
126+
- `test`
127+
- `trace`
128+
129+
## Compatibility Helper Subpaths
130+
131+
These subpaths remain available during migration, but they should not be the
132+
primary boundary for new command behavior:
133+
134+
- `agent-device/contracts`
135+
- `agent-device/selectors`
136+
- `agent-device/finders`
137+
- `agent-device/install-source`
138+
- `agent-device/android-apps`
139+
- `agent-device/artifacts`
140+
- `agent-device/metro`
141+
- `agent-device/remote-config`

package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@
1212
"import": "./dist/src/index.js",
1313
"types": "./dist/src/index.d.ts"
1414
},
15+
"./commands": {
16+
"import": "./dist/src/commands/index.js",
17+
"types": "./dist/src/commands/index.d.ts"
18+
},
19+
"./backend": {
20+
"import": "./dist/src/backend.js",
21+
"types": "./dist/src/backend.d.ts"
22+
},
23+
"./io": {
24+
"import": "./dist/src/io.js",
25+
"types": "./dist/src/io.d.ts"
26+
},
27+
"./testing/conformance": {
28+
"import": "./dist/src/testing/conformance.js",
29+
"types": "./dist/src/testing/conformance.d.ts"
30+
},
1531
"./artifacts": {
1632
"import": "./dist/src/artifacts.js",
1733
"types": "./dist/src/artifacts.d.ts"

rslib.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export default defineConfig({
1717
source: {
1818
entry: {
1919
index: 'src/index.ts',
20+
'commands/index': 'src/commands/index.ts',
21+
backend: 'src/backend.ts',
22+
io: 'src/io.ts',
23+
'testing/conformance': 'src/testing/conformance.ts',
2024
artifacts: 'src/artifacts.ts',
2125
metro: 'src/metro.ts',
2226
'remote-config': 'src/remote-config.ts',

src/__tests__/cli-batch.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ async function runCliCapture(
3434
const originalExit = process.exit;
3535
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
3636
const originalStderrWrite = process.stderr.write.bind(process.stderr);
37+
const originalStateDir = process.env.AGENT_DEVICE_STATE_DIR;
38+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-batch-'));
39+
process.env.AGENT_DEVICE_STATE_DIR = stateDir;
3740

3841
(process as any).exit = ((nextCode?: number) => {
3942
throw new ExitSignal(nextCode ?? 0);
@@ -61,6 +64,9 @@ async function runCliCapture(
6164
if (error instanceof ExitSignal) code = error.code;
6265
else throw error;
6366
} finally {
67+
if (originalStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
68+
else process.env.AGENT_DEVICE_STATE_DIR = originalStateDir;
69+
fs.rmSync(stateDir, { recursive: true, force: true });
6470
process.exit = originalExit;
6571
process.stdout.write = originalStdoutWrite;
6672
process.stderr.write = originalStderrWrite;

src/__tests__/cli-client-commands.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import os from 'node:os';
33
import path from 'node:path';
44
import { test } from 'vitest';
55
import assert from 'node:assert/strict';
6+
import { PNG } from 'pngjs';
67
import { tryRunClientBackedCommand } from '../cli/commands/router.ts';
78
import type {
89
AgentDeviceClient,
@@ -204,6 +205,59 @@ test('screenshot forwards --overlay-refs to the client capture API', async () =>
204205
});
205206
});
206207

208+
test('diff screenshot forwards --surface to live client screenshot capture', async () => {
209+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-diff-surface-'));
210+
const baseline = path.join(dir, 'baseline.png');
211+
const out = path.join(dir, 'diff.png');
212+
fs.writeFileSync(baseline, solidPngBuffer(4, 4, { r: 0, g: 0, b: 0 }));
213+
let observed: Parameters<AgentDeviceClient['capture']['screenshot']>[0] | undefined;
214+
215+
try {
216+
const client = createStubClient({
217+
installFromSource: async () => {
218+
throw new Error('unexpected install call');
219+
},
220+
screenshot: async (options) => {
221+
if (!options?.path) {
222+
throw new Error('expected runtime to request a live screenshot path');
223+
}
224+
observed = options;
225+
fs.writeFileSync(options.path, solidPngBuffer(4, 4, { r: 255, g: 255, b: 255 }));
226+
return {
227+
path: options.path,
228+
identifiers: { session: options.session ?? 'default' },
229+
};
230+
},
231+
});
232+
233+
await captureStdout(async () => {
234+
const handled = await tryRunClientBackedCommand({
235+
command: 'diff',
236+
positionals: ['screenshot'],
237+
flags: {
238+
json: true,
239+
help: false,
240+
version: false,
241+
baseline,
242+
out,
243+
platform: 'macos',
244+
session: 'surface-session',
245+
surface: 'menubar',
246+
threshold: '0',
247+
},
248+
client,
249+
});
250+
assert.equal(handled, true);
251+
});
252+
253+
assert.equal(observed?.session, 'surface-session');
254+
assert.equal(observed?.surface, 'menubar');
255+
assert.equal(fs.existsSync(out), true);
256+
} finally {
257+
fs.rmSync(dir, { recursive: true, force: true });
258+
}
259+
});
260+
207261
test('open forwards macOS surface to the client apps API', async () => {
208262
let observed: AppOpenOptions | undefined;
209263
const client = createStubClient({
@@ -630,3 +684,18 @@ function createThrowingMethodGroup<T extends object>(): T {
630684
get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall,
631685
}) as T;
632686
}
687+
688+
function solidPngBuffer(
689+
width: number,
690+
height: number,
691+
color: { r: number; g: number; b: number },
692+
): Buffer {
693+
const png = new PNG({ width, height });
694+
for (let i = 0; i < png.data.length; i += 4) {
695+
png.data[i] = color.r;
696+
png.data[i + 1] = color.g;
697+
png.data[i + 2] = color.b;
698+
png.data[i + 3] = 255;
699+
}
700+
return PNG.sync.write(png);
701+
}

src/__tests__/cli-diagnostics.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ async function runCliCapture(
3535
const originalExit = process.exit;
3636
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
3737
const originalStderrWrite = process.stderr.write.bind(process.stderr);
38+
const originalStateDir = process.env.AGENT_DEVICE_STATE_DIR;
39+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-diagnostics-'));
40+
process.env.AGENT_DEVICE_STATE_DIR = stateDir;
3841

3942
(process as any).exit = ((nextCode?: number) => {
4043
throw new ExitSignal(nextCode ?? 0);
@@ -59,6 +62,9 @@ async function runCliCapture(
5962
if (error instanceof ExitSignal) code = error.code;
6063
else throw error;
6164
} finally {
65+
if (originalStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
66+
else process.env.AGENT_DEVICE_STATE_DIR = originalStateDir;
67+
fs.rmSync(stateDir, { recursive: true, force: true });
6268
process.exit = originalExit;
6369
process.stdout.write = originalStdoutWrite;
6470
process.stderr.write = originalStderrWrite;

0 commit comments

Comments
 (0)