Skip to content

Commit 2c41225

Browse files
authored
feat: add runtime app commands (#414)
1 parent 94d44d5 commit 2c41225

14 files changed

Lines changed: 1073 additions & 6 deletions

COMMAND_OWNERSHIP.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ Their semantics should live in `agent-device/commands` as they migrate.
5959
- `fill`: runtime command implemented for point, ref, and selector targets; the
6060
daemon fill dispatch calls the runtime.
6161
- `type`: runtime command implemented; daemon type dispatch calls the runtime.
62+
- `open`: runtime `apps.open` implemented for typed app, bundle/package,
63+
activity, URL, and relaunch targets.
64+
- `close`: runtime `apps.close` implemented for optional app targets.
65+
- `apps`: runtime `apps.list` implemented with typed app list filters.
66+
- `appstate`: runtime `apps.state` implemented against backend state
67+
primitives.
68+
- `push`: runtime `apps.push` implemented with JSON and artifact/file inputs;
69+
local file inputs remain command-policy gated.
70+
- `trigger-app-event`: runtime `apps.triggerEvent` implemented with event name
71+
and JSON payload validation.
6272

6373
## Boundary Requirements
6474

src/__tests__/runtime-apps.test.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type {
4+
AgentDeviceBackend,
5+
BackendAppEvent,
6+
BackendOpenTarget,
7+
BackendPushInput,
8+
} from '../backend.ts';
9+
import { createLocalArtifactAdapter } from '../io.ts';
10+
import { createAgentDevice, localCommandPolicy, restrictedCommandPolicy } from '../runtime.ts';
11+
12+
test('runtime app commands call typed backend lifecycle primitives', async () => {
13+
const calls: unknown[] = [];
14+
const device = createAgentDevice({
15+
backend: createAppsBackend(calls),
16+
artifacts: createLocalArtifactAdapter(),
17+
policy: localCommandPolicy(),
18+
});
19+
20+
const opened = await device.apps.open({
21+
session: 'default',
22+
app: ' com.example.app ',
23+
relaunch: true,
24+
});
25+
assert.deepEqual(opened, {
26+
kind: 'appOpened',
27+
target: { app: 'com.example.app' },
28+
relaunch: true,
29+
backendResult: { opened: true },
30+
message: 'Opened: com.example.app',
31+
});
32+
33+
const closed = await device.apps.close({ app: 'com.example.app' });
34+
assert.equal(closed.kind, 'appClosed');
35+
36+
const listed = await device.apps.list({ filter: 'user-installed' });
37+
assert.deepEqual(listed.apps, [
38+
{
39+
id: 'com.example.app',
40+
name: 'Example',
41+
bundleId: 'com.example.app',
42+
},
43+
]);
44+
45+
const state = await device.apps.state({ app: 'com.example.app' });
46+
assert.deepEqual(state.state, { bundleId: 'com.example.app', state: 'foreground' });
47+
48+
const pushed = await device.apps.push({
49+
app: 'com.example.app',
50+
input: { kind: 'json', payload: { aps: { alert: 'hello' } } },
51+
});
52+
assert.equal(pushed.inputKind, 'json');
53+
54+
const triggered = await device.apps.triggerEvent({
55+
name: 'example.ready',
56+
payload: { source: 'test' },
57+
});
58+
assert.equal(triggered.name, 'example.ready');
59+
60+
assert.deepEqual(calls, [
61+
{
62+
command: 'openApp',
63+
target: { app: 'com.example.app' },
64+
options: { relaunch: true },
65+
session: 'default',
66+
},
67+
{ command: 'closeApp', app: 'com.example.app' },
68+
{ command: 'listApps', filter: 'user-installed' },
69+
{ command: 'getAppState', app: 'com.example.app' },
70+
{
71+
command: 'pushFile',
72+
target: 'com.example.app',
73+
input: { kind: 'json', payload: { aps: { alert: 'hello' } } },
74+
},
75+
{
76+
command: 'triggerAppEvent',
77+
event: { name: 'example.ready', payload: { source: 'test' } },
78+
},
79+
]);
80+
});
81+
82+
test('runtime app push rejects local payload paths under restricted policy', async () => {
83+
let pushCalled = false;
84+
const device = createAgentDevice({
85+
backend: {
86+
...createAppsBackend([]),
87+
pushFile: async () => {
88+
pushCalled = true;
89+
},
90+
},
91+
artifacts: createLocalArtifactAdapter(),
92+
policy: restrictedCommandPolicy(),
93+
});
94+
95+
await assert.rejects(
96+
() =>
97+
device.apps.push({
98+
app: 'com.example.app',
99+
input: { kind: 'path', path: '/tmp/payload.json' },
100+
}),
101+
/Local input paths are not allowed/,
102+
);
103+
assert.equal(pushCalled, false);
104+
});
105+
106+
test('runtime app commands validate JSON payloads', async () => {
107+
const device = createAgentDevice({
108+
backend: createAppsBackend([]),
109+
artifacts: createLocalArtifactAdapter(),
110+
policy: localCommandPolicy(),
111+
});
112+
113+
await assert.rejects(
114+
() => device.apps.triggerEvent({ name: 'bad event' }),
115+
/Invalid apps\.triggerEvent name/,
116+
);
117+
await assert.rejects(
118+
() =>
119+
device.apps.push({
120+
app: 'com.example.app',
121+
input: { kind: 'json', payload: [] as unknown as Record<string, unknown> },
122+
}),
123+
/JSON payload must be a JSON object/,
124+
);
125+
await assert.rejects(
126+
() =>
127+
device.apps.push({
128+
app: 'com.example.app',
129+
input: {
130+
kind: 'json',
131+
payload: { count: 1n } as unknown as Record<string, unknown>,
132+
},
133+
}),
134+
/JSON payload must be JSON-serializable/,
135+
);
136+
await assert.rejects(
137+
() =>
138+
device.apps.push({
139+
app: 'com.example.app',
140+
input: {
141+
kind: 'json',
142+
payload: { toJSON: () => undefined } as unknown as Record<string, unknown>,
143+
},
144+
}),
145+
/JSON payload must be JSON-serializable/,
146+
);
147+
await assert.rejects(
148+
() =>
149+
device.apps.push({
150+
app: 'com.example.app',
151+
input: {
152+
kind: 'json',
153+
payload: { data: 'x'.repeat(8 * 1024) },
154+
},
155+
}),
156+
/JSON payload exceeds 8192 bytes/,
157+
);
158+
await assert.rejects(
159+
() =>
160+
device.apps.triggerEvent({
161+
name: 'example.ready',
162+
payload: { count: 1n } as unknown as Record<string, unknown>,
163+
}),
164+
/payload for "example.ready" must be JSON-serializable/,
165+
);
166+
await assert.rejects(
167+
() =>
168+
device.apps.triggerEvent({
169+
name: 'example.ready',
170+
payload: { toJSON: () => undefined } as unknown as Record<string, unknown>,
171+
}),
172+
/payload for "example.ready" must be JSON-serializable/,
173+
);
174+
await assert.rejects(
175+
() =>
176+
device.apps.triggerEvent({
177+
name: 'example.ready',
178+
payload: { data: 'x'.repeat(8 * 1024) },
179+
}),
180+
/payload for "example.ready" exceeds 8192 bytes/,
181+
);
182+
await assert.rejects(
183+
() =>
184+
device.apps.push({
185+
app: 'com.example.app',
186+
input: undefined as unknown as Parameters<typeof device.apps.push>[0]['input'],
187+
}),
188+
/apps\.push requires an input/,
189+
);
190+
});
191+
192+
function createAppsBackend(calls: unknown[]): AgentDeviceBackend {
193+
return {
194+
platform: 'ios',
195+
openApp: async (context, target: BackendOpenTarget, options) => {
196+
calls.push({
197+
command: 'openApp',
198+
target,
199+
options,
200+
session: context.session,
201+
});
202+
return { opened: true };
203+
},
204+
closeApp: async (_context, app) => {
205+
calls.push({ command: 'closeApp', app });
206+
},
207+
listApps: async (_context, filter) => {
208+
calls.push({ command: 'listApps', filter });
209+
return [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }];
210+
},
211+
getAppState: async (_context, app) => {
212+
calls.push({ command: 'getAppState', app });
213+
return { bundleId: app, state: 'foreground' };
214+
},
215+
pushFile: async (_context, input: BackendPushInput, target) => {
216+
calls.push({ command: 'pushFile', target, input });
217+
},
218+
triggerAppEvent: async (_context, event: BackendAppEvent) => {
219+
calls.push({ command: 'triggerAppEvent', event });
220+
},
221+
};
222+
}

src/__tests__/runtime-conformance.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ test('command conformance suites run against a fixture backend', async () => {
3131
assert.equal(calls.includes('tap'), true);
3232
assert.equal(calls.includes('fill'), true);
3333
assert.equal(calls.includes('typeText'), true);
34+
assert.equal(calls.includes('openApp'), true);
35+
assert.equal(calls.includes('closeApp'), true);
36+
assert.equal(calls.includes('listApps'), true);
37+
assert.equal(calls.includes('getAppState'), true);
38+
assert.equal(calls.includes('pushFile'), true);
39+
assert.equal(calls.includes('triggerAppEvent'), true);
3440
});
3541

3642
test('assertCommandConformance throws when a suite fails', async () => {
@@ -72,6 +78,26 @@ function createFixtureBackend(calls: string[]): AgentDeviceBackend {
7278
typeText: async () => {
7379
calls.push('typeText');
7480
},
81+
openApp: async () => {
82+
calls.push('openApp');
83+
},
84+
closeApp: async () => {
85+
calls.push('closeApp');
86+
},
87+
listApps: async () => {
88+
calls.push('listApps');
89+
return [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }];
90+
},
91+
getAppState: async (_context, app) => {
92+
calls.push('getAppState');
93+
return { bundleId: app, state: 'foreground' };
94+
},
95+
pushFile: async () => {
96+
calls.push('pushFile');
97+
},
98+
triggerAppEvent: async () => {
99+
calls.push('triggerAppEvent');
100+
},
75101
};
76102
}
77103

src/__tests__/runtime-public.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ const backend = {
3737
platform: 'ios',
3838
captureScreenshot: async () => {},
3939
typeText: async () => {},
40+
openApp: async () => {},
41+
closeApp: async () => {},
42+
listApps: async () => [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }],
43+
getAppState: async (_context, app: string) => ({ bundleId: app, state: 'foreground' as const }),
44+
pushFile: async () => {},
45+
triggerAppEvent: async () => {},
4046
} satisfies AgentDeviceBackend;
4147

4248
const artifacts = {
@@ -70,7 +76,7 @@ test('package root exposes command runtime skeleton', async () => {
7076
assert.equal(device.policy.allowLocalInputPaths, false);
7177
assert.equal(typeof device.capture.screenshot, 'function');
7278
assert.equal(typeof device.interactions.click, 'function');
73-
assert.equal('apps' in device, false);
79+
assert.equal(typeof device.apps.open, 'function');
7480
const result = await device.capture.screenshot({});
7581
assert.equal(result.path, '/tmp/path.png');
7682
});
@@ -363,7 +369,7 @@ test('public backend, commands, io, and conformance subpaths are importable', ()
363369
commandCatalog.some((entry) => entry.command === 'click' && entry.status === 'implemented'),
364370
true,
365371
);
366-
assert.equal(commandConformanceSuites.length, 3);
372+
assert.equal(commandConformanceSuites.length, 4);
367373
assert.equal(typeof runCommandConformance, 'function');
368374
assert.equal(target.name, 'fake');
369375
});
@@ -415,6 +421,33 @@ test('command router dispatches implemented runtime commands and normalizes erro
415421
assert.equal(typed.ok, true);
416422
assert.equal(typed.ok && 'text' in typed.data ? typed.data.text : undefined, 'hello');
417423

424+
const opened = await router.dispatch({
425+
command: 'apps.open',
426+
options: {
427+
app: 'com.example.app',
428+
relaunch: true,
429+
},
430+
});
431+
assert.equal(opened.ok, true);
432+
assert.equal(
433+
opened.ok && 'kind' in opened.data && opened.data.kind === 'appOpened'
434+
? opened.data.relaunch
435+
: false,
436+
true,
437+
);
438+
439+
const listed = await router.dispatch({
440+
command: 'apps.list',
441+
options: { filter: 'user-installed' },
442+
});
443+
assert.equal(listed.ok, true);
444+
assert.equal(
445+
listed.ok && 'kind' in listed.data && listed.data.kind === 'appsList'
446+
? listed.data.apps.length
447+
: 0,
448+
1,
449+
);
450+
418451
const planned = await router.dispatch({
419452
command: 'alert',
420453
options: {},

0 commit comments

Comments
 (0)