Skip to content

Commit 793f277

Browse files
committed
feat: expose typed client command methods
1 parent f4d90a3 commit 793f277

11 files changed

Lines changed: 925 additions & 23 deletions

File tree

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

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,121 @@ 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 routes 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 { selector: 'role=button[name="Continue"]', waitedMs: 12 };
287+
};
288+
289+
const handled = await tryRunClientBackedCommand({
290+
command: 'wait',
291+
positionals: ['role=button[name="Continue"]', '1500'],
292+
flags: {
293+
json: false,
294+
help: false,
295+
version: false,
296+
platform: 'android',
297+
snapshotDepth: 3,
298+
snapshotRaw: true,
299+
},
300+
client,
301+
});
302+
303+
assert.equal(handled, true);
304+
assert.deepEqual(observed, {
305+
platform: 'android',
306+
target: undefined,
307+
device: undefined,
308+
udid: undefined,
309+
serial: undefined,
310+
iosSimulatorDeviceSet: undefined,
311+
androidDeviceAllowlist: undefined,
312+
depth: 3,
313+
scope: undefined,
314+
raw: true,
315+
selector: 'role=button[name="Continue"]',
316+
timeoutMs: 1500,
317+
});
318+
});
319+
320+
test('back routes through the typed client command API', async () => {
321+
let observed: Parameters<AgentDeviceClient['command']['back']>[0] | undefined;
322+
const client = createStubClient({
323+
installFromSource: async () => {
324+
throw new Error('unexpected install call');
325+
},
326+
});
327+
client.command.back = async (options) => {
328+
observed = options;
329+
return { action: 'back', mode: 'system', message: 'Back' };
330+
};
331+
332+
const stdout = await captureStdout(async () => {
333+
const handled = await tryRunClientBackedCommand({
334+
command: 'back',
335+
positionals: [],
336+
flags: {
337+
json: false,
338+
help: false,
339+
version: false,
340+
platform: 'android',
341+
backMode: 'system',
342+
},
343+
client,
344+
});
345+
assert.equal(handled, true);
346+
});
347+
348+
assert.equal(stdout, 'Back\n');
349+
assert.equal(observed?.platform, 'android');
350+
assert.equal(observed?.mode, 'system');
351+
});
352+
353+
test('appstate routes through the typed client command API and keeps human output', async () => {
354+
let observed: Parameters<AgentDeviceClient['command']['appState']>[0] | undefined;
355+
const client = createStubClient({
356+
installFromSource: async () => {
357+
throw new Error('unexpected install call');
358+
},
359+
});
360+
client.command.appState = async (options) => {
361+
observed = options;
362+
return {
363+
platform: 'ios',
364+
appName: 'Settings',
365+
appBundleId: 'com.apple.Preferences',
366+
source: 'session',
367+
};
368+
};
369+
370+
const stdout = await captureStdout(async () => {
371+
const handled = await tryRunClientBackedCommand({
372+
command: 'appstate',
373+
positionals: [],
374+
flags: {
375+
json: false,
376+
help: false,
377+
version: false,
378+
platform: 'ios',
379+
},
380+
client,
381+
});
382+
assert.equal(handled, true);
383+
});
384+
385+
assert.equal(
386+
stdout,
387+
'Foreground app: Settings\nBundle: com.apple.Preferences\nSource: session\n',
388+
);
389+
assert.equal(observed?.platform, 'ios');
390+
});
391+
277392
test('metro prepare wraps output in the standard success envelope for --json', async () => {
278393
const client = createStubClient({
279394
installFromSource: async () => {
@@ -634,7 +749,21 @@ function createStubClient(params: {
634749
open?: AgentDeviceClient['apps']['open'];
635750
screenshot?: AgentDeviceClient['capture']['screenshot'];
636751
}): AgentDeviceClient {
752+
const unexpectedCommandCall = async (): Promise<never> => {
753+
throw new Error('unexpected command call');
754+
};
637755
return {
756+
command: {
757+
wait: unexpectedCommandCall,
758+
alert: unexpectedCommandCall,
759+
appState: unexpectedCommandCall,
760+
back: unexpectedCommandCall,
761+
home: unexpectedCommandCall,
762+
rotate: unexpectedCommandCall,
763+
appSwitcher: unexpectedCommandCall,
764+
keyboard: unexpectedCommandCall,
765+
clipboard: unexpectedCommandCall,
766+
},
638767
devices: {
639768
list: async () => [],
640769
},

src/__tests__/client.test.ts

Lines changed: 166 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';
@@ -347,6 +350,169 @@ test('client throws AppError for daemon failures', async () => {
347350
);
348351
});
349352

353+
test('client.command.back forwards typed options through shared request metadata', async () => {
354+
const setup = createTransport(async () => ({
355+
ok: true,
356+
data: {
357+
action: 'back',
358+
mode: 'system',
359+
},
360+
}));
361+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
362+
363+
const result = await client.command.back({
364+
mode: 'system',
365+
platform: 'android',
366+
});
367+
368+
assert.equal(setup.calls.length, 1);
369+
assert.equal(setup.calls[0]?.session, 'qa');
370+
assert.equal(setup.calls[0]?.command, 'back');
371+
assert.deepEqual(setup.calls[0]?.positionals, []);
372+
assert.equal(setup.calls[0]?.flags?.backMode, 'system');
373+
assert.equal(setup.calls[0]?.flags?.platform, 'android');
374+
assert.equal(setup.calls[0]?.flags?.daemonBaseUrl, 'http://daemon.example.test');
375+
assert.equal(setup.calls[0]?.meta?.tenantId, 'acme');
376+
assert.equal(setup.calls[0]?.meta?.runId, 'run-123');
377+
assert.equal(setup.calls[0]?.meta?.leaseId, 'lease-123');
378+
assert.deepEqual(result, {
379+
action: 'back',
380+
mode: 'system',
381+
});
382+
});
383+
384+
test('client.command.wait prepares selector options without raw daemon requests', async () => {
385+
const setup = createTransport(async () => ({
386+
ok: true,
387+
data: {
388+
selector: 'role=button[name="Continue"]',
389+
waitedMs: 120,
390+
},
391+
}));
392+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
393+
394+
const result = await client.command.wait({
395+
selector: 'role=button[name="Continue"]',
396+
timeoutMs: 1_500,
397+
depth: 3,
398+
raw: true,
399+
});
400+
401+
assert.equal(setup.calls.length, 1);
402+
assert.equal(setup.calls[0]?.command, 'wait');
403+
assert.deepEqual(setup.calls[0]?.positionals, ['role=button[name="Continue"]', '1500']);
404+
assert.equal(setup.calls[0]?.flags?.snapshotDepth, 3);
405+
assert.equal(setup.calls[0]?.flags?.snapshotRaw, true);
406+
assert.deepEqual(result, {
407+
selector: 'role=button[name="Continue"]',
408+
waitedMs: 120,
409+
});
410+
});
411+
412+
test('client.command.appSwitcher maps to daemon app-switcher command', async () => {
413+
const setup = createTransport(async () => ({
414+
ok: true,
415+
data: {
416+
action: 'app-switcher',
417+
},
418+
}));
419+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
420+
421+
await client.command.appSwitcher();
422+
423+
assert.equal(setup.calls.length, 1);
424+
assert.equal(setup.calls[0]?.command, 'app-switcher');
425+
assert.deepEqual(setup.calls[0]?.positionals, []);
426+
});
427+
428+
test('remote-config defaults are scoped to the command escape hatch', async () => {
429+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-client-remote-scope-'));
430+
try {
431+
const remoteConfig = path.join(tempRoot, 'remote.json');
432+
fs.writeFileSync(
433+
remoteConfig,
434+
JSON.stringify({
435+
session: 'remote-session',
436+
platform: 'android',
437+
daemonBaseUrl: 'http://127.0.0.1:9124/agent-device',
438+
}),
439+
);
440+
const setup = createTransport(async () => ({
441+
ok: true,
442+
data: {
443+
devices: [],
444+
},
445+
}));
446+
const client = createAgentDeviceClient(
447+
{
448+
session: 'local-session',
449+
remoteConfig,
450+
cwd: tempRoot,
451+
},
452+
{ transport: setup.transport },
453+
);
454+
455+
await client.devices.list();
456+
457+
assert.equal(setup.calls.length, 1);
458+
assert.equal(setup.calls[0]?.session, 'local-session');
459+
assert.equal(setup.calls[0]?.command, 'devices');
460+
assert.equal(setup.calls[0]?.flags?.platform, undefined);
461+
assert.equal(setup.calls[0]?.flags?.daemonBaseUrl, undefined);
462+
} finally {
463+
fs.rmSync(tempRoot, { recursive: true, force: true });
464+
}
465+
});
466+
467+
test('client.command.home applies remote-config defaults before daemon transport', async () => {
468+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-client-remote-config-'));
469+
try {
470+
const remoteConfig = path.join(tempRoot, 'remote.json');
471+
fs.writeFileSync(
472+
remoteConfig,
473+
JSON.stringify({
474+
session: 'remote-session',
475+
platform: 'android',
476+
daemonBaseUrl: 'http://127.0.0.1:9124/agent-device',
477+
tenant: 'remote-tenant',
478+
sessionIsolation: 'tenant',
479+
runId: 'remote-run',
480+
leaseId: 'remote-lease',
481+
}),
482+
);
483+
const setup = createTransport(async () => ({
484+
ok: true,
485+
data: {
486+
action: 'home',
487+
},
488+
}));
489+
const client = createAgentDeviceClient(
490+
{
491+
remoteConfig,
492+
cwd: tempRoot,
493+
},
494+
{ transport: setup.transport },
495+
);
496+
497+
await client.command.home();
498+
499+
assert.equal(setup.calls.length, 1);
500+
assert.equal(setup.calls[0]?.session, 'remote-session');
501+
assert.equal(setup.calls[0]?.command, 'home');
502+
assert.equal(setup.calls[0]?.flags?.platform, 'android');
503+
assert.equal(setup.calls[0]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device');
504+
assert.equal(setup.calls[0]?.flags?.tenant, 'remote-tenant');
505+
assert.equal(setup.calls[0]?.flags?.sessionIsolation, 'tenant');
506+
assert.equal(setup.calls[0]?.flags?.runId, 'remote-run');
507+
assert.equal(setup.calls[0]?.flags?.leaseId, 'remote-lease');
508+
assert.equal(setup.calls[0]?.meta?.tenantId, 'remote-tenant');
509+
assert.equal(setup.calls[0]?.meta?.runId, 'remote-run');
510+
assert.equal(setup.calls[0]?.meta?.leaseId, 'remote-lease');
511+
} finally {
512+
fs.rmSync(tempRoot, { recursive: true, force: true });
513+
}
514+
});
515+
350516
test('client capture.snapshot preserves visibility metadata from daemon responses', async () => {
351517
const setup = createTransport(async () => ({
352518
ok: true,

0 commit comments

Comments
 (0)