Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Minimal operating guide for AI coding agents in this repo.
- Surgical edits only.
- Match existing style.
- Remove imports/variables YOUR changes made unused; do not clean unrelated dead code.
- Keep tests minimal: if TypeScript can enforce a contract or invalid shape, prefer a type-level check over duplicating that assertion in runtime tests.
- Keep modules small for agent context safety:
- target <= 300 LOC per implementation file when practical.
- if a file grows past 500 LOC, plan/extract focused submodules before adding new behavior.
Expand Down Expand Up @@ -154,6 +155,7 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
## Testing Matrix
- Docs/skills only: no tests required.
- Non-TS, no behavior impact: no tests unless requested.
- Keep tests behavioral; do not assert shapes or cases TypeScript already proves.
- Any TS change: `pnpm typecheck` or `pnpm check:quick`.
- Tooling/config change (`package.json`, `tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`): `pnpm check:tooling`.
- Daemon handler/shared module change: `pnpm check:unit`.
Expand Down
79 changes: 78 additions & 1 deletion src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,59 @@ test('screenshot reports annotated ref count in non-json mode', async () => {
assert.equal(stdout, 'Annotated 2 refs onto /tmp/screenshot.png\n');
});

test('wait keeps CLI bare text behavior through the typed client command API', async () => {
let observed: Parameters<AgentDeviceClient['command']['wait']>[0] | undefined;
const client = createStubClient({
installFromSource: async () => {
throw new Error('unexpected install call');
},
});
client.command.wait = async (options) => {
observed = options;
return { text: 'Continue', waitedMs: 12 };
};

const handled = await tryRunClientBackedCommand({
command: 'wait',
positionals: ['Continue', '1500'],
flags: {
json: false,
help: false,
version: false,
},
client,
});

assert.equal(handled, true);
assert.equal(observed?.text, 'Continue');
assert.equal(observed?.timeoutMs, 1500);
});

test('clipboard read keeps human text output through the typed client command API', async () => {
const client = createStubClient({
installFromSource: async () => {
throw new Error('unexpected install call');
},
});
client.command.clipboard = async () => ({ action: 'read', text: 'hello' });

const stdout = await captureStdout(async () => {
const handled = await tryRunClientBackedCommand({
command: 'clipboard',
positionals: ['read'],
flags: {
json: false,
help: false,
version: false,
},
client,
});
assert.equal(handled, true);
});

assert.equal(stdout, 'hello\n');
});

test('metro prepare wraps output in the standard success envelope for --json', async () => {
const client = createStubClient({
installFromSource: async () => {
Expand Down Expand Up @@ -634,9 +687,15 @@ function createStubClient(params: {
open?: AgentDeviceClient['apps']['open'];
screenshot?: AgentDeviceClient['capture']['screenshot'];
}): AgentDeviceClient {
const unexpectedCommandCall = async (): Promise<never> => {
throw new Error('unexpected command call');
};
const command = createThrowingMethodGroup<AgentDeviceClient['command']>();
return {
command,
devices: {
list: async () => [],
boot: unexpectedCommandCall,
},
sessions: {
list: async () => [],
Expand Down Expand Up @@ -681,6 +740,8 @@ function createStubClient(params: {
session: 'default',
identifiers: { session: 'default' },
}),
push: unexpectedCommandCall,
triggerEvent: unexpectedCommandCall,
},
materializations: {
release: async (options) => ({
Expand Down Expand Up @@ -726,6 +787,22 @@ function createStubClient(params: {
path: '/tmp/screenshot.png',
identifiers: { session: 'default' },
})),
},
diff: unexpectedCommandCall,
},
interactions: createThrowingMethodGroup<AgentDeviceClient['interactions']>(),
replay: createThrowingMethodGroup<AgentDeviceClient['replay']>(),
batch: createThrowingMethodGroup<AgentDeviceClient['batch']>(),
observability: createThrowingMethodGroup<AgentDeviceClient['observability']>(),
recording: createThrowingMethodGroup<AgentDeviceClient['recording']>(),
settings: createThrowingMethodGroup<AgentDeviceClient['settings']>(),
};
}

function createThrowingMethodGroup<T extends object>(): T {
const unexpectedCommandCall = async (): Promise<never> => {
throw new Error('unexpected command call');
};
return new Proxy({} as Partial<T>, {
get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall,
}) as T;
}
58 changes: 58 additions & 0 deletions src/__tests__/client-public.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
createAgentDeviceClient,
type AgentDeviceClient,
type CaptureScreenshotResult,
type CaptureSnapshotResult,
type Point,
type Rect,
type ScreenshotOverlayRef,
type SnapshotNode,
type SnapshotVisibility,
type SnapshotVisibilityReason,
} from '../index.ts';

const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect;
const point = { x: 2, y: 4 } satisfies Point;
const visibilityReason = 'offscreen-nodes' satisfies SnapshotVisibilityReason;

const node = {
index: 0,
ref: 'e1',
type: 'Button',
label: 'Continue',
rect,
} satisfies SnapshotNode;

const visibility = {
partial: true,
visibleNodeCount: 1,
totalNodeCount: 2,
reasons: [visibilityReason],
} satisfies SnapshotVisibility;

({
nodes: [node],
truncated: false,
visibility,
identifiers: { session: 'default' },
}) satisfies CaptureSnapshotResult;

const overlay = {
ref: 'e1',
rect,
overlayRect: rect,
center: point,
} satisfies ScreenshotOverlayRef;

({
path: '/tmp/screenshot.png',
overlayRefs: [overlay],
identifiers: { session: 'default' },
}) satisfies CaptureScreenshotResult;

test('package root exports createAgentDeviceClient', () => {
const client: AgentDeviceClient = createAgentDeviceClient();
assert.equal(typeof client.capture.snapshot, 'function');
});
84 changes: 84 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts';
import type { DaemonRequest, DaemonResponse } from '../daemon/types.ts';
import { AppError } from '../utils/errors.ts';
Expand Down Expand Up @@ -371,6 +374,87 @@ test('client throws AppError for daemon failures', async () => {
);
});

test('client.command.wait prepares selector options and rejects invalid selectors', async () => {
const setup = createTransport(async () => ({
ok: true,
data: {},
}));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

await client.command.wait({
selector: 'role=button[name="Continue"]',
timeoutMs: 1_500,
depth: 3,
raw: true,
});

assert.equal(setup.calls.length, 1);
assert.equal(setup.calls[0]?.command, 'wait');
assert.deepEqual(setup.calls[0]?.positionals, ['role=button[name="Continue"]', '1500']);
assert.equal(setup.calls[0]?.flags?.snapshotDepth, 3);
assert.equal(setup.calls[0]?.flags?.snapshotRaw, true);

await assert.rejects(
async () => await client.command.wait({ selector: 'Continue' }),
/Invalid wait selector: Continue/,
);
assert.equal(setup.calls.length, 1);
});

test('remote-config defaults apply across daemon-backed client methods', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-client-remote-scope-'));
try {
const remoteConfig = path.join(tempRoot, 'remote.json');
fs.writeFileSync(
remoteConfig,
JSON.stringify({
session: 'remote-session',
platform: 'android',
daemonBaseUrl: 'http://127.0.0.1:9124/agent-device',
tenant: 'remote-tenant',
sessionIsolation: 'tenant',
runId: 'remote-run',
leaseId: 'remote-lease',
}),
);
const setup = createTransport(async () => ({
ok: true,
data: {},
}));
const client = createAgentDeviceClient(
{
remoteConfig,
cwd: tempRoot,
},
{ transport: setup.transport },
);
fs.writeFileSync(remoteConfig, '{');

await client.devices.list();
await client.command.home();
const snapshot = await client.capture.snapshot();

assert.equal(setup.calls[0]?.session, 'remote-session');
assert.equal(setup.calls[0]?.command, 'devices');
assert.equal(setup.calls[0]?.flags?.platform, 'android');
assert.equal(setup.calls[0]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device');
assert.equal(setup.calls[0]?.meta?.tenantId, 'remote-tenant');
assert.equal(setup.calls[1]?.session, 'remote-session');
assert.equal(setup.calls[1]?.command, 'home');
assert.equal(setup.calls[1]?.flags?.platform, 'android');
assert.equal(setup.calls[1]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device');
assert.equal(setup.calls[1]?.meta?.tenantId, 'remote-tenant');
assert.equal(setup.calls[1]?.meta?.runId, 'remote-run');
assert.equal(setup.calls[1]?.meta?.leaseId, 'remote-lease');
assert.equal(setup.calls[2]?.session, 'remote-session');
assert.equal(setup.calls[2]?.command, 'snapshot');
assert.equal(setup.calls[2]?.flags?.platform, 'android');
assert.equal(snapshot.identifiers.session, 'remote-session');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});

test('client capture.snapshot preserves visibility metadata from daemon responses', async () => {
const setup = createTransport(async () => ({
ok: true,
Expand Down
Loading
Loading