diff --git a/docs/adr/0001-provider-first-integration-scenarios.md b/docs/adr/0001-provider-first-integration-scenarios.md index 96f779732..b90563549 100644 --- a/docs/adr/0001-provider-first-integration-scenarios.md +++ b/docs/adr/0001-provider-first-integration-scenarios.md @@ -58,6 +58,8 @@ Coverage is expected to improve over the old handler-heavy unit suite, but the f Operational metrics are generated by `pnpm test:integration:progress`. CI runs `pnpm test:integration:progress:check` after integration tests so public-command coverage, device-observable workflow flag coverage, and public-flag classification cannot silently regress. The script is the source of truth for provider-backed integration size, handler-unit size, mock-heavy handler pressure, public-command coverage, command-family ownership, device-observable workflow flag coverage, provider transcript pressure, and low-coverage files after a coverage run. Config, remote transport, Metro preparation, parser/client-only, report-writing, and boot-fallback flags stay in their owning unit/CLI suites and are reported as explicit exclusions rather than silently missing from the denominator. Provider pressure separates semantic Apple `simctl`/`devicectl`, macOS helper, and macOS host usage from generic Apple host-tool usage, and separates semantic Linux desktop/accessibility/clipboard/screenshot/input usage from generic Linux tool usage, so remote-adapter pressure is visible without treating named subproviders as raw shell intent. +The progress CLI should stay a thin report and check runner over a progress model. The model owns discovery, coverage classification, provider-pressure accounting, and check-failure derivation; the CLI owns Markdown output and process exit behavior. The progress script runs as a Node type-stripped TypeScript script and should consume command metadata directly when the metadata API exposes the needed facet. Source parsing inside the model is an implementation detail, not the desired long-term interface, and should remain limited to facets that are not yet represented as runtime metadata, such as mapping typed client method calls back to command names. + Every public command should have at least one provider-backed integration scenario that runs through the request router and request-scoped provider seams. Unit tests remain for parser matrices, selector matching, capability maps, malformed inputs, state machines, cleanup behavior, provider scope routing, and platform error boundaries. The temporary migration roadmap is complete and intentionally removed from the repository. Future work should be justified from live pressure in the progress script, failing tests, or concrete adapter needs rather than from a standing refactor backlog. diff --git a/package.json b/package.json index 38ceffd18..bf78e9c48 100644 --- a/package.json +++ b/package.json @@ -132,8 +132,8 @@ "test:unit": "vitest run --project unit", "test:coverage": "vitest run --coverage", "test:integration:provider": "vitest run --project provider-integration", - "test:integration:progress": "node scripts/integration-progress.mjs", - "test:integration:progress:check": "node scripts/integration-progress.mjs --check", + "test:integration:progress": "node --experimental-strip-types scripts/integration-progress.ts", + "test:integration:progress:check": "node --experimental-strip-types scripts/integration-progress.ts --check", "test:skillgym": "node test/skillgym/runner-environment.ts && pnpm build && skillgym run ./test/skillgym/suites/agent-device-smoke-suite.ts --config ./test/skillgym/skillgym.config.ts", "test:skillgym:case": "node test/skillgym/runner-environment.ts && pnpm build && skillgym run ./test/skillgym/suites/agent-device-smoke-suite.ts --config ./test/skillgym/skillgym.config.ts --case", "test:smoke": "node --test test/integration/smoke-*.test.ts", diff --git a/scripts/integration-progress.mjs b/scripts/integration-progress-model.ts similarity index 53% rename from scripts/integration-progress.mjs rename to scripts/integration-progress-model.ts index 81dd2d3d1..019f05b5f 100644 --- a/scripts/integration-progress.mjs +++ b/scripts/integration-progress-model.ts @@ -1,180 +1,117 @@ -#!/usr/bin/env node - import fs from 'node:fs'; import path from 'node:path'; - -const ROOT = process.cwd(); -const CHECK_MODE = process.argv.includes('--check'); -const HANDLER_TEST_DIR = path.join(ROOT, 'src/daemon/handlers/__tests__'); -const PROVIDER_SCENARIO_DIR = path.join(ROOT, 'test/integration/provider-scenarios'); -const COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); -const COMMAND_CATALOG = path.join(ROOT, 'src/command-catalog.ts'); -const COMMAND_CONTRACT_FILES = [ - path.join(ROOT, 'src/commands/client-command-contracts.ts'), - path.join(ROOT, 'src/commands/interaction-command-contracts.ts'), -]; -const COMMAND_CATALOG_SOURCE = fs.readFileSync(COMMAND_CATALOG, 'utf8'); -const clientCommandMethods = readClientCommandMethods(); - -const handlerTests = listFiles(HANDLER_TEST_DIR, (file) => file.endsWith('.test.ts')); -const providerScenarioTests = listFiles(PROVIDER_SCENARIO_DIR, (file) => file.endsWith('.test.ts')); -const providerScenarioSources = listFiles(PROVIDER_SCENARIO_DIR, (file) => file.endsWith('.ts')); -const providerScenarioSupportSources = providerScenarioSources.filter((file) => !file.endsWith('.test.ts')); -const handlerStats = summarizeFiles(handlerTests); -const providerScenarioStats = summarizeFiles(providerScenarioTests); -const providerScenarioSupportStats = summarizeFiles(providerScenarioSupportSources); -const mockHeavyHandlerFiles = handlerTests.filter((file) => - fs.readFileSync(file, 'utf8').includes('vi.mock('), -); -const mockHeavyHandlerRows = summarizeMockHeavyHandlerFiles(mockHeavyHandlerFiles); -const providerPressureRows = summarizeProviderPressure(providerScenarioSources); -const publicCommandRows = summarizePublicCommandCoverage(providerScenarioTests); -const missingPublicCommands = publicCommandRows.filter((command) => command.references === 0); -const flagCoverageRows = summarizeProviderScenarioFlagCoverage(providerScenarioTests); -const missingFlagRows = flagCoverageRows.filter((flag) => flag.references === 0); -const excludedFlagRows = summarizeProviderScenarioFlagExclusions(); -const publicCliFlagKeys = readPublicCliFlagKeys(); -const classifiedFlagKeys = new Set([ - ...flagCoverageRows.map((flag) => flag.key), - ...excludedFlagRows.flatMap((group) => group.keys), -]); -const unclassifiedFlagKeys = [...publicCliFlagKeys].filter((key) => !classifiedFlagKeys.has(key)); -const coverage = readCoverageSummary(); -const lowCoverageFiles = readLowCoverageFiles(); - -const rows = [ - ['Handler unit test files', String(handlerStats.files)], - ['Handler unit test LOC', String(handlerStats.lines)], - ['Handler unit tests', String(handlerStats.tests)], - ['Handler files with vi.mock', String(mockHeavyHandlerFiles.length)], - ['Provider scenario files', String(providerScenarioStats.files)], - ['Provider scenario LOC', String(providerScenarioStats.lines)], - ['Provider scenario tests', String(providerScenarioStats.tests)], - ['Provider scenario support files', String(providerScenarioSupportStats.files)], - ['Provider scenario support LOC', String(providerScenarioSupportStats.lines)], - ['Provider scenario / handler LOC', ratio(providerScenarioStats.lines, handlerStats.lines)], - [ - 'Public commands covered by provider-backed integration', - `${publicCommandRows.length - missingPublicCommands.length}/${publicCommandRows.length}`, - ], - ['Public commands missing provider-backed integration coverage', String(missingPublicCommands.length)], - [ - 'Device-observable workflow flags covered by provider-backed integration', - `${flagCoverageRows.length - missingFlagRows.length}/${flagCoverageRows.length}`, - ], - ['Device-observable workflow flags missing provider-backed integration coverage', String(missingFlagRows.length)], - [ - 'Public CLI flags intentionally outside provider-backed integration', - String(excludedFlagRows.reduce((sum, group) => sum + group.keys.length, 0)), - ], - ['Public CLI flags unclassified by progress script', String(unclassifiedFlagKeys.length)], -]; - -if (coverage) { - rows.push( - ['Coverage statements', formatPercent(coverage.statements)], - ['Coverage branches', formatPercent(coverage.branches)], - ['Coverage functions', formatPercent(coverage.functions)], - ['Coverage lines', formatPercent(coverage.lines)], +import { PUBLIC_COMMANDS } from '../src/command-catalog.ts'; +import { listCommandMetadata } from '../src/commands/command-metadata.ts'; +import { getFlagDefinitions } from '../src/utils/cli-flags.ts'; + +const EMPTY_COVERAGE_METRIC = { pct: 0 }; +const EMPTY_STATEMENT_COVERAGE = { covered: 0, pct: 0, total: 0 }; + +export function buildIntegrationProgressModel({ root = process.cwd() } = {}) { + const coverageSummary = path.join(root, 'coverage/coverage-summary.json'); + const handlerTestDir = path.join(root, 'src/daemon/handlers/__tests__'); + const providerScenarioDir = path.join(root, 'test/integration/provider-scenarios'); + const commandContractFiles = listFiles(path.join(root, 'src/commands'), (file) => + file.endsWith(`${path.sep}index.ts`), ); -} else { - rows.push(['Coverage summary', 'not available; run pnpm test:coverage first']); -} - -console.log('Provider-backed integration status'); -console.log(''); -console.log('| Measure | Value |'); -console.log('| --- | ---: |'); -for (const [name, value] of rows) { - console.log(`| ${name} | ${value} |`); -} - -if (mockHeavyHandlerRows.length > 0) { - console.log(''); - console.log('Mock-heavy handler unit tests'); - console.log(''); - console.log('| Tests | LOC | File |'); - console.log('| ---: | ---: | --- |'); - for (const file of mockHeavyHandlerRows) { - console.log(`| ${file.tests} | ${file.lines} | ${file.file} |`); - } -} - -if (missingPublicCommands.length > 0) { - console.log(''); - console.log('Public command coverage gaps'); - console.log(''); - console.log('| Command |'); - console.log('| --- |'); - for (const command of missingPublicCommands) { - console.log(`| ${command.command} |`); - } -} - -if (missingFlagRows.length > 0) { - console.log(''); - console.log('Device-observable workflow flag coverage gaps'); - console.log(''); - console.log('| Flag | Intended integration coverage |'); - console.log('| --- | --- |'); - for (const flag of missingFlagRows) { - console.log(`| ${flag.key} | ${flag.reason} |`); - } -} - -if (excludedFlagRows.length > 0) { - console.log(''); - console.log('Public CLI flag coverage outside provider-backed integration'); - console.log(''); - console.log('| Bucket | Flags | Coverage owner |'); - console.log('| --- | --- | --- |'); - for (const group of excludedFlagRows) { - console.log(`| ${group.name} | ${group.keys.join(', ')} | ${group.owner} |`); - } -} + const clientCommandMethods = readClientCommandMethods(commandContractFiles); + + const handlerTests = listFiles(handlerTestDir, (file) => file.endsWith('.test.ts')); + const providerScenarioTests = listFiles(providerScenarioDir, (file) => file.endsWith('.test.ts')); + const providerScenarioSources = listFiles(providerScenarioDir, (file) => file.endsWith('.ts')); + const providerScenarioSupportSources = providerScenarioSources.filter((file) => !file.endsWith('.test.ts')); + const handlerStats = summarizeFiles(handlerTests); + const providerScenarioStats = summarizeFiles(providerScenarioTests); + const providerScenarioSupportStats = summarizeFiles(providerScenarioSupportSources); + const mockHeavyHandlerFiles = handlerTests.filter((file) => + fs.readFileSync(file, 'utf8').includes('vi.mock('), + ); + const mockHeavyHandlerRows = summarizeMockHeavyHandlerFiles(root, mockHeavyHandlerFiles); + const providerPressureRows = summarizeProviderPressure(providerScenarioSources); + const publicCommandRows = summarizePublicCommandCoverage(providerScenarioTests, clientCommandMethods); + const missingPublicCommands = publicCommandRows.filter((command) => command.references === 0); + const flagCoverageRows = summarizeProviderScenarioFlagCoverage(providerScenarioTests); + const missingFlagRows = flagCoverageRows.filter((flag) => flag.references === 0); + const excludedFlagRows = summarizeProviderScenarioFlagExclusions(); + const publicCliFlagKeys = readPublicCliFlagKeys(); + const classifiedFlagKeys = new Set([ + ...flagCoverageRows.map((flag) => flag.key), + ...excludedFlagRows.flatMap((group) => group.keys), + ]); + const unclassifiedFlagKeys = [...publicCliFlagKeys].filter((key) => !classifiedFlagKeys.has(key)); + const coverage = readCoverageSummary(coverageSummary); + const lowCoverageFiles = readLowCoverageFiles(root, coverageSummary); + + const summaryRows = [ + ['Handler unit test files', String(handlerStats.files)], + ['Handler unit test LOC', String(handlerStats.lines)], + ['Handler unit tests', String(handlerStats.tests)], + ['Handler files with vi.mock', String(mockHeavyHandlerFiles.length)], + ['Provider scenario files', String(providerScenarioStats.files)], + ['Provider scenario LOC', String(providerScenarioStats.lines)], + ['Provider scenario tests', String(providerScenarioStats.tests)], + ['Provider scenario support files', String(providerScenarioSupportStats.files)], + ['Provider scenario support LOC', String(providerScenarioSupportStats.lines)], + ['Provider scenario / handler LOC', ratio(providerScenarioStats.lines, handlerStats.lines)], + [ + 'Public commands covered by provider-backed integration', + `${publicCommandRows.length - missingPublicCommands.length}/${publicCommandRows.length}`, + ], + ['Public commands missing provider-backed integration coverage', String(missingPublicCommands.length)], + [ + 'Device-observable workflow flags covered by provider-backed integration', + `${flagCoverageRows.length - missingFlagRows.length}/${flagCoverageRows.length}`, + ], + ['Device-observable workflow flags missing provider-backed integration coverage', String(missingFlagRows.length)], + [ + 'Public CLI flags intentionally outside provider-backed integration', + String(excludedFlagRows.reduce((sum, group) => sum + group.keys.length, 0)), + ], + ['Public CLI flags unclassified by progress script', String(unclassifiedFlagKeys.length)], + ]; -if (unclassifiedFlagKeys.length > 0) { - console.log(''); - console.log('Unclassified public CLI flags'); - console.log(''); - console.log('| Flag |'); - console.log('| --- |'); - for (const key of unclassifiedFlagKeys) { - console.log(`| ${key} |`); + if (coverage) { + summaryRows.push( + ['Coverage statements', formatPercent(coverage.statements)], + ['Coverage branches', formatPercent(coverage.branches)], + ['Coverage functions', formatPercent(coverage.functions)], + ['Coverage lines', formatPercent(coverage.lines)], + ); + } else { + summaryRows.push(['Coverage summary', 'not available; run pnpm test:coverage first']); } -} -if (providerPressureRows.length > 0) { - console.log(''); - console.log('Provider transcript pressure'); - console.log(''); - console.log('| Contract surface | References | Files |'); - console.log('| --- | ---: | ---: |'); - for (const pressure of providerPressureRows) { - console.log(`| ${pressure.name} | ${pressure.references} | ${pressure.files} |`); - } + return { + coverage, + excludedFlagRows, + flagCoverageRows, + lowCoverageFiles, + missingFlagRows, + missingPublicCommands, + mockHeavyHandlerRows, + providerPressureRows, + publicCommandRows, + summaryRows, + unclassifiedFlagKeys, + }; } -if (CHECK_MODE) { +export function buildIntegrationProgressFailures(progress) { const failures = []; - if (missingPublicCommands.length > 0) { + if (progress.missingPublicCommands.length > 0) { failures.push( - `missing Provider-backed integration command coverage: ${missingPublicCommands.map((row) => row.command).join(', ')}`, + `missing Provider-backed integration command coverage: ${progress.missingPublicCommands.map((row) => row.command).join(', ')}`, ); } - if (missingFlagRows.length > 0) { + if (progress.missingFlagRows.length > 0) { failures.push( - `missing Provider-backed integration workflow flag coverage: ${missingFlagRows.map((row) => row.key).join(', ')}`, + `missing Provider-backed integration workflow flag coverage: ${progress.missingFlagRows.map((row) => row.key).join(', ')}`, ); } - if (unclassifiedFlagKeys.length > 0) { - failures.push(`unclassified public CLI flags: ${unclassifiedFlagKeys.join(', ')}`); - } - if (failures.length > 0) { - console.error(''); - console.error(`provider-backed integration progress check failed: ${failures.join('; ')}`); - process.exit(1); + if (progress.unclassifiedFlagKeys.length > 0) { + failures.push(`unclassified public CLI flags: ${progress.unclassifiedFlagKeys.join(', ')}`); } + return failures; } function summarizeProviderScenarioFlagCoverage(files) { @@ -303,10 +240,17 @@ function summarizeProviderScenarioFlagExclusions() { 'launchUrl', ], }, + { + name: 'Apple launch and perf artifact options', + owner: 'iOS platform, observability command, and parser tests', + keys: ['deviceHub', 'kind', 'launchArgs', 'perfTemplate'], + }, { name: 'parser/client-only command flags', - owner: 'args, CLI, screenshot-diff, and batch tests', + owner: 'args, CLI, debug-symbols, screenshot-diff, and batch tests', keys: [ + 'artifact', + 'dsym', 'githubActionsArtifact', 'snapshotDiff', 'snapshotForceFull', @@ -314,6 +258,11 @@ function summarizeProviderScenarioFlagExclusions() { 'threshold', 'reportJunit', 'replayMaestro', + 'replayExportFormat', + 'recordVideo', + 'shardAll', + 'shardSplit', + 'searchPath', 'stepsFile', ], }, @@ -326,35 +275,11 @@ function summarizeProviderScenarioFlagExclusions() { } function readPublicCliFlagKeys() { - const sources = [ - path.join(ROOT, 'src/utils/command-schema.ts'), - path.join(ROOT, 'src/commands/capture-screenshot-options.ts'), - ]; - const keys = new Set(); - for (const source of sources) { - const text = fs.readFileSync(source, 'utf8'); - for (const match of text.matchAll(/\{\s*key: '([^']+)'[\s\S]*?names:\s*\[([^\]]*)\]/g)) { - const key = match[1]; - const names = match[2] ?? ''; - if (names.includes("'--") || names.includes("'-")) { - keys.add(key); - } - } - } - return keys; -} - -if (lowCoverageFiles.length > 0) { - console.log(''); - console.log('Lowest covered implementation files'); - console.log(''); - console.log('| Missing statements | Statements | Statement coverage | File |'); - console.log('| ---: | ---: | ---: | --- |'); - for (const file of lowCoverageFiles) { - console.log( - `| ${file.missingStatements} | ${file.statementTotal} | ${formatPercent(file.statementPercent)} | ${file.file} |`, - ); - } + return new Set( + getFlagDefinitions() + .filter((definition) => definition.names.some((name) => name.startsWith('-'))) + .map((definition) => definition.key), + ); } function listFiles(dir, predicate) { @@ -378,12 +303,12 @@ function summarizeFiles(files) { return { files: files.length, lines, tests }; } -function summarizeMockHeavyHandlerFiles(files) { +function summarizeMockHeavyHandlerFiles(root, files) { return files .map((file) => { const text = fs.readFileSync(file, 'utf8'); return { - file: path.relative(ROOT, file), + file: path.relative(root, file), lines: text.split('\n').length, tests: countTestDeclarations(text), }; @@ -446,29 +371,33 @@ function summarizeProviderPressure(files) { ]; return surfaces - .map((surface) => { - let references = 0; - let filesWithReferences = 0; - for (const file of files) { - const text = fs.readFileSync(file, 'utf8'); - const matches = text.match(surface.pattern)?.length ?? 0; - references += matches; - if (matches > 0) filesWithReferences += 1; - } - return { - name: surface.name, - references, - files: filesWithReferences, - }; - }) + .map((surface) => ({ name: surface.name, ...countSurfaceReferences(files, surface.pattern) })) .filter((surface) => surface.references > 0); } -function summarizePublicCommandCoverage(files) { +function countSurfaceReferences(files, pattern) { + let references = 0; + let filesWithReferences = 0; + for (const file of files) { + const matches = countPatternReferences(fs.readFileSync(file, 'utf8'), pattern); + references += matches; + filesWithReferences += matches > 0 ? 1 : 0; + } + return { references, files: filesWithReferences }; +} + +function countPatternReferences(text, pattern) { + return text.match(pattern)?.length ?? 0; +} + +function summarizePublicCommandCoverage(files, clientCommandMethods) { const publicCommands = readPublicCommands(); const commandRefsByFile = files.map((file) => ({ file, - commands: extractProviderScenarioCommandReferences(fs.readFileSync(file, 'utf8')), + commands: extractProviderScenarioCommandReferences( + fs.readFileSync(file, 'utf8'), + clientCommandMethods, + ), })); return publicCommands.map((command) => { @@ -484,24 +413,20 @@ function summarizePublicCommandCoverage(files) { } function readPublicCommands() { - return [...readPublicCommandEntries().values()].sort(); -} - -function readPublicCommandEntries() { - const match = COMMAND_CATALOG_SOURCE.match(/export const PUBLIC_COMMANDS = \{([\s\S]*?)\} as const;/); - if (!match) { - throw new Error('Unable to find PUBLIC_COMMANDS in src/command-catalog.ts'); - } - const commands = new Map(); - for (const command of match[1].matchAll(/\b([A-Za-z0-9_]+):\s*'([^']+)'/g)) { - commands.set(command[1], command[2]); - } - return commands; + const metadataNames = new Set(listCommandMetadata().map((metadata) => metadata.name)); + return Object.values(PUBLIC_COMMANDS) + .map((name) => { + if (!metadataNames.has(name)) { + throw new Error(`Missing command metadata for public command: ${name}`); + } + return name; + }) + .sort(); } -function readClientCommandMethods() { +function readClientCommandMethods(commandContractFiles) { const commands = new Map(); - for (const file of COMMAND_CONTRACT_FILES) { + for (const file of commandContractFiles) { const text = fs.readFileSync(file, 'utf8'); for (const block of readCommandContractBlocks(text)) { for (const method of block.source.matchAll(/\bclient\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\s*\(/g)) { @@ -513,8 +438,26 @@ function readClientCommandMethods() { } function readCommandContractBlocks(text) { + const constants = new Map(); + for (const match of text.matchAll(/\bconst\s+([A-Z0-9_]+)\s*=\s*['"]([^'"]+)['"]/g)) { + constants.set(match[1], match[2]); + } + + const metadataNames = new Map(); + for (const match of text.matchAll( + /\bconst\s+([A-Za-z0-9_]+CommandMetadata)\s*=\s*defineFieldCommandMetadata\(\s*([^,\s)]+)/g, + )) { + metadataNames.set(match[1], readMetadataName(match[2], constants)); + } + const starts = [ ...text.matchAll(/defineExecutableCommand\(\s*metadata\(\s*['"]([^'"]+)['"]\s*\)/g), + ...[...text.matchAll(/defineExecutableCommand\(\s*([A-Za-z0-9_]+CommandMetadata)\b/g)].flatMap( + (match) => { + const name = metadataNames.get(match[1]); + return name ? [{ ...match, 1: name }] : []; + }, + ), ...text.matchAll(/defineFieldCommand\(\s*['"]([^'"]+)['"]/g), ...text.matchAll(/defineCommand\(\s*\{[\s\S]*?\bname:\s*['"]([^'"]+)['"]/g), ] @@ -533,14 +476,32 @@ function readCommandContractBlocks(text) { }); } -function extractProviderScenarioCommandReferences(text) { +function readMetadataName(token, constants) { + const literal = token.match(/^['"]([^'"]+)['"]$/); + if (literal) return literal[1]; + return constants.get(token); +} + +function extractProviderScenarioCommandReferences(text, clientCommandMethods) { + return [ + ...extractLiteralCommandReferences(text), + ...extractClientCommandReferences(text, clientCommandMethods), + ]; +} + +function extractLiteralCommandReferences(text) { const commands = []; for (const match of text.matchAll(/\bcommand:\s*['"]([^'"]+)['"]|\.callCommand\(\s*['"]([^'"]+)['"]/g)) { commands.push(match[1] ?? match[2]); } + return commands; +} + +function extractClientCommandReferences(text, clientCommandMethods) { + const commands = []; for (const [method, command] of clientCommandMethods) { const escapedMethod = method.replace('.', '\\.'); - const matches = text.match(new RegExp(`\\.${escapedMethod}\\s*\\(`, 'g'))?.length ?? 0; + const matches = countPatternReferences(text, new RegExp(`\\.${escapedMethod}\\s*\\(`, 'g')); for (let index = 0; index < matches; index += 1) commands.push(command); } return commands; @@ -550,45 +511,54 @@ function countTestDeclarations(text) { return [...text.matchAll(/(?:^|[^\w.])test\(/g)].length; } -function readCoverageSummary() { - if (!fs.existsSync(COVERAGE_SUMMARY)) return null; - const summary = JSON.parse(fs.readFileSync(COVERAGE_SUMMARY, 'utf8')); - const total = summary.total; +function readCoverageSummary(coverageSummary) { + const total = readCoverageSummaryJson(coverageSummary)?.total; if (!total) return null; return { - statements: Number(total.statements?.pct ?? 0), - branches: Number(total.branches?.pct ?? 0), - functions: Number(total.functions?.pct ?? 0), - lines: Number(total.lines?.pct ?? 0), + statements: readCoveragePercent(total, 'statements'), + branches: readCoveragePercent(total, 'branches'), + functions: readCoveragePercent(total, 'functions'), + lines: readCoveragePercent(total, 'lines'), }; } -function readLowCoverageFiles() { - if (!fs.existsSync(COVERAGE_SUMMARY)) return []; - const summary = JSON.parse(fs.readFileSync(COVERAGE_SUMMARY, 'utf8')); +function readLowCoverageFiles(root, coverageSummary) { + const summary = readCoverageSummaryJson(coverageSummary); + if (!summary) return []; return Object.entries(summary) .filter(([file]) => file !== 'total') - .map(([file, value]) => { - const statements = value.statements ?? {}; - const statementTotal = Number(statements.total ?? 0); - const statementCovered = Number(statements.covered ?? 0); - return { - file: path.relative(ROOT, file), - statementPercent: Number(statements.pct ?? 0), - statementTotal, - missingStatements: statementTotal - statementCovered, - }; - }) + .map(([file, value]) => readLowCoverageFile(root, file, value)) .filter((file) => file.statementTotal >= 10 && file.statementPercent < 60) .sort((a, b) => b.missingStatements - a.missingStatements) .slice(0, 10); } +function readCoverageSummaryJson(coverageSummary) { + if (!fs.existsSync(coverageSummary)) return null; + return JSON.parse(fs.readFileSync(coverageSummary, 'utf8')); +} + +function readCoveragePercent(total, key) { + return Number((total[key] ?? EMPTY_COVERAGE_METRIC).pct); +} + +function readLowCoverageFile(root, file, value) { + const statements = value.statements ?? EMPTY_STATEMENT_COVERAGE; + const statementTotal = Number(statements.total); + const statementCovered = Number(statements.covered); + return { + file: path.relative(root, file), + statementPercent: Number(statements.pct), + statementTotal, + missingStatements: statementTotal - statementCovered, + }; +} + function ratio(numerator, denominator) { if (denominator === 0) return 'n/a'; return `${((numerator / denominator) * 100).toFixed(1)}%`; } -function formatPercent(value) { +export function formatPercent(value) { return `${value.toFixed(2)}%`; } diff --git a/scripts/integration-progress.ts b/scripts/integration-progress.ts new file mode 100644 index 000000000..f1aa352b1 --- /dev/null +++ b/scripts/integration-progress.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +import { + buildIntegrationProgressFailures, + buildIntegrationProgressModel, + formatPercent, +} from './integration-progress-model.ts'; + +const CHECK_MODE = process.argv.includes('--check'); +const progress = buildIntegrationProgressModel({ root: process.cwd() }); + +console.log('Provider-backed integration status'); +console.log(''); +console.log('| Measure | Value |'); +console.log('| --- | ---: |'); +for (const [name, value] of progress.summaryRows) { + console.log(`| ${name} | ${value} |`); +} + +if (progress.mockHeavyHandlerRows.length > 0) { + console.log(''); + console.log('Mock-heavy handler unit tests'); + console.log(''); + console.log('| Tests | LOC | File |'); + console.log('| ---: | ---: | --- |'); + for (const file of progress.mockHeavyHandlerRows) { + console.log(`| ${file.tests} | ${file.lines} | ${file.file} |`); + } +} + +if (progress.missingPublicCommands.length > 0) { + console.log(''); + console.log('Public command coverage gaps'); + console.log(''); + console.log('| Command |'); + console.log('| --- |'); + for (const command of progress.missingPublicCommands) { + console.log(`| ${command.command} |`); + } +} + +if (progress.missingFlagRows.length > 0) { + console.log(''); + console.log('Device-observable workflow flag coverage gaps'); + console.log(''); + console.log('| Flag | Intended integration coverage |'); + console.log('| --- | --- |'); + for (const flag of progress.missingFlagRows) { + console.log(`| ${flag.key} | ${flag.reason} |`); + } +} + +if (progress.excludedFlagRows.length > 0) { + console.log(''); + console.log('Public CLI flag coverage outside provider-backed integration'); + console.log(''); + console.log('| Bucket | Flags | Coverage owner |'); + console.log('| --- | --- | --- |'); + for (const group of progress.excludedFlagRows) { + console.log(`| ${group.name} | ${group.keys.join(', ')} | ${group.owner} |`); + } +} + +if (progress.unclassifiedFlagKeys.length > 0) { + console.log(''); + console.log('Unclassified public CLI flags'); + console.log(''); + console.log('| Flag |'); + console.log('| --- |'); + for (const key of progress.unclassifiedFlagKeys) { + console.log(`| ${key} |`); + } +} + +if (progress.providerPressureRows.length > 0) { + console.log(''); + console.log('Provider transcript pressure'); + console.log(''); + console.log('| Contract surface | References | Files |'); + console.log('| --- | ---: | ---: |'); + for (const pressure of progress.providerPressureRows) { + console.log(`| ${pressure.name} | ${pressure.references} | ${pressure.files} |`); + } +} + +if (progress.lowCoverageFiles.length > 0) { + console.log(''); + console.log('Lowest covered implementation files'); + console.log(''); + console.log('| Missing statements | Statements | Statement coverage | File |'); + console.log('| ---: | ---: | ---: | --- |'); + for (const file of progress.lowCoverageFiles) { + console.log( + `| ${file.missingStatements} | ${file.statementTotal} | ${formatPercent(file.statementPercent)} | ${file.file} |`, + ); + } +} + +if (CHECK_MODE) { + const failures = buildIntegrationProgressFailures(progress); + if (failures.length > 0) { + console.error(''); + console.error(`provider-backed integration progress check failed: ${failures.join('; ')}`); + process.exit(1); + } +} diff --git a/src/batch-policy.ts b/src/batch-policy.ts new file mode 100644 index 000000000..3c690c476 --- /dev/null +++ b/src/batch-policy.ts @@ -0,0 +1,92 @@ +import { PUBLIC_COMMANDS } from './command-catalog.ts'; +import { AppError } from './utils/errors.ts'; + +export const STRUCTURED_BATCH_COMMAND_NAMES = [ + PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.boot, + PUBLIC_COMMANDS.shutdown, + 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; + +export type StructuredBatchCommandName = (typeof STRUCTURED_BATCH_COMMAND_NAMES)[number]; + +export const BATCH_BLOCKED_COMMANDS: ReadonlySet = new Set(['batch', 'replay']); + +export const BATCH_DAEMON_STEP_KEYS = ['command', 'positionals', 'flags', 'runtime'] as const; + +export const INHERITED_PARENT_FLAG_KEYS = [ + 'platform', + 'target', + 'device', + 'udid', + 'serial', + 'verbose', + 'out', +] as const; + +const structuredBatchCommandNames = new Set(STRUCTURED_BATCH_COMMAND_NAMES); + +function isStructuredBatchCommandName(command: string): command is StructuredBatchCommandName { + return structuredBatchCommandNames.has(command); +} + +export function normalizeBatchCommandName(command: unknown): string { + return typeof command === 'string' ? command.trim().toLowerCase() : ''; +} + +export function readStructuredBatchCommandName( + command: unknown, + stepNumber: number, +): StructuredBatchCommandName { + const normalized = normalizeBatchCommandName(command); + if (isStructuredBatchCommandName(normalized)) return normalized; + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} command is not available through command batch: ${String(command)}`, + ); +} + +export function assertBatchRuntimeCommandAllowed(command: string, stepNumber: number): void { + if (BATCH_BLOCKED_COMMANDS.has(command)) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} cannot run ${command}.`); + } +} diff --git a/src/client-types.ts b/src/client-types.ts index 312b19a4e..e6fd9cfd1 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -22,7 +22,7 @@ import type { SwipePreset, TransformGestureParams, } from './core/scroll-gesture.ts'; -import type { ScrollInputDirection } from './commands/interaction-gestures.ts'; +import type { ScrollInputDirection } from './commands/interaction/runtime/gestures.ts'; import type { LogAction } from './contracts/logs.ts'; import type { SessionSurface } from './core/session-surface.ts'; import type { FindLocator } from './utils/finders.ts'; diff --git a/src/command-catalog.ts b/src/command-catalog.ts index a4d56cd2e..394a05b23 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -82,52 +82,6 @@ export type ClientBackedCliCommandName = | typeof LOCAL_CLI_COMMANDS.metro | typeof LOCAL_CLI_COMMANDS.session; -export const BATCH_COMMAND_NAMES = [ - PUBLIC_COMMANDS.devices, - PUBLIC_COMMANDS.boot, - PUBLIC_COMMANDS.shutdown, - 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, LOCAL_CLI_COMMANDS.connect, diff --git a/src/commands/app-inventory-contract.ts b/src/commands/app-inventory-contract.ts deleted file mode 100644 index 406c8203b..000000000 --- a/src/commands/app-inventory-contract.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../contracts/app-inventory.ts'; diff --git a/src/commands/batch-command.ts b/src/commands/batch-command.ts deleted file mode 100644 index 4c9029a9d..000000000 --- a/src/commands/batch-command.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { BatchRunOptions } from '../client-types.ts'; -import { defineExecutableCommand } from './command-contract.ts'; -import { type DaemonCommandName } from './command-projection.ts'; -import { commonToClientOptions } from './command-input.ts'; -import { createBatchCommandMetadata, type BatchInput } from './batch-command-metadata.ts'; - -export function createBatchCommand( - nestedCommands: readonly TCommand[], -) { - return defineExecutableCommand(createBatchCommandMetadata(nestedCommands), (client, input) => - client.batch.run(toBatchOptions(input)), - ); -} - -function toBatchOptions(input: BatchInput): BatchRunOptions { - return { - ...commonToClientOptions(input), - steps: input.steps, - onError: input.onError, - maxSteps: input.maxSteps, - out: input.out, - }; -} diff --git a/src/__tests__/cli-batch.test.ts b/src/commands/batch/cli.test.ts similarity index 95% rename from src/__tests__/cli-batch.test.ts rename to src/commands/batch/cli.test.ts index e07a71a70..8071e46e1 100644 --- a/src/__tests__/cli-batch.test.ts +++ b/src/commands/batch/cli.test.ts @@ -3,13 +3,13 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { DaemonResponse } from '../daemon-client.ts'; +import type { DaemonResponse } from '../../daemon-client.ts'; import { runCliCapture as captureCli, type CapturedCliRun, type CapturedDaemonRequest, type CliCaptureOptions, -} from './cli-capture.ts'; +} from '../../__tests__/cli-capture.ts'; const batchDefaultResponse: DaemonResponse = { ok: true, @@ -78,6 +78,18 @@ test('batch structured interaction target is projected to positionals, not devic assert.equal(step?.flags?.count, 2); }); +test('batch rejects structured replay steps before daemon dispatch', async () => { + const result = await runCliCapture([ + 'batch', + '--steps', + '[{"command":"replay","input":{"path":"flow.ad"}}]', + ]); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.match(result.stderr, /not available through command batch/); +}); + test('batch accepts legacy positionals/flags steps with deprecation warning', async () => { const result = await runCliCapture([ 'batch', diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts new file mode 100644 index 000000000..386dd752a --- /dev/null +++ b/src/commands/batch/index.ts @@ -0,0 +1,48 @@ +import type { BatchRunOptions } from '../../client-types.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { commonInputFromFlags } from '../cli-grammar/common.ts'; +import type { CliReader } from '../cli-grammar/types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonToClientOptions } from '../command-input.ts'; +import { createBatchCommandMetadata, type BatchInput } from './metadata.ts'; +import { createBatchDaemonWriter } from './projection.ts'; + +export const batchCommandMetadata = createBatchCommandMetadata(); + +export const batchCommandDefinition = defineExecutableCommand( + batchCommandMetadata, + (client, input) => client.batch.run(toBatchOptions(input)), +); + +export const batchCliSchemas = { + batch: { + usageOverride: 'batch [--steps | --steps-file ]', + listUsageOverride: 'batch --steps | --steps-file ', + helpDescription: 'Execute multiple commands in one daemon request', + summary: 'Run multiple commands', + allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], + }, +} as const satisfies Record; + +export const batchCliReaders = { + batch: ((_positionals, flags) => ({ + ...commonInputFromFlags(flags), + steps: flags.batchSteps ?? [], + onError: flags.batchOnError, + maxSteps: flags.batchMaxSteps, + out: flags.out, + })) satisfies CliReader, +} as const; + +export { createBatchDaemonWriter }; +export type { BatchCommandName } from './projection.ts'; + +function toBatchOptions(input: BatchInput): BatchRunOptions { + return { + ...commonToClientOptions(input), + steps: input.steps, + onError: input.onError, + maxSteps: input.maxSteps, + out: input.out, + }; +} diff --git a/src/commands/batch-command-metadata.ts b/src/commands/batch/metadata.ts similarity index 83% rename from src/commands/batch-command-metadata.ts rename to src/commands/batch/metadata.ts index ea2530aab..7c439c6b5 100644 --- a/src/commands/batch-command-metadata.ts +++ b/src/commands/batch/metadata.ts @@ -1,10 +1,13 @@ -import { BATCH_COMMAND_NAMES } from '../command-catalog.ts'; -import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts'; +import { DEFAULT_BATCH_MAX_STEPS } from '../../batch-contract.ts'; +import { + STRUCTURED_BATCH_COMMAND_NAMES, + readStructuredBatchCommandName, +} from '../../batch-policy.ts'; import { defineCommandMetadata, type CommandMetadata, type JsonSchema, -} from './command-contract.ts'; +} from '../command-contract.ts'; import { assertAllowedKeys, customField, @@ -12,12 +15,11 @@ import { fieldsInputSchema, integerField, readFieldInput, - requiredEnum, requiredField, stringField, type CommandFieldMap, type InferCommandInput, -} from './command-input.ts'; +} from '../command-input.ts'; export type BatchCommandStep = { command: string; @@ -33,7 +35,7 @@ export type BatchInput = InferCommandInput & { }; export function createBatchCommandMetadata( - nestedCommands: readonly string[] = BATCH_COMMAND_NAMES, + nestedCommands: readonly string[] = STRUCTURED_BATCH_COMMAND_NAMES, ): CommandMetadata<'batch', BatchInput> { const fields = batchFields(nestedCommands); return defineCommandMetadata({ @@ -121,12 +123,27 @@ function readBatchStep( const record = readBatchStepRecord(step, stepNumber); assertAllowedKeys(record, ['command', 'input', 'runtime'], `Batch step ${stepNumber}`); return { - command: requiredEnum(record, 'command', nestedCommands), + command: readBatchStepCommand(record, stepNumber, nestedCommands), input: readBatchStepInput(record, stepNumber), ...readBatchStepRuntimeProperty(record, stepNumber), }; } +function readBatchStepCommand( + record: Record, + stepNumber: number, + nestedCommands: readonly string[], +): string { + if (nestedCommands === STRUCTURED_BATCH_COMMAND_NAMES) { + return readStructuredBatchCommandName(record.command, stepNumber); + } + const command = record.command; + if (typeof command !== 'string' || !nestedCommands.includes(command)) { + throw new Error(`Expected command to be one of: ${nestedCommands.join(', ')}.`); + } + return command; +} + function readBatchStepRecord(step: unknown, stepNumber: number): Record { if (!step || typeof step !== 'object' || Array.isArray(step)) { throw new Error(`Invalid batch step ${stepNumber}.`); diff --git a/src/commands/batch/output.ts b/src/commands/batch/output.ts new file mode 100644 index 000000000..f8e762b4f --- /dev/null +++ b/src/commands/batch/output.ts @@ -0,0 +1,50 @@ +import type { CommandRequestResult } from '../../client-types.ts'; +import { readCommandMessage } from '../../utils/success-text.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { readRecord, resultOutput, type CliOutputFormatter } from '../output-common.ts'; + +function batchCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const total = typeof data.total === 'number' ? data.total : 0; + const executed = typeof data.executed === 'number' ? data.executed : 0; + const durationMs = typeof data.totalDurationMs === 'number' ? data.totalDurationMs : undefined; + const lines = [ + `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}`, + ]; + const results = Array.isArray(data.results) ? data.results : []; + for (const entry of results) { + const line = renderBatchStepLine(entry); + if (line) lines.push(line); + } + return { data, text: lines.join('\n') }; +} + +export const batchCliOutputFormatters = { + batch: resultOutput(batchCliOutput), +} as const satisfies Record; + +function renderBatchStepLine(entry: unknown): string | undefined { + const result = readRecord(entry); + if (!result) return undefined; + const step = typeof result.step === 'number' ? result.step : undefined; + const command = typeof result.command === 'string' ? result.command : 'step'; + const stepOk = result.ok !== false; + const description = readBatchStepDescription(result, stepOk, command); + const prefix = step !== undefined ? `${step}. ` : '- '; + const durationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; + const durationSuffix = durationMs !== undefined ? ` (${durationMs}ms)` : ''; + return `${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}`; +} + +function readBatchStepDescription( + result: Record, + stepOk: boolean, + command: string, +): string { + if (stepOk) return readCommandMessage(readRecord(result.data)) ?? command; + return readBatchStepFailure(readRecord(result.error)) ?? command; +} + +function readBatchStepFailure(error: Record | undefined): string | null { + return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; +} diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts new file mode 100644 index 000000000..f689ef15a --- /dev/null +++ b/src/commands/batch/projection.ts @@ -0,0 +1,107 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { + STRUCTURED_BATCH_COMMAND_NAMES, + readStructuredBatchCommandName, +} from '../../batch-policy.ts'; +import { buildFlags } from '../../client-normalizers.ts'; +import type { DaemonBatchStep } from '../../core/batch.ts'; +import { AppError } from '../../utils/errors.ts'; +import { request } from '../cli-grammar/common.ts'; +import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts'; +import type { DaemonCommandName } from '../command-projection.ts'; + +const batchCommandNames = STRUCTURED_BATCH_COMMAND_NAMES satisfies readonly DaemonCommandName[]; + +export type BatchCommandName = (typeof batchCommandNames)[number]; + +type PrepareDaemonCommandRequest = (command: string, input: CommandInput) => DaemonCommandRequest; + +export function createBatchDaemonWriter( + prepareDaemonCommandRequest: PrepareDaemonCommandRequest, +): DaemonWriter { + return (input) => + request(PUBLIC_COMMANDS.batch, [], { + ...input, + batchSteps: readBatchDaemonSteps(input.steps, prepareDaemonCommandRequest), + batchOnError: input.onError, + batchMaxSteps: input.maxSteps, + }); +} + +function readBatchDaemonSteps( + steps: unknown, + prepareDaemonCommandRequest: PrepareDaemonCommandRequest, +): DaemonBatchStep[] { + if (!Array.isArray(steps) || steps.length === 0) { + throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array.'); + } + return steps.map((step, index) => + readBatchDaemonStep(step, index + 1, prepareDaemonCommandRequest), + ); +} + +function readBatchDaemonStep( + step: unknown, + stepNumber: number, + prepareDaemonCommandRequest: PrepareDaemonCommandRequest, +): DaemonBatchStep { + const record = readBatchStepRecord(step, stepNumber); + const command = readBatchStepCommand(record, stepNumber); + const input = readBatchStepInput(record, stepNumber); + const runtime = readBatchStepRuntime(record, stepNumber); + const prepared = prepareBatchStep(command, input, prepareDaemonCommandRequest); + return { + ...prepared, + runtime: runtime ?? prepared.runtime, + }; +} + +function prepareBatchStep( + command: BatchCommandName, + input: CommandInput, + prepareDaemonCommandRequest: PrepareDaemonCommandRequest, +): DaemonBatchStep { + const prepared = prepareDaemonCommandRequest(command, input); + return { + command: prepared.command, + positionals: prepared.positionals, + flags: buildFlags(prepared.options), + runtime: prepared.options.runtime, + }; +} + +function readBatchStepRecord(step: unknown, stepNumber: number): Record { + if (!step || typeof step !== 'object' || Array.isArray(step)) { + throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`); + } + return step as Record; +} + +function readBatchStepCommand( + record: Record, + stepNumber: number, +): BatchCommandName { + return readStructuredBatchCommandName(record.command, stepNumber); +} + +function readBatchStepInput(record: Record, stepNumber: number): CommandInput { + const input = record.input; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`); + } + return input as CommandInput; +} + +function readBatchStepRuntime( + record: Record, + stepNumber: number, +): Record | undefined { + const runtime = record.runtime; + if ( + runtime !== undefined && + (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) + ) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} runtime must be an object.`); + } + return runtime as Record | undefined; +} diff --git a/src/__tests__/batch-public.test.ts b/src/commands/batch/public.test.ts similarity index 96% rename from src/__tests__/batch-public.test.ts rename to src/commands/batch/public.test.ts index 3b3509768..3a1f75dee 100644 --- a/src/__tests__/batch-public.test.ts +++ b/src/commands/batch/public.test.ts @@ -8,8 +8,8 @@ import { runBatch, validateAndNormalizeBatchSteps, type BatchStepResult, -} from '../batch.ts'; -import type { DaemonRequest } from '../contracts.ts'; +} from '../../batch.ts'; +import type { DaemonRequest } from '../../contracts.ts'; test('public batch entrypoint exports daemon-compatible orchestration helpers', async () => { const seenCommands: string[] = []; diff --git a/src/commands/capture-screenshot-options.ts b/src/commands/capture-screenshot-options.ts deleted file mode 100644 index a22fd2036..000000000 --- a/src/commands/capture-screenshot-options.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../contracts/screenshot.ts'; diff --git a/src/commands/capture/index.test.ts b/src/commands/capture/index.test.ts new file mode 100644 index 000000000..d56f3417b --- /dev/null +++ b/src/commands/capture/index.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + alertCliReader, + alertDaemonWriter, + diffCliReader, + screenshotCliReader, + screenshotDaemonWriter, + settingsCliReader, + settingsDaemonWriter, + snapshotCliReader, + waitCliReader, + waitDaemonWriter, +} from './index.ts'; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('capture command interface', () => { + test('reads snapshot flags', () => { + expect( + snapshotCliReader( + [], + flags({ + snapshotInteractiveOnly: true, + snapshotCompact: true, + snapshotDepth: 3, + snapshotScope: 'Login', + snapshotRaw: true, + snapshotForceFull: true, + timeoutMs: 10_000, + }), + ), + ).toMatchObject({ + interactiveOnly: true, + compact: true, + depth: 3, + scope: 'Login', + raw: true, + forceFull: true, + timeoutMs: 10_000, + }); + }); + + test('reads screenshot path and writes screenshot flags', () => { + const input = screenshotCliReader( + ['page.png'], + flags({ screenshotFullscreen: true, screenshotMaxSize: 1024 }), + ); + expect(input).toMatchObject({ path: 'page.png', fullscreen: true, maxSize: 1024 }); + expect(screenshotDaemonWriter(input)).toMatchObject({ + command: 'screenshot', + positionals: ['page.png'], + options: { screenshotFullscreen: true, screenshotMaxSize: 1024 }, + }); + }); + + test('reads diff snapshot input only', () => { + expect( + diffCliReader(['snapshot'], flags({ snapshotDepth: 4, out: './diff.json' })), + ).toMatchObject({ + kind: 'snapshot', + depth: 4, + out: './diff.json', + }); + expectInvalidArgs(() => diffCliReader(['screenshot'], flags()), 'Only diff snapshot'); + }); + + test('reads and writes wait targets', () => { + expect(waitCliReader(['text', 'Ready', '5000'], flags())).toMatchObject({ + text: 'Ready', + timeoutMs: 5000, + }); + expect(waitDaemonWriter({ text: 'Ready', timeoutMs: 5000 })).toMatchObject({ + command: 'wait', + positionals: ['text', 'Ready', '5000'], + }); + expectInvalidArgs(() => waitDaemonWriter({ text: 'Ready', ref: '@e1' }), 'exactly one'); + }); + + test('reads and writes alert action and timeout', () => { + expect(alertCliReader(['wait', '3000'], flags())).toMatchObject({ + action: 'wait', + timeoutMs: 3000, + }); + expect(alertDaemonWriter({ action: 'dismiss', timeoutMs: 1000 })).toMatchObject({ + command: 'alert', + positionals: ['dismiss', '1000'], + }); + }); + + test('reads and writes settings input', () => { + const input = settingsCliReader(['permission', 'grant', 'camera', 'limited'], flags()); + expect(input).toMatchObject({ + setting: 'permission', + state: 'grant', + permission: 'camera', + mode: 'limited', + }); + expect(settingsDaemonWriter(input)).toMatchObject({ + command: 'settings', + positionals: ['permission', 'grant', 'camera', 'limited'], + }); + }); +}); diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts new file mode 100644 index 000000000..8edc7db06 --- /dev/null +++ b/src/commands/capture/index.ts @@ -0,0 +1,365 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { + AlertCommandOptions, + CaptureScreenshotOptions, + WaitCommandOptions, +} from '../../client-types.ts'; +import type { AlertAction } from '../../alert-contract.ts'; +import { ALERT_ACTIONS } from '../../alert-contract.ts'; +import { parseWaitPositionals } from '../../core/wait-positionals.ts'; +import { SESSION_SURFACES } from '../../core/session-surface.ts'; +import { + SCREENSHOT_COMMAND_FLAG_KEYS, + screenshotFlagsFromOptions, + screenshotOptionsFromFlags, +} from '../../contracts/screenshot.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { SELECTOR_SNAPSHOT_FLAGS, SNAPSHOT_FLAGS, type CliFlags } from '../../utils/cli-flags.ts'; +import { AppError } from '../../utils/errors.ts'; +import { tryParseSelectorChain } from '../../utils/selectors-parse.ts'; +import { + booleanField, + compactRecord, + enumField, + integerField, + jsonSchemaField, + optionalEnum, + requiredField, + stringField, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; +import { + commonInputFromFlags, + direct, + optionalNumber, + optionalString, + readFiniteNumber, + request, + requiredDaemonString, + selectionOptionsFromFlags, + selectorSnapshotOptionsFromFlags, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { + SETTINGS_COMMAND_NAME, + settingsCliReader as settingsCliReaderImpl, + settingsCliSchema, + settingsCommandDefinition, + settingsCommandMetadata, + settingsDaemonWriter as settingsDaemonWriterImpl, +} from './settings.ts'; + +const SNAPSHOT_COMMAND_NAME = 'snapshot'; +const SCREENSHOT_COMMAND_NAME = 'screenshot'; +const DIFF_COMMAND_NAME = 'diff'; +const WAIT_COMMAND_NAME = 'wait'; +const ALERT_COMMAND_NAME = 'alert'; + +const snapshotCommandDescription = 'Capture an accessibility snapshot.'; +const screenshotCommandDescription = 'Capture a screenshot.'; +const diffCommandDescription = 'Diff accessibility snapshots.'; +const waitCommandDescription = 'Wait for duration, text, ref, or selector.'; +const alertCommandDescription = 'Inspect or handle platform alerts.'; + +const snapshotCommandMetadata = defineFieldCommandMetadata( + SNAPSHOT_COMMAND_NAME, + snapshotCommandDescription, + { + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + forceFull: booleanField(), + timeoutMs: integerField('Maximum wall-clock time for the snapshot command.'), + }, +); + +const screenshotCommandMetadata = defineFieldCommandMetadata( + SCREENSHOT_COMMAND_NAME, + screenshotCommandDescription, + { + path: stringField('Output path.'), + overlayRefs: booleanField(), + fullscreen: booleanField(), + maxSize: integerField(), + stabilize: booleanField(), + surface: enumField(SESSION_SURFACES), + }, +); + +const diffCommandMetadata = defineFieldCommandMetadata(DIFF_COMMAND_NAME, diffCommandDescription, { + kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), + out: stringField(), + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), +}); + +const waitCommandMetadata = defineFieldCommandMetadata(WAIT_COMMAND_NAME, waitCommandDescription, { + kind: enumField(WAIT_KIND_VALUES), + durationMs: integerField(), + text: stringField(), + ref: stringField(), + selector: stringField(), + timeoutMs: integerField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), +}); + +const alertCommandMetadata = defineFieldCommandMetadata( + ALERT_COMMAND_NAME, + alertCommandDescription, + { + action: enumField(ALERT_ACTIONS), + timeoutMs: integerField(), + }, +); + +export const captureCommandMetadata = [ + snapshotCommandMetadata, + screenshotCommandMetadata, + diffCommandMetadata, + waitCommandMetadata, + alertCommandMetadata, + settingsCommandMetadata, +] as const; + +const snapshotCommandDefinition = defineExecutableCommand( + snapshotCommandMetadata, + (client, input) => client.capture.snapshot(input), +); + +const screenshotCommandDefinition = defineExecutableCommand( + screenshotCommandMetadata, + (client, input) => client.capture.screenshot(input), +); + +const diffCommandDefinition = defineExecutableCommand(diffCommandMetadata, (client, input) => + client.capture.diff(input), +); + +const waitCommandDefinition = defineExecutableCommand(waitCommandMetadata, (client, input) => + client.command.wait(waitInputToOptions(input)), +); + +const alertCommandDefinition = defineExecutableCommand(alertCommandMetadata, (client, input) => + client.command.alert(input), +); + +export const captureCommandDefinitions = [ + snapshotCommandDefinition, + screenshotCommandDefinition, + diffCommandDefinition, + waitCommandDefinition, + alertCommandDefinition, + settingsCommandDefinition, +] as const; + +const snapshotCliSchema = { + usageOverride: + 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', + helpDescription: 'Capture accessibility tree or diff against the previous session baseline', + allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], +} as const satisfies CommandSchemaOverride; + +const diffCliSchema = { + usageOverride: + 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--threshold <0-1>] [--overlay-refs]', + helpDescription: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel', + summary: 'Diff snapshot or screenshot', + positionalArgs: ['kind', 'current?'], + allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'], +} as const satisfies CommandSchemaOverride; + +const screenshotCliSchema = { + helpDescription: + 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', + positionalArgs: ['path?'], + allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, +} as const satisfies CommandSchemaOverride; + +const waitCliSchema = { + usageOverride: 'wait |text |@ref| [timeoutMs]', + positionalArgs: ['durationOrSelector', 'timeoutMs?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], +} as const satisfies CommandSchemaOverride; + +const alertCliSchema = { + usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', + positionalArgs: ['action?', 'timeout?'], +} as const satisfies CommandSchemaOverride; + +export const captureCliSchemas = { + [SNAPSHOT_COMMAND_NAME]: snapshotCliSchema, + [SCREENSHOT_COMMAND_NAME]: screenshotCliSchema, + [DIFF_COMMAND_NAME]: diffCliSchema, + [WAIT_COMMAND_NAME]: waitCliSchema, + [ALERT_COMMAND_NAME]: alertCliSchema, + [SETTINGS_COMMAND_NAME]: settingsCliSchema, +} as const satisfies Record; + +function waitInputToOptions(input: Record): WaitCommandOptions { + optionalEnum(input, 'kind', WAIT_KIND_VALUES); + const options = { ...input }; + delete options.kind; + return options as WaitCommandOptions & { kind?: never }; +} + +export const captureCliReaders = { + snapshot: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + interactiveOnly: flags.snapshotInteractiveOnly, + compact: flags.snapshotCompact, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + forceFull: flags.snapshotForceFull, + timeoutMs: flags.timeoutMs, + }), + screenshot: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + path: positionals[0] ?? flags.out, + ...screenshotOptionsFromFlags(flags), + }), + diff: (positionals, flags) => { + if (positionals[0] !== 'snapshot') { + throw new AppError('INVALID_ARGS', 'Only diff snapshot is available through this parser.'); + } + return { + ...commonInputFromFlags(flags), + kind: 'snapshot', + out: flags.out, + interactiveOnly: flags.snapshotInteractiveOnly, + compact: flags.snapshotCompact, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }; + }, + wait: (positionals, flags) => readWaitOptionsFromPositionals(positionals, flags), + alert: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readAlertInput(positionals), + }), + settings: settingsCliReaderImpl, +} satisfies Record; + +export const snapshotCliReader = captureCliReaders.snapshot; +export const screenshotCliReader = captureCliReaders.screenshot; +export const diffCliReader = captureCliReaders.diff; +export const waitCliReader = captureCliReaders.wait; +export const alertCliReader = captureCliReaders.alert; +export const settingsCliReader = captureCliReaders.settings; + +export const captureDaemonWriters = { + snapshot: direct(PUBLIC_COMMANDS.snapshot), + screenshot: (input) => + request(PUBLIC_COMMANDS.screenshot, optionalString(input.path), { + ...input, + ...screenshotFlagsFromOptions(input as CaptureScreenshotOptions), + }), + diff: direct(PUBLIC_COMMANDS.diff, (input) => [ + requiredDaemonString(input.kind, 'diff requires kind'), + ]), + wait: direct(PUBLIC_COMMANDS.wait, (input) => waitPositionals(input as WaitCommandOptions)), + alert: direct(PUBLIC_COMMANDS.alert, (input) => alertPositionals(input as AlertCommandOptions)), + settings: settingsDaemonWriterImpl, +} satisfies Record; + +export const screenshotDaemonWriter = captureDaemonWriters.screenshot; +export const waitDaemonWriter = captureDaemonWriters.wait; +export const alertDaemonWriter = captureDaemonWriters.alert; +export const settingsDaemonWriter = captureDaemonWriters.settings; + +function readWaitOptionsFromPositionals( + positionals: string[], + flags: CliFlags, +): WaitCommandOptions { + const parsed = parseWaitPositionals(positionals); + if (!parsed) { + throw new AppError( + 'INVALID_ARGS', + 'wait requires , text , @ref, or [timeoutMs].', + ); + } + const base = { + ...selectionOptionsFromFlags(flags), + ...selectorSnapshotOptionsFromFlags(flags), + }; + if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs }; + if (parsed.kind === 'text') { + if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.'); + return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) }; + } + if (parsed.kind === 'ref') { + return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) }; + } + return { + ...base, + selector: parsed.selectorExpression, + ...readTimeoutOption(parsed.timeoutMs), + }; +} + +// fallow-ignore-next-line complexity +function waitPositionals(options: WaitCommandOptions): string[] { + const targets = [ + options.durationMs !== undefined ? 'durationMs' : undefined, + options.text !== undefined ? 'text' : undefined, + options.ref !== undefined ? 'ref' : undefined, + options.selector !== undefined ? 'selector' : undefined, + ].filter(Boolean); + if (targets.length !== 1) { + throw new AppError( + 'INVALID_ARGS', + 'wait command requires exactly one of durationMs, text, ref, or selector.', + ); + } + if (options.durationMs !== undefined) return [String(options.durationMs)]; + const timeout = optionalNumber(options.timeoutMs); + if (options.text !== undefined) return ['text', options.text, ...timeout]; + if (options.ref !== undefined) return [options.ref, ...timeout]; + const selector = options.selector!; + if (!tryParseSelectorChain(selector)) { + throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); + } + return [selector, ...timeout]; +} + +function alertPositionals(input: AlertCommandOptions): string[] { + return [input.action ?? 'get', ...optionalNumber(input.timeoutMs)]; +} + +function readAlertInput(positionals: string[]): Record { + if (positionals.length > 2) { + throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.'); + } + const action = readAlertAction(positionals[0]); + const timeoutMs = readFiniteNumber(positionals[1], 'alert timeout'); + return compactRecord({ action, timeoutMs }); +} + +function readAlertAction(value: string | undefined): AlertAction | undefined { + const action = value?.toLowerCase(); + if ( + action === undefined || + action === 'get' || + action === 'accept' || + action === 'dismiss' || + action === 'wait' + ) { + return action; + } + throw new AppError('INVALID_ARGS', 'alert action must be get, accept, dismiss, or wait.'); +} + +function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { + return timeoutMs === null ? {} : { timeoutMs }; +} diff --git a/src/commands/capture/output.ts b/src/commands/capture/output.ts new file mode 100644 index 000000000..7425cc823 --- /dev/null +++ b/src/commands/capture/output.ts @@ -0,0 +1,44 @@ +import { serializeSnapshotResult } from '../../client-shared.ts'; +import type { CaptureSnapshotResult } from '../../client-types.ts'; +import { formatSnapshotText } from '../../utils/output.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { messageOutput, type CliOutputFormatter } from '../output-common.ts'; + +export function snapshotCliOutput(params: { + result: CaptureSnapshotResult; + raw?: boolean; + interactiveOnly?: boolean; + scope?: string; + depth?: number; +}): CliOutput { + const data = serializeSnapshotResult(params.result); + return { + data, + // Programmatic SDK callers can see `unchanged`; CLI --json hides it for schema compatibility. + jsonData: withoutUnchanged(data), + text: formatSnapshotText(data, { + raw: params.raw, + flatten: params.interactiveOnly, + scoped: typeof params.scope === 'string' && params.scope.trim().length > 0, + depthLimited: typeof params.depth === 'number', + }), + }; +} + +export const captureCliOutputFormatters = { + snapshot: ({ input, result }) => + snapshotCliOutput({ + result: result as Parameters[0]['result'], + raw: input.raw as boolean | undefined, + interactiveOnly: input.interactiveOnly as boolean | undefined, + scope: input.scope as string | undefined, + depth: input.depth as number | undefined, + }), + wait: messageOutput, + alert: messageOutput, +} as const satisfies Record; + +function withoutUnchanged(data: Record): Record { + const { unchanged: _unchanged, ...outputData } = data; + return outputData; +} diff --git a/src/__tests__/runtime-diff-screenshot.test.ts b/src/commands/capture/runtime/diff-screenshot.test.ts similarity index 96% rename from src/__tests__/runtime-diff-screenshot.test.ts rename to src/commands/capture/runtime/diff-screenshot.test.ts index e326fbd68..8a18021e2 100644 --- a/src/__tests__/runtime-diff-screenshot.test.ts +++ b/src/commands/capture/runtime/diff-screenshot.test.ts @@ -2,15 +2,19 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { PNG } from '../utils/png.ts'; +import { PNG } from '../../../utils/png.ts'; import { test } from 'vitest'; import type { AgentDeviceBackend, BackendScreenshotOptions, BackendScreenshotResult, -} from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, localCommandPolicy, type CommandSessionStore } from '../runtime.ts'; +} from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + type CommandSessionStore, +} from '../../../runtime.ts'; const sessions = { get: () => undefined, diff --git a/src/commands/capture-diff-screenshot.ts b/src/commands/capture/runtime/diff-screenshot.ts similarity index 93% rename from src/commands/capture-diff-screenshot.ts rename to src/commands/capture/runtime/diff-screenshot.ts index 378b4e3c8..8b6cc8e10 100644 --- a/src/commands/capture-diff-screenshot.ts +++ b/src/commands/capture/runtime/diff-screenshot.ts @@ -1,19 +1,23 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { BackendScreenshotOptions, BackendScreenshotResult } from '../backend.ts'; +import type { BackendScreenshotOptions, BackendScreenshotResult } from '../../../backend.ts'; import type { ArtifactDescriptor, FileInputRef, FileOutputRef, ReservedOutputFile, ResolvedInputFile, -} from '../io.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { compareScreenshots, type ScreenshotDiffResult } from '../utils/screenshot-diff.ts'; -import { attachCurrentOverlayMatches } from '../utils/screenshot-diff-overlay-matches.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; -import { createCommandTempFile, reserveCommandOutput, resolveCommandInput } from './io-policy.ts'; +} from '../../../io.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { compareScreenshots, type ScreenshotDiffResult } from '../../../utils/screenshot-diff.ts'; +import { attachCurrentOverlayMatches } from '../../../utils/screenshot-diff-overlay-matches.ts'; +import type { RuntimeCommand } from '../../runtime-types.ts'; +import { + createCommandTempFile, + reserveCommandOutput, + resolveCommandInput, +} from '../../io-policy.ts'; export type LiveScreenshotInputRef = { kind: 'live'; @@ -135,6 +139,7 @@ async function captureLiveCurrentScreenshot( return temp; } +// fallow-ignore-next-line complexity async function maybeAttachCurrentOverlay( runtime: AgentDeviceRuntime, options: DiffScreenshotCommandOptions, diff --git a/src/commands/capture/runtime/index.ts b/src/commands/capture/runtime/index.ts new file mode 100644 index 000000000..14bd7f2fe --- /dev/null +++ b/src/commands/capture/runtime/index.ts @@ -0,0 +1,50 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { + BoundRuntimeCommand, + DiffSnapshotCommandOptions, + RuntimeCommand, + ScreenshotCommandOptions, + SnapshotCommandOptions, +} from '../../runtime-types.ts'; +import { + diffScreenshotCommand, + type DiffScreenshotCommandOptions, + type DiffScreenshotCommandResult, +} from './diff-screenshot.ts'; +import { screenshotCommand, type ScreenshotCommandResult } from './screenshot.ts'; +import { + diffSnapshotCommand, + snapshotCommand, + type DiffSnapshotCommandResult, + type SnapshotCommandResult, +} from './snapshot.ts'; + +export type CaptureCommands = { + screenshot: RuntimeCommand; + diffScreenshot: RuntimeCommand; + snapshot: RuntimeCommand; + diffSnapshot: RuntimeCommand; +}; + +export type BoundCaptureCommands = { + screenshot: BoundRuntimeCommand; + diffScreenshot: BoundRuntimeCommand; + snapshot: BoundRuntimeCommand; + diffSnapshot: BoundRuntimeCommand; +}; + +export const captureCommands: CaptureCommands = { + screenshot: screenshotCommand, + diffScreenshot: diffScreenshotCommand, + snapshot: snapshotCommand, + diffSnapshot: diffSnapshotCommand, +}; + +export function bindCaptureCommands(runtime: AgentDeviceRuntime): BoundCaptureCommands { + return { + screenshot: (options) => captureCommands.screenshot(runtime, options), + diffScreenshot: (options) => captureCommands.diffScreenshot(runtime, options), + snapshot: (options) => captureCommands.snapshot(runtime, options), + diffSnapshot: (options) => captureCommands.diffSnapshot(runtime, options), + }; +} diff --git a/src/commands/capture-screenshot.ts b/src/commands/capture/runtime/screenshot.ts similarity index 82% rename from src/commands/capture-screenshot.ts rename to src/commands/capture/runtime/screenshot.ts index 282e1d729..cdb32d5c7 100644 --- a/src/commands/capture-screenshot.ts +++ b/src/commands/capture/runtime/screenshot.ts @@ -1,9 +1,9 @@ -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; -import { resizePngFileToMaxSize } from '../utils/png-resize.ts'; -import type { ArtifactDescriptor } from '../io.ts'; -import type { RuntimeCommand, ScreenshotCommandOptions } from './runtime-types.ts'; -import { reserveCommandOutput } from './io-policy.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { resizePngFileToMaxSize } from '../../../utils/png-resize.ts'; +import type { ArtifactDescriptor } from '../../../io.ts'; +import type { RuntimeCommand, ScreenshotCommandOptions } from '../../runtime-types.ts'; +import { reserveCommandOutput } from '../../io-policy.ts'; export type ScreenshotCommandResult = { path: string; diff --git a/src/commands/__tests__/snapshot-unchanged.test.ts b/src/commands/capture/runtime/snapshot-unchanged.test.ts similarity index 97% rename from src/commands/__tests__/snapshot-unchanged.test.ts rename to src/commands/capture/runtime/snapshot-unchanged.test.ts index 0f214d5b0..78200b591 100644 --- a/src/commands/__tests__/snapshot-unchanged.test.ts +++ b/src/commands/capture/runtime/snapshot-unchanged.test.ts @@ -2,8 +2,8 @@ import { expect, test } from 'vitest'; import { buildUnchangedSnapshotMetadata, ensureSnapshotPresentationKey, -} from '../snapshot-unchanged.ts'; -import type { SnapshotState } from '../../utils/snapshot.ts'; +} from './snapshot-unchanged.ts'; +import type { SnapshotState } from '../../../utils/snapshot.ts'; function snapshot( label: string, diff --git a/src/commands/snapshot-unchanged.ts b/src/commands/capture/runtime/snapshot-unchanged.ts similarity index 96% rename from src/commands/snapshot-unchanged.ts rename to src/commands/capture/runtime/snapshot-unchanged.ts index 6749a244c..3d706baa1 100644 --- a/src/commands/snapshot-unchanged.ts +++ b/src/commands/capture/runtime/snapshot-unchanged.ts @@ -1,10 +1,10 @@ -import type { SnapshotCommandOptions } from './runtime-types.ts'; +import type { SnapshotCommandOptions } from '../../runtime-types.ts'; import { buildSnapshotPresentationKey, type SnapshotNode, type SnapshotState, type SnapshotUnchanged, -} from '../utils/snapshot.ts'; +} from '../../../utils/snapshot.ts'; type SnapshotIdentity = { previousAppBundleId?: string; diff --git a/src/__tests__/runtime-snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts similarity index 98% rename from src/__tests__/runtime-snapshot.test.ts rename to src/commands/capture/runtime/snapshot.test.ts index 4496d864c..163edfe35 100644 --- a/src/__tests__/runtime-snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -1,9 +1,13 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, localCommandPolicy, type CommandSessionStore } from '../runtime.ts'; -import { makeSnapshotState } from './test-utils/index.ts'; +import type { AgentDeviceBackend, BackendSnapshotResult } from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + type CommandSessionStore, +} from '../../../runtime.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; test('runtime snapshot captures nodes and updates the session baseline', async () => { let stored: Parameters[0] | undefined; diff --git a/src/commands/capture-snapshot.ts b/src/commands/capture/runtime/snapshot.ts similarity index 94% rename from src/commands/capture-snapshot.ts rename to src/commands/capture/runtime/snapshot.ts index f8629e696..42b7c4feb 100644 --- a/src/commands/capture-snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -1,23 +1,23 @@ -import type { BackendSnapshotResult } from '../backend.ts'; -import type { AgentDeviceRuntime, CommandSessionRecord } from '../runtime-contract.ts'; +import type { BackendSnapshotResult } from '../../../backend.ts'; +import type { AgentDeviceRuntime, CommandSessionRecord } from '../../../runtime-contract.ts'; import { publicSnapshotCaptureAnnotations, snapshotCaptureAnnotationsFrom, type PublicSnapshotCaptureAnnotations, type SnapshotCaptureAnnotations, -} from '../snapshot-capture-annotations.ts'; -import { renderSnapshotQualityWarnings } from '../utils/snapshot-quality.ts'; -import { AppError } from '../utils/errors.ts'; -import { buildSnapshotDiff, countSnapshotComparableLines } from '../utils/snapshot-diff.ts'; -import type { SnapshotDiffLine, SnapshotDiffSummary } from '../utils/snapshot-diff.ts'; +} from '../../../snapshot-capture-annotations.ts'; +import { renderSnapshotQualityWarnings } from '../../../utils/snapshot-quality.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { buildSnapshotDiff, countSnapshotComparableLines } from '../../../utils/snapshot-diff.ts'; +import type { SnapshotDiffLine, SnapshotDiffSummary } from '../../../utils/snapshot-diff.ts'; import type { SnapshotNode, SnapshotState, SnapshotUnchanged, SnapshotVisibility, -} from '../utils/snapshot.ts'; -import { buildSnapshotVisibility } from '../utils/snapshot-visibility.ts'; -import { formatReactNativeOverlayWarning } from './react-native/overlay.ts'; +} from '../../../utils/snapshot.ts'; +import { buildSnapshotVisibility } from '../../../utils/snapshot-visibility.ts'; +import { formatReactNativeOverlayWarning } from '../../react-native/overlay.ts'; import { buildUnchangedSnapshotMetadata, ensureSnapshotPresentationKey, @@ -26,10 +26,10 @@ import type { DiffSnapshotCommandOptions, RuntimeCommand, SnapshotCommandOptions, -} from './runtime-types.ts'; -import { now } from './selector-read-utils.ts'; +} from '../../runtime-types.ts'; +import { now } from '../../runtime-common.ts'; -export type { SnapshotDiffLine, SnapshotDiffSummary } from '../utils/snapshot-diff.ts'; +export type { SnapshotDiffLine, SnapshotDiffSummary } from '../../../utils/snapshot-diff.ts'; export type SnapshotCommandResult = { nodes: SnapshotNode[]; @@ -303,6 +303,7 @@ function buildMergedAccessibilityLeafWarnings(nodes: SnapshotState['nodes']): st }); } +// fallow-ignore-next-line complexity function buildEmptyAndroidInteractiveWarnings(params: { annotations: SnapshotCaptureAnnotations; snapshot: SnapshotState; diff --git a/src/commands/__tests__/capture-screenshot-options.test.ts b/src/commands/capture/screenshot-options.test.ts similarity index 98% rename from src/commands/__tests__/capture-screenshot-options.test.ts rename to src/commands/capture/screenshot-options.test.ts index d998a81b4..077e99fef 100644 --- a/src/commands/__tests__/capture-screenshot-options.test.ts +++ b/src/commands/capture/screenshot-options.test.ts @@ -8,7 +8,7 @@ import { readScreenshotScriptFlag, screenshotFlagsFromOptions, screenshotOptionsFromFlags, -} from '../capture-screenshot-options.ts'; +} from '../../contracts/screenshot.ts'; test('screenshot flag projection maps CLI flags to runtime options', () => { assert.deepEqual( diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts new file mode 100644 index 000000000..751a5bd44 --- /dev/null +++ b/src/commands/capture/settings.ts @@ -0,0 +1,162 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { SettingsUpdateOptions } from '../../client-types.ts'; +import { SETTINGS_USAGE_OVERRIDE } from '../../core/settings-contract.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { AppError } from '../../utils/errors.ts'; +import { readLocationCoordinate } from '../../utils/location-coordinates.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { enumField, numberField, requiredField, stringField } from '../command-input.ts'; +import { + direct, + isOneOf, + optionalString, + selectionOptionsFromFlags, + setOf, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; + +export const SETTINGS_COMMAND_NAME = 'settings'; +const settingsCommandDescription = 'Change OS settings and app permissions.'; + +export const settingsCommandMetadata = defineFieldCommandMetadata( + SETTINGS_COMMAND_NAME, + settingsCommandDescription, + { + setting: requiredField(stringField()), + state: requiredField(stringField()), + app: stringField(), + latitude: numberField(), + longitude: numberField(), + permission: stringField(), + mode: enumField(['full', 'limited']), + }, +); + +export const settingsCommandDefinition = defineExecutableCommand( + settingsCommandMetadata, + (client, input) => client.settings.update(input as SettingsUpdateOptions), +); + +export const settingsCliSchema = { + usageOverride: SETTINGS_USAGE_OVERRIDE, + listUsageOverride: 'settings [area] [options]', + helpDescription: + 'Toggle OS settings, animation scales, appearance, and app permissions (macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS; mobile permission actions use the active session app)', + summary: 'Change OS settings and app permissions', + positionalArgs: ['setting', 'state', 'target?', 'mode?'], +} as const satisfies CommandSchemaOverride; + +export const settingsCliReader: CliReader = (positionals, flags) => + readSettingsOptionsFromPositionals(positionals, flags); + +export const settingsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.settings, (input) => + settingsPositionals(input as SettingsUpdateOptions), +); + +// fallow-ignore-next-line complexity +function readSettingsOptionsFromPositionals( + positionals: string[], + flags: CliFlags, +): SettingsUpdateOptions { + const base = selectionOptionsFromFlags(flags); + const setting = positionals[0]; + const state = positionals[1]; + if (isOneOf(setting, ON_OFF_SETTINGS) && isOneOf(state, ON_OFF_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'location' && state === 'set') { + return { + ...base, + setting, + state, + latitude: readLocationCoordinate(positionals[2], 'latitude'), + longitude: readLocationCoordinate(positionals[3], 'longitude'), + }; + } + if (setting === 'appearance' && isOneOf(state, APPEARANCE_STATES)) { + return { ...base, setting, state }; + } + if (isOneOf(setting, BIOMETRIC_SETTINGS) && isOneOf(state, BIOMETRIC_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'fingerprint' && isOneOf(state, FINGERPRINT_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'permission' && isOneOf(state, PERMISSION_STATES)) { + return { + ...base, + setting, + state, + permission: readPermission(positionals[2]), + mode: readPermissionMode(positionals[3]), + }; + } + if (setting === 'clear-app-state') { + const app = state === 'clear' ? positionals[2] : state; + return { ...base, setting, state: 'clear', app }; + } + throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); +} + +function settingsPositionals(input: SettingsUpdateOptions): string[] { + if (input.setting === 'clear-app-state') { + return [input.setting, ...optionalString(input.app)]; + } + if (input.setting === 'location' && input.state === 'set') { + return [input.setting, input.state, String(input.latitude), String(input.longitude)]; + } + if (input.setting === 'permission') { + return [input.setting, input.state, input.permission, ...optionalString(input.mode)]; + } + return [input.setting, input.state]; +} + +function readPermission(value: string | undefined): PermissionTarget { + if (isOneOf(value, PERMISSION_TARGETS)) return value; + throw new AppError('INVALID_ARGS', 'settings permission requires a permission target.'); +} + +function readPermissionMode(value: string | undefined): 'full' | 'limited' | undefined { + if (value === undefined || value === 'full' || value === 'limited') return value; + throw new AppError('INVALID_ARGS', 'settings permission mode must be full or limited.'); +} + +type PermissionTarget = Extract['permission']; +type OnOffSetting = Extract['setting']; +type OnOffState = Extract['state']; +type BiometricSetting = Extract< + SettingsUpdateOptions, + { setting: 'faceid' | 'touchid' } +>['setting']; +type BiometricState = Extract['state']; +type FingerprintState = Extract['state']; +type AppearanceState = Extract['state']; +type PermissionState = Extract['state']; + +const ON_OFF_SETTINGS = setOf('wifi', 'airplane', 'location', 'animations'); +const ON_OFF_STATES = setOf('on', 'off'); +const APPEARANCE_STATES = setOf('light', 'dark', 'toggle'); +const BIOMETRIC_SETTINGS = setOf('faceid', 'touchid'); +const BIOMETRIC_STATES = setOf('match', 'nonmatch', 'enroll', 'unenroll'); +const FINGERPRINT_STATES = setOf('match', 'nonmatch'); +const PERMISSION_STATES = setOf('grant', 'deny', 'reset'); +const PERMISSION_TARGETS = setOf( + 'camera', + 'microphone', + 'photos', + 'contacts', + 'contacts-limited', + 'notifications', + 'calendar', + 'location', + 'location-always', + 'media-library', + 'motion', + 'reminders', + 'siri', + 'accessibility', + 'screen-recording', + 'input-monitoring', +); diff --git a/src/commands/wait-command-contract.ts b/src/commands/capture/wait-command-contract.ts similarity index 58% rename from src/commands/wait-command-contract.ts rename to src/commands/capture/wait-command-contract.ts index e8e62d99c..d7cbb428c 100644 --- a/src/commands/wait-command-contract.ts +++ b/src/commands/capture/wait-command-contract.ts @@ -1,2 +1 @@ export const WAIT_KIND_VALUES = ['duration', 'text', 'ref', 'selector'] as const; -export type WaitKind = (typeof WAIT_KIND_VALUES)[number]; diff --git a/src/commands/cli-grammar/apps.ts b/src/commands/cli-grammar/apps.ts deleted file mode 100644 index 55e97cea1..000000000 --- a/src/commands/cli-grammar/apps.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { AppPushOptions, AppTriggerEventOptions } from '../../client-types.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'; -import { - commonInputFromFlags, - direct, - optionalString, - readJsonObject, - request, - requiredDaemonString, - requiredString, -} from './common.ts'; -import type { CliReader, DaemonWriter, CommandInput } from './types.ts'; - -export const appCliReaders = { - devices: (_positionals, flags) => commonInputFromFlags(flags), - apps: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - appsFilter: assertResolvedAppsFilter(flags.appsFilter), - }), - session: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readSessionAction(positionals[0]), - }), - boot: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - headless: flags.headless, - }), - shutdown: (_positionals, flags) => commonInputFromFlags(flags), - prepare: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: requiredString(positionals[0], 'prepare requires subcommand'), - timeoutMs: flags.timeoutMs, - }), - open: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: positionals[0], - url: positionals[1], - surface: flags.surface, - activity: flags.activity, - launchConsole: flags.launchConsole, - launchArgs: flags.launchArgs, - relaunch: flags.relaunch, - saveScript: flags.saveScript, - deviceHub: flags.deviceHub, - noRecord: flags.noRecord, - }), - close: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: positionals[0], - shutdown: flags.shutdown, - saveScript: flags.saveScript, - }), - install: installInputFromCli, - reinstall: installInputFromCli, - 'install-from-source': (positionals, flags) => ({ - ...commonInputFromFlags(flags), - source: resolveInstallSource(positionals, flags), - retainPaths: flags.retainPaths, - retentionMs: flags.retentionMs, - }), - push: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: requiredString(positionals[0], 'push requires bundleOrPackage'), - payload: requiredString(positionals[1], 'push requires payloadOrJson'), - }), - 'trigger-app-event': (positionals, flags) => ({ - ...commonInputFromFlags(flags), - event: requiredString(positionals[0], 'trigger-app-event requires event'), - payload: positionals[1] - ? readJsonObject(positionals[1], 'trigger-app-event payload') - : undefined, - }), -} satisfies Record; - -export const appDaemonWriters = { - devices: direct(PUBLIC_COMMANDS.devices), - boot: direct(PUBLIC_COMMANDS.boot), - shutdown: direct(PUBLIC_COMMANDS.shutdown), - prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [ - requiredDaemonString(input.action, 'prepare requires subcommand'), - ]), - apps: direct(PUBLIC_COMMANDS.apps), - open: direct(PUBLIC_COMMANDS.open, openPositionals), - close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)), - install: direct(PUBLIC_COMMANDS.install, (input) => requiredPair(input.app, input.appPath)), - reinstall: direct(PUBLIC_COMMANDS.reinstall, (input) => requiredPair(input.app, input.appPath)), - 'install-from-source': (input) => - request(INTERNAL_COMMANDS.installSource, [], { - ...input, - installSource: input.source, - retainMaterializedPaths: input.retainPaths, - materializedPathRetentionMs: input.retentionMs, - }), - push: direct(PUBLIC_COMMANDS.push, (input) => pushPositionals(input as AppPushOptions)), - 'trigger-app-event': direct(PUBLIC_COMMANDS.triggerAppEvent, (input) => - triggerEventPositionals(input as AppTriggerEventOptions), - ), -} satisfies Record; - -function installInputFromCli( - positionals: string[], - flags: CliFlags, - command = 'install', -): Record { - return { - ...commonInputFromFlags(flags), - app: requiredString(positionals[0], `${command} requires app`), - appPath: requiredString(positionals[1], `${command} requires path`), - }; -} - -function readSessionAction(value: string | undefined): 'list' | 'state-dir' { - const action = value ?? 'list'; - if (action === 'list') return action; - if (action === 'state-dir') return action; - throw new AppError('INVALID_ARGS', 'session only supports list or state-dir'); -} - -function openPositionals(input: CommandInput): string[] { - if (!input.app) return []; - return input.url ? [input.app, input.url] : [input.app]; -} - -function requiredPair(first: unknown, second: unknown): string[] { - return [ - requiredDaemonString(first, 'missing first positional'), - requiredDaemonString(second, 'missing second positional'), - ]; -} - -function pushPositionals(input: AppPushOptions): string[] { - return [ - input.app, - typeof input.payload === 'string' ? input.payload : JSON.stringify(input.payload), - ]; -} - -function triggerEventPositionals(input: AppTriggerEventOptions): string[] { - return [input.event, ...(input.payload ? [JSON.stringify(input.payload)] : [])]; -} - -// fallow-ignore-next-line complexity -function resolveInstallSource(positionals: string[], flags: CliFlags) { - const url = positionals[0]?.trim(); - if (positionals.length > 1) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source accepts either one positional or --github-actions-artifact', - ); - } - const githubArtifactSource = flags.githubActionsArtifact - ? parseGitHubActionsArtifactInstallSourceSpec(flags.githubActionsArtifact) - : undefined; - const configuredSource = flags.installSource; - const sourceCount = (url ? 1 : 0) + (githubArtifactSource ? 1 : 0) + (configuredSource ? 1 : 0); - if (sourceCount !== 1) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source requires exactly one source: , --github-actions-artifact, or config installSource', - ); - } - if (!url && flags.header && flags.header.length > 0) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source --header is only supported for URL sources', - ); - } - if (githubArtifactSource) return githubArtifactSource; - if (configuredSource) return configuredSource; - return { - kind: 'url' as const, - url: url!, - headers: parseInstallSourceHeaders(flags.header), - }; -} - -function parseInstallSourceHeaders( - headerFlags: CliFlags['header'], -): Record | undefined { - if (!headerFlags || headerFlags.length === 0) return undefined; - const headers: Record = {}; - for (const rawHeader of headerFlags) { - const separator = rawHeader.indexOf(':'); - if (separator <= 0) { - throw new AppError( - 'INVALID_ARGS', - `Invalid --header value "${rawHeader}". Expected "name:value".`, - ); - } - const name = rawHeader.slice(0, separator).trim(); - const value = rawHeader.slice(separator + 1).trim(); - if (!name) { - throw new AppError( - 'INVALID_ARGS', - `Invalid --header value "${rawHeader}". Header name cannot be empty.`, - ); - } - headers[name] = value; - } - return headers; -} diff --git a/src/commands/cli-grammar/capture.ts b/src/commands/cli-grammar/capture.ts deleted file mode 100644 index 7812f0a05..000000000 --- a/src/commands/cli-grammar/capture.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { - AlertCommandOptions, - CaptureScreenshotOptions, - SettingsUpdateOptions, - WaitCommandOptions, -} from '../../client-types.ts'; -import type { AlertAction } from '../../alert-contract.ts'; -import { parseWaitPositionals } from '../../core/wait-positionals.ts'; -import type { CliFlags } from '../../utils/cli-flags.ts'; -import { AppError } from '../../utils/errors.ts'; -import { readLocationCoordinate } from '../../utils/location-coordinates.ts'; -import { tryParseSelectorChain } from '../../utils/selectors-parse.ts'; -import { - screenshotFlagsFromOptions, - screenshotOptionsFromFlags, -} from '../capture-screenshot-options.ts'; -import { compactRecord } from '../command-input.ts'; -import { - commonInputFromFlags, - direct, - isOneOf, - optionalNumber, - optionalString, - readFiniteNumber, - request, - requiredDaemonString, - selectionOptionsFromFlags, - selectorSnapshotOptionsFromFlags, - setOf, -} from './common.ts'; -import type { CliReader, DaemonWriter } from './types.ts'; - -export const captureCliReaders = { - snapshot: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - interactiveOnly: flags.snapshotInteractiveOnly, - compact: flags.snapshotCompact, - depth: flags.snapshotDepth, - scope: flags.snapshotScope, - raw: flags.snapshotRaw, - forceFull: flags.snapshotForceFull, - timeoutMs: flags.timeoutMs, - }), - screenshot: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - path: positionals[0] ?? flags.out, - ...screenshotOptionsFromFlags(flags), - }), - diff: (positionals, flags) => { - if (positionals[0] !== 'snapshot') { - throw new AppError('INVALID_ARGS', 'Only diff snapshot is available through this parser.'); - } - return { - ...commonInputFromFlags(flags), - kind: 'snapshot', - out: flags.out, - interactiveOnly: flags.snapshotInteractiveOnly, - compact: flags.snapshotCompact, - depth: flags.snapshotDepth, - scope: flags.snapshotScope, - raw: flags.snapshotRaw, - }; - }, - wait: (positionals, flags) => readWaitOptionsFromPositionals(positionals, flags), - alert: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readAlertInput(positionals), - }), - settings: (positionals, flags) => readSettingsOptionsFromPositionals(positionals, flags), -} satisfies Record; - -export const captureDaemonWriters = { - snapshot: direct(PUBLIC_COMMANDS.snapshot), - screenshot: (input) => - request(PUBLIC_COMMANDS.screenshot, optionalString(input.path), { - ...input, - ...screenshotFlagsFromOptions(input as CaptureScreenshotOptions), - }), - diff: direct(PUBLIC_COMMANDS.diff, (input) => [ - requiredDaemonString(input.kind, 'diff requires kind'), - ]), - wait: direct(PUBLIC_COMMANDS.wait, (input) => waitPositionals(input as WaitCommandOptions)), - alert: direct(PUBLIC_COMMANDS.alert, (input) => alertPositionals(input as AlertCommandOptions)), - settings: direct(PUBLIC_COMMANDS.settings, (input) => - settingsPositionals(input as SettingsUpdateOptions), - ), -} satisfies Record; - -function readWaitOptionsFromPositionals( - positionals: string[], - flags: CliFlags, -): WaitCommandOptions { - const parsed = parseWaitPositionals(positionals); - if (!parsed) { - throw new AppError( - 'INVALID_ARGS', - 'wait requires , text , @ref, or [timeoutMs].', - ); - } - const base = { - ...selectionOptionsFromFlags(flags), - ...selectorSnapshotOptionsFromFlags(flags), - }; - if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs }; - if (parsed.kind === 'text') { - if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.'); - return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) }; - } - if (parsed.kind === 'ref') { - return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) }; - } - return { - ...base, - selector: parsed.selectorExpression, - ...readTimeoutOption(parsed.timeoutMs), - }; -} - -export { parseWaitPositionals }; - -// fallow-ignore-next-line complexity -function waitPositionals(options: WaitCommandOptions): string[] { - const targets = [ - options.durationMs !== undefined ? 'durationMs' : undefined, - options.text !== undefined ? 'text' : undefined, - options.ref !== undefined ? 'ref' : undefined, - options.selector !== undefined ? 'selector' : undefined, - ].filter(Boolean); - if (targets.length !== 1) { - throw new AppError( - 'INVALID_ARGS', - 'wait command requires exactly one of durationMs, text, ref, or selector.', - ); - } - if (options.durationMs !== undefined) return [String(options.durationMs)]; - const timeout = optionalNumber(options.timeoutMs); - if (options.text !== undefined) return ['text', options.text, ...timeout]; - if (options.ref !== undefined) return [options.ref, ...timeout]; - const selector = options.selector!; - if (!tryParseSelectorChain(selector)) { - throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); - } - return [selector, ...timeout]; -} - -function alertPositionals(input: AlertCommandOptions): string[] { - return [input.action ?? 'get', ...optionalNumber(input.timeoutMs)]; -} - -function readAlertInput(positionals: string[]): Record { - if (positionals.length > 2) { - throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.'); - } - const action = readAlertAction(positionals[0]); - const timeoutMs = readFiniteNumber(positionals[1], 'alert timeout'); - return compactRecord({ action, timeoutMs }); -} - -function readAlertAction(value: string | undefined): AlertAction | undefined { - const action = value?.toLowerCase(); - if ( - action === undefined || - action === 'get' || - action === 'accept' || - action === 'dismiss' || - action === 'wait' - ) { - return action; - } - throw new AppError('INVALID_ARGS', 'alert action must be get, accept, dismiss, or wait.'); -} - -function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { - return timeoutMs === null ? {} : { timeoutMs }; -} - -// fallow-ignore-next-line complexity -function readSettingsOptionsFromPositionals( - positionals: string[], - flags: CliFlags, -): SettingsUpdateOptions { - const base = selectionOptionsFromFlags(flags); - const setting = positionals[0]; - const state = positionals[1]; - if (isOneOf(setting, ON_OFF_SETTINGS) && isOneOf(state, ON_OFF_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'location' && state === 'set') { - return { - ...base, - setting, - state, - latitude: readLocationCoordinate(positionals[2], 'latitude'), - longitude: readLocationCoordinate(positionals[3], 'longitude'), - }; - } - if (setting === 'appearance' && isOneOf(state, APPEARANCE_STATES)) { - return { ...base, setting, state }; - } - if (isOneOf(setting, BIOMETRIC_SETTINGS) && isOneOf(state, BIOMETRIC_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'fingerprint' && isOneOf(state, FINGERPRINT_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'permission' && isOneOf(state, PERMISSION_STATES)) { - return { - ...base, - setting, - state, - permission: readPermission(positionals[2]), - mode: readPermissionMode(positionals[3]), - }; - } - if (setting === 'clear-app-state') { - const app = state === 'clear' ? positionals[2] : state; - return { ...base, setting, state: 'clear', app }; - } - throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); -} - -function settingsPositionals(input: SettingsUpdateOptions): string[] { - if (input.setting === 'clear-app-state') { - return [input.setting, ...optionalString(input.app)]; - } - if (input.setting === 'location' && input.state === 'set') { - return [input.setting, input.state, String(input.latitude), String(input.longitude)]; - } - if (input.setting === 'permission') { - return [input.setting, input.state, input.permission, ...optionalString(input.mode)]; - } - return [input.setting, input.state]; -} - -function readPermission(value: string | undefined): PermissionTarget { - if (isOneOf(value, PERMISSION_TARGETS)) return value; - throw new AppError('INVALID_ARGS', 'settings permission requires a permission target.'); -} - -function readPermissionMode(value: string | undefined): 'full' | 'limited' | undefined { - if (value === undefined || value === 'full' || value === 'limited') return value; - throw new AppError('INVALID_ARGS', 'settings permission mode must be full or limited.'); -} - -type PermissionTarget = Extract['permission']; -type OnOffSetting = Extract['setting']; -type OnOffState = Extract['state']; -type BiometricSetting = Extract< - SettingsUpdateOptions, - { setting: 'faceid' | 'touchid' } ->['setting']; -type BiometricState = Extract['state']; -type FingerprintState = Extract['state']; -type AppearanceState = Extract['state']; -type PermissionState = Extract['state']; - -const ON_OFF_SETTINGS = setOf('wifi', 'airplane', 'location', 'animations'); -const ON_OFF_STATES = setOf('on', 'off'); -const APPEARANCE_STATES = setOf('light', 'dark', 'toggle'); -const BIOMETRIC_SETTINGS = setOf('faceid', 'touchid'); -const BIOMETRIC_STATES = setOf('match', 'nonmatch', 'enroll', 'unenroll'); -const FINGERPRINT_STATES = setOf('match', 'nonmatch'); -const PERMISSION_STATES = setOf('grant', 'deny', 'reset'); -const PERMISSION_TARGETS = setOf( - 'camera', - 'microphone', - 'photos', - 'contacts', - 'contacts-limited', - 'notifications', - 'calendar', - 'location', - 'location-always', - 'media-library', - 'motion', - 'reminders', - 'siri', - 'accessibility', - 'screen-recording', - 'input-monitoring', -); diff --git a/src/commands/cli-grammar/metro.ts b/src/commands/cli-grammar/metro.ts deleted file mode 100644 index cd2c6f740..000000000 --- a/src/commands/cli-grammar/metro.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { AppError } from '../../utils/errors.ts'; -import type { MetroPrepareKind } from '../../client-metro.ts'; -import type { CliReader } from './types.ts'; - -export const metroCliReaders = { - metro: metroInputFromCli, -} satisfies Record; - -// fallow-ignore-next-line complexity -function metroInputFromCli(positionals: string[], flags: Parameters[1]) { - const action = (positionals[0] ?? '').toLowerCase(); - if (action !== 'prepare' && action !== 'reload') { - throw new AppError('INVALID_ARGS', 'metro requires a subcommand: prepare or reload'); - } - if (action === 'reload') { - return { - action, - metroHost: flags.metroHost, - metroPort: flags.metroPort, - bundleUrl: flags.bundleUrl, - timeoutMs: flags.metroProbeTimeoutMs, - }; - } - if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { - throw new AppError( - 'INVALID_ARGS', - 'metro prepare requires --public-base-url or --proxy-base-url .', - ); - } - return { - action, - projectRoot: flags.metroProjectRoot, - kind: readMetroPrepareKind(flags.kind ?? flags.metroKind), - port: flags.metroPreparePort, - listenHost: flags.metroListenHost, - statusHost: flags.metroStatusHost, - publicBaseUrl: flags.metroPublicBaseUrl, - proxyBaseUrl: flags.metroProxyBaseUrl, - bearerToken: flags.metroBearerToken, - bridgeScope: - flags.tenant && flags.runId && flags.leaseId - ? { - tenantId: flags.tenant, - runId: flags.runId, - leaseId: flags.leaseId, - } - : undefined, - startupTimeoutMs: flags.metroStartupTimeoutMs, - probeTimeoutMs: flags.metroProbeTimeoutMs, - reuseExisting: flags.metroNoReuseExisting ? false : undefined, - installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, - runtimeFilePath: flags.metroRuntimeFile, - }; -} - -function readMetroPrepareKind(value: string | undefined): MetroPrepareKind | undefined { - if (value === undefined) return undefined; - if (value === 'auto' || value === 'react-native' || value === 'expo') return value; - throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo'); -} diff --git a/src/commands/cli-grammar/observability.ts b/src/commands/cli-grammar/observability.ts deleted file mode 100644 index 02f77f17b..000000000 --- a/src/commands/cli-grammar/observability.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { - LogsOptions, - NetworkOptions, - PerfOptions, - RecordOptions, -} from '../../client-types.ts'; -import { AppError } from '../../utils/errors.ts'; -import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; -import { parseStringMember } from '../../utils/string-enum.ts'; -import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts'; -import { - isPerfAction, - isPerfArea, - isPerfKind, - isPerfSubject, - PERF_ACTION_ERROR_MESSAGE, - PERF_AREA_ERROR_MESSAGE, - PERF_KIND_ERROR_MESSAGE, - PERF_SUBJECT_ERROR_MESSAGE, - type PerfAction, - type PerfArea, - type PerfKind, - type PerfSubject, -} from '../perf-command-contract.ts'; -import { - commonInputFromFlags, - direct, - optionalCliNumber, - optionalNumber, - optionalString, - request, -} from './common.ts'; -import type { CliReader, DaemonWriter } from './types.ts'; - -export const observabilityCliReaders = { - debug: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readDebugAction(positionals[0]), - artifact: flags.artifact, - dsym: flags.dsym, - searchPath: flags.searchPath, - out: flags.out, - }), - perf: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readPerfPositionals(positionals, { - kind: readPerfKindFlag(flags.kind), - template: flags.perfTemplate, - out: flags.out, - }), - }), - logs: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readLogsAction(positionals[0]), - message: positionals.slice(1).join(' ') || undefined, - restart: flags.restart, - }), - network: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readNetworkAction(positionals[0]), - limit: optionalCliNumber(positionals[1]), - include: flags.networkInclude ?? readNetworkInclude(positionals[2]), - }), - record: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readStartStop(positionals[0], 'record'), - path: positionals[1], - fps: flags.fps, - quality: flags.quality as RecordOptions['quality'], - hideTouches: flags.hideTouches, - }), - trace: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readStartStop(positionals[0], 'trace'), - path: positionals[1], - }), -} satisfies Record; - -export const observabilityDaemonWriters = { - perf: direct(PUBLIC_COMMANDS.perf, (input) => perfPositionals(input as PerfOptions)), - logs: direct(PUBLIC_COMMANDS.logs, (input) => logsPositionals(input as LogsOptions)), - network: (input) => - request(PUBLIC_COMMANDS.network, networkPositionals(input as NetworkOptions), { - ...input, - networkInclude: input.include, - }), - record: direct(PUBLIC_COMMANDS.record, (input) => recordingPositionals(input as RecordOptions)), - trace: direct(PUBLIC_COMMANDS.trace, (input) => recordingPositionals(input as RecordOptions)), -} satisfies Record; - -function perfPositionals(input: PerfOptions): string[] { - const area = input.area ?? (input.action ? 'metrics' : undefined); - if (area === 'cpu') { - return nativePerfPositionals( - [ - ...optionalString(area), - ...optionalString(input.subject), - ...optionalString(input.action), - ...optionalString(input.kind), - ], - input, - ); - } - if (area === 'trace') { - return nativePerfPositionals( - [...optionalString(area), ...optionalString(input.action), ...optionalString(input.kind)], - input, - ); - } - return [...optionalString(area), ...optionalString(input.action)]; -} - -function nativePerfPositionals(base: string[], input: PerfOptions): string[] { - const positionals = [...base]; - if (input.template || input.out || input.tracePath) { - positionals.push(input.template ?? ''); - } - if (input.out || input.tracePath) { - positionals.push(input.out ?? ''); - } - if (input.tracePath) { - positionals.push(input.tracePath); - } - return positionals; -} - -function readPerfPositionals( - positionals: string[], - flags: Pick = {}, -): Pick { - if (positionals[0] !== undefined && positionals[1] === undefined) { - const action = readPerfAction(positionals[0], { allowUndefined: true }); - if (action) return { action, kind: readPerfKind(flags.kind), out: flags.out }; - } - const area = readPerfArea(positionals[0]); - if (area === 'cpu') { - return { - area, - subject: readPerfSubject(positionals[1]), - action: readPerfAction(positionals[2]), - kind: readPerfKind(flags.kind), - template: flags.template, - out: flags.out, - }; - } - if (area === 'trace') { - return { - area, - action: readPerfAction(positionals[1]), - kind: readPerfKind(flags.kind), - template: flags.template, - out: flags.out, - }; - } - return { - area, - action: readPerfAction(positionals[1]), - kind: readPerfKind(flags.kind), - out: flags.out, - }; -} - -function logsPositionals(input: { action?: string; message?: string }): string[] { - return [input.action ?? 'path', ...optionalString(input.message)]; -} - -function networkPositionals(input: NetworkOptions): string[] { - return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)]; -} - -function recordingPositionals(input: RecordOptions): string[] { - return [input.action, ...optionalString(input.path)]; -} - -function readStartStop(value: string | undefined, command: string): 'start' | 'stop' { - if (value === 'start' || value === 'stop') return value; - throw new AppError('INVALID_ARGS', `${command} requires start|stop`); -} - -function readDebugAction(value: string | undefined): 'symbols' { - if (value === 'symbols') return value; - throw new AppError( - 'INVALID_ARGS', - 'debug supports only symbols; use logs, network, perf, record, trace, or react-devtools for other diagnostics.', - ); -} - -function readPerfArea(value: string | undefined): PerfArea | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfArea(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); -} - -function readPerfAction( - value: string | undefined, - options: { allowUndefined?: boolean } = {}, -): PerfAction | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfAction(normalized)) return normalized; - if (options.allowUndefined) return undefined; - throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); -} - -function readPerfSubject(value: string | undefined): PerfSubject { - const normalized = value?.toLowerCase(); - if (normalized !== undefined && isPerfSubject(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); -} - -function readPerfKind(value: string | undefined): PerfKind | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfKind(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); -} - -function readPerfKindFlag(value: unknown): PerfKind | undefined { - return typeof value === 'string' ? readPerfKind(value) : undefined; -} - -function readLogsAction(value: string | undefined): LogAction | undefined { - if (value === undefined) return undefined; - return parseStringMember(LOG_ACTION_VALUES, value, { - message: 'logs requires path, start, stop, doctor, mark, or clear', - }); -} - -function readNetworkAction(value: string | undefined): 'dump' | 'log' | undefined { - if (value === undefined) return undefined; - if (value === 'dump' || value === 'log') return value; - throw new AppError('INVALID_ARGS', 'network requires dump or log'); -} - -function readNetworkInclude(value: string | undefined): NetworkIncludeMode | undefined { - if (value === undefined) return undefined; - return parseStringMember(NETWORK_INCLUDE_MODES, value, { - message: 'network include mode must be summary, headers, body, or all', - }); -} diff --git a/src/commands/cli-grammar/registry.ts b/src/commands/cli-grammar/registry.ts index 99ad268ba..553daf656 100644 --- a/src/commands/cli-grammar/registry.ts +++ b/src/commands/cli-grammar/registry.ts @@ -1,34 +1,34 @@ import type { CliFlags } from '../../utils/cli-flags.ts'; -import { appCliReaders } from './apps.ts'; -import { captureCliReaders } from './capture.ts'; -import { commonInputFromFlags } from './common.ts'; -import { gestureCliReaders } from './gesture.ts'; -import { interactionCliReaders } from './interactions.ts'; -import { metroCliReaders } from './metro.ts'; -import { observabilityCliReaders } from './observability.ts'; -import { replayCliReaders } from './replay.ts'; -import { selectorCliReaders } from './selectors.ts'; -import { systemCliReaders } from './system.ts'; +import { batchCliReaders } from '../batch/index.ts'; +import { captureCliReaders } from '../capture/index.ts'; import type { CliReader } from './types.ts'; import type { CommandName } from '../command-metadata.ts'; +import { + gestureCliReaders, + interactionCliReaders, + selectorCliReaders as interactionSelectorCliReaders, +} from '../interaction/index.ts'; +import { appCliReaders } from '../management/index.ts'; +import { metroCliReaders } from '../metro/index.ts'; +import { observabilityCliReaders } from '../observability/index.ts'; +import { reactNativeCliReaders } from '../react-native/index.ts'; +import { recordingCliReaders } from '../recording/index.ts'; +import { replayCliReaders } from '../replay/index.ts'; +import { systemCliReaders } from '../system/index.ts'; const cliReaders = { ...appCliReaders, ...captureCliReaders, ...interactionCliReaders, ...gestureCliReaders, - ...selectorCliReaders, + ...interactionSelectorCliReaders, ...observabilityCliReaders, + ...reactNativeCliReaders, + ...recordingCliReaders, ...replayCliReaders, ...systemCliReaders, ...metroCliReaders, - batch: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - steps: flags.batchSteps ?? [], - onError: flags.batchOnError, - maxSteps: flags.batchMaxSteps, - out: flags.out, - }), + ...batchCliReaders, } satisfies Record; export function readInputFromCli( diff --git a/src/commands/cli-grammar/replay.ts b/src/commands/cli-grammar/replay.ts deleted file mode 100644 index bc963f3a7..000000000 --- a/src/commands/cli-grammar/replay.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { commonInputFromFlags, request, requiredDaemonString, requiredString } from './common.ts'; -import type { CliReader, CommandInput, DaemonWriter } from './types.ts'; - -export const replayCliReaders = { - replay: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - path: requiredString(positionals[0], 'replay requires path'), - update: flags.replayUpdate, - backend: flags.replayMaestro ? 'maestro' : undefined, - env: flags.replayEnv, - }), - test: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - paths: positionals, - update: flags.replayUpdate, - backend: flags.replayMaestro ? 'maestro' : undefined, - env: flags.replayEnv, - failFast: flags.failFast, - timeoutMs: flags.timeoutMs, - retries: flags.retries, - recordVideo: flags.recordVideo, - artifactsDir: flags.artifactsDir, - reportJunit: flags.reportJunit, - shardAll: flags.shardAll, - shardSplit: flags.shardSplit, - }), -} satisfies Record; - -export const replayDaemonWriters = { - replay: (input) => - request(PUBLIC_COMMANDS.replay, [requiredDaemonString(input.path, 'replay requires path')], { - ...input, - replayUpdate: input.update, - replayBackend: readReplayBackend(input), - replayEnv: input.env, - replayShellEnv: collectReplayClientShellEnv(process.env), - }), - test: (input) => - request(PUBLIC_COMMANDS.test, input.paths ?? [], { - ...input, - replayUpdate: input.update, - replayBackend: readReplayBackend(input), - replayEnv: input.env, - replayShellEnv: collectReplayClientShellEnv(process.env), - }), -} satisfies Record; - -const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; - -function readReplayBackend(input: CommandInput): string | undefined { - return input.backend ?? (input.maestro === true ? 'maestro' : undefined); -} - -function collectReplayClientShellEnv(env: NodeJS.ProcessEnv): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (typeof value === 'string' && key.startsWith(REPLAY_SHELL_ENV_PREFIX)) result[key] = value; - } - return result; -} diff --git a/src/commands/cli-grammar/system.test.ts b/src/commands/cli-grammar/system.test.ts deleted file mode 100644 index 4d7508974..000000000 --- a/src/commands/cli-grammar/system.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import type { CliFlags } from '../../utils/cli-flags.ts'; -import type { CommandInput } from './types.ts'; -import { systemCliReaders, systemDaemonWriters } from './system.ts'; - -function flags(overrides: Partial = {}): CliFlags { - return overrides as CliFlags; -} - -function expectInvalidArgs(fn: () => unknown, messageFragment: string) { - expect(fn).toThrow( - expect.objectContaining({ - code: 'INVALID_ARGS', - message: expect.stringContaining(messageFragment), - }), - ); -} - -describe('system CLI readers', () => { - test('the parameterless readers project the common selection flags through', () => { - for (const command of ['appstate', 'home', 'app-switcher'] as const) { - expect(systemCliReaders[command]([], flags({ platform: 'ios' }))).toEqual({ - platform: 'ios', - }); - } - }); - - test('back reader forwards the configured back mode', () => { - expect(systemCliReaders.back([], flags({ backMode: 'system' }))).toMatchObject({ - mode: 'system', - }); - }); - - test('rotate reader normalizes the orientation argument', () => { - expect(systemCliReaders.rotate(['left'], flags())).toMatchObject({ - orientation: 'landscape-left', - }); - }); - - test('rotate reader rejects a missing orientation', () => { - expectInvalidArgs(() => systemCliReaders.rotate([], flags()), 'rotate requires an orientation'); - }); - - describe('keyboard reader', () => { - test('maps the "get" alias to the status action', () => { - expect(systemCliReaders.keyboard(['get'], flags())).toMatchObject({ action: 'status' }); - }); - - test('omits the action entirely when no argument is given', () => { - expect(systemCliReaders.keyboard([], flags())).not.toHaveProperty('action'); - }); - - test('rejects more than one keyboard argument', () => { - expectInvalidArgs( - () => systemCliReaders.keyboard(['dismiss', 'extra'], flags()), - 'at most one action argument', - ); - }); - - test('rejects an unknown keyboard action', () => { - expectInvalidArgs( - () => systemCliReaders.keyboard(['wiggle'], flags()), - 'keyboard action must be', - ); - }); - }); - - describe('clipboard reader', () => { - test('parses a read subcommand', () => { - expect(systemCliReaders.clipboard(['read'], flags())).toMatchObject({ action: 'read' }); - }); - - test('joins multi-word text for a write subcommand', () => { - expect(systemCliReaders.clipboard(['write', 'hello', 'world'], flags())).toMatchObject({ - action: 'write', - text: 'hello world', - }); - }); - - test('rejects a missing subcommand', () => { - expectInvalidArgs(() => systemCliReaders.clipboard([], flags()), 'read or write'); - }); - - test('rejects extra arguments after read', () => { - expectInvalidArgs( - () => systemCliReaders.clipboard(['read', 'oops'], flags()), - 'does not accept additional arguments', - ); - }); - - test('rejects a write without any text', () => { - expectInvalidArgs( - () => systemCliReaders.clipboard(['write'], flags()), - 'clipboard write requires text', - ); - }); - }); - - describe('react-native reader', () => { - test('accepts the dismiss-overlay action', () => { - expect(systemCliReaders['react-native'](['dismiss-overlay'], flags())).toMatchObject({ - action: 'dismiss-overlay', - }); - }); - - test('rejects any other react-native action', () => { - expectInvalidArgs( - () => systemCliReaders['react-native'](['reload'], flags()), - 'react-native supports only', - ); - }); - }); -}); - -describe('system daemon writers', () => { - test('the direct writers emit their command with no positionals', () => { - for (const command of ['appstate', 'home', 'app-switcher'] as const) { - const request = systemDaemonWriters[command]({} as CommandInput); - expect(request.command).toBe(command); - expect(request.positionals).toEqual([]); - } - }); - - test('back writer keeps recognized back modes', () => { - expect(systemDaemonWriters.back({ mode: 'in-app' } as CommandInput).options).toMatchObject({ - backMode: 'in-app', - }); - }); - - test('back writer drops an unrecognized back mode', () => { - const options = systemDaemonWriters.back({ mode: 'teleport' } as unknown as CommandInput) - .options as Record; - expect(options.backMode).toBeUndefined(); - }); - - test('rotate writer serializes the orientation positional', () => { - expect( - systemDaemonWriters.rotate({ orientation: 'portrait' } as CommandInput).positionals, - ).toEqual(['portrait']); - }); - - test('rotate writer requires an orientation', () => { - expectInvalidArgs( - () => systemDaemonWriters.rotate({} as CommandInput), - 'rotate requires orientation', - ); - }); - - test('keyboard writer forwards the action when present and is empty otherwise', () => { - expect(systemDaemonWriters.keyboard({ action: 'dismiss' } as CommandInput).positionals).toEqual( - ['dismiss'], - ); - expect(systemDaemonWriters.keyboard({} as CommandInput).positionals).toEqual([]); - }); - - test('clipboard writer serializes read and write subcommands', () => { - expect(systemDaemonWriters.clipboard({ action: 'read' } as CommandInput).positionals).toEqual([ - 'read', - ]); - expect( - systemDaemonWriters.clipboard({ action: 'write', text: 'copied' } as CommandInput) - .positionals, - ).toEqual(['write', 'copied']); - }); - - test('react-native writer requires an action', () => { - expect( - systemDaemonWriters['react-native']({ action: 'dismiss-overlay' } as CommandInput) - .positionals, - ).toEqual(['dismiss-overlay']); - expectInvalidArgs( - () => systemDaemonWriters['react-native']({} as CommandInput), - 'react-native requires action', - ); - }); -}); diff --git a/src/commands/cli-grammar/system.ts b/src/commands/cli-grammar/system.ts deleted file mode 100644 index f77c6a089..000000000 --- a/src/commands/cli-grammar/system.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { ClipboardCommandOptions } from '../../client-types.ts'; -import { parseDeviceRotation } from '../../core/device-rotation.ts'; -import type { BackMode } from '../../core/back-mode.ts'; -import { AppError } from '../../utils/errors.ts'; -import { compactRecord } from '../command-input.ts'; -import { - commonInputFromFlags, - direct, - optionalString, - request, - requiredDaemonString, -} from './common.ts'; -import type { CliReader, DaemonWriter } from './types.ts'; - -export const systemCliReaders = { - appstate: (_positionals, flags) => commonInputFromFlags(flags), - home: (_positionals, flags) => commonInputFromFlags(flags), - 'app-switcher': (_positionals, flags) => commonInputFromFlags(flags), - back: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - mode: flags.backMode, - }), - rotate: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - orientation: parseDeviceRotation(positionals[0]), - }), - keyboard: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readKeyboardInput(positionals), - }), - clipboard: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readClipboardInput(positionals), - }), - 'react-native': (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readReactNativeAction(positionals[0]), - }), -} satisfies Record; - -export const systemDaemonWriters = { - appstate: direct(PUBLIC_COMMANDS.appState), - back: (input) => - request(PUBLIC_COMMANDS.back, [], { ...input, backMode: readBackMode(input.mode) }), - home: direct(PUBLIC_COMMANDS.home), - rotate: direct(PUBLIC_COMMANDS.rotate, (input) => [ - requiredDaemonString(input.orientation, 'rotate requires orientation'), - ]), - 'app-switcher': direct(PUBLIC_COMMANDS.appSwitcher), - keyboard: direct(PUBLIC_COMMANDS.keyboard, (input) => optionalString(input.action)), - clipboard: direct(PUBLIC_COMMANDS.clipboard, (input) => - clipboardPositionals(input as ClipboardCommandOptions), - ), - 'react-native': direct(PUBLIC_COMMANDS.reactNative, (input) => [ - requiredDaemonString(input.action, 'react-native requires action'), - ]), -} satisfies Record; - -function readBackMode(value: unknown): BackMode | undefined { - return value === 'in-app' || value === 'system' ? value : undefined; -} - -function clipboardPositionals(input: ClipboardCommandOptions): string[] { - return input.action === 'read' ? ['read'] : ['write', input.text]; -} - -function readKeyboardInput(positionals: string[]): Record { - if (positionals.length > 1) { - throw new AppError('INVALID_ARGS', 'keyboard accepts at most one action argument.'); - } - return compactRecord({ action: readKeyboardAction(positionals[0]) }); -} - -function readClipboardInput(positionals: string[]): Record { - const action = positionals[0]?.toLowerCase(); - if (action !== 'read' && action !== 'write') { - throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write.'); - } - if (action === 'read') { - if (positionals.length !== 1) { - throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments.'); - } - return { action }; - } - if (positionals.length < 2) { - throw new AppError('INVALID_ARGS', 'clipboard write requires text.'); - } - return { action, text: positionals.slice(1).join(' ') }; -} - -function readKeyboardAction( - value: string | undefined, -): 'status' | 'dismiss' | 'enter' | 'return' | undefined { - const action = value?.toLowerCase(); - if (action === 'get') return 'status'; - if ( - action === undefined || - action === 'status' || - action === 'dismiss' || - action === 'enter' || - action === 'return' - ) { - return action; - } - throw new AppError( - 'INVALID_ARGS', - 'keyboard action must be status, get, dismiss, enter, or return.', - ); -} - -function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { - if (value === 'dismiss-overlay') return value; - throw new AppError('INVALID_ARGS', 'react-native supports only: dismiss-overlay'); -} diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index aecab3324..1ea87be1c 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -1,97 +1,24 @@ -import type { CommandRequestResult } from '../client.ts'; -import type { CommandName } from './command-metadata.ts'; +import { batchCliOutputFormatters } from './batch/output.ts'; +import { captureCliOutputFormatters } from './capture/output.ts'; import type { CliOutput } from './command-contract.ts'; -import { - appStateCliOutput, - appsCliOutput, - bootCliOutput, - clipboardCliOutput, - closeCliOutput, - debugSymbolsCliOutput, - deployCliOutput, - devicesCliOutput, - findCliOutput, - getCliOutput, - installFromSourceCliOutput, - isCliOutput, - keyboardCliOutput, - messageCliOutput, - metroCliOutput, - openCliOutput, - recordCliOutput, - sessionCliOutput, - shutdownCliOutput, - snapshotCliOutput, - tapCliOutput, -} from './client-output.ts'; -import { - batchCliOutput, - logsCliOutput, - networkCliOutput, - perfCliOutput, -} from './runtime-output.ts'; - -type CliOutputFormatter = (params: { - input: Record; - result: unknown; -}) => CliOutput; - -function resultOutput(formatter: (result: TResult) => CliOutput): CliOutputFormatter { - return ({ result }) => formatter(result as TResult); -} - -const messageOutput = resultOutput(messageCliOutput); +import { interactionCliOutputFormatters } from './interaction/output.ts'; +import { managementCliOutputFormatters } from './management/output.ts'; +import { metroCliOutputFormatters } from './metro/output.ts'; +import { observabilityCliOutputFormatters } from './observability/output.ts'; +import type { CliOutputFormatter } from './output-common.ts'; +import { recordingCliOutputFormatters } from './recording/output.ts'; +import { systemCliOutputFormatters } from './system/output.ts'; +import type { CommandName } from './command-metadata.ts'; const cliOutputFormatters: Partial> = { - boot: resultOutput(bootCliOutput), - shutdown: resultOutput(shutdownCliOutput), - click: resultOutput(tapCliOutput), - press: resultOutput(tapCliOutput), - batch: resultOutput(batchCliOutput), - devices: resultOutput(devicesCliOutput), - apps: ({ input, result }) => - appsCliOutput({ - result: result as Parameters[0]['result'], - appsFilter: input.appsFilter as Parameters[0]['appsFilter'], - }), - session: resultOutput(sessionCliOutput), - debug: resultOutput(debugSymbolsCliOutput), - open: resultOutput(openCliOutput), - close: resultOutput(closeCliOutput), - install: resultOutput(deployCliOutput), - reinstall: resultOutput(deployCliOutput), - 'install-from-source': resultOutput(installFromSourceCliOutput), - snapshot: ({ input, result }) => - snapshotCliOutput({ - result: result as Parameters[0]['result'], - raw: input.raw as boolean | undefined, - interactiveOnly: input.interactiveOnly as boolean | undefined, - scope: input.scope as string | undefined, - depth: input.depth as number | undefined, - }), - wait: messageOutput, - alert: messageOutput, - appstate: resultOutput(appStateCliOutput), - back: messageOutput, - home: messageOutput, - rotate: messageOutput, - 'app-switcher': messageOutput, - keyboard: resultOutput(keyboardCliOutput), - clipboard: resultOutput(clipboardCliOutput), - get: ({ input, result }) => - getCliOutput({ - result: result as CommandRequestResult, - format: input.format as Parameters[0]['format'], - }), - is: resultOutput(isCliOutput), - find: resultOutput(findCliOutput), - perf: resultOutput(perfCliOutput), - prepare: messageOutput, - logs: resultOutput(logsCliOutput), - network: resultOutput(networkCliOutput), - record: resultOutput(recordCliOutput), - metro: ({ input, result }) => - metroCliOutput({ result, action: input.action as string | undefined }), + ...managementCliOutputFormatters, + ...captureCliOutputFormatters, + ...systemCliOutputFormatters, + ...interactionCliOutputFormatters, + ...observabilityCliOutputFormatters, + ...batchCliOutputFormatters, + ...recordingCliOutputFormatters, + ...metroCliOutputFormatters, }; export function formatCliOutput(params: { diff --git a/src/commands/client-command-contracts.ts b/src/commands/client-command-contracts.ts deleted file mode 100644 index 6adefe929..000000000 --- a/src/commands/client-command-contracts.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { - AppCloseOptions, - ClipboardCommandOptions, - MetroPrepareOptions, - MetroPrepareResult, - MetroReloadOptions, - MetroReloadResult, - RecordOptions, - SettingsUpdateOptions, - WaitCommandOptions, -} from '../client-types.ts'; -import { defineExecutableCommand } from './command-contract.ts'; -import { optionalEnum } from './command-input.ts'; -import { clientCommandMetadata } from './client-command-metadata.ts'; -import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; - -type ClientCommandMetadata = (typeof clientCommandMetadata)[number]; -type ClientCommandName = ClientCommandMetadata['name']; -type MetroInput = { action: 'prepare' | 'reload' } & MetroPrepareOptions & MetroReloadOptions; - -export const clientCommandDefinitions = [ - defineExecutableCommand(metadata('devices'), (client, input) => client.devices.list(input)), - defineExecutableCommand(metadata('boot'), (client, input) => client.devices.boot(input)), - defineExecutableCommand(metadata('shutdown'), (client, input) => client.devices.shutdown(input)), - defineExecutableCommand(metadata('apps'), (client, input) => client.apps.list(input)), - defineExecutableCommand(metadata('session'), async (client, { action, ...input }) => - action === 'state-dir' - ? { stateDir: await client.sessions.stateDir(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), - ), - defineExecutableCommand(metadata('keyboard'), (client, input) => client.command.keyboard(input)), - defineExecutableCommand(metadata('clipboard'), (client, input) => - client.command.clipboard(input as ClipboardCommandOptions), - ), - defineExecutableCommand(metadata('react-native'), (client, input) => - client.command.reactNative(input), - ), - defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)), - defineExecutableCommand(metadata('debug'), (client, input) => client.debug.symbols(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), - ), - defineExecutableCommand(metadata('record'), (client, input) => - client.recording.record(input as RecordOptions), - ), - defineExecutableCommand(metadata('trace'), (client, input) => client.recording.trace(input)), - defineExecutableCommand(metadata('settings'), (client, input) => - client.settings.update(input as SettingsUpdateOptions), - ), - defineExecutableCommand( - metadata('metro'), - async (client, input): Promise => - input.action === 'prepare' - ? await client.metro.prepare(toMetroPrepareOptions(input)) - : await client.metro.reload(toMetroReloadOptions(input)), - ), -] 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; -} - -function toMetroPrepareOptions(input: MetroInput): MetroPrepareOptions { - return { - projectRoot: input.projectRoot, - kind: input.kind, - publicBaseUrl: input.publicBaseUrl, - proxyBaseUrl: input.proxyBaseUrl, - bearerToken: input.bearerToken, - bridgeScope: input.bridgeScope ?? metroBridgeScopeFromInput(input), - port: input.port, - listenHost: input.listenHost, - statusHost: input.statusHost, - startupTimeoutMs: input.startupTimeoutMs, - probeTimeoutMs: input.probeTimeoutMs, - reuseExisting: input.reuseExisting, - installDependenciesIfNeeded: input.installDependenciesIfNeeded, - runtimeFilePath: input.runtimeFilePath, - }; -} - -function metroBridgeScopeFromInput( - input: MetroInput & { - tenant?: string; - runId?: string; - leaseId?: string; - }, -): MetroPrepareOptions['bridgeScope'] { - return input.tenant && input.runId && input.leaseId - ? { tenantId: input.tenant, runId: input.runId, leaseId: input.leaseId } - : undefined; -} - -function toMetroReloadOptions(input: MetroInput): MetroReloadOptions { - return { - metroHost: input.metroHost, - metroPort: input.metroPort, - bundleUrl: input.bundleUrl, - timeoutMs: input.timeoutMs, - }; -} - -function waitInputToOptions(input: Record): WaitCommandOptions { - optionalEnum(input, 'kind', WAIT_KIND_VALUES); - const options = { ...input }; - delete options.kind; - return options as WaitCommandOptions & { kind?: never }; -} diff --git a/src/commands/client-command-facets.ts b/src/commands/client-command-facets.ts new file mode 100644 index 000000000..a9f3a217f --- /dev/null +++ b/src/commands/client-command-facets.ts @@ -0,0 +1,66 @@ +import { captureCommandDefinitions, captureCommandMetadata } from './capture/index.ts'; +import { managementCommandDefinitions, managementCommandMetadata } from './management/index.ts'; +import { metroCommandDefinition, metroCommandMetadata } from './metro/index.ts'; +import { + observabilityCommandDefinitions, + observabilityCommandMetadata, +} from './observability/index.ts'; +import { reactNativeCommandDefinition, reactNativeCommandMetadata } from './react-native/index.ts'; +import { recordingCommandDefinitions, recordingCommandMetadata } from './recording/index.ts'; +import { replayCommandDefinitions, replayCommandMetadataList } from './replay/index.ts'; +import { systemCommandDefinitions, systemCommandMetadata } from './system/index.ts'; + +const clientCommandFamilyFacets = [ + { + metadata: managementCommandMetadata, + definitions: managementCommandDefinitions, + }, + { + metadata: captureCommandMetadata, + definitions: captureCommandDefinitions, + }, + { + metadata: systemCommandMetadata, + definitions: systemCommandDefinitions, + }, + { + metadata: [reactNativeCommandMetadata], + definitions: [reactNativeCommandDefinition], + }, + { + metadata: replayCommandMetadataList, + definitions: replayCommandDefinitions, + }, + { + metadata: observabilityCommandMetadata, + definitions: observabilityCommandDefinitions, + }, + { + metadata: recordingCommandMetadata, + definitions: recordingCommandDefinitions, + }, + { + metadata: [metroCommandMetadata], + definitions: [metroCommandDefinition], + }, +] as const; + +export const clientCommandMetadata = readClientCommandMetadata(clientCommandFamilyFacets); + +export const clientCommandDefinitions = readClientCommandDefinitions(clientCommandFamilyFacets); + +function readClientCommandMetadata< + const TFacets extends readonly { metadata: readonly unknown[] }[], +>(facets: TFacets): Array { + return facets.flatMap((family) => [...family.metadata]) as Array< + TFacets[number]['metadata'][number] + >; +} + +function readClientCommandDefinitions< + const TFacets extends readonly { definitions: readonly unknown[] }[], +>(facets: TFacets): Array { + return facets.flatMap((family) => [...family.definitions]) as Array< + TFacets[number]['definitions'][number] + >; +} diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts deleted file mode 100644 index 99a6bc9b9..000000000 --- a/src/commands/client-command-metadata.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { MetroPrepareOptions, RecordOptions } from '../client-types.ts'; -import { NETWORK_INCLUDE_MODES, type DaemonInstallSource } from '../contracts.ts'; -import { ALERT_ACTIONS } from '../alert-contract.ts'; -import { BACK_MODES } from '../core/back-mode.ts'; -import { DEVICE_ROTATIONS } from '../core/device-rotation.ts'; -import { SESSION_SURFACES } from '../core/session-surface.ts'; -import { LOG_ACTION_VALUES } from './log-command-contract.ts'; -import { requireCommandDescription } 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'; -import { - PERF_ACTION_VALUES, - PERF_AREA_VALUES, - PERF_KIND_VALUES, - PERF_SUBJECT_VALUES, -} from './perf-command-contract.ts'; -import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; - -const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; -const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; -const START_STOP_VALUES = ['start', 'stop'] as const; -const DEBUG_ACTION_VALUES = ['symbols'] as const; -const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; -const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; -const PREPARE_ACTION_VALUES = ['ios-runner'] as const; - -export const clientCommandMetadata = [ - defineClientCommandMetadata('devices', {}), - defineClientCommandMetadata('boot', { - headless: booleanField('Boot without showing simulator UI when supported.'), - }), - defineClientCommandMetadata('shutdown', {}), - defineClientCommandMetadata('prepare', { - action: requiredField(enumField(PREPARE_ACTION_VALUES)), - timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), - }), - defineClientCommandMetadata('debug', { - action: requiredField(enumField(DEBUG_ACTION_VALUES)), - artifact: requiredField(stringField('Apple crash artifact path (.ips, .crash, or .log).')), - dsym: stringField('Path to a matching .dSYM bundle.'), - searchPath: stringField('Directory to scan for matching .dSYM bundles.'), - out: stringField('Output path for the symbolicated artifact.'), - }), - defineClientCommandMetadata('apps', { - appsFilter: enumField(['user-installed', 'all']), - }), - defineClientCommandMetadata('session', { - action: enumField( - ['list', 'state-dir'], - 'list shows active sessions; state-dir prints the resolved daemon state directory without contacting the daemon.', - ), - }), - defineClientCommandMetadata('open', { - app: stringField('App name, bundle id, package, or URL.'), - url: stringField('Optional URL passed with an app shell.'), - surface: enumField(SESSION_SURFACES), - 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()] }), - deviceHub: booleanField('Use Xcode Device Hub when surfacing Apple simulators.'), - 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(), - timeoutMs: integerField('Maximum wall-clock time for the snapshot command.'), - }), - defineClientCommandMetadata('screenshot', { - path: stringField('Output path.'), - overlayRefs: booleanField(), - fullscreen: booleanField(), - maxSize: integerField(), - stabilize: booleanField(), - surface: enumField(SESSION_SURFACES), - }), - 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_ACTIONS), - timeoutMs: integerField(), - }), - defineClientCommandMetadata('appstate', {}), - defineClientCommandMetadata('back', { - mode: enumField(BACK_MODES), - }), - defineClientCommandMetadata('home', {}), - defineClientCommandMetadata('rotate', { - orientation: requiredField(enumField(DEVICE_ROTATIONS)), - }), - 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(), - recordVideo: booleanField(), - artifactsDir: stringField(), - reportJunit: stringField(), - shardAll: integerField(), - shardSplit: integerField(), - }), - defineClientCommandMetadata('perf', { - area: enumField(PERF_AREA_VALUES), - subject: enumField(PERF_SUBJECT_VALUES), - action: enumField(PERF_ACTION_VALUES), - kind: enumField(PERF_KIND_VALUES), - template: stringField('xctrace template name, for example Time Profiler.'), - out: stringField('Output artifact path.'), - tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'), - }), - defineClientCommandMetadata('logs', { - action: enumField(LOG_ACTION_VALUES), - message: stringField(), - restart: booleanField(), - }), - defineClientCommandMetadata('network', { - action: enumField(NETWORK_ACTION_VALUES), - limit: integerField(), - include: enumField(NETWORK_INCLUDE_MODES), - }), - 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()), - app: 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) { - return defineFieldCommandMetadata(name, requireCommandDescription(name), fields); -} diff --git a/src/commands/client-output.ts b/src/commands/client-output.ts deleted file mode 100644 index f5f83c454..000000000 --- a/src/commands/client-output.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { - serializeCloseResult, - serializeDeployResult, - serializeDevice, - serializeInstallFromSourceResult, - serializeOpenResult, - serializeSessionListEntry, - serializeSnapshotResult, -} from '../client-shared.ts'; -import type { - AgentDeviceDevice, - AgentDeviceSession, - AppStateCommandResult, - AppCloseResult, - AppDeployResult, - AppInstallFromSourceResult, - AppOpenResult, - CaptureSnapshotResult, - ClipboardCommandResult, - CommandRequestResult, - DebugSymbolsResult, - KeyboardCommandResult, - SessionCloseResult, -} from '../client-types.ts'; -import { formatSnapshotText } from '../utils/output.ts'; -import { readCommandMessage } from '../utils/success-text.ts'; -import type { CliOutput } from './command-contract.ts'; - -export function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput { - const data = { devices: result.map(serializeDevice) }; - return { data, text: result.map(formatDeviceLine).join('\n') }; -} - -export function appsCliOutput(params: { - result: string[]; - appsFilter?: 'user-installed' | 'all'; -}): CliOutput { - const data = { apps: params.result }; - return { - data, - stderr: - params.appsFilter === 'all' - ? 'Showing all apps, including system apps.\n' - : 'Showing user-installed apps. Use --all to include system apps.\n', - text: - params.result.length > 0 - ? params.result.join('\n') - : params.appsFilter === 'all' - ? 'No apps found.' - : 'No user-installed apps found.', - }; -} - -export function sessionCliOutput( - result: { sessions: AgentDeviceSession[] } | { stateDir: string }, -): CliOutput { - if ('stateDir' in result) { - return { data: result, text: result.stateDir }; - } - const data = { sessions: result.sessions.map(serializeSessionListEntry) }; - return { data, text: JSON.stringify(data, null, 2) }; -} - -export function openCliOutput(result: AppOpenResult): CliOutput { - const data = serializeOpenResult(result); - const lines = [readCommandMessage(data)].filter((line): line is string => Boolean(line)); - if (typeof data.sessionStateDir === 'string') { - lines.push(`Session state: ${data.sessionStateDir}`); - } - return { data, text: lines.join('\n') || null }; -} - -export function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput { - return messageOutput(serializeCloseResult(result)); -} - -export function messageCliOutput(result: Record): CliOutput { - return messageOutput(result); -} - -export function appStateCliOutput(result: AppStateCommandResult): CliOutput { - return { - data: result, - text: formatAppState(result), - }; -} - -export function keyboardCliOutput(result: KeyboardCommandResult): CliOutput { - if (result.platform === 'android' && result.action === 'status') { - const lines = [ - `Keyboard visible: ${result.visible === true ? 'yes' : 'no'}`, - `Input type: ${result.type ?? result.inputType ?? 'unknown'}`, - `Input owner: ${result.inputOwner ?? 'unknown'}`, - ]; - if (result.inputMethodPackage) lines.push(`Input method: ${result.inputMethodPackage}`); - if (result.focusedPackage) lines.push(`Focused package: ${result.focusedPackage}`); - if (result.focusedResourceId) lines.push(`Focused resource: ${result.focusedResourceId}`); - lines.push(`Next action: ${androidKeyboardNextAction(result.visible, result.inputOwner)}`); - return { data: result, text: lines.join('\n') }; - } - return messageOutput(result); -} - -export function clipboardCliOutput(result: ClipboardCommandResult): CliOutput { - if (result.action === 'read') return { data: result, text: result.text }; - return messageOutput(result); -} - -export function deployCliOutput(result: AppDeployResult): CliOutput { - return messageOutput(serializeDeployResult(result)); -} - -export function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { - const lines = [result.outPath, result.message]; - lines.push(...formatDebugCrashSummary(result)); - for (const image of result.matchedImages) { - lines.push(`Matched: ${image.name} ${image.uuid}${image.arch ? ` ${image.arch}` : ''}`); - } - for (const warning of result.warnings ?? []) { - lines.push(`Warning: ${warning}`); - } - return { data: result, text: lines.join('\n') }; -} - -function formatDebugCrashSummary(result: DebugSymbolsResult): string[] { - const crash = result.crash; - const lines = [ - `Crash: ${crash.appName ?? 'unknown app'}${crash.crashedThread === undefined ? '' : ` thread ${crash.crashedThread}`}`, - ]; - if (crash.bundleId) lines.push(`Bundle: ${crash.bundleId}`); - if (crash.exceptionType) lines.push(`Exception: ${crash.exceptionType}`); - if (crash.terminationReason) lines.push(`Termination: ${crash.terminationReason}`); - for (const frame of crash.topFrames) { - lines.push(`Frame ${frame.index}: ${frame.image} ${frame.symbol ?? frame.address}`); - } - for (const finding of crash.findings) { - lines.push(`Finding: ${finding}`); - } - return lines; -} - -export function installFromSourceCliOutput(result: AppInstallFromSourceResult): CliOutput { - return messageOutput(serializeInstallFromSourceResult(result)); -} - -export function snapshotCliOutput(params: { - result: CaptureSnapshotResult; - raw?: boolean; - interactiveOnly?: boolean; - scope?: string; - depth?: number; -}): CliOutput { - const data = serializeSnapshotResult(params.result); - return { - data, - // Programmatic SDK callers can see `unchanged`; CLI --json hides it for schema compatibility. - jsonData: withoutUnchanged(data), - text: formatSnapshotText(data, { - raw: params.raw, - flatten: params.interactiveOnly, - scoped: typeof params.scope === 'string' && params.scope.trim().length > 0, - depthLimited: typeof params.depth === 'number', - }), - }; -} - -export function metroCliOutput(params: { result: unknown; action?: string }): CliOutput { - return { - data: params.result, - text: - params.action === 'reload' - ? `Reloaded React Native apps via ${(params.result as { reloadUrl?: unknown }).reloadUrl}` - : JSON.stringify(params.result, null, 2), - }; -} - -export function bootCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const platform = data.platform ?? 'unknown'; - const device = data.device ?? data.id ?? 'unknown'; - return { data, text: `Boot ready: ${device} (${platform})` }; -} - -export function shutdownCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const platform = data.platform ?? 'unknown'; - const device = data.device ?? data.id ?? 'unknown'; - const shutdown = data.shutdown; - const success = - shutdown && typeof shutdown === 'object' && 'success' in shutdown - ? (shutdown as { success?: unknown }).success === true - : false; - const status = success ? 'Shutdown' : 'Shutdown failed'; - return { data, text: `${status}: ${device} (${platform})` }; -} - -export function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput { - const data = params.result as Record; - if (params.format === 'text') { - return { data, text: typeof data.text === 'string' ? data.text : '' }; - } - if (params.format === 'attrs') { - return { data, text: JSON.stringify(data.node ?? {}, null, 2) }; - } - return defaultCommandCliOutput(data); -} - -export function findCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - if (typeof data.text === 'string') return { data, text: data.text }; - if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` }; - if (data.node) return { data, text: JSON.stringify(data.node, null, 2) }; - return defaultCommandCliOutput(data); -} - -export function isCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - return { data, text: `Passed: is ${data.predicate ?? 'assertion'}` }; -} - -export function tapCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const ref = data.ref ?? ''; - const x = data.x; - const y = data.y; - if (!ref || typeof x !== 'number' || typeof y !== 'number') { - return defaultCommandCliOutput(data); - } - return { data, text: `Tapped @${ref} (${x}, ${y})` }; -} - -export function recordCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const outPath = typeof data.outPath === 'string' ? data.outPath : ''; - const chunks = readRecordingChunks(data); - if (chunks.length <= 1) { - return { data, text: formatRecordSingleOutput(data, outPath) }; - } - - const lines = ['Recording chunks:']; - for (const chunk of chunks) { - lines.push(` ${chunk.index}: ${chunk.path}`); - } - if (typeof data.telemetryPath === 'string') { - lines.push(`Telemetry: ${data.telemetryPath}`); - } - if (typeof data.warning === 'string') { - lines.push(`Warning: ${data.warning}`); - } - if (typeof data.overlayWarning === 'string') { - lines.push(`Overlay warning: ${data.overlayWarning}`); - } - return { data, text: lines.join('\n') }; -} - -function defaultCommandCliOutput(result: CommandRequestResult): CliOutput { - return messageOutput(result as Record); -} - -function formatRecordSingleOutput(data: Record, outPath: string): string { - const lines: string[] = []; - if (outPath) lines.push(outPath); - if (typeof data.sessionStateDir === 'string') - lines.push(`Session state: ${data.sessionStateDir}`); - if (typeof data.warning === 'string') lines.push(`Warning: ${data.warning}`); - if (typeof data.overlayWarning === 'string') - lines.push(`Overlay warning: ${data.overlayWarning}`); - return lines.join('\n'); -} - -function readRecordingChunks( - data: Record, -): Array<{ index: number; path: string }> { - const rawChunks = data.chunks; - if (!Array.isArray(rawChunks)) return []; - return rawChunks.flatMap((chunk) => { - if (!chunk || typeof chunk !== 'object') return []; - const candidate = chunk as Record; - if (typeof candidate.index !== 'number' || typeof candidate.path !== 'string') return []; - return [{ index: candidate.index, path: candidate.path }]; - }); -} - -function messageOutput(data: Record): CliOutput { - return { data, text: readCommandMessage(data) }; -} - -function formatAppState(data: AppStateCommandResult): string | null { - if (data.platform === 'ios') { - const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; - if (data.appBundleId) lines.push(`Bundle: ${data.appBundleId}`); - if (data.source) lines.push(`Source: ${data.source}`); - return lines.join('\n'); - } - if (data.platform === 'android') { - const lines = [`Foreground app: ${data.package ?? 'unknown'}`]; - if (data.activity) lines.push(`Activity: ${data.activity}`); - return lines.join('\n'); - } - return null; -} - -function androidKeyboardNextAction( - visible: boolean | undefined, - inputOwner: KeyboardCommandResult['inputOwner'], -): string { - if (inputOwner === 'ime') { - return 'Focused input appears to be owned by the keyboard/IME; dismiss or change the IME before retrying text entry.'; - } - if (visible === true) { - return 'Keyboard is visible and focused input appears app-owned; fill/type can proceed.'; - } - return 'Keyboard is hidden; focus an app field before type, or use fill with a concrete target.'; -} - -function formatDeviceLine(device: AgentDeviceDevice): string { - const kind = device.kind ? ` ${device.kind}` : ''; - const target = device.target ? ` target=${device.target}` : ''; - const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; - return `${device.name} (${device.platform}${kind}${target})${booted}`; -} - -function withoutUnchanged(data: Record): Record { - const { unchanged: _unchanged, ...outputData } = data; - return outputData; -} diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts deleted file mode 100644 index 78ac8a607..000000000 --- a/src/commands/command-descriptions.ts +++ /dev/null @@ -1,73 +0,0 @@ -const COMMAND_DESCRIPTIONS = { - devices: 'List available devices.', - debug: 'Symbolicate crash artifacts with matching debug symbols.', - boot: 'Boot or prepare a selected device without using CLI positional arguments.', - shutdown: 'Shutdown a selected simulator or emulator.', - apps: 'List installed apps.', - session: 'List active sessions or print daemon state directory.', - open: 'Open an app, deep link, URL, or platform surface.', - prepare: 'Prepare platform helper infrastructure.', - 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, frame health, and memory diagnostics.', - 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; - -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; -}> { - 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 index e0679117b..6c13f5e37 100644 --- a/src/commands/command-metadata.ts +++ b/src/commands/command-metadata.ts @@ -1,10 +1,8 @@ -import { BATCH_COMMAND_NAMES, listMcpExposedCommandNames } from '../command-catalog.ts'; -import { createBatchCommandMetadata } from './batch-command-metadata.ts'; -import { clientCommandMetadata } from './client-command-metadata.ts'; +import { listMcpExposedCommandNames } from '../command-catalog.ts'; +import { batchCommandMetadata } from './batch/index.ts'; +import { clientCommandMetadata } from './client-command-facets.ts'; import type { CommandMetadata } from './command-contract.ts'; -import { interactionCommandMetadata } from './interaction-command-metadata.ts'; - -const batchCommandMetadata = createBatchCommandMetadata(BATCH_COMMAND_NAMES); +import { interactionCommandMetadata } from './interaction/index.ts'; const commandMetadata = [ ...interactionCommandMetadata, @@ -20,6 +18,10 @@ const commandMetadataMap: ReadonlyMap = new Map commandMetadata.map((definition) => [definition.name, definition as AnyCommandMetadata]), ); +export function listCommandMetadata(): AnyCommandMetadata[] { + return [...commandMetadata]; +} + export function listMcpCommandMetadata(): AnyCommandMetadata[] { return listMcpExposedCommandNames().map((name) => { if (!isCommandName(name)) { diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index 19fc947a4..391680f95 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -1,17 +1,17 @@ -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'; -import { appDaemonWriters } from './cli-grammar/apps.ts'; -import { captureDaemonWriters } from './cli-grammar/capture.ts'; -import { commandNameSet, request } from './cli-grammar/common.ts'; -import { gestureDaemonWriters } from './cli-grammar/gesture.ts'; -import { interactionDaemonWriters } from './cli-grammar/interactions.ts'; -import { observabilityDaemonWriters } from './cli-grammar/observability.ts'; -import { replayDaemonWriters } from './cli-grammar/replay.ts'; -import { selectorDaemonWriters } from './cli-grammar/selectors.ts'; -import { systemDaemonWriters } from './cli-grammar/system.ts'; +import { createBatchDaemonWriter, type BatchCommandName } from './batch/index.ts'; +import { captureDaemonWriters } from './capture/index.ts'; import type { CommandInput, DaemonCommandRequest, DaemonWriter } from './cli-grammar/types.ts'; +import { + gestureDaemonWriters, + interactionDaemonWriters, + selectorDaemonWriters, +} from './interaction/index.ts'; +import { appDaemonWriters } from './management/index.ts'; +import { observabilityDaemonWriters } from './observability/index.ts'; +import { reactNativeDaemonWriters } from './react-native/index.ts'; +import { recordingDaemonWriters } from './recording/index.ts'; +import { replayDaemonWriters } from './replay/index.ts'; +import { systemDaemonWriters } from './system/index.ts'; const daemonWriters = { ...appDaemonWriters, @@ -20,97 +20,26 @@ const daemonWriters = { ...gestureDaemonWriters, ...selectorDaemonWriters, ...observabilityDaemonWriters, + ...reactNativeDaemonWriters, + ...recordingDaemonWriters, ...replayDaemonWriters, ...systemDaemonWriters, - batch: (input) => - request(PUBLIC_COMMANDS.batch, [], { - ...input, - batchSteps: readBatchDaemonSteps(input.steps), - batchOnError: input.onError, - batchMaxSteps: input.maxSteps, - }), + batch: createBatchDaemonWriter(prepareBatchDaemonCommandRequest), } satisfies Record; export type DaemonCommandName = keyof typeof daemonWriters; -export type BatchCommandName = (typeof BATCH_COMMAND_NAMES)[number]; +export type { BatchCommandName }; -export const batchCommandNames = BATCH_COMMAND_NAMES satisfies readonly DaemonCommandName[]; - -const batchNames = commandNameSet(batchCommandNames); - -function isBatchCommandName(name: string): name is BatchCommandName { - return batchNames.has(name); -} - -function prepareBatchStep(command: DaemonCommandName, input: CommandInput): DaemonBatchStep { - const prepared = prepareDaemonCommandRequest(command, input); - return { - command: prepared.command, - positionals: prepared.positionals, - flags: buildFlags(prepared.options), - runtime: prepared.options.runtime, - }; -} - -function readBatchDaemonSteps(steps: unknown): DaemonBatchStep[] { - if (!Array.isArray(steps) || steps.length === 0) { - throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array.'); - } - return steps.map((step, index) => readBatchDaemonStep(step, index + 1)); -} - -function readBatchDaemonStep(step: unknown, stepNumber: number): DaemonBatchStep { - const record = readBatchStepRecord(step, stepNumber); - const command = readBatchStepCommand(record, stepNumber); - const input = readBatchStepInput(record, stepNumber); - const runtime = readBatchStepRuntime(record, stepNumber); - const prepared = prepareBatchStep(command, input); - return { - ...prepared, - runtime: runtime ?? prepared.runtime, - }; -} - -function readBatchStepRecord(step: unknown, stepNumber: number): Record { - if (!step || typeof step !== 'object' || Array.isArray(step)) { - throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`); - } - return step as Record; -} - -function readBatchStepCommand( - record: Record, - stepNumber: number, -): BatchCommandName { - const command = typeof record.command === 'string' ? record.command.trim().toLowerCase() : ''; - if (isBatchCommandName(command)) return command; - throw new AppError( - 'INVALID_ARGS', - `Batch step ${stepNumber} command is not available through command batch: ${String(record.command)}`, - ); -} - -function readBatchStepInput(record: Record, stepNumber: number): CommandInput { - const input = record.input; - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`); - } - return input as CommandInput; -} - -function readBatchStepRuntime( - record: Record, - stepNumber: number, -): Record | undefined { - const runtime = record.runtime; - if ( - runtime !== undefined && - (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) - ) { - throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} runtime must be an object.`); +function prepareBatchDaemonCommandRequest( + command: string, + input: CommandInput, +): DaemonCommandRequest { + const writer = (daemonWriters as Readonly>)[command]; + if (!writer) { + throw new Error(`Missing daemon writer for batch command: ${command}`); } - return runtime as Record | undefined; + return writer(input); } export function prepareDaemonCommandRequest( diff --git a/src/commands/command-surface.ts b/src/commands/command-surface.ts index ed38a3ef4..6e2d8f8f9 100644 --- a/src/commands/command-surface.ts +++ b/src/commands/command-surface.ts @@ -1,9 +1,9 @@ import type { AgentDeviceClient } from '../client-types.ts'; -import { createBatchCommand } from './batch-command.ts'; -import { clientCommandDefinitions } from './client-command-contracts.ts'; +import { batchCommandDefinition } from './batch/index.ts'; +import { clientCommandDefinitions } from './client-command-facets.ts'; import type { JsonSchema } from './command-contract.ts'; -import { interactionCommandDefinitions } from './interaction-command-contracts.ts'; -import { batchCommandNames, type BatchCommandName } from './command-projection.ts'; +import { interactionCommandDefinitions } from './interaction/index.ts'; +import type { BatchCommandName } from './command-projection.ts'; import type { CommandName } from './command-metadata.ts'; type AnyExecutableCommand = { @@ -13,8 +13,6 @@ type AnyExecutableCommand = { invoke: (client: AgentDeviceClient, input: unknown) => Promise; }; -const batchCommandDefinition = createBatchCommand(batchCommandNames); - const commandSurface = [ ...interactionCommandDefinitions, ...clientCommandDefinitions, diff --git a/src/commands/index.ts b/src/commands/index.ts index 856d04edc..d2c988d26 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,277 +1,50 @@ import type { AgentDeviceRuntime } from '../runtime-contract.ts'; -import type { - BoundRuntimeCommand, - DiffSnapshotCommandOptions, - RuntimeCommand, - ScreenshotCommandOptions, - SnapshotCommandOptions, -} from './runtime-types.ts'; -import { screenshotCommand, type ScreenshotCommandResult } from './capture-screenshot.ts'; -import { - diffScreenshotCommand, - type DiffScreenshotCommandOptions, - type DiffScreenshotCommandResult, -} from './capture-diff-screenshot.ts'; -import { - diffSnapshotCommand, - snapshotCommand, - type DiffSnapshotCommandResult, - type SnapshotCommandResult, -} from './capture-snapshot.ts'; -import { - findCommand, - getAttrsCommand, - getCommand, - getTextCommand, - isHiddenCommand, - isVisibleCommand, - isCommand, - waitCommand, - waitForTextCommand, - type ElementTarget, - type FindReadCommandOptions, - type FindReadCommandResult, - type GetAttrsCommandOptions, - type GetCommandOptions, - type GetCommandResult, - type GetTextCommandOptions, - type IsCommandOptions, - type IsCommandResult, - type IsSelectorCommandOptions, - type SelectorTarget, - type WaitCommandOptions, - type WaitCommandResult, - type WaitForTextCommandOptions, -} from './selector-read.ts'; import { - clickCommand, - fillCommand, - focusCommand, - longPressCommand, - pinchCommand, - pressCommand, - scrollCommand, - swipeCommand, - typeTextCommand, - type ClickCommandOptions, - type FillCommandOptions, - type FillCommandResult, - type FocusCommandOptions, - type FocusCommandResult, - type InteractionTarget, - type LongPressCommandOptions, - type LongPressCommandResult, - type PinchCommandOptions, - type PinchCommandResult, - type PressCommandOptions, - type PressCommandResult, - type ScrollCommandOptions, - type ScrollCommandResult, - type SwipeCommandOptions, - type SwipeCommandResult, - type TypeTextCommandOptions, - type TypeTextCommandResult, -} from './interactions.ts'; + bindCaptureCommands, + captureCommands, + type BoundCaptureCommands, + type CaptureCommands, +} from './capture/runtime/index.ts'; import { - alertCommand, - appSwitcherCommand, - backCommand, - clipboardCommand, - homeCommand, - keyboardCommand, - rotateCommand, - settingsCommand, - type SystemAlertCommandOptions, - type SystemAlertCommandResult, - type SystemAppSwitcherCommandOptions, - type SystemAppSwitcherCommandResult, - type SystemBackCommandOptions, - type SystemBackCommandResult, - type SystemClipboardCommandOptions, - type SystemClipboardCommandResult, - type SystemHomeCommandOptions, - type SystemHomeCommandResult, - type SystemKeyboardCommandOptions, - type SystemKeyboardCommandResult, - type SystemRotateCommandOptions, - type SystemRotateCommandResult, - type SystemSettingsCommandOptions, - type SystemSettingsCommandResult, -} from './system.ts'; + bindInteractionCommands, + bindSelectorCommands, + interactionCommands, + selectorCommands, + type BoundInteractionCommands, + type BoundSelectorCommands, + type InteractionCommands, + type SelectorCommands, +} from './interaction/runtime/index.ts'; import { - closeAppCommand, - getAppStateCommand, - listAppsCommand, - openAppCommand, - pushAppCommand, - triggerAppEventCommand, - type CloseAppCommandOptions, - type CloseAppCommandResult, - type GetAppStateCommandOptions, - type GetAppStateCommandResult, - type ListAppsCommandOptions, - type ListAppsCommandResult, - type OpenAppCommandOptions, - type OpenAppCommandResult, - type PushAppCommandOptions, - type PushAppCommandResult, - type TriggerAppEventCommandOptions, - type TriggerAppEventCommandResult, -} from './apps.ts'; -import { resolveAppsFilter } from './app-inventory-contract.ts'; + adminCommands, + appCommands, + bindAdminCommands, + bindAppCommands, + type AdminCommands, + type AppCommands, + type BoundAdminCommands, + type BoundAppCommands, +} from './management/runtime/index.ts'; import { - bootCommand, - devicesCommand, - installCommand, - installFromSourceCommand, - reinstallCommand, - shutdownCommand, - type AdminBootCommandOptions, - type AdminBootCommandResult, - type AdminDevicesCommandOptions, - type AdminDevicesCommandResult, - type AdminInstallCommandOptions, - type AdminInstallCommandResult, - type AdminInstallFromSourceCommandOptions, - type AdminReinstallCommandOptions, - type AdminShutdownCommandOptions, - type AdminShutdownCommandResult, -} from './admin.ts'; + bindObservabilityCommands, + diagnosticsCommands, + type BoundObservabilityCommands, + type DiagnosticsCommands, +} from './observability/runtime/index.ts'; import { - recordCommand, - traceCommand, - type RecordingRecordCommandOptions, - type RecordingRecordCommandResult, - type RecordingTraceCommandOptions, - type RecordingTraceCommandResult, -} from './recording.ts'; + bindRecordingCommands, + recordingCommands, + type BoundRecordingCommands, + type RecordingCommands, +} from './recording/runtime/index.ts'; import { - logsCommand, - networkCommand, - perfCommand, - type DiagnosticsLogsCommandOptions, - type DiagnosticsLogsCommandResult, - type DiagnosticsNetworkCommandOptions, - type DiagnosticsNetworkCommandResult, - type DiagnosticsPerfCommandOptions, - type DiagnosticsPerfCommandResult, -} from './diagnostics.ts'; + bindSystemCommands, + systemCommands, + type BoundSystemCommands, + type SystemCommands, +} from './system/runtime/index.ts'; -export type { ScreenshotCommandResult } from './capture-screenshot.ts'; -export type { - DiffScreenshotCommandOptions, - DiffScreenshotCommandResult, - LiveScreenshotInputRef, -} from './capture-diff-screenshot.ts'; -export type { - DiffSnapshotCommandResult, - SnapshotCommandResult, - SnapshotDiffLine, - SnapshotDiffSummary, -} from './capture-snapshot.ts'; -export type { - FindReadCommandOptions, - FindReadCommandResult, - GetAttrsCommandOptions, - GetCommandOptions, - GetCommandResult, - GetTextCommandOptions, - IsCommandOptions, - IsCommandResult, - IsSelectorCommandOptions, - ElementTarget, - RefTarget, - ResolvedTarget, - SelectorTarget, - SelectorSnapshotOptions, - WaitCommandOptions, - WaitCommandResult, - WaitForTextCommandOptions, -} from './selector-read.ts'; -export type { - ClickCommandOptions, - FillCommandOptions, - FillCommandResult, - FocusCommandOptions, - FocusCommandResult, - InteractionTarget, - LongPressCommandOptions, - LongPressCommandResult, - PinchCommandOptions, - PinchCommandResult, - PointTarget, - PressCommandOptions, - PressCommandResult, - ResolvedInteractionTarget, - ScrollCommandOptions, - ScrollCommandResult, - ScrollTarget, - SwipeCommandOptions, - SwipeCommandResult, - SwipeOptions, - TypeTextCommandOptions, - TypeTextCommandResult, -} from './interactions.ts'; -export type { - SystemAlertCommandOptions, - SystemAlertCommandResult, - SystemAppSwitcherCommandOptions, - SystemAppSwitcherCommandResult, - SystemBackCommandOptions, - SystemBackCommandResult, - SystemClipboardCommandOptions, - SystemClipboardCommandResult, - SystemHomeCommandOptions, - SystemHomeCommandResult, - SystemKeyboardCommandOptions, - SystemKeyboardCommandResult, - SystemRotateCommandOptions, - SystemRotateCommandResult, - SystemSettingsCommandOptions, - SystemSettingsCommandResult, -} from './system.ts'; -export type { - AppPushInput, - CloseAppCommandOptions, - CloseAppCommandResult, - GetAppStateCommandOptions, - GetAppStateCommandResult, - ListAppsCommandOptions, - ListAppsCommandResult, - OpenAppCommandOptions, - OpenAppCommandResult, - PushAppCommandOptions, - PushAppCommandResult, - TriggerAppEventCommandOptions, - TriggerAppEventCommandResult, -} from './apps.ts'; -export type { - AdminBootCommandOptions, - AdminBootCommandResult, - AdminDevicesCommandOptions, - AdminDevicesCommandResult, - AdminInstallCommandOptions, - AdminInstallCommandResult, - AdminInstallFromSourceCommandOptions, - AdminReinstallCommandOptions, - AdminShutdownCommandOptions, - AdminShutdownCommandResult, -} from './admin.ts'; -export type { - RecordingRecordCommandOptions, - RecordingRecordCommandResult, - RecordingTraceCommandOptions, - RecordingTraceCommandResult, -} from './recording.ts'; -export type { - DiagnosticsLogsCommandOptions, - DiagnosticsLogsCommandResult, - DiagnosticsNetworkCommandOptions, - DiagnosticsNetworkCommandResult, - DiagnosticsPerfCommandOptions, - DiagnosticsPerfCommandResult, -} from './diagnostics.ts'; -export { ref, selector } from './selector-read.ts'; +export { ref, selector } from './interaction/runtime/selector-read.ts'; export type { BoundRuntimeCommand, @@ -283,332 +56,47 @@ export type { } from './runtime-types.ts'; export type AgentDeviceCommands = { - capture: { - screenshot: RuntimeCommand; - diffScreenshot: RuntimeCommand; - snapshot: RuntimeCommand; - diffSnapshot: RuntimeCommand; - }; - selectors: { - find: RuntimeCommand; - get: RuntimeCommand; - getText: RuntimeCommand>; - getAttrs: RuntimeCommand>; - is: RuntimeCommand; - isVisible: RuntimeCommand; - isHidden: RuntimeCommand; - wait: RuntimeCommand; - waitForText: RuntimeCommand< - WaitForTextCommandOptions, - Extract - >; - }; - interactions: { - click: RuntimeCommand; - press: RuntimeCommand; - fill: RuntimeCommand; - typeText: RuntimeCommand; - focus: RuntimeCommand; - longPress: RuntimeCommand; - swipe: RuntimeCommand; - scroll: RuntimeCommand; - pinch: RuntimeCommand; - }; - system: { - back: RuntimeCommand; - home: RuntimeCommand; - rotate: RuntimeCommand; - keyboard: RuntimeCommand; - clipboard: RuntimeCommand; - settings: RuntimeCommand; - alert: RuntimeCommand; - appSwitcher: RuntimeCommand< - SystemAppSwitcherCommandOptions | undefined, - SystemAppSwitcherCommandResult - >; - }; - apps: { - open: RuntimeCommand; - close: RuntimeCommand; - list: RuntimeCommand; - state: RuntimeCommand; - push: RuntimeCommand; - triggerEvent: RuntimeCommand; - }; - admin: { - devices: RuntimeCommand; - boot: RuntimeCommand; - shutdown: RuntimeCommand; - install: RuntimeCommand; - reinstall: RuntimeCommand; - installFromSource: RuntimeCommand< - AdminInstallFromSourceCommandOptions, - AdminInstallCommandResult - >; - }; - recording: { - record: RuntimeCommand; - trace: RuntimeCommand; - }; - diagnostics: { - logs: RuntimeCommand; - network: RuntimeCommand< - DiagnosticsNetworkCommandOptions | undefined, - DiagnosticsNetworkCommandResult - >; - perf: RuntimeCommand; - }; + capture: CaptureCommands; + selectors: SelectorCommands; + interactions: InteractionCommands; + system: SystemCommands; + apps: AppCommands; + admin: AdminCommands; + recording: RecordingCommands; + diagnostics: DiagnosticsCommands; }; export type BoundAgentDeviceCommands = { - capture: { - screenshot: BoundRuntimeCommand; - diffScreenshot: BoundRuntimeCommand; - snapshot: BoundRuntimeCommand; - diffSnapshot: BoundRuntimeCommand; - }; - selectors: { - find: BoundRuntimeCommand; - get: BoundRuntimeCommand; - getText: ( - target: ElementTarget, - options?: Omit, - ) => Promise>; - getAttrs: ( - target: ElementTarget, - options?: Omit, - ) => Promise>; - is: BoundRuntimeCommand; - isVisible: ( - target: SelectorTarget, - options?: Omit, - ) => Promise; - isHidden: ( - target: SelectorTarget, - options?: Omit, - ) => Promise; - wait: BoundRuntimeCommand; - waitForText: ( - text: string, - options?: Omit, - ) => Promise>; - }; - interactions: { - click: ( - target: InteractionTarget, - options?: Omit, - ) => Promise; - press: ( - target: InteractionTarget, - options?: Omit, - ) => Promise; - fill: ( - target: InteractionTarget, - text: string, - options?: Omit, - ) => Promise; - typeText: ( - text: string, - options?: Omit, - ) => Promise; - focus: ( - target: InteractionTarget, - options?: Omit, - ) => Promise; - longPress: ( - target: InteractionTarget, - options?: Omit, - ) => Promise; - swipe: BoundRuntimeCommand; - scroll: BoundRuntimeCommand; - pinch: BoundRuntimeCommand; - }; - system: { - back: (options?: SystemBackCommandOptions) => Promise; - home: (options?: SystemHomeCommandOptions) => Promise; - rotate: BoundRuntimeCommand; - keyboard: (options?: SystemKeyboardCommandOptions) => Promise; - clipboard: BoundRuntimeCommand; - settings: (options?: SystemSettingsCommandOptions) => Promise; - alert: (options?: SystemAlertCommandOptions) => Promise; - appSwitcher: ( - options?: SystemAppSwitcherCommandOptions, - ) => Promise; - }; - apps: { - open: BoundRuntimeCommand; - close: (options?: CloseAppCommandOptions) => Promise; - list: (options?: ListAppsCommandOptions) => Promise; - state: BoundRuntimeCommand; - push: BoundRuntimeCommand; - triggerEvent: BoundRuntimeCommand; - }; - admin: { - devices: (options?: AdminDevicesCommandOptions) => Promise; - boot: (options?: AdminBootCommandOptions) => Promise; - shutdown: (options?: AdminShutdownCommandOptions) => Promise; - install: BoundRuntimeCommand; - reinstall: BoundRuntimeCommand; - installFromSource: BoundRuntimeCommand< - AdminInstallFromSourceCommandOptions, - AdminInstallCommandResult - >; - }; - recording: { - record: BoundRuntimeCommand; - trace: BoundRuntimeCommand; - }; - observability: { - logs: (options?: DiagnosticsLogsCommandOptions) => Promise; - network: ( - options?: DiagnosticsNetworkCommandOptions, - ) => Promise; - perf: (options?: DiagnosticsPerfCommandOptions) => Promise; - }; + capture: BoundCaptureCommands; + selectors: BoundSelectorCommands; + interactions: BoundInteractionCommands; + system: BoundSystemCommands; + apps: BoundAppCommands; + admin: BoundAdminCommands; + recording: BoundRecordingCommands; + observability: BoundObservabilityCommands; }; export const commands: AgentDeviceCommands = { - capture: { - screenshot: screenshotCommand, - diffScreenshot: diffScreenshotCommand, - snapshot: snapshotCommand, - diffSnapshot: diffSnapshotCommand, - }, - selectors: { - find: findCommand, - get: getCommand, - getText: getTextCommand, - getAttrs: getAttrsCommand, - is: isCommand, - isVisible: isVisibleCommand, - isHidden: isHiddenCommand, - wait: waitCommand, - waitForText: waitForTextCommand, - }, - interactions: { - click: clickCommand, - press: pressCommand, - fill: fillCommand, - typeText: typeTextCommand, - focus: focusCommand, - longPress: longPressCommand, - swipe: swipeCommand, - scroll: scrollCommand, - pinch: pinchCommand, - }, - system: { - back: backCommand, - home: homeCommand, - rotate: rotateCommand, - keyboard: keyboardCommand, - clipboard: clipboardCommand, - settings: settingsCommand, - alert: alertCommand, - appSwitcher: appSwitcherCommand, - }, - apps: { - open: openAppCommand, - close: closeAppCommand, - list: listAppsCommand, - state: getAppStateCommand, - push: pushAppCommand, - triggerEvent: triggerAppEventCommand, - }, - admin: { - devices: devicesCommand, - boot: bootCommand, - shutdown: shutdownCommand, - install: installCommand, - reinstall: reinstallCommand, - installFromSource: installFromSourceCommand, - }, - recording: { - record: recordCommand, - trace: traceCommand, - }, - diagnostics: { - logs: logsCommand, - network: networkCommand, - perf: perfCommand, - }, + capture: captureCommands, + selectors: selectorCommands, + interactions: interactionCommands, + system: systemCommands, + apps: appCommands, + admin: adminCommands, + recording: recordingCommands, + diagnostics: diagnosticsCommands, }; export function bindCommands(runtime: AgentDeviceRuntime): BoundAgentDeviceCommands { return { - capture: { - screenshot: (options) => commands.capture.screenshot(runtime, options), - diffScreenshot: (options) => commands.capture.diffScreenshot(runtime, options), - snapshot: (options) => commands.capture.snapshot(runtime, options), - diffSnapshot: (options) => commands.capture.diffSnapshot(runtime, options), - }, - selectors: { - find: (options) => commands.selectors.find(runtime, options), - get: (options) => commands.selectors.get(runtime, options), - getText: (target, options = {}) => - commands.selectors.getText(runtime, { ...options, target }), - getAttrs: (target, options = {}) => - commands.selectors.getAttrs(runtime, { ...options, target }), - is: (options) => commands.selectors.is(runtime, options), - isVisible: (target, options = {}) => - commands.selectors.isVisible(runtime, { ...options, target }), - isHidden: (target, options = {}) => - commands.selectors.isHidden(runtime, { ...options, target }), - wait: (options) => commands.selectors.wait(runtime, options), - waitForText: (text, options = {}) => - commands.selectors.waitForText(runtime, { ...options, text }), - }, - interactions: { - click: (target, options = {}) => commands.interactions.click(runtime, { ...options, target }), - press: (target, options = {}) => commands.interactions.press(runtime, { ...options, target }), - fill: (target, text, options = {}) => - commands.interactions.fill(runtime, { ...options, target, text }), - typeText: (text, options = {}) => - commands.interactions.typeText(runtime, { ...options, text }), - focus: (target, options = {}) => commands.interactions.focus(runtime, { ...options, target }), - longPress: (target, options = {}) => - commands.interactions.longPress(runtime, { ...options, target }), - swipe: (options) => commands.interactions.swipe(runtime, options), - scroll: (options) => commands.interactions.scroll(runtime, options), - pinch: (options) => commands.interactions.pinch(runtime, options), - }, - system: { - back: (options) => commands.system.back(runtime, options), - home: (options) => commands.system.home(runtime, options), - rotate: (options) => commands.system.rotate(runtime, options), - keyboard: (options) => commands.system.keyboard(runtime, options), - clipboard: (options) => commands.system.clipboard(runtime, options), - settings: (options) => commands.system.settings(runtime, options), - alert: (options) => commands.system.alert(runtime, options), - appSwitcher: (options) => commands.system.appSwitcher(runtime, options), - }, - apps: { - open: (options) => commands.apps.open(runtime, options), - close: (options) => commands.apps.close(runtime, options), - list: (options = {}) => - commands.apps.list(runtime, { - ...options, - filter: resolveAppsFilter(options.filter), - }), - state: (options) => commands.apps.state(runtime, options), - push: (options) => commands.apps.push(runtime, options), - triggerEvent: (options) => commands.apps.triggerEvent(runtime, options), - }, - admin: { - devices: (options) => commands.admin.devices(runtime, options), - boot: (options) => commands.admin.boot(runtime, options), - shutdown: (options) => commands.admin.shutdown(runtime, options), - install: (options) => commands.admin.install(runtime, options), - reinstall: (options) => commands.admin.reinstall(runtime, options), - installFromSource: (options) => commands.admin.installFromSource(runtime, options), - }, - recording: { - record: (options) => commands.recording.record(runtime, options), - trace: (options) => commands.recording.trace(runtime, options), - }, - observability: { - logs: (options) => commands.diagnostics.logs(runtime, options), - network: (options) => commands.diagnostics.network(runtime, options), - perf: (options) => commands.diagnostics.perf(runtime, options), - }, + capture: bindCaptureCommands(runtime), + selectors: bindSelectorCommands(runtime), + interactions: bindInteractionCommands(runtime), + system: bindSystemCommands(runtime), + apps: bindAppCommands(runtime), + admin: bindAdminCommands(runtime), + recording: bindRecordingCommands(runtime), + observability: bindObservabilityCommands(runtime), }; } diff --git a/src/commands/interaction-targeting.ts b/src/commands/interaction-targeting.ts deleted file mode 100644 index 9a995028f..000000000 --- a/src/commands/interaction-targeting.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../core/interaction-targeting.ts'; diff --git a/src/commands/cli-grammar/gesture.test.ts b/src/commands/interaction/gesture.test.ts similarity index 99% rename from src/commands/cli-grammar/gesture.test.ts rename to src/commands/interaction/gesture.test.ts index 0eb8ce6ae..60de74f74 100644 --- a/src/commands/cli-grammar/gesture.test.ts +++ b/src/commands/interaction/gesture.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import type { CliFlags } from '../../utils/cli-flags.ts'; -import type { CommandInput } from './types.ts'; +import type { CommandInput } from '../cli-grammar/types.ts'; import { gestureCliReaders, gestureDaemonWriters } from './gesture.ts'; const NO_FLAGS = {} as CliFlags; diff --git a/src/commands/cli-grammar/gesture.ts b/src/commands/interaction/gesture.ts similarity index 98% rename from src/commands/cli-grammar/gesture.ts rename to src/commands/interaction/gesture.ts index b8f67f8c0..0bb640483 100644 --- a/src/commands/cli-grammar/gesture.ts +++ b/src/commands/interaction/gesture.ts @@ -8,8 +8,8 @@ import { optionalCliNumber, optionalNumber, requiredDaemonString, -} from './common.ts'; -import type { CliReader, DaemonWriter, CommandInput } from './types.ts'; +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter, CommandInput } from '../cli-grammar/types.ts'; export const gestureCliReaders = { gesture: gestureInputFromCli, diff --git a/src/commands/interaction-command-contracts.ts b/src/commands/interaction/index.ts similarity index 62% rename from src/commands/interaction-command-contracts.ts rename to src/commands/interaction/index.ts index 44b8ce235..b947c3c36 100644 --- a/src/commands/interaction-command-contracts.ts +++ b/src/commands/interaction/index.ts @@ -16,15 +16,17 @@ import type { SwipeOptions, TransformGestureOptions, TypeTextOptions, -} from '../client-types.ts'; -import { defineExecutableCommand } from './command-contract.ts'; +} from '../../client-types.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { REPEATED_TOUCH_FLAGS, SELECTOR_SNAPSHOT_FLAGS } from '../../utils/cli-flags.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; import { commonToClientOptions, toClientElementTarget, toClientInteractionTarget, toRepeatedOptions, toSelectorSnapshotOptions, -} from './command-input.ts'; +} from '../command-input.ts'; import { interactionCommandMetadata, type ClickInput, @@ -38,7 +40,86 @@ import { type RotateInput, type SwipeGestureInput, type TransformInput, -} from './interaction-command-metadata.ts'; +} from './metadata.ts'; + +export { gestureCliReaders, gestureDaemonWriters } from './gesture.ts'; +export { interactionCliReaders, interactionDaemonWriters } from './interactions.ts'; +export { interactionCommandMetadata } from './metadata.ts'; +export { selectorCliReaders, selectorDaemonWriters } from './selectors.ts'; + +export const interactionCliSchemas = { + get: { + usageOverride: 'get text|attrs <@ref|selector>', + positionalArgs: ['subcommand', 'target'], + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + find: { + usageOverride: 'find [value] [--first|--last]', + helpDescription: 'Find by text/label/value/role/id and run action', + summary: 'Find an element and act', + positionalArgs: ['query', 'action', 'value?'], + allowsExtraPositionals: true, + allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast'], + }, + is: { + positionalArgs: ['predicate', 'selector', 'value?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + click: { + usageOverride: 'click ', + positionalArgs: ['target'], + allowsExtraPositionals: true, + allowedFlags: [...REPEATED_TOUCH_FLAGS, 'clickButton', ...SELECTOR_SNAPSHOT_FLAGS], + }, + press: { + usageOverride: 'press ', + positionalArgs: ['targetOrX', 'y?'], + allowsExtraPositionals: true, + allowedFlags: [...REPEATED_TOUCH_FLAGS, ...SELECTOR_SNAPSHOT_FLAGS], + }, + longpress: { + usageOverride: 'longpress [durationMs]', + positionalArgs: ['targetOrX', 'yOrDurationMs?', 'durationMs?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + swipe: { + helpDescription: 'Swipe coordinates with optional repeat pattern', + positionalArgs: ['x1', 'y1', 'x2', 'y2', 'durationMs?'], + allowedFlags: ['count', 'pauseMs', 'pattern'], + }, + gesture: { + usageOverride: 'gesture ...', + listUsageOverride: 'gesture ...', + helpDescription: + 'Run touch gestures: pan [durationMs], fling [distance] [durationMs], swipe [durationMs], pinch [x] [y], rotate [x] [y] [velocity], or transform [durationMs]', + summary: 'Run pan, fling, swipe, pinch, rotate, or transform gestures', + positionalArgs: ['pan|fling|swipe|pinch|rotate|transform', 'args?'], + allowsExtraPositionals: true, + }, + focus: { + positionalArgs: ['x', 'y'], + }, + type: { + positionalArgs: ['text'], + allowsExtraPositionals: true, + allowedFlags: ['delayMs'], + }, + fill: { + usageOverride: 'fill | fill <@ref|selector> ', + positionalArgs: ['targetOrX', 'yOrText', 'text?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'delayMs'], + }, + scroll: { + usageOverride: 'scroll [amount] [--pixels ]', + helpDescription: 'Scroll in direction, or verify hidden content and scroll toward top/bottom', + summary: 'Scroll in a direction or to an edge', + positionalArgs: ['directionOrEdge', 'amount?'], + allowedFlags: ['pixels'], + }, +} as const satisfies Record; type InteractionCommandMetadata = (typeof interactionCommandMetadata)[number]; type InteractionCommandName = InteractionCommandMetadata['name']; diff --git a/src/commands/cli-grammar/interactions.ts b/src/commands/interaction/interactions.ts similarity index 96% rename from src/commands/cli-grammar/interactions.ts rename to src/commands/interaction/interactions.ts index 34480eb1c..23ce1e674 100644 --- a/src/commands/cli-grammar/interactions.ts +++ b/src/commands/interaction/interactions.ts @@ -11,7 +11,7 @@ import { readInteractionTargetFromPositionals, } from '../../core/interaction-positionals.ts'; import { AppError } from '../../utils/errors.ts'; -import type { ScrollInputDirection } from '../interaction-gestures.ts'; +import type { ScrollInputDirection } from './runtime/gestures.ts'; import { commonInputFromFlags, direct, @@ -27,8 +27,8 @@ import { repeatedInputFromFlags, selectorSnapshotInputFromFlags, targetInputFromClientTarget, -} from './common.ts'; -import type { CliReader, DaemonWriter, CommandInput } from './types.ts'; +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter, CommandInput } from '../cli-grammar/types.ts'; export const interactionCliReaders = { click: (positionals, flags) => ({ diff --git a/src/commands/interaction-command-metadata.ts b/src/commands/interaction/metadata.ts similarity index 84% rename from src/commands/interaction-command-metadata.ts rename to src/commands/interaction/metadata.ts index 9e83784c7..8fac7575a 100644 --- a/src/commands/interaction-command-metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -1,6 +1,5 @@ -import { requireCommandDescription } from './command-descriptions.ts'; -import { defineCommandMetadata } from './command-contract.ts'; -import { GESTURE_KINDS } from '../command-catalog.ts'; +import { defineCommandMetadata } from '../command-contract.ts'; +import { GESTURE_KINDS } from '../../command-catalog.ts'; import { booleanField, elementTargetField, @@ -25,18 +24,18 @@ import { type CommonCommandInput, type InferCommandInput, type PointInput, -} from './command-input.ts'; -import { defineFieldCommandMetadata } from './field-command-contract.ts'; -import { CLICK_BUTTONS } from '../core/click-button.ts'; +} from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { CLICK_BUTTONS } from '../../core/click-button.ts'; import { SCROLL_DIRECTIONS, SWIPE_PATTERNS, SWIPE_PRESETS, type ScrollDirection, type SwipePreset, -} from '../core/scroll-gesture.ts'; -import { SCROLL_INPUT_DIRECTIONS } from './interaction-gestures.ts'; -import { FIND_LOCATORS } from '../utils/finders.ts'; +} from '../../core/scroll-gesture.ts'; +import { SCROLL_INPUT_DIRECTIONS } from './runtime/gestures.ts'; +import { FIND_LOCATORS } from '../../utils/finders.ts'; const FIND_ACTION_VALUES = [ 'click', @@ -49,6 +48,23 @@ const FIND_ACTION_VALUES = [ 'type', ] as const; +const interactionCommandDescriptions = { + 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.', +} as const; + +type InteractionCommandName = keyof typeof interactionCommandDescriptions; + const clickFields = { target: requiredField(interactionTargetField()), button: enumField(CLICK_BUTTONS, 'Pointer button for platforms that support mouse buttons.'), @@ -200,19 +216,19 @@ export type GestureInput = export const interactionCommandMetadata = [ defineCommandMetadata({ name: 'click', - description: requireCommandDescription('click'), + description: interactionCommandDescriptions.click, inputSchema: fieldsInputSchema(clickFields), readInput: (input) => readFieldInput(input, clickFields), }), defineCommandMetadata({ name: 'press', - description: requireCommandDescription('press'), + description: interactionCommandDescriptions.press, inputSchema: fieldsInputSchema(pressFields), readInput: (input) => readFieldInput(input, pressFields), }), defineCommandMetadata({ name: 'fill', - description: requireCommandDescription('fill'), + description: interactionCommandDescriptions.fill, inputSchema: fieldsInputSchema(fillFields), readInput: (input) => readFieldInput(input, fillFields), }), @@ -226,7 +242,7 @@ export const interactionCommandMetadata = [ defineInteractionCommandMetadata('find', findFields), defineCommandMetadata({ name: 'gesture', - description: requireCommandDescription('gesture'), + description: interactionCommandDescriptions.gesture, inputSchema: fieldsInputSchema(gestureFields), readInput: readGestureInput, }), @@ -292,10 +308,10 @@ function readGestureInput(input: unknown): GestureInput { } function defineInteractionCommandMetadata< - const TName extends string, + const TName extends InteractionCommandName, const TFields extends CommandFieldMap, >(name: TName, fields: TFields) { - return defineFieldCommandMetadata(name, requireCommandDescription(name), fields); + return defineFieldCommandMetadata(name, interactionCommandDescriptions[name], fields); } function optionalPoint(record: Record, key: string): PointInput | undefined { diff --git a/src/commands/interaction/output.ts b/src/commands/interaction/output.ts new file mode 100644 index 000000000..7c9804b78 --- /dev/null +++ b/src/commands/interaction/output.ts @@ -0,0 +1,54 @@ +import type { CommandRequestResult } from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { messageCliOutput, resultOutput, type CliOutputFormatter } from '../output-common.ts'; + +function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput { + const data = params.result as Record; + if (params.format === 'text') { + return { data, text: typeof data.text === 'string' ? data.text : '' }; + } + if (params.format === 'attrs') { + return { data, text: JSON.stringify(data.node ?? {}, null, 2) }; + } + return defaultCommandCliOutput(data); +} + +function findCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + if (typeof data.text === 'string') return { data, text: data.text }; + if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` }; + if (data.node) return { data, text: JSON.stringify(data.node, null, 2) }; + return defaultCommandCliOutput(data); +} + +function isCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + return { data, text: `Passed: is ${data.predicate ?? 'assertion'}` }; +} + +function tapCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const ref = data.ref ?? ''; + const x = data.x; + const y = data.y; + if (!ref || typeof x !== 'number' || typeof y !== 'number') { + return defaultCommandCliOutput(data); + } + return { data, text: `Tapped @${ref} (${x}, ${y})` }; +} + +export const interactionCliOutputFormatters = { + click: resultOutput(tapCliOutput), + press: resultOutput(tapCliOutput), + get: ({ input, result }) => + getCliOutput({ + result: result as CommandRequestResult, + format: input.format as Parameters[0]['format'], + }), + is: resultOutput(isCliOutput), + find: resultOutput(findCliOutput), +} as const satisfies Record; + +function defaultCommandCliOutput(result: CommandRequestResult): CliOutput { + return messageCliOutput(result as Record); +} diff --git a/src/commands/interaction-gestures.ts b/src/commands/interaction/runtime/gestures.ts similarity index 95% rename from src/commands/interaction-gestures.ts rename to src/commands/interaction/runtime/gestures.ts index f418e0b1e..414a2debd 100644 --- a/src/commands/interaction-gestures.ts +++ b/src/commands/interaction/runtime/gestures.ts @@ -1,17 +1,17 @@ -import { AppError } from '../utils/errors.ts'; -import type { Point, Rect, SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; -import { centerOfRect } from '../utils/snapshot.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { Point, Rect, SnapshotNode, SnapshotState } from '../../../utils/snapshot.ts'; +import { centerOfRect } from '../../../utils/snapshot.ts'; import { buildSwipePresetGesturePlan, parseSwipePreset, type GestureReferenceFrame, type ScrollDirection, type SwipePreset, -} from '../core/scroll-gesture.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { requireIntInRange } from '../utils/validation.ts'; -import { successText } from '../utils/success-text.ts'; -import { isNodeVisibleInEffectiveViewport } from '../utils/mobile-snapshot-semantics.ts'; +} from '../../../core/scroll-gesture.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { isNodeVisibleInEffectiveViewport } from '../../../utils/mobile-snapshot-semantics.ts'; import { captureScrollEdgeState, formatScrollEdgeMessage, @@ -19,22 +19,22 @@ import { type ScrollEdge, type ScrollEdgeState, type ScrollEdgeTarget, -} from '../utils/scroll-edge-state.ts'; +} from '../../../utils/scroll-edge-state.ts'; +import { toBackendContext } from '../../runtime-common.ts'; import { toBackendResult, type BackendResultEnvelope, type BackendResultVariant, type RuntimeCommand, -} from './runtime-types.ts'; -import type { LongPressCommandResult } from '../contracts/interaction.ts'; +} from '../../runtime-types.ts'; +import type { LongPressCommandResult } from '../../../contracts/interaction.ts'; import { assertSupportedInteractionSurface, captureInteractionSnapshot, type InteractionTarget, type ResolvedInteractionTarget, resolveInteractionTarget, -} from './interaction-resolution.ts'; -import { toBackendContext } from './selector-read-utils.ts'; +} from './resolution.ts'; export type FocusCommandOptions = CommandContext & { target: InteractionTarget; diff --git a/src/commands/interaction/runtime/index.ts b/src/commands/interaction/runtime/index.ts new file mode 100644 index 000000000..db5a29601 --- /dev/null +++ b/src/commands/interaction/runtime/index.ts @@ -0,0 +1,197 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { BoundRuntimeCommand, RuntimeCommand } from '../../runtime-types.ts'; +import { + clickCommand, + fillCommand, + focusCommand, + longPressCommand, + pinchCommand, + pressCommand, + scrollCommand, + swipeCommand, + typeTextCommand, + type ClickCommandOptions, + type FillCommandOptions, + type FillCommandResult, + type FocusCommandOptions, + type FocusCommandResult, + type InteractionTarget, + type LongPressCommandOptions, + type LongPressCommandResult, + type PinchCommandOptions, + type PinchCommandResult, + type PressCommandOptions, + type PressCommandResult, + type ScrollCommandOptions, + type ScrollCommandResult, + type SwipeCommandOptions, + type SwipeCommandResult, + type TypeTextCommandOptions, + type TypeTextCommandResult, +} from './interactions.ts'; +import { + findCommand, + getAttrsCommand, + getCommand, + getTextCommand, + isCommand, + isHiddenCommand, + isVisibleCommand, + waitCommand, + waitForTextCommand, + type ElementTarget, + type FindReadCommandOptions, + type FindReadCommandResult, + type GetAttrsCommandOptions, + type GetCommandOptions, + type GetCommandResult, + type GetTextCommandOptions, + type IsCommandOptions, + type IsCommandResult, + type IsSelectorCommandOptions, + type SelectorTarget, + type WaitCommandOptions, + type WaitCommandResult, + type WaitForTextCommandOptions, +} from './selector-read.ts'; + +export type SelectorCommands = { + find: RuntimeCommand; + get: RuntimeCommand; + getText: RuntimeCommand>; + getAttrs: RuntimeCommand>; + is: RuntimeCommand; + isVisible: RuntimeCommand; + isHidden: RuntimeCommand; + wait: RuntimeCommand; + waitForText: RuntimeCommand< + WaitForTextCommandOptions, + Extract + >; +}; + +export type InteractionCommands = { + click: RuntimeCommand; + press: RuntimeCommand; + fill: RuntimeCommand; + typeText: RuntimeCommand; + focus: RuntimeCommand; + longPress: RuntimeCommand; + swipe: RuntimeCommand; + scroll: RuntimeCommand; + pinch: RuntimeCommand; +}; + +export type BoundSelectorCommands = { + find: BoundRuntimeCommand; + get: BoundRuntimeCommand; + getText: ( + target: ElementTarget, + options?: Omit, + ) => Promise>; + getAttrs: ( + target: ElementTarget, + options?: Omit, + ) => Promise>; + is: BoundRuntimeCommand; + isVisible: ( + target: SelectorTarget, + options?: Omit, + ) => Promise; + isHidden: ( + target: SelectorTarget, + options?: Omit, + ) => Promise; + wait: BoundRuntimeCommand; + waitForText: ( + text: string, + options?: Omit, + ) => Promise>; +}; + +export type BoundInteractionCommands = { + click: ( + target: InteractionTarget, + options?: Omit, + ) => Promise; + press: ( + target: InteractionTarget, + options?: Omit, + ) => Promise; + fill: ( + target: InteractionTarget, + text: string, + options?: Omit, + ) => Promise; + typeText: ( + text: string, + options?: Omit, + ) => Promise; + focus: ( + target: InteractionTarget, + options?: Omit, + ) => Promise; + longPress: ( + target: InteractionTarget, + options?: Omit, + ) => Promise; + swipe: BoundRuntimeCommand; + scroll: BoundRuntimeCommand; + pinch: BoundRuntimeCommand; +}; + +export const selectorCommands: SelectorCommands = { + find: findCommand, + get: getCommand, + getText: getTextCommand, + getAttrs: getAttrsCommand, + is: isCommand, + isVisible: isVisibleCommand, + isHidden: isHiddenCommand, + wait: waitCommand, + waitForText: waitForTextCommand, +}; + +export const interactionCommands: InteractionCommands = { + click: clickCommand, + press: pressCommand, + fill: fillCommand, + typeText: typeTextCommand, + focus: focusCommand, + longPress: longPressCommand, + swipe: swipeCommand, + scroll: scrollCommand, + pinch: pinchCommand, +}; + +export function bindSelectorCommands(runtime: AgentDeviceRuntime): BoundSelectorCommands { + return { + find: (options) => selectorCommands.find(runtime, options), + get: (options) => selectorCommands.get(runtime, options), + getText: (target, options = {}) => selectorCommands.getText(runtime, { ...options, target }), + getAttrs: (target, options = {}) => selectorCommands.getAttrs(runtime, { ...options, target }), + is: (options) => selectorCommands.is(runtime, options), + isVisible: (target, options = {}) => + selectorCommands.isVisible(runtime, { ...options, target }), + isHidden: (target, options = {}) => selectorCommands.isHidden(runtime, { ...options, target }), + wait: (options) => selectorCommands.wait(runtime, options), + waitForText: (text, options = {}) => + selectorCommands.waitForText(runtime, { ...options, text }), + }; +} + +export function bindInteractionCommands(runtime: AgentDeviceRuntime): BoundInteractionCommands { + return { + click: (target, options = {}) => interactionCommands.click(runtime, { ...options, target }), + press: (target, options = {}) => interactionCommands.press(runtime, { ...options, target }), + fill: (target, text, options = {}) => + interactionCommands.fill(runtime, { ...options, target, text }), + typeText: (text, options = {}) => interactionCommands.typeText(runtime, { ...options, text }), + focus: (target, options = {}) => interactionCommands.focus(runtime, { ...options, target }), + longPress: (target, options = {}) => + interactionCommands.longPress(runtime, { ...options, target }), + swipe: (options) => interactionCommands.swipe(runtime, options), + scroll: (options) => interactionCommands.scroll(runtime, options), + pinch: (options) => interactionCommands.pinch(runtime, options), + }; +} diff --git a/src/__tests__/runtime-interactions.test.ts b/src/commands/interaction/runtime/interactions.test.ts similarity index 98% rename from src/__tests__/runtime-interactions.test.ts rename to src/commands/interaction/runtime/interactions.test.ts index bd54a83b9..fc6e9fecc 100644 --- a/src/__tests__/runtime-interactions.test.ts +++ b/src/commands/interaction/runtime/interactions.test.ts @@ -1,13 +1,17 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendSnapshotOptions } from '../backend.ts'; -import { commands, ref, selector } from '../commands/index.ts'; -import { resolveActionableTouchResolution } from '../core/interaction-targeting.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, createMemorySessionStore, localCommandPolicy } from '../runtime.ts'; -import { AppError } from '../utils/errors.ts'; -import type { Point, SnapshotState } from '../utils/snapshot.ts'; -import { makeSnapshotState } from './test-utils/index.ts'; +import type { AgentDeviceBackend, BackendSnapshotOptions } from '../../../backend.ts'; +import { commands, ref, selector } from '../../index.ts'; +import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + createMemorySessionStore, + localCommandPolicy, +} from '../../../runtime.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { Point, SnapshotState } from '../../../utils/snapshot.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; test('runtime click taps an explicit point without requiring a snapshot', async () => { const calls: Array<{ point: Point; count?: number }> = []; diff --git a/src/commands/interactions.ts b/src/commands/interaction/runtime/interactions.ts similarity index 86% rename from src/commands/interactions.ts rename to src/commands/interaction/runtime/interactions.ts index 8e18f56b8..9fdb4171e 100644 --- a/src/commands/interactions.ts +++ b/src/commands/interaction/runtime/interactions.ts @@ -1,23 +1,23 @@ -import { AppError } from '../utils/errors.ts'; -import type { ClickButton } from '../core/click-button.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { isFillableType } from '../utils/snapshot-processing.ts'; -import { requireIntInRange } from '../utils/validation.ts'; -import { successText } from '../utils/success-text.ts'; -import { findMistargetedTypeRefToken } from '../utils/type-target-warning.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { ClickButton } from '../../../core/click-button.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { isFillableType } from '../../../utils/snapshot-processing.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { findMistargetedTypeRefToken } from '../../../utils/type-target-warning.ts'; import type { FillCommandResult, PressCommandResult, ResolvedTarget, -} from '../contracts/interaction.ts'; -import { toBackendContext } from './selector-read-utils.ts'; +} from '../../../contracts/interaction.ts'; +import { toBackendContext } from '../../runtime-common.ts'; import { toBackendResult, type BackendResultEnvelope, type RuntimeCommand, -} from './runtime-types.ts'; -import type { RepeatedInput } from './command-input.ts'; -import { type InteractionTarget, resolveInteractionTarget } from './interaction-resolution.ts'; +} from '../../runtime-types.ts'; +import type { RepeatedInput } from '../../command-input.ts'; +import { type InteractionTarget, resolveInteractionTarget } from './resolution.ts'; export { focusCommand, @@ -25,7 +25,7 @@ export { pinchCommand, scrollCommand, swipeCommand, -} from './interaction-gestures.ts'; +} from './gestures.ts'; export type { FocusCommandOptions, FocusCommandResult, @@ -40,12 +40,8 @@ export type { SwipeCommandOptions, SwipeCommandResult, SwipeOptions, -} from './interaction-gestures.ts'; -export type { - InteractionTarget, - PointTarget, - ResolvedInteractionTarget, -} from './interaction-resolution.ts'; +} from './gestures.ts'; +export type { InteractionTarget, PointTarget, ResolvedInteractionTarget } from './resolution.ts'; export type PressCommandOptions = CommandContext & RepeatedInput & { diff --git a/src/commands/interaction-resolution.ts b/src/commands/interaction/runtime/resolution.ts similarity index 91% rename from src/commands/interaction-resolution.ts rename to src/commands/interaction/runtime/resolution.ts index 943555b62..74aa27017 100644 --- a/src/commands/interaction-resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -1,23 +1,27 @@ -import { AppError } from '../utils/errors.ts'; -import type { Point, SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; -import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; -import { resolveRectCenter } from '../utils/rect-center.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { formatSelectorFailure, parseSelectorChain, resolveSelectorChain } from '../selectors.ts'; -import { buildSelectorChainForNode } from '../utils/selector-build.ts'; -import { findNodeByLabel, resolveRefLabel } from '../utils/snapshot-processing.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { Point, SnapshotNode, SnapshotState } from '../../../utils/snapshot.ts'; +import { findNodeByRef, normalizeRef } from '../../../utils/snapshot.ts'; +import { resolveRectCenter } from '../../../utils/rect-center.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { + formatSelectorFailure, + parseSelectorChain, + resolveSelectorChain, +} from '../../../selectors.ts'; +import { buildSelectorChainForNode } from '../../../utils/selector-build.ts'; +import { findNodeByLabel, resolveRefLabel } from '../../../utils/snapshot-processing.ts'; import { isNodeVisibleInEffectiveViewport, resolveEffectiveViewportRect, -} from '../utils/mobile-snapshot-semantics.ts'; -import { isSnapshotNodeInteractionBlocked } from '../utils/snapshot-occlusion.ts'; +} from '../../../utils/mobile-snapshot-semantics.ts'; +import { isSnapshotNodeInteractionBlocked } from '../../../utils/snapshot-occlusion.ts'; import type { InteractionTarget, PointTarget, ResolvedInteractionTarget, -} from '../contracts/interaction.ts'; -import { resolveActionableTouchResolution } from './interaction-targeting.ts'; -import { now, toBackendContext } from './selector-read-utils.ts'; +} from '../../../contracts/interaction.ts'; +import { now, toBackendContext } from '../../runtime-common.ts'; +import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts'; export type { InteractionTarget, PointTarget, ResolvedInteractionTarget }; @@ -95,6 +99,7 @@ async function resolveRefInteractionTarget( }; } +// fallow-ignore-next-line complexity async function resolveSelectorInteractionTarget( runtime: AgentDeviceRuntime, options: CommandContext, diff --git a/src/commands/selector-read-shared.ts b/src/commands/interaction/runtime/selector-read-shared.ts similarity index 84% rename from src/commands/selector-read-shared.ts rename to src/commands/interaction/runtime/selector-read-shared.ts index d60683e99..4cf313032 100644 --- a/src/commands/selector-read-shared.ts +++ b/src/commands/interaction/runtime/selector-read-shared.ts @@ -2,14 +2,15 @@ import type { AgentDeviceRuntime, CommandContext, CommandSessionRecord, -} from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import type { SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; -import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; -import { isSparseSnapshotQualityVerdict } from '../utils/snapshot-quality.ts'; -import { extractReadableText } from '../utils/text-surface.ts'; -import { findNodeByLabel, now, toBackendContext } from './selector-read-utils.ts'; -import type { SelectorSnapshotInput } from './command-input.ts'; +} from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { SnapshotNode, SnapshotState } from '../../../utils/snapshot.ts'; +import { findNodeByRef, normalizeRef } from '../../../utils/snapshot.ts'; +import { isSparseSnapshotQualityVerdict } from '../../../utils/snapshot-quality.ts'; +import { extractReadableText } from '../../../utils/text-surface.ts'; +import { now, toBackendContext } from '../../runtime-common.ts'; +import { findNodeByLabel } from './selector-read-utils.ts'; +import type { SelectorSnapshotInput } from '../../command-input.ts'; export type CapturedSnapshot = { sessionName: string; diff --git a/src/commands/interaction/runtime/selector-read-utils.ts b/src/commands/interaction/runtime/selector-read-utils.ts new file mode 100644 index 000000000..1ac93cb90 --- /dev/null +++ b/src/commands/interaction/runtime/selector-read-utils.ts @@ -0,0 +1,5 @@ +export { findNodeByLabel, resolveRefLabel } from '../../../utils/snapshot-processing.ts'; + +export function shouldScopeFind(locator: string): boolean { + return locator === 'text' || locator === 'label' || locator === 'any'; +} diff --git a/src/__tests__/runtime-selector-read.test.ts b/src/commands/interaction/runtime/selector-read.test.ts similarity index 96% rename from src/__tests__/runtime-selector-read.test.ts rename to src/commands/interaction/runtime/selector-read.test.ts index 8e50c318d..642296af4 100644 --- a/src/__tests__/runtime-selector-read.test.ts +++ b/src/commands/interaction/runtime/selector-read.test.ts @@ -4,17 +4,17 @@ import type { AgentDeviceBackend, BackendSnapshotOptions, BackendSnapshotResult, -} from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; +} from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; import { createAgentDevice, createMemorySessionStore, localCommandPolicy, type CommandSessionStore, -} from '../runtime.ts'; -import { ref, selector } from '../commands/index.ts'; -import type { SnapshotState } from '../utils/snapshot.ts'; -import { makeSnapshotState } from './test-utils/index.ts'; +} from '../../../runtime.ts'; +import { ref, selector } from '../../index.ts'; +import type { SnapshotState } from '../../../utils/snapshot.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; test('runtime get reads text from a selector target', async () => { const snapshot = selectorSnapshot(); diff --git a/src/commands/selector-read.ts b/src/commands/interaction/runtime/selector-read.ts similarity index 94% rename from src/commands/selector-read.ts rename to src/commands/interaction/runtime/selector-read.ts index 3aba94b09..0f1a4ef97 100644 --- a/src/commands/selector-read.ts +++ b/src/commands/interaction/runtime/selector-read.ts @@ -1,28 +1,31 @@ -import type { FindAction, FindLocator } from '../utils/finders.ts'; -import { findBestMatchesByLocator } from '../utils/finders.ts'; -import type { SnapshotNode } from '../utils/snapshot.ts'; -import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; +import type { FindAction, FindLocator } from '../../../utils/finders.ts'; +import { findBestMatchesByLocator } from '../../../utils/finders.ts'; +import type { SnapshotNode } from '../../../utils/snapshot.ts'; +import { findNodeByRef, normalizeRef } from '../../../utils/snapshot.ts'; import { isSparseSnapshotQualityVerdict, type SnapshotQualityVerdict, -} from '../utils/snapshot-quality.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; +} from '../../../utils/snapshot-quality.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; import { findSelectorChainMatch, formatSelectorFailure, parseSelectorChain, resolveSelectorChain, -} from '../selectors.ts'; -import { buildSelectorChainForNode } from '../utils/selector-build.ts'; -import { evaluateIsPredicate, isSupportedPredicate } from '../utils/selector-is-predicates.ts'; +} from '../../../selectors.ts'; +import { buildSelectorChainForNode } from '../../../utils/selector-build.ts'; +import { + evaluateIsPredicate, + isSupportedPredicate, +} from '../../../utils/selector-is-predicates.ts'; import type { ElementTarget, RefTarget, ResolvedTarget, SelectorTarget, -} from '../contracts/interaction.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; +} from '../../../contracts/interaction.ts'; +import type { RuntimeCommand } from '../../runtime-types.ts'; import { type CapturedSnapshot, type SelectorSnapshotOptions, @@ -31,14 +34,8 @@ import { requireSnapshotSession, resolveRefNode, } from './selector-read-shared.ts'; -import { - findNodeByLabel, - now, - resolveRefLabel, - shouldScopeFind, - sleep, - toBackendContext, -} from './selector-read-utils.ts'; +import { findNodeByLabel, resolveRefLabel, shouldScopeFind } from './selector-read-utils.ts'; +import { now, sleep, toBackendContext } from '../../runtime-common.ts'; export type { SelectorSnapshotOptions } from './selector-read-shared.ts'; export type { ElementTarget, RefTarget, ResolvedTarget, SelectorTarget }; diff --git a/src/commands/cli-grammar/selectors.ts b/src/commands/interaction/selectors.ts similarity index 97% rename from src/commands/cli-grammar/selectors.ts rename to src/commands/interaction/selectors.ts index cbf0dbba8..1fefa159b 100644 --- a/src/commands/cli-grammar/selectors.ts +++ b/src/commands/interaction/selectors.ts @@ -10,8 +10,8 @@ import { selectionOptionsFromFlags, selectorSnapshotOptionsFromFlags, splitRequiredSelector, -} from './common.ts'; -import type { CliReader, DaemonWriter } from './types.ts'; +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; export const selectorCliReaders = { find: (positionals, flags) => readFindOptionsFromPositionals(positionals, flags), diff --git a/src/commands/log-command-contract.ts b/src/commands/log-command-contract.ts deleted file mode 100644 index fe7fa36d6..000000000 --- a/src/commands/log-command-contract.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../contracts/logs.ts'; diff --git a/src/commands/management/app-inventory-contract.ts b/src/commands/management/app-inventory-contract.ts new file mode 100644 index 000000000..5b47228dd --- /dev/null +++ b/src/commands/management/app-inventory-contract.ts @@ -0,0 +1 @@ +export * from '../../contracts/app-inventory.ts'; diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts new file mode 100644 index 000000000..3bf31cc4c --- /dev/null +++ b/src/commands/management/index.ts @@ -0,0 +1,441 @@ +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { + AppCloseOptions, + AppPushOptions, + AppTriggerEventOptions, +} from '../../client-types.ts'; +import type { DaemonInstallSource } from '../../contracts.ts'; +import { SESSION_SURFACES } from '../../core/session-surface.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; +import { assertResolvedAppsFilter } from './app-inventory-contract.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + booleanField, + booleanSchema, + enumField, + integerField, + jsonSchemaField, + looseObjectField, + looseObjectSchema, + requiredField, + stringArrayField, + stringField, + stringSchema, +} from '../command-input.ts'; +import { + commonInputFromFlags, + direct, + optionalString, + readJsonObject, + request, + requiredDaemonString, + requiredString, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter, CommandInput } from '../cli-grammar/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { DEFAULT_APPS_FILTER } from '../../contracts/app-inventory.ts'; + +const PREPARE_ACTION_VALUES = ['ios-runner'] as const; + +const managementCommandDescriptions = { + devices: 'List available devices.', + boot: 'Boot or prepare a selected device without using CLI positional arguments.', + shutdown: 'Shutdown a selected simulator or emulator.', + apps: 'List installed apps.', + session: 'List active sessions or print daemon state directory.', + open: 'Open an app, deep link, URL, or platform surface.', + prepare: 'Prepare platform helper infrastructure.', + 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.', +} as const; + +export const managementCommandMetadata = [ + defineFieldCommandMetadata('devices', managementCommandDescriptions.devices, {}), + defineFieldCommandMetadata('boot', managementCommandDescriptions.boot, { + headless: booleanField('Boot without showing simulator UI when supported.'), + }), + defineFieldCommandMetadata('shutdown', managementCommandDescriptions.shutdown, {}), + defineFieldCommandMetadata('prepare', managementCommandDescriptions.prepare, { + action: requiredField(enumField(PREPARE_ACTION_VALUES)), + timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), + }), + defineFieldCommandMetadata('apps', managementCommandDescriptions.apps, { + appsFilter: enumField(['user-installed', 'all']), + }), + defineFieldCommandMetadata('session', managementCommandDescriptions.session, { + action: enumField( + ['list', 'state-dir'], + 'list shows active sessions; state-dir prints the resolved daemon state directory without contacting the daemon.', + ), + }), + defineFieldCommandMetadata('open', managementCommandDescriptions.open, { + app: stringField('App name, bundle id, package, or URL.'), + url: stringField('Optional URL passed with an app shell.'), + surface: enumField(SESSION_SURFACES), + 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()] }), + deviceHub: booleanField('Use Xcode Device Hub when surfacing Apple simulators.'), + noRecord: booleanField('Do not record this action.'), + }), + defineFieldCommandMetadata('close', managementCommandDescriptions.close, { + app: stringField('Optional app to close.'), + shutdown: booleanField('Shutdown the session/device where supported.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + }), + defineFieldCommandMetadata('install', managementCommandDescriptions.install, { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }), + defineFieldCommandMetadata('reinstall', managementCommandDescriptions.reinstall, { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }), + defineFieldCommandMetadata( + 'install-from-source', + managementCommandDescriptions['install-from-source'], + { + source: requiredField( + jsonSchemaField(looseObjectSchema('Install source object.')), + ), + retainPaths: booleanField(), + retentionMs: integerField(), + }, + ), + defineFieldCommandMetadata('push', managementCommandDescriptions.push, { + app: requiredField(stringField()), + payload: requiredField( + jsonSchemaField>({ + oneOf: [stringSchema(), looseObjectSchema()], + }), + ), + }), + defineFieldCommandMetadata( + 'trigger-app-event', + managementCommandDescriptions['trigger-app-event'], + { + event: requiredField(stringField()), + payload: looseObjectField(), + }, + ), +] as const; + +type ManagementCommandMetadata = (typeof managementCommandMetadata)[number]; +type ManagementCommandName = ManagementCommandMetadata['name']; + +export const managementCommandDefinitions = [ + defineExecutableCommand(metadata('devices'), (client, input) => client.devices.list(input)), + defineExecutableCommand(metadata('boot'), (client, input) => client.devices.boot(input)), + defineExecutableCommand(metadata('shutdown'), (client, input) => client.devices.shutdown(input)), + defineExecutableCommand(metadata('apps'), (client, input) => client.apps.list(input)), + defineExecutableCommand(metadata('session'), async (client, { action, ...input }) => + action === 'state-dir' + ? { stateDir: await client.sessions.stateDir(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('prepare'), (client, input) => client.command.prepare(input)), +] as const; + +export const managementCliSchemas = { + boot: { + summary: 'Boot target device/simulator', + allowedFlags: ['headless'], + }, + shutdown: { + summary: 'Shutdown target simulator/emulator', + }, + prepare: { + usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', + listUsageOverride: 'prepare ios-runner --platform ios|macos', + helpDescription: + 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', + summary: 'Prepare platform helpers', + positionalArgs: ['ios-runner'], + allowedFlags: ['timeoutMs'], + }, + open: { + helpDescription: + 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', + summary: 'Open an app, deep link or URL, save replays', + positionalArgs: ['appOrUrl?', 'url?'], + allowedFlags: [ + 'activity', + 'launchConsole', + 'launchArgs', + 'deviceHub', + 'saveScript', + 'relaunch', + 'surface', + ], + }, + close: { + positionalArgs: ['app?'], + allowedFlags: ['saveScript', 'shutdown'], + }, + reinstall: { + positionalArgs: ['app', 'path'], + }, + install: { + positionalArgs: ['app', 'path'], + }, + 'install-from-source': { + usageOverride: + 'install-from-source | install-from-source --github-actions-artifact ', + listUsageOverride: 'install-from-source | install-from-source --github-actions-artifact', + helpDescription: 'Install app from a URL or remote-resolved source', + summary: 'Install app from a source', + positionalArgs: ['url?'], + allowedFlags: [ + 'header', + 'githubActionsArtifact', + 'installSource', + 'retainPaths', + 'retentionMs', + ], + }, + apps: { + helpDescription: 'List user-installed apps; use --all to include system/OEM apps', + summary: 'List installed apps', + allowedFlags: ['appsFilter'], + defaults: { appsFilter: DEFAULT_APPS_FILTER }, + }, + push: { + positionalArgs: ['bundleOrPackage', 'payloadOrJson'], + }, + 'trigger-app-event': { + usageOverride: 'trigger-app-event [payloadJson]', + positionalArgs: ['event', 'payloadJson?'], + }, + session: { + usageOverride: 'session list | session state-dir', + listUsageOverride: 'session list', + helpDescription: 'List active sessions or print the effective daemon state directory', + positionalArgs: ['list|state-dir?'], + }, +} as const satisfies Record; + +function metadata( + name: TName, +): Extract { + const definition = managementCommandMetadata.find((item) => item.name === name); + if (!definition) throw new Error(`Missing management command metadata for ${name}`); + return definition as Extract; +} + +function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown?: boolean } { + const { app: _app, ...rest } = input; + return rest; +} + +export const appCliReaders = { + devices: (_positionals, flags) => commonInputFromFlags(flags), + apps: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + appsFilter: assertResolvedAppsFilter(flags.appsFilter), + }), + session: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readSessionAction(positionals[0]), + }), + boot: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + headless: flags.headless, + }), + shutdown: (_positionals, flags) => commonInputFromFlags(flags), + prepare: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: requiredString(positionals[0], 'prepare requires subcommand'), + timeoutMs: flags.timeoutMs, + }), + open: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: positionals[0], + url: positionals[1], + surface: flags.surface, + activity: flags.activity, + launchConsole: flags.launchConsole, + launchArgs: flags.launchArgs, + relaunch: flags.relaunch, + saveScript: flags.saveScript, + deviceHub: flags.deviceHub, + noRecord: flags.noRecord, + }), + close: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: positionals[0], + shutdown: flags.shutdown, + saveScript: flags.saveScript, + }), + install: installInputFromCli, + reinstall: installInputFromCli, + 'install-from-source': (positionals, flags) => ({ + ...commonInputFromFlags(flags), + source: resolveInstallSource(positionals, flags), + retainPaths: flags.retainPaths, + retentionMs: flags.retentionMs, + }), + push: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], 'push requires bundleOrPackage'), + payload: requiredString(positionals[1], 'push requires payloadOrJson'), + }), + 'trigger-app-event': (positionals, flags) => ({ + ...commonInputFromFlags(flags), + event: requiredString(positionals[0], 'trigger-app-event requires event'), + payload: positionals[1] + ? readJsonObject(positionals[1], 'trigger-app-event payload') + : undefined, + }), +} satisfies Record; + +export const appDaemonWriters = { + devices: direct(PUBLIC_COMMANDS.devices), + boot: direct(PUBLIC_COMMANDS.boot), + shutdown: direct(PUBLIC_COMMANDS.shutdown), + prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [ + requiredDaemonString(input.action, 'prepare requires subcommand'), + ]), + apps: direct(PUBLIC_COMMANDS.apps), + open: direct(PUBLIC_COMMANDS.open, openPositionals), + close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)), + install: direct(PUBLIC_COMMANDS.install, (input) => requiredPair(input.app, input.appPath)), + reinstall: direct(PUBLIC_COMMANDS.reinstall, (input) => requiredPair(input.app, input.appPath)), + 'install-from-source': (input) => + request(INTERNAL_COMMANDS.installSource, [], { + ...input, + installSource: input.source, + retainMaterializedPaths: input.retainPaths, + materializedPathRetentionMs: input.retentionMs, + }), + push: direct(PUBLIC_COMMANDS.push, (input) => pushPositionals(input as AppPushOptions)), + 'trigger-app-event': direct(PUBLIC_COMMANDS.triggerAppEvent, (input) => + triggerEventPositionals(input as AppTriggerEventOptions), + ), +} satisfies Record; + +function installInputFromCli( + positionals: string[], + flags: CliFlags, + command = 'install', +): Record { + return { + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], `${command} requires app`), + appPath: requiredString(positionals[1], `${command} requires path`), + }; +} + +function readSessionAction(value: string | undefined): 'list' | 'state-dir' { + const action = value ?? 'list'; + if (action === 'list') return action; + if (action === 'state-dir') return action; + throw new AppError('INVALID_ARGS', 'session only supports list or state-dir'); +} + +function openPositionals(input: CommandInput): string[] { + if (!input.app) return []; + return input.url ? [input.app, input.url] : [input.app]; +} + +function requiredPair(first: unknown, second: unknown): string[] { + return [ + requiredDaemonString(first, 'missing first positional'), + requiredDaemonString(second, 'missing second positional'), + ]; +} + +function pushPositionals(input: AppPushOptions): string[] { + return [ + input.app, + typeof input.payload === 'string' ? input.payload : JSON.stringify(input.payload), + ]; +} + +function triggerEventPositionals(input: AppTriggerEventOptions): string[] { + return [input.event, ...(input.payload ? [JSON.stringify(input.payload)] : [])]; +} + +// fallow-ignore-next-line complexity +function resolveInstallSource(positionals: string[], flags: CliFlags) { + const url = positionals[0]?.trim(); + if (positionals.length > 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source accepts either one positional or --github-actions-artifact', + ); + } + const githubArtifactSource = flags.githubActionsArtifact + ? parseGitHubActionsArtifactInstallSourceSpec(flags.githubActionsArtifact) + : undefined; + const configuredSource = flags.installSource; + const sourceCount = (url ? 1 : 0) + (githubArtifactSource ? 1 : 0) + (configuredSource ? 1 : 0); + if (sourceCount !== 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source requires exactly one source: , --github-actions-artifact, or config installSource', + ); + } + if (!url && flags.header && flags.header.length > 0) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source --header is only supported for URL sources', + ); + } + if (githubArtifactSource) return githubArtifactSource; + if (configuredSource) return configuredSource; + return { + kind: 'url' as const, + url: url!, + headers: parseInstallSourceHeaders(flags.header), + }; +} + +function parseInstallSourceHeaders( + headerFlags: CliFlags['header'], +): Record | undefined { + if (!headerFlags || headerFlags.length === 0) return undefined; + const headers: Record = {}; + for (const rawHeader of headerFlags) { + const separator = rawHeader.indexOf(':'); + if (separator <= 0) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Expected "name:value".`, + ); + } + const name = rawHeader.slice(0, separator).trim(); + const value = rawHeader.slice(separator + 1).trim(); + if (!name) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Header name cannot be empty.`, + ); + } + headers[name] = value; + } + return headers; +} diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts new file mode 100644 index 000000000..dfe7e176f --- /dev/null +++ b/src/commands/management/output.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest'; +import { openCliOutput } from './output.ts'; + +describe('openCliOutput', () => { + test('prints session state directory on a second line', () => { + const output = openCliOutput({ + session: 'default', + sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default', + identifiers: { session: 'default' }, + }); + + expect(output.text).toBe( + ['Opened: default', 'Session state: /tmp/agent-device/sessions/cwd_123_default'].join('\n'), + ); + expect(output.data).toMatchObject({ + session: 'default', + sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default', + }); + }); +}); diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts new file mode 100644 index 000000000..f2566766c --- /dev/null +++ b/src/commands/management/output.ts @@ -0,0 +1,127 @@ +import { + serializeCloseResult, + serializeDeployResult, + serializeDevice, + serializeInstallFromSourceResult, + serializeOpenResult, + serializeSessionListEntry, +} from '../../client-shared.ts'; +import type { + AgentDeviceDevice, + AgentDeviceSession, + AppCloseResult, + AppDeployResult, + AppInstallFromSourceResult, + AppOpenResult, + CommandRequestResult, + SessionCloseResult, +} from '../../client-types.ts'; +import { readCommandMessage } from '../../utils/success-text.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { + messageCliOutput, + messageOutput, + resultOutput, + type CliOutputFormatter, +} from '../output-common.ts'; + +function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput { + const data = { devices: result.map(serializeDevice) }; + return { data, text: result.map(formatDeviceLine).join('\n') }; +} + +function appsCliOutput(params: { + result: string[]; + appsFilter?: 'user-installed' | 'all'; +}): CliOutput { + const data = { apps: params.result }; + return { + data, + stderr: + params.appsFilter === 'all' + ? 'Showing all apps, including system apps.\n' + : 'Showing user-installed apps. Use --all to include system apps.\n', + text: + params.result.length > 0 + ? params.result.join('\n') + : params.appsFilter === 'all' + ? 'No apps found.' + : 'No user-installed apps found.', + }; +} + +function sessionCliOutput( + result: { sessions: AgentDeviceSession[] } | { stateDir: string }, +): CliOutput { + if ('stateDir' in result) { + return { data: result, text: result.stateDir }; + } + const data = { sessions: result.sessions.map(serializeSessionListEntry) }; + return { data, text: JSON.stringify(data, null, 2) }; +} + +export function openCliOutput(result: AppOpenResult): CliOutput { + const data = serializeOpenResult(result); + const lines = [readCommandMessage(data)].filter((line): line is string => Boolean(line)); + if (typeof data.sessionStateDir === 'string') { + lines.push(`Session state: ${data.sessionStateDir}`); + } + return { data, text: lines.join('\n') || null }; +} + +function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput { + return messageCliOutput(serializeCloseResult(result)); +} + +function deployCliOutput(result: AppDeployResult): CliOutput { + return messageCliOutput(serializeDeployResult(result)); +} + +function installFromSourceCliOutput(result: AppInstallFromSourceResult): CliOutput { + return messageCliOutput(serializeInstallFromSourceResult(result)); +} + +function bootCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const platform = data.platform ?? 'unknown'; + const device = data.device ?? data.id ?? 'unknown'; + return { data, text: `Boot ready: ${device} (${platform})` }; +} + +function shutdownCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const platform = data.platform ?? 'unknown'; + const device = data.device ?? data.id ?? 'unknown'; + const shutdown = data.shutdown; + const success = + shutdown && typeof shutdown === 'object' && 'success' in shutdown + ? (shutdown as { success?: unknown }).success === true + : false; + const status = success ? 'Shutdown' : 'Shutdown failed'; + return { data, text: `${status}: ${device} (${platform})` }; +} + +export const managementCliOutputFormatters = { + boot: resultOutput(bootCliOutput), + shutdown: resultOutput(shutdownCliOutput), + devices: resultOutput(devicesCliOutput), + apps: ({ input, result }) => + appsCliOutput({ + result: result as Parameters[0]['result'], + appsFilter: input.appsFilter as Parameters[0]['appsFilter'], + }), + session: resultOutput(sessionCliOutput), + open: resultOutput(openCliOutput), + close: resultOutput(closeCliOutput), + install: resultOutput(deployCliOutput), + reinstall: resultOutput(deployCliOutput), + 'install-from-source': resultOutput(installFromSourceCliOutput), + prepare: messageOutput, +} as const satisfies Record; + +function formatDeviceLine(device: AgentDeviceDevice): string { + const kind = device.kind ? ` ${device.kind}` : ''; + const target = device.target ? ` target=${device.target}` : ''; + const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; + return `${device.name} (${device.platform}${kind}${target})${booted}`; +} diff --git a/src/__tests__/runtime-admin-router.test.ts b/src/commands/management/runtime/admin-router.test.ts similarity index 63% rename from src/__tests__/runtime-admin-router.test.ts rename to src/commands/management/runtime/admin-router.test.ts index 72fa5cc9e..8f6fc2507 100644 --- a/src/__tests__/runtime-admin-router.test.ts +++ b/src/commands/management/runtime/admin-router.test.ts @@ -1,8 +1,12 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendInstallSource } from '../backend.ts'; -import type { ArtifactAdapter, FileInputRef } from '../io.ts'; -import { createAgentDevice, localCommandPolicy, restrictedCommandPolicy } from '../runtime.ts'; +import type { AgentDeviceBackend, BackendInstallSource } from '../../../backend.ts'; +import type { ArtifactAdapter, FileInputRef } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + restrictedCommandPolicy, +} from '../../../runtime.ts'; const artifacts = { resolveInput: async (ref: FileInputRef) => ({ @@ -138,103 +142,6 @@ test('admin install cleans materialized input when backend source resolution fai assert.equal(installCalled, false); }); -test('record and trace runtime commands call typed backend lifecycle primitives', async () => { - const calls: unknown[] = []; - const device = createAgentDevice({ - backend: { - platform: 'ios', - startRecording: async (_context, options) => { - calls.push({ command: 'startRecording', options }); - return { path: options?.outPath ?? '/tmp/recording.mp4' }; - }, - stopTrace: async (_context, options) => { - calls.push({ command: 'stopTrace', options }); - return { outPath: options?.outPath ?? '/tmp/trace.log' }; - }, - }, - artifacts, - policy: localCommandPolicy(), - }); - - const recording = await device.recording.record({ - action: 'start', - out: { kind: 'path', path: '/tmp/out.mp4' }, - fps: 30, - quality: 7, - hideTouches: true, - }); - assert.equal(recording.kind, 'recordingStarted'); - - const trace = await device.recording.trace({ - action: 'stop', - out: { kind: 'path', path: '/tmp/out.trace' }, - }); - assert.equal(trace.kind, 'traceStopped'); - - assert.deepEqual(calls, [ - { - command: 'startRecording', - options: { outPath: '/tmp/out.mp4', fps: 30, quality: 7, showTouches: false }, - }, - { command: 'stopTrace', options: { outPath: '/tmp/out.trace' } }, - ]); -}); - -test('record output paths are policy-gated', async () => { - const device = createAgentDevice({ - backend: { - platform: 'ios', - startRecording: async () => ({ path: '/tmp/recording.mp4' }), - }, - artifacts, - policy: restrictedCommandPolicy(), - }); - - await assert.rejects( - () => - device.recording.record({ - action: 'start', - out: { kind: 'path', path: '/tmp/out.mp4' }, - }), - /Local output paths are not allowed/, - ); -}); - -test('record keeps successful reserved outputs available after publish', async () => { - let cleanupCalled = false; - const device = createAgentDevice({ - backend: { - platform: 'ios', - startRecording: async (_context, options) => ({ path: options?.outPath }), - }, - artifacts: { - ...artifacts, - reserveOutput: async (_ref, options) => ({ - path: `/tmp/${options.field}${options.ext}`, - visibility: options.visibility ?? 'client-visible', - publish: async () => ({ - kind: 'artifact', - field: options.field, - artifactId: 'recording-1', - fileName: 'recording.mp4', - }), - cleanup: async () => { - cleanupCalled = true; - }, - }), - }, - policy: restrictedCommandPolicy(), - }); - - const result = await device.recording.record({ - action: 'start', - out: { kind: 'downloadableArtifact', fileName: 'recording.mp4' }, - }); - - assert.equal(result.artifact?.kind, 'artifact'); - assert.equal(cleanupCalled, false); -}); - function createAdminBackend( calls: string[], onInstallSource?: (source: BackendInstallSource) => void, diff --git a/src/commands/admin.ts b/src/commands/management/runtime/admin.ts similarity index 95% rename from src/commands/admin.ts rename to src/commands/management/runtime/admin.ts index 7015278f6..ae018301f 100644 --- a/src/commands/admin.ts +++ b/src/commands/management/runtime/admin.ts @@ -5,19 +5,19 @@ import type { BackendDeviceTarget, BackendInstallResult, BackendInstallSource, -} from '../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; +} from '../../../backend.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; import { toBackendResult, type BackendResultEnvelope, type BackendResultVariant, type RuntimeCommand, -} from './runtime-types.ts'; -import { resolveCommandInput } from './io-policy.ts'; -import { toBackendContext } from './selector-read-utils.ts'; -import { normalizeOptionalText, requireText } from './text.ts'; +} from '../../runtime-types.ts'; +import { resolveCommandInput } from '../../io-policy.ts'; +import { toBackendContext } from '../../runtime-common.ts'; +import { normalizeOptionalText, requireText } from '../../text.ts'; export type AdminDevicesCommandOptions = CommandContext & { filter?: BackendDeviceFilter; @@ -265,6 +265,7 @@ function normalizeDeviceTarget( return Object.keys(normalized).length > 0 ? normalized : undefined; } +// fallow-ignore-next-line complexity function formatInstallResult( mode: 'install' | 'reinstall' | 'installFromSource', app: string | undefined, diff --git a/src/__tests__/runtime-apps.test.ts b/src/commands/management/runtime/apps.test.ts similarity index 96% rename from src/__tests__/runtime-apps.test.ts rename to src/commands/management/runtime/apps.test.ts index ecf372e82..afc349450 100644 --- a/src/__tests__/runtime-apps.test.ts +++ b/src/commands/management/runtime/apps.test.ts @@ -5,9 +5,13 @@ import type { BackendAppEvent, BackendOpenTarget, BackendPushInput, -} from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, localCommandPolicy, restrictedCommandPolicy } from '../runtime.ts'; +} from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + restrictedCommandPolicy, +} from '../../../runtime.ts'; test('runtime app commands call typed backend lifecycle primitives', async () => { const calls: unknown[] = []; diff --git a/src/commands/apps.ts b/src/commands/management/runtime/apps.ts similarity index 95% rename from src/commands/apps.ts rename to src/commands/management/runtime/apps.ts index 25833982d..b78f22f7f 100644 --- a/src/commands/apps.ts +++ b/src/commands/management/runtime/apps.ts @@ -5,19 +5,19 @@ import type { BackendCommandContext, BackendOpenTarget, BackendPushInput, -} from '../backend.ts'; -import type { FileInputRef } from '../io.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { assertResolvedAppsFilter } from './app-inventory-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; -import { resolveCommandInput } from './io-policy.ts'; +} from '../../../backend.ts'; +import type { FileInputRef } from '../../../io.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { assertResolvedAppsFilter } from '../app-inventory-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { resolveCommandInput } from '../../io-policy.ts'; import { toBackendResult, type BackendResultEnvelope, type RuntimeCommand, -} from './runtime-types.ts'; -import { normalizeOptionalText, requireText } from './text.ts'; +} from '../../runtime-types.ts'; +import { normalizeOptionalText, requireText } from '../../text.ts'; const APP_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/; const MAX_APP_EVENT_PAYLOAD_BYTES = 8 * 1024; diff --git a/src/commands/management/runtime/index.ts b/src/commands/management/runtime/index.ts new file mode 100644 index 000000000..53e719eb6 --- /dev/null +++ b/src/commands/management/runtime/index.ts @@ -0,0 +1,127 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { BoundRuntimeCommand, RuntimeCommand } from '../../runtime-types.ts'; +import { resolveAppsFilter } from '../app-inventory-contract.ts'; +import { + bootCommand, + devicesCommand, + installCommand, + installFromSourceCommand, + reinstallCommand, + shutdownCommand, + type AdminBootCommandOptions, + type AdminBootCommandResult, + type AdminDevicesCommandOptions, + type AdminDevicesCommandResult, + type AdminInstallCommandOptions, + type AdminInstallCommandResult, + type AdminInstallFromSourceCommandOptions, + type AdminReinstallCommandOptions, + type AdminShutdownCommandOptions, + type AdminShutdownCommandResult, +} from './admin.ts'; +import { + closeAppCommand, + getAppStateCommand, + listAppsCommand, + openAppCommand, + pushAppCommand, + triggerAppEventCommand, + type CloseAppCommandOptions, + type CloseAppCommandResult, + type GetAppStateCommandOptions, + type GetAppStateCommandResult, + type ListAppsCommandOptions, + type ListAppsCommandResult, + type OpenAppCommandOptions, + type OpenAppCommandResult, + type PushAppCommandOptions, + type PushAppCommandResult, + type TriggerAppEventCommandOptions, + type TriggerAppEventCommandResult, +} from './apps.ts'; + +export type AppCommands = { + open: RuntimeCommand; + close: RuntimeCommand; + list: RuntimeCommand; + state: RuntimeCommand; + push: RuntimeCommand; + triggerEvent: RuntimeCommand; +}; + +export type AdminCommands = { + devices: RuntimeCommand; + boot: RuntimeCommand; + shutdown: RuntimeCommand; + install: RuntimeCommand; + reinstall: RuntimeCommand; + installFromSource: RuntimeCommand< + AdminInstallFromSourceCommandOptions, + AdminInstallCommandResult + >; +}; + +export type BoundAppCommands = { + open: BoundRuntimeCommand; + close: (options?: CloseAppCommandOptions) => Promise; + list: (options?: ListAppsCommandOptions) => Promise; + state: BoundRuntimeCommand; + push: BoundRuntimeCommand; + triggerEvent: BoundRuntimeCommand; +}; + +export type BoundAdminCommands = { + devices: (options?: AdminDevicesCommandOptions) => Promise; + boot: (options?: AdminBootCommandOptions) => Promise; + shutdown: (options?: AdminShutdownCommandOptions) => Promise; + install: BoundRuntimeCommand; + reinstall: BoundRuntimeCommand; + installFromSource: BoundRuntimeCommand< + AdminInstallFromSourceCommandOptions, + AdminInstallCommandResult + >; +}; + +export const appCommands: AppCommands = { + open: openAppCommand, + close: closeAppCommand, + list: listAppsCommand, + state: getAppStateCommand, + push: pushAppCommand, + triggerEvent: triggerAppEventCommand, +}; + +export const adminCommands: AdminCommands = { + devices: devicesCommand, + boot: bootCommand, + shutdown: shutdownCommand, + install: installCommand, + reinstall: reinstallCommand, + installFromSource: installFromSourceCommand, +}; + +export function bindAppCommands(runtime: AgentDeviceRuntime): BoundAppCommands { + return { + open: (options) => appCommands.open(runtime, options), + close: (options) => appCommands.close(runtime, options), + list: (options = {}) => + appCommands.list(runtime, { + ...options, + filter: resolveAppsFilter(options.filter), + }), + state: (options) => appCommands.state(runtime, options), + push: (options) => appCommands.push(runtime, options), + triggerEvent: (options) => appCommands.triggerEvent(runtime, options), + }; +} + +export function bindAdminCommands(runtime: AgentDeviceRuntime): BoundAdminCommands { + return { + devices: (options) => adminCommands.devices(runtime, options), + boot: (options) => adminCommands.boot(runtime, options), + shutdown: (options) => adminCommands.shutdown(runtime, options), + install: (options) => adminCommands.install(runtime, options), + reinstall: (options) => adminCommands.reinstall(runtime, options), + installFromSource: (options) => adminCommands.installFromSource(runtime, options), + }; +} diff --git a/src/commands/metro/index.test.ts b/src/commands/metro/index.test.ts new file mode 100644 index 000000000..53bac71db --- /dev/null +++ b/src/commands/metro/index.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { metroCliReader, metroCommandDefinition, metroCommandMetadata } from './index.ts'; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('metro command interface', () => { + test('owns public metadata', () => { + expect(metroCommandMetadata.name).toBe('metro'); + expect(metroCommandDefinition.name).toBe('metro'); + }); + + test('reads prepare input from flags', () => { + expect( + metroCliReader( + ['prepare'], + flags({ + metroProjectRoot: './apps/demo', + metroPublicBaseUrl: 'https://public.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + metroBearerToken: 'secret', + metroPreparePort: 9090, + metroListenHost: '127.0.0.1', + metroStatusHost: 'localhost', + metroStartupTimeoutMs: 30_000, + metroProbeTimeoutMs: 1_500, + metroRuntimeFile: './runtime.json', + metroNoReuseExisting: true, + metroNoInstallDeps: true, + kind: 'expo', + tenant: 'tenant-a', + runId: 'run-a', + leaseId: 'lease-a', + }), + ), + ).toMatchObject({ + action: 'prepare', + projectRoot: './apps/demo', + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + bearerToken: 'secret', + port: 9090, + listenHost: '127.0.0.1', + statusHost: 'localhost', + startupTimeoutMs: 30_000, + probeTimeoutMs: 1_500, + runtimeFilePath: './runtime.json', + reuseExisting: false, + installDependenciesIfNeeded: false, + kind: 'expo', + bridgeScope: { + tenantId: 'tenant-a', + runId: 'run-a', + leaseId: 'lease-a', + }, + }); + }); + + test('reads reload input from flags', () => { + expect( + metroCliReader( + ['reload'], + flags({ + metroHost: '127.0.0.1', + metroPort: 9090, + bundleUrl: 'http://localhost:9090/index.bundle', + metroProbeTimeoutMs: 1_500, + }), + ), + ).toEqual({ + action: 'reload', + metroHost: '127.0.0.1', + metroPort: 9090, + bundleUrl: 'http://localhost:9090/index.bundle', + timeoutMs: 1_500, + }); + }); + + test('rejects invalid metro input', () => { + expectInvalidArgs(() => metroCliReader(['start'], flags()), 'metro requires a subcommand'); + expectInvalidArgs( + () => metroCliReader(['prepare'], flags()), + 'metro prepare requires --public-base-url', + ); + expectInvalidArgs( + () => metroCliReader(['prepare'], flags({ metroPublicBaseUrl: 'https://x', kind: 'web' })), + 'metro prepare --kind must be', + ); + }); +}); diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts new file mode 100644 index 000000000..3c56a56e4 --- /dev/null +++ b/src/commands/metro/index.ts @@ -0,0 +1,179 @@ +import type { MetroPrepareKind } from '../../client-metro.ts'; +import type { + MetroPrepareOptions, + MetroPrepareResult, + MetroReloadOptions, + MetroReloadResult, +} from '../../client-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { + booleanField, + enumField, + integerField, + jsonSchemaField, + requiredField, + stringField, + stringSchema, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import type { CliReader } from '../cli-grammar/types.ts'; +import { METRO_PREPARE_FLAGS, METRO_RELOAD_FLAGS } from '../../utils/cli-flags.ts'; + +const METRO_COMMAND_NAME = 'metro'; +const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; + +const metroCommandDescription = 'Prepare Metro runtime or reload React Native apps.'; + +export const metroCommandMetadata = defineFieldCommandMetadata( + METRO_COMMAND_NAME, + metroCommandDescription, + { + 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(), + }, +); + +type MetroInput = { action: 'prepare' | 'reload' } & MetroPrepareOptions & MetroReloadOptions; + +export const metroCommandDefinition = defineExecutableCommand( + metroCommandMetadata, + async (client, input): Promise => + input.action === 'prepare' + ? await client.metro.prepare(toMetroPrepareOptions(input)) + : await client.metro.reload(toMetroReloadOptions(input)), +); + +const metroCliSchema = { + usageOverride: + 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', + listUsageOverride: 'metro prepare --public-base-url | --proxy-base-url ; metro reload', + helpDescription: + 'Prepare a local Metro runtime or ask Metro to reload connected React Native apps', + summary: 'Prepare Metro or reload apps', + positionalArgs: ['prepare|reload'], + allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], +} as const satisfies CommandSchemaOverride; + +export const metroCliSchemas = { + [METRO_COMMAND_NAME]: metroCliSchema, +} as const satisfies Record; + +export const metroCliReader: CliReader = (positionals, flags) => { + const action = (positionals[0] ?? '').toLowerCase(); + if (action !== 'prepare' && action !== 'reload') { + throw new AppError('INVALID_ARGS', 'metro requires a subcommand: prepare or reload'); + } + if (action === 'reload') { + return { + action, + metroHost: flags.metroHost, + metroPort: flags.metroPort, + bundleUrl: flags.bundleUrl, + timeoutMs: flags.metroProbeTimeoutMs, + }; + } + if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'metro prepare requires --public-base-url or --proxy-base-url .', + ); + } + return { + action, + projectRoot: flags.metroProjectRoot, + kind: readMetroPrepareKind(flags.kind ?? flags.metroKind), + port: flags.metroPreparePort, + listenHost: flags.metroListenHost, + statusHost: flags.metroStatusHost, + publicBaseUrl: flags.metroPublicBaseUrl, + proxyBaseUrl: flags.metroProxyBaseUrl, + bearerToken: flags.metroBearerToken, + bridgeScope: + flags.tenant && flags.runId && flags.leaseId + ? { + tenantId: flags.tenant, + runId: flags.runId, + leaseId: flags.leaseId, + } + : undefined, + startupTimeoutMs: flags.metroStartupTimeoutMs, + probeTimeoutMs: flags.metroProbeTimeoutMs, + reuseExisting: flags.metroNoReuseExisting ? false : undefined, + installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, + runtimeFilePath: flags.metroRuntimeFile, + }; +}; + +export const metroCliReaders = { + metro: metroCliReader, +} satisfies Record; + +function toMetroPrepareOptions(input: MetroInput): MetroPrepareOptions { + return { + projectRoot: input.projectRoot, + kind: input.kind, + publicBaseUrl: input.publicBaseUrl, + proxyBaseUrl: input.proxyBaseUrl, + bearerToken: input.bearerToken, + bridgeScope: input.bridgeScope ?? metroBridgeScopeFromInput(input), + port: input.port, + listenHost: input.listenHost, + statusHost: input.statusHost, + startupTimeoutMs: input.startupTimeoutMs, + probeTimeoutMs: input.probeTimeoutMs, + reuseExisting: input.reuseExisting, + installDependenciesIfNeeded: input.installDependenciesIfNeeded, + runtimeFilePath: input.runtimeFilePath, + }; +} + +function metroBridgeScopeFromInput( + input: MetroInput & { + tenant?: string; + runId?: string; + leaseId?: string; + }, +): MetroPrepareOptions['bridgeScope'] { + return input.tenant && input.runId && input.leaseId + ? { tenantId: input.tenant, runId: input.runId, leaseId: input.leaseId } + : undefined; +} + +function toMetroReloadOptions(input: MetroInput): MetroReloadOptions { + return { + metroHost: input.metroHost, + metroPort: input.metroPort, + bundleUrl: input.bundleUrl, + timeoutMs: input.timeoutMs, + }; +} + +function readMetroPrepareKind(value: string | undefined): MetroPrepareKind | undefined { + if (value === undefined) return undefined; + if (value === 'auto' || value === 'react-native' || value === 'expo') return value; + throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo'); +} diff --git a/src/commands/metro/output.ts b/src/commands/metro/output.ts new file mode 100644 index 000000000..684c2fc9b --- /dev/null +++ b/src/commands/metro/output.ts @@ -0,0 +1,17 @@ +import type { CliOutput } from '../command-contract.ts'; +import type { CliOutputFormatter } from '../output-common.ts'; + +function metroCliOutput(params: { result: unknown; action?: string }): CliOutput { + return { + data: params.result, + text: + params.action === 'reload' + ? `Reloaded React Native apps via ${(params.result as { reloadUrl?: unknown }).reloadUrl}` + : JSON.stringify(params.result, null, 2), + }; +} + +export const metroCliOutputFormatters = { + metro: ({ input, result }) => + metroCliOutput({ result, action: input.action as string | undefined }), +} as const satisfies Record; diff --git a/src/commands/observability/index.test.ts b/src/commands/observability/index.test.ts new file mode 100644 index 000000000..c01479431 --- /dev/null +++ b/src/commands/observability/index.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + logsCliReader, + logsCommandDefinition, + logsCommandMetadata, + logsDaemonWriter, + networkCliReader, + networkCommandDefinition, + networkCommandMetadata, + networkDaemonWriter, + perfCliReader, + perfCommandDefinition, + perfCommandMetadata, + perfDaemonWriter, +} from './index.ts'; + +const NO_FLAGS = {} as CliFlags; + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('observability command interface', () => { + test('owns perf, logs, and network public metadata', () => { + expect(perfCommandMetadata.name).toBe('perf'); + expect(perfCommandDefinition.name).toBe('perf'); + expect(logsCommandMetadata.name).toBe('logs'); + expect(logsCommandDefinition.name).toBe('logs'); + expect(networkCommandMetadata.name).toBe('network'); + expect(networkCommandDefinition.name).toBe('network'); + }); + + test('reads perf area, action, kind, and out flags', () => { + expect( + perfCliReader(['memory', 'snapshot'], { + kind: 'android-hprof', + out: './heap.hprof', + } as CliFlags), + ).toEqual({ + area: 'memory', + action: 'snapshot', + kind: 'android-hprof', + out: './heap.hprof', + }); + }); + + test('treats a single perf action as metrics action', () => { + expect(perfCliReader(['sample'], NO_FLAGS)).toEqual({ + action: 'sample', + kind: undefined, + out: undefined, + }); + expect(perfDaemonWriter({ action: 'sample' })).toMatchObject({ + command: 'perf', + positionals: ['metrics', 'sample'], + }); + }); + + test('reads logs action and message', () => { + expect(logsCliReader(['mark', 'checkout', 'started'], NO_FLAGS)).toEqual({ + action: 'mark', + message: 'checkout started', + restart: undefined, + }); + expect(logsDaemonWriter({ action: 'mark', message: 'checkout started' })).toMatchObject({ + command: 'logs', + positionals: ['mark', 'checkout started'], + }); + }); + + test('reads network include from flag or positional', () => { + expect(networkCliReader(['dump', '25', 'headers'], NO_FLAGS)).toEqual({ + action: 'dump', + limit: 25, + include: 'headers', + }); + expect( + networkCliReader(['dump', '25', 'headers'], { networkInclude: 'all' } as CliFlags), + ).toMatchObject({ + include: 'all', + }); + }); + + test('writes network include as daemon flag', () => { + expect(networkDaemonWriter({ action: 'dump', limit: 25, include: 'body' })).toMatchObject({ + command: 'network', + positionals: ['dump', '25'], + options: { networkInclude: 'body' }, + }); + }); + + test('rejects invalid observability positionals', () => { + expectInvalidArgs(() => perfCliReader(['memory', 'explode'], NO_FLAGS), 'perf action'); + expectInvalidArgs(() => logsCliReader(['explode'], NO_FLAGS), 'logs requires'); + expectInvalidArgs(() => networkCliReader(['explode'], NO_FLAGS), 'network requires'); + expectInvalidArgs( + () => networkCliReader(['dump', '25', 'explode'], NO_FLAGS), + 'network include', + ); + }); +}); diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts new file mode 100644 index 000000000..0b15c278d --- /dev/null +++ b/src/commands/observability/index.ts @@ -0,0 +1,382 @@ +import type { LogsOptions, NetworkOptions, PerfOptions } from '../../client-types.ts'; +import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; +import { AppError } from '../../utils/errors.ts'; +import { parseStringMember } from '../../utils/string-enum.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { + booleanField, + enumField, + integerField, + requiredField, + stringField, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { LOG_ACTION_VALUES, type LogAction } from './log-command-contract.ts'; +import { + isPerfAction, + isPerfArea, + isPerfKind, + isPerfSubject, + PERF_ACTION_ERROR_MESSAGE, + PERF_ACTION_VALUES, + PERF_AREA_ERROR_MESSAGE, + PERF_AREA_VALUES, + PERF_KIND_ERROR_MESSAGE, + PERF_KIND_VALUES, + PERF_SUBJECT_ERROR_MESSAGE, + PERF_SUBJECT_VALUES, + type PerfAction, + type PerfArea, + type PerfKind, + type PerfSubject, +} from './perf-command-contract.ts'; +import { + commonInputFromFlags, + direct, + optionalCliNumber, + optionalNumber, + optionalString, + request, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +const PERF_COMMAND_NAME = 'perf'; +const LOGS_COMMAND_NAME = 'logs'; +const NETWORK_COMMAND_NAME = 'network'; +const DEBUG_COMMAND_NAME = 'debug'; +const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; +const DEBUG_ACTION_VALUES = ['symbols'] as const; + +const perfCommandDescription = 'Show session performance, frame health, and memory diagnostics.'; +const logsCommandDescription = 'Manage session app logs.'; +const networkCommandDescription = 'Show recent HTTP traffic.'; +const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.'; + +export const perfCommandMetadata = defineFieldCommandMetadata( + PERF_COMMAND_NAME, + perfCommandDescription, + { + area: enumField(PERF_AREA_VALUES), + subject: enumField(PERF_SUBJECT_VALUES), + action: enumField(PERF_ACTION_VALUES), + kind: enumField(PERF_KIND_VALUES), + template: stringField('xctrace template name, for example Time Profiler.'), + out: stringField('Output artifact path.'), + tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'), + }, +); + +export const logsCommandMetadata = defineFieldCommandMetadata( + LOGS_COMMAND_NAME, + logsCommandDescription, + { + action: enumField(LOG_ACTION_VALUES), + message: stringField(), + restart: booleanField(), + }, +); + +export const networkCommandMetadata = defineFieldCommandMetadata( + NETWORK_COMMAND_NAME, + networkCommandDescription, + { + action: enumField(NETWORK_ACTION_VALUES), + limit: integerField(), + include: enumField(NETWORK_INCLUDE_MODES), + }, +); + +const debugCommandMetadata = defineFieldCommandMetadata( + DEBUG_COMMAND_NAME, + debugCommandDescription, + { + action: requiredField(enumField(DEBUG_ACTION_VALUES)), + artifact: requiredField(stringField('Apple crash artifact path (.ips, .crash, or .log).')), + dsym: stringField('Path to a matching .dSYM bundle.'), + searchPath: stringField('Directory to scan for matching .dSYM bundles.'), + out: stringField('Output path for the symbolicated artifact.'), + }, +); + +export const observabilityCommandMetadata = [ + perfCommandMetadata, + logsCommandMetadata, + networkCommandMetadata, + debugCommandMetadata, +] as const; + +export const perfCommandDefinition = defineExecutableCommand(perfCommandMetadata, (client, input) => + client.observability.perf(input), +); + +export const logsCommandDefinition = defineExecutableCommand(logsCommandMetadata, (client, input) => + client.observability.logs(input), +); + +export const networkCommandDefinition = defineExecutableCommand( + networkCommandMetadata, + (client, input) => client.observability.network(input), +); + +const debugCommandDefinition = defineExecutableCommand(debugCommandMetadata, (client, input) => + client.debug.symbols(input), +); + +export const observabilityCommandDefinitions = [ + perfCommandDefinition, + logsCommandDefinition, + networkCommandDefinition, + debugCommandDefinition, +] as const; + +const perfCliSchema = { + usageOverride: + 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start|stop|report --kind simpleperf [--out ]\n agent-device perf trace start|stop --kind perfetto [--out ]', + listUsageOverride: + 'perf [metrics|frames|memory] | perf cpu profile start|stop|report | perf trace start|stop', + helpDescription: + 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, Apple xctrace artifacts, or Android native Simpleperf/Perfetto artifacts. Bare perf and metrics are aliases for perf metrics. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', + summary: 'Show performance metrics or collect native perf artifacts', + positionalArgs: ['area?', 'subjectOrAction?', 'action?'], + allowedFlags: ['kind', 'perfTemplate', 'out'], +} as const satisfies CommandSchemaOverride; + +const logsCliSchema = { + usageOverride: + 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', + helpDescription: 'Session app log info, start/stop streaming, diagnostics, and markers', + summary: 'Manage session app logs', + positionalArgs: ['path|start|stop|clear|doctor|mark', 'message?'], + allowsExtraPositionals: true, + allowedFlags: ['restart'], +} as const satisfies CommandSchemaOverride; + +const networkCliSchema = { + usageOverride: + 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', + helpDescription: 'Dump recent HTTP(s) traffic parsed from the session app log', + summary: 'Show recent HTTP traffic', + positionalArgs: ['dump|log', 'limit?', 'include?'], + allowedFlags: ['networkInclude'], +} as const satisfies CommandSchemaOverride; + +const debugCliSchema = { + usageOverride: + 'debug symbols --artifact (--dsym | --search-path ) [--out ]', + listUsageOverride: 'debug symbols --artifact --dsym ', + helpDescription: + 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.', + summary: 'Symbolicate Apple crash artifacts', + positionalArgs: ['symbols'], + allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], +} as const satisfies CommandSchemaOverride; + +export const observabilityCliSchemas = { + [PERF_COMMAND_NAME]: perfCliSchema, + [LOGS_COMMAND_NAME]: logsCliSchema, + [NETWORK_COMMAND_NAME]: networkCliSchema, + [DEBUG_COMMAND_NAME]: debugCliSchema, +} as const satisfies Record; + +export const perfCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readPerfPositionals(positionals, { + kind: readPerfKindFlag(flags.kind), + template: flags.perfTemplate, + out: flags.out, + }), +}); + +export const logsCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readLogsAction(positionals[0]), + message: positionals.slice(1).join(' ') || undefined, + restart: flags.restart, +}); + +export const networkCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readNetworkAction(positionals[0]), + limit: optionalCliNumber(positionals[1]), + include: flags.networkInclude ?? readNetworkInclude(positionals[2]), +}); + +const debugCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readDebugAction(positionals[0]), + artifact: flags.artifact, + dsym: flags.dsym, + searchPath: flags.searchPath, + out: flags.out, +}); + +export const observabilityCliReaders = { + perf: perfCliReader, + logs: logsCliReader, + network: networkCliReader, + debug: debugCliReader, +} satisfies Record; + +export const perfDaemonWriter: DaemonWriter = direct(PERF_COMMAND_NAME, (input) => + perfPositionals(input as PerfOptions), +); + +export const logsDaemonWriter: DaemonWriter = direct(LOGS_COMMAND_NAME, (input) => + logsPositionals(input as LogsOptions), +); + +export const networkDaemonWriter: DaemonWriter = (input) => + request(NETWORK_COMMAND_NAME, networkPositionals(input as NetworkOptions), { + ...input, + networkInclude: input.include, + }); + +export const observabilityDaemonWriters = { + perf: perfDaemonWriter, + logs: logsDaemonWriter, + network: networkDaemonWriter, +} satisfies Record; + +function perfPositionals(input: PerfOptions): string[] { + const area = input.area ?? (input.action ? 'metrics' : undefined); + if (area === 'cpu') { + return nativePerfPositionals( + [ + ...optionalString(area), + ...optionalString(input.subject), + ...optionalString(input.action), + ...optionalString(input.kind), + ], + input, + ); + } + if (area === 'trace') { + return nativePerfPositionals( + [...optionalString(area), ...optionalString(input.action), ...optionalString(input.kind)], + input, + ); + } + return [...optionalString(area), ...optionalString(input.action)]; +} + +function nativePerfPositionals(base: string[], input: PerfOptions): string[] { + const positionals = [...base]; + if (input.template || input.out || input.tracePath) { + positionals.push(input.template ?? ''); + } + if (input.out || input.tracePath) { + positionals.push(input.out ?? ''); + } + if (input.tracePath) { + positionals.push(input.tracePath); + } + return positionals; +} + +function readPerfPositionals( + positionals: string[], + flags: Pick = {}, +): Pick { + if (positionals[0] !== undefined && positionals[1] === undefined) { + const action = readPerfAction(positionals[0], { allowUndefined: true }); + if (action) return { action, kind: readPerfKind(flags.kind), out: flags.out }; + } + const area = readPerfArea(positionals[0]); + if (area === 'cpu') { + return { + area, + subject: readPerfSubject(positionals[1]), + action: readPerfAction(positionals[2]), + kind: readPerfKind(flags.kind), + template: flags.template, + out: flags.out, + }; + } + if (area === 'trace') { + return { + area, + action: readPerfAction(positionals[1]), + kind: readPerfKind(flags.kind), + template: flags.template, + out: flags.out, + }; + } + return { + area, + action: readPerfAction(positionals[1]), + kind: readPerfKind(flags.kind), + out: flags.out, + }; +} + +function logsPositionals(input: { action?: string; message?: string }): string[] { + return [input.action ?? 'path', ...optionalString(input.message)]; +} + +function networkPositionals(input: NetworkOptions): string[] { + return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)]; +} + +function readPerfArea(value: string | undefined): PerfArea | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfArea(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); +} + +function readPerfAction( + value: string | undefined, + options: { allowUndefined?: boolean } = {}, +): PerfAction | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfAction(normalized)) return normalized; + if (options.allowUndefined) return undefined; + throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); +} + +function readPerfSubject(value: string | undefined): PerfSubject { + const normalized = value?.toLowerCase(); + if (normalized !== undefined && isPerfSubject(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); +} + +function readPerfKind(value: string | undefined): PerfKind | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfKind(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); +} + +function readPerfKindFlag(value: unknown): PerfKind | undefined { + return typeof value === 'string' ? readPerfKind(value) : undefined; +} + +function readLogsAction(value: string | undefined): LogAction | undefined { + if (value === undefined) return undefined; + return parseStringMember(LOG_ACTION_VALUES, value, { + message: 'logs requires path, start, stop, doctor, mark, or clear', + }); +} + +function readNetworkAction(value: string | undefined): 'dump' | 'log' | undefined { + if (value === undefined) return undefined; + if (value === 'dump' || value === 'log') return value; + throw new AppError('INVALID_ARGS', 'network requires dump or log'); +} + +function readNetworkInclude(value: string | undefined): NetworkIncludeMode | undefined { + if (value === undefined) return undefined; + return parseStringMember(NETWORK_INCLUDE_MODES, value, { + message: 'network include must be summary, headers, body, or all', + }); +} + +function readDebugAction(value: string | undefined): 'symbols' { + if (value === 'symbols') return value; + throw new AppError( + 'INVALID_ARGS', + 'debug supports only symbols; use logs, network, perf, record, trace, or react-devtools for other diagnostics', + ); +} diff --git a/src/commands/observability/log-command-contract.ts b/src/commands/observability/log-command-contract.ts new file mode 100644 index 000000000..0c7fab38e --- /dev/null +++ b/src/commands/observability/log-command-contract.ts @@ -0,0 +1 @@ +export * from '../../contracts/logs.ts'; diff --git a/src/commands/runtime-output.ts b/src/commands/observability/output.ts similarity index 82% rename from src/commands/runtime-output.ts rename to src/commands/observability/output.ts index df167def7..3e880a518 100644 --- a/src/commands/runtime-output.ts +++ b/src/commands/observability/output.ts @@ -1,24 +1,13 @@ -import type { CommandRequestResult } from '../client-types.ts'; -import { readCommandMessage } from '../utils/success-text.ts'; -import type { CliOutput } from './command-contract.ts'; - -export function batchCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const total = typeof data.total === 'number' ? data.total : 0; - const executed = typeof data.executed === 'number' ? data.executed : 0; - const durationMs = typeof data.totalDurationMs === 'number' ? data.totalDurationMs : undefined; - const lines = [ - `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}`, - ]; - const results = Array.isArray(data.results) ? data.results : []; - for (const entry of results) { - const line = renderBatchStepLine(entry); - if (line) lines.push(line); - } - return { data, text: lines.join('\n') }; -} - -export function logsCliOutput(result: CommandRequestResult): CliOutput { +import type { CommandRequestResult, DebugSymbolsResult } from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { + readRecord, + readRecordArray, + resultOutput, + type CliOutputFormatter, +} from '../output-common.ts'; + +function logsCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const pathOut = typeof data.path === 'string' ? data.path : ''; return { @@ -33,7 +22,7 @@ export function logsCliOutput(result: CommandRequestResult): CliOutput { }; } -export function networkCliOutput(result: CommandRequestResult): CliOutput { +function networkCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const lines: string[] = []; const pathOut = typeof data.path === 'string' ? data.path : ''; @@ -63,35 +52,45 @@ export function networkCliOutput(result: CommandRequestResult): CliOutput { }; } -export function perfCliOutput(result: CommandRequestResult): CliOutput { +function perfCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; return { data, text: formatPerfCliOutput(data) }; } -function renderBatchStepLine(entry: unknown): string | undefined { - const result = readRecord(entry); - if (!result) return undefined; - const step = typeof result.step === 'number' ? result.step : undefined; - const command = typeof result.command === 'string' ? result.command : 'step'; - const stepOk = result.ok !== false; - const description = readBatchStepDescription(result, stepOk, command); - const prefix = step !== undefined ? `${step}. ` : '- '; - const durationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; - const durationSuffix = durationMs !== undefined ? ` (${durationMs}ms)` : ''; - return `${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}`; +function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { + const lines = [result.outPath, result.message]; + lines.push(...formatDebugCrashSummary(result)); + for (const image of result.matchedImages) { + lines.push(`Matched: ${image.name} ${image.uuid}${image.arch ? ` ${image.arch}` : ''}`); + } + for (const warning of result.warnings ?? []) { + lines.push(`Warning: ${warning}`); + } + return { data: result, text: lines.join('\n') }; } -function readBatchStepDescription( - result: Record, - stepOk: boolean, - command: string, -): string { - if (stepOk) return readCommandMessage(readRecord(result.data)) ?? command; - return readBatchStepFailure(readRecord(result.error)) ?? command; -} +export const observabilityCliOutputFormatters = { + perf: resultOutput(perfCliOutput), + logs: resultOutput(logsCliOutput), + network: resultOutput(networkCliOutput), + debug: resultOutput(debugSymbolsCliOutput), +} as const satisfies Record; -function readBatchStepFailure(error: Record | undefined): string | null { - return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; +function formatDebugCrashSummary(result: DebugSymbolsResult): string[] { + const crash = result.crash; + const lines = [ + `Crash: ${crash.appName ?? 'unknown app'}${crash.crashedThread === undefined ? '' : ` thread ${crash.crashedThread}`}`, + ]; + if (crash.bundleId) lines.push(`Bundle: ${crash.bundleId}`); + if (crash.exceptionType) lines.push(`Exception: ${crash.exceptionType}`); + if (crash.terminationReason) lines.push(`Termination: ${crash.terminationReason}`); + for (const frame of crash.topFrames) { + lines.push(`Frame ${frame.index}: ${frame.image} ${frame.symbol ?? frame.address}`); + } + for (const finding of crash.findings) { + lines.push(`Finding: ${finding}`); + } + return lines; } function formatActionFields(data: Record): string | undefined { @@ -351,21 +350,6 @@ function formatMemoryPerfSummary(memory: Record | undefined): s return memoryKb !== undefined ? `memory ${formatMemoryKb(memoryKb)}` : undefined; } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readRecordArray(value: unknown): Array> { - return Array.isArray(value) - ? value.filter( - (entry): entry is Record => - Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), - ) - : []; -} - function readFiniteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } diff --git a/src/commands/observability/perf-command-contract.ts b/src/commands/observability/perf-command-contract.ts new file mode 100644 index 000000000..d69a17cb5 --- /dev/null +++ b/src/commands/observability/perf-command-contract.ts @@ -0,0 +1 @@ +export * from '../../contracts/perf.ts'; diff --git a/src/commands/diagnostics-format.ts b/src/commands/observability/runtime/diagnostics-format.ts similarity index 98% rename from src/commands/diagnostics-format.ts rename to src/commands/observability/runtime/diagnostics-format.ts index 1e0e9f9a7..f2e764a5c 100644 --- a/src/commands/diagnostics-format.ts +++ b/src/commands/observability/runtime/diagnostics-format.ts @@ -3,7 +3,7 @@ import type { BackendMeasurePerfResult, BackendNetworkIncludeMode, BackendReadLogsResult, -} from '../backend.ts'; +} from '../../../backend.ts'; import type { DiagnosticsLogsCommandResult, DiagnosticsNetworkCommandResult, @@ -12,7 +12,7 @@ import type { import { redactNetworkLogText as redactText, redactNetworkUrl, -} from '../observability-redaction.ts'; +} from '../../../observability-redaction.ts'; const PAYLOAD_MAX_CHARS = 2048; const MESSAGE_MAX_CHARS = 4096; @@ -48,6 +48,7 @@ export function formatNetworkResult( include: BackendNetworkIncludeMode, ): DiagnosticsNetworkCommandResult { let redacted = result.redacted === true; + // fallow-ignore-next-line complexity const entries = result.entries.map((entry) => { const url = entry.url ? redactUrl(entry.url) : undefined; const requestHeaders = diff --git a/src/__tests__/runtime-diagnostics-router.test.ts b/src/commands/observability/runtime/diagnostics-router.test.ts similarity index 96% rename from src/__tests__/runtime-diagnostics-router.test.ts rename to src/commands/observability/runtime/diagnostics-router.test.ts index 28c78a30d..37acdd08f 100644 --- a/src/__tests__/runtime-diagnostics-router.test.ts +++ b/src/commands/observability/runtime/diagnostics-router.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendCommandContext } from '../backend.ts'; -import type { ArtifactAdapter } from '../io.ts'; +import type { AgentDeviceBackend, BackendCommandContext } from '../../../backend.ts'; +import type { ArtifactAdapter } from '../../../io.ts'; import { createAgentDevice, createMemorySessionStore, restrictedCommandPolicy, -} from '../runtime.ts'; +} from '../../../runtime.ts'; const artifacts = { resolveInput: async () => ({ path: '/tmp/input' }), @@ -22,6 +22,7 @@ const artifacts = { }), } satisfies ArtifactAdapter; +// fallow-ignore-next-line complexity test('diagnostics runtime commands call typed backend primitives and redact sensitive data', async () => { const contexts: BackendCommandContext[] = []; const device = createAgentDevice({ diff --git a/src/commands/diagnostics-types.ts b/src/commands/observability/runtime/diagnostics-types.ts similarity index 96% rename from src/commands/diagnostics-types.ts rename to src/commands/observability/runtime/diagnostics-types.ts index 073aa63f3..b0e01ce6c 100644 --- a/src/commands/diagnostics-types.ts +++ b/src/commands/observability/runtime/diagnostics-types.ts @@ -3,7 +3,7 @@ import type { BackendLogEntry, BackendNetworkEntry, BackendPerfMetric, -} from '../backend.ts'; +} from '../../../backend.ts'; export type DiagnosticsLogsCommandResult = { kind: 'diagnosticsLogs'; diff --git a/src/commands/diagnostics.ts b/src/commands/observability/runtime/diagnostics.ts similarity index 93% rename from src/commands/diagnostics.ts rename to src/commands/observability/runtime/diagnostics.ts index 77a24b9d6..fa0f0136d 100644 --- a/src/commands/diagnostics.ts +++ b/src/commands/observability/runtime/diagnostics.ts @@ -6,19 +6,19 @@ import type { BackendMeasurePerfOptions, BackendNetworkIncludeMode, BackendReadLogsOptions, -} from '../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { requireIntInRange } from '../utils/validation.ts'; +} from '../../../backend.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; import { formatLogsResult, formatNetworkResult, formatPerfResult } from './diagnostics-format.ts'; import type { DiagnosticsLogsCommandResult, DiagnosticsNetworkCommandResult, DiagnosticsPerfCommandResult, } from './diagnostics-types.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; -import { toBackendContext } from './selector-read-utils.ts'; -import { requireText } from './text.ts'; +import type { RuntimeCommand } from '../../runtime-types.ts'; +import { toBackendContext } from '../../runtime-common.ts'; +import { requireText } from '../../text.ts'; export type DiagnosticsPageOptions = CommandContext & { appId?: string; @@ -114,6 +114,7 @@ export const perfCommand: RuntimeCommand< return formatPerfResult(result); }; +// fallow-ignore-next-line complexity async function toDiagnosticsBackendContext( runtime: AgentDeviceRuntime, options: CommandContext & { appId?: string; appBundleId?: string }, diff --git a/src/commands/observability/runtime/index.ts b/src/commands/observability/runtime/index.ts new file mode 100644 index 000000000..150be884b --- /dev/null +++ b/src/commands/observability/runtime/index.ts @@ -0,0 +1,42 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { RuntimeCommand } from '../../runtime-types.ts'; +import { + logsCommand, + networkCommand, + perfCommand, + type DiagnosticsLogsCommandOptions, + type DiagnosticsLogsCommandResult, + type DiagnosticsNetworkCommandOptions, + type DiagnosticsNetworkCommandResult, + type DiagnosticsPerfCommandOptions, + type DiagnosticsPerfCommandResult, +} from './diagnostics.ts'; + +export type DiagnosticsCommands = { + logs: RuntimeCommand; + network: RuntimeCommand< + DiagnosticsNetworkCommandOptions | undefined, + DiagnosticsNetworkCommandResult + >; + perf: RuntimeCommand; +}; + +export type BoundObservabilityCommands = { + logs: (options?: DiagnosticsLogsCommandOptions) => Promise; + network: (options?: DiagnosticsNetworkCommandOptions) => Promise; + perf: (options?: DiagnosticsPerfCommandOptions) => Promise; +}; + +export const diagnosticsCommands: DiagnosticsCommands = { + logs: logsCommand, + network: networkCommand, + perf: perfCommand, +}; + +export function bindObservabilityCommands(runtime: AgentDeviceRuntime): BoundObservabilityCommands { + return { + logs: (options) => diagnosticsCommands.logs(runtime, options), + network: (options) => diagnosticsCommands.network(runtime, options), + perf: (options) => diagnosticsCommands.perf(runtime, options), + }; +} diff --git a/src/commands/output-common.ts b/src/commands/output-common.ts new file mode 100644 index 000000000..42ff2e337 --- /dev/null +++ b/src/commands/output-common.ts @@ -0,0 +1,33 @@ +import { readCommandMessage } from '../utils/success-text.ts'; +import type { CliOutput } from './command-contract.ts'; + +export type CliOutputFormatter = (params: { + input: Record; + result: unknown; +}) => CliOutput; + +export function resultOutput( + formatter: (result: TResult) => CliOutput, +): CliOutputFormatter { + return ({ result }) => formatter(result as TResult); +} + +export const messageOutput = resultOutput(messageCliOutput); + +export function messageCliOutput(result: Record): CliOutput { + return { data: result, text: readCommandMessage(result) }; +} + +export function readRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function readRecordArray(value: unknown): Array> { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/commands/perf-command-contract.ts b/src/commands/perf-command-contract.ts deleted file mode 100644 index 20d3b97f2..000000000 --- a/src/commands/perf-command-contract.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../contracts/perf.ts'; diff --git a/src/commands/react-native/index.test.ts b/src/commands/react-native/index.test.ts new file mode 100644 index 000000000..59f0701bb --- /dev/null +++ b/src/commands/react-native/index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + reactNativeCliReader, + reactNativeCommandDefinition, + reactNativeCommandMetadata, + reactNativeDaemonWriter, +} from './index.ts'; + +const NO_FLAGS = {} as CliFlags; + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('react-native command interface', () => { + test('owns its public metadata', () => { + expect(reactNativeCommandMetadata.name).toBe('react-native'); + expect(reactNativeCommandDefinition.name).toBe('react-native'); + expect(reactNativeCommandMetadata.description).toContain('React Native'); + }); + + test('reads the dismiss-overlay CLI action', () => { + expect(reactNativeCliReader(['dismiss-overlay'], NO_FLAGS)).toEqual({ + action: 'dismiss-overlay', + }); + }); + + test('rejects unsupported CLI actions', () => { + expectInvalidArgs( + () => reactNativeCliReader(['reload'], NO_FLAGS), + 'react-native supports only', + ); + }); + + test('writes daemon request positionals', () => { + expect(reactNativeDaemonWriter({ action: 'dismiss-overlay' })).toMatchObject({ + command: 'react-native', + positionals: ['dismiss-overlay'], + options: { action: 'dismiss-overlay' }, + }); + }); + + test('rejects daemon request without action', () => { + expectInvalidArgs(() => reactNativeDaemonWriter({}), 'react-native requires action'); + }); +}); diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts new file mode 100644 index 000000000..b2b7c40e8 --- /dev/null +++ b/src/commands/react-native/index.ts @@ -0,0 +1,59 @@ +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { enumField, requiredField } from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { commonInputFromFlags, direct, requiredDaemonString } from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +const REACT_NATIVE_COMMAND_NAME = 'react-native'; +const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; + +const reactNativeCommandDescription = 'Run supported React Native app automation helpers.'; + +export const reactNativeCommandMetadata = defineFieldCommandMetadata( + REACT_NATIVE_COMMAND_NAME, + reactNativeCommandDescription, + { + action: requiredField(enumField(REACT_NATIVE_ACTION_VALUES)), + }, +); + +export const reactNativeCommandDefinition = defineExecutableCommand( + reactNativeCommandMetadata, + (client, input) => client.command.reactNative(input), +); + +const reactNativeCliSchema = { + usageOverride: 'react-native dismiss-overlay', + listUsageOverride: 'react-native dismiss-overlay', + positionalArgs: ['dismiss-overlay'], +} as const satisfies CommandSchemaOverride; + +export const reactNativeCliSchemas = { + [REACT_NATIVE_COMMAND_NAME]: reactNativeCliSchema, +} as const satisfies Record; + +export const reactNativeCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readReactNativeAction(positionals[0]), +}); + +export const reactNativeCliReaders = { + 'react-native': reactNativeCliReader, +} satisfies Record; + +export const reactNativeDaemonWriter: DaemonWriter = direct(REACT_NATIVE_COMMAND_NAME, (input) => [ + requiredDaemonString(input.action, 'react-native requires action'), +]); + +export const reactNativeDaemonWriters = { + 'react-native': reactNativeDaemonWriter, +} satisfies Record; + +function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { + if (value === 'dismiss-overlay') return value; + throw new AppError('INVALID_ARGS', 'react-native supports only: dismiss-overlay'); +} + +export * from './overlay.ts'; diff --git a/src/commands/recording/index.test.ts b/src/commands/recording/index.test.ts new file mode 100644 index 000000000..8b7f39dba --- /dev/null +++ b/src/commands/recording/index.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + recordCliReader, + recordCommandDefinition, + recordCommandMetadata, + recordDaemonWriter, + traceCliReader, + traceCommandDefinition, + traceCommandMetadata, + traceDaemonWriter, +} from './index.ts'; + +const NO_FLAGS = {} as CliFlags; + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('recording command interface', () => { + test('owns record and trace public metadata', () => { + expect(recordCommandMetadata.name).toBe('record'); + expect(recordCommandDefinition.name).toBe('record'); + expect(traceCommandMetadata.name).toBe('trace'); + expect(traceCommandDefinition.name).toBe('trace'); + }); + + test('reads record CLI input with recording flags', () => { + expect( + recordCliReader(['start', './capture.mp4'], { + fps: 30, + quality: 7, + hideTouches: true, + } as CliFlags), + ).toEqual({ + action: 'start', + path: './capture.mp4', + fps: 30, + quality: 7, + hideTouches: true, + }); + }); + + test('reads trace CLI input', () => { + expect(traceCliReader(['stop', './diagnostics.trace'], NO_FLAGS)).toEqual({ + action: 'stop', + path: './diagnostics.trace', + }); + }); + + test('rejects unsupported recording actions', () => { + expectInvalidArgs(() => recordCliReader(['pause'], NO_FLAGS), 'record requires start|stop'); + expectInvalidArgs(() => traceCliReader(['pause'], NO_FLAGS), 'trace requires start|stop'); + }); + + test('writes record and trace daemon request positionals', () => { + expect(recordDaemonWriter({ action: 'start', path: './capture.mp4' })).toMatchObject({ + command: 'record', + positionals: ['start', './capture.mp4'], + }); + expect(traceDaemonWriter({ action: 'stop', path: './diagnostics.trace' })).toMatchObject({ + command: 'trace', + positionals: ['stop', './diagnostics.trace'], + }); + }); +}); diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts new file mode 100644 index 000000000..538c264d0 --- /dev/null +++ b/src/commands/recording/index.ts @@ -0,0 +1,128 @@ +import type { RecordOptions } from '../../client-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + booleanField, + enumField, + integerField, + integerSchema, + jsonSchemaField, + requiredField, + stringField, +} from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { commonInputFromFlags, direct, optionalString } from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +const RECORD_COMMAND_NAME = 'record'; +const TRACE_COMMAND_NAME = 'trace'; +const RECORDING_ACTION_VALUES = ['start', 'stop'] as const; + +const recordCommandDescription = 'Start or stop screen recording.'; +const traceCommandDescription = 'Start or stop trace capture.'; + +export const recordCommandMetadata = defineFieldCommandMetadata( + RECORD_COMMAND_NAME, + recordCommandDescription, + { + action: requiredField(enumField(RECORDING_ACTION_VALUES)), + path: stringField(), + fps: integerField(), + quality: jsonSchemaField(integerSchema()), + hideTouches: booleanField(), + }, +); + +export const traceCommandMetadata = defineFieldCommandMetadata( + TRACE_COMMAND_NAME, + traceCommandDescription, + { + action: requiredField(enumField(RECORDING_ACTION_VALUES)), + path: stringField(), + }, +); + +export const recordingCommandMetadata = [recordCommandMetadata, traceCommandMetadata] as const; + +export const recordCommandDefinition = defineExecutableCommand( + recordCommandMetadata, + (client, input) => client.recording.record(input as RecordOptions), +); + +export const traceCommandDefinition = defineExecutableCommand( + traceCommandMetadata, + (client, input) => client.recording.trace(input), +); + +export const recordingCommandDefinitions = [ + recordCommandDefinition, + traceCommandDefinition, +] as const; + +const recordCliSchema = { + usageOverride: + 'record start [path] [--fps ] [--quality <5-10>] [--hide-touches] | record stop', + listUsageOverride: 'record start [path] | record stop', + helpDescription: + 'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks', + summary: 'Start or stop screen recording', + positionalArgs: ['start|stop', 'path?'], + allowedFlags: ['fps', 'quality', 'hideTouches'], +} as const satisfies CommandSchemaOverride; + +const traceCliSchema = { + usageOverride: 'trace start | trace stop ', + listUsageOverride: 'trace start | trace stop ', + helpDescription: + 'Start/stop trace log capture; when an artifact path is requested, pass the same positional path to start and stop', + summary: 'Start or stop trace capture', + positionalArgs: ['start|stop', 'path?'], +} as const satisfies CommandSchemaOverride; + +export const recordingCliSchemas = { + [RECORD_COMMAND_NAME]: recordCliSchema, + [TRACE_COMMAND_NAME]: traceCliSchema, +} as const satisfies Record; + +export const recordCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readRecordingAction(positionals[0], RECORD_COMMAND_NAME), + path: positionals[1], + fps: flags.fps, + quality: flags.quality as RecordOptions['quality'], + hideTouches: flags.hideTouches, +}); + +export const traceCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readRecordingAction(positionals[0], TRACE_COMMAND_NAME), + path: positionals[1], +}); + +export const recordingCliReaders = { + record: recordCliReader, + trace: traceCliReader, +} satisfies Record; + +export const recordDaemonWriter: DaemonWriter = direct(RECORD_COMMAND_NAME, (input) => + recordingPositionals(input as RecordOptions), +); + +export const traceDaemonWriter: DaemonWriter = direct(TRACE_COMMAND_NAME, (input) => + recordingPositionals(input as RecordOptions), +); + +export const recordingDaemonWriters = { + record: recordDaemonWriter, + trace: traceDaemonWriter, +} satisfies Record; + +function recordingPositionals(input: RecordOptions): string[] { + return [input.action, ...optionalString(input.path)]; +} + +function readRecordingAction(value: string | undefined, command: string): 'start' | 'stop' { + if (value === 'start' || value === 'stop') return value; + throw new AppError('INVALID_ARGS', `${command} requires start|stop`); +} diff --git a/src/commands/__tests__/client-output.test.ts b/src/commands/recording/output.test.ts similarity index 74% rename from src/commands/__tests__/client-output.test.ts rename to src/commands/recording/output.test.ts index e11c3cabf..10fcc6f03 100644 --- a/src/commands/__tests__/client-output.test.ts +++ b/src/commands/recording/output.test.ts @@ -1,23 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { openCliOutput, recordCliOutput } from '../client-output.ts'; - -describe('openCliOutput', () => { - test('prints session state directory on a second line', () => { - const output = openCliOutput({ - session: 'default', - sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default', - identifiers: { session: 'default' }, - }); - - expect(output.text).toBe( - ['Opened: default', 'Session state: /tmp/agent-device/sessions/cwd_123_default'].join('\n'), - ); - expect(output.data).toMatchObject({ - session: 'default', - sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default', - }); - }); -}); +import { recordCliOutput } from './output.ts'; describe('recordCliOutput', () => { test('prints session state directory for record-created sessions', () => { diff --git a/src/commands/recording/output.ts b/src/commands/recording/output.ts new file mode 100644 index 000000000..d36ed9c61 --- /dev/null +++ b/src/commands/recording/output.ts @@ -0,0 +1,55 @@ +import type { CommandRequestResult } from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; + +export function recordCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const outPath = typeof data.outPath === 'string' ? data.outPath : ''; + const chunks = readRecordingChunks(data); + if (chunks.length <= 1) { + return { data, text: formatRecordSingleOutput(data, outPath) }; + } + + const lines = ['Recording chunks:']; + for (const chunk of chunks) { + lines.push(` ${chunk.index}: ${chunk.path}`); + } + if (typeof data.telemetryPath === 'string') { + lines.push(`Telemetry: ${data.telemetryPath}`); + } + if (typeof data.warning === 'string') { + lines.push(`Warning: ${data.warning}`); + } + if (typeof data.overlayWarning === 'string') { + lines.push(`Overlay warning: ${data.overlayWarning}`); + } + return { data, text: lines.join('\n') }; +} + +export const recordingCliOutputFormatters = { + record: resultOutput(recordCliOutput), +} as const satisfies Record; + +function formatRecordSingleOutput(data: Record, outPath: string): string { + const lines: string[] = []; + if (outPath) lines.push(outPath); + if (typeof data.sessionStateDir === 'string') + lines.push(`Session state: ${data.sessionStateDir}`); + if (typeof data.warning === 'string') lines.push(`Warning: ${data.warning}`); + if (typeof data.overlayWarning === 'string') + lines.push(`Overlay warning: ${data.overlayWarning}`); + return lines.join('\n'); +} + +function readRecordingChunks( + data: Record, +): Array<{ index: number; path: string }> { + const rawChunks = data.chunks; + if (!Array.isArray(rawChunks)) return []; + return rawChunks.flatMap((chunk) => { + if (!chunk || typeof chunk !== 'object') return []; + const candidate = chunk as Record; + if (typeof candidate.index !== 'number' || typeof candidate.path !== 'string') return []; + return [{ index: candidate.index, path: candidate.path }]; + }); +} diff --git a/src/commands/recording/runtime/index.ts b/src/commands/recording/runtime/index.ts new file mode 100644 index 000000000..81b8240f5 --- /dev/null +++ b/src/commands/recording/runtime/index.ts @@ -0,0 +1,32 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { BoundRuntimeCommand, RuntimeCommand } from '../../runtime-types.ts'; +import { + recordCommand, + traceCommand, + type RecordingRecordCommandOptions, + type RecordingRecordCommandResult, + type RecordingTraceCommandOptions, + type RecordingTraceCommandResult, +} from './recording.ts'; + +export type RecordingCommands = { + record: RuntimeCommand; + trace: RuntimeCommand; +}; + +export type BoundRecordingCommands = { + record: BoundRuntimeCommand; + trace: BoundRuntimeCommand; +}; + +export const recordingCommands: RecordingCommands = { + record: recordCommand, + trace: traceCommand, +}; + +export function bindRecordingCommands(runtime: AgentDeviceRuntime): BoundRecordingCommands { + return { + record: (options) => recordingCommands.record(runtime, options), + trace: (options) => recordingCommands.trace(runtime, options), + }; +} diff --git a/src/commands/recording/runtime/recording.test.ts b/src/commands/recording/runtime/recording.test.ts new file mode 100644 index 000000000..d7216f2aa --- /dev/null +++ b/src/commands/recording/runtime/recording.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { ArtifactAdapter, FileInputRef } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + restrictedCommandPolicy, +} from '../../../runtime.ts'; + +const artifacts = { + resolveInput: async (ref: FileInputRef) => ({ + path: ref.kind === 'path' ? ref.path : `/tmp/uploaded/${ref.id}.app`, + cleanup: ref.kind === 'uploadedArtifact' ? async () => {} : undefined, + }), + reserveOutput: async (ref, options) => ({ + path: ref?.kind === 'path' ? ref.path : `/tmp/${options.field}${options.ext}`, + visibility: options.visibility ?? 'client-visible', + publish: async () => undefined, + }), + createTempFile: async (options) => ({ + path: `/tmp/${options.prefix}${options.ext}`, + visibility: 'internal', + cleanup: async () => {}, + }), +} satisfies ArtifactAdapter; + +test('record and trace runtime commands call typed backend lifecycle primitives', async () => { + const calls: unknown[] = []; + const device = createAgentDevice({ + backend: { + platform: 'ios', + startRecording: async (_context, options) => { + calls.push({ command: 'startRecording', options }); + return { path: options?.outPath ?? '/tmp/recording.mp4' }; + }, + stopTrace: async (_context, options) => { + calls.push({ command: 'stopTrace', options }); + return { outPath: options?.outPath ?? '/tmp/trace.log' }; + }, + }, + artifacts, + policy: localCommandPolicy(), + }); + + const recording = await device.recording.record({ + action: 'start', + out: { kind: 'path', path: '/tmp/out.mp4' }, + fps: 30, + quality: 7, + hideTouches: true, + }); + assert.equal(recording.kind, 'recordingStarted'); + + const trace = await device.recording.trace({ + action: 'stop', + out: { kind: 'path', path: '/tmp/out.trace' }, + }); + assert.equal(trace.kind, 'traceStopped'); + + assert.deepEqual(calls, [ + { + command: 'startRecording', + options: { outPath: '/tmp/out.mp4', fps: 30, quality: 7, showTouches: false }, + }, + { command: 'stopTrace', options: { outPath: '/tmp/out.trace' } }, + ]); +}); + +test('record output paths are policy-gated', async () => { + const device = createAgentDevice({ + backend: { + platform: 'ios', + startRecording: async () => ({ path: '/tmp/recording.mp4' }), + }, + artifacts, + policy: restrictedCommandPolicy(), + }); + + await assert.rejects( + () => + device.recording.record({ + action: 'start', + out: { kind: 'path', path: '/tmp/out.mp4' }, + }), + /Local output paths are not allowed/, + ); +}); + +test('record keeps successful reserved outputs available after publish', async () => { + let cleanupCalled = false; + const device = createAgentDevice({ + backend: { + platform: 'ios', + startRecording: async (_context, options) => ({ path: options?.outPath }), + }, + artifacts: { + ...artifacts, + reserveOutput: async (_ref, options) => ({ + path: `/tmp/${options.field}${options.ext}`, + visibility: options.visibility ?? 'client-visible', + publish: async () => ({ + kind: 'artifact', + field: options.field, + artifactId: 'recording-1', + fileName: 'recording.mp4', + }), + cleanup: async () => { + cleanupCalled = true; + }, + }), + }, + policy: restrictedCommandPolicy(), + }); + + const result = await device.recording.record({ + action: 'start', + out: { kind: 'downloadableArtifact', fileName: 'recording.mp4' }, + }); + + assert.equal(result.artifact?.kind, 'artifact'); + assert.equal(cleanupCalled, false); +}); diff --git a/src/commands/recording.ts b/src/commands/recording/runtime/recording.ts similarity index 91% rename from src/commands/recording.ts rename to src/commands/recording/runtime/recording.ts index 4d51bae13..8826aaad7 100644 --- a/src/commands/recording.ts +++ b/src/commands/recording/runtime/recording.ts @@ -3,19 +3,19 @@ import type { BackendRecordingResult, BackendTraceOptions, BackendTraceResult, -} from '../backend.ts'; -import type { ArtifactDescriptor, FileOutputRef } from '../io.ts'; -import type { CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; -import { requireIntInRange } from '../utils/validation.ts'; +} from '../../../backend.ts'; +import type { ArtifactDescriptor, FileOutputRef } from '../../../io.ts'; +import type { CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; import type { BackendResultEnvelope, BackendResultVariant, RuntimeCommand, -} from './runtime-types.ts'; -import { reserveCommandOutput } from './io-policy.ts'; -import { toBackendContext } from './selector-read-utils.ts'; +} from '../../runtime-types.ts'; +import { reserveCommandOutput } from '../../io-policy.ts'; +import { toBackendContext } from '../../runtime-common.ts'; export type RecordingRecordCommandOptions = CommandContext & { action: 'start' | 'stop'; diff --git a/src/commands/replay/index.test.ts b/src/commands/replay/index.test.ts new file mode 100644 index 000000000..bda3c1fda --- /dev/null +++ b/src/commands/replay/index.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + replayCliReader, + replayCommandDefinition, + replayCommandMetadata, + replayDaemonWriter, + testCliReader, + testCommandDefinition, + testCommandMetadata, + testDaemonWriter, +} from './index.ts'; + +const ORIGINAL_AD_VAR = process.env.AD_VAR_REPLAY_TEST; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +afterEach(() => { + if (ORIGINAL_AD_VAR === undefined) { + delete process.env.AD_VAR_REPLAY_TEST; + } else { + process.env.AD_VAR_REPLAY_TEST = ORIGINAL_AD_VAR; + } +}); + +describe('replay command interface', () => { + test('owns replay and test public metadata', () => { + expect(replayCommandMetadata.name).toBe('replay'); + expect(replayCommandDefinition.name).toBe('replay'); + expect(testCommandMetadata.name).toBe('test'); + expect(testCommandDefinition.name).toBe('test'); + }); + + test('reads replay CLI input', () => { + expect( + replayCliReader( + ['./checkout.ad'], + flags({ + replayUpdate: true, + replayMaestro: true, + replayEnv: ['FOO=bar'], + }), + ), + ).toEqual({ + path: './checkout.ad', + update: true, + backend: 'maestro', + env: ['FOO=bar'], + }); + }); + + test('rejects missing replay path', () => { + expectInvalidArgs(() => replayCliReader([], flags()), 'replay requires path'); + }); + + test('reads test CLI input', () => { + expect( + testCliReader( + ['./suite-a.ad', './suite-b.ad'], + flags({ + replayUpdate: true, + replayMaestro: true, + replayEnv: ['FOO=bar'], + failFast: true, + timeoutMs: 10_000, + retries: 2, + recordVideo: true, + artifactsDir: './artifacts', + reportJunit: './junit.xml', + shardAll: 4, + shardSplit: 2, + }), + ), + ).toMatchObject({ + paths: ['./suite-a.ad', './suite-b.ad'], + update: true, + backend: 'maestro', + env: ['FOO=bar'], + failFast: true, + timeoutMs: 10_000, + retries: 2, + recordVideo: true, + artifactsDir: './artifacts', + reportJunit: './junit.xml', + shardAll: 4, + shardSplit: 2, + }); + }); + + test('writes daemon replay and test requests with replay flags', () => { + process.env.AD_VAR_REPLAY_TEST = 'enabled'; + expect( + replayDaemonWriter({ + path: './checkout.ad', + update: true, + backend: 'maestro', + env: ['FOO=bar'], + }), + ).toMatchObject({ + command: 'replay', + positionals: ['./checkout.ad'], + options: { + replayUpdate: true, + replayBackend: 'maestro', + replayEnv: ['FOO=bar'], + replayShellEnv: { AD_VAR_REPLAY_TEST: 'enabled' }, + }, + }); + + expect(testDaemonWriter({ paths: ['./suite.ad'], maestro: true })).toMatchObject({ + command: 'test', + positionals: ['./suite.ad'], + options: { + replayBackend: 'maestro', + }, + }); + }); +}); diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts new file mode 100644 index 000000000..d6ac9dcc9 --- /dev/null +++ b/src/commands/replay/index.ts @@ -0,0 +1,168 @@ +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + booleanField, + integerField, + requiredField, + stringArrayField, + stringField, +} from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { + commonInputFromFlags, + request, + requiredDaemonString, + requiredString, +} from '../cli-grammar/common.ts'; +import type { CliReader, CommandInput, DaemonWriter } from '../cli-grammar/types.ts'; +import { REPLAY_FLAGS } from '../../utils/cli-flags.ts'; + +const REPLAY_COMMAND_NAME = 'replay'; +const TEST_COMMAND_NAME = 'test'; + +const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; + +const replayCommandDescription = 'Replay a recorded session.'; +const testCommandDescription = 'Run one or more replay scripts.'; + +export const replayCommandMetadata = defineFieldCommandMetadata( + REPLAY_COMMAND_NAME, + replayCommandDescription, + { + path: requiredField(stringField()), + update: booleanField(), + backend: stringField(), + maestro: booleanField(), + env: stringArrayField(), + }, +); + +export const testCommandMetadata = defineFieldCommandMetadata( + TEST_COMMAND_NAME, + testCommandDescription, + { + paths: requiredField(stringArrayField()), + update: booleanField(), + backend: stringField(), + maestro: booleanField(), + env: stringArrayField(), + failFast: booleanField(), + timeoutMs: integerField(), + retries: integerField(), + recordVideo: booleanField(), + artifactsDir: stringField(), + reportJunit: stringField(), + shardAll: integerField(), + shardSplit: integerField(), + }, +); + +export const replayCommandMetadataList = [replayCommandMetadata, testCommandMetadata] as const; + +export const replayCommandDefinition = defineExecutableCommand( + replayCommandMetadata, + (client, input) => client.replay.run(input), +); + +export const testCommandDefinition = defineExecutableCommand(testCommandMetadata, (client, input) => + client.replay.test(input), +); + +export const replayCommandDefinitions = [replayCommandDefinition, testCommandDefinition] as const; + +const replayCliSchema = { + usageOverride: 'replay | replay export [--format maestro] [--out ]', + positionalArgs: ['path'], + allowsExtraPositionals: true, + allowedFlags: ['replayMaestro', 'replayExportFormat', ...REPLAY_FLAGS, 'timeoutMs', 'out'], +} as const satisfies CommandSchemaOverride; + +const testCliSchema = { + usageOverride: 'test ...', + listUsageOverride: 'test ...', + helpDescription: 'Run one or more replay scripts as a serial test suite', + summary: 'Run replay test suites', + positionalArgs: ['pathOrGlob'], + allowsExtraPositionals: true, + allowedFlags: [ + 'replayMaestro', + ...REPLAY_FLAGS, + 'failFast', + 'timeoutMs', + 'retries', + 'recordVideo', + 'artifactsDir', + 'reportJunit', + 'shardAll', + 'shardSplit', + ], +} as const satisfies CommandSchemaOverride; + +export const replayCliSchemas = { + [REPLAY_COMMAND_NAME]: replayCliSchema, + [TEST_COMMAND_NAME]: testCliSchema, +} as const satisfies Record; + +export const replayCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + path: requiredString(positionals[0], 'replay requires path'), + update: flags.replayUpdate, + backend: flags.replayMaestro ? 'maestro' : undefined, + env: flags.replayEnv, +}); + +export const testCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + paths: positionals, + update: flags.replayUpdate, + backend: flags.replayMaestro ? 'maestro' : undefined, + env: flags.replayEnv, + failFast: flags.failFast, + timeoutMs: flags.timeoutMs, + retries: flags.retries, + recordVideo: flags.recordVideo, + artifactsDir: flags.artifactsDir, + reportJunit: flags.reportJunit, + shardAll: flags.shardAll, + shardSplit: flags.shardSplit, +}); + +export const replayDaemonWriter: DaemonWriter = (input) => + request(REPLAY_COMMAND_NAME, [requiredDaemonString(input.path, 'replay requires path')], { + ...input, + replayUpdate: input.update, + replayBackend: readReplayBackend(input), + replayEnv: input.env, + replayShellEnv: collectReplayClientShellEnv(process.env), + }); + +export const testDaemonWriter: DaemonWriter = (input) => + request(TEST_COMMAND_NAME, input.paths ?? [], { + ...input, + replayUpdate: input.update, + replayBackend: readReplayBackend(input), + replayEnv: input.env, + replayShellEnv: collectReplayClientShellEnv(process.env), + }); + +export const replayCliReaders = { + replay: replayCliReader, + test: testCliReader, +} satisfies Record; + +export const replayDaemonWriters = { + replay: replayDaemonWriter, + test: testDaemonWriter, +} satisfies Record; + +function readReplayBackend(input: CommandInput): string | undefined { + return input.backend ?? (input.maestro === true ? 'maestro' : undefined); +} + +function collectReplayClientShellEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string' && key.startsWith(REPLAY_SHELL_ENV_PREFIX)) result[key] = value; + } + return result; +} diff --git a/src/commands/selector-read-utils.ts b/src/commands/runtime-common.ts similarity index 62% rename from src/commands/selector-read-utils.ts rename to src/commands/runtime-common.ts index 31cd72c9d..9b5eecc7e 100644 --- a/src/commands/selector-read-utils.ts +++ b/src/commands/runtime-common.ts @@ -1,12 +1,6 @@ import type { BackendCommandContext } from '../backend.ts'; import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -export { findNodeByLabel, resolveRefLabel } from '../utils/snapshot-processing.ts'; - -export function shouldScopeFind(locator: string): boolean { - return locator === 'text' || locator === 'label' || locator === 'any'; -} - export function toBackendContext( runtime: Pick, options: CommandContext, @@ -19,11 +13,11 @@ export function toBackendContext( }; } -export function now(runtime: AgentDeviceRuntime): number { +export function now(runtime: Pick): number { return runtime.clock?.now() ?? Date.now(); } -export async function sleep(runtime: AgentDeviceRuntime, ms: number): Promise { +export async function sleep(runtime: Pick, ms: number): Promise { if (runtime.clock) await runtime.clock.sleep(ms); else await new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts new file mode 100644 index 000000000..8539cc9fb --- /dev/null +++ b/src/commands/system/index.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + appStateCliReader, + appStateDaemonWriter, + appSwitcherCliReader, + appSwitcherDaemonWriter, + backCliReader, + backDaemonWriter, + clipboardCliReader, + clipboardDaemonWriter, + homeCliReader, + homeDaemonWriter, + keyboardCliReader, + keyboardDaemonWriter, + rotateCliReader, + rotateDaemonWriter, +} from './index.ts'; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('system command interface', () => { + test('parameterless readers project common selection flags through', () => { + for (const reader of [appStateCliReader, homeCliReader, appSwitcherCliReader]) { + expect(reader([], flags({ platform: 'ios' }))).toEqual({ + platform: 'ios', + }); + } + }); + + test('parameterless daemon writers emit command names with no positionals', () => { + expect(appStateDaemonWriter({})).toMatchObject({ command: 'appstate', positionals: [] }); + expect(homeDaemonWriter({})).toMatchObject({ command: 'home', positionals: [] }); + expect(appSwitcherDaemonWriter({})).toMatchObject({ + command: 'app-switcher', + positionals: [], + }); + }); + + test('back reader and writer normalize back mode', () => { + expect(backCliReader([], flags({ backMode: 'system' }))).toMatchObject({ + mode: 'system', + }); + expect(backDaemonWriter({ mode: 'in-app' }).options).toMatchObject({ + backMode: 'in-app', + }); + expect( + ( + backDaemonWriter({ mode: 'teleport' } as unknown as Record) + .options as Record + ).backMode, + ).toBeUndefined(); + }); + + test('rotate reader and writer normalize orientation', () => { + expect(rotateCliReader(['left'], flags())).toMatchObject({ + orientation: 'landscape-left', + }); + expect(rotateDaemonWriter({ orientation: 'portrait' }).positionals).toEqual(['portrait']); + }); + + test('rotate reader and writer reject missing orientation', () => { + expectInvalidArgs(() => rotateCliReader([], flags()), 'rotate requires an orientation'); + expectInvalidArgs(() => rotateDaemonWriter({}), 'rotate requires orientation'); + }); + + test('keyboard reader maps aliases and validates arguments', () => { + expect(keyboardCliReader(['get'], flags())).toMatchObject({ action: 'status' }); + expect(keyboardCliReader([], flags())).not.toHaveProperty('action'); + expectInvalidArgs( + () => keyboardCliReader(['dismiss', 'extra'], flags()), + 'at most one action argument', + ); + expectInvalidArgs(() => keyboardCliReader(['wiggle'], flags()), 'keyboard action must be'); + }); + + test('keyboard writer forwards action when present', () => { + expect(keyboardDaemonWriter({ action: 'dismiss' }).positionals).toEqual(['dismiss']); + expect(keyboardDaemonWriter({}).positionals).toEqual([]); + }); + + test('clipboard reader parses read and write subcommands', () => { + expect(clipboardCliReader(['read'], flags())).toMatchObject({ action: 'read' }); + expect(clipboardCliReader(['write', 'hello', 'world'], flags())).toMatchObject({ + action: 'write', + text: 'hello world', + }); + }); + + test('clipboard reader rejects invalid subcommands', () => { + expectInvalidArgs(() => clipboardCliReader([], flags()), 'read or write'); + expectInvalidArgs( + () => clipboardCliReader(['read', 'oops'], flags()), + 'does not accept additional arguments', + ); + expectInvalidArgs( + () => clipboardCliReader(['write'], flags()), + 'clipboard write requires text', + ); + }); + + test('clipboard writer serializes read and write subcommands', () => { + expect(clipboardDaemonWriter({ action: 'read' }).positionals).toEqual(['read']); + expect(clipboardDaemonWriter({ action: 'write', text: 'copied' }).positionals).toEqual([ + 'write', + 'copied', + ]); + }); +}); diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts new file mode 100644 index 000000000..41a4e691f --- /dev/null +++ b/src/commands/system/index.ts @@ -0,0 +1,290 @@ +import type { ClipboardCommandOptions } from '../../client-types.ts'; +import type { BackMode } from '../../core/back-mode.ts'; +import { BACK_MODES } from '../../core/back-mode.ts'; +import { parseDeviceRotation, DEVICE_ROTATIONS } from '../../core/device-rotation.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { compactRecord, enumField, requiredField, stringField } from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { + commonInputFromFlags, + direct, + optionalString, + request, + requiredDaemonString, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +const APPSTATE_COMMAND_NAME = 'appstate'; +const BACK_COMMAND_NAME = 'back'; +const HOME_COMMAND_NAME = 'home'; +const ROTATE_COMMAND_NAME = 'rotate'; +const APP_SWITCHER_COMMAND_NAME = 'app-switcher'; +const KEYBOARD_COMMAND_NAME = 'keyboard'; +const CLIPBOARD_COMMAND_NAME = 'clipboard'; + +const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; +const KEYBOARD_METADATA_ACTION_VALUES = ['status', 'dismiss'] as const; + +const appStateCommandDescription = 'Show foreground app or activity.'; +const backCommandDescription = 'Navigate back.'; +const homeCommandDescription = 'Go to the home screen.'; +const rotateCommandDescription = 'Rotate device orientation.'; +const appSwitcherCommandDescription = 'Open the app switcher.'; +const keyboardCommandDescription = 'Inspect or dismiss the keyboard.'; +const clipboardCommandDescription = 'Read or write clipboard text.'; + +const appStateCommandMetadata = defineFieldCommandMetadata( + APPSTATE_COMMAND_NAME, + appStateCommandDescription, + {}, +); + +const backCommandMetadata = defineFieldCommandMetadata(BACK_COMMAND_NAME, backCommandDescription, { + mode: enumField(BACK_MODES), +}); + +const homeCommandMetadata = defineFieldCommandMetadata( + HOME_COMMAND_NAME, + homeCommandDescription, + {}, +); + +const rotateCommandMetadata = defineFieldCommandMetadata( + ROTATE_COMMAND_NAME, + rotateCommandDescription, + { + orientation: requiredField(enumField(DEVICE_ROTATIONS)), + }, +); + +const appSwitcherCommandMetadata = defineFieldCommandMetadata( + APP_SWITCHER_COMMAND_NAME, + appSwitcherCommandDescription, + {}, +); + +const keyboardCommandMetadata = defineFieldCommandMetadata( + KEYBOARD_COMMAND_NAME, + keyboardCommandDescription, + { + action: enumField(KEYBOARD_METADATA_ACTION_VALUES), + }, +); + +const clipboardCommandMetadata = defineFieldCommandMetadata( + CLIPBOARD_COMMAND_NAME, + clipboardCommandDescription, + { + action: requiredField(enumField(CLIPBOARD_ACTION_VALUES)), + text: stringField(), + }, +); + +export const systemCommandMetadata = [ + appStateCommandMetadata, + backCommandMetadata, + homeCommandMetadata, + rotateCommandMetadata, + appSwitcherCommandMetadata, + keyboardCommandMetadata, + clipboardCommandMetadata, +] as const; + +const appStateCommandDefinition = defineExecutableCommand( + appStateCommandMetadata, + (client, input) => client.command.appState(input), +); + +const backCommandDefinition = defineExecutableCommand(backCommandMetadata, (client, input) => + client.command.back(input), +); + +const homeCommandDefinition = defineExecutableCommand(homeCommandMetadata, (client, input) => + client.command.home(input), +); + +const rotateCommandDefinition = defineExecutableCommand(rotateCommandMetadata, (client, input) => + client.command.rotate(input), +); + +const appSwitcherCommandDefinition = defineExecutableCommand( + appSwitcherCommandMetadata, + (client, input) => client.command.appSwitcher(input), +); + +const keyboardCommandDefinition = defineExecutableCommand( + keyboardCommandMetadata, + (client, input) => client.command.keyboard(input), +); + +const clipboardCommandDefinition = defineExecutableCommand( + clipboardCommandMetadata, + (client, input) => client.command.clipboard(input as ClipboardCommandOptions), +); + +export const systemCommandDefinitions = [ + appStateCommandDefinition, + backCommandDefinition, + homeCommandDefinition, + rotateCommandDefinition, + appSwitcherCommandDefinition, + keyboardCommandDefinition, + clipboardCommandDefinition, +] as const; + +const appStateCliSchema = { + helpDescription: 'Show foreground app/activity', +} as const satisfies CommandSchemaOverride; + +const backCliSchema = { + usageOverride: 'back [--in-app|--system]', + allowedFlags: ['backMode'], +} as const satisfies CommandSchemaOverride; + +const rotateCliSchema = { + usageOverride: 'rotate ', + helpDescription: 'Rotate device orientation on iOS and Android', + positionalArgs: ['orientation'], +} as const satisfies CommandSchemaOverride; + +const keyboardCliSchema = { + usageOverride: 'keyboard [status|get|dismiss|enter|return]', + helpDescription: 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard', + summary: 'Inspect, press, or dismiss the device keyboard', + positionalArgs: ['action?'], +} as const satisfies CommandSchemaOverride; + +const clipboardCliSchema = { + usageOverride: 'clipboard read | clipboard write ', + listUsageOverride: 'clipboard read | clipboard write ', + helpDescription: 'Read or write device clipboard text', + positionalArgs: ['read|write', 'text?'], + allowsExtraPositionals: true, +} as const satisfies CommandSchemaOverride; + +export const systemCliSchemas = { + [APPSTATE_COMMAND_NAME]: appStateCliSchema, + [BACK_COMMAND_NAME]: backCliSchema, + [ROTATE_COMMAND_NAME]: rotateCliSchema, + [KEYBOARD_COMMAND_NAME]: keyboardCliSchema, + [CLIPBOARD_COMMAND_NAME]: clipboardCliSchema, +} as const satisfies Record; + +export const appStateCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); +export const homeCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); +export const appSwitcherCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); + +export const backCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + mode: flags.backMode, +}); + +export const rotateCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + orientation: parseDeviceRotation(positionals[0]), +}); + +export const keyboardCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readKeyboardInput(positionals), +}); + +export const clipboardCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readClipboardInput(positionals), +}); + +export const systemCliReaders = { + appstate: appStateCliReader, + home: homeCliReader, + 'app-switcher': appSwitcherCliReader, + back: backCliReader, + rotate: rotateCliReader, + keyboard: keyboardCliReader, + clipboard: clipboardCliReader, +} satisfies Record; + +export const appStateDaemonWriter: DaemonWriter = direct(APPSTATE_COMMAND_NAME); + +export const backDaemonWriter: DaemonWriter = (input) => + request(BACK_COMMAND_NAME, [], { ...input, backMode: readBackMode(input.mode) }); + +export const homeDaemonWriter: DaemonWriter = direct(HOME_COMMAND_NAME); + +export const rotateDaemonWriter: DaemonWriter = direct(ROTATE_COMMAND_NAME, (input) => [ + requiredDaemonString(input.orientation, 'rotate requires orientation'), +]); + +export const appSwitcherDaemonWriter: DaemonWriter = direct(APP_SWITCHER_COMMAND_NAME); + +export const keyboardDaemonWriter: DaemonWriter = direct(KEYBOARD_COMMAND_NAME, (input) => + optionalString(input.action), +); + +export const clipboardDaemonWriter: DaemonWriter = direct(CLIPBOARD_COMMAND_NAME, (input) => + clipboardPositionals(input as ClipboardCommandOptions), +); + +export const systemDaemonWriters = { + appstate: appStateDaemonWriter, + back: backDaemonWriter, + home: homeDaemonWriter, + rotate: rotateDaemonWriter, + 'app-switcher': appSwitcherDaemonWriter, + keyboard: keyboardDaemonWriter, + clipboard: clipboardDaemonWriter, +} satisfies Record; + +function readBackMode(value: unknown): BackMode | undefined { + return value === 'in-app' || value === 'system' ? value : undefined; +} + +function clipboardPositionals(input: ClipboardCommandOptions): string[] { + return input.action === 'read' ? ['read'] : ['write', input.text]; +} + +function readKeyboardInput(positionals: string[]): Record { + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'keyboard accepts at most one action argument.'); + } + return compactRecord({ action: readKeyboardAction(positionals[0]) }); +} + +function readClipboardInput(positionals: string[]): Record { + const action = positionals[0]?.toLowerCase(); + if (action !== 'read' && action !== 'write') { + throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write.'); + } + if (action === 'read') { + if (positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments.'); + } + return { action }; + } + if (positionals.length < 2) { + throw new AppError('INVALID_ARGS', 'clipboard write requires text.'); + } + return { action, text: positionals.slice(1).join(' ') }; +} + +function readKeyboardAction( + value: string | undefined, +): 'status' | 'dismiss' | 'enter' | 'return' | undefined { + const action = value?.toLowerCase(); + if (action === 'get') return 'status'; + if ( + action === undefined || + action === 'status' || + action === 'dismiss' || + action === 'enter' || + action === 'return' + ) { + return action; + } + throw new AppError( + 'INVALID_ARGS', + 'keyboard action must be status, get, dismiss, enter, or return.', + ); +} diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts new file mode 100644 index 000000000..9c3f62487 --- /dev/null +++ b/src/commands/system/output.ts @@ -0,0 +1,79 @@ +import type { + AppStateCommandResult, + ClipboardCommandResult, + KeyboardCommandResult, +} from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { + messageCliOutput, + messageOutput, + resultOutput, + type CliOutputFormatter, +} from '../output-common.ts'; + +function appStateCliOutput(result: AppStateCommandResult): CliOutput { + return { + data: result, + text: formatAppState(result), + }; +} + +// fallow-ignore-next-line complexity +function keyboardCliOutput(result: KeyboardCommandResult): CliOutput { + if (result.platform === 'android' && result.action === 'status') { + const lines = [ + `Keyboard visible: ${result.visible === true ? 'yes' : 'no'}`, + `Input type: ${result.type ?? result.inputType ?? 'unknown'}`, + `Input owner: ${result.inputOwner ?? 'unknown'}`, + ]; + if (result.inputMethodPackage) lines.push(`Input method: ${result.inputMethodPackage}`); + if (result.focusedPackage) lines.push(`Focused package: ${result.focusedPackage}`); + if (result.focusedResourceId) lines.push(`Focused resource: ${result.focusedResourceId}`); + lines.push(`Next action: ${androidKeyboardNextAction(result.visible, result.inputOwner)}`); + return { data: result, text: lines.join('\n') }; + } + return messageCliOutput(result); +} + +function clipboardCliOutput(result: ClipboardCommandResult): CliOutput { + if (result.action === 'read') return { data: result, text: result.text }; + return messageCliOutput(result); +} + +export const systemCliOutputFormatters = { + appstate: resultOutput(appStateCliOutput), + back: messageOutput, + home: messageOutput, + rotate: messageOutput, + 'app-switcher': messageOutput, + keyboard: resultOutput(keyboardCliOutput), + clipboard: resultOutput(clipboardCliOutput), +} as const satisfies Record; + +function formatAppState(data: AppStateCommandResult): string | null { + if (data.platform === 'ios') { + const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; + if (data.appBundleId) lines.push(`Bundle: ${data.appBundleId}`); + if (data.source) lines.push(`Source: ${data.source}`); + return lines.join('\n'); + } + if (data.platform === 'android') { + const lines = [`Foreground app: ${data.package ?? 'unknown'}`]; + if (data.activity) lines.push(`Activity: ${data.activity}`); + return lines.join('\n'); + } + return null; +} + +function androidKeyboardNextAction( + visible: boolean | undefined, + inputOwner: KeyboardCommandResult['inputOwner'], +): string { + if (inputOwner === 'ime') { + return 'Focused input appears to be owned by the keyboard/IME; dismiss or change the IME before retrying text entry.'; + } + if (visible === true) { + return 'Keyboard is visible and focused input appears app-owned; fill/type can proceed.'; + } + return 'Keyboard is hidden; focus an app field before type, or use fill with a concrete target.'; +} diff --git a/src/commands/system/runtime/index.ts b/src/commands/system/runtime/index.ts new file mode 100644 index 000000000..f9494b50c --- /dev/null +++ b/src/commands/system/runtime/index.ts @@ -0,0 +1,79 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { BoundRuntimeCommand, RuntimeCommand } from '../../runtime-types.ts'; +import { + alertCommand, + appSwitcherCommand, + backCommand, + clipboardCommand, + homeCommand, + keyboardCommand, + rotateCommand, + settingsCommand, + type SystemAlertCommandOptions, + type SystemAlertCommandResult, + type SystemAppSwitcherCommandOptions, + type SystemAppSwitcherCommandResult, + type SystemBackCommandOptions, + type SystemBackCommandResult, + type SystemClipboardCommandOptions, + type SystemClipboardCommandResult, + type SystemHomeCommandOptions, + type SystemHomeCommandResult, + type SystemKeyboardCommandOptions, + type SystemKeyboardCommandResult, + type SystemRotateCommandOptions, + type SystemRotateCommandResult, + type SystemSettingsCommandOptions, + type SystemSettingsCommandResult, +} from './system.ts'; + +export type SystemCommands = { + back: RuntimeCommand; + home: RuntimeCommand; + rotate: RuntimeCommand; + keyboard: RuntimeCommand; + clipboard: RuntimeCommand; + settings: RuntimeCommand; + alert: RuntimeCommand; + appSwitcher: RuntimeCommand< + SystemAppSwitcherCommandOptions | undefined, + SystemAppSwitcherCommandResult + >; +}; + +export type BoundSystemCommands = { + back: (options?: SystemBackCommandOptions) => Promise; + home: (options?: SystemHomeCommandOptions) => Promise; + rotate: BoundRuntimeCommand; + keyboard: (options?: SystemKeyboardCommandOptions) => Promise; + clipboard: BoundRuntimeCommand; + settings: (options?: SystemSettingsCommandOptions) => Promise; + alert: (options?: SystemAlertCommandOptions) => Promise; + appSwitcher: ( + options?: SystemAppSwitcherCommandOptions, + ) => Promise; +}; + +export const systemCommands: SystemCommands = { + back: backCommand, + home: homeCommand, + rotate: rotateCommand, + keyboard: keyboardCommand, + clipboard: clipboardCommand, + settings: settingsCommand, + alert: alertCommand, + appSwitcher: appSwitcherCommand, +}; + +export function bindSystemCommands(runtime: AgentDeviceRuntime): BoundSystemCommands { + return { + back: (options) => systemCommands.back(runtime, options), + home: (options) => systemCommands.home(runtime, options), + rotate: (options) => systemCommands.rotate(runtime, options), + keyboard: (options) => systemCommands.keyboard(runtime, options), + clipboard: (options) => systemCommands.clipboard(runtime, options), + settings: (options) => systemCommands.settings(runtime, options), + alert: (options) => systemCommands.alert(runtime, options), + appSwitcher: (options) => systemCommands.appSwitcher(runtime, options), + }; +} diff --git a/src/__tests__/runtime-system.test.ts b/src/commands/system/runtime/system.test.ts similarity index 96% rename from src/__tests__/runtime-system.test.ts rename to src/commands/system/runtime/system.test.ts index 2f73c75e7..7fd4227bb 100644 --- a/src/__tests__/runtime-system.test.ts +++ b/src/commands/system/runtime/system.test.ts @@ -5,9 +5,9 @@ import type { BackendAlertAction, BackendDeviceOrientation, BackendKeyboardOptions, -} from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, localCommandPolicy } from '../runtime.ts'; +} from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { createAgentDevice, localCommandPolicy } from '../../../runtime.ts'; test('runtime system commands call typed backend primitives', async () => { const calls: unknown[] = []; diff --git a/src/commands/system.ts b/src/commands/system/runtime/system.ts similarity index 96% rename from src/commands/system.ts rename to src/commands/system/runtime/system.ts index 731eea48e..617f8793d 100644 --- a/src/commands/system.ts +++ b/src/commands/system/runtime/system.ts @@ -4,21 +4,21 @@ import type { BackendAlertResult, BackendDeviceOrientation, BackendKeyboardResult, -} from '../backend.ts'; -import type { CommandContext } from '../runtime-contract.ts'; -import type { BackMode } from '../core/back-mode.ts'; -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; -import { requireIntInRange } from '../utils/validation.ts'; -import { isKeyboardAction } from '../utils/keyboard-actions.ts'; +} from '../../../backend.ts'; +import type { CommandContext } from '../../../runtime-contract.ts'; +import type { BackMode } from '../../../core/back-mode.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; +import { isKeyboardAction } from '../../../utils/keyboard-actions.ts'; import { toBackendResult, type BackendResultEnvelope, type BackendResultVariant, type RuntimeCommand, -} from './runtime-types.ts'; -import { toBackendContext } from './selector-read-utils.ts'; -import { normalizeOptionalText } from './text.ts'; +} from '../../runtime-types.ts'; +import { toBackendContext } from '../../runtime-common.ts'; +import { normalizeOptionalText } from '../../text.ts'; export type SystemBackCommandOptions = CommandContext & { mode?: BackMode; diff --git a/src/contracts/perf.ts b/src/contracts/perf.ts index 229433faa..453e0d43c 100644 --- a/src/contracts/perf.ts +++ b/src/contracts/perf.ts @@ -10,7 +10,7 @@ export const PERF_KIND_VALUES = [ 'android-hprof', 'memgraph', ] as const; -export const PERF_MEMORY_KIND_VALUES = ['android-hprof', 'memgraph'] as const; +const PERF_MEMORY_KIND_VALUES = ['android-hprof', 'memgraph'] as const; const PERF_AREAS = defineStringEnum(PERF_AREA_VALUES); const PERF_ACTIONS = defineStringEnum(PERF_ACTION_VALUES); const PERF_SUBJECTS = defineStringEnum(PERF_SUBJECT_VALUES); diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index afa818540..37dcbdaf6 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -18,3 +18,10 @@ test('validateAndNormalizeBatchSteps rejects unknown top-level step fields', () /unknown field\(s\): "args"/i, ); }); + +test('validateAndNormalizeBatchSteps blocks replay daemon steps', () => { + assert.throws( + () => validateAndNormalizeBatchSteps([{ command: 'replay' }], 10), + /cannot run replay/i, + ); +}); diff --git a/src/core/batch.ts b/src/core/batch.ts index 5a9b438e8..0cb8ea7d3 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -1,19 +1,18 @@ import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; import { AppError, asAppError } from '../utils/errors.ts'; import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts'; +import { + BATCH_BLOCKED_COMMANDS, + BATCH_DAEMON_STEP_KEYS, + INHERITED_PARENT_FLAG_KEYS, + assertBatchRuntimeCommandAllowed, + normalizeBatchCommandName, +} from '../batch-policy.ts'; 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 = [ - 'platform', - 'target', - 'device', - 'udid', - 'serial', - 'verbose', - 'out', -] as const; +export { BATCH_BLOCKED_COMMANDS, INHERITED_PARENT_FLAG_KEYS }; + +const batchAllowedStepKeys = new Set(BATCH_DAEMON_STEP_KEYS); export type DaemonBatchStep = { command: string; @@ -130,7 +129,7 @@ export function validateAndNormalizeBatchSteps( if (!step || typeof step !== 'object') { throw new AppError('INVALID_ARGS', `Invalid batch step at index ${index}.`); } - const unknownKeys = Object.keys(step).filter((key) => !BATCH_ALLOWED_STEP_KEYS.has(key)); + const unknownKeys = Object.keys(step).filter((key) => !batchAllowedStepKeys.has(key)); if (unknownKeys.length > 0) { const fields = unknownKeys.map((key) => `"${key}"`).join(', '); throw new AppError( @@ -138,13 +137,11 @@ export function validateAndNormalizeBatchSteps( `Batch step ${index + 1} has unknown field(s): ${fields}. Allowed fields: command, positionals, flags, runtime.`, ); } - const command = typeof step.command === 'string' ? step.command.trim().toLowerCase() : ''; + const command = normalizeBatchCommandName(step.command); if (!command) { throw new AppError('INVALID_ARGS', `Batch step ${index + 1} requires command.`); } - if (BATCH_BLOCKED_COMMANDS.has(command)) { - throw new AppError('INVALID_ARGS', `Batch step ${index + 1} cannot run ${command}.`); - } + assertBatchRuntimeCommandAllowed(command, index + 1); if (step.positionals !== undefined && !Array.isArray(step.positionals)) { throw new AppError('INVALID_ARGS', `Batch step ${index + 1} positionals must be an array.`); } diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 06ba4833b..d2c1bc873 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -12,7 +12,7 @@ import { AppError } from '../../../utils/errors.ts'; import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts'; import { buildInteractionSurfaceSignature } from '../../interaction-outcome-policy.ts'; import { buildSnapshotPresentationKey } from '../../../utils/snapshot.ts'; -import { snapshotCliOutput } from '../../../commands/client-output.ts'; +import { snapshotCliOutput } from '../../../commands/capture/output.ts'; import type { CaptureSnapshotResult } from '../../../client-types.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { diff --git a/src/daemon/handlers/__tests__/snapshot.test.ts b/src/daemon/handlers/__tests__/snapshot.test.ts index c741e1651..cb1c65e85 100644 --- a/src/daemon/handlers/__tests__/snapshot.test.ts +++ b/src/daemon/handlers/__tests__/snapshot.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { parseWaitPositionals as parseWaitArgs } from '../../../commands/cli-grammar/capture.ts'; +import { parseWaitPositionals as parseWaitArgs } from '../../../core/wait-positionals.ts'; import { parseTimeout } from '../parse-utils.ts'; // --- parseTimeout --- diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index cb7c7575d..f255d4509 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -1,17 +1,17 @@ -import { SETTINGS_USAGE_OVERRIDE } from '../core/settings-contract.ts'; import type { CommandName } from '../commands/command-metadata.ts'; -import { DEFAULT_APPS_FILTER } from '../contracts/app-inventory.ts'; -import { SCREENSHOT_COMMAND_FLAG_KEYS } from '../contracts/screenshot.ts'; +import { batchCliSchemas } from '../commands/batch/index.ts'; +import { captureCliSchemas } from '../commands/capture/index.ts'; +import { interactionCliSchemas } from '../commands/interaction/index.ts'; +import { managementCliSchemas } from '../commands/management/index.ts'; +import { metroCliSchemas } from '../commands/metro/index.ts'; +import { observabilityCliSchemas } from '../commands/observability/index.ts'; +import { reactNativeCliSchemas } from '../commands/react-native/index.ts'; +import { recordingCliSchemas } from '../commands/recording/index.ts'; +import { replayCliSchemas } from '../commands/replay/index.ts'; +import { systemCliSchemas } from '../commands/system/index.ts'; import type { LocalCliCommandName } from '../command-catalog.ts'; import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema-types.ts'; -import { - METRO_PREPARE_FLAGS, - METRO_RELOAD_FLAGS, - REPEATED_TOUCH_FLAGS, - REPLAY_FLAGS, - SELECTOR_SNAPSHOT_FLAGS, - SNAPSHOT_FLAGS, -} from './cli-flags.ts'; +import { METRO_PREPARE_FLAGS } from './cli-flags.ts'; type SchemaOnlyCliCommandName = Exclude; @@ -61,321 +61,16 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { } as const satisfies Record; const CLI_COMMAND_OVERRIDES = { - boot: { - summary: 'Boot target device/simulator', - allowedFlags: ['headless'], - }, - shutdown: { - summary: 'Shutdown target simulator/emulator', - }, - prepare: { - usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', - listUsageOverride: 'prepare ios-runner --platform ios|macos', - helpDescription: - 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', - summary: 'Prepare platform helpers', - positionalArgs: ['ios-runner'], - allowedFlags: ['timeoutMs'], - }, - debug: { - usageOverride: - 'debug symbols --artifact (--dsym | --search-path ) [--out ]', - listUsageOverride: 'debug symbols --artifact --dsym ', - helpDescription: - 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.', - summary: 'Symbolicate Apple crash artifacts', - positionalArgs: ['symbols'], - allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], - }, - open: { - helpDescription: - 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', - summary: 'Open an app, deep link or URL, save replays', - positionalArgs: ['appOrUrl?', 'url?'], - allowedFlags: [ - 'activity', - 'launchConsole', - 'launchArgs', - 'deviceHub', - 'saveScript', - 'relaunch', - 'surface', - ], - }, - close: { - positionalArgs: ['app?'], - allowedFlags: ['saveScript', 'shutdown'], - }, - reinstall: { - positionalArgs: ['app', 'path'], - }, - install: { - positionalArgs: ['app', 'path'], - }, - 'install-from-source': { - usageOverride: - 'install-from-source | install-from-source --github-actions-artifact ', - listUsageOverride: 'install-from-source | install-from-source --github-actions-artifact', - helpDescription: 'Install app from a URL or remote-resolved source', - summary: 'Install app from a source', - positionalArgs: ['url?'], - allowedFlags: [ - 'header', - 'githubActionsArtifact', - 'installSource', - 'retainPaths', - 'retentionMs', - ], - }, - apps: { - helpDescription: 'List user-installed apps; use --all to include system/OEM apps', - summary: 'List installed apps', - allowedFlags: ['appsFilter'], - defaults: { appsFilter: DEFAULT_APPS_FILTER }, - }, - push: { - positionalArgs: ['bundleOrPackage', 'payloadOrJson'], - }, - snapshot: { - usageOverride: - 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', - helpDescription: 'Capture accessibility tree or diff against the previous session baseline', - allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], - }, - diff: { - usageOverride: - 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--threshold <0-1>] [--overlay-refs]', - helpDescription: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel', - summary: 'Diff snapshot or screenshot', - positionalArgs: ['kind', 'current?'], - allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'], - }, - screenshot: { - helpDescription: - 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', - positionalArgs: ['path?'], - allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, - }, - appstate: { - helpDescription: 'Show foreground app/activity', - }, - perf: { - usageOverride: - 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start|stop|report --kind simpleperf [--out ]\n agent-device perf trace start|stop --kind perfetto [--out ]', - listUsageOverride: - 'perf [metrics|frames|memory] | perf cpu profile start|stop|report | perf trace start|stop', - helpDescription: - 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, Apple xctrace artifacts, or Android native Simpleperf/Perfetto artifacts. Bare perf and metrics are aliases for perf metrics. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', - summary: 'Show performance metrics or collect native perf artifacts', - positionalArgs: ['area?', 'subjectOrAction?', 'action?'], - allowedFlags: ['kind', 'perfTemplate', 'out'], - }, - metro: { - usageOverride: - 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', - listUsageOverride: - 'metro prepare --public-base-url | --proxy-base-url ; metro reload', - helpDescription: - 'Prepare a local Metro runtime or ask Metro to reload connected React Native apps', - summary: 'Prepare Metro or reload apps', - positionalArgs: ['prepare|reload'], - allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], - }, - clipboard: { - usageOverride: 'clipboard read | clipboard write ', - listUsageOverride: 'clipboard read | clipboard write ', - helpDescription: 'Read or write device clipboard text', - positionalArgs: ['read|write', 'text?'], - allowsExtraPositionals: true, - }, - keyboard: { - usageOverride: 'keyboard [status|get|dismiss|enter|return]', - helpDescription: - 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard', - summary: 'Inspect, press, or dismiss the device keyboard', - positionalArgs: ['action?'], - }, - back: { - usageOverride: 'back [--in-app|--system]', - allowedFlags: ['backMode'], - }, - rotate: { - usageOverride: 'rotate ', - helpDescription: 'Rotate device orientation on iOS and Android', - positionalArgs: ['orientation'], - }, - wait: { - usageOverride: 'wait |text |@ref| [timeoutMs]', - positionalArgs: ['durationOrSelector', 'timeoutMs?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - get: { - usageOverride: 'get text|attrs <@ref|selector>', - positionalArgs: ['subcommand', 'target'], - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - find: { - usageOverride: 'find [value] [--first|--last]', - helpDescription: 'Find by text/label/value/role/id and run action', - summary: 'Find an element and act', - positionalArgs: ['query', 'action', 'value?'], - allowsExtraPositionals: true, - allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast'], - }, - is: { - positionalArgs: ['predicate', 'selector', 'value?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - alert: { - usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', - positionalArgs: ['action?', 'timeout?'], - }, - click: { - usageOverride: 'click ', - positionalArgs: ['target'], - allowsExtraPositionals: true, - allowedFlags: [...REPEATED_TOUCH_FLAGS, 'clickButton', ...SELECTOR_SNAPSHOT_FLAGS], - }, - replay: { - usageOverride: 'replay | replay export [--format maestro] [--out ]', - positionalArgs: ['path'], - allowsExtraPositionals: true, - allowedFlags: ['replayMaestro', 'replayExportFormat', ...REPLAY_FLAGS, 'timeoutMs', 'out'], - }, - test: { - usageOverride: 'test ...', - listUsageOverride: 'test ...', - helpDescription: 'Run one or more replay scripts as a serial test suite', - summary: 'Run replay test suites', - positionalArgs: ['pathOrGlob'], - allowsExtraPositionals: true, - allowedFlags: [ - 'replayMaestro', - ...REPLAY_FLAGS, - 'failFast', - 'timeoutMs', - 'retries', - 'recordVideo', - 'artifactsDir', - 'reportJunit', - 'shardAll', - 'shardSplit', - ], - }, - batch: { - usageOverride: 'batch [--steps | --steps-file ]', - listUsageOverride: 'batch --steps | --steps-file ', - helpDescription: 'Execute multiple commands in one daemon request', - summary: 'Run multiple commands', - allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], - }, - press: { - usageOverride: 'press ', - positionalArgs: ['targetOrX', 'y?'], - allowsExtraPositionals: true, - allowedFlags: [...REPEATED_TOUCH_FLAGS, ...SELECTOR_SNAPSHOT_FLAGS], - }, - longpress: { - usageOverride: 'longpress [durationMs]', - positionalArgs: ['targetOrX', 'yOrDurationMs?', 'durationMs?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - swipe: { - helpDescription: 'Swipe coordinates with optional repeat pattern', - positionalArgs: ['x1', 'y1', 'x2', 'y2', 'durationMs?'], - allowedFlags: ['count', 'pauseMs', 'pattern'], - }, - gesture: { - usageOverride: 'gesture ...', - listUsageOverride: 'gesture ...', - helpDescription: - 'Run touch gestures: pan [durationMs], fling [distance] [durationMs], swipe [durationMs], pinch [x] [y], rotate [x] [y] [velocity], or transform [durationMs]', - summary: 'Run pan, fling, swipe, pinch, rotate, or transform gestures', - positionalArgs: ['pan|fling|swipe|pinch|rotate|transform', 'args?'], - allowsExtraPositionals: true, - }, - focus: { - positionalArgs: ['x', 'y'], - }, - type: { - positionalArgs: ['text'], - allowsExtraPositionals: true, - allowedFlags: ['delayMs'], - }, - fill: { - usageOverride: 'fill | fill <@ref|selector> ', - positionalArgs: ['targetOrX', 'yOrText', 'text?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'delayMs'], - }, - scroll: { - usageOverride: 'scroll [amount] [--pixels ]', - helpDescription: 'Scroll in direction, or verify hidden content and scroll toward top/bottom', - summary: 'Scroll in a direction or to an edge', - positionalArgs: ['directionOrEdge', 'amount?'], - allowedFlags: ['pixels'], - }, - 'trigger-app-event': { - usageOverride: 'trigger-app-event [payloadJson]', - positionalArgs: ['event', 'payloadJson?'], - }, - record: { - usageOverride: - 'record start [path] [--fps ] [--quality <5-10>] [--hide-touches] | record stop', - listUsageOverride: 'record start [path] | record stop', - helpDescription: - 'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks', - summary: 'Start or stop screen recording', - positionalArgs: ['start|stop', 'path?'], - allowedFlags: ['fps', 'quality', 'hideTouches'], - }, - 'react-native': { - usageOverride: 'react-native dismiss-overlay', - listUsageOverride: 'react-native dismiss-overlay', - positionalArgs: ['dismiss-overlay'], - }, - trace: { - usageOverride: 'trace start | trace stop ', - listUsageOverride: 'trace start | trace stop ', - helpDescription: - 'Start/stop trace log capture; when an artifact path is requested, pass the same positional path to start and stop', - summary: 'Start or stop trace capture', - positionalArgs: ['start|stop', 'path?'], - }, - logs: { - usageOverride: - 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', - helpDescription: 'Session app log info, start/stop streaming, diagnostics, and markers', - summary: 'Manage session app logs', - positionalArgs: ['path|start|stop|clear|doctor|mark', 'message?'], - allowsExtraPositionals: true, - allowedFlags: ['restart'], - }, - network: { - usageOverride: - 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', - helpDescription: 'Dump recent HTTP(s) traffic parsed from the session app log', - summary: 'Show recent HTTP traffic', - positionalArgs: ['dump|log', 'limit?', 'include?'], - allowedFlags: ['networkInclude'], - }, - settings: { - usageOverride: SETTINGS_USAGE_OVERRIDE, - listUsageOverride: 'settings [area] [options]', - helpDescription: - 'Toggle OS settings, animation scales, appearance, and app permissions (macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS; mobile permission actions use the active session app)', - summary: 'Change OS settings and app permissions', - positionalArgs: ['setting', 'state', 'target?', 'mode?'], - }, - session: { - usageOverride: 'session list | session state-dir', - listUsageOverride: 'session list', - helpDescription: 'List active sessions or print the effective daemon state directory', - positionalArgs: ['list|state-dir?'], - }, + ...managementCliSchemas, + ...captureCliSchemas, + ...systemCliSchemas, + ...interactionCliSchemas, + ...observabilityCliSchemas, + ...metroCliSchemas, + ...replayCliSchemas, + ...batchCliSchemas, + ...recordingCliSchemas, + ...reactNativeCliSchemas, } as const satisfies Partial>; export function getSchemaOnlyCliCommandSchema(command: string): CommandSchema | undefined { diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 82fdff869..0cca030c5 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -1,5 +1,5 @@ -import { listCommandDescriptionMetadata } from '../commands/command-descriptions.ts'; import type { CliCommandName } from '../command-catalog.ts'; +import { listCommandMetadata } from '../commands/command-metadata.ts'; import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema-types.ts'; import { getCliCommandOverride, getSchemaOnlyCliCommandSchema } from './cli-command-overrides.ts'; import { @@ -17,9 +17,9 @@ export type { CommandSchema, CommandSchemaOverride }; export { getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS }; const COMMAND_SCHEMA_BASES = new Map( - listCommandDescriptionMetadata().map((definition) => [ - definition.name, - { helpDescription: definition.description }, + listCommandMetadata().map((metadata) => [ + metadata.name, + { helpDescription: metadata.description }, ]), );