Skip to content

Commit ffd472a

Browse files
authored
feat: expose typed client command methods (#380)
1 parent 60bed77 commit ffd472a

24 files changed

Lines changed: 2536 additions & 663 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Minimal operating guide for AI coding agents in this repo.
3434
- Surgical edits only.
3535
- Match existing style.
3636
- Remove imports/variables YOUR changes made unused; do not clean unrelated dead code.
37+
- Keep tests minimal: if TypeScript can enforce a contract or invalid shape, prefer a type-level check over duplicating that assertion in runtime tests.
3738
- Keep modules small for agent context safety:
3839
- target <= 300 LOC per implementation file when practical.
3940
- if a file grows past 500 LOC, plan/extract focused submodules before adding new behavior.
@@ -154,6 +155,7 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
154155
## Testing Matrix
155156
- Docs/skills only: no tests required.
156157
- Non-TS, no behavior impact: no tests unless requested.
158+
- Keep tests behavioral; do not assert shapes or cases TypeScript already proves.
157159
- Any TS change: `pnpm typecheck` or `pnpm check:quick`.
158160
- Tooling/config change (`package.json`, `tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`): `pnpm check:tooling`.
159161
- Daemon handler/shared module change: `pnpm check:unit`.

src/__tests__/cli-client-commands.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,59 @@ test('screenshot reports annotated ref count in non-json mode', async () => {
274274
assert.equal(stdout, 'Annotated 2 refs onto /tmp/screenshot.png\n');
275275
});
276276

277+
test('wait keeps CLI bare text behavior through the typed client command API', async () => {
278+
let observed: Parameters<AgentDeviceClient['command']['wait']>[0] | undefined;
279+
const client = createStubClient({
280+
installFromSource: async () => {
281+
throw new Error('unexpected install call');
282+
},
283+
});
284+
client.command.wait = async (options) => {
285+
observed = options;
286+
return { text: 'Continue', waitedMs: 12 };
287+
};
288+
289+
const handled = await tryRunClientBackedCommand({
290+
command: 'wait',
291+
positionals: ['Continue', '1500'],
292+
flags: {
293+
json: false,
294+
help: false,
295+
version: false,
296+
},
297+
client,
298+
});
299+
300+
assert.equal(handled, true);
301+
assert.equal(observed?.text, 'Continue');
302+
assert.equal(observed?.timeoutMs, 1500);
303+
});
304+
305+
test('clipboard read keeps human text output through the typed client command API', async () => {
306+
const client = createStubClient({
307+
installFromSource: async () => {
308+
throw new Error('unexpected install call');
309+
},
310+
});
311+
client.command.clipboard = async () => ({ action: 'read', text: 'hello' });
312+
313+
const stdout = await captureStdout(async () => {
314+
const handled = await tryRunClientBackedCommand({
315+
command: 'clipboard',
316+
positionals: ['read'],
317+
flags: {
318+
json: false,
319+
help: false,
320+
version: false,
321+
},
322+
client,
323+
});
324+
assert.equal(handled, true);
325+
});
326+
327+
assert.equal(stdout, 'hello\n');
328+
});
329+
277330
test('metro prepare wraps output in the standard success envelope for --json', async () => {
278331
const client = createStubClient({
279332
installFromSource: async () => {
@@ -634,9 +687,15 @@ function createStubClient(params: {
634687
open?: AgentDeviceClient['apps']['open'];
635688
screenshot?: AgentDeviceClient['capture']['screenshot'];
636689
}): AgentDeviceClient {
690+
const unexpectedCommandCall = async (): Promise<never> => {
691+
throw new Error('unexpected command call');
692+
};
693+
const command = createThrowingMethodGroup<AgentDeviceClient['command']>();
637694
return {
695+
command,
638696
devices: {
639697
list: async () => [],
698+
boot: unexpectedCommandCall,
640699
},
641700
sessions: {
642701
list: async () => [],
@@ -681,6 +740,8 @@ function createStubClient(params: {
681740
session: 'default',
682741
identifiers: { session: 'default' },
683742
}),
743+
push: unexpectedCommandCall,
744+
triggerEvent: unexpectedCommandCall,
684745
},
685746
materializations: {
686747
release: async (options) => ({
@@ -726,6 +787,22 @@ function createStubClient(params: {
726787
path: '/tmp/screenshot.png',
727788
identifiers: { session: 'default' },
728789
})),
729-
},
790+
diff: unexpectedCommandCall,
791+
},
792+
interactions: createThrowingMethodGroup<AgentDeviceClient['interactions']>(),
793+
replay: createThrowingMethodGroup<AgentDeviceClient['replay']>(),
794+
batch: createThrowingMethodGroup<AgentDeviceClient['batch']>(),
795+
observability: createThrowingMethodGroup<AgentDeviceClient['observability']>(),
796+
recording: createThrowingMethodGroup<AgentDeviceClient['recording']>(),
797+
settings: createThrowingMethodGroup<AgentDeviceClient['settings']>(),
798+
};
799+
}
800+
801+
function createThrowingMethodGroup<T extends object>(): T {
802+
const unexpectedCommandCall = async (): Promise<never> => {
803+
throw new Error('unexpected command call');
730804
};
805+
return new Proxy({} as Partial<T>, {
806+
get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall,
807+
}) as T;
731808
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import {
4+
createAgentDeviceClient,
5+
type AgentDeviceClient,
6+
type CaptureScreenshotResult,
7+
type CaptureSnapshotResult,
8+
type Point,
9+
type Rect,
10+
type ScreenshotOverlayRef,
11+
type SnapshotNode,
12+
type SnapshotVisibility,
13+
type SnapshotVisibilityReason,
14+
} from '../index.ts';
15+
16+
const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect;
17+
const point = { x: 2, y: 4 } satisfies Point;
18+
const visibilityReason = 'offscreen-nodes' satisfies SnapshotVisibilityReason;
19+
20+
const node = {
21+
index: 0,
22+
ref: 'e1',
23+
type: 'Button',
24+
label: 'Continue',
25+
rect,
26+
} satisfies SnapshotNode;
27+
28+
const visibility = {
29+
partial: true,
30+
visibleNodeCount: 1,
31+
totalNodeCount: 2,
32+
reasons: [visibilityReason],
33+
} satisfies SnapshotVisibility;
34+
35+
({
36+
nodes: [node],
37+
truncated: false,
38+
visibility,
39+
identifiers: { session: 'default' },
40+
}) satisfies CaptureSnapshotResult;
41+
42+
const overlay = {
43+
ref: 'e1',
44+
rect,
45+
overlayRect: rect,
46+
center: point,
47+
} satisfies ScreenshotOverlayRef;
48+
49+
({
50+
path: '/tmp/screenshot.png',
51+
overlayRefs: [overlay],
52+
identifiers: { session: 'default' },
53+
}) satisfies CaptureScreenshotResult;
54+
55+
test('package root exports createAgentDeviceClient', () => {
56+
const client: AgentDeviceClient = createAgentDeviceClient();
57+
assert.equal(typeof client.capture.snapshot, 'function');
58+
});

src/__tests__/client.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
36
import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts';
47
import type { DaemonRequest, DaemonResponse } from '../daemon/types.ts';
58
import { AppError } from '../utils/errors.ts';
@@ -371,6 +374,87 @@ test('client throws AppError for daemon failures', async () => {
371374
);
372375
});
373376

377+
test('client.command.wait prepares selector options and rejects invalid selectors', async () => {
378+
const setup = createTransport(async () => ({
379+
ok: true,
380+
data: {},
381+
}));
382+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
383+
384+
await client.command.wait({
385+
selector: 'role=button[name="Continue"]',
386+
timeoutMs: 1_500,
387+
depth: 3,
388+
raw: true,
389+
});
390+
391+
assert.equal(setup.calls.length, 1);
392+
assert.equal(setup.calls[0]?.command, 'wait');
393+
assert.deepEqual(setup.calls[0]?.positionals, ['role=button[name="Continue"]', '1500']);
394+
assert.equal(setup.calls[0]?.flags?.snapshotDepth, 3);
395+
assert.equal(setup.calls[0]?.flags?.snapshotRaw, true);
396+
397+
await assert.rejects(
398+
async () => await client.command.wait({ selector: 'Continue' }),
399+
/Invalid wait selector: Continue/,
400+
);
401+
assert.equal(setup.calls.length, 1);
402+
});
403+
404+
test('remote-config defaults apply across daemon-backed client methods', async () => {
405+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-client-remote-scope-'));
406+
try {
407+
const remoteConfig = path.join(tempRoot, 'remote.json');
408+
fs.writeFileSync(
409+
remoteConfig,
410+
JSON.stringify({
411+
session: 'remote-session',
412+
platform: 'android',
413+
daemonBaseUrl: 'http://127.0.0.1:9124/agent-device',
414+
tenant: 'remote-tenant',
415+
sessionIsolation: 'tenant',
416+
runId: 'remote-run',
417+
leaseId: 'remote-lease',
418+
}),
419+
);
420+
const setup = createTransport(async () => ({
421+
ok: true,
422+
data: {},
423+
}));
424+
const client = createAgentDeviceClient(
425+
{
426+
remoteConfig,
427+
cwd: tempRoot,
428+
},
429+
{ transport: setup.transport },
430+
);
431+
fs.writeFileSync(remoteConfig, '{');
432+
433+
await client.devices.list();
434+
await client.command.home();
435+
const snapshot = await client.capture.snapshot();
436+
437+
assert.equal(setup.calls[0]?.session, 'remote-session');
438+
assert.equal(setup.calls[0]?.command, 'devices');
439+
assert.equal(setup.calls[0]?.flags?.platform, 'android');
440+
assert.equal(setup.calls[0]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device');
441+
assert.equal(setup.calls[0]?.meta?.tenantId, 'remote-tenant');
442+
assert.equal(setup.calls[1]?.session, 'remote-session');
443+
assert.equal(setup.calls[1]?.command, 'home');
444+
assert.equal(setup.calls[1]?.flags?.platform, 'android');
445+
assert.equal(setup.calls[1]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device');
446+
assert.equal(setup.calls[1]?.meta?.tenantId, 'remote-tenant');
447+
assert.equal(setup.calls[1]?.meta?.runId, 'remote-run');
448+
assert.equal(setup.calls[1]?.meta?.leaseId, 'remote-lease');
449+
assert.equal(setup.calls[2]?.session, 'remote-session');
450+
assert.equal(setup.calls[2]?.command, 'snapshot');
451+
assert.equal(setup.calls[2]?.flags?.platform, 'android');
452+
assert.equal(snapshot.identifiers.session, 'remote-session');
453+
} finally {
454+
fs.rmSync(tempRoot, { recursive: true, force: true });
455+
}
456+
});
457+
374458
test('client capture.snapshot preserves visibility metadata from daemon responses', async () => {
375459
const setup = createTransport(async () => ({
376460
ok: true,

0 commit comments

Comments
 (0)