Skip to content

Commit 52ce29d

Browse files
authored
test(commands): cover gesture, settle-baseline, appstate, alert-wait, and JSON-object branches (#3438)
* test(commands): cover JSON-object, gesture, settle-baseline, appstate, and alert-wait branches Burn down the coverage-based CRAP findings from #3422: - readJsonObject: accept/refuse table through the cli-grammar reader - gesture run: all seven kinds routed through the family definition's invoke - sessionBaseline: ref-frame, snapshot, and pre-frame-empty chains through settleAfterInteraction with a point target - formatAppleAppState: runner/session/unknown renders through the appstate CLI output formatter - normalizeAlertWaitResult: visible and timed-out projections plus the wrong-kind refusal through the runtime alert command * test(system,interaction): type the alert backend double and pin gesture option projection Review follow-up on #3438: - the alert-wait backend double returns BackendAlertResult values, dropping the `as never` casts, and the wrong-kind refusal asserts the typed COMMAND_FAILED code alongside the message - the gesture routing table now asserts the projected client options for every kind (fling, swipe, rotate, and transform had no toXOptions mapping test in gesture.test.ts)
1 parent da46f4c commit 52ce29d

5 files changed

Lines changed: 325 additions & 1 deletion

File tree

‎src/commands/cli-grammar/common.test.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from 'vitest';
22
import type { CliFlags } from '@agent-device/contracts/command';
3-
import { commonInputFromFlags, selectionOptionsFromFlags } from './common.ts';
3+
import { commonInputFromFlags, readJsonObject, selectionOptionsFromFlags } from './common.ts';
44

55
function flags(overrides: Partial<CliFlags> = {}): CliFlags {
66
return overrides as CliFlags;
@@ -91,3 +91,27 @@ describe('selectionOptionsFromFlags', () => {
9191
});
9292
});
9393
});
94+
95+
// `trigger-app-event [payloadJson]` reads its payload through this reader, so the
96+
// refusal must name the label the user actually typed against.
97+
describe('readJsonObject', () => {
98+
test('accepts a JSON object', () => {
99+
expect(readJsonObject('{"aps":{"alert":"hi"}}', 'payload')).toEqual({
100+
aps: { alert: 'hi' },
101+
});
102+
});
103+
104+
test('refuses malformed JSON, arrays, nulls, and scalars with the labelled error', () => {
105+
const refusal = (value: string) =>
106+
expect(() => readJsonObject(value, 'trigger-app-event payload')).toThrow(
107+
expect.objectContaining({
108+
code: 'INVALID_ARGS',
109+
message: 'trigger-app-event payload must be a JSON object',
110+
}),
111+
);
112+
refusal('not json');
113+
refusal('[1,2]');
114+
refusal('null');
115+
refusal('7');
116+
});
117+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, test } from 'vitest';
2+
import type { CliFlags } from '@agent-device/contracts/command';
3+
import type { AgentDeviceClient } from '../../client/client-types.ts';
4+
import { gestureCliReaders } from './gesture.ts';
5+
import { interactionCommandFamily } from './index.ts';
6+
7+
const NO_FLAGS = {} as CliFlags;
8+
9+
/**
10+
* The gesture `run` projection is exercised through the family's executable
11+
* definition — the same `invoke` path the CLI/daemon/MCP surfaces dispatch
12+
* through — with a client that records which interaction method each gesture
13+
* kind lands on and with which options.
14+
*/
15+
function recordingGestureClient(calls: Array<{ method: string; options: unknown }>) {
16+
const interactions = new Proxy(
17+
{},
18+
{
19+
get: (_target, method: string) => async (options: unknown) => {
20+
calls.push({ method, options });
21+
return { ok: true };
22+
},
23+
},
24+
);
25+
return { interactions } as unknown as AgentDeviceClient;
26+
}
27+
28+
function gestureDefinition() {
29+
const definition = interactionCommandFamily.definitions.find((entry) => entry.name === 'gesture');
30+
if (!definition) throw new Error('missing gesture definition');
31+
return definition;
32+
}
33+
34+
describe('gesture run dispatch', () => {
35+
// Each entry pins both halves of the run projection: which
36+
// `client.interactions.*` method the kind routes to and how its payload
37+
// lands on that method's options (`gesture.test.ts` covers the reader and
38+
// daemon writer, not this client-side projection).
39+
test.for([
40+
{
41+
argv: ['pan', '10', '20', '5', '6', '300'],
42+
method: 'pan',
43+
options: { x: 10, y: 20, dx: 5, dy: 6, durationMs: 300 },
44+
},
45+
{
46+
argv: ['fling', 'up', '10', '20', '400'],
47+
method: 'fling',
48+
options: { direction: 'up', x: 10, y: 20, distance: 400 },
49+
},
50+
{ argv: ['swipe', 'left-edge'], method: 'swipeGesture', options: { preset: 'left-edge' } },
51+
{ argv: ['pinch', '2', '30', '40'], method: 'pinch', options: { scale: 2, x: 30, y: 40 } },
52+
{
53+
argv: ['rotate', '45', '10', '20'],
54+
method: 'rotateGesture',
55+
options: { degrees: 45, x: 10, y: 20 },
56+
},
57+
{
58+
argv: ['transform', '10', '20', '5', '6', '1.5', '45', '250'],
59+
method: 'transformGesture',
60+
options: { x: 10, y: 20, dx: 5, dy: 6, scale: 1.5, degrees: 45, durationMs: 250 },
61+
},
62+
{
63+
argv: ['drag', 'id="drag-source"', '@e2~s42', '700', '600', '200'],
64+
method: 'drag',
65+
options: {
66+
source: 'id="drag-source"',
67+
destination: '@e2~s42',
68+
sourceHoldMs: 700,
69+
moveMs: 600,
70+
destinationHoldMs: 200,
71+
},
72+
},
73+
])(
74+
'%argv routes to client.interactions.%method with projected options',
75+
async ({ argv, method, options }) => {
76+
const calls: Array<{ method: string; options: unknown }> = [];
77+
const input = gestureCliReaders.gesture(argv, NO_FLAGS);
78+
await gestureDefinition().invoke(recordingGestureClient(calls), input);
79+
expect(calls).toHaveLength(1);
80+
expect(calls[0]?.method).toBe(method);
81+
expect(calls[0]?.options).toMatchObject(options);
82+
},
83+
);
84+
85+
test('pinch without an origin still projects the scale', async () => {
86+
const calls: Array<{ method: string; options: unknown }> = [];
87+
const input = gestureCliReaders.gesture(['pinch', '2'], NO_FLAGS);
88+
await gestureDefinition().invoke(recordingGestureClient(calls), input);
89+
const options = calls[0]?.options as { scale?: number; x?: unknown } | undefined;
90+
expect(options).toMatchObject({ scale: 2 });
91+
expect(options?.x).toBeUndefined();
92+
});
93+
});

‎src/commands/interaction/runtime/settle-transition-baseline.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,81 @@ test('settle uses the authorized ref frame instead of a polluted evidence captur
114114
`settled against polluted evidence after ${outcome.observation.waitedMs}ms`,
115115
);
116116
});
117+
118+
// The point-target chain (`kind: 'point'`, no pre-action evidence) falls to
119+
// sessionBaseline, which prefers the session's ref frame over the operational
120+
// snapshot and only yields the empty baseline for a pre-frame session. The
121+
// branch chosen decides what the settled diff reports as removed.
122+
async function settlePointTargetAgainstSession(session: {
123+
name: string;
124+
refFrameSnapshot?: typeof elementThreadsNoticeSnapshot;
125+
snapshot?: typeof elementThreadsNoticeSnapshot;
126+
}) {
127+
let elapsedMs = 0;
128+
const runtime = createAgentDevice({
129+
backend: {
130+
platform: 'ios',
131+
captureSnapshot: async () => ({
132+
snapshot: elapsedMs < 1_200 ? elementTransientRoomSnapshot : elementSettledRoomSnapshot,
133+
}),
134+
} satisfies AgentDeviceBackend,
135+
artifacts: createLocalArtifactAdapter(),
136+
sessions: createMemorySessionStore([session]),
137+
policy: localCommandPolicy(),
138+
clock: {
139+
now: () => elapsedMs,
140+
sleep: async (ms: number) => {
141+
elapsedMs += ms;
142+
},
143+
},
144+
});
145+
146+
const outcome = await settleAfterInteraction(
147+
runtime,
148+
{ session: 'default' },
149+
{
150+
// A tap at the transient room's title field: the branch under test only
151+
// runs for a non-ref resolution with no pre-action evidence.
152+
resolved: { kind: 'point', point: { x: 100, y: 67 } },
153+
quietMs: 500,
154+
timeoutMs: 5_000,
155+
},
156+
);
157+
const diff = outcome.observation.diff;
158+
assert.ok(diff, `expected a settled diff: ${outcome.observation.hint ?? 'no hint'}`);
159+
return {
160+
removed: diff.lines.filter((line) => line.kind === 'removed').map((line) => line.text),
161+
};
162+
}
163+
164+
test('point-target settle prefers the session ref frame over the operational snapshot', async () => {
165+
const { removed } = await settlePointTargetAgainstSession({
166+
name: 'default',
167+
refFrameSnapshot: elementThreadsNoticeSnapshot,
168+
snapshot: elementTransientRoomSnapshot,
169+
});
170+
assert.ok(
171+
removed.some((text) => text.includes('Threads no longer experimental')),
172+
`ref frame should be the baseline: ${removed.join(' | ')}`,
173+
);
174+
assert.ok(
175+
!removed.some((text) => text.includes('Weekend Plans')),
176+
`operational snapshot leaked into the baseline: ${removed.join(' | ')}`,
177+
);
178+
});
179+
180+
test('point-target settle falls back to the session snapshot without a ref frame', async () => {
181+
const { removed } = await settlePointTargetAgainstSession({
182+
name: 'default',
183+
snapshot: elementThreadsNoticeSnapshot,
184+
});
185+
assert.ok(
186+
removed.some((text) => text.includes('Threads no longer experimental')),
187+
`session snapshot should be the baseline: ${removed.join(' | ')}`,
188+
);
189+
});
190+
191+
test('point-target settle diffs from an empty baseline for a pre-frame session', async () => {
192+
const { removed } = await settlePointTargetAgainstSession({ name: 'default' });
193+
assert.deepEqual(removed, []);
194+
});

‎src/commands/system/index.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,65 @@ describe('system command interface', () => {
151151
);
152152
});
153153

154+
test('appstate CLI output renders the Apple session app from the runner read', () => {
155+
const output = systemCliOutputFormatters.appstate({
156+
input: {},
157+
result: {
158+
platform: 'ios',
159+
appName: 'TestApp',
160+
appBundleId: 'com.example.testapp',
161+
state: 'runningForeground',
162+
source: 'runner',
163+
surface: 'app',
164+
},
165+
});
166+
expect(output.text).toBe(
167+
[
168+
'Session app: TestApp',
169+
'Bundle: com.example.testapp',
170+
'State: runningForeground',
171+
'Source: runner',
172+
].join('\n'),
173+
);
174+
});
175+
176+
test('appstate CLI output falls back to the bundle id and omits absent lines', () => {
177+
const output = systemCliOutputFormatters.appstate({
178+
input: {},
179+
result: {
180+
platform: 'ios',
181+
appBundleId: 'com.example.mobile',
182+
source: 'session',
183+
surface: 'app',
184+
},
185+
});
186+
expect(output.text).toBe(
187+
['Session app: com.example.mobile', 'Bundle: com.example.mobile', 'Source: session'].join(
188+
'\n',
189+
),
190+
);
191+
});
192+
193+
test('appstate CLI output renders unknown when the Apple session carries no app identity', () => {
194+
const output = systemCliOutputFormatters.appstate({
195+
input: {},
196+
result: { platform: 'ios', surface: 'app' },
197+
});
198+
expect(output.text).toBe('Session app: unknown');
199+
});
200+
201+
test('appstate CLI output renders the Android foreground app and activity', () => {
202+
const output = systemCliOutputFormatters.appstate({
203+
input: {},
204+
result: {
205+
platform: 'android',
206+
package: 'com.example',
207+
activity: '.MainActivity',
208+
},
209+
});
210+
expect(output.text).toBe(['Foreground app: com.example', 'Activity: .MainActivity'].join('\n'));
211+
});
212+
154213
test('orientation reader and writer normalize orientation', () => {
155214
expect(orientationCliReader(['left'], flags())).toMatchObject({
156215
orientation: 'landscape-left',

‎src/commands/system/runtime/system.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { test } from 'vitest';
33
import type {
44
AgentDeviceBackend,
55
BackendAlertAction,
6+
BackendAlertResult,
67
BackendDeviceOrientation,
78
BackendKeyboardOptions,
89
BackendTvRemoteOptions,
@@ -114,6 +115,75 @@ test('runtime system commands validate options before backend calls', async () =
114115
assert.deepEqual(calls, []);
115116
});
116117

118+
test('runtime alert wait projects the backend alert, timing, and timeout verdict', async () => {
119+
const alert = { title: 'Location', message: 'Allow access?', buttons: ['Allow', 'Deny'] };
120+
const outcomes: BackendAlertResult[] = [
121+
{ kind: 'alertWait', alert, waitedMs: 250 },
122+
{ kind: 'alertWait', alert: null, waitedMs: 5_000, timedOut: true },
123+
{ kind: 'alertWait', alert },
124+
];
125+
let next = 0;
126+
const device = createAgentDevice({
127+
backend: {
128+
platform: 'ios',
129+
handleAlert: async (): Promise<BackendAlertResult> => {
130+
const result = outcomes[next++];
131+
if (!result) throw new Error('alert backend double exhausted');
132+
return result;
133+
},
134+
},
135+
artifacts: createLocalArtifactAdapter(),
136+
policy: localCommandPolicy(),
137+
});
138+
139+
const visible = await device.system.alert({ action: 'wait', timeoutMs: 1_000 });
140+
if (visible.kind !== 'alertWait') throw new Error(`expected alertWait, got ${visible.kind}`);
141+
assert.deepEqual(visible.alert, alert);
142+
assert.equal(visible.waitedMs, 250);
143+
assert.equal(visible.timedOut, undefined);
144+
assert.equal(String(visible.message), 'Alert visible');
145+
146+
const timedOut = await device.system.alert({ action: 'wait', timeoutMs: 5_000 });
147+
if (timedOut.kind !== 'alertWait') throw new Error(`expected alertWait, got ${timedOut.kind}`);
148+
assert.equal(timedOut.alert, null);
149+
assert.equal(timedOut.waitedMs, 5_000);
150+
assert.equal(timedOut.timedOut, true);
151+
assert.equal(String(timedOut.message), 'Alert wait timed out');
152+
153+
// A backend that reports neither timing field keeps both keys absent.
154+
const bare = await device.system.alert({ action: 'wait' });
155+
if (bare.kind !== 'alertWait') throw new Error(`expected alertWait, got ${bare.kind}`);
156+
assert.deepEqual(bare.alert, alert);
157+
assert.equal('waitedMs' in bare, false);
158+
assert.equal('timedOut' in bare, false);
159+
assert.equal(String(bare.message), 'Alert visible');
160+
});
161+
162+
test('runtime alert wait refuses a backend result of the wrong kind', async () => {
163+
// Typed as the union the backend contract allows: a wrong `kind` is a legal
164+
// backend result the normalizer must refuse with the typed COMMAND_FAILED
165+
// code, not just the message.
166+
const wrongKind: BackendAlertResult = { kind: 'alertStatus', alert: null };
167+
const device = createAgentDevice({
168+
backend: {
169+
platform: 'ios',
170+
handleAlert: async () => wrongKind,
171+
},
172+
artifacts: createLocalArtifactAdapter(),
173+
policy: localCommandPolicy(),
174+
});
175+
176+
await assert.rejects(
177+
() => device.system.alert({ action: 'wait' }),
178+
(error: unknown) => {
179+
const appError = error as { code?: string; message?: string };
180+
assert.equal(appError.code, 'COMMAND_FAILED');
181+
assert.match(appError.message ?? '', /system\.alert wait returned an invalid backend result/);
182+
return true;
183+
},
184+
);
185+
});
186+
117187
function createSystemBackend(calls: unknown[]): AgentDeviceBackend {
118188
return {
119189
platform: 'ios',

0 commit comments

Comments
 (0)