diff --git a/src/commands/cli-grammar/common.test.ts b/src/commands/cli-grammar/common.test.ts index 4c58eba487..10ff17a910 100644 --- a/src/commands/cli-grammar/common.test.ts +++ b/src/commands/cli-grammar/common.test.ts @@ -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 { return overrides as CliFlags; @@ -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'); + }); +}); diff --git a/src/commands/interaction/index.test.ts b/src/commands/interaction/index.test.ts new file mode 100644 index 0000000000..815d0aa62c --- /dev/null +++ b/src/commands/interaction/index.test.ts @@ -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(); + }); +}); diff --git a/src/commands/interaction/runtime/settle-transition-baseline.test.ts b/src/commands/interaction/runtime/settle-transition-baseline.test.ts index b3b7cf01b9..dd3851fb9a 100644 --- a/src/commands/interaction/runtime/settle-transition-baseline.test.ts +++ b/src/commands/interaction/runtime/settle-transition-baseline.test.ts @@ -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, []); +}); diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index deb81e3c11..8ed0cc6cf3 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -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', diff --git a/src/commands/system/runtime/system.test.ts b/src/commands/system/runtime/system.test.ts index dee2d3feb3..f5c701c836 100644 --- a/src/commands/system/runtime/system.test.ts +++ b/src/commands/system/runtime/system.test.ts @@ -3,6 +3,7 @@ import { test } from 'vitest'; import type { AgentDeviceBackend, BackendAlertAction, + BackendAlertResult, BackendDeviceOrientation, BackendKeyboardOptions, BackendTvRemoteOptions, @@ -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 => { + 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',