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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions scripts/integration-progress.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ 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_CONTRACT_FILES = listFiles(path.join(ROOT, 'src/commands'), (file) => file.endsWith(`${path.sep}index.ts`));
const COMMAND_CATALOG_SOURCE = fs.readFileSync(COMMAND_CATALOG, 'utf8');
const clientCommandMethods = readClientCommandMethods();

Expand Down Expand Up @@ -284,6 +281,7 @@ function summarizeProviderScenarioFlagExclusions() {
name: 'Metro and React Native runtime preparation',
owner: 'Metro companion integration and parser tests',
keys: [
'kind',
'metroHost',
'metroPort',
'metroProjectRoot',
Expand All @@ -303,6 +301,11 @@ function summarizeProviderScenarioFlagExclusions() {
'launchUrl',
],
},
{
name: 'Apple launch and perf artifact options',
owner: 'iOS platform, observability command, and parser tests',
keys: ['deviceHub', 'launchArgs', 'perfTemplate'],
},
{
name: 'parser/client-only command flags',
owner: 'args, CLI, screenshot-diff, and batch tests',
Expand All @@ -314,6 +317,10 @@ function summarizeProviderScenarioFlagExclusions() {
'threshold',
'reportJunit',
'replayMaestro',
'replayExportFormat',
'recordVideo',
'shardAll',
'shardSplit',
'stepsFile',
],
},
Expand All @@ -326,10 +333,7 @@ function summarizeProviderScenarioFlagExclusions() {
}

function readPublicCliFlagKeys() {
const sources = [
path.join(ROOT, 'src/utils/command-schema.ts'),
path.join(ROOT, 'src/commands/capture-screenshot-options.ts'),
];
const sources = [path.join(ROOT, 'src/utils/cli-flags.ts')];
const keys = new Set();
for (const source of sources) {
const text = fs.readFileSync(source, 'utf8');
Expand Down Expand Up @@ -513,8 +517,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),
]
Expand All @@ -533,6 +555,12 @@ function readCommandContractBlocks(text) {
});
}

function readMetadataName(token, constants) {
const literal = token.match(/^['"]([^'"]+)['"]$/);
if (literal) return literal[1];
return constants.get(token);
}

function extractProviderScenarioCommandReferences(text) {
const commands = [];
for (const match of text.matchAll(/\bcommand:\s*['"]([^'"]+)['"]|\.callCommand\(\s*['"]([^'"]+)['"]/g)) {
Expand Down
2 changes: 1 addition & 1 deletion src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,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';
Expand Down
1 change: 0 additions & 1 deletion src/commands/app-inventory-contract.ts

This file was deleted.

23 changes: 0 additions & 23 deletions src/commands/batch-command.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions src/commands/batch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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';

const batchCommandDescription = 'Run multiple structured command steps in one daemon request.';

export const batchCommandDescriptions = {
batch: batchCommandDescription,
} as const;

export const batchCommandMetadata = createBatchCommandMetadata();

export const batchCommandDefinition = defineExecutableCommand(
batchCommandMetadata,
(client, input) => client.batch.run(toBatchOptions(input)),
);

export const batchCliSchemas = {
batch: {
usageOverride: 'batch [--steps <json> | --steps-file <path>]',
listUsageOverride: 'batch --steps <json> | --steps-file <path>',
helpDescription: 'Execute multiple commands in one daemon request',
summary: 'Run multiple commands',
allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'],
},
} as const satisfies Record<string, CommandSchemaOverride>;

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,
};
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { BATCH_COMMAND_NAMES } from '../command-catalog.ts';
import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts';
import { BATCH_COMMAND_NAMES } from '../../command-catalog.ts';
import { DEFAULT_BATCH_MAX_STEPS } from '../../batch-contract.ts';
import {
defineCommandMetadata,
type CommandMetadata,
type JsonSchema,
} from './command-contract.ts';
} from '../command-contract.ts';
import {
assertAllowedKeys,
customField,
Expand All @@ -17,7 +17,7 @@ import {
stringField,
type CommandFieldMap,
type InferCommandInput,
} from './command-input.ts';
} from '../command-input.ts';

export type BatchCommandStep = {
command: string;
Expand Down
56 changes: 56 additions & 0 deletions src/commands/batch/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { CommandRequestResult } from '../../client-types.ts';
import { readCommandMessage } from '../../utils/success-text.ts';
import type { CliOutput } from '../command-contract.ts';
import { resultOutput, type CliOutputFormatter } from '../output-common.ts';

function batchCliOutput(result: CommandRequestResult): CliOutput {
const data = result as Record<string, unknown>;
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<string, CliOutputFormatter>;

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<string, unknown>,
stepOk: boolean,
command: string,
): string {
if (stepOk) return readCommandMessage(readRecord(result.data)) ?? command;
return readBatchStepFailure(readRecord(result.error)) ?? command;
}

function readBatchStepFailure(error: Record<string, unknown> | undefined): string | null {
return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null;
}

function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
111 changes: 111 additions & 0 deletions src/commands/batch/projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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 { commandNameSet, request } from '../cli-grammar/common.ts';
import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts';

export type BatchCommandName = (typeof BATCH_COMMAND_NAMES)[number];

type PrepareDaemonCommandRequest = (command: string, input: CommandInput) => DaemonCommandRequest;

const batchNames = commandNameSet(BATCH_COMMAND_NAMES);

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<string, unknown> {
if (!step || typeof step !== 'object' || Array.isArray(step)) {
throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`);
}
return step as Record<string, unknown>;
}

function readBatchStepCommand(
record: Record<string, unknown>,
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 isBatchCommandName(name: string): name is BatchCommandName {
return batchNames.has(name);
}

function readBatchStepInput(record: Record<string, unknown>, 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<string, unknown>,
stepNumber: number,
): Record<string, unknown> | 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<string, unknown> | undefined;
}
Loading
Loading