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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
- On macOS, snapshot rects are absolute in window space. Point-based runner interactions must translate through the interaction root frame; do not assume app-origin `(0,0)` coordinates.
- Prefer selector or `@ref` interactions over raw x/y commands in tests and docs, especially on macOS where window position can vary across runs.

## Shared Test Utilities
- Before writing a new test, check `src/__tests__/test-utils/` for existing helpers:
- `device-fixtures.ts`: canonical `DeviceInfo` constants (`ANDROID_EMULATOR`, `IOS_SIMULATOR`, `IOS_DEVICE`, `MACOS_DEVICE`, `LINUX_DEVICE`, etc.)
- `session-factories.ts`: `makeSession`, `makeIosSession`, `makeAndroidSession`, `makeMacOsSession`
- `store-factory.ts`: `makeSessionStore` (creates temp `SessionStore` instances)
- `snapshot-builders.ts`: `buildNodes`, `makeSnapshotState`
- `mocked-binaries.ts`: `withMockedAdb`, `withMockedXcrun` (stub CLI binaries for dispatch tests)
- Use `import { ... } from '<relative-path>/__tests__/test-utils/index.ts'` for convenient barrel imports.
- Prefer shared fixtures over inlining new `DeviceInfo` or `SessionState` objects in tests.
- Do not duplicate `makeSessionStore`, `makeSession`, or device constants when a shared helper already exists.

## Testing Matrix
- Docs/skills only: no tests required.
- Non-TS, no behavior impact: no tests unless requested.
Expand Down
81 changes: 34 additions & 47 deletions src/__tests__/metro-protocol-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,57 +8,44 @@ import {
type MetroTunnelResponseMessage,
} from '../metro.ts';

test('public metro exports expose stable bridge payload types and url helpers', () => {
const descriptor: MetroBridgeDescriptor = {
enabled: true,
base_url: 'https://bridge.example.test',
ios_runtime: {
metro_host: '127.0.0.1',
metro_port: 8081,
metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios',
},
android_runtime: {
metro_host: '10.0.2.2',
metro_port: 8081,
metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android',
},
upstream: {
bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios',
},
probe: {
reachable: true,
status_code: 200,
latency_ms: 4,
detail: 'ok',
},
};
// Type-only contract fixtures — these verify that the public subpath types
// remain structurally stable. A rename or breaking shape change will fail
// the compile, not a runtime assertion.
({
enabled: true,
base_url: 'https://bridge.example.test',
ios_runtime: {
metro_host: '127.0.0.1',
metro_port: 8081,
metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios',
},
android_runtime: {
metro_host: '10.0.2.2',
metro_port: 8081,
metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android',
},
upstream: { bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios' },
probe: { reachable: true, status_code: 200, latency_ms: 4, detail: 'ok' },
}) satisfies MetroBridgeDescriptor;

assert.equal(descriptor.upstream.port, undefined);
assert.equal(descriptor.status_url, undefined);
({
type: 'ws-frame',
streamId: 'stream-1',
dataBase64: 'aGVsbG8=',
binary: false,
}) satisfies MetroTunnelRequestMessage;

({
type: 'http-response',
requestId: 'req-1',
status: 200,
headers: { 'content-type': 'application/json' },
}) satisfies MetroTunnelResponseMessage;

test('public metro exports expose stable url helpers', () => {
assert.equal(normalizeBaseUrl('https://bridge.example.test///'), 'https://bridge.example.test');
assert.equal(
buildBundleUrl('https://bridge.example.test/', 'ios'),
'https://bridge.example.test/index.bundle?platform=ios&dev=true&minify=false',
);
});

test('public metro exports compile for representative tunnel request and response payloads', () => {
const request: MetroTunnelRequestMessage = {
type: 'ws-frame',
streamId: 'stream-1',
dataBase64: 'aGVsbG8=',
binary: false,
};
const response: MetroTunnelResponseMessage = {
type: 'http-response',
requestId: 'req-1',
status: 200,
headers: { 'content-type': 'application/json' },
};

const messages = [request, response];

assert.equal(request.type, 'ws-frame');
assert.equal(response.type, 'http-response');
assert.equal(messages.length, 2);
});
58 changes: 58 additions & 0 deletions src/__tests__/test-utils/device-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { DeviceInfo } from '../../utils/device.ts';

export const ANDROID_EMULATOR: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};

export const IOS_SIMULATOR: DeviceInfo = {
platform: 'ios',
id: 'sim-1',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: true,
};

export const IOS_DEVICE: DeviceInfo = {
platform: 'ios',
id: 'ios-device-1',
name: 'iPhone',
kind: 'device',
booted: true,
};

export const MACOS_DEVICE: DeviceInfo = {
platform: 'macos',
id: 'host-macos-local',
name: 'Mac',
kind: 'device',
target: 'desktop',
booted: true,
};

export const LINUX_DEVICE: DeviceInfo = {
platform: 'linux',
id: 'local',
name: 'Linux Desktop',
kind: 'device',
target: 'desktop',
};

export const ANDROID_TV_DEVICE: DeviceInfo = {
platform: 'android',
id: 'and-tv-1',
name: 'Android TV',
kind: 'device',
target: 'tv',
};

export const TVOS_SIMULATOR: DeviceInfo = {
platform: 'ios',
id: 'tv-sim-1',
name: 'Apple TV',
kind: 'simulator',
target: 'tv',
};
22 changes: 22 additions & 0 deletions src/__tests__/test-utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export {
ANDROID_EMULATOR,
ANDROID_TV_DEVICE,
IOS_DEVICE,
IOS_SIMULATOR,
LINUX_DEVICE,
MACOS_DEVICE,
TVOS_SIMULATOR,
} from './device-fixtures.ts';

export {
makeAndroidSession,
makeIosSession,
makeMacOsSession,
makeSession,
} from './session-factories.ts';

export { makeSessionStore } from './store-factory.ts';

export { buildNodes, makeSnapshotState } from './snapshot-builders.ts';

export { withMockedAdb, withMockedXcrun } from './mocked-binaries.ts';
69 changes: 69 additions & 0 deletions src/__tests__/test-utils/mocked-binaries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';

/**
* Creates a temporary stub `adb` binary that logs all args to a file,
* prepends it to PATH, and cleans up after the callback finishes.
*/
export async function withMockedAdb(
tempPrefix: string,
run: (argsLogPath: string) => Promise<void>,
): Promise<void> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
const adbPath = path.join(tmpDir, 'adb');
const argsLogPath = path.join(tmpDir, 'args.log');
await fs.writeFile(
adbPath,
'#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
'utf8',
);
await fs.chmod(adbPath, 0o755);

const previousPath = process.env.PATH;
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;

try {
await run(argsLogPath);
} finally {
process.env.PATH = previousPath;
if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
await fs.rm(tmpDir, { recursive: true, force: true });
}
}

/**
* Creates a temporary stub `xcrun` binary that logs all args to a file,
* prepends it to PATH, and cleans up after the callback finishes.
*/
export async function withMockedXcrun(
tempPrefix: string,
run: (argsLogPath: string) => Promise<void>,
): Promise<void> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
const xcrunPath = path.join(tmpDir, 'xcrun');
const argsLogPath = path.join(tmpDir, 'args.log');
await fs.writeFile(
xcrunPath,
'#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
'utf8',
);
await fs.chmod(xcrunPath, 0o755);

const previousPath = process.env.PATH;
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;

try {
await run(argsLogPath);
} finally {
process.env.PATH = previousPath;
if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
36 changes: 36 additions & 0 deletions src/__tests__/test-utils/session-factories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { SessionState } from '../../daemon/types.ts';
import { IOS_SIMULATOR, ANDROID_EMULATOR, MACOS_DEVICE } from './device-fixtures.ts';

export function makeSession(
name: string,
overrides?: Partial<SessionState>,
): SessionState {
return {
name,
device: IOS_SIMULATOR,
createdAt: Date.now(),
actions: [],
...overrides,
};
}

export function makeIosSession(
name: string,
overrides?: Partial<SessionState>,
): SessionState {
return makeSession(name, { device: IOS_SIMULATOR, ...overrides });
}

export function makeAndroidSession(
name: string,
overrides?: Partial<SessionState>,
): SessionState {
return makeSession(name, { device: ANDROID_EMULATOR, ...overrides });
}

export function makeMacOsSession(
name: string,
overrides?: Partial<SessionState>,
): SessionState {
return makeSession(name, { device: MACOS_DEVICE, ...overrides });
}
16 changes: 16 additions & 0 deletions src/__tests__/test-utils/snapshot-builders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';

export function buildNodes(raw: RawSnapshotNode[]) {
return attachRefs(raw);
}

export function makeSnapshotState(
raw: RawSnapshotNode[],
overrides?: Partial<SnapshotState>,
): SnapshotState {
return {
nodes: attachRefs(raw),
createdAt: Date.now(),
...overrides,
};
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { SessionStore } from '../../session-store.ts';
import { SessionStore } from '../../daemon/session-store.ts';

export function makeSessionStore(prefix: string): SessionStore {
export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
return new SessionStore(path.join(tempRoot, 'sessions'));
}
44 changes: 3 additions & 41 deletions src/core/__tests__/dispatch-back.test.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,14 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { dispatchCommand } from '../dispatch.ts';
import type { DeviceInfo } from '../../utils/device.ts';

const ANDROID_DEVICE: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};

async function withMockedAdb(
tempPrefix: string,
run: (argsLogPath: string) => Promise<void>,
): Promise<void> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
const adbPath = path.join(tmpDir, 'adb');
const argsLogPath = path.join(tmpDir, 'args.log');
await fs.writeFile(
adbPath,
'#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
'utf8',
);
await fs.chmod(adbPath, 0o755);

const previousPath = process.env.PATH;
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;

try {
await run(argsLogPath);
} finally {
process.env.PATH = previousPath;
if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts';

test('dispatch back defaults to in-app mode and keeps Android back on keyevent 4', async () => {
await withMockedAdb('agent-device-dispatch-back-modes-', async (argsLogPath) => {
for (const backMode of [undefined, 'in-app', 'system'] as const) {
const result = await dispatchCommand(ANDROID_DEVICE, 'back', [], undefined, {
const result = await dispatchCommand(ANDROID_EMULATOR, 'back', [], undefined, {
backMode,
});

Expand Down
Loading
Loading