From a49e139ad04ede136a2cc9a8eb0e804d3bf2689b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 28 May 2026 17:25:25 +0200 Subject: [PATCH 1/6] perf: lazy load daemon handlers --- .github/workflows/size.yml | 56 +++ package.json | 2 + scripts/size-report.mjs | 324 ++++++++++++++++++ src/bin.ts | 87 ++++- src/command-catalog.ts | 44 +++ .../__tests__/request-handler-chain.test.ts | 71 ++++ src/daemon/request-handler-chain.ts | 223 ++++++++---- 7 files changed, 735 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/size.yml create mode 100644 scripts/size-report.mjs create mode 100644 src/daemon/__tests__/request-handler-chain.test.ts diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml new file mode 100644 index 000000000..74f0434cc --- /dev/null +++ b/.github/workflows/size.yml @@ -0,0 +1,56 @@ +name: Size + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: size-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + bundle-size: + name: Bundle Size + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Preserve report script + run: cp scripts/size-report.mjs /tmp/agent-device-size-report.mjs + + - name: Measure base size + run: | + git checkout --detach "${{ github.event.pull_request.base.sha }}" + pnpm install --frozen-lockfile + pnpm build + node /tmp/agent-device-size-report.mjs --json /tmp/agent-device-size-base.json + + - name: Measure PR size + run: | + git checkout --detach "${{ github.event.pull_request.head.sha }}" + pnpm install --frozen-lockfile + pnpm build + node scripts/size-report.mjs \ + --compare /tmp/agent-device-size-base.json \ + --json .tmp/size-report.json \ + --markdown .tmp/size-report.md + + - name: Add job summary + run: cat .tmp/size-report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Comment on PR + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} + run: node scripts/size-report.mjs --post-comment .tmp/size-report.md diff --git a/package.json b/package.json index 54a430792..ed3bd7131 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,8 @@ "build:macos-helper": "swift build -c release --package-path macos-helper", "build:all": "pnpm build:node && pnpm build:xcuitest", "ad": "node bin/agent-device.mjs", + "size": "node scripts/size-report.mjs", + "size:markdown": "node scripts/size-report.mjs --json .tmp/size-report.json --markdown .tmp/size-report.md", "lint": "oxlint . --deny-warnings", "format": "oxfmt --write src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", "fallow": "fallow --summary", diff --git a/scripts/size-report.mjs b/scripts/size-report.mjs new file mode 100644 index 000000000..9fa9c8608 --- /dev/null +++ b/scripts/size-report.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { gzipSync } from 'node:zlib'; + +const COMMENT_MARKER = ''; +const VALUE_ARGS = new Map([ + ['--cwd', 'cwd'], + ['--json', 'json'], + ['--markdown', 'markdown'], + ['--compare', 'compare'], + ['--post-comment', 'postComment'], + ['--pr', 'pr'], +]); + +const args = parseArgs(process.argv.slice(2)); +const cwd = path.resolve(args.cwd ?? process.cwd()); + +if (args.postComment) { + await postGitHubComment(args.postComment, args.pr); + process.exit(0); +} + +const report = collectReport(cwd); +const baseReport = args.compare ? JSON.parse(fs.readFileSync(args.compare, 'utf8')) : null; + +if (args.json) { + writeFile(args.json, `${JSON.stringify(report, null, 2)}\n`); +} + +const markdown = formatMarkdown(report, baseReport); + +if (args.markdown) { + writeFile(args.markdown, markdown); +} else { + process.stdout.write(markdown); +} + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (assignValueArg(parsed, arg, argv, index)) index += 1; + else if (isHelpArg(arg)) printHelpAndExit(); + else throw new Error(`Unknown argument: ${arg}`); + } + return parsed; +} + +function assignValueArg(parsed, arg, argv, index) { + const key = VALUE_ARGS.get(arg); + if (!key) return false; + parsed[key] = readValue(argv, index + 1, arg); + return true; +} + +function isHelpArg(arg) { + return arg === '--help' || arg === '-h'; +} + +function printHelpAndExit() { + process.stdout.write(`Usage: node scripts/size-report.mjs [options] + +Options: + --cwd Project root to measure. Defaults to cwd. + --json Write the raw size report JSON. + --markdown Write the markdown report. + --compare Compare against a previously written JSON report. + --post-comment Post or update the markdown report on the current PR. + --pr Pull request number for --post-comment. +`); + process.exit(0); +} + +function readValue(argv, index, flag) { + const value = argv[index]; + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function collectReport(root) { + const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const jsFiles = walk(path.join(root, 'dist', 'src')).filter((file) => file.endsWith('.js')); + if (jsFiles.length === 0) { + throw new Error('No dist/src JavaScript files found. Run `pnpm build` before measuring size.'); + } + + const chunks = jsFiles + .map((file) => { + const buffer = fs.readFileSync(file); + return { + path: path.relative(root, file), + rawBytes: buffer.byteLength, + gzipBytes: gzipSync(buffer, { level: 9 }).byteLength, + }; + }) + .sort((left, right) => right.rawBytes - left.rawBytes); + + const js = chunks.reduce( + (total, chunk) => ({ + files: total.files + 1, + rawBytes: total.rawBytes + chunk.rawBytes, + gzipBytes: total.gzipBytes + chunk.gzipBytes, + }), + { files: 0, rawBytes: 0, gzipBytes: 0 }, + ); + + return { + packageName: packageJson.name, + version: packageJson.version, + generatedAt: new Date().toISOString(), + js, + npmPack: collectNpmPack(root), + chunks: chunks.slice(0, 20), + }; +} + +function walk(root) { + if (!fs.existsSync(root)) return []; + const entries = fs.readdirSync(root, { withFileTypes: true }); + return entries.flatMap((entry) => { + const entryPath = path.join(root, entry.name); + return entry.isDirectory() ? walk(entryPath) : [entryPath]; + }); +} + +function collectNpmPack(root) { + const cachePath = path.join(root, '.tmp', 'npm-cache'); + fs.mkdirSync(cachePath, { recursive: true }); + const stdout = execFileSync( + 'npm', + ['pack', '--dry-run', '--ignore-scripts', '--json', '--cache', cachePath], + { cwd: root, encoding: 'utf8' }, + ); + const pack = parseNpmPackOutput(stdout); + return { + filename: pack.filename, + tarballBytes: pack.size, + unpackedBytes: pack.unpackedSize, + files: countNpmPackEntries(pack), + }; +} + +function parseNpmPackOutput(stdout) { + const parsed = JSON.parse(stdout); + return Array.isArray(parsed) ? parsed[0] : parsed; +} + +function countNpmPackEntries(pack) { + if (typeof pack.entryCount === 'number') return pack.entryCount; + return Array.isArray(pack.files) ? pack.files.length : 0; +} + +function formatMarkdown(report, baseReport) { + const rows = [ + metricRow('JS raw', baseReport?.js.rawBytes, report.js.rawBytes), + metricRow('JS gzip', baseReport?.js.gzipBytes, report.js.gzipBytes), + metricRow('npm tarball', baseReport?.npmPack.tarballBytes, report.npmPack.tarballBytes), + metricRow('npm unpacked', baseReport?.npmPack.unpackedBytes, report.npmPack.unpackedBytes), + ]; + + const changedChunks = baseReport + ? formatChangedChunks(report.chunks, baseReport.chunks ?? []) + : formatTopChunks(report.chunks); + + return `${COMMENT_MARKER} +## Size Report + +| Metric | Base | Current | Diff | +|---|---:|---:|---:| +${rows.join('\n')} + +${changedChunks} +`; +} + +function metricRow(label, base, current) { + return `| ${label} | ${formatMaybeBytes(base)} | ${formatBytes(current)} | ${formatDiff(base, current)} |`; +} + +function formatTopChunks(chunks) { + const rows = chunks.slice(0, 5).map((chunk) => { + return `| \`${chunk.path}\` | ${formatBytes(chunk.rawBytes)} | ${formatBytes(chunk.gzipBytes)} |`; + }); + return `Top chunks: + +| Chunk | Raw | Gzip | +|---|---:|---:| +${rows.join('\n')} +`; +} + +function formatChangedChunks(currentChunks, baseChunks) { + const baseByPath = new Map(baseChunks.map((chunk) => [chunk.path, chunk])); + const rows = currentChunks + .map((chunk) => { + const base = baseByPath.get(chunk.path); + return { + path: chunk.path, + rawDiff: base ? chunk.rawBytes - base.rawBytes : chunk.rawBytes, + gzipDiff: base ? chunk.gzipBytes - base.gzipBytes : chunk.gzipBytes, + }; + }) + .filter((chunk) => chunk.rawDiff !== 0 || chunk.gzipDiff !== 0) + .sort((left, right) => Math.abs(right.gzipDiff) - Math.abs(left.gzipDiff)) + .slice(0, 5) + .map((chunk) => { + return `| \`${chunk.path}\` | ${formatSignedBytes(chunk.rawDiff)} | ${formatSignedBytes(chunk.gzipDiff)} |`; + }); + + if (rows.length === 0) { + return 'Top changed chunks: no changes in the largest emitted chunks.\n'; + } + + return `Top changed chunks: + +| Chunk | Raw diff | Gzip diff | +|---|---:|---:| +${rows.join('\n')} +`; +} + +function formatMaybeBytes(value) { + return typeof value === 'number' ? formatBytes(value) : '-'; +} + +function formatDiff(base, current) { + return typeof base === 'number' ? formatSignedBytes(current - base) : '-'; +} + +function formatBytes(value) { + const absoluteValue = Math.abs(value); + if (absoluteValue < 1000) return `${value} B`; + if (absoluteValue < 1000 * 1000) return `${(value / 1000).toFixed(1)} kB`; + return `${(value / (1000 * 1000)).toFixed(1)} MB`; +} + +function formatSignedBytes(value) { + if (value === 0) return '0 B'; + const sign = value > 0 ? '+' : '-'; + return `${sign}${formatBytes(Math.abs(value))}`; +} + +function writeFile(filePath, contents) { + fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +async function postGitHubComment(markdownPath, explicitPrNumber) { + const config = readGitHubCommentConfig(explicitPrNumber); + const body = fs.readFileSync(markdownPath, 'utf8'); + const commentsUrl = buildCommentsUrl(config.repository, config.prNumber); + const comments = await listGitHubComments(commentsUrl, config.headers); + const existing = comments.find((comment) => comment.body?.includes(COMMENT_MARKER)); + await writeGitHubComment(commentsUrl, config.headers, body, existing?.url); +} + +function readGitHubCommentConfig(explicitPrNumber) { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const prNumber = explicitPrNumber ?? process.env.GITHUB_PR_NUMBER; + assertGitHubCommentConfig(token, repository, prNumber); + return { + repository, + prNumber, + headers: buildGitHubHeaders(token), + }; +} + +function assertGitHubCommentConfig(token, repository, prNumber) { + for (const value of [token, repository, prNumber]) { + if (!value) { + throw new Error('GITHUB_TOKEN, GITHUB_REPOSITORY, and PR number are required to post a comment.'); + } + } +} + +function buildGitHubHeaders(token) { + return { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'x-github-api-version': '2022-11-28', + }; +} + +function buildCommentsUrl(repository, prNumber) { + const [owner, repo] = repository.split('/'); + return `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`; +} + +async function listGitHubComments(commentsUrl, headers) { + const response = await fetch(`${commentsUrl}?per_page=100`, { headers }); + if (!response.ok) { + throw new Error(`Failed to list PR comments: ${response.status} ${await response.text()}`); + } + return await response.json(); +} + +async function writeGitHubComment(commentsUrl, headers, body, existingUrl) { + const target = commentWriteTarget(commentsUrl, existingUrl); + const response = await fetch(target.url, { + method: target.method, + headers, + body: JSON.stringify({ body }), + }); + await assertGitHubWriteResponse(response, target.action); +} + +function commentWriteTarget(commentsUrl, existingUrl) { + if (existingUrl) { + return { url: existingUrl, method: 'PATCH', action: 'update' }; + } + return { url: commentsUrl, method: 'POST', action: 'create' }; +} + +async function assertGitHubWriteResponse(response, action) { + if (!response.ok) { + throw new Error(`Failed to ${action} PR comment: ${response.status} ${await response.text()}`); + } +} diff --git a/src/bin.ts b/src/bin.ts index d805c3bc7..392333154 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,10 +1,95 @@ const argv = process.argv.slice(2); -if (argv[0] === 'mcp' && !argv.includes('--help') && !argv.includes('-h')) { +if (runFastPath(argv)) { + // Fast path owns process output and exit behavior. +} else if (argv[0] === 'mcp' && !argv.includes('--help') && !argv.includes('-h')) { import('./mcp/server.ts') .then(({ runAgentDeviceMcpServer }) => runAgentDeviceMcpServer()) .catch(handleStartupError); } else { + runCli(argv); +} + +function runFastPath(argv: string[]): boolean { + return runVersionFastPath(argv) || runHelpFastPath(argv); +} + +function runVersionFastPath(argv: string[]): boolean { + if (argv.length !== 1 || !isVersionFlag(argv[0])) return false; + import('./utils/version.ts') + .then(({ readVersion }) => { + process.stdout.write(`${readVersion()}\n`); + }) + .catch(handleStartupError); + return true; +} + +function runHelpFastPath(argv: string[]): boolean { + const helpTarget = resolveSimpleHelpTarget(argv); + if (helpTarget === undefined) return false; + + import('./utils/args.ts') + .then(({ usage, usageForCommand }) => { + if (helpTarget === null) { + process.stdout.write(`${usage()}\n`); + return; + } + const commandHelp = usageForCommand(helpTarget); + if (commandHelp) { + process.stdout.write(commandHelp); + return; + } + // Unknown help topics still need full CLI parsing for the normal error path. + runCli(argv); + }) + .catch(handleStartupError); + return true; +} + +function resolveSimpleHelpTarget(argv: string[]): string | null | undefined { + switch (argv.length) { + case 1: + return resolveSingleArgHelpTarget(argv[0]); + case 2: + return resolveTwoArgHelpTarget(argv[0], argv[1]); + default: + return undefined; + } +} + +function resolveSingleArgHelpTarget(arg: string | undefined): null | undefined { + if (arg === 'help') return null; + return isHelpFlag(arg) ? null : undefined; +} + +function resolveTwoArgHelpTarget( + command: string | undefined, + helpArg: string | undefined, +): string | undefined { + if (isHelpCommand(command)) return helpArg; + return resolveTrailingHelpTarget(command, helpArg); +} + +function resolveTrailingHelpTarget( + command: string | undefined, + helpArg: string | undefined, +): string | undefined { + return isHelpFlag(helpArg) ? command : undefined; +} + +function isHelpCommand(command: string | undefined): boolean { + return command === 'help'; +} + +function isHelpFlag(arg: string | undefined): boolean { + return arg === '--help' || arg === '-h'; +} + +function isVersionFlag(arg: string | undefined): boolean { + return arg === '--version' || arg === '-V'; +} + +function runCli(argv: string[]): void { import('./cli.ts').then(({ runCli }) => runCli(argv)).catch(handleStartupError); } diff --git a/src/command-catalog.ts b/src/command-catalog.ts index aace45853..6070b2843 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -51,6 +51,7 @@ export const INTERNAL_COMMANDS = { leaseHeartbeat: 'lease_heartbeat', leaseRelease: 'lease_release', releaseMaterializedPaths: 'release_materialized_paths', + runtime: 'runtime', sessionList: 'session_list', } as const; @@ -175,6 +176,49 @@ export const DAEMON_COMMAND_GROUPS = { INTERNAL_COMMANDS.leaseHeartbeat, INTERNAL_COMMANDS.leaseRelease, ), + // Specialized daemon handler families. Commands absent from these sets fall through to + // request-generic-dispatch after request admission and provider scoping. + leaseHandler: commandSet( + INTERNAL_COMMANDS.leaseAllocate, + INTERNAL_COMMANDS.leaseHeartbeat, + INTERNAL_COMMANDS.leaseRelease, + ), + sessionHandler: commandSet( + INTERNAL_COMMANDS.installSource, + INTERNAL_COMMANDS.releaseMaterializedPaths, + INTERNAL_COMMANDS.sessionList, + PUBLIC_COMMANDS.appState, + PUBLIC_COMMANDS.apps, + PUBLIC_COMMANDS.batch, + PUBLIC_COMMANDS.boot, + PUBLIC_COMMANDS.clipboard, + PUBLIC_COMMANDS.close, + PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.install, + PUBLIC_COMMANDS.keyboard, + PUBLIC_COMMANDS.logs, + PUBLIC_COMMANDS.network, + PUBLIC_COMMANDS.open, + PUBLIC_COMMANDS.perf, + PUBLIC_COMMANDS.push, + PUBLIC_COMMANDS.reinstall, + PUBLIC_COMMANDS.replay, + PUBLIC_COMMANDS.test, + PUBLIC_COMMANDS.triggerAppEvent, + INTERNAL_COMMANDS.runtime, + ), + reactNativeHandler: commandSet(PUBLIC_COMMANDS.reactNative), + recordTraceHandler: commandSet(PUBLIC_COMMANDS.record, PUBLIC_COMMANDS.trace), + findHandler: commandSet(PUBLIC_COMMANDS.find), + interactionHandler: commandSet( + PUBLIC_COMMANDS.click, + PUBLIC_COMMANDS.fill, + PUBLIC_COMMANDS.get, + PUBLIC_COMMANDS.is, + PUBLIC_COMMANDS.longPress, + PUBLIC_COMMANDS.press, + PUBLIC_COMMANDS.type, + ), } as const; function commandSet(...commands: readonly string[]): ReadonlySet { diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts new file mode 100644 index 000000000..b5f6580fb --- /dev/null +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { runRequestHandlerChain } from '../request-handler-chain.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import { makeIosSession } from '../../__tests__/test-utils/index.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; + +function makeRequest(command: string, positionals: string[] = []): DaemonRequest { + return { + command, + token: 'test-token', + session: 'chain-test', + positionals, + flags: {}, + meta: { requestId: `req-${command}` }, + }; +} + +function makeChainParams(req: DaemonRequest) { + const sessionStore = makeSessionStore('agent-device-request-chain-'); + sessionStore.set('chain-test', makeIosSession('chain-test')); + return { + req, + sessionName: 'chain-test', + logPath: '/tmp/agent-device-request-chain.log', + sessionStore, + leaseRegistry: new LeaseRegistry(), + invoke: async (): Promise => ({ ok: true, data: {} }), + contextFromFlags: () => ({ logPath: '/tmp/agent-device-request-chain.log' }), + }; +} + +test('request handler chain routes trace commands to the record-trace family', async () => { + const response = await runRequestHandlerChain(makeChainParams(makeRequest('trace', ['start']))); + + assert.equal(response?.ok, true); + assert.equal(response?.data?.trace, 'started'); +}); + +test('request handler chain leaves generic commands for fallback dispatch', async () => { + for (const command of ['back', 'gesture', 'home', 'screenshot', 'scroll', 'swipe']) { + const response = await runRequestHandlerChain(makeChainParams(makeRequest(command))); + + assert.equal(response, null, `${command} should fall through to generic dispatch`); + } +}); + +test('request handler chain routes lease commands to the lease family', async () => { + const response = await runRequestHandlerChain({ + ...makeChainParams({ + ...makeRequest(INTERNAL_COMMANDS.leaseAllocate), + flags: { tenant: 'tenant-a', runId: 'run-a' }, + }), + sessionName: 'other-session', + }); + + assert.equal(response?.ok, true); + assert.equal(typeof response?.data?.lease, 'object'); +}); + +test('request handler chain routes session commands to the session family', async () => { + const response = await runRequestHandlerChain( + makeChainParams(makeRequest(INTERNAL_COMMANDS.runtime, ['show'])), + ); + + assert.equal(response?.ok, true); + assert.equal(response?.data?.session, 'chain-test'); + assert.equal(response?.data?.configured, false); +}); diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 7fbf1aa4e..0bacbb26c 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -1,18 +1,21 @@ import type { CommandFlags } from '../core/dispatch.ts'; +import { DAEMON_COMMAND_GROUPS } from '../command-catalog.ts'; import type { AndroidAdbExecutor } from '../platforms/android/adb-executor.ts'; +import { AppError } from '../utils/errors.ts'; import type { DaemonCommandContext } from './context.ts'; -import { handleFindCommands } from './handlers/find.ts'; -import { handleInteractionCommands } from './handlers/interaction.ts'; -import { handleLeaseCommands } from './handlers/lease.ts'; -import { handleReactNativeCommands } from './handlers/react-native.ts'; -import { handleRecordTraceCommands } from './handlers/record-trace.ts'; -import { handleSessionCommands } from './handlers/session.ts'; -import { handleSnapshotCommands } from './handlers/snapshot.ts'; import type { LeaseRegistry } from './lease-registry.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './types.ts'; -export async function runRequestHandlerChain(params: { +const loadLeaseHandlerModule = lazyImport(() => import('./handlers/lease.ts')); +const loadSessionHandlerModule = lazyImport(() => import('./handlers/session.ts')); +const loadSnapshotHandlerModule = lazyImport(() => import('./handlers/snapshot.ts')); +const loadReactNativeHandlerModule = lazyImport(() => import('./handlers/react-native.ts')); +const loadRecordTraceHandlerModule = lazyImport(() => import('./handlers/record-trace.ts')); +const loadFindHandlerModule = lazyImport(() => import('./handlers/find.ts')); +const loadInteractionHandlerModule = lazyImport(() => import('./handlers/interaction.ts')); + +type RequestHandlerChainParams = { req: DaemonRequest; sessionName: string; logPath: string; @@ -26,75 +29,153 @@ export async function runRequestHandlerChain(params: { appBundleId?: string, traceLogPath?: string, ) => DaemonCommandContext; -}): Promise { - const { - req, - sessionName, - logPath, - sessionStore, - leaseRegistry, - invoke, - invokeReplayAction, - androidAdbExecutor, - contextFromFlags, - } = params; +}; - const leaseResponse = await handleLeaseCommands({ req, leaseRegistry }); - if (leaseResponse) return leaseResponse; +export async function runRequestHandlerChain( + params: RequestHandlerChainParams, +): Promise { + const { command } = params.req; + if (DAEMON_COMMAND_GROUPS.leaseHandler.has(command)) { + return await runLeaseHandler(params); + } + if (DAEMON_COMMAND_GROUPS.sessionHandler.has(command)) { + return await runSessionHandler(params); + } + if (DAEMON_COMMAND_GROUPS.snapshot.has(command)) { + return await runSnapshotHandler(params); + } + if (DAEMON_COMMAND_GROUPS.reactNativeHandler.has(command)) { + return await runReactNativeHandler(params); + } + if (DAEMON_COMMAND_GROUPS.recordTraceHandler.has(command)) { + return await runRecordTraceHandler(params); + } + if (DAEMON_COMMAND_GROUPS.findHandler.has(command)) { + return await runFindHandler(params); + } + if (DAEMON_COMMAND_GROUPS.interactionHandler.has(command)) { + return await runInteractionHandler(params); + } - const sessionResponse = await handleSessionCommands({ - req, - sessionName, - logPath, - sessionStore, - invoke, - invokeReplayAction, - androidAdbExecutor, - }); - if (sessionResponse) return sessionResponse; + // Commands not claimed by a specialized family continue to generic platform dispatch. + return null; +} - const snapshotResponse = await handleSnapshotCommands({ - req, - sessionName, - logPath, - sessionStore, - }); - if (snapshotResponse) return snapshotResponse; +async function runLeaseHandler(params: RequestHandlerChainParams): Promise { + const { handleLeaseCommands } = await loadLeaseHandlerModule(); + return expectHandlerResponse( + params.req.command, + 'lease', + await handleLeaseCommands({ req: params.req, leaseRegistry: params.leaseRegistry }), + ); +} - const reactNativeResponse = await handleReactNativeCommands({ - req, - sessionName, - logPath, - sessionStore, - contextFromFlags, - }); - if (reactNativeResponse) return reactNativeResponse; +async function runSessionHandler(params: RequestHandlerChainParams): Promise { + const { handleSessionCommands } = await loadSessionHandlerModule(); + return expectHandlerResponse( + params.req.command, + 'session', + await handleSessionCommands({ + req: params.req, + sessionName: params.sessionName, + logPath: params.logPath, + sessionStore: params.sessionStore, + invoke: params.invoke, + invokeReplayAction: params.invokeReplayAction, + androidAdbExecutor: params.androidAdbExecutor, + }), + ); +} - const recordTraceResponse = await handleRecordTraceCommands({ - req, - sessionName, - sessionStore, - logPath, - }); - if (recordTraceResponse) return recordTraceResponse; +async function runSnapshotHandler(params: RequestHandlerChainParams): Promise { + const { handleSnapshotCommands } = await loadSnapshotHandlerModule(); + return expectHandlerResponse( + params.req.command, + 'snapshot', + await handleSnapshotCommands({ + req: params.req, + sessionName: params.sessionName, + logPath: params.logPath, + sessionStore: params.sessionStore, + }), + ); +} - const findResponse = await handleFindCommands({ - req, - sessionName, - logPath, - sessionStore, - invoke, - }); - if (findResponse) return findResponse; +async function runReactNativeHandler(params: RequestHandlerChainParams): Promise { + const { handleReactNativeCommands } = await loadReactNativeHandlerModule(); + return expectHandlerResponse( + params.req.command, + 'react-native', + await handleReactNativeCommands({ + req: params.req, + sessionName: params.sessionName, + logPath: params.logPath, + sessionStore: params.sessionStore, + contextFromFlags: params.contextFromFlags, + }), + ); +} - const interactionResponse = await handleInteractionCommands({ - req, - sessionName, - logPath, - sessionStore, - contextFromFlags, - }); - if (interactionResponse) return interactionResponse; +async function runRecordTraceHandler(params: RequestHandlerChainParams): Promise { + const { handleRecordTraceCommands } = await loadRecordTraceHandlerModule(); + return expectHandlerResponse( + params.req.command, + 'record-trace', + await handleRecordTraceCommands({ + req: params.req, + sessionName: params.sessionName, + sessionStore: params.sessionStore, + logPath: params.logPath, + }), + ); +} - return null; +async function runFindHandler(params: RequestHandlerChainParams): Promise { + const { handleFindCommands } = await loadFindHandlerModule(); + return expectHandlerResponse( + params.req.command, + 'find', + await handleFindCommands({ + req: params.req, + sessionName: params.sessionName, + logPath: params.logPath, + sessionStore: params.sessionStore, + invoke: params.invoke, + }), + ); +} + +async function runInteractionHandler(params: RequestHandlerChainParams): Promise { + const { handleInteractionCommands } = await loadInteractionHandlerModule(); + return expectHandlerResponse( + params.req.command, + 'interaction', + await handleInteractionCommands({ + req: params.req, + sessionName: params.sessionName, + logPath: params.logPath, + sessionStore: params.sessionStore, + contextFromFlags: params.contextFromFlags, + }), + ); +} + +function lazyImport(load: () => Promise): () => Promise { + let modulePromise: Promise | undefined; + return () => { + modulePromise ??= load(); + return modulePromise; + }; +} + +function expectHandlerResponse( + command: string, + handlerFamily: string, + response: DaemonResponse | null, +): DaemonResponse { + if (response) return response; + throw new AppError( + 'INTERNAL_ERROR', + `Daemon handler routing mismatch: ${handlerFamily} handler matched command "${command}" but returned no response.`, + ); } From 9724a2f723392884b9e87f6fa166f3bcfcdb27eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 16:31:49 +0200 Subject: [PATCH 2/6] perf: thin command metadata paths --- src/__tests__/cli-client-commands.test.ts | 1 + src/__tests__/cli-grammar.test.ts | 2 +- src/batch-contract.ts | 1 + src/bin.ts | 13 +- src/cli.ts | 2 +- src/cli/auth-session.ts | 2 +- src/cli/batch-steps.ts | 4 +- src/cli/cloud-connection-profile.ts | 2 +- src/cli/commands/connection-runtime.ts | 2 +- src/cli/commands/connection.ts | 2 +- src/cli/commands/generic.ts | 101 ++--- src/cli/commands/react-devtools.ts | 2 +- src/cli/commands/router-types.ts | 2 +- src/cli/commands/router.ts | 37 +- src/cli/commands/screenshot.ts | 2 +- src/cli/commands/shared.ts | 2 +- src/command-catalog.ts | 59 +++ src/commands/batch-command-metadata.ts | 157 +++++++ src/commands/batch-command.ts | 151 +------ src/commands/cli-grammar/apps.ts | 2 +- src/commands/cli-grammar/capture.ts | 2 +- src/commands/cli-grammar/common.ts | 2 +- src/commands/cli-grammar/gesture.ts | 2 +- src/commands/cli-grammar/registry.ts | 4 +- src/commands/cli-grammar/selectors.ts | 2 +- src/commands/cli-grammar/types.ts | 2 +- src/commands/cli-output.ts | 6 +- src/commands/cli-runner.ts | 2 +- src/commands/client-command-contracts.ts | 401 +++--------------- src/commands/client-command-metadata.ts | 239 +++++++++++ src/commands/command-contract.ts | 25 +- src/commands/command-descriptions.ts | 64 +++ src/commands/command-metadata.ts | 38 ++ src/commands/command-projection.ts | 20 +- src/commands/command-surface.ts | 26 +- src/commands/field-command-contract.ts | 23 +- src/commands/interaction-command-contracts.ts | 348 +++------------ src/commands/interaction-command-metadata.ts | 286 +++++++++++++ src/core/batch.ts | 3 +- src/core/dispatch-context.ts | 2 +- src/core/dispatch-interactions.ts | 15 +- src/core/dispatch-resolve.ts | 16 +- src/core/dispatch.ts | 18 +- src/core/interactors.ts | 20 +- src/daemon-runtime.ts | 4 +- src/daemon/request-platform-providers.ts | 60 +-- src/daemon/transport.ts | 2 +- src/mcp/command-tools.ts | 49 ++- src/platforms/ios/app-filter.ts | 2 +- src/platforms/ios/app-info.ts | 5 + src/platforms/ios/apps.ts | 2 +- src/platforms/ios/devicectl.ts | 13 +- src/platforms/ios/macos-apps.ts | 2 +- src/platforms/ios/macos-host-provider.ts | 4 +- src/platforms/ios/tool-provider-types.ts | 38 ++ src/platforms/ios/tool-provider.ts | 55 +-- src/remote-connection-state.ts | 2 +- src/utils/__tests__/interactors.test.ts | 6 +- src/utils/cli-command-overrides.ts | 2 +- src/utils/cli-config.ts | 2 +- src/utils/cli-options.ts | 2 +- src/utils/command-schema.ts | 4 +- src/utils/remote-config.ts | 2 +- src/utils/session-binding.ts | 2 +- 64 files changed, 1278 insertions(+), 1092 deletions(-) create mode 100644 src/batch-contract.ts create mode 100644 src/commands/batch-command-metadata.ts create mode 100644 src/commands/client-command-metadata.ts create mode 100644 src/commands/command-descriptions.ts create mode 100644 src/commands/command-metadata.ts create mode 100644 src/commands/interaction-command-metadata.ts create mode 100644 src/platforms/ios/app-info.ts create mode 100644 src/platforms/ios/tool-provider-types.ts diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index a833586d4..0f843df4b 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -569,6 +569,7 @@ test('apps command defaults to user-installed and prints discovery hint', async help: false, version: false, platform: 'android', + appsFilter: 'user-installed', }, client, }); diff --git a/src/__tests__/cli-grammar.test.ts b/src/__tests__/cli-grammar.test.ts index 7b1a13e49..c1de4e3d2 100644 --- a/src/__tests__/cli-grammar.test.ts +++ b/src/__tests__/cli-grammar.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { DAEMON_COMMAND_GROUPS, PUBLIC_COMMANDS } from '../command-catalog.ts'; import { readInputFromCli } from '../commands/cli-grammar.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; const BASE_FLAGS: CliFlags = { json: false, diff --git a/src/batch-contract.ts b/src/batch-contract.ts new file mode 100644 index 000000000..0b92f4e01 --- /dev/null +++ b/src/batch-contract.ts @@ -0,0 +1 @@ +export const DEFAULT_BATCH_MAX_STEPS = 100; diff --git a/src/bin.ts b/src/bin.ts index 392333154..310b1aa0e 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -11,7 +11,7 @@ if (runFastPath(argv)) { } function runFastPath(argv: string[]): boolean { - return runVersionFastPath(argv) || runHelpFastPath(argv); + return runVersionFastPath(argv) || runNoCommandFastPath(argv) || runHelpFastPath(argv); } function runVersionFastPath(argv: string[]): boolean { @@ -24,6 +24,17 @@ function runVersionFastPath(argv: string[]): boolean { return true; } +function runNoCommandFastPath(argv: string[]): boolean { + if (argv.length !== 0) return false; + import('./utils/args.ts') + .then(({ usage }) => { + process.stdout.write(`${usage()}\n`); + process.exit(1); + }) + .catch(handleStartupError); + return true; +} + function runHelpFastPath(argv: string[]): boolean { const helpTarget = resolveSimpleHelpTarget(argv); if (helpTarget === undefined) return false; diff --git a/src/cli.ts b/src/cli.ts index df5b52224..3675be0dd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,7 @@ import { resolveCliOptions } from './utils/cli-options.ts'; import { maybeRunUpgradeNotifier } from './utils/update-check.ts'; import { resolveRemoteConnectionDefaults } from './remote-connection-state.ts'; import { resolveRemoteAuthForCli } from './cli/auth-session.ts'; -import type { CliFlags, FlagKey } from './utils/command-schema.ts'; +import type { CliFlags, FlagKey } from './utils/cli-flags.ts'; import type { SessionRuntimeHints } from './contracts.ts'; type CliDeps = { diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index 54fbbf3a7..4ce48a1d2 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { runCmd } from '../utils/exec.ts'; import { AppError } from '../utils/errors.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; const DEFAULT_CLOUD_BASE_URL = 'https://cloud.agent-device.dev'; const DEVICE_AUTH_START_PATH = '/api/control-plane/device-auth/start'; diff --git a/src/cli/batch-steps.ts b/src/cli/batch-steps.ts index 70b56a197..783ea12f6 100644 --- a/src/cli/batch-steps.ts +++ b/src/cli/batch-steps.ts @@ -1,7 +1,7 @@ import type { BatchStep } from '../client-types.ts'; import { readInputFromCli } from '../commands/cli-grammar.ts'; -import { isCommandName, type CommandName } from '../commands/command-surface.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; +import { isCommandName, type CommandName } from '../commands/command-metadata.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; import { AppError } from '../utils/errors.ts'; type LegacyCliBatchStep = { diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index 68cfaf150..bb4db889b 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -5,7 +5,7 @@ import { resolveRemoteConfigProfile } from '../remote-config.ts'; import type { RemoteConfigProfile, ResolvedRemoteConfigProfile } from '../remote-config-schema.ts'; import { profileToCliFlags } from '../utils/remote-config.ts'; import { AppError, asAppError } from '../utils/errors.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; import { resolveCloudAccessForConnect } from './auth-session.ts'; const CONNECTION_PROFILE_PATH = '/api/control-plane/connection-profile'; diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 11930be38..844105c4a 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -14,7 +14,7 @@ import { profileToCliFlags } from '../../utils/remote-config.ts'; import type { BatchStep } from '../../client-types.ts'; import { AppError } from '../../utils/errors.ts'; import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import type { AgentDeviceClient, Lease } from '../../client.ts'; const leaseDeferredCommands = new Set([ diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 75c014037..ed9f14bfc 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -22,7 +22,7 @@ import { } from './connection-runtime.ts'; import { writeCommandOutput } from './shared.ts'; import type { LeaseBackend } from '../../contracts.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import type { ClientCommandHandler } from './router-types.ts'; export const connectCommand: ClientCommandHandler = async ({ flags, client }) => { diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 19e0cda49..7094a54d9 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -1,68 +1,43 @@ -import type { AgentDeviceClient, CommandRequestResult } from '../../client.ts'; +import type { CommandRequestResult } from '../../client.ts'; import { announceReplayTestRun, renderReplayTestResponse } from '../../cli-test.ts'; -import { listCliOutputCommandNames } from '../../commands/cli-output.ts'; -import { runCliCommand, runCliCommandWithOutput } from '../../commands/cli-runner.ts'; -import { listCommandNames, type CommandName } from '../../commands/command-surface.ts'; +import { runCliCommandWithOutput } from '../../commands/cli-runner.ts'; +import type { CommandName } from '../../commands/command-metadata.ts'; import type { CliOutput } from '../../commands/command-contract.ts'; import type { ReplaySuiteResult } from '../../daemon/types.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; import { writeCommandOutput } from './shared.ts'; -import type { PublicCommandName } from '../../command-catalog.ts'; -import type { ClientCommandHandler } from './router-types.ts'; +import type { ClientBackedCliCommandName } from '../../command-catalog.ts'; +import type { ClientCommandParams } from './router-types.ts'; -type GenericClientCommandRunner = (params: { - client: AgentDeviceClient; - positionals: string[]; - flags: CliFlags; -}) => Promise; - -const formattedCommandHandlers = Object.fromEntries( - listCliOutputCommandNames().map((command) => [command, createFormattedHandler(command)]), -) as Partial>; - -export const dedicatedCommandHandlers = formattedCommandHandlers; - -const genericCommands = listCommandNames().filter(isGenericCliCommand); - -const genericClientCommandRunners = Object.fromEntries( - genericCommands.map((command) => [ - command, - async ({ client, positionals, flags }) => { - if (command === 'test') { - announceReplayTestRun({ json: flags.json }); - } - return await runCliCommand({ client, command, positionals, flags }); - }, - ]), -) as Record<(typeof genericCommands)[number], GenericClientCommandRunner>; - -export const genericClientCommandHandlers = Object.fromEntries( - Object.entries(genericClientCommandRunners).map(([command, run]) => [ - command, - createGenericClientCommandHandler( - command as PublicCommandName, - run as GenericClientCommandRunner, - ), - ]), -) as { [TCommand in keyof typeof genericClientCommandRunners]: ClientCommandHandler }; - -function createGenericClientCommandHandler( - command: PublicCommandName, - run: GenericClientCommandRunner, -): ClientCommandHandler { - return async ({ positionals, flags, client }) => { - const data = await run({ client, positionals, flags }); - const exitCode = writeGenericCliOutput(command, flags, data); +export async function runGenericClientBackedCommand({ + command, + positionals, + flags, + client, +}: ClientCommandParams & { command: ClientBackedCliCommandName }): Promise { + if (command === 'test') { + announceReplayTestRun({ json: flags.json }); + } + const { result, cliOutput } = await runCliCommandWithOutput({ + client, + command: command as CommandName, + positionals, + flags, + }); + if (cliOutput) { + writeCliOutput(flags, cliOutput); + } else { + const exitCode = writeGenericCliOutput(command, flags, result); if (exitCode !== 0) { process.exit(exitCode); } - return true; - }; + } + return true; } function writeGenericCliOutput( - command: PublicCommandName, + command: ClientBackedCliCommandName, flags: CliFlags, data: CommandRequestResult, ): number { @@ -80,22 +55,6 @@ function writeGenericCliOutput( return 0; } -function createFormattedHandler(command: CommandName): ClientCommandHandler { - return async ({ positionals, flags, client }) => { - const { cliOutput } = await runCliCommandWithOutput({ - client, - command, - positionals, - flags, - }); - if (!cliOutput) { - throw new Error(`Missing CLI output formatter for command: ${command}`); - } - writeCliOutput(flags, cliOutput); - return true; - }; -} - function writeCliOutput(flags: CliFlags, output: CliOutput): void { if (!flags.json && output.stderr) { process.stderr.write(output.stderr); @@ -106,7 +65,3 @@ function writeCliOutput(flags: CliFlags, output: CliOutput): void { () => output.text, ); } - -function isGenericCliCommand(command: CommandName): boolean { - return !(command in formattedCommandHandlers) && command !== 'screenshot' && command !== 'diff'; -} diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index db8004e0c..257efd3e0 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -4,7 +4,7 @@ import { stopReactDevtoolsCompanion, } from '../../client-react-devtools-companion.ts'; import { AppError } from '../../utils/errors.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; diff --git a/src/cli/commands/router-types.ts b/src/cli/commands/router-types.ts index 6df7c776d..c9d71a236 100644 --- a/src/cli/commands/router-types.ts +++ b/src/cli/commands/router-types.ts @@ -1,4 +1,4 @@ -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import type { AgentDeviceClient } from '../../client.ts'; import type { CliCommandName } from '../../command-catalog.ts'; diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index ec6c12654..0d49d5d9d 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -1,10 +1,13 @@ -import { applyCommandDefaults, type CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import type { AgentDeviceClient } from '../../client.ts'; +import { + isClientBackedCliCommandName, + type ClientBackedCliCommandName, +} from '../../command-catalog.ts'; import { connectCommand, connectionCommand, disconnectCommand } from './connection.ts'; import { authCommand } from './auth.ts'; import { screenshotCommand, diffCommand } from './screenshot.ts'; -import { dedicatedCommandHandlers, genericClientCommandHandlers } from './generic.ts'; -import type { ClientCommandHandlerMap } from './router-types.ts'; +import type { ClientCommandHandlerMap, ClientCommandParams } from './router-types.ts'; export type { ClientCommandHandler, @@ -21,21 +24,29 @@ const dedicatedCliCommandHandlers = { diff: diffCommand, } satisfies ClientCommandHandlerMap; -const clientCommandHandlers: ClientCommandHandlerMap = { - ...dedicatedCliCommandHandlers, - ...dedicatedCommandHandlers, - ...genericClientCommandHandlers, -}; - export async function tryRunClientBackedCommand(params: { command: string; positionals: string[]; flags: CliFlags; client: AgentDeviceClient; }): Promise { - const handler = clientCommandHandlers[params.command as keyof typeof clientCommandHandlers]; - if (!handler) return false; const flags = { ...params.flags }; - applyCommandDefaults(params.command, flags); - return await handler({ ...params, flags }); + const dedicatedHandler = + dedicatedCliCommandHandlers[params.command as keyof typeof dedicatedCliCommandHandlers]; + if (dedicatedHandler) { + return await dedicatedHandler({ ...params, flags }); + } + if (isClientBackedCliCommandName(params.command)) { + return await runGenericClientBackedCommand({ ...params, command: params.command, flags }); + } + return false; +} + +async function runGenericClientBackedCommand( + params: { + command: ClientBackedCliCommandName; + } & ClientCommandParams, +): Promise { + const { runGenericClientBackedCommand } = await import('./generic.ts'); + return await runGenericClientBackedCommand(params); } diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 271aa2713..6576da1ab 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -6,7 +6,7 @@ import type { AgentDeviceClient, CaptureScreenshotResult } from '../../client.ts import { createLocalArtifactAdapter } from '../../io.ts'; import { createAgentDevice, localCommandPolicy } from '../../runtime.ts'; import { runCliCommand } from '../../commands/cli-runner.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; diff --git a/src/cli/commands/shared.ts b/src/cli/commands/shared.ts index 7728a34ba..e58372081 100644 --- a/src/cli/commands/shared.ts +++ b/src/cli/commands/shared.ts @@ -1,4 +1,4 @@ -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { printJson } from '../../utils/output.ts'; export function writeCommandOutput( diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 6070b2843..33d1e4051 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -72,6 +72,55 @@ export const GESTURE_SUBCOMMAND_ERROR = `gesture requires one of: ${GESTURE_SUBC export type PublicCommandName = (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS]; export type LocalCliCommandName = (typeof LOCAL_CLI_COMMANDS)[keyof typeof LOCAL_CLI_COMMANDS]; export type CliCommandName = PublicCommandName | LocalCliCommandName; +export type ClientBackedCliCommandName = + | PublicCommandName + | typeof LOCAL_CLI_COMMANDS.metro + | typeof LOCAL_CLI_COMMANDS.session; + +export const BATCH_COMMAND_NAMES = [ + PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.boot, + PUBLIC_COMMANDS.apps, + PUBLIC_COMMANDS.open, + PUBLIC_COMMANDS.close, + PUBLIC_COMMANDS.install, + PUBLIC_COMMANDS.reinstall, + PUBLIC_COMMANDS.installFromSource, + PUBLIC_COMMANDS.push, + PUBLIC_COMMANDS.triggerAppEvent, + PUBLIC_COMMANDS.snapshot, + PUBLIC_COMMANDS.screenshot, + PUBLIC_COMMANDS.diff, + PUBLIC_COMMANDS.wait, + PUBLIC_COMMANDS.alert, + PUBLIC_COMMANDS.settings, + PUBLIC_COMMANDS.click, + PUBLIC_COMMANDS.press, + PUBLIC_COMMANDS.longPress, + PUBLIC_COMMANDS.swipe, + PUBLIC_COMMANDS.focus, + PUBLIC_COMMANDS.type, + PUBLIC_COMMANDS.fill, + PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.get, + PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.is, + PUBLIC_COMMANDS.find, + PUBLIC_COMMANDS.perf, + PUBLIC_COMMANDS.logs, + PUBLIC_COMMANDS.network, + PUBLIC_COMMANDS.record, + PUBLIC_COMMANDS.trace, + PUBLIC_COMMANDS.test, + PUBLIC_COMMANDS.appState, + PUBLIC_COMMANDS.back, + PUBLIC_COMMANDS.home, + PUBLIC_COMMANDS.rotate, + PUBLIC_COMMANDS.appSwitcher, + PUBLIC_COMMANDS.keyboard, + PUBLIC_COMMANDS.clipboard, + PUBLIC_COMMANDS.reactNative, +] as const; const MCP_UNEXPOSED_CLI_COMMANDS = commandSet( LOCAL_CLI_COMMANDS.auth, @@ -229,6 +278,16 @@ export function listCliCommandNames(): CliCommandName[] { return [...Object.values(PUBLIC_COMMANDS), ...Object.values(LOCAL_CLI_COMMANDS)].sort(); } +export function isClientBackedCliCommandName( + command: string, +): command is ClientBackedCliCommandName { + return ( + Object.values(PUBLIC_COMMANDS).includes(command as PublicCommandName) || + command === LOCAL_CLI_COMMANDS.metro || + command === LOCAL_CLI_COMMANDS.session + ); +} + export function listMcpExposedCommandNames(): CliCommandName[] { return listCliCommandNames().filter((command) => !MCP_UNEXPOSED_CLI_COMMANDS.has(command)); } diff --git a/src/commands/batch-command-metadata.ts b/src/commands/batch-command-metadata.ts new file mode 100644 index 000000000..ea2530aab --- /dev/null +++ b/src/commands/batch-command-metadata.ts @@ -0,0 +1,157 @@ +import { BATCH_COMMAND_NAMES } from '../command-catalog.ts'; +import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts'; +import { + defineCommandMetadata, + type CommandMetadata, + type JsonSchema, +} from './command-contract.ts'; +import { + assertAllowedKeys, + customField, + enumField, + fieldsInputSchema, + integerField, + readFieldInput, + requiredEnum, + requiredField, + stringField, + type CommandFieldMap, + type InferCommandInput, +} from './command-input.ts'; + +export type BatchCommandStep = { + command: string; + input: Record; + runtime?: unknown; +}; + +export type BatchInput = InferCommandInput & { + steps: BatchCommandStep[]; + onError?: 'stop'; + maxSteps?: number; + out?: string; +}; + +export function createBatchCommandMetadata( + nestedCommands: readonly string[] = BATCH_COMMAND_NAMES, +): CommandMetadata<'batch', BatchInput> { + const fields = batchFields(nestedCommands); + return defineCommandMetadata({ + name: 'batch', + description: 'Run multiple structured command steps in one daemon request.', + inputSchema: fieldsInputSchema(fields), + readInput: (input) => readBatchInput(input, fields), + }); +} + +function batchFields(nestedCommands: readonly string[]) { + return { + steps: requiredField( + customField( + { + type: 'array', + description: + 'Structured batch steps. Each step uses a command name and the same input object as that command tool.', + items: batchStepSchema(nestedCommands), + }, + (record, key) => readBatchSteps(record[key], nestedCommands), + ), + ), + onError: enumField(['stop'] as const, 'Batch failure policy.'), + maxSteps: integerField('Maximum number of steps accepted for this batch.', { + min: 1, + max: 1000, + }), + out: stringField('Optional output path for command artifacts.'), + }; +} + +function batchStepSchema(nestedCommands: readonly string[]): JsonSchema { + return { + type: 'object', + properties: { + command: { + type: 'string', + enum: nestedCommands, + description: 'Command name to run with structured input.', + }, + input: { + type: 'object', + additionalProperties: true, + description: + 'Structured command input for the nested command. Use the matching MCP tool schema for this object.', + }, + runtime: { + type: 'object', + additionalProperties: true, + description: 'Optional per-step runtime payload.', + }, + }, + required: ['command', 'input'], + additionalProperties: false, + }; +} + +function readBatchInput(input: unknown, fields: ReturnType): BatchInput { + const parsed = readFieldInput(input, fields); + const maxSteps = parsed.maxSteps ?? DEFAULT_BATCH_MAX_STEPS; + if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 1000) { + throw new Error(`Invalid batch maxSteps: ${String(parsed.maxSteps)}`); + } + if (parsed.steps.length > maxSteps) { + throw new Error(`batch has ${parsed.steps.length} steps; max allowed is ${maxSteps}.`); + } + return { + ...parsed, + }; +} + +function readBatchSteps(steps: unknown, nestedCommands: readonly string[]): BatchCommandStep[] { + if (!Array.isArray(steps)) { + throw new Error('Expected steps to be an array.'); + } + return steps.map((step, index) => readBatchStep(step, index + 1, nestedCommands)); +} + +function readBatchStep( + step: unknown, + stepNumber: number, + nestedCommands: readonly string[], +): BatchCommandStep { + const record = readBatchStepRecord(step, stepNumber); + assertAllowedKeys(record, ['command', 'input', 'runtime'], `Batch step ${stepNumber}`); + return { + command: requiredEnum(record, 'command', nestedCommands), + input: readBatchStepInput(record, stepNumber), + ...readBatchStepRuntimeProperty(record, stepNumber), + }; +} + +function readBatchStepRecord(step: unknown, stepNumber: number): Record { + if (!step || typeof step !== 'object' || Array.isArray(step)) { + throw new Error(`Invalid batch step ${stepNumber}.`); + } + return step as Record; +} + +function readBatchStepInput(record: Record, stepNumber: number) { + const input = record.input; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error(`Batch step ${stepNumber} input must be an object.`); + } + return input as Record; +} + +function readBatchStepRuntimeProperty( + record: Record, + stepNumber: number, +): Pick { + const runtime = record.runtime; + if ( + runtime !== undefined && + (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) + ) { + throw new Error(`Batch step ${stepNumber} runtime must be an object.`); + } + return runtime === undefined ? {} : { runtime }; +} diff --git a/src/commands/batch-command.ts b/src/commands/batch-command.ts index f05dc6b1a..4c9029a9d 100644 --- a/src/commands/batch-command.ts +++ b/src/commands/batch-command.ts @@ -1,152 +1,15 @@ -import type { BatchRunOptions, BatchStep } from '../client-types.ts'; -import { DEFAULT_BATCH_MAX_STEPS } from '../core/batch.ts'; -import { defineCommand, type JsonSchema } from './command-contract.ts'; +import type { BatchRunOptions } from '../client-types.ts'; +import { defineExecutableCommand } from './command-contract.ts'; import { type DaemonCommandName } from './command-projection.ts'; -import { - assertAllowedKeys, - commonToClientOptions, - customField, - enumField, - fieldsInputSchema, - integerField, - readFieldInput, - requiredEnum, - requiredField, - stringField, - type InferCommandInput, - type CommandFieldMap, -} from './command-input.ts'; - -type BatchInput = InferCommandInput & { - steps: BatchStep[]; - onError?: 'stop'; - maxSteps?: number; - out?: string; -}; +import { commonToClientOptions } from './command-input.ts'; +import { createBatchCommandMetadata, type BatchInput } from './batch-command-metadata.ts'; export function createBatchCommand( nestedCommands: readonly TCommand[], ) { - const fields = batchFields(nestedCommands); - return defineCommand({ - name: 'batch', - description: 'Run multiple structured command steps in one daemon request.', - inputSchema: fieldsInputSchema(fields), - readInput: (input) => readBatchInput(input, fields), - run: (client, input) => client.batch.run(toBatchOptions(input)), - }); -} - -function batchFields(nestedCommands: readonly DaemonCommandName[]) { - return { - steps: requiredField( - customField( - { - type: 'array', - description: - 'Structured batch steps. Each step uses a command name and the same input object as that command tool.', - items: batchStepSchema(nestedCommands), - }, - (record, key) => readBatchSteps(record[key], nestedCommands), - ), - ), - onError: enumField(['stop'] as const, 'Batch failure policy.'), - maxSteps: integerField('Maximum number of steps accepted for this batch.', { - min: 1, - max: 1000, - }), - out: stringField('Optional output path for command artifacts.'), - }; -} - -function batchStepSchema(nestedCommands: readonly DaemonCommandName[]): JsonSchema { - return { - type: 'object', - properties: { - command: { - type: 'string', - enum: nestedCommands, - description: 'Command name to run with structured input.', - }, - input: { - type: 'object', - additionalProperties: true, - description: - 'Structured command input for the nested command. Use the matching MCP tool schema for this object.', - }, - runtime: { - type: 'object', - additionalProperties: true, - description: 'Optional per-step runtime payload.', - }, - }, - required: ['command', 'input'], - additionalProperties: false, - }; -} - -function readBatchInput(input: unknown, fields: ReturnType): BatchInput { - const parsed = readFieldInput(input, fields); - const maxSteps = parsed.maxSteps ?? DEFAULT_BATCH_MAX_STEPS; - if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 1000) { - throw new Error(`Invalid batch maxSteps: ${String(parsed.maxSteps)}`); - } - if (parsed.steps.length > maxSteps) { - throw new Error(`batch has ${parsed.steps.length} steps; max allowed is ${maxSteps}.`); - } - return { - ...parsed, - }; -} - -function readBatchSteps(steps: unknown, nestedCommands: readonly DaemonCommandName[]): BatchStep[] { - if (!Array.isArray(steps)) { - throw new Error('Expected steps to be an array.'); - } - return steps.map((step, index) => readBatchStep(step, index + 1, nestedCommands)); -} - -function readBatchStep( - step: unknown, - stepNumber: number, - nestedCommands: readonly DaemonCommandName[], -): BatchStep { - const record = readBatchStepRecord(step, stepNumber); - assertAllowedKeys(record, ['command', 'input', 'runtime'], `Batch step ${stepNumber}`); - return { - command: requiredEnum(record, 'command', nestedCommands), - input: readBatchStepInput(record, stepNumber), - ...readBatchStepRuntimeProperty(record, stepNumber), - }; -} - -function readBatchStepRecord(step: unknown, stepNumber: number): Record { - if (!step || typeof step !== 'object' || Array.isArray(step)) { - throw new Error(`Invalid batch step ${stepNumber}.`); - } - return step as Record; -} - -function readBatchStepInput(record: Record, stepNumber: number) { - const input = record.input; - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new Error(`Batch step ${stepNumber} input must be an object.`); - } - return input as Record; -} - -function readBatchStepRuntimeProperty( - record: Record, - stepNumber: number, -): Pick { - const runtime = record.runtime; - if ( - runtime !== undefined && - (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) - ) { - throw new Error(`Batch step ${stepNumber} runtime must be an object.`); - } - return runtime === undefined ? {} : { runtime }; + return defineExecutableCommand(createBatchCommandMetadata(nestedCommands), (client, input) => + client.batch.run(toBatchOptions(input)), + ); } function toBatchOptions(input: BatchInput): BatchRunOptions { diff --git a/src/commands/cli-grammar/apps.ts b/src/commands/cli-grammar/apps.ts index 8e59d7e37..aa1784f8a 100644 --- a/src/commands/cli-grammar/apps.ts +++ b/src/commands/cli-grammar/apps.ts @@ -1,6 +1,6 @@ import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { AppPushOptions, AppTriggerEventOptions } from '../../client-types.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; import { assertResolvedAppsFilter } from '../app-inventory-contract.ts'; diff --git a/src/commands/cli-grammar/capture.ts b/src/commands/cli-grammar/capture.ts index 30ab75747..b0ca03b7a 100644 --- a/src/commands/cli-grammar/capture.ts +++ b/src/commands/cli-grammar/capture.ts @@ -7,7 +7,7 @@ import type { } from '../../client-types.ts'; import { parseTimeout } from '../../daemon/handlers/parse-utils.ts'; import { splitSelectorFromArgs, tryParseSelectorChain } from '../../daemon/selectors.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; import { readLocationCoordinate } from '../../utils/location-coordinates.ts'; import { diff --git a/src/commands/cli-grammar/common.ts b/src/commands/cli-grammar/common.ts index 93ead593a..47ef3c75f 100644 --- a/src/commands/cli-grammar/common.ts +++ b/src/commands/cli-grammar/common.ts @@ -4,7 +4,7 @@ import type { InternalRequestOptions, } from '../../client-types.ts'; import { splitSelectorFromArgs } from '../../daemon/selectors.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; import { compactRecord } from '../command-input.ts'; import type { diff --git a/src/commands/cli-grammar/gesture.ts b/src/commands/cli-grammar/gesture.ts index 6ded85308..6fe2c7b75 100644 --- a/src/commands/cli-grammar/gesture.ts +++ b/src/commands/cli-grammar/gesture.ts @@ -1,6 +1,6 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { FlingOptions, RotateGestureOptions } from '../../client-types.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; import { commonInputFromFlags, diff --git a/src/commands/cli-grammar/registry.ts b/src/commands/cli-grammar/registry.ts index f04eaacbf..99ad268ba 100644 --- a/src/commands/cli-grammar/registry.ts +++ b/src/commands/cli-grammar/registry.ts @@ -1,4 +1,4 @@ -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { appCliReaders } from './apps.ts'; import { captureCliReaders } from './capture.ts'; import { commonInputFromFlags } from './common.ts'; @@ -10,7 +10,7 @@ import { replayCliReaders } from './replay.ts'; import { selectorCliReaders } from './selectors.ts'; import { systemCliReaders } from './system.ts'; import type { CliReader } from './types.ts'; -import type { CommandName } from '../command-surface.ts'; +import type { CommandName } from '../command-metadata.ts'; const cliReaders = { ...appCliReaders, diff --git a/src/commands/cli-grammar/selectors.ts b/src/commands/cli-grammar/selectors.ts index 34e4fdbc7..cbf0dbba8 100644 --- a/src/commands/cli-grammar/selectors.ts +++ b/src/commands/cli-grammar/selectors.ts @@ -1,6 +1,6 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { FindOptions, IsOptions } from '../../client-types.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; import { direct, diff --git a/src/commands/cli-grammar/types.ts b/src/commands/cli-grammar/types.ts index 03877618a..cc8952ef4 100644 --- a/src/commands/cli-grammar/types.ts +++ b/src/commands/cli-grammar/types.ts @@ -1,5 +1,5 @@ import type { InteractionTarget, InternalRequestOptions } from '../../client-types.ts'; -import type { CliFlags } from '../../utils/command-schema.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; export type DaemonCommandRequest = { command: string; diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index 8cb65ee0a..aac4361c8 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -1,5 +1,5 @@ import type { CommandRequestResult } from '../client.ts'; -import type { CommandName } from './command-surface.ts'; +import type { CommandName } from './command-metadata.ts'; import type { CliOutput } from './command-contract.ts'; import { appStateCliOutput, @@ -89,10 +89,6 @@ const cliOutputFormatters: Partial> = { metroCliOutput({ result, action: input.action as string | undefined }), }; -export function listCliOutputCommandNames(): CommandName[] { - return Object.keys(cliOutputFormatters) as CommandName[]; -} - export function formatCliOutput(params: { name: CommandName; input: unknown; diff --git a/src/commands/cli-runner.ts b/src/commands/cli-runner.ts index e117a4ec3..4bead825b 100644 --- a/src/commands/cli-runner.ts +++ b/src/commands/cli-runner.ts @@ -3,7 +3,7 @@ import { formatCliOutput } from './cli-output.ts'; import { readInputFromCli } from './cli-grammar.ts'; import { runCommand, type CommandName } from './command-surface.ts'; import type { CliOutput } from './command-contract.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; type CliRunOptions = { client: AgentDeviceClient; diff --git a/src/commands/client-command-contracts.ts b/src/commands/client-command-contracts.ts index 38bac884b..d1912bbe5 100644 --- a/src/commands/client-command-contracts.ts +++ b/src/commands/client-command-contracts.ts @@ -9,356 +9,75 @@ import type { SettingsUpdateOptions, WaitCommandOptions, } from '../client-types.ts'; -import type { DaemonInstallSource } from '../contracts.ts'; -import { - booleanSchema, - booleanField, - enumField, - integerField, - integerSchema, - jsonSchemaField, - looseObjectField, - looseObjectSchema, - numberField, - optionalEnum, - requiredField, - stringArrayField, - stringField, - stringSchema, -} from './command-input.ts'; -import { defineFieldCommand } from './field-command-contract.ts'; +import { defineExecutableCommand } from './command-contract.ts'; +import { optionalEnum } from './command-input.ts'; +import { clientCommandMetadata } from './client-command-metadata.ts'; -const SURFACE_VALUES = ['app', 'frontmost-app', 'desktop', 'menubar'] as const; const WAIT_KIND_VALUES = ['duration', 'text', 'ref', 'selector'] as const; -const ALERT_ACTION_VALUES = ['get', 'accept', 'dismiss', 'wait'] as const; -const BACK_MODE_VALUES = ['in-app', 'system'] as const; -const ORIENTATION_VALUES = [ - 'portrait', - 'portrait-upside-down', - 'landscape-left', - 'landscape-right', -] as const; -const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; -const LOG_ACTION_VALUES = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const; -const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; -const NETWORK_INCLUDE_VALUES = ['summary', 'headers', 'body', 'all'] as const; -const START_STOP_VALUES = ['start', 'stop'] as const; -const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; -const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; +type ClientCommandMetadata = (typeof clientCommandMetadata)[number]; +type ClientCommandName = ClientCommandMetadata['name']; type MetroInput = { action: 'prepare' | 'reload' } & MetroPrepareOptions & MetroReloadOptions; export const clientCommandDefinitions = [ - defineFieldCommand('devices', 'List available devices.', {}, (client, input) => - client.devices.list(input), - ), - defineFieldCommand( - 'boot', - 'Boot or prepare a selected device without using CLI positional arguments.', - { headless: booleanField('Boot without showing simulator UI when supported.') }, - (client, input) => client.devices.boot(input), - ), - defineFieldCommand( - 'apps', - 'List installed apps.', - { appsFilter: enumField(['user-installed', 'all']) }, - (client, input) => client.apps.list(input), - ), - defineFieldCommand( - 'session', - 'List active sessions.', - { action: enumField(['list']) }, - async (client, { action: _action, ...input }) => ({ - sessions: await client.sessions.list(input), - }), - ), - defineFieldCommand( - 'open', - 'Open an app, deep link, URL, or platform surface.', - { - app: stringField('App name, bundle id, package, or URL.'), - url: stringField('Optional URL passed with an app shell.'), - surface: enumField(SURFACE_VALUES), - activity: stringField('Android activity name.'), - launchConsole: stringField('Launch console mode.'), - launchArgs: stringArrayField( - 'Launch arguments forwarded verbatim to the platform launch command.', - ), - relaunch: booleanField('Force relaunch.'), - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), - noRecord: booleanField('Do not record this action.'), - }, - (client, input) => client.apps.open(input), - ), - defineFieldCommand( - 'close', - 'Close an app or end the active session.', - { - app: stringField('Optional app to close.'), - shutdown: booleanField('Shutdown the session/device where supported.'), - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), - }, - (client, input) => - input.app ? client.apps.close(input) : client.sessions.close(withoutApp(input)), - ), - defineFieldCommand( - 'install', - 'Install an app binary.', - { - app: requiredField(stringField()), - appPath: requiredField(stringField('Path to app binary.')), - }, - (client, input) => client.apps.install(input), - ), - defineFieldCommand( - 'reinstall', - 'Reinstall an app binary.', - { - app: requiredField(stringField()), - appPath: requiredField(stringField('Path to app binary.')), - }, - (client, input) => client.apps.reinstall(input), - ), - defineFieldCommand( - 'install-from-source', - 'Install an app from a structured source.', - { - source: requiredField( - jsonSchemaField(looseObjectSchema('Install source object.')), - ), - retainPaths: booleanField(), - retentionMs: integerField(), - }, - (client, input) => client.apps.installFromSource(input), - ), - defineFieldCommand( - 'push', - 'Deliver a push payload.', - { - app: requiredField(stringField()), - payload: requiredField( - jsonSchemaField>({ - oneOf: [stringSchema(), looseObjectSchema()], - }), - ), - }, - (client, input) => client.apps.push(input), - ), - defineFieldCommand( - 'trigger-app-event', - 'Trigger an app-defined event.', - { event: requiredField(stringField()), payload: looseObjectField() }, - (client, input) => client.apps.triggerEvent(input), - ), - defineFieldCommand( - 'snapshot', - 'Capture an accessibility snapshot.', - { - interactiveOnly: booleanField(), - compact: booleanField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - forceFull: booleanField(), - }, - (client, input) => client.capture.snapshot(input), - ), - defineFieldCommand( - 'screenshot', - 'Capture a screenshot.', - { - path: stringField('Output path.'), - overlayRefs: booleanField(), - fullscreen: booleanField(), - maxSize: integerField(), - stabilize: booleanField(), - surface: enumField(SURFACE_VALUES), - }, - (client, input) => client.capture.screenshot(input), - ), - defineFieldCommand( - 'diff', - 'Diff accessibility snapshots.', - { - kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), - out: stringField(), - interactiveOnly: booleanField(), - compact: booleanField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - }, - (client, input) => client.capture.diff(input), - ), - defineFieldCommand( - 'wait', - 'Wait for duration, text, ref, or selector.', - { - kind: enumField(WAIT_KIND_VALUES), - durationMs: integerField(), - text: stringField(), - ref: stringField(), - selector: stringField(), - timeoutMs: integerField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - }, - (client, input) => client.command.wait(waitInputToOptions(input)), - ), - defineFieldCommand( - 'alert', - 'Inspect or handle platform alerts.', - { action: enumField(ALERT_ACTION_VALUES), timeoutMs: integerField() }, - (client, input) => client.command.alert(input), - ), - defineFieldCommand('appstate', 'Show foreground app or activity.', {}, (client, input) => - client.command.appState(input), - ), - defineFieldCommand( - 'back', - 'Navigate back.', - { mode: enumField(BACK_MODE_VALUES) }, - (client, input) => client.command.back(input), - ), - defineFieldCommand('home', 'Go to the home screen.', {}, (client, input) => - client.command.home(input), - ), - defineFieldCommand( - 'rotate', - 'Rotate device orientation.', - { orientation: requiredField(enumField(ORIENTATION_VALUES)) }, - (client, input) => client.command.rotate(input), - ), - defineFieldCommand('app-switcher', 'Open the app switcher.', {}, (client, input) => + defineExecutableCommand(metadata('devices'), (client, input) => client.devices.list(input)), + defineExecutableCommand(metadata('boot'), (client, input) => client.devices.boot(input)), + defineExecutableCommand(metadata('apps'), (client, input) => client.apps.list(input)), + defineExecutableCommand(metadata('session'), async (client, { action: _action, ...input }) => ({ + sessions: await client.sessions.list(input), + })), + defineExecutableCommand(metadata('open'), (client, input) => client.apps.open(input)), + defineExecutableCommand(metadata('close'), (client, input) => + input.app ? client.apps.close(input) : client.sessions.close(withoutApp(input)), + ), + defineExecutableCommand(metadata('install'), (client, input) => client.apps.install(input)), + defineExecutableCommand(metadata('reinstall'), (client, input) => client.apps.reinstall(input)), + defineExecutableCommand(metadata('install-from-source'), (client, input) => + client.apps.installFromSource(input), + ), + defineExecutableCommand(metadata('push'), (client, input) => client.apps.push(input)), + defineExecutableCommand(metadata('trigger-app-event'), (client, input) => + client.apps.triggerEvent(input), + ), + defineExecutableCommand(metadata('snapshot'), (client, input) => client.capture.snapshot(input)), + defineExecutableCommand(metadata('screenshot'), (client, input) => + client.capture.screenshot(input), + ), + defineExecutableCommand(metadata('diff'), (client, input) => client.capture.diff(input)), + defineExecutableCommand(metadata('wait'), (client, input) => + client.command.wait(waitInputToOptions(input)), + ), + defineExecutableCommand(metadata('alert'), (client, input) => client.command.alert(input)), + defineExecutableCommand(metadata('appstate'), (client, input) => client.command.appState(input)), + defineExecutableCommand(metadata('back'), (client, input) => client.command.back(input)), + defineExecutableCommand(metadata('home'), (client, input) => client.command.home(input)), + defineExecutableCommand(metadata('rotate'), (client, input) => client.command.rotate(input)), + defineExecutableCommand(metadata('app-switcher'), (client, input) => client.command.appSwitcher(input), ), - defineFieldCommand( - 'keyboard', - 'Inspect or dismiss the keyboard.', - { action: enumField(['status', 'dismiss']) }, - (client, input) => client.command.keyboard(input), - ), - defineFieldCommand( - 'clipboard', - 'Read or write clipboard text.', - { action: requiredField(enumField(CLIPBOARD_ACTION_VALUES)), text: stringField() }, - (client, input) => client.command.clipboard(input as ClipboardCommandOptions), + defineExecutableCommand(metadata('keyboard'), (client, input) => client.command.keyboard(input)), + defineExecutableCommand(metadata('clipboard'), (client, input) => + client.command.clipboard(input as ClipboardCommandOptions), ), - defineFieldCommand( - 'react-native', - 'Run supported React Native app automation helpers.', - { action: requiredField(enumField(REACT_NATIVE_ACTION_VALUES)) }, - (client, input) => client.command.reactNative(input), + defineExecutableCommand(metadata('react-native'), (client, input) => + client.command.reactNative(input), ), - defineFieldCommand( - 'replay', - 'Replay a recorded session.', - { - path: requiredField(stringField()), - update: booleanField(), - backend: stringField(), - maestro: booleanField(), - env: stringArrayField(), - }, - (client, input) => client.replay.run(input), + defineExecutableCommand(metadata('replay'), (client, input) => client.replay.run(input)), + defineExecutableCommand(metadata('test'), (client, input) => client.replay.test(input)), + defineExecutableCommand(metadata('perf'), (client, input) => client.observability.perf(input)), + defineExecutableCommand(metadata('logs'), (client, input) => client.observability.logs(input)), + defineExecutableCommand(metadata('network'), (client, input) => + client.observability.network(input), ), - defineFieldCommand( - 'test', - 'Run one or more replay scripts.', - { - paths: requiredField(stringArrayField()), - update: booleanField(), - backend: stringField(), - maestro: booleanField(), - env: stringArrayField(), - failFast: booleanField(), - timeoutMs: integerField(), - retries: integerField(), - artifactsDir: stringField(), - reportJunit: stringField(), - }, - (client, input) => client.replay.test(input), + defineExecutableCommand(metadata('record'), (client, input) => + client.recording.record(input as RecordOptions), ), - defineFieldCommand('perf', 'Show session performance metrics.', {}, (client, input) => - client.observability.perf(input), + defineExecutableCommand(metadata('trace'), (client, input) => client.recording.trace(input)), + defineExecutableCommand(metadata('settings'), (client, input) => + client.settings.update(input as SettingsUpdateOptions), ), - defineFieldCommand( - 'logs', - 'Manage session app logs.', - { action: enumField(LOG_ACTION_VALUES), message: stringField(), restart: booleanField() }, - (client, input) => client.observability.logs(input), - ), - defineFieldCommand( - 'network', - 'Show recent HTTP traffic.', - { - action: enumField(NETWORK_ACTION_VALUES), - limit: integerField(), - include: enumField(NETWORK_INCLUDE_VALUES), - }, - (client, input) => client.observability.network(input), - ), - defineFieldCommand( - 'record', - 'Start or stop screen recording.', - { - action: requiredField(enumField(START_STOP_VALUES)), - path: stringField(), - fps: integerField(), - quality: jsonSchemaField(integerSchema()), - hideTouches: booleanField(), - }, - (client, input) => client.recording.record(input as RecordOptions), - ), - defineFieldCommand( - 'trace', - 'Start or stop trace capture.', - { action: requiredField(enumField(START_STOP_VALUES)), path: stringField() }, - (client, input) => client.recording.trace(input), - ), - defineFieldCommand( - 'settings', - 'Change OS settings and app permissions.', - { - setting: requiredField(stringField()), - state: requiredField(stringField()), - latitude: numberField(), - longitude: numberField(), - permission: stringField(), - mode: enumField(['full', 'limited']), - }, - (client, input) => client.settings.update(input as SettingsUpdateOptions), - ), - defineFieldCommand( - 'metro', - 'Prepare Metro runtime or reload React Native apps.', - { - action: requiredField(enumField(METRO_ACTION_VALUES)), - projectRoot: stringField(), - kind: jsonSchemaField(stringSchema()), - publicBaseUrl: stringField(), - proxyBaseUrl: stringField(), - bearerToken: stringField(), - bridgeScope: jsonSchemaField({ - type: 'object', - additionalProperties: true, - }), - launchUrl: stringField(), - port: integerField(), - listenHost: stringField(), - statusHost: stringField(), - startupTimeoutMs: integerField(), - probeTimeoutMs: integerField(), - reuseExisting: booleanField(), - installDependenciesIfNeeded: booleanField(), - runtimeFilePath: stringField(), - logPath: stringField(), - metroHost: stringField(), - metroPort: integerField(), - bundleUrl: stringField(), - timeoutMs: integerField(), - }, + defineExecutableCommand( + metadata('metro'), async (client, input): Promise => input.action === 'prepare' ? await client.metro.prepare(toMetroPrepareOptions(input)) @@ -366,6 +85,14 @@ export const clientCommandDefinitions = [ ), ] as const; +function metadata( + name: TName, +): Extract { + const definition = clientCommandMetadata.find((item) => item.name === name); + if (!definition) throw new Error(`Missing client command metadata for ${name}`); + return definition as Extract; +} + function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown?: boolean } { const { app: _app, ...rest } = input; return rest; diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts new file mode 100644 index 000000000..a47ad1a70 --- /dev/null +++ b/src/commands/client-command-metadata.ts @@ -0,0 +1,239 @@ +import type { MetroPrepareOptions, RecordOptions } from '../client-types.ts'; +import type { DaemonInstallSource } from '../contracts.ts'; +import { getCommandDescription } from './command-descriptions.ts'; +import { + booleanField, + booleanSchema, + enumField, + integerField, + integerSchema, + jsonSchemaField, + looseObjectField, + looseObjectSchema, + numberField, + requiredField, + stringArrayField, + stringField, + stringSchema, + type CommandFieldMap, +} from './command-input.ts'; +import { defineFieldCommandMetadata } from './field-command-contract.ts'; + +const SURFACE_VALUES = ['app', 'frontmost-app', 'desktop', 'menubar'] as const; +const WAIT_KIND_VALUES = ['duration', 'text', 'ref', 'selector'] as const; +const ALERT_ACTION_VALUES = ['get', 'accept', 'dismiss', 'wait'] as const; +const BACK_MODE_VALUES = ['in-app', 'system'] as const; +const ORIENTATION_VALUES = [ + 'portrait', + 'portrait-upside-down', + 'landscape-left', + 'landscape-right', +] as const; +const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; +const LOG_ACTION_VALUES = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const; +const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; +const NETWORK_INCLUDE_VALUES = ['summary', 'headers', 'body', 'all'] as const; +const START_STOP_VALUES = ['start', 'stop'] as const; +const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; +const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; + +export const clientCommandMetadata = [ + defineClientCommandMetadata('devices', {}), + defineClientCommandMetadata('boot', { + headless: booleanField('Boot without showing simulator UI when supported.'), + }), + defineClientCommandMetadata('apps', { + appsFilter: enumField(['user-installed', 'all']), + }), + defineClientCommandMetadata('session', { + action: enumField(['list']), + }), + defineClientCommandMetadata('open', { + app: stringField('App name, bundle id, package, or URL.'), + url: stringField('Optional URL passed with an app shell.'), + surface: enumField(SURFACE_VALUES), + activity: stringField('Android activity name.'), + launchConsole: stringField('Launch console mode.'), + launchArgs: stringArrayField( + 'Launch arguments forwarded verbatim to the platform launch command.', + ), + relaunch: booleanField('Force relaunch.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + noRecord: booleanField('Do not record this action.'), + }), + defineClientCommandMetadata('close', { + app: stringField('Optional app to close.'), + shutdown: booleanField('Shutdown the session/device where supported.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + }), + defineClientCommandMetadata('install', { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }), + defineClientCommandMetadata('reinstall', { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }), + defineClientCommandMetadata('install-from-source', { + source: requiredField( + jsonSchemaField(looseObjectSchema('Install source object.')), + ), + retainPaths: booleanField(), + retentionMs: integerField(), + }), + defineClientCommandMetadata('push', { + app: requiredField(stringField()), + payload: requiredField( + jsonSchemaField>({ + oneOf: [stringSchema(), looseObjectSchema()], + }), + ), + }), + defineClientCommandMetadata('trigger-app-event', { + event: requiredField(stringField()), + payload: looseObjectField(), + }), + defineClientCommandMetadata('snapshot', { + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + forceFull: booleanField(), + }), + defineClientCommandMetadata('screenshot', { + path: stringField('Output path.'), + overlayRefs: booleanField(), + fullscreen: booleanField(), + maxSize: integerField(), + stabilize: booleanField(), + surface: enumField(SURFACE_VALUES), + }), + defineClientCommandMetadata('diff', { + kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), + out: stringField(), + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + }), + defineClientCommandMetadata('wait', { + kind: enumField(WAIT_KIND_VALUES), + durationMs: integerField(), + text: stringField(), + ref: stringField(), + selector: stringField(), + timeoutMs: integerField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + }), + defineClientCommandMetadata('alert', { + action: enumField(ALERT_ACTION_VALUES), + timeoutMs: integerField(), + }), + defineClientCommandMetadata('appstate', {}), + defineClientCommandMetadata('back', { + mode: enumField(BACK_MODE_VALUES), + }), + defineClientCommandMetadata('home', {}), + defineClientCommandMetadata('rotate', { + orientation: requiredField(enumField(ORIENTATION_VALUES)), + }), + defineClientCommandMetadata('app-switcher', {}), + defineClientCommandMetadata('keyboard', { + action: enumField(['status', 'dismiss']), + }), + defineClientCommandMetadata('clipboard', { + action: requiredField(enumField(CLIPBOARD_ACTION_VALUES)), + text: stringField(), + }), + defineClientCommandMetadata('react-native', { + action: requiredField(enumField(REACT_NATIVE_ACTION_VALUES)), + }), + defineClientCommandMetadata('replay', { + path: requiredField(stringField()), + update: booleanField(), + backend: stringField(), + maestro: booleanField(), + env: stringArrayField(), + }), + defineClientCommandMetadata('test', { + paths: requiredField(stringArrayField()), + update: booleanField(), + backend: stringField(), + maestro: booleanField(), + env: stringArrayField(), + failFast: booleanField(), + timeoutMs: integerField(), + retries: integerField(), + artifactsDir: stringField(), + reportJunit: stringField(), + }), + defineClientCommandMetadata('perf', {}), + defineClientCommandMetadata('logs', { + action: enumField(LOG_ACTION_VALUES), + message: stringField(), + restart: booleanField(), + }), + defineClientCommandMetadata('network', { + action: enumField(NETWORK_ACTION_VALUES), + limit: integerField(), + include: enumField(NETWORK_INCLUDE_VALUES), + }), + defineClientCommandMetadata('record', { + action: requiredField(enumField(START_STOP_VALUES)), + path: stringField(), + fps: integerField(), + quality: jsonSchemaField(integerSchema()), + hideTouches: booleanField(), + }), + defineClientCommandMetadata('trace', { + action: requiredField(enumField(START_STOP_VALUES)), + path: stringField(), + }), + defineClientCommandMetadata('settings', { + setting: requiredField(stringField()), + state: requiredField(stringField()), + latitude: numberField(), + longitude: numberField(), + permission: stringField(), + mode: enumField(['full', 'limited']), + }), + defineClientCommandMetadata('metro', { + action: requiredField(enumField(METRO_ACTION_VALUES)), + projectRoot: stringField(), + kind: jsonSchemaField(stringSchema()), + publicBaseUrl: stringField(), + proxyBaseUrl: stringField(), + bearerToken: stringField(), + bridgeScope: jsonSchemaField({ + type: 'object', + additionalProperties: true, + }), + launchUrl: stringField(), + port: integerField(), + listenHost: stringField(), + statusHost: stringField(), + startupTimeoutMs: integerField(), + probeTimeoutMs: integerField(), + reuseExisting: booleanField(), + installDependenciesIfNeeded: booleanField(), + runtimeFilePath: stringField(), + logPath: stringField(), + metroHost: stringField(), + metroPort: integerField(), + bundleUrl: stringField(), + timeoutMs: integerField(), + }), +] as const; + +function defineClientCommandMetadata< + const TName extends string, + const TFields extends CommandFieldMap, +>(name: TName, fields: TFields) { + const description = getCommandDescription(name); + if (!description) throw new Error(`Missing command description for ${name}`); + return defineFieldCommandMetadata(name, description, fields); +} diff --git a/src/commands/command-contract.ts b/src/commands/command-contract.ts index 8a4feea56..2e16fbbcc 100644 --- a/src/commands/command-contract.ts +++ b/src/commands/command-contract.ts @@ -15,19 +15,18 @@ export type JsonSchema = { maximum?: number; }; -type CommandContract = { +export type CommandMetadata = { name: Name; description: string; inputSchema: JsonSchema; readInput: (input: unknown) => Input; - run: (client: AgentDeviceClient, input: Input) => Promise; }; -export type ExecutableCommandContract = CommandContract< +export type ExecutableCommandContract = CommandMetadata< Name, - Input, - Result + Input > & { + run: (client: AgentDeviceClient, input: Input) => Promise; invoke: (client: AgentDeviceClient, input: unknown) => Promise; }; @@ -38,11 +37,19 @@ export type CliOutput = { stderr?: string | null; }; -export function defineCommand( - definition: CommandContract, +export function defineCommandMetadata( + definition: CommandMetadata, +): CommandMetadata { + return definition; +} + +export function defineExecutableCommand( + metadata: CommandMetadata, + run: (client: AgentDeviceClient, input: Input) => Promise, ): ExecutableCommandContract { return { - ...definition, - invoke: async (client, input) => await definition.run(client, definition.readInput(input)), + ...metadata, + run, + invoke: async (client, input) => await run(client, metadata.readInput(input)), }; } diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts new file mode 100644 index 000000000..e5f9a9291 --- /dev/null +++ b/src/commands/command-descriptions.ts @@ -0,0 +1,64 @@ +const COMMAND_DESCRIPTIONS = { + devices: 'List available devices.', + boot: 'Boot or prepare a selected device without using CLI positional arguments.', + apps: 'List installed apps.', + session: 'List active sessions.', + open: 'Open an app, deep link, URL, or platform surface.', + close: 'Close an app or end the active session.', + install: 'Install an app binary.', + reinstall: 'Reinstall an app binary.', + 'install-from-source': 'Install an app from a structured source.', + push: 'Deliver a push payload.', + 'trigger-app-event': 'Trigger an app-defined event.', + snapshot: 'Capture an accessibility snapshot.', + screenshot: 'Capture a screenshot.', + diff: 'Diff accessibility snapshots.', + wait: 'Wait for duration, text, ref, or selector.', + alert: 'Inspect or handle platform alerts.', + appstate: 'Show foreground app or activity.', + back: 'Navigate back.', + home: 'Go to the home screen.', + rotate: 'Rotate device orientation.', + 'app-switcher': 'Open the app switcher.', + keyboard: 'Inspect or dismiss the keyboard.', + clipboard: 'Read or write clipboard text.', + 'react-native': 'Run supported React Native app automation helpers.', + replay: 'Replay a recorded session.', + test: 'Run one or more replay scripts.', + perf: 'Show session performance metrics.', + logs: 'Manage session app logs.', + network: 'Show recent HTTP traffic.', + record: 'Start or stop screen recording.', + trace: 'Start or stop trace capture.', + settings: 'Change OS settings and app permissions.', + metro: 'Prepare Metro runtime or reload React Native apps.', + click: 'Click or tap a semantic UI target by ref, selector, or point.', + press: 'Press a semantic UI target by ref, selector, or point.', + fill: 'Fill text into a semantic UI target by ref, selector, or point.', + longpress: 'Long press by ref, selector, or point.', + swipe: 'Swipe between two points.', + focus: 'Focus input at coordinates.', + type: 'Type text in the focused field.', + scroll: 'Scroll in a direction or to an edge.', + get: 'Get element text or attributes.', + is: 'Assert UI state.', + find: 'Find an element and optionally act on it.', + gesture: 'Run a structured gesture.', + batch: 'Run multiple structured command steps in one daemon request.', +} as const; + +export type DescribedCommandName = keyof typeof COMMAND_DESCRIPTIONS; + +export function getCommandDescription(command: string): string | undefined { + return COMMAND_DESCRIPTIONS[command as DescribedCommandName]; +} + +export function listCommandDescriptionMetadata(): Array<{ + name: DescribedCommandName; + description: string; +}> { + return Object.entries(COMMAND_DESCRIPTIONS).map(([name, description]) => ({ + name: name as DescribedCommandName, + description, + })); +} diff --git a/src/commands/command-metadata.ts b/src/commands/command-metadata.ts new file mode 100644 index 000000000..a7c669070 --- /dev/null +++ b/src/commands/command-metadata.ts @@ -0,0 +1,38 @@ +import { BATCH_COMMAND_NAMES, listMcpExposedCommandNames } from '../command-catalog.ts'; +import { createBatchCommandMetadata } from './batch-command-metadata.ts'; +import { clientCommandMetadata } from './client-command-metadata.ts'; +import type { CommandMetadata } from './command-contract.ts'; +import { interactionCommandMetadata } from './interaction-command-metadata.ts'; + +const batchCommandMetadata = createBatchCommandMetadata(BATCH_COMMAND_NAMES); + +const commandMetadata = [ + ...interactionCommandMetadata, + ...clientCommandMetadata, + batchCommandMetadata, +] as const; + +export type CommandName = (typeof commandMetadata)[number]['name']; + +type AnyCommandMetadata = CommandMetadata; + +const commandMetadataMap: ReadonlyMap = new Map( + commandMetadata.map((definition) => [definition.name, definition as AnyCommandMetadata]), +); + +export function listMcpCommandMetadata(): AnyCommandMetadata[] { + return listMcpExposedCommandNames().map((name) => { + if (!isCommandName(name)) { + throw new Error(`Missing command metadata for MCP-exposed command: ${name}`); + } + return getCommandMetadata(name); + }); +} + +export function isCommandName(name: string): name is CommandName { + return commandMetadataMap.has(name as CommandName); +} + +function getCommandMetadata(name: CommandName): AnyCommandMetadata { + return commandMetadataMap.get(name)!; +} diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index 4c95ee0d4..19fc947a4 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -1,4 +1,4 @@ -import { PUBLIC_COMMANDS } from '../command-catalog.ts'; +import { BATCH_COMMAND_NAMES, PUBLIC_COMMANDS } from '../command-catalog.ts'; import { buildFlags } from '../client-normalizers.ts'; import type { DaemonBatchStep } from '../core/batch.ts'; import { AppError } from '../utils/errors.ts'; @@ -33,23 +33,9 @@ const daemonWriters = { export type DaemonCommandName = keyof typeof daemonWriters; -const NON_BATCH_COMMAND_NAMES = [ - 'replay', - 'batch', - 'gesture-pan', - 'gesture-fling', - 'gesture-pinch', - 'gesture-rotate', - 'gesture-transform', -] as const; -type NonBatchCommandName = (typeof NON_BATCH_COMMAND_NAMES)[number]; -export type BatchCommandName = Exclude; +export type BatchCommandName = (typeof BATCH_COMMAND_NAMES)[number]; -const nonBatchCommandNames = commandNameSet(NON_BATCH_COMMAND_NAMES); - -export const batchCommandNames = (Object.keys(daemonWriters) as DaemonCommandName[]).filter( - (name): name is BatchCommandName => !nonBatchCommandNames.has(name), -); +export const batchCommandNames = BATCH_COMMAND_NAMES satisfies readonly DaemonCommandName[]; const batchNames = commandNameSet(batchCommandNames); diff --git a/src/commands/command-surface.ts b/src/commands/command-surface.ts index 8cc001380..32da3fa6d 100644 --- a/src/commands/command-surface.ts +++ b/src/commands/command-surface.ts @@ -1,10 +1,10 @@ import type { AgentDeviceClient } from '../client-types.ts'; -import { listMcpExposedCommandNames } from '../command-catalog.ts'; import { createBatchCommand } from './batch-command.ts'; import { clientCommandDefinitions } from './client-command-contracts.ts'; import type { JsonSchema } from './command-contract.ts'; import { interactionCommandDefinitions } from './interaction-command-contracts.ts'; import { batchCommandNames, type BatchCommandName } from './command-projection.ts'; +import type { CommandName } from './command-metadata.ts'; type AnyExecutableCommand = { name: string; @@ -21,34 +21,12 @@ const commandSurface = [ batchCommandDefinition, ] as const; -export type CommandName = (typeof commandSurface)[number]['name']; -export type { BatchCommandName }; +export type { BatchCommandName, CommandName }; const commandMap: ReadonlyMap = new Map( commandSurface.map((definition) => [definition.name, definition]), ); -export function listMcpToolDefinitions(): AnyExecutableCommand[] { - return listMcpExposedCommandNames().map((name) => { - if (!isCommandName(name)) { - throw new Error(`Missing command for MCP-exposed command: ${name}`); - } - return getCommandDefinition(name); - }); -} - -export function listCommandNames(): CommandName[] { - return commandSurface.map((definition) => definition.name); -} - -export function listCommandDefinitions(): AnyExecutableCommand[] { - return [...commandSurface]; -} - -export function isCommandName(name: string): name is CommandName { - return commandMap.has(name as CommandName); -} - export async function runCommand( client: AgentDeviceClient, name: CommandName, diff --git a/src/commands/field-command-contract.ts b/src/commands/field-command-contract.ts index 61fff530b..5c3697972 100644 --- a/src/commands/field-command-contract.ts +++ b/src/commands/field-command-contract.ts @@ -1,27 +1,14 @@ -import type { AgentDeviceClient } from '../client-types.ts'; -import { defineCommand } from './command-contract.ts'; -import { - fieldsInputSchema, - readFieldInput, - type CommandFieldMap, - type InferCommandInput, -} from './command-input.ts'; +import { defineCommandMetadata } from './command-contract.ts'; +import { fieldsInputSchema, readFieldInput, type CommandFieldMap } from './command-input.ts'; -export function defineFieldCommand< +export function defineFieldCommandMetadata< const TName extends string, const TFields extends CommandFieldMap, - TResult, ->( - name: TName, - description: string, - fields: TFields, - run: (client: AgentDeviceClient, input: InferCommandInput) => Promise, -) { - return defineCommand({ +>(name: TName, description: string, fields: TFields) { + return defineCommandMetadata({ name, description, inputSchema: fieldsInputSchema(fields), readInput: (input) => readFieldInput(input, fields), - run, }); } diff --git a/src/commands/interaction-command-contracts.ts b/src/commands/interaction-command-contracts.ts index 405cb36d4..c6122367f 100644 --- a/src/commands/interaction-command-contracts.ts +++ b/src/commands/interaction-command-contracts.ts @@ -1,8 +1,8 @@ import type { ClickOptions, FindOptions, - FlingOptions, FillOptions, + FlingOptions, FocusOptions, GetOptions, PanOptions, @@ -16,323 +16,87 @@ import type { TransformGestureOptions, TypeTextOptions, } from '../client-types.ts'; -import { defineCommand } from './command-contract.ts'; +import { defineExecutableCommand } from './command-contract.ts'; import { - booleanField, commonToClientOptions, - elementTargetField, - enumField, - fieldsInputSchema, - integerField, - interactionTargetField, - numberField, - optionalInteger, - pointField, - readCommonInput, - readFieldInput, - readInputRecord, - readPoint, - repeatedFields, - requiredEnum, - requiredField, - requiredNumber, - selectorSnapshotFields, - stringField, toClientElementTarget, toClientInteractionTarget, toRepeatedOptions, toSelectorSnapshotOptions, - type CommonCommandInput, - type InferCommandInput, - type PointInput, } from './command-input.ts'; -import { defineFieldCommand } from './field-command-contract.ts'; - -const CLICK_BUTTON_VALUES = ['primary', 'secondary', 'middle'] as const; -const GESTURE_KIND_VALUES = ['pan', 'fling', 'pinch', 'rotate', 'transform'] as const; -const GESTURE_DIRECTION_VALUES = ['up', 'down', 'left', 'right'] as const; -const FIND_ACTION_VALUES = [ - 'click', - 'focus', - 'exists', - 'getText', - 'getAttrs', - 'wait', - 'fill', - 'type', -] as const; -const FIND_LOCATOR_VALUES = ['any', 'text', 'label', 'value', 'role', 'id'] as const; -const SCROLL_DIRECTION_VALUES = ['up', 'down', 'left', 'right', 'top', 'bottom'] as const; -const SWIPE_PATTERN_VALUES = ['one-way', 'ping-pong'] as const; +import { + interactionCommandMetadata, + type ClickInput, + type FillInput, + type FlingInput, + type GetInput, + type LongPressInput, + type PanInput, + type PinchInput, + type PressInput, + type RotateInput, + type TransformInput, +} from './interaction-command-metadata.ts'; + +type InteractionCommandMetadata = (typeof interactionCommandMetadata)[number]; +type InteractionCommandName = InteractionCommandMetadata['name']; -const clickFields = { - target: requiredField(interactionTargetField()), - button: enumField( - CLICK_BUTTON_VALUES, - 'Pointer button for platforms that support mouse buttons.', +export const interactionCommandDefinitions = [ + defineExecutableCommand(metadata('click'), (client, input) => + client.interactions.click(toClickOptions(input)), ), - ...selectorSnapshotFields(), - ...repeatedFields(), -}; - -const pressFields = { - target: requiredField(interactionTargetField()), - ...selectorSnapshotFields(), - ...repeatedFields(), -}; - -const fillFields = { - target: requiredField(interactionTargetField()), - text: requiredField(stringField('Text to enter into the target.')), - delayMs: integerField('Delay between typed characters.', { min: 0 }), - ...selectorSnapshotFields(), -}; - -const longPressFields = { - target: requiredField(interactionTargetField()), - durationMs: integerField('Long press duration in milliseconds.', { min: 0 }), - ...selectorSnapshotFields(), -}; - -const swipeFields = { - from: requiredField(pointField('Swipe start point.')), - to: requiredField(pointField('Swipe end point.')), - durationMs: integerField('Swipe duration in milliseconds.', { min: 0 }), - count: integerField('Number of swipe repetitions.', { min: 1 }), - pauseMs: integerField('Pause between repeated swipes.', { min: 0 }), - pattern: enumField(SWIPE_PATTERN_VALUES), -}; - -const focusFields = { - x: requiredField(numberField('X coordinate.')), - y: requiredField(numberField('Y coordinate.')), -}; - -const typeFields = { - text: requiredField(stringField('Text to type.')), - delayMs: integerField('Delay between typed characters.', { min: 0 }), -}; - -const scrollFields = { - direction: requiredField(enumField(SCROLL_DIRECTION_VALUES)), - amount: numberField('Platform scroll amount.'), - pixels: integerField('Pixel scroll amount.', { min: 0 }), -}; - -const getFields = { - format: requiredField(enumField(['text', 'attrs'] as const)), - target: requiredField(elementTargetField()), - ...selectorSnapshotFields(), -}; - -const isFields = { - predicate: requiredField( - enumField(['visible', 'hidden', 'exists', 'editable', 'selected', 'text'] as const), + defineExecutableCommand(metadata('press'), (client, input) => + client.interactions.press(toPressOptions(input)), ), - selector: requiredField(stringField()), - value: stringField(), - ...selectorSnapshotFields(), -}; - -const findFields = { - locator: enumField(FIND_LOCATOR_VALUES), - query: requiredField(stringField()), - action: enumField(FIND_ACTION_VALUES), - value: stringField(), - timeoutMs: integerField(), - first: booleanField(), - last: booleanField(), - depth: integerField(), - raw: booleanField(), -}; - -const gestureFields = { - kind: requiredField(enumField(GESTURE_KIND_VALUES, 'Gesture variant.')), - direction: enumField(GESTURE_DIRECTION_VALUES, 'Fling direction.'), - origin: pointField('Gesture origin point.'), - delta: pointField('Movement delta for pan or transform gestures.'), - distance: integerField('Fling distance.', { min: 0 }), - scale: numberField('Pinch or transform scale.'), - degrees: numberField('Rotation in degrees.'), - velocity: integerField('Rotate gesture velocity.', { min: 0 }), - durationMs: integerField('Gesture duration in milliseconds.', { min: 0 }), -}; - -type ClickInput = InferCommandInput; -type PressInput = InferCommandInput; -type FillInput = InferCommandInput; -type LongPressInput = InferCommandInput; -type GetInput = InferCommandInput; - -type PanInput = CommonCommandInput & { - kind: 'pan'; - origin: PointInput; - delta: PointInput; - durationMs?: number; -}; - -type FlingInput = CommonCommandInput & { - kind: 'fling'; - direction: 'up' | 'down' | 'left' | 'right'; - origin: PointInput; - distance?: number; - durationMs?: number; -}; - -type PinchInput = CommonCommandInput & { - kind: 'pinch'; - scale: number; - origin?: PointInput; -}; - -type RotateInput = CommonCommandInput & { - kind: 'rotate'; - degrees: number; - origin?: PointInput; - velocity?: number; -}; - -type TransformInput = CommonCommandInput & { - kind: 'transform'; - origin: PointInput; - delta: PointInput; - scale: number; - degrees: number; - durationMs?: number; -}; - -type GestureInput = PanInput | FlingInput | PinchInput | RotateInput | TransformInput; - -export const interactionCommandDefinitions = [ - defineCommand({ - name: 'click', - description: 'Click or tap a semantic UI target by ref, selector, or point.', - inputSchema: fieldsInputSchema(clickFields), - readInput: (input) => readFieldInput(input, clickFields), - run: (client, input) => client.interactions.click(toClickOptions(input)), - }), - defineCommand({ - name: 'press', - description: 'Press a semantic UI target by ref, selector, or point.', - inputSchema: fieldsInputSchema(pressFields), - readInput: (input) => readFieldInput(input, pressFields), - run: (client, input) => client.interactions.press(toPressOptions(input)), - }), - defineCommand({ - name: 'fill', - description: 'Fill text into a semantic UI target by ref, selector, or point.', - inputSchema: fieldsInputSchema(fillFields), - readInput: (input) => readFieldInput(input, fillFields), - run: (client, input) => client.interactions.fill(toFillOptions(input)), - }), - defineFieldCommand( - 'longpress', - 'Long press by ref, selector, or point.', - longPressFields, - (client, input) => client.interactions.longPress(toLongPressOptions(input)), + defineExecutableCommand(metadata('fill'), (client, input) => + client.interactions.fill(toFillOptions(input)), + ), + defineExecutableCommand(metadata('longpress'), (client, input) => + client.interactions.longPress(toLongPressOptions(input)), ), - defineFieldCommand('swipe', 'Swipe between two points.', swipeFields, (client, input) => + defineExecutableCommand(metadata('swipe'), (client, input) => client.interactions.swipe(input as SwipeOptions), ), - defineFieldCommand('focus', 'Focus input at coordinates.', focusFields, (client, input) => + defineExecutableCommand(metadata('focus'), (client, input) => client.interactions.focus(input as FocusOptions), ), - defineFieldCommand('type', 'Type text in the focused field.', typeFields, (client, input) => + defineExecutableCommand(metadata('type'), (client, input) => client.interactions.type(input as TypeTextOptions), ), - defineFieldCommand( - 'scroll', - 'Scroll in a direction or to an edge.', - scrollFields, - (client, input) => client.interactions.scroll(input as ScrollOptions), + defineExecutableCommand(metadata('scroll'), (client, input) => + client.interactions.scroll(input as ScrollOptions), ), - defineFieldCommand('get', 'Get element text or attributes.', getFields, (client, input) => + defineExecutableCommand(metadata('get'), (client, input) => client.interactions.get(toGetOptions(input)), ), - defineFieldCommand('is', 'Assert UI state.', isFields, (client, input) => + defineExecutableCommand(metadata('is'), (client, input) => client.interactions.is(input as IsOptions), ), - defineFieldCommand( - 'find', - 'Find an element and optionally act on it.', - findFields, - (client, input) => client.interactions.find(input as FindOptions), + defineExecutableCommand(metadata('find'), (client, input) => + client.interactions.find(input as FindOptions), ), - defineCommand({ - name: 'gesture', - description: 'Run a structured gesture.', - inputSchema: fieldsInputSchema(gestureFields), - readInput: readGestureInput, - run: async (client, input) => { - switch (input.kind) { - case 'pan': - return await client.interactions.pan(toPanOptions(input)); - case 'fling': - return await client.interactions.fling(toFlingOptions(input)); - case 'pinch': - return await client.interactions.pinch(toPinchOptions(input)); - case 'rotate': - return await client.interactions.rotateGesture(toRotateOptions(input)); - case 'transform': - return await client.interactions.transformGesture(toTransformOptions(input)); - } - }, + defineExecutableCommand(metadata('gesture'), async (client, input) => { + switch (input.kind) { + case 'pan': + return await client.interactions.pan(toPanOptions(input)); + case 'fling': + return await client.interactions.fling(toFlingOptions(input)); + case 'pinch': + return await client.interactions.pinch(toPinchOptions(input)); + case 'rotate': + return await client.interactions.rotateGesture(toRotateOptions(input)); + case 'transform': + return await client.interactions.transformGesture(toTransformOptions(input)); + } }), ] as const; -function readGestureInput(input: unknown): GestureInput { - const record = readInputRecord(input); - const common = readCommonInput(record); - const kind = requiredEnum(record, 'kind', GESTURE_KIND_VALUES); - if (kind === 'pan') { - return { - ...common, - kind, - origin: readPoint(record, 'origin'), - delta: readPoint(record, 'delta'), - durationMs: optionalInteger(record, 'durationMs', { min: 0 }), - }; - } - if (kind === 'fling') { - return { - ...common, - kind, - direction: requiredEnum(record, 'direction', GESTURE_DIRECTION_VALUES), - origin: readPoint(record, 'origin'), - distance: optionalInteger(record, 'distance', { min: 0 }), - durationMs: optionalInteger(record, 'durationMs', { min: 0 }), - }; - } - if (kind === 'pinch') { - return { - ...common, - kind, - scale: requiredNumber(record, 'scale'), - origin: optionalPoint(record, 'origin'), - }; - } - if (kind === 'rotate') { - return { - ...common, - kind, - degrees: requiredNumber(record, 'degrees'), - origin: optionalPoint(record, 'origin'), - velocity: optionalInteger(record, 'velocity', { min: 0 }), - }; - } - return { - ...common, - kind, - origin: readPoint(record, 'origin'), - delta: readPoint(record, 'delta'), - scale: requiredNumber(record, 'scale'), - degrees: requiredNumber(record, 'degrees'), - durationMs: optionalInteger(record, 'durationMs', { min: 0 }), - }; -} - -function optionalPoint(record: Record, key: string): PointInput | undefined { - return record[key] === undefined ? undefined : readPoint(record, key); +function metadata( + name: TName, +): Extract { + const definition = interactionCommandMetadata.find((item) => item.name === name); + if (!definition) throw new Error(`Missing interaction command metadata for ${name}`); + return definition as Extract; } function toClickOptions(input: ClickInput): ClickOptions { diff --git a/src/commands/interaction-command-metadata.ts b/src/commands/interaction-command-metadata.ts new file mode 100644 index 000000000..fb295ef3c --- /dev/null +++ b/src/commands/interaction-command-metadata.ts @@ -0,0 +1,286 @@ +import { getCommandDescription } from './command-descriptions.ts'; +import { defineCommandMetadata } from './command-contract.ts'; +import { + booleanField, + elementTargetField, + enumField, + fieldsInputSchema, + integerField, + interactionTargetField, + numberField, + optionalInteger, + pointField, + readCommonInput, + readFieldInput, + readInputRecord, + readPoint, + repeatedFields, + requiredEnum, + requiredField, + requiredNumber, + selectorSnapshotFields, + stringField, + type CommandFieldMap, + type CommonCommandInput, + type InferCommandInput, + type PointInput, +} from './command-input.ts'; +import { defineFieldCommandMetadata } from './field-command-contract.ts'; + +const CLICK_BUTTON_VALUES = ['primary', 'secondary', 'middle'] as const; +const GESTURE_KIND_VALUES = ['pan', 'fling', 'pinch', 'rotate', 'transform'] as const; +const GESTURE_DIRECTION_VALUES = ['up', 'down', 'left', 'right'] as const; +const FIND_ACTION_VALUES = [ + 'click', + 'focus', + 'exists', + 'getText', + 'getAttrs', + 'wait', + 'fill', + 'type', +] as const; +const FIND_LOCATOR_VALUES = ['any', 'text', 'label', 'value', 'role', 'id'] as const; +const SCROLL_DIRECTION_VALUES = ['up', 'down', 'left', 'right', 'top', 'bottom'] as const; +const SWIPE_PATTERN_VALUES = ['one-way', 'ping-pong'] as const; + +const clickFields = { + target: requiredField(interactionTargetField()), + button: enumField( + CLICK_BUTTON_VALUES, + 'Pointer button for platforms that support mouse buttons.', + ), + ...selectorSnapshotFields(), + ...repeatedFields(), +}; + +const pressFields = { + target: requiredField(interactionTargetField()), + ...selectorSnapshotFields(), + ...repeatedFields(), +}; + +const fillFields = { + target: requiredField(interactionTargetField()), + text: requiredField(stringField('Text to enter into the target.')), + delayMs: integerField('Delay between typed characters.', { min: 0 }), + ...selectorSnapshotFields(), +}; + +const longPressFields = { + target: requiredField(interactionTargetField()), + durationMs: integerField('Long press duration in milliseconds.', { min: 0 }), + ...selectorSnapshotFields(), +}; + +const swipeFields = { + from: requiredField(pointField('Swipe start point.')), + to: requiredField(pointField('Swipe end point.')), + durationMs: integerField('Swipe duration in milliseconds.', { min: 0 }), + count: integerField('Number of swipe repetitions.', { min: 1 }), + pauseMs: integerField('Pause between repeated swipes.', { min: 0 }), + pattern: enumField(SWIPE_PATTERN_VALUES), +}; + +const focusFields = { + x: requiredField(numberField('X coordinate.')), + y: requiredField(numberField('Y coordinate.')), +}; + +const typeFields = { + text: requiredField(stringField('Text to type.')), + delayMs: integerField('Delay between typed characters.', { min: 0 }), +}; + +const scrollFields = { + direction: requiredField(enumField(SCROLL_DIRECTION_VALUES)), + amount: numberField('Platform scroll amount.'), + pixels: integerField('Pixel scroll amount.', { min: 0 }), +}; + +const getFields = { + format: requiredField(enumField(['text', 'attrs'] as const)), + target: requiredField(elementTargetField()), + ...selectorSnapshotFields(), +}; + +const isFields = { + predicate: requiredField( + enumField(['visible', 'hidden', 'exists', 'editable', 'selected', 'text'] as const), + ), + selector: requiredField(stringField()), + value: stringField(), + ...selectorSnapshotFields(), +}; + +const findFields = { + locator: enumField(FIND_LOCATOR_VALUES), + query: requiredField(stringField()), + action: enumField(FIND_ACTION_VALUES), + value: stringField(), + timeoutMs: integerField(), + first: booleanField(), + last: booleanField(), + depth: integerField(), + raw: booleanField(), +}; + +const gestureFields = { + kind: requiredField(enumField(GESTURE_KIND_VALUES, 'Gesture variant.')), + direction: enumField(GESTURE_DIRECTION_VALUES, 'Fling direction.'), + origin: pointField('Gesture origin point.'), + delta: pointField('Movement delta for pan or transform gestures.'), + distance: integerField('Fling distance.', { min: 0 }), + scale: numberField('Pinch or transform scale.'), + degrees: numberField('Rotation in degrees.'), + velocity: integerField('Rotate gesture velocity.', { min: 0 }), + durationMs: integerField('Gesture duration in milliseconds.', { min: 0 }), +}; + +export type ClickInput = InferCommandInput; +export type PressInput = InferCommandInput; +export type FillInput = InferCommandInput; +export type LongPressInput = InferCommandInput; +export type GetInput = InferCommandInput; + +export type PanInput = CommonCommandInput & { + kind: 'pan'; + origin: PointInput; + delta: PointInput; + durationMs?: number; +}; + +export type FlingInput = CommonCommandInput & { + kind: 'fling'; + direction: 'up' | 'down' | 'left' | 'right'; + origin: PointInput; + distance?: number; + durationMs?: number; +}; + +export type PinchInput = CommonCommandInput & { + kind: 'pinch'; + scale: number; + origin?: PointInput; +}; + +export type RotateInput = CommonCommandInput & { + kind: 'rotate'; + degrees: number; + origin?: PointInput; + velocity?: number; +}; + +export type TransformInput = CommonCommandInput & { + kind: 'transform'; + origin: PointInput; + delta: PointInput; + scale: number; + degrees: number; + durationMs?: number; +}; + +export type GestureInput = PanInput | FlingInput | PinchInput | RotateInput | TransformInput; + +export const interactionCommandMetadata = [ + defineCommandMetadata({ + name: 'click', + description: descriptionFor('click'), + inputSchema: fieldsInputSchema(clickFields), + readInput: (input) => readFieldInput(input, clickFields), + }), + defineCommandMetadata({ + name: 'press', + description: descriptionFor('press'), + inputSchema: fieldsInputSchema(pressFields), + readInput: (input) => readFieldInput(input, pressFields), + }), + defineCommandMetadata({ + name: 'fill', + description: descriptionFor('fill'), + inputSchema: fieldsInputSchema(fillFields), + readInput: (input) => readFieldInput(input, fillFields), + }), + defineInteractionCommandMetadata('longpress', longPressFields), + defineInteractionCommandMetadata('swipe', swipeFields), + defineInteractionCommandMetadata('focus', focusFields), + defineInteractionCommandMetadata('type', typeFields), + defineInteractionCommandMetadata('scroll', scrollFields), + defineInteractionCommandMetadata('get', getFields), + defineInteractionCommandMetadata('is', isFields), + defineInteractionCommandMetadata('find', findFields), + defineCommandMetadata({ + name: 'gesture', + description: descriptionFor('gesture'), + inputSchema: fieldsInputSchema(gestureFields), + readInput: readGestureInput, + }), +] as const; + +function readGestureInput(input: unknown): GestureInput { + const record = readInputRecord(input); + const common = readCommonInput(record); + const kind = requiredEnum(record, 'kind', GESTURE_KIND_VALUES); + if (kind === 'pan') { + return { + ...common, + kind, + origin: readPoint(record, 'origin'), + delta: readPoint(record, 'delta'), + durationMs: optionalInteger(record, 'durationMs', { min: 0 }), + }; + } + if (kind === 'fling') { + return { + ...common, + kind, + direction: requiredEnum(record, 'direction', GESTURE_DIRECTION_VALUES), + origin: readPoint(record, 'origin'), + distance: optionalInteger(record, 'distance', { min: 0 }), + durationMs: optionalInteger(record, 'durationMs', { min: 0 }), + }; + } + if (kind === 'pinch') { + return { + ...common, + kind, + scale: requiredNumber(record, 'scale'), + origin: optionalPoint(record, 'origin'), + }; + } + if (kind === 'rotate') { + return { + ...common, + kind, + degrees: requiredNumber(record, 'degrees'), + origin: optionalPoint(record, 'origin'), + velocity: optionalInteger(record, 'velocity', { min: 0 }), + }; + } + return { + ...common, + kind, + origin: readPoint(record, 'origin'), + delta: readPoint(record, 'delta'), + scale: requiredNumber(record, 'scale'), + degrees: requiredNumber(record, 'degrees'), + durationMs: optionalInteger(record, 'durationMs', { min: 0 }), + }; +} + +function defineInteractionCommandMetadata< + const TName extends string, + const TFields extends CommandFieldMap, +>(name: TName, fields: TFields) { + return defineFieldCommandMetadata(name, descriptionFor(name), fields); +} + +function descriptionFor(name: string): string { + const description = getCommandDescription(name); + if (!description) throw new Error(`Missing command description for ${name}`); + return description; +} + +function optionalPoint(record: Record, key: string): PointInput | undefined { + return record[key] === undefined ? undefined : readPoint(record, key); +} diff --git a/src/core/batch.ts b/src/core/batch.ts index 7d991f5cb..5a9b438e8 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -1,7 +1,8 @@ import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; import { AppError, asAppError } from '../utils/errors.ts'; +import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts'; -export const DEFAULT_BATCH_MAX_STEPS = 100; +export { DEFAULT_BATCH_MAX_STEPS }; export const BATCH_BLOCKED_COMMANDS: ReadonlySet = new Set(['batch', 'replay']); const BATCH_ALLOWED_STEP_KEYS = new Set(['command', 'positionals', 'flags', 'runtime']); export const INHERITED_PARENT_FLAG_KEYS = [ diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 1278e0368..a89921d71 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -1,4 +1,4 @@ -import type { CliFlags, DaemonExcludedCliFlag } from '../utils/command-schema.ts'; +import type { CliFlags, DaemonExcludedCliFlag } from '../utils/cli-flags.ts'; import type { ScreenshotDispatchFlags } from '../commands/capture-screenshot-options.ts'; import type { DaemonBatchStep } from './batch.ts'; import type { ClickButton } from './click-button.ts'; diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index c091c5847..23fe549c5 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -1,10 +1,5 @@ import { AppError } from '../utils/errors.ts'; import type { DeviceInfo } from '../utils/device.ts'; -import { readAndroidTextAtPoint } from '../platforms/android/input-actions.ts'; -import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; -import { runMacOsPressAction, runMacOsReadTextAction } from '../platforms/ios/macos-helper.ts'; -import { rightClickLinux, middleClickLinux } from '../platforms/linux/input-actions.ts'; -import { readLinuxTextAtPoint } from '../platforms/linux/snapshot.ts'; import { successText, withSuccessText } from '../utils/success-text.ts'; import { findMistargetedTypeRefToken } from '../utils/type-target-warning.ts'; import { parseScrollDirection } from './scroll-gesture.ts'; @@ -202,6 +197,7 @@ async function handleMacOsSurfacePress( `${clickButton} click is not supported on macOS ${context.surface} sessions.`, ); } + const { runMacOsPressAction } = await import('../platforms/ios/macos-helper.ts'); await runMacOsPressAction(x, y, { bundleId: context.appBundleId, surface: context.surface, @@ -220,6 +216,7 @@ async function handleAlternateClick( if (device.platform === 'linux') { return await runLinuxAlternateClick(x, y, button); } + const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts'); await runIosRunnerCommand( device, { @@ -265,8 +262,10 @@ async function runLinuxAlternateClick( button: ClickButton, ): Promise> { if (button === 'secondary') { + const { rightClickLinux } = await import('../platforms/linux/input-actions.ts'); await rightClickLinux(x, y); } else { + const { middleClickLinux } = await import('../platforms/linux/input-actions.ts'); await middleClickLinux(x, y); } return { @@ -313,6 +312,7 @@ async function runIosTapSeries( series: PressSeriesOptions, context: DispatchContext | undefined, ): Promise> { + const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts'); const runnerResult = await runIosRunnerCommand( device, { @@ -418,6 +418,7 @@ export async function handleSwipeCommand( } if (shouldUseIosDragSeries(device, count)) { + const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts'); const runnerResult = await runIosRunnerCommand( device, { @@ -819,14 +820,17 @@ export async function handleReadCommand( ): Promise> { const { x, y } = readPoint(positionals, 'read requires x y'); if (device.platform === 'android') { + const { readAndroidTextAtPoint } = await import('../platforms/android/input-actions.ts'); const text = await readAndroidTextAtPoint(device, x, y); return { action: 'read', text: text ?? '' }; } if (device.platform === 'linux') { + const { readLinuxTextAtPoint } = await import('../platforms/linux/snapshot.ts'); const text = await readLinuxTextAtPoint(x, y, context?.surface); return { action: 'read', text }; } if (device.platform === 'macos' && context?.surface && context.surface !== 'app') { + const { runMacOsReadTextAction } = await import('../platforms/ios/macos-helper.ts'); const result = await runMacOsReadTextAction(x, y, { bundleId: context.appBundleId, surface: context.surface, @@ -834,6 +838,7 @@ export async function handleReadCommand( return { action: 'read', text: result.text }; } // macOS app sessions run through the XCUITest runner; only desktop/menubar surfaces use the helper. + const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts'); const result = await runIosRunnerCommand( device, { diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 068a7d801..8ad7d1276 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -8,17 +8,12 @@ import { type DeviceTarget, type PlatformSelector, } from '../utils/device.ts'; -import { listAndroidDevices } from '../platforms/android/devices.ts'; -import { ensureAdb } from '../platforms/android/adb.ts'; -import { findBootableIosSimulator, listAppleDevices } from '../platforms/ios/devices.ts'; -import { listLinuxDevices } from '../platforms/linux/devices.ts'; -import { listMacosDevices } from '../platforms/macos/devices.ts'; import { withDiagnosticTimer } from '../utils/diagnostics.ts'; import { resolveAndroidSerialAllowlist, resolveIosSimulatorDeviceSetPath, } from '../utils/device-isolation.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; type ResolveDeviceFlags = Pick< CliFlags, | 'platform' @@ -70,6 +65,7 @@ async function resolveAppleDevice( const selected = await resolveAppleDeviceCandidate(devices, selector, context); if (shouldUseAppleSimulatorFallback(selector, selected)) { + const { findBootableIosSimulator } = await import('../platforms/ios/devices.ts'); const simulator = await findBootableIosSimulator({ simulatorSetPath: context.simulatorSetPath, target: selector.target, @@ -193,6 +189,7 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise { if (shouldUseHostMacFastPath(request)) { + const { listMacosDevices } = await import('../platforms/macos/devices.ts'); return await listMacosDevices(); } if (request.platform === 'linux') { + const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); return await listLinuxDevices(); } if (request.platform === 'android') { + const { listAndroidDevices } = await import('../platforms/android/devices.ts'); return await listAndroidDevices({ serialAllowlist: request.androidSerialAllowlist ? new Set(request.androidSerialAllowlist) @@ -287,6 +287,7 @@ async function listLocalDeviceInventory(request: DeviceInventoryRequest): Promis } if (request.platform) { + const { listAppleDevices } = await import('../platforms/ios/devices.ts'); return await listAppleDevices({ simulatorSetPath: request.iosSimulatorSetPath, udid: request.udid, @@ -295,6 +296,7 @@ async function listLocalDeviceInventory(request: DeviceInventoryRequest): Promis const devices: DeviceInfo[] = []; try { + const { listAndroidDevices } = await import('../platforms/android/devices.ts'); devices.push( ...(await listAndroidDevices({ serialAllowlist: request.androidSerialAllowlist @@ -304,6 +306,7 @@ async function listLocalDeviceInventory(request: DeviceInventoryRequest): Promis ); } catch {} try { + const { listAppleDevices } = await import('../platforms/ios/devices.ts'); devices.push( ...(await listAppleDevices({ simulatorSetPath: request.iosSimulatorSetPath, @@ -314,6 +317,7 @@ async function listLocalDeviceInventory(request: DeviceInventoryRequest): Promis // Linux local device is appended last so it does not displace // connected Android/Apple devices in implicit auto-selection. try { + const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); devices.push(...(await listLinuxDevices())); } catch {} return devices; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 8afc629cd..2a10c0f3b 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -2,16 +2,8 @@ import { promises as fs } from 'node:fs'; import pathModule from 'node:path'; import { AppError } from '../utils/errors.ts'; import type { DeviceInfo } from '../utils/device.ts'; -import { - dismissAndroidKeyboard, - getAndroidKeyboardState, -} from '../platforms/android/device-input-state.ts'; -import { pressAndroidEnter } from '../platforms/android/input-actions.ts'; -import { pushAndroidNotification } from '../platforms/android/notifications.ts'; import { getInteractor } from './interactors.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; -import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; -import { clearIosSimulatorAppState, pushIosNotification } from '../platforms/ios/apps.ts'; import { isDeepLinkTarget } from './open-target.ts'; import { parseTriggerAppEventArgs, resolveAppEventUrl } from './app-events.ts'; import { @@ -59,7 +51,7 @@ export async function dispatchCommand( logPath: context?.logPath, traceLogPath: context?.traceLogPath, }; - const interactor = getInteractor(device, runnerCtx); + const interactor = await getInteractor(device, runnerCtx); emitDiagnostic({ level: 'debug', phase: 'platform_command_prepare', @@ -238,6 +230,7 @@ async function handleOpenCommand( 'Clearing app state is currently supported only on iOS simulators.', ); } + const { clearIosSimulatorAppState } = await import('../platforms/ios/apps.ts'); await clearIosSimulatorAppState(device, app); } await interactor.open(app, { @@ -342,6 +335,7 @@ async function handleAndroidKeyboardCommand( action: KeyboardAction, ): Promise> { if (action === 'enter' || action === 'return') { + const { pressAndroidEnter } = await import('../platforms/android/input-actions.ts'); await pressAndroidEnter(device); return { platform: 'android', @@ -350,6 +344,7 @@ async function handleAndroidKeyboardCommand( }; } if (action === 'dismiss') { + const { dismissAndroidKeyboard } = await import('../platforms/android/device-input-state.ts'); const result = await dismissAndroidKeyboard(device); return { platform: 'android', @@ -366,6 +361,7 @@ async function handleAndroidKeyboardCommand( inputOwner: result.inputOwner, }; } + const { getAndroidKeyboardState } = await import('../platforms/android/device-input-state.ts'); const state = await getAndroidKeyboardState(device); return { platform: 'android', @@ -393,6 +389,7 @@ async function handleIosKeyboardCommand( ); } if (action === 'enter' || action === 'return') { + const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts'); const result = await runIosRunnerCommand( device, { command: 'keyboardReturn', appBundleId: context?.appBundleId }, @@ -406,6 +403,7 @@ async function handleIosKeyboardCommand( ...successText('Keyboard enter pressed'), }; } + const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts'); const result = await runIosRunnerCommand( device, { command: 'keyboardDismiss', appBundleId: context?.appBundleId }, @@ -484,6 +482,7 @@ async function handlePushCommand( } const payload = await readNotificationPayload(payloadArg); if (device.platform === 'ios') { + const { pushIosNotification } = await import('../platforms/ios/apps.ts'); await pushIosNotification(device, target, payload); return { platform: 'ios', @@ -491,6 +490,7 @@ async function handlePushCommand( ...successText(`Pushed notification to ${target}`), }; } + const { pushAndroidNotification } = await import('../platforms/android/notifications.ts'); const androidResult = await pushAndroidNotification(device, target, payload); return { platform: 'android', diff --git a/src/core/interactors.ts b/src/core/interactors.ts index 3713712b7..e6b9152cc 100644 --- a/src/core/interactors.ts +++ b/src/core/interactors.ts @@ -1,19 +1,25 @@ import type { DeviceInfo } from '../utils/device.ts'; import { AppError } from '../utils/errors.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; -import { createAndroidInteractor } from './interactors/android.ts'; -import { createAppleInteractor } from './interactors/apple.ts'; -import { createLinuxInteractor } from './interactors/linux.ts'; -export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor { +export async function getInteractor( + device: DeviceInfo, + runnerContext: RunnerContext, +): Promise { switch (device.platform) { - case 'android': + case 'android': { + const { createAndroidInteractor } = await import('./interactors/android.ts'); return createAndroidInteractor(device); - case 'linux': + } + case 'linux': { + const { createLinuxInteractor } = await import('./interactors/linux.ts'); return createLinuxInteractor(); + } case 'ios': - case 'macos': + case 'macos': { + const { createAppleInteractor } = await import('./interactors/apple.ts'); return createAppleInteractor(device, runnerContext); + } default: throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${device.platform}`); } diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index 369a9b7be..7eaf182e8 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -1,8 +1,7 @@ import crypto from 'node:crypto'; import { asAppError, AppError } from './utils/errors.ts'; -import { stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts'; import { SessionStore } from './daemon/session-store.ts'; -import { cleanupStaleAppLogProcesses } from './daemon/app-log.ts'; +import { cleanupStaleAppLogProcesses } from './daemon/app-log-process.ts'; import { resolveDaemonPaths, resolveDaemonServerMode } from './daemon/config.ts'; import { createDaemonHttpServer } from './daemon/http-server.ts'; import { trackDownloadableArtifact } from './daemon/artifact-tracking.ts'; @@ -212,6 +211,7 @@ export async function startDaemonRuntime( } await closeDaemonServers(servers); await teardownDaemonSessions(); + const { stopAllIosRunnerSessions } = await import('./platforms/ios/runner-client.ts'); await stopAllIosRunnerSessions(); removeInfo(infoPath); releaseDaemonLock(lockPath); diff --git a/src/daemon/request-platform-providers.ts b/src/daemon/request-platform-providers.ts index 72737ec93..8a3247c1e 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/daemon/request-platform-providers.ts @@ -1,24 +1,18 @@ import { resolveTargetDevice } from '../core/dispatch-resolve.ts'; -import { - type AndroidAdbExecutor, - type AndroidAdbProvider, - withAndroidAdbProvider, -} from '../platforms/android/adb-executor.ts'; -import { - type AppleRunnerCommandExecutor, - type AppleRunnerProvider, - withAppleRunnerProvider, +import type { AndroidAdbExecutor, AndroidAdbProvider } from '../platforms/android/adb-executor.ts'; +import type { + AppleRunnerCommandExecutor, + AppleRunnerProvider, } from '../platforms/ios/runner-provider.ts'; -import { - type AppleToolCommandExecutor, - type AppleToolProvider, - withAppleToolProvider, +import type { + AppleToolCommandExecutor, + AppleToolProvider, } from '../platforms/ios/tool-provider.ts'; -import { type LinuxToolProvider, withLinuxToolProvider } from '../platforms/linux/tool-provider.ts'; +import type { LinuxToolProvider } from '../platforms/linux/tool-provider.ts'; import { isApplePlatform, type DeviceInfo } from '../utils/device.ts'; -import { type AppLogProvider, withAppLogProvider } from './app-log.ts'; +import type { AppLogProvider } from './app-log.ts'; import { hasExplicitDeviceSelector } from './handlers/session-device-utils.ts'; -import { type RecordingProvider, withRecordingProvider } from './recording-provider.ts'; +import type { RecordingProvider } from './recording-provider.ts'; import type { DaemonRequest, SessionState } from './types.ts'; import { PUBLIC_COMMANDS } from '../command-catalog.ts'; @@ -124,7 +118,7 @@ type RequestPlatformProviderDescriptor = { appendWrapper: ( scopedProviders: ResolvedRequestPlatformProviders, wrappers: RequestPlatformProviderScopeWrapper[], - ) => void; + ) => Promise; }; const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ @@ -137,7 +131,9 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ const executor = typeof provider === 'function' ? provider : provider?.exec; return { androidAdb: { provider, executor, serial: context.device.id } }; }, - appendWrapper(scopedProviders, wrappers) { + async appendWrapper(scopedProviders, wrappers) { + if (!scopedProviders.androidAdb?.provider) return; + const { withAndroidAdbProvider } = await import('../platforms/android/adb-executor.ts'); appendRequestProviderWrapper(wrappers, scopedProviders.androidAdb, (provider, task) => withAndroidAdbProvider( provider, @@ -161,7 +157,9 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ }, }; }, - appendWrapper(scopedProviders, wrappers) { + async appendWrapper(scopedProviders, wrappers) { + if (!scopedProviders.appleRunner?.provider) return; + const { withAppleRunnerProvider } = await import('../platforms/ios/runner-provider.ts'); appendRequestProviderWrapper(wrappers, scopedProviders.appleRunner, (provider, task) => withAppleRunnerProvider( provider, @@ -181,7 +179,9 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ if (!appleToolProvider || !isApplePlatform(context.device.platform)) return {}; return { appleTool: { provider: appleToolProvider(context) } }; }, - appendWrapper(scopedProviders, wrappers) { + async appendWrapper(scopedProviders, wrappers) { + if (!scopedProviders.appleTool?.provider) return; + const { withAppleToolProvider } = await import('../platforms/ios/tool-provider.ts'); appendRequestProviderWrapper(wrappers, scopedProviders.appleTool, withAppleToolProvider); }, }, @@ -192,7 +192,9 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ if (!linuxToolProvider || context.device.platform !== 'linux') return {}; return { linuxTool: { provider: linuxToolProvider(context) } }; }, - appendWrapper(scopedProviders, wrappers) { + async appendWrapper(scopedProviders, wrappers) { + if (!scopedProviders.linuxTool?.provider) return; + const { withLinuxToolProvider } = await import('../platforms/linux/tool-provider.ts'); appendRequestProviderWrapper(wrappers, scopedProviders.linuxTool, withLinuxToolProvider); }, }, @@ -203,7 +205,9 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ if (!appLogProvider) return {}; return { appLog: { provider: appLogProvider(context) } }; }, - appendWrapper(scopedProviders, wrappers) { + async appendWrapper(scopedProviders, wrappers) { + if (!scopedProviders.appLog?.provider) return; + const { withAppLogProvider } = await import('./app-log.ts'); appendRequestProviderWrapper(wrappers, scopedProviders.appLog, withAppLogProvider); }, }, @@ -214,7 +218,9 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ if (!recordingProvider) return {}; return { recording: { provider: recordingProvider(context) } }; }, - appendWrapper(scopedProviders, wrappers) { + async appendWrapper(scopedProviders, wrappers) { + if (!scopedProviders.recording?.provider) return; + const { withRecordingProvider } = await import('./recording-provider.ts'); appendRequestProviderWrapper(wrappers, scopedProviders.recording, withRecordingProvider); }, }, @@ -228,7 +234,7 @@ export async function withRequestPlatformProviderScope( const scope: RequestPlatformProviderScope = { androidAdbExecutor: scopedProviders.androidAdb?.executor, }; - const wrappers = requestPlatformProviderScopeWrappers(scopedProviders); + const wrappers = await requestPlatformProviderScopeWrappers(scopedProviders); return await runRequestPlatformProviderScopes(wrappers, async () => await task(scope)); } @@ -291,12 +297,12 @@ function usesSessionlessDefaultDevice(req: DaemonRequest): boolean { ); } -function requestPlatformProviderScopeWrappers( +async function requestPlatformProviderScopeWrappers( scopedProviders: ResolvedRequestPlatformProviders, -): RequestPlatformProviderScopeWrapper[] { +): Promise { const wrappers: RequestPlatformProviderScopeWrapper[] = []; for (const descriptor of REQUEST_PLATFORM_PROVIDER_DESCRIPTORS) { - descriptor.appendWrapper(scopedProviders, wrappers); + await descriptor.appendWrapper(scopedProviders, wrappers); } return wrappers; } diff --git a/src/daemon/transport.ts b/src/daemon/transport.ts index ab255489c..4b04faf50 100644 --- a/src/daemon/transport.ts +++ b/src/daemon/transport.ts @@ -2,7 +2,6 @@ import net from 'node:net'; import type { Server as HttpServer } from 'node:http'; import { AppError, normalizeError } from '../utils/errors.ts'; import type { DaemonRequest, DaemonResponse } from './types.ts'; -import { abortAllIosRunnerSessions } from '../platforms/ios/runner-client.ts'; import { clearRequestCanceled, createRequestCanceledError, @@ -56,6 +55,7 @@ export function createSocketServer( try { const deadline = Date.now() + disconnectAbortMaxWindowMs; while (inFlightRequests > 0 && Date.now() < deadline) { + const { abortAllIosRunnerSessions } = await import('../platforms/ios/runner-client.ts'); await abortAllIosRunnerSessions(); if (inFlightRequests <= 0) break; await sleep(disconnectAbortPollIntervalMs); diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index bccda730b..a89a13077 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -1,12 +1,10 @@ -import { createAgentDeviceClient } from '../client.ts'; import type { AgentDeviceClient, AgentDeviceClientConfig } from '../client-types.ts'; +import type { JsonSchema } from '../commands/command-contract.ts'; import { isCommandName, - listMcpToolDefinitions, - runCommand, + listMcpCommandMetadata, type CommandName, -} from '../commands/command-surface.ts'; -import type { JsonSchema } from '../commands/command-contract.ts'; +} from '../commands/command-metadata.ts'; type ToolResult = { isError: boolean; @@ -15,8 +13,10 @@ type ToolResult = { }; type CommandToolExecutorDeps = { - createClient: (config: AgentDeviceClientConfig) => AgentDeviceClient; - runCommand: (client: AgentDeviceClient, name: CommandName, input: unknown) => Promise; + createClient?: ( + config: AgentDeviceClientConfig, + ) => AgentDeviceClient | Promise; + runCommand?: (client: AgentDeviceClient, name: CommandName, input: unknown) => Promise; }; type CommandToolExecutor = { @@ -28,26 +28,25 @@ export function listCommandTools(): Array<{ description: string; inputSchema: JsonSchema; }> { - return listMcpToolDefinitions().map((definition) => ({ + return listMcpCommandMetadata().map((definition) => ({ name: definition.name, description: definition.description, inputSchema: withMcpConfigSchema(definition.inputSchema), })); } -export function createCommandToolExecutor( - deps: CommandToolExecutorDeps = { - createClient: createAgentDeviceClient, - runCommand: runCommand, - }, -): CommandToolExecutor { +export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): CommandToolExecutor { return { execute: async (name, input) => { if (!isCommandName(name)) { throw new Error(`Unknown command tool: ${name}`); } - const client = deps.createClient(readClientConfig(input)); - const result = await deps.runCommand(client, name, stripClientConfigFields(input)); + const client = await createClient(deps, readClientConfig(input)); + const result = await (deps.runCommand ?? runCommand)( + client, + name, + stripClientConfigFields(input), + ); return { isError: false, structuredContent: result, @@ -59,6 +58,24 @@ export function createCommandToolExecutor( export const commandToolExecutor = createCommandToolExecutor(); +async function createClient( + deps: CommandToolExecutorDeps, + config: AgentDeviceClientConfig, +): Promise { + if (deps.createClient) return await deps.createClient(config); + const { createAgentDeviceClient } = await import('../client.ts'); + return createAgentDeviceClient(config); +} + +async function runCommand( + client: AgentDeviceClient, + name: CommandName, + input: unknown, +): Promise { + const commandSurface = await import('../commands/command-surface.ts'); + return await commandSurface.runCommand(client, name, input); +} + function readClientConfig(input: unknown): AgentDeviceClientConfig { if (!input || typeof input !== 'object' || Array.isArray(input)) return {}; const stateDir = (input as Record).stateDir; diff --git a/src/platforms/ios/app-filter.ts b/src/platforms/ios/app-filter.ts index 76337cc37..a484c6f21 100644 --- a/src/platforms/ios/app-filter.ts +++ b/src/platforms/ios/app-filter.ts @@ -1,4 +1,4 @@ -import type { IosAppInfo } from './devicectl.ts'; +import type { IosAppInfo } from './app-info.ts'; export function filterAppleAppsByBundlePrefix( apps: IosAppInfo[], diff --git a/src/platforms/ios/app-info.ts b/src/platforms/ios/app-info.ts new file mode 100644 index 000000000..4ca6c2c0e --- /dev/null +++ b/src/platforms/ios/app-info.ts @@ -0,0 +1,5 @@ +export type IosAppInfo = { + bundleId: string; + name: string; + url?: string; +}; diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index dc7daae4a..d8d16a192 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -34,8 +34,8 @@ import { listIosDeviceApps, resolveIosDevicectlHint, runIosDevicectl, - type IosAppInfo, } from './devicectl.ts'; +import type { IosAppInfo } from './app-info.ts'; import { isSimulatorLaunchFBSError, probeSimulatorLaunchContext, diff --git a/src/platforms/ios/devicectl.ts b/src/platforms/ios/devicectl.ts index 204b924f9..abfada782 100644 --- a/src/platforms/ios/devicectl.ts +++ b/src/platforms/ios/devicectl.ts @@ -7,12 +7,8 @@ import { AppError } from '../../utils/errors.ts'; import { IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; import { runXcrun } from './tool-provider.ts'; - -export type IosAppInfo = { - bundleId: string; - name: string; - url?: string; -}; +import type { IosAppInfo } from './app-info.ts'; +import { filterAppleAppsByBundlePrefix } from './app-filter.ts'; type IosDeviceAppsPayload = { result?: { @@ -169,10 +165,7 @@ export function parseIosDeviceProcessesPayload(payload: unknown): IosDeviceProce } function filterIosDeviceApps(apps: IosAppInfo[], filter: 'user-installed' | 'all'): IosAppInfo[] { - if (filter === 'user-installed') { - return apps.filter((app) => !app.bundleId.startsWith('com.apple.')); - } - return apps; + return filterAppleAppsByBundlePrefix(apps, filter); } export const IOS_DEVICECTL_DEFAULT_HINT = diff --git a/src/platforms/ios/macos-apps.ts b/src/platforms/ios/macos-apps.ts index 50fbc863d..4fdb229e0 100644 --- a/src/platforms/ios/macos-apps.ts +++ b/src/platforms/ios/macos-apps.ts @@ -6,7 +6,7 @@ import { parseAppearanceAction } from '../appearance.ts'; import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; import { quitMacOsApp } from './macos-helper.ts'; import { resolveAppleToolProvider, type AppleMacOsHostProvider } from './tool-provider.ts'; -import type { IosAppInfo } from './devicectl.ts'; +import type { IosAppInfo } from './app-info.ts'; const MACOS_ALIASES: Record = { settings: 'com.apple.systempreferences', diff --git a/src/platforms/ios/macos-host-provider.ts b/src/platforms/ios/macos-host-provider.ts index c06927f59..b9bb2d1ce 100644 --- a/src/platforms/ios/macos-host-provider.ts +++ b/src/platforms/ios/macos-host-provider.ts @@ -4,8 +4,8 @@ import path from 'node:path'; import type { AppsFilter } from '../../commands/app-inventory-contract.ts'; import { AppError } from '../../utils/errors.ts'; import { filterAppleAppsByBundlePrefix } from './app-filter.ts'; -import type { IosAppInfo } from './devicectl.ts'; -import type { AppleMacOsHostProvider, AppleToolCommandExecutor } from './tool-provider.ts'; +import type { IosAppInfo } from './app-info.ts'; +import type { AppleMacOsHostProvider, AppleToolCommandExecutor } from './tool-provider-types.ts'; type ApplePlistJsonReader = (plistPath: string) => Promise | null>; diff --git a/src/platforms/ios/tool-provider-types.ts b/src/platforms/ios/tool-provider-types.ts new file mode 100644 index 000000000..e44f86014 --- /dev/null +++ b/src/platforms/ios/tool-provider-types.ts @@ -0,0 +1,38 @@ +import type { AppsFilter } from '../../commands/app-inventory-contract.ts'; +import type { ExecOptions, ExecResult } from '../../utils/exec.ts'; +import type { IosAppInfo } from './app-info.ts'; + +export type AppleToolCommandExecutor = ( + cmd: string, + args: string[], + options?: ExecOptions, +) => Promise; + +export type AppleToolSubcommandExecutor = ( + args: string[], + options?: ExecOptions, +) => Promise; + +export type AppleToolAvailabilityChecker = (cmd: string) => Promise; + +export type AppleXcrunToolProvider = { + run: AppleToolSubcommandExecutor; +}; + +export type AppleMacOsHelperProvider = { + run: AppleToolSubcommandExecutor; +}; + +export type ApplePlistProvider = { + readJson(path: string): Promise | null>; +}; + +export type AppleMacOsHostProvider = { + openBundle(bundleId: string, url?: string): Promise; + openTarget(target: string): Promise; + readClipboard(): Promise; + writeClipboard(text: string): Promise; + readDarkMode(): Promise; + setDarkMode(enabled: boolean): Promise; + listApps(filter: AppsFilter): Promise; +}; diff --git a/src/platforms/ios/tool-provider.ts b/src/platforms/ios/tool-provider.ts index 42bd643be..0c6d9a60e 100644 --- a/src/platforms/ios/tool-provider.ts +++ b/src/platforms/ios/tool-provider.ts @@ -1,43 +1,24 @@ import { runCmd, whichCmd, type ExecOptions, type ExecResult } from '../../utils/exec.ts'; import { createScopedProvider } from '../../utils/scoped-provider.ts'; -import type { AppsFilter } from '../../commands/app-inventory-contract.ts'; -import type { IosAppInfo } from './devicectl.ts'; import { createLocalAppleMacOsHostProvider } from './macos-host-provider.ts'; - -export type AppleToolCommandExecutor = ( - cmd: string, - args: string[], - options?: ExecOptions, -) => Promise; - -export type AppleToolSubcommandExecutor = ( - args: string[], - options?: ExecOptions, -) => Promise; - -export type AppleToolAvailabilityChecker = (cmd: string) => Promise; - -export type AppleXcrunToolProvider = { - run: AppleToolSubcommandExecutor; -}; - -export type AppleMacOsHelperProvider = { - run: AppleToolSubcommandExecutor; -}; - -export type ApplePlistProvider = { - readJson(path: string): Promise | null>; -}; - -export type AppleMacOsHostProvider = { - openBundle(bundleId: string, url?: string): Promise; - openTarget(target: string): Promise; - readClipboard(): Promise; - writeClipboard(text: string): Promise; - readDarkMode(): Promise; - setDarkMode(enabled: boolean): Promise; - listApps(filter: AppsFilter): Promise; -}; +import type { + AppleMacOsHelperProvider, + AppleMacOsHostProvider, + ApplePlistProvider, + AppleToolAvailabilityChecker, + AppleToolCommandExecutor, + AppleXcrunToolProvider, +} from './tool-provider-types.ts'; + +export type { + AppleMacOsHelperProvider, + AppleMacOsHostProvider, + ApplePlistProvider, + AppleToolAvailabilityChecker, + AppleToolCommandExecutor, + AppleToolSubcommandExecutor, + AppleXcrunToolProvider, +} from './tool-provider-types.ts'; export type AppleToolProvider = { runCommand: AppleToolCommandExecutor; diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index 3f3d3cf6e..e4354d152 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { resolveRemoteConfigPath, resolveRemoteConfigProfile } from './remote-config-core.ts'; import { AppError } from './utils/errors.ts'; import { emitDiagnostic } from './utils/diagnostics.ts'; -import type { CliFlags } from './utils/command-schema.ts'; +import type { CliFlags } from './utils/cli-flags.ts'; import type { LeaseBackend, SessionRuntimeHints } from './contracts.ts'; export type RemoteConnectionState = { diff --git a/src/utils/__tests__/interactors.test.ts b/src/utils/__tests__/interactors.test.ts index 2e154aed5..9469d8b18 100644 --- a/src/utils/__tests__/interactors.test.ts +++ b/src/utils/__tests__/interactors.test.ts @@ -58,7 +58,7 @@ test('ios scroll reports planned pixels without recomputing from runner coordina } throw new Error(`Unexpected runner command: ${command.command}`); }); - const interactor = getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); const result = await interactor.scroll('down', { pixels: 120 }); const pixels = @@ -72,7 +72,7 @@ test('ios fill sends one verified replacement text-entry command at the target c commands.push(command); return {}; }); - const interactor = getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); await interactor.fill(120, 240, 'hunter2'); @@ -95,7 +95,7 @@ test('ios type uses verified append text-entry mode', async () => { commands.push(command); return {}; }); - const interactor = getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); await interactor.type('hello', 25); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 75b627738..13afdfa6e 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -1,5 +1,5 @@ import { SETTINGS_USAGE_OVERRIDE } from '../core/settings-contract.ts'; -import type { CommandName } from '../commands/command-surface.ts'; +import type { CommandName } from '../commands/command-metadata.ts'; import { DEFAULT_APPS_FILTER } from '../commands/app-inventory-contract.ts'; import { SCREENSHOT_COMMAND_FLAG_KEYS } from '../commands/capture-screenshot-options.ts'; import type { LocalCliCommandName } from '../command-catalog.ts'; diff --git a/src/utils/cli-config.ts b/src/utils/cli-config.ts index c7ac3d012..119560e5a 100644 --- a/src/utils/cli-config.ts +++ b/src/utils/cli-config.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from './errors.ts'; import { mergeDefinedFlags } from './merge-flags.ts'; -import { type CliFlags, type FlagKey } from './command-schema.ts'; +import { type CliFlags, type FlagKey } from './cli-flags.ts'; import { expandUserHomePath, resolveUserPath } from './path-resolution.ts'; import { getConfigurableOptionSpecs, diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index be71adb27..543a0c8b2 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -1,4 +1,4 @@ -import type { CliFlags } from './command-schema.ts'; +import type { CliFlags } from './cli-flags.ts'; import { mergeDefinedFlags } from './merge-flags.ts'; import { finalizeParsedArgs, parseRawArgs } from './args.ts'; import { resolveConfigBackedFlagDefaults } from './cli-config.ts'; diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 9d47dfb37..82fdff869 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -1,4 +1,4 @@ -import { listCommandDefinitions } from '../commands/command-surface.ts'; +import { listCommandDescriptionMetadata } from '../commands/command-descriptions.ts'; import type { CliCommandName } from '../command-catalog.ts'; import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema-types.ts'; import { getCliCommandOverride, getSchemaOnlyCliCommandSchema } from './cli-command-overrides.ts'; @@ -17,7 +17,7 @@ export type { CommandSchema, CommandSchemaOverride }; export { getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS }; const COMMAND_SCHEMA_BASES = new Map( - listCommandDefinitions().map((definition) => [ + listCommandDescriptionMetadata().map((definition) => [ definition.name, { helpDescription: definition.description }, ]), diff --git a/src/utils/remote-config.ts b/src/utils/remote-config.ts index 8abc40b60..66375a1bf 100644 --- a/src/utils/remote-config.ts +++ b/src/utils/remote-config.ts @@ -1,4 +1,4 @@ -import type { CliFlags } from './command-schema.ts'; +import type { CliFlags } from './cli-flags.ts'; import { REMOTE_CONFIG_FIELD_SPECS, type RemoteConfigProfile } from '../remote-config-schema.ts'; import { resolveRemoteConfigProfile } from '../remote-config-core.ts'; diff --git a/src/utils/session-binding.ts b/src/utils/session-binding.ts index 6d5d1e6bc..5ce5b66d6 100644 --- a/src/utils/session-binding.ts +++ b/src/utils/session-binding.ts @@ -1,5 +1,5 @@ import { AppError } from './errors.ts'; -import type { CliFlags } from './command-schema.ts'; +import type { CliFlags } from './cli-flags.ts'; import type { DaemonLockPolicy } from '../daemon/types.ts'; export type BindingSettings = { From 575e15656fdac70718a479c4b910c0559fd87249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 18:22:56 +0200 Subject: [PATCH 3/6] refactor: isolate platform inventory loading --- src/core/dispatch-resolve.ts | 78 +-------------------------------- src/core/platform-inventory.ts | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 76 deletions(-) create mode 100644 src/core/platform-inventory.ts diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 8ad7d1276..693e80b8a 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -14,6 +14,7 @@ import { resolveIosSimulatorDeviceSetPath, } from '../utils/device-isolation.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; +import { listLocalDeviceInventory, type DeviceInventoryRequest } from './platform-inventory.ts'; type ResolveDeviceFlags = Pick< CliFlags, | 'platform' @@ -28,15 +29,7 @@ type ResolveDeviceFlags = Pick< const resolveTargetDeviceCacheScope = new AsyncLocalStorage>(); const deviceInventoryProviderScope = new AsyncLocalStorage(); -export type DeviceInventoryRequest = { - platform?: PlatformSelector; - target?: DeviceTarget; - deviceName?: string; - udid?: string; - serial?: string; - iosSimulatorSetPath?: string; - androidSerialAllowlist?: string[]; -}; +export type { DeviceInventoryRequest }; export type DeviceInventoryProvider = ( request: DeviceInventoryRequest, @@ -219,16 +212,6 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise(task: () => Promise): Promise { if (resolveTargetDeviceCacheScope.getStore()) return await task(); return await resolveTargetDeviceCacheScope.run(new Map(), task); @@ -266,63 +249,6 @@ async function readInjectedDeviceInventory( return devices.map((device) => ({ ...device })); } -async function listLocalDeviceInventory(request: DeviceInventoryRequest): Promise { - if (shouldUseHostMacFastPath(request)) { - const { listMacosDevices } = await import('../platforms/macos/devices.ts'); - return await listMacosDevices(); - } - - if (request.platform === 'linux') { - const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); - return await listLinuxDevices(); - } - - if (request.platform === 'android') { - const { listAndroidDevices } = await import('../platforms/android/devices.ts'); - return await listAndroidDevices({ - serialAllowlist: request.androidSerialAllowlist - ? new Set(request.androidSerialAllowlist) - : undefined, - }); - } - - if (request.platform) { - const { listAppleDevices } = await import('../platforms/ios/devices.ts'); - return await listAppleDevices({ - simulatorSetPath: request.iosSimulatorSetPath, - udid: request.udid, - }); - } - - const devices: DeviceInfo[] = []; - try { - const { listAndroidDevices } = await import('../platforms/android/devices.ts'); - devices.push( - ...(await listAndroidDevices({ - serialAllowlist: request.androidSerialAllowlist - ? new Set(request.androidSerialAllowlist) - : undefined, - })), - ); - } catch {} - try { - const { listAppleDevices } = await import('../platforms/ios/devices.ts'); - devices.push( - ...(await listAppleDevices({ - simulatorSetPath: request.iosSimulatorSetPath, - udid: request.udid, - })), - ); - } catch {} - // Linux local device is appended last so it does not displace - // connected Android/Apple devices in implicit auto-selection. - try { - const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); - devices.push(...(await listLinuxDevices())); - } catch {} - return devices; -} - function isAppleResolutionSelector(selector: { platform?: PlatformSelector; target?: DeviceTarget; diff --git a/src/core/platform-inventory.ts b/src/core/platform-inventory.ts new file mode 100644 index 000000000..5835b08b6 --- /dev/null +++ b/src/core/platform-inventory.ts @@ -0,0 +1,80 @@ +import type { DeviceInfo, DeviceTarget, PlatformSelector } from '../utils/device.ts'; + +export type DeviceInventoryRequest = { + platform?: PlatformSelector; + target?: DeviceTarget; + deviceName?: string; + udid?: string; + serial?: string; + iosSimulatorSetPath?: string; + androidSerialAllowlist?: string[]; +}; + +export async function listLocalDeviceInventory( + request: DeviceInventoryRequest, +): Promise { + if (shouldUseHostMacFastPath(request)) { + const { listMacosDevices } = await import('../platforms/macos/devices.ts'); + return await listMacosDevices(); + } + + if (request.platform === 'linux') { + const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); + return await listLinuxDevices(); + } + + if (request.platform === 'android') { + const { listAndroidDevices } = await import('../platforms/android/devices.ts'); + return await listAndroidDevices({ + serialAllowlist: request.androidSerialAllowlist + ? new Set(request.androidSerialAllowlist) + : undefined, + }); + } + + if (request.platform) { + const { listAppleDevices } = await import('../platforms/ios/devices.ts'); + return await listAppleDevices({ + simulatorSetPath: request.iosSimulatorSetPath, + udid: request.udid, + }); + } + + const devices: DeviceInfo[] = []; + try { + const { listAndroidDevices } = await import('../platforms/android/devices.ts'); + devices.push( + ...(await listAndroidDevices({ + serialAllowlist: request.androidSerialAllowlist + ? new Set(request.androidSerialAllowlist) + : undefined, + })), + ); + } catch {} + try { + const { listAppleDevices } = await import('../platforms/ios/devices.ts'); + devices.push( + ...(await listAppleDevices({ + simulatorSetPath: request.iosSimulatorSetPath, + udid: request.udid, + })), + ); + } catch {} + // Linux local device is appended last so it does not displace + // connected Android/Apple devices in implicit auto-selection. + try { + const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); + devices.push(...(await listLinuxDevices())); + } catch {} + return devices; +} + +function shouldUseHostMacFastPath(selector: { + platform?: PlatformSelector; + target?: DeviceTarget; +}): boolean { + return ( + selector.platform === 'macos' || + (selector.platform === 'apple' && selector.target === 'desktop') + ); +} From e72dddec914c0744a8a4971541ec5dba51ff4e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 29 May 2026 18:38:24 +0200 Subject: [PATCH 4/6] refactor: trim lazy loading cleanup --- src/cli/commands/connection.ts | 48 ++++++++++++-------- src/commands/client-command-metadata.ts | 6 +-- src/commands/command-descriptions.ts | 8 +++- src/commands/interaction-command-metadata.ts | 18 +++----- src/core/dispatch-resolve.ts | 5 -- 5 files changed, 43 insertions(+), 42 deletions(-) diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index ed9f14bfc..cae7a593d 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -149,17 +149,9 @@ function resolveRemoteConnectFlags(flags: CliFlags): { } export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { - const session = flags.session ?? 'default'; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; - const state = - readRemoteConnectionState({ stateDir, session }) ?? - (flags.session ? null : readActiveConnectionState({ stateDir })); + const { session, stateDir, state } = readRequestedConnectionState(flags); if (!state) { - writeCommandOutput( - flags, - { connected: false, session }, - () => `No remote connection for "${session}".`, - ); + writeNoRemoteConnectionOutput(flags, session); return true; } const connectedSession = state.session; @@ -197,17 +189,9 @@ export const connectionCommand: ClientCommandHandler = async ({ positionals, fla if (positionals[0] !== 'status') { throw new AppError('INVALID_ARGS', 'connection accepts only: status'); } - const session = flags.session ?? 'default'; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; - const state = - readRemoteConnectionState({ stateDir, session }) ?? - (flags.session ? null : readActiveConnectionState({ stateDir })); + const { session, state } = readRequestedConnectionState(flags); if (!state) { - writeCommandOutput( - flags, - { connected: false, session }, - () => `No remote connection for "${session}".`, - ); + writeNoRemoteConnectionOutput(flags, session); return true; } const leasePreparation = buildLeasePreparationNotice(state); @@ -237,6 +221,30 @@ function createRemoteSessionName(stateDir: string): string { return `adc-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`; } +function readRequestedConnectionState(flags: CliFlags): { + session: string; + stateDir: string; + state: RemoteConnectionState | null; +} { + const session = flags.session ?? 'default'; + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + return { + session, + stateDir, + state: + readRemoteConnectionState({ stateDir, session }) ?? + (flags.session ? null : readActiveConnectionState({ stateDir })), + }; +} + +function writeNoRemoteConnectionOutput(flags: CliFlags, session: string): void { + writeCommandOutput( + flags, + { connected: false, session }, + () => `No remote connection for "${session}".`, + ); +} + function isCompatibleConnection( state: RemoteConnectionState, options: { diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index a47ad1a70..83f922ea4 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -1,6 +1,6 @@ import type { MetroPrepareOptions, RecordOptions } from '../client-types.ts'; import type { DaemonInstallSource } from '../contracts.ts'; -import { getCommandDescription } from './command-descriptions.ts'; +import { requireCommandDescription } from './command-descriptions.ts'; import { booleanField, booleanSchema, @@ -233,7 +233,5 @@ function defineClientCommandMetadata< const TName extends string, const TFields extends CommandFieldMap, >(name: TName, fields: TFields) { - const description = getCommandDescription(name); - if (!description) throw new Error(`Missing command description for ${name}`); - return defineFieldCommandMetadata(name, description, fields); + return defineFieldCommandMetadata(name, requireCommandDescription(name), fields); } diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts index e5f9a9291..23be6e089 100644 --- a/src/commands/command-descriptions.ts +++ b/src/commands/command-descriptions.ts @@ -49,10 +49,16 @@ const COMMAND_DESCRIPTIONS = { export type DescribedCommandName = keyof typeof COMMAND_DESCRIPTIONS; -export function getCommandDescription(command: string): string | undefined { +function getCommandDescription(command: string): string | undefined { return COMMAND_DESCRIPTIONS[command as DescribedCommandName]; } +export function requireCommandDescription(command: string): string { + const description = getCommandDescription(command); + if (!description) throw new Error(`Missing command description for ${command}`); + return description; +} + export function listCommandDescriptionMetadata(): Array<{ name: DescribedCommandName; description: string; diff --git a/src/commands/interaction-command-metadata.ts b/src/commands/interaction-command-metadata.ts index fb295ef3c..253afa980 100644 --- a/src/commands/interaction-command-metadata.ts +++ b/src/commands/interaction-command-metadata.ts @@ -1,4 +1,4 @@ -import { getCommandDescription } from './command-descriptions.ts'; +import { requireCommandDescription } from './command-descriptions.ts'; import { defineCommandMetadata } from './command-contract.ts'; import { booleanField, @@ -185,19 +185,19 @@ export type GestureInput = PanInput | FlingInput | PinchInput | RotateInput | Tr export const interactionCommandMetadata = [ defineCommandMetadata({ name: 'click', - description: descriptionFor('click'), + description: requireCommandDescription('click'), inputSchema: fieldsInputSchema(clickFields), readInput: (input) => readFieldInput(input, clickFields), }), defineCommandMetadata({ name: 'press', - description: descriptionFor('press'), + description: requireCommandDescription('press'), inputSchema: fieldsInputSchema(pressFields), readInput: (input) => readFieldInput(input, pressFields), }), defineCommandMetadata({ name: 'fill', - description: descriptionFor('fill'), + description: requireCommandDescription('fill'), inputSchema: fieldsInputSchema(fillFields), readInput: (input) => readFieldInput(input, fillFields), }), @@ -211,7 +211,7 @@ export const interactionCommandMetadata = [ defineInteractionCommandMetadata('find', findFields), defineCommandMetadata({ name: 'gesture', - description: descriptionFor('gesture'), + description: requireCommandDescription('gesture'), inputSchema: fieldsInputSchema(gestureFields), readInput: readGestureInput, }), @@ -272,13 +272,7 @@ function defineInteractionCommandMetadata< const TName extends string, const TFields extends CommandFieldMap, >(name: TName, fields: TFields) { - return defineFieldCommandMetadata(name, descriptionFor(name), fields); -} - -function descriptionFor(name: string): string { - const description = getCommandDescription(name); - if (!description) throw new Error(`Missing command description for ${name}`); - return description; + return defineFieldCommandMetadata(name, requireCommandDescription(name), fields); } function optionalPoint(record: Record, key: string): PointInput | undefined { diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 693e80b8a..57307d6c2 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -181,11 +181,6 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise Date: Sat, 30 May 2026 08:58:18 +0200 Subject: [PATCH 5/6] test: guard daemon routing metadata drift --- AGENTS.md | 1 + .../command-surface-metadata.test.ts | 21 ++++ src/commands/command-metadata.ts | 4 + src/commands/command-surface.ts | 4 + .../__tests__/request-handler-catalog.test.ts | 100 ++++++++++++++++++ src/daemon/handlers/find.ts | 5 + src/daemon/handlers/interaction.ts | 10 ++ src/daemon/handlers/lease.ts | 7 ++ src/daemon/handlers/react-native.ts | 4 + src/daemon/handlers/record-trace.ts | 6 ++ src/daemon/handlers/session.ts | 19 ++++ 11 files changed, 181 insertions(+) create mode 100644 src/commands/__tests__/command-surface-metadata.test.ts create mode 100644 src/daemon/__tests__/request-handler-catalog.test.ts diff --git a/AGENTS.md b/AGENTS.md index fd5a33cd7..086e9b95a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect - CLI/client/runtime output projection: `src/commands/cli-output.ts`, `src/commands/client-output.ts`, `src/commands/runtime-output.ts` - Do not reintroduce CLI-shaped command adapters or schemas as a second source of truth. CLI, Node.js, and MCP should project from command contracts. - Keep `src/daemon/request-router.ts` as request orchestration: auth, diagnostics scope, request admission, locking, handler chain, and fallback dispatch. +- New daemon handler-family commands must update the relevant `DAEMON_COMMAND_GROUPS.*Handler` entry and the handler module's exported `*_COMMAND_HANDLERS` coverage table; `src/daemon/__tests__/request-handler-catalog.test.ts` guards drift and overlap. - Put request policies in focused request modules: - tenant/lease/selector/lock admission: `src/daemon/request-admission.ts` - artifact/error finalization: `src/daemon/request-finalization.ts` diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts new file mode 100644 index 000000000..c4feaefc5 --- /dev/null +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { listMcpExposedCommandNames } from '../../command-catalog.ts'; +import { listCommandMetadataNames, listMcpCommandMetadata } from '../command-metadata.ts'; +import { listExecutableCommandNames } from '../command-surface.ts'; + +test('MCP exposed command names have metadata and executable command definitions', () => { + const mcpExposedNames = listMcpExposedCommandNames().sort(); + const mcpMetadataNames = listMcpCommandMetadata() + .map((definition) => definition.name) + .sort(); + const metadataNames = new Set(listCommandMetadataNames()); + const executableNames = new Set(listExecutableCommandNames()); + + assert.deepEqual(mcpMetadataNames, mcpExposedNames); + + for (const name of mcpExposedNames) { + assert.ok(metadataNames.has(name), `${name} must have command metadata`); + assert.ok(executableNames.has(name), `${name} must have an executable command definition`); + } +}); diff --git a/src/commands/command-metadata.ts b/src/commands/command-metadata.ts index a7c669070..e0679117b 100644 --- a/src/commands/command-metadata.ts +++ b/src/commands/command-metadata.ts @@ -29,6 +29,10 @@ export function listMcpCommandMetadata(): AnyCommandMetadata[] { }); } +export function listCommandMetadataNames(): CommandName[] { + return [...commandMetadataMap.keys()].sort(); +} + export function isCommandName(name: string): name is CommandName { return commandMetadataMap.has(name as CommandName); } diff --git a/src/commands/command-surface.ts b/src/commands/command-surface.ts index 32da3fa6d..ed38a3ef4 100644 --- a/src/commands/command-surface.ts +++ b/src/commands/command-surface.ts @@ -35,6 +35,10 @@ export async function runCommand( return await getCommandDefinition(name).invoke(client, input); } +export function listExecutableCommandNames(): CommandName[] { + return [...commandMap.keys()].sort(); +} + function getCommandDefinition(name: CommandName): AnyExecutableCommand { return commandMap.get(name)!; } diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts new file mode 100644 index 000000000..85920f175 --- /dev/null +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { DAEMON_COMMAND_GROUPS, INTERNAL_COMMANDS } from '../../command-catalog.ts'; +import { FIND_COMMAND_HANDLERS } from '../handlers/find.ts'; +import { INTERACTION_COMMAND_HANDLERS } from '../handlers/interaction.ts'; +import { handleLeaseCommands, LEASE_COMMAND_HANDLERS } from '../handlers/lease.ts'; +import { REACT_NATIVE_COMMAND_HANDLERS } from '../handlers/react-native.ts'; +import { RECORD_TRACE_COMMAND_HANDLERS } from '../handlers/record-trace.ts'; +import { SESSION_COMMAND_HANDLERS } from '../handlers/session.ts'; +import { SNAPSHOT_COMMAND_HANDLERS } from '../handlers/snapshot.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; + +const handlerFamilies = [ + { + name: 'leaseHandler', + commands: DAEMON_COMMAND_GROUPS.leaseHandler, + handlers: LEASE_COMMAND_HANDLERS, + }, + { + name: 'sessionHandler', + commands: DAEMON_COMMAND_GROUPS.sessionHandler, + handlers: SESSION_COMMAND_HANDLERS, + }, + { + name: 'snapshot', + commands: DAEMON_COMMAND_GROUPS.snapshot, + handlers: SNAPSHOT_COMMAND_HANDLERS, + }, + { + name: 'reactNativeHandler', + commands: DAEMON_COMMAND_GROUPS.reactNativeHandler, + handlers: REACT_NATIVE_COMMAND_HANDLERS, + }, + { + name: 'recordTraceHandler', + commands: DAEMON_COMMAND_GROUPS.recordTraceHandler, + handlers: RECORD_TRACE_COMMAND_HANDLERS, + }, + { + name: 'findHandler', + commands: DAEMON_COMMAND_GROUPS.findHandler, + handlers: FIND_COMMAND_HANDLERS, + }, + { + name: 'interactionHandler', + commands: DAEMON_COMMAND_GROUPS.interactionHandler, + handlers: INTERACTION_COMMAND_HANDLERS, + }, +] as const; + +test('daemon handler routing groups match handler coverage', () => { + for (const { name, commands, handlers } of handlerFamilies) { + assert.deepEqual( + Object.keys(handlers).sort(), + [...commands].sort(), + `${name} catalog must match its handler module`, + ); + } +}); + +test('lease handler coverage table points at executable commands', async () => { + const leaseRegistry = new LeaseRegistry(); + const allocated = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-a' }); + + for (const command of Object.keys(LEASE_COMMAND_HANDLERS)) { + const response = await handleLeaseCommands({ + req: { + command, + token: 'test-token', + session: 'catalog-test', + flags: { + tenant: 'tenant-a', + runId: 'run-a', + ...(command === INTERNAL_COMMANDS.leaseAllocate + ? {} + : { leaseId: allocated.leaseId }), + }, + positionals: [], + }, + leaseRegistry, + }); + + assert.notEqual(response, null, `${command} should be handled by lease handler`); + } +}); + +test('daemon handler routing groups are disjoint', () => { + const ownerByCommand = new Map(); + for (const { name, commands } of handlerFamilies) { + for (const command of commands) { + const previousOwner = ownerByCommand.get(command); + assert.equal( + previousOwner, + undefined, + `${command} is routed by both ${previousOwner} and ${name}`, + ); + ownerByCommand.set(command, name); + } + } +}); diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index add0db435..7ad36804b 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -17,9 +17,14 @@ import { setSessionSnapshot } from '../session-snapshot.ts'; import { errorResponse } from './response.ts'; import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts'; import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts'; +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; export { parseFindArgs } from '../../utils/finders.ts'; +export const FIND_COMMAND_HANDLERS = { + [PUBLIC_COMMANDS.find]: true, +} as const satisfies Record; + type FindContext = { req: DaemonRequest; sessionName: string; diff --git a/src/daemon/handlers/interaction.ts b/src/daemon/handlers/interaction.ts index 2d5590a20..caab37671 100644 --- a/src/daemon/handlers/interaction.ts +++ b/src/daemon/handlers/interaction.ts @@ -16,6 +16,16 @@ import { recoverAndroidBlockingSystemDialog, } from '../android-system-dialog.ts'; +export const INTERACTION_COMMAND_HANDLERS = { + [PUBLIC_COMMANDS.click]: true, + [PUBLIC_COMMANDS.fill]: true, + [PUBLIC_COMMANDS.get]: true, + [PUBLIC_COMMANDS.is]: true, + [PUBLIC_COMMANDS.longPress]: true, + [PUBLIC_COMMANDS.press]: true, + [PUBLIC_COMMANDS.type]: true, +} as const satisfies Record; + export async function handleInteractionCommands( params: InteractionHandlerParams, ): Promise { diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index ad36e01ac..05e191085 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -1,12 +1,19 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { resolveLeaseScope } from '../lease-context.ts'; +import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; type LeaseHandlerArgs = { req: DaemonRequest; leaseRegistry: LeaseRegistry; }; +export const LEASE_COMMAND_HANDLERS = { + [INTERNAL_COMMANDS.leaseAllocate]: true, + [INTERNAL_COMMANDS.leaseHeartbeat]: true, + [INTERNAL_COMMANDS.leaseRelease]: true, +} as const satisfies Record; + export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { const { req, leaseRegistry } = args; const leaseScope = resolveLeaseScope(req); diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index bea2f9f56..1453e4087 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -15,6 +15,10 @@ import { captureSnapshotForSession } from './interaction-snapshot.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-frame.ts'; +export const REACT_NATIVE_COMMAND_HANDLERS = { + [PUBLIC_COMMANDS.reactNative]: true, +} as const satisfies Record; + export async function handleReactNativeCommands( params: InteractionHandlerParams, ): Promise { diff --git a/src/daemon/handlers/record-trace.ts b/src/daemon/handlers/record-trace.ts index ccff718d2..ab54e1fd9 100644 --- a/src/daemon/handlers/record-trace.ts +++ b/src/daemon/handlers/record-trace.ts @@ -5,6 +5,12 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { handleRecordCommand } from './record-trace-recording.ts'; import { errorResponse } from './response.ts'; +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; + +export const RECORD_TRACE_COMMAND_HANDLERS = { + [PUBLIC_COMMANDS.record]: true, + [PUBLIC_COMMANDS.trace]: true, +} as const satisfies Record; export async function handleRecordTraceCommands(params: { req: DaemonRequest; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 08c0c05c3..1f96836fa 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -41,6 +41,25 @@ const STATE_COMMANDS = DAEMON_COMMAND_GROUPS.state; const OBSERVABILITY_COMMANDS = DAEMON_COMMAND_GROUPS.observability; const REPLAY_COMMANDS = DAEMON_COMMAND_GROUPS.replay; +export const SESSION_COMMAND_HANDLERS = { + ...Object.fromEntries([...INVENTORY_COMMANDS].map((command) => [command, true] as const)), + ...Object.fromEntries([...STATE_COMMANDS].map((command) => [command, true] as const)), + ...Object.fromEntries([...OBSERVABILITY_COMMANDS].map((command) => [command, true] as const)), + ...Object.fromEntries([...REPLAY_COMMANDS].map((command) => [command, true] as const)), + [INTERNAL_COMMANDS.runtime]: true, + [PUBLIC_COMMANDS.clipboard]: true, + [PUBLIC_COMMANDS.keyboard]: true, + [PUBLIC_COMMANDS.install]: true, + [PUBLIC_COMMANDS.reinstall]: true, + [INTERNAL_COMMANDS.installSource]: true, + [INTERNAL_COMMANDS.releaseMaterializedPaths]: true, + [PUBLIC_COMMANDS.push]: true, + [PUBLIC_COMMANDS.triggerAppEvent]: true, + [PUBLIC_COMMANDS.open]: true, + [PUBLIC_COMMANDS.batch]: true, + [PUBLIC_COMMANDS.close]: true, +} as const satisfies Record; + // fallow-ignore-next-line complexity async function runSessionOrSelectorDispatch(params: { req: DaemonRequest; From bb10697a66b7d9bd326a8ce9474ecc0003d7d75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 30 May 2026 15:46:48 +0200 Subject: [PATCH 6/6] ci: report startup timing with size --- .github/workflows/size.yml | 5 +- scripts/integration-progress.mjs | 1 + scripts/size-report.mjs | 100 ++++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 74f0434cc..aa12dfae8 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -34,7 +34,9 @@ jobs: git checkout --detach "${{ github.event.pull_request.base.sha }}" pnpm install --frozen-lockfile pnpm build - node /tmp/agent-device-size-report.mjs --json /tmp/agent-device-size-base.json + node /tmp/agent-device-size-report.mjs \ + --startup-runs 7 \ + --json /tmp/agent-device-size-base.json - name: Measure PR size run: | @@ -43,6 +45,7 @@ jobs: pnpm build node scripts/size-report.mjs \ --compare /tmp/agent-device-size-base.json \ + --startup-runs 7 \ --json .tmp/size-report.json \ --markdown .tmp/size-report.md diff --git a/scripts/integration-progress.mjs b/scripts/integration-progress.mjs index e295436ae..81dd2d3d1 100644 --- a/scripts/integration-progress.mjs +++ b/scripts/integration-progress.mjs @@ -514,6 +514,7 @@ function readClientCommandMethods() { function readCommandContractBlocks(text) { const starts = [ + ...text.matchAll(/defineExecutableCommand\(\s*metadata\(\s*['"]([^'"]+)['"]\s*\)/g), ...text.matchAll(/defineFieldCommand\(\s*['"]([^'"]+)['"]/g), ...text.matchAll(/defineCommand\(\s*\{[\s\S]*?\bname:\s*['"]([^'"]+)['"]/g), ] diff --git a/scripts/size-report.mjs b/scripts/size-report.mjs index 9fa9c8608..a027307ba 100644 --- a/scripts/size-report.mjs +++ b/scripts/size-report.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { performance } from 'node:perf_hooks'; import { gzipSync } from 'node:zlib'; const COMMENT_MARKER = ''; @@ -12,8 +13,14 @@ const VALUE_ARGS = new Map([ ['--compare', 'compare'], ['--post-comment', 'postComment'], ['--pr', 'pr'], + ['--startup-runs', 'startupRuns'], ]); +const STARTUP_BENCHMARKS = [ + { name: 'CLI --version', args: ['--version'] }, + { name: 'CLI --help', args: ['--help'] }, +]; + const args = parseArgs(process.argv.slice(2)); const cwd = path.resolve(args.cwd ?? process.cwd()); @@ -22,7 +29,9 @@ if (args.postComment) { process.exit(0); } -const report = collectReport(cwd); +const report = collectReport(cwd, { + startupRuns: parseNonNegativeInteger(args.startupRuns ?? '0', '--startup-runs'), +}); const baseReport = args.compare ? JSON.parse(fs.readFileSync(args.compare, 'utf8')) : null; if (args.json) { @@ -67,6 +76,7 @@ Options: --json Write the raw size report JSON. --markdown Write the markdown report. --compare Compare against a previously written JSON report. + --startup-runs Measure startup medians for side-effect-free CLI commands. --post-comment Post or update the markdown report on the current PR. --pr Pull request number for --post-comment. `); @@ -81,7 +91,15 @@ function readValue(argv, index, flag) { return value; } -function collectReport(root) { +function parseNonNegativeInteger(value, flag) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${flag} must be a non-negative integer`); + } + return parsed; +} + +function collectReport(root, options) { const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const jsFiles = walk(path.join(root, 'dist', 'src')).filter((file) => file.endsWith('.js')); if (jsFiles.length === 0) { @@ -114,10 +132,54 @@ function collectReport(root) { generatedAt: new Date().toISOString(), js, npmPack: collectNpmPack(root), + ...(options.startupRuns > 0 ? { startup: collectStartupBenchmarks(root, options.startupRuns) } : {}), chunks: chunks.slice(0, 20), }; } +function collectStartupBenchmarks(root, runs) { + return { + runs, + benchmarks: STARTUP_BENCHMARKS.map((benchmark) => + measureStartupBenchmark(root, benchmark, runs), + ), + }; +} + +function measureStartupBenchmark(root, benchmark, runs) { + const samplesMs = []; + runStartupCommand(root, benchmark.args); + for (let index = 0; index < runs; index += 1) { + const start = performance.now(); + runStartupCommand(root, benchmark.args); + samplesMs.push(performance.now() - start); + } + const sortedSamples = [...samplesMs].sort((left, right) => left - right); + return { + name: benchmark.name, + command: `agent-device ${benchmark.args.join(' ')}`, + medianMs: median(sortedSamples), + minMs: sortedSamples[0], + maxMs: sortedSamples.at(-1), + samplesMs, + }; +} + +function runStartupCommand(root, args) { + execFileSync(process.execPath, ['bin/agent-device.mjs', ...args], { + cwd: root, + stdio: 'ignore', + timeout: 5_000, + }); +} + +function median(sortedValues) { + const midpoint = Math.floor(sortedValues.length / 2); + return sortedValues.length % 2 === 0 + ? (sortedValues[midpoint - 1] + sortedValues[midpoint]) / 2 + : sortedValues[midpoint]; +} + function walk(root) { if (!fs.existsSync(root)) return []; const entries = fs.readdirSync(root, { withFileTypes: true }); @@ -165,6 +227,7 @@ function formatMarkdown(report, baseReport) { const changedChunks = baseReport ? formatChangedChunks(report.chunks, baseReport.chunks ?? []) : formatTopChunks(report.chunks); + const startup = formatStartupBenchmarks(report.startup, baseReport?.startup); return `${COMMENT_MARKER} ## Size Report @@ -173,6 +236,7 @@ function formatMarkdown(report, baseReport) { |---|---:|---:|---:| ${rows.join('\n')} +${startup} ${changedChunks} `; } @@ -231,6 +295,38 @@ function formatDiff(base, current) { return typeof base === 'number' ? formatSignedBytes(current - base) : '-'; } +function formatStartupBenchmarks(startup, baseStartup) { + if (!startup) return ''; + const baseByName = new Map((baseStartup?.benchmarks ?? []).map((benchmark) => [benchmark.name, benchmark])); + const rows = startup.benchmarks.map((benchmark) => { + const base = baseByName.get(benchmark.name); + return `| ${benchmark.name} | ${formatMaybeMs(base?.medianMs)} | ${formatMs(benchmark.medianMs)} | ${formatMsDiff(base?.medianMs, benchmark.medianMs)} |`; + }); + return `Startup median (${startup.runs} runs, lower is better): + +| Scenario | Base | Current | Diff | +|---|---:|---:|---:| +${rows.join('\n')} + +`; +} + +function formatMaybeMs(value) { + return typeof value === 'number' ? formatMs(value) : '-'; +} + +function formatMsDiff(base, current) { + if (typeof base !== 'number') return '-'; + const diff = current - base; + if (diff === 0) return '0 ms'; + const sign = diff > 0 ? '+' : '-'; + return `${sign}${formatMs(Math.abs(diff))}`; +} + +function formatMs(value) { + return value < 1000 ? `${value.toFixed(1)} ms` : `${(value / 1000).toFixed(2)} s`; +} + function formatBytes(value) { const absoluteValue = Math.abs(value); if (absoluteValue < 1000) return `${value} B`;