Skip to content

Commit 7865a56

Browse files
committed
feat: expose typed client command escape hatch
1 parent f4d90a3 commit 7865a56

7 files changed

Lines changed: 550 additions & 2 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,9 @@ function createStubClient(params: {
635635
screenshot?: AgentDeviceClient['capture']['screenshot'];
636636
}): AgentDeviceClient {
637637
return {
638+
command: async () => {
639+
throw new Error('unexpected command call');
640+
},
638641
devices: {
639642
list: async () => [],
640643
},

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 forwards typed back 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 prepares wait 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 maps appSwitcher alias 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 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,

src/__tests__/close-remote-metro.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ test('close with remote-config stops the managed Metro companion for that projec
3838
});
3939

4040
const client: AgentDeviceClient = {
41+
command: async () => {
42+
throw new Error('unexpected call');
43+
},
4144
devices: { list: async () => [] },
4245
sessions: {
4346
list: async () => [],
@@ -131,6 +134,9 @@ test('close with remote-config still stops the managed Metro companion when clos
131134
});
132135

133136
const client: AgentDeviceClient = {
137+
command: async () => {
138+
throw new Error('unexpected call');
139+
},
134140
devices: { list: async () => [] },
135141
sessions: {
136142
list: async () => [],
@@ -229,6 +235,9 @@ test('close app with remote-config stops the managed Metro companion for that se
229235
);
230236

231237
const client: AgentDeviceClient = {
238+
command: async () => {
239+
throw new Error('unexpected call');
240+
},
232241
devices: { list: async () => [] },
233242
sessions: {
234243
list: async () => [],
@@ -325,6 +334,9 @@ test('close with remote-config still succeeds when the config file is gone befor
325334
fs.rmSync(remoteConfigPath);
326335

327336
const client: AgentDeviceClient = {
337+
command: async () => {
338+
throw new Error('unexpected call');
339+
},
328340
devices: { list: async () => [] },
329341
sessions: {
330342
list: async () => [],

src/client-normalizers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
257257
shutdown: options.shutdown,
258258
saveScript: options.saveScript,
259259
noRecord: options.noRecord,
260+
backMode: options.backMode,
260261
metroHost: options.metroHost,
261262
metroPort: options.metroPort,
262263
bundleUrl: options.bundleUrl,

0 commit comments

Comments
 (0)