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
10 changes: 10 additions & 0 deletions COMMAND_OWNERSHIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ Their semantics should live in `agent-device/commands` as they migrate.
- `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.
- `open`: runtime `apps.open` implemented for typed app, bundle/package,
activity, URL, and relaunch targets.
- `close`: runtime `apps.close` implemented for optional app targets.
- `apps`: runtime `apps.list` implemented with typed app list filters.
- `appstate`: runtime `apps.state` implemented against backend state
primitives.
- `push`: runtime `apps.push` implemented with JSON and artifact/file inputs;
local file inputs remain command-policy gated.
- `trigger-app-event`: runtime `apps.triggerEvent` implemented with event name
and JSON payload validation.

## Boundary Requirements

Expand Down
222 changes: 222 additions & 0 deletions src/__tests__/runtime-apps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type {
AgentDeviceBackend,
BackendAppEvent,
BackendOpenTarget,
BackendPushInput,
} from '../backend.ts';
import { createLocalArtifactAdapter } from '../io.ts';
import { createAgentDevice, localCommandPolicy, restrictedCommandPolicy } from '../runtime.ts';

test('runtime app commands call typed backend lifecycle primitives', async () => {
const calls: unknown[] = [];
const device = createAgentDevice({
backend: createAppsBackend(calls),
artifacts: createLocalArtifactAdapter(),
policy: localCommandPolicy(),
});

const opened = await device.apps.open({
session: 'default',
app: ' com.example.app ',
relaunch: true,
});
assert.deepEqual(opened, {
kind: 'appOpened',
target: { app: 'com.example.app' },
relaunch: true,
backendResult: { opened: true },
message: 'Opened: com.example.app',
});

const closed = await device.apps.close({ app: 'com.example.app' });
assert.equal(closed.kind, 'appClosed');

const listed = await device.apps.list({ filter: 'user-installed' });
assert.deepEqual(listed.apps, [
{
id: 'com.example.app',
name: 'Example',
bundleId: 'com.example.app',
},
]);

const state = await device.apps.state({ app: 'com.example.app' });
assert.deepEqual(state.state, { bundleId: 'com.example.app', state: 'foreground' });

const pushed = await device.apps.push({
app: 'com.example.app',
input: { kind: 'json', payload: { aps: { alert: 'hello' } } },
});
assert.equal(pushed.inputKind, 'json');

const triggered = await device.apps.triggerEvent({
name: 'example.ready',
payload: { source: 'test' },
});
assert.equal(triggered.name, 'example.ready');

assert.deepEqual(calls, [
{
command: 'openApp',
target: { app: 'com.example.app' },
options: { relaunch: true },
session: 'default',
},
{ command: 'closeApp', app: 'com.example.app' },
{ command: 'listApps', filter: 'user-installed' },
{ command: 'getAppState', app: 'com.example.app' },
{
command: 'pushFile',
target: 'com.example.app',
input: { kind: 'json', payload: { aps: { alert: 'hello' } } },
},
{
command: 'triggerAppEvent',
event: { name: 'example.ready', payload: { source: 'test' } },
},
]);
});

test('runtime app push rejects local payload paths under restricted policy', async () => {
let pushCalled = false;
const device = createAgentDevice({
backend: {
...createAppsBackend([]),
pushFile: async () => {
pushCalled = true;
},
},
artifacts: createLocalArtifactAdapter(),
policy: restrictedCommandPolicy(),
});

await assert.rejects(
() =>
device.apps.push({
app: 'com.example.app',
input: { kind: 'path', path: '/tmp/payload.json' },
}),
/Local input paths are not allowed/,
);
assert.equal(pushCalled, false);
});

test('runtime app commands validate JSON payloads', async () => {
const device = createAgentDevice({
backend: createAppsBackend([]),
artifacts: createLocalArtifactAdapter(),
policy: localCommandPolicy(),
});

await assert.rejects(
() => device.apps.triggerEvent({ name: 'bad event' }),
/Invalid apps\.triggerEvent name/,
);
await assert.rejects(
() =>
device.apps.push({
app: 'com.example.app',
input: { kind: 'json', payload: [] as unknown as Record<string, unknown> },
}),
/JSON payload must be a JSON object/,
);
await assert.rejects(
() =>
device.apps.push({
app: 'com.example.app',
input: {
kind: 'json',
payload: { count: 1n } as unknown as Record<string, unknown>,
},
}),
/JSON payload must be JSON-serializable/,
);
await assert.rejects(
() =>
device.apps.push({
app: 'com.example.app',
input: {
kind: 'json',
payload: { toJSON: () => undefined } as unknown as Record<string, unknown>,
},
}),
/JSON payload must be JSON-serializable/,
);
await assert.rejects(
() =>
device.apps.push({
app: 'com.example.app',
input: {
kind: 'json',
payload: { data: 'x'.repeat(8 * 1024) },
},
}),
/JSON payload exceeds 8192 bytes/,
);
await assert.rejects(
() =>
device.apps.triggerEvent({
name: 'example.ready',
payload: { count: 1n } as unknown as Record<string, unknown>,
}),
/payload for "example.ready" must be JSON-serializable/,
);
await assert.rejects(
() =>
device.apps.triggerEvent({
name: 'example.ready',
payload: { toJSON: () => undefined } as unknown as Record<string, unknown>,
}),
/payload for "example.ready" must be JSON-serializable/,
);
await assert.rejects(
() =>
device.apps.triggerEvent({
name: 'example.ready',
payload: { data: 'x'.repeat(8 * 1024) },
}),
/payload for "example.ready" exceeds 8192 bytes/,
);
await assert.rejects(
() =>
device.apps.push({
app: 'com.example.app',
input: undefined as unknown as Parameters<typeof device.apps.push>[0]['input'],
}),
/apps\.push requires an input/,
);
});

function createAppsBackend(calls: unknown[]): AgentDeviceBackend {
return {
platform: 'ios',
openApp: async (context, target: BackendOpenTarget, options) => {
calls.push({
command: 'openApp',
target,
options,
session: context.session,
});
return { opened: true };
},
closeApp: async (_context, app) => {
calls.push({ command: 'closeApp', app });
},
listApps: async (_context, filter) => {
calls.push({ command: 'listApps', filter });
return [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }];
},
getAppState: async (_context, app) => {
calls.push({ command: 'getAppState', app });
return { bundleId: app, state: 'foreground' };
},
pushFile: async (_context, input: BackendPushInput, target) => {
calls.push({ command: 'pushFile', target, input });
},
triggerAppEvent: async (_context, event: BackendAppEvent) => {
calls.push({ command: 'triggerAppEvent', event });
},
};
}
26 changes: 26 additions & 0 deletions src/__tests__/runtime-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ test('command conformance suites run against a fixture backend', async () => {
assert.equal(calls.includes('tap'), true);
assert.equal(calls.includes('fill'), true);
assert.equal(calls.includes('typeText'), true);
assert.equal(calls.includes('openApp'), true);
assert.equal(calls.includes('closeApp'), true);
assert.equal(calls.includes('listApps'), true);
assert.equal(calls.includes('getAppState'), true);
assert.equal(calls.includes('pushFile'), true);
assert.equal(calls.includes('triggerAppEvent'), true);
});

test('assertCommandConformance throws when a suite fails', async () => {
Expand Down Expand Up @@ -72,6 +78,26 @@ function createFixtureBackend(calls: string[]): AgentDeviceBackend {
typeText: async () => {
calls.push('typeText');
},
openApp: async () => {
calls.push('openApp');
},
closeApp: async () => {
calls.push('closeApp');
},
listApps: async () => {
calls.push('listApps');
return [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }];
},
getAppState: async (_context, app) => {
calls.push('getAppState');
return { bundleId: app, state: 'foreground' };
},
pushFile: async () => {
calls.push('pushFile');
},
triggerAppEvent: async () => {
calls.push('triggerAppEvent');
},
};
}

Expand Down
37 changes: 35 additions & 2 deletions src/__tests__/runtime-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ const backend = {
platform: 'ios',
captureScreenshot: async () => {},
typeText: async () => {},
openApp: async () => {},
closeApp: async () => {},
listApps: async () => [{ id: 'com.example.app', name: 'Example', bundleId: 'com.example.app' }],
getAppState: async (_context, app: string) => ({ bundleId: app, state: 'foreground' as const }),
pushFile: async () => {},
triggerAppEvent: async () => {},
} satisfies AgentDeviceBackend;

const artifacts = {
Expand Down Expand Up @@ -70,7 +76,7 @@ test('package root exposes command runtime skeleton', async () => {
assert.equal(device.policy.allowLocalInputPaths, false);
assert.equal(typeof device.capture.screenshot, 'function');
assert.equal(typeof device.interactions.click, 'function');
assert.equal('apps' in device, false);
assert.equal(typeof device.apps.open, 'function');
const result = await device.capture.screenshot({});
assert.equal(result.path, '/tmp/path.png');
});
Expand Down Expand Up @@ -363,7 +369,7 @@ test('public backend, commands, io, and conformance subpaths are importable', ()
commandCatalog.some((entry) => entry.command === 'click' && entry.status === 'implemented'),
true,
);
assert.equal(commandConformanceSuites.length, 3);
assert.equal(commandConformanceSuites.length, 4);
assert.equal(typeof runCommandConformance, 'function');
assert.equal(target.name, 'fake');
});
Expand Down Expand Up @@ -415,6 +421,33 @@ test('command router dispatches implemented runtime commands and normalizes erro
assert.equal(typed.ok, true);
assert.equal(typed.ok && 'text' in typed.data ? typed.data.text : undefined, 'hello');

const opened = await router.dispatch({
command: 'apps.open',
options: {
app: 'com.example.app',
relaunch: true,
},
});
assert.equal(opened.ok, true);
assert.equal(
opened.ok && 'kind' in opened.data && opened.data.kind === 'appOpened'
? opened.data.relaunch
: false,
true,
);

const listed = await router.dispatch({
command: 'apps.list',
options: { filter: 'user-installed' },
});
assert.equal(listed.ok, true);
assert.equal(
listed.ok && 'kind' in listed.data && listed.data.kind === 'appsList'
? listed.data.apps.length
: 0,
1,
);

const planned = await router.dispatch({
command: 'alert',
options: {},
Expand Down
Loading
Loading