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
26 changes: 25 additions & 1 deletion src/commands/cli-grammar/common.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import type { CliFlags } from '@agent-device/contracts/command';
import { commonInputFromFlags, selectionOptionsFromFlags } from './common.ts';
import { commonInputFromFlags, readJsonObject, selectionOptionsFromFlags } from './common.ts';

function flags(overrides: Partial<CliFlags> = {}): CliFlags {
return overrides as CliFlags;
Expand Down Expand Up @@ -91,3 +91,27 @@ describe('selectionOptionsFromFlags', () => {
});
});
});

// `trigger-app-event [payloadJson]` reads its payload through this reader, so the
// refusal must name the label the user actually typed against.
describe('readJsonObject', () => {
test('accepts a JSON object', () => {
expect(readJsonObject('{"aps":{"alert":"hi"}}', 'payload')).toEqual({
aps: { alert: 'hi' },
});
});

test('refuses malformed JSON, arrays, nulls, and scalars with the labelled error', () => {
const refusal = (value: string) =>
expect(() => readJsonObject(value, 'trigger-app-event payload')).toThrow(
expect.objectContaining({
code: 'INVALID_ARGS',
message: 'trigger-app-event payload must be a JSON object',
}),
);
refusal('not json');
refusal('[1,2]');
refusal('null');
refusal('7');
});
});
93 changes: 93 additions & 0 deletions src/commands/interaction/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, test } from 'vitest';
import type { CliFlags } from '@agent-device/contracts/command';
import type { AgentDeviceClient } from '../../client/client-types.ts';
import { gestureCliReaders } from './gesture.ts';
import { interactionCommandFamily } from './index.ts';

const NO_FLAGS = {} as CliFlags;

/**
* The gesture `run` projection is exercised through the family's executable
* definition — the same `invoke` path the CLI/daemon/MCP surfaces dispatch
* through — with a client that records which interaction method each gesture
* kind lands on and with which options.
*/
function recordingGestureClient(calls: Array<{ method: string; options: unknown }>) {
const interactions = new Proxy(
{},
{
get: (_target, method: string) => async (options: unknown) => {
calls.push({ method, options });
return { ok: true };
},
},
);
return { interactions } as unknown as AgentDeviceClient;
}

function gestureDefinition() {
const definition = interactionCommandFamily.definitions.find((entry) => entry.name === 'gesture');
if (!definition) throw new Error('missing gesture definition');
return definition;
}

describe('gesture run dispatch', () => {
// Each entry pins both halves of the run projection: which
// `client.interactions.*` method the kind routes to and how its payload
// lands on that method's options (`gesture.test.ts` covers the reader and
// daemon writer, not this client-side projection).
test.for([
{
argv: ['pan', '10', '20', '5', '6', '300'],
method: 'pan',
options: { x: 10, y: 20, dx: 5, dy: 6, durationMs: 300 },
},
{
argv: ['fling', 'up', '10', '20', '400'],
method: 'fling',
options: { direction: 'up', x: 10, y: 20, distance: 400 },
},
{ argv: ['swipe', 'left-edge'], method: 'swipeGesture', options: { preset: 'left-edge' } },
{ argv: ['pinch', '2', '30', '40'], method: 'pinch', options: { scale: 2, x: 30, y: 40 } },
{
argv: ['rotate', '45', '10', '20'],
method: 'rotateGesture',
options: { degrees: 45, x: 10, y: 20 },
},
{
argv: ['transform', '10', '20', '5', '6', '1.5', '45', '250'],
method: 'transformGesture',
options: { x: 10, y: 20, dx: 5, dy: 6, scale: 1.5, degrees: 45, durationMs: 250 },
},
{
argv: ['drag', 'id="drag-source"', '@e2~s42', '700', '600', '200'],
method: 'drag',
options: {
source: 'id="drag-source"',
destination: '@e2~s42',
sourceHoldMs: 700,
moveMs: 600,
destinationHoldMs: 200,
},
},
])(
'%argv routes to client.interactions.%method with projected options',
async ({ argv, method, options }) => {
const calls: Array<{ method: string; options: unknown }> = [];
const input = gestureCliReaders.gesture(argv, NO_FLAGS);
await gestureDefinition().invoke(recordingGestureClient(calls), input);
expect(calls).toHaveLength(1);
expect(calls[0]?.method).toBe(method);
expect(calls[0]?.options).toMatchObject(options);
},
);

test('pinch without an origin still projects the scale', async () => {
const calls: Array<{ method: string; options: unknown }> = [];
const input = gestureCliReaders.gesture(['pinch', '2'], NO_FLAGS);
await gestureDefinition().invoke(recordingGestureClient(calls), input);
const options = calls[0]?.options as { scale?: number; x?: unknown } | undefined;
expect(options).toMatchObject({ scale: 2 });
expect(options?.x).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,81 @@ test('settle uses the authorized ref frame instead of a polluted evidence captur
`settled against polluted evidence after ${outcome.observation.waitedMs}ms`,
);
});

// The point-target chain (`kind: 'point'`, no pre-action evidence) falls to
// sessionBaseline, which prefers the session's ref frame over the operational
// snapshot and only yields the empty baseline for a pre-frame session. The
// branch chosen decides what the settled diff reports as removed.
async function settlePointTargetAgainstSession(session: {
name: string;
refFrameSnapshot?: typeof elementThreadsNoticeSnapshot;
snapshot?: typeof elementThreadsNoticeSnapshot;
}) {
let elapsedMs = 0;
const runtime = createAgentDevice({
backend: {
platform: 'ios',
captureSnapshot: async () => ({
snapshot: elapsedMs < 1_200 ? elementTransientRoomSnapshot : elementSettledRoomSnapshot,
}),
} satisfies AgentDeviceBackend,
artifacts: createLocalArtifactAdapter(),
sessions: createMemorySessionStore([session]),
policy: localCommandPolicy(),
clock: {
now: () => elapsedMs,
sleep: async (ms: number) => {
elapsedMs += ms;
},
},
});

const outcome = await settleAfterInteraction(
runtime,
{ session: 'default' },
{
// A tap at the transient room's title field: the branch under test only
// runs for a non-ref resolution with no pre-action evidence.
resolved: { kind: 'point', point: { x: 100, y: 67 } },
quietMs: 500,
timeoutMs: 5_000,
},
);
const diff = outcome.observation.diff;
assert.ok(diff, `expected a settled diff: ${outcome.observation.hint ?? 'no hint'}`);
return {
removed: diff.lines.filter((line) => line.kind === 'removed').map((line) => line.text),
};
}

test('point-target settle prefers the session ref frame over the operational snapshot', async () => {
const { removed } = await settlePointTargetAgainstSession({
name: 'default',
refFrameSnapshot: elementThreadsNoticeSnapshot,
snapshot: elementTransientRoomSnapshot,
});
assert.ok(
removed.some((text) => text.includes('Threads no longer experimental')),
`ref frame should be the baseline: ${removed.join(' | ')}`,
);
assert.ok(
!removed.some((text) => text.includes('Weekend Plans')),
`operational snapshot leaked into the baseline: ${removed.join(' | ')}`,
);
});

test('point-target settle falls back to the session snapshot without a ref frame', async () => {
const { removed } = await settlePointTargetAgainstSession({
name: 'default',
snapshot: elementThreadsNoticeSnapshot,
});
assert.ok(
removed.some((text) => text.includes('Threads no longer experimental')),
`session snapshot should be the baseline: ${removed.join(' | ')}`,
);
});

test('point-target settle diffs from an empty baseline for a pre-frame session', async () => {
const { removed } = await settlePointTargetAgainstSession({ name: 'default' });
assert.deepEqual(removed, []);
});
59 changes: 59 additions & 0 deletions src/commands/system/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,65 @@ describe('system command interface', () => {
);
});

test('appstate CLI output renders the Apple session app from the runner read', () => {
const output = systemCliOutputFormatters.appstate({
input: {},
result: {
platform: 'ios',
appName: 'TestApp',
appBundleId: 'com.example.testapp',
state: 'runningForeground',
source: 'runner',
surface: 'app',
},
});
expect(output.text).toBe(
[
'Session app: TestApp',
'Bundle: com.example.testapp',
'State: runningForeground',
'Source: runner',
].join('\n'),
);
});

test('appstate CLI output falls back to the bundle id and omits absent lines', () => {
const output = systemCliOutputFormatters.appstate({
input: {},
result: {
platform: 'ios',
appBundleId: 'com.example.mobile',
source: 'session',
surface: 'app',
},
});
expect(output.text).toBe(
['Session app: com.example.mobile', 'Bundle: com.example.mobile', 'Source: session'].join(
'\n',
),
);
});

test('appstate CLI output renders unknown when the Apple session carries no app identity', () => {
const output = systemCliOutputFormatters.appstate({
input: {},
result: { platform: 'ios', surface: 'app' },
});
expect(output.text).toBe('Session app: unknown');
});

test('appstate CLI output renders the Android foreground app and activity', () => {
const output = systemCliOutputFormatters.appstate({
input: {},
result: {
platform: 'android',
package: 'com.example',
activity: '.MainActivity',
},
});
expect(output.text).toBe(['Foreground app: com.example', 'Activity: .MainActivity'].join('\n'));
});

test('orientation reader and writer normalize orientation', () => {
expect(orientationCliReader(['left'], flags())).toMatchObject({
orientation: 'landscape-left',
Expand Down
70 changes: 70 additions & 0 deletions src/commands/system/runtime/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from 'vitest';
import type {
AgentDeviceBackend,
BackendAlertAction,
BackendAlertResult,
BackendDeviceOrientation,
BackendKeyboardOptions,
BackendTvRemoteOptions,
Expand Down Expand Up @@ -114,6 +115,75 @@ test('runtime system commands validate options before backend calls', async () =
assert.deepEqual(calls, []);
});

test('runtime alert wait projects the backend alert, timing, and timeout verdict', async () => {
const alert = { title: 'Location', message: 'Allow access?', buttons: ['Allow', 'Deny'] };
const outcomes: BackendAlertResult[] = [
{ kind: 'alertWait', alert, waitedMs: 250 },
{ kind: 'alertWait', alert: null, waitedMs: 5_000, timedOut: true },
{ kind: 'alertWait', alert },
];
let next = 0;
const device = createAgentDevice({
backend: {
platform: 'ios',
handleAlert: async (): Promise<BackendAlertResult> => {
const result = outcomes[next++];
if (!result) throw new Error('alert backend double exhausted');
return result;
},
},
artifacts: createLocalArtifactAdapter(),
policy: localCommandPolicy(),
});

const visible = await device.system.alert({ action: 'wait', timeoutMs: 1_000 });
if (visible.kind !== 'alertWait') throw new Error(`expected alertWait, got ${visible.kind}`);
assert.deepEqual(visible.alert, alert);
assert.equal(visible.waitedMs, 250);
assert.equal(visible.timedOut, undefined);
assert.equal(String(visible.message), 'Alert visible');

const timedOut = await device.system.alert({ action: 'wait', timeoutMs: 5_000 });
if (timedOut.kind !== 'alertWait') throw new Error(`expected alertWait, got ${timedOut.kind}`);
assert.equal(timedOut.alert, null);
assert.equal(timedOut.waitedMs, 5_000);
assert.equal(timedOut.timedOut, true);
assert.equal(String(timedOut.message), 'Alert wait timed out');

// A backend that reports neither timing field keeps both keys absent.
const bare = await device.system.alert({ action: 'wait' });
if (bare.kind !== 'alertWait') throw new Error(`expected alertWait, got ${bare.kind}`);
assert.deepEqual(bare.alert, alert);
assert.equal('waitedMs' in bare, false);
assert.equal('timedOut' in bare, false);
assert.equal(String(bare.message), 'Alert visible');
});

test('runtime alert wait refuses a backend result of the wrong kind', async () => {
// Typed as the union the backend contract allows: a wrong `kind` is a legal
// backend result the normalizer must refuse with the typed COMMAND_FAILED
// code, not just the message.
const wrongKind: BackendAlertResult = { kind: 'alertStatus', alert: null };
const device = createAgentDevice({
backend: {
platform: 'ios',
handleAlert: async () => wrongKind,
},
artifacts: createLocalArtifactAdapter(),
policy: localCommandPolicy(),
});

await assert.rejects(
() => device.system.alert({ action: 'wait' }),
(error: unknown) => {
const appError = error as { code?: string; message?: string };
assert.equal(appError.code, 'COMMAND_FAILED');
assert.match(appError.message ?? '', /system\.alert wait returned an invalid backend result/);
return true;
},
);
});

function createSystemBackend(calls: unknown[]): AgentDeviceBackend {
return {
platform: 'ios',
Expand Down
Loading