Skip to content

Commit b7724fe

Browse files
committed
feat: export batch orchestration helpers
1 parent a9e8d90 commit b7724fe

11 files changed

Lines changed: 317 additions & 173 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
"import": "./dist/src/metro.js",
3434
"types": "./dist/src/metro.d.ts"
3535
},
36+
"./batch": {
37+
"import": "./dist/src/batch.js",
38+
"types": "./dist/src/batch.d.ts"
39+
},
3640
"./remote-config": {
3741
"import": "./dist/src/remote-config.js",
3842
"types": "./dist/src/remote-config.d.ts"

rslib.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default defineConfig({
1919
index: 'src/index.ts',
2020
io: 'src/io.ts',
2121
artifacts: 'src/artifacts.ts',
22+
batch: 'src/batch.ts',
2223
metro: 'src/metro.ts',
2324
'remote-config': 'src/remote-config.ts',
2425
'install-source': 'src/install-source.ts',

src/__tests__/batch-public.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import {
4+
BATCH_BLOCKED_COMMANDS,
5+
DEFAULT_BATCH_MAX_STEPS,
6+
INHERITED_PARENT_FLAG_KEYS,
7+
buildBatchStepFlags,
8+
runBatch,
9+
validateAndNormalizeBatchSteps,
10+
type BatchStepResult,
11+
} from '../batch.ts';
12+
import type { DaemonRequest } from '../contracts.ts';
13+
14+
test('public batch entrypoint exports daemon-compatible orchestration helpers', async () => {
15+
const seenCommands: string[] = [];
16+
const req: Omit<DaemonRequest, 'token'> = {
17+
command: 'batch',
18+
positionals: [],
19+
flags: {
20+
platform: 'ios',
21+
udid: 'sim-1',
22+
batchSteps: [
23+
{ command: 'open', positionals: ['settings'] },
24+
{ command: 'wait', positionals: ['100'], flags: { platform: 'android' } },
25+
],
26+
},
27+
};
28+
29+
const response = await runBatch(req, 'resolved-session', async (stepReq) => {
30+
seenCommands.push(stepReq.command);
31+
assert.equal(stepReq.session, 'resolved-session');
32+
assert.equal(stepReq.flags?.session, 'resolved-session');
33+
if (stepReq.command === 'open') {
34+
assert.equal(stepReq.flags?.platform, 'ios');
35+
assert.equal(stepReq.flags?.udid, 'sim-1');
36+
}
37+
if (stepReq.command === 'wait') {
38+
assert.equal(stepReq.flags?.platform, 'android');
39+
}
40+
return { ok: true, data: { command: stepReq.command } };
41+
});
42+
43+
assert.equal(response.ok, true);
44+
assert.deepEqual(seenCommands, ['open', 'wait']);
45+
if (response.ok) {
46+
assert.equal(response.data?.total, 2);
47+
const results = response.data?.results as BatchStepResult[];
48+
assert.equal(results[0]?.command, 'open');
49+
}
50+
});
51+
52+
test('public batch helpers expose validation and flag policy', () => {
53+
assert.equal(DEFAULT_BATCH_MAX_STEPS, 100);
54+
assert.equal(BATCH_BLOCKED_COMMANDS.has('replay'), true);
55+
assert.equal(INHERITED_PARENT_FLAG_KEYS.includes('udid'), true);
56+
assert.deepEqual(validateAndNormalizeBatchSteps([{ command: 'WAIT', positionals: ['100'] }], 1), [
57+
{ command: 'wait', positionals: ['100'], flags: {}, runtime: undefined },
58+
]);
59+
assert.deepEqual(
60+
buildBatchStepFlags(
61+
{ platform: 'ios', udid: 'sim-1', batchSteps: [{ command: 'open' }] },
62+
{ batchMaxSteps: 10, platform: 'android' },
63+
),
64+
{ platform: 'android', udid: 'sim-1' },
65+
);
66+
});

src/batch.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export {
2+
BATCH_BLOCKED_COMMANDS,
3+
DEFAULT_BATCH_MAX_STEPS,
4+
INHERITED_PARENT_FLAG_KEYS,
5+
buildBatchStepFlags,
6+
runBatch,
7+
validateAndNormalizeBatchSteps,
8+
} from './core/batch.ts';
9+
10+
export type {
11+
BatchFlags,
12+
BatchInvoke,
13+
BatchRequest,
14+
BatchStep,
15+
BatchStepResult,
16+
NormalizedBatchStep,
17+
} from './core/batch.ts';

src/cli.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import { readVersion } from './utils/version.ts';
55
import { pathToFileURL } from 'node:url';
66
import { sendToDaemon } from './daemon-client.ts';
77
import fs from 'node:fs';
8-
import type { BatchStep } from './core/dispatch.ts';
9-
import { parseBatchStepsJson } from './core/batch.ts';
8+
import { parseBatchStepsJson, type BatchStep } from './core/batch.ts';
109
import {
1110
createAgentDeviceClient,
1211
type AgentDeviceClientConfig,

src/cli/commands/connection-runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type RemoteConnectionState,
1212
} from '../../remote-connection-state.ts';
1313
import { REMOTE_CONFIG_FIELD_SPECS, type RemoteConfigProfile } from '../../remote-config-schema.ts';
14-
import type { BatchStep } from '../../core/dispatch.ts';
14+
import type { BatchStep } from '../../core/batch.ts';
1515
import { AppError } from '../../utils/errors.ts';
1616
import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts';
1717
import type { CliFlags } from '../../utils/command-schema.ts';

src/core/__tests__/batch.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3-
import { validateAndNormalizeBatchSteps } from '../batch.ts';
4-
import type { BatchStep } from '../dispatch.ts';
3+
import { validateAndNormalizeBatchSteps, type BatchStep } from '../batch.ts';
54

65
test('validateAndNormalizeBatchSteps rejects unknown top-level step fields', () => {
76
assert.throws(

src/core/batch.ts

Lines changed: 193 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,42 @@
1-
import { AppError } from '../utils/errors.ts';
2-
import type { BatchStep, CommandFlags } from './dispatch.ts';
1+
import type { DaemonRequest, DaemonResponse } from '../contracts.ts';
2+
import { AppError, asAppError } from '../utils/errors.ts';
33

44
export const DEFAULT_BATCH_MAX_STEPS = 100;
5-
const BATCH_BLOCKED_COMMANDS = new Set(['batch', 'replay']);
5+
export const BATCH_BLOCKED_COMMANDS: ReadonlySet<string> = new Set(['batch', 'replay']);
66
const BATCH_ALLOWED_STEP_KEYS = new Set(['command', 'positionals', 'flags', 'runtime']);
7+
export const INHERITED_PARENT_FLAG_KEYS = [
8+
'platform',
9+
'target',
10+
'device',
11+
'udid',
12+
'serial',
13+
'verbose',
14+
'out',
15+
] as const;
16+
17+
export type BatchStep = {
18+
command: string;
19+
positionals?: string[];
20+
flags?: Record<string, unknown>;
21+
runtime?: unknown;
22+
};
23+
24+
export type BatchFlags = Record<string, unknown> & {
25+
batchOnError?: 'stop';
26+
batchMaxSteps?: number;
27+
batchSteps?: BatchStep[];
28+
};
29+
30+
export type BatchRequest = Omit<DaemonRequest, 'flags'> & {
31+
flags?: BatchFlags | Record<string, unknown>;
32+
};
33+
34+
export type BatchInvoke = (req: BatchRequest) => Promise<DaemonResponse>;
735

836
export type NormalizedBatchStep = {
937
command: string;
1038
positionals: string[];
11-
flags: Partial<CommandFlags>;
39+
flags: Record<string, unknown>;
1240
runtime?: unknown;
1341
};
1442

@@ -20,6 +48,68 @@ export type BatchStepResult = {
2048
durationMs: number;
2149
};
2250

51+
export async function runBatch(
52+
req: BatchRequest,
53+
sessionName: string,
54+
invoke: BatchInvoke,
55+
): Promise<DaemonResponse> {
56+
const flags = readBatchFlags(req.flags);
57+
const batchOnError = flags?.batchOnError ?? 'stop';
58+
if (batchOnError !== 'stop') {
59+
return batchErrorResponse('INVALID_ARGS', `Unsupported batch on-error mode: ${batchOnError}.`);
60+
}
61+
const batchMaxSteps = flags?.batchMaxSteps ?? DEFAULT_BATCH_MAX_STEPS;
62+
if (!Number.isInteger(batchMaxSteps) || batchMaxSteps < 1 || batchMaxSteps > 1000) {
63+
return batchErrorResponse(
64+
'INVALID_ARGS',
65+
`Invalid batch max-steps: ${String(flags?.batchMaxSteps)}`,
66+
);
67+
}
68+
try {
69+
const steps = validateAndNormalizeBatchSteps(flags?.batchSteps, batchMaxSteps);
70+
const startedAt = Date.now();
71+
const partialResults: BatchStepResult[] = [];
72+
for (let index = 0; index < steps.length; index += 1) {
73+
const step = steps[index];
74+
const stepResponse = await runBatchStep(req, sessionName, step, invoke, index + 1);
75+
if (!stepResponse.ok) {
76+
return {
77+
ok: false,
78+
error: {
79+
code: stepResponse.error.code,
80+
message: `Batch failed at step ${stepResponse.step} (${step.command}): ${stepResponse.error.message}`,
81+
hint: stepResponse.error.hint,
82+
diagnosticId: stepResponse.error.diagnosticId,
83+
logPath: stepResponse.error.logPath,
84+
details: {
85+
...(stepResponse.error.details ?? {}),
86+
step: stepResponse.step,
87+
command: step.command,
88+
positionals: step.positionals,
89+
executed: index,
90+
total: steps.length,
91+
partialResults,
92+
},
93+
},
94+
};
95+
}
96+
partialResults.push(stepResponse.result);
97+
}
98+
return {
99+
ok: true,
100+
data: {
101+
total: steps.length,
102+
executed: steps.length,
103+
totalDurationMs: Date.now() - startedAt,
104+
results: partialResults,
105+
},
106+
};
107+
} catch (error) {
108+
const appErr = asAppError(error);
109+
return batchErrorResponse(appErr.code, appErr.message, appErr.details);
110+
}
111+
}
112+
23113
export function parseBatchStepsJson(raw: string): BatchStep[] {
24114
let parsed: unknown;
25115
try {
@@ -34,7 +124,7 @@ export function parseBatchStepsJson(raw: string): BatchStep[] {
34124
}
35125

36126
export function validateAndNormalizeBatchSteps(
37-
steps: CommandFlags['batchSteps'],
127+
steps: unknown,
38128
maxSteps: number,
39129
): NormalizedBatchStep[] {
40130
if (!Array.isArray(steps) || steps.length === 0) {
@@ -49,7 +139,7 @@ export function validateAndNormalizeBatchSteps(
49139

50140
const normalized: NormalizedBatchStep[] = [];
51141
for (let index = 0; index < steps.length; index += 1) {
52-
const step = steps[index];
142+
const step = steps[index] as Partial<BatchStep>;
53143
if (!step || typeof step !== 'object') {
54144
throw new AppError('INVALID_ARGS', `Invalid batch step at index ${index}.`);
55145
}
@@ -93,9 +183,105 @@ export function validateAndNormalizeBatchSteps(
93183
normalized.push({
94184
command,
95185
positionals: positionals as string[],
96-
flags: (step.flags ?? {}) as Partial<CommandFlags>,
186+
flags: (step.flags ?? {}) as Record<string, unknown>,
97187
runtime: step.runtime,
98188
});
99189
}
100190
return normalized;
101191
}
192+
193+
export function buildBatchStepFlags(
194+
parentFlags: BatchFlags | Record<string, unknown> | undefined,
195+
stepFlags: BatchStep['flags'] | Record<string, unknown> | undefined,
196+
): BatchFlags {
197+
const {
198+
batchSteps: _batchSteps,
199+
batchOnError: _batchOnError,
200+
batchMaxSteps: _batchMaxSteps,
201+
...merged
202+
} = stepFlags ?? {};
203+
return mergeParentFlags(readBatchFlags(parentFlags), merged as BatchFlags);
204+
}
205+
206+
export function mergeParentFlags<TFlags extends Record<string, unknown>>(
207+
parentFlags: BatchFlags | Record<string, unknown> | undefined,
208+
childFlags: TFlags,
209+
): TFlags {
210+
const parentRecord = readBatchFlags(parentFlags) ?? {};
211+
const childRecord = childFlags as Record<string, unknown>;
212+
for (const key of INHERITED_PARENT_FLAG_KEYS) {
213+
if (childRecord[key] === undefined && parentRecord[key] !== undefined) {
214+
childRecord[key] = parentRecord[key];
215+
}
216+
}
217+
return childFlags;
218+
}
219+
220+
async function runBatchStep(
221+
req: BatchRequest,
222+
sessionName: string,
223+
step: NormalizedBatchStep,
224+
invoke: BatchInvoke,
225+
stepNumber: number,
226+
): Promise<
227+
| { ok: true; step: number; result: BatchStepResult }
228+
| {
229+
ok: false;
230+
step: number;
231+
error: {
232+
code: string;
233+
message: string;
234+
hint?: string;
235+
diagnosticId?: string;
236+
logPath?: string;
237+
details?: Record<string, unknown>;
238+
};
239+
}
240+
> {
241+
const stepStartedAt = Date.now();
242+
const stepFlags = buildBatchStepFlags(req.flags, step.flags);
243+
if (stepFlags.session === undefined) {
244+
stepFlags.session = sessionName;
245+
}
246+
const response = await invoke({
247+
token: req.token,
248+
session: sessionName,
249+
command: step.command,
250+
positionals: step.positionals,
251+
flags: stepFlags,
252+
runtime: (step.runtime === undefined ? req.runtime : step.runtime) as DaemonRequest['runtime'],
253+
meta: req.meta,
254+
});
255+
const durationMs = Date.now() - stepStartedAt;
256+
if (!response.ok) {
257+
return { ok: false, step: stepNumber, error: response.error };
258+
}
259+
return {
260+
ok: true,
261+
step: stepNumber,
262+
result: {
263+
step: stepNumber,
264+
command: step.command,
265+
ok: true,
266+
data: response.data ?? {},
267+
durationMs,
268+
},
269+
};
270+
}
271+
272+
function readBatchFlags(
273+
flags: BatchFlags | Record<string, unknown> | undefined,
274+
): BatchFlags | undefined {
275+
return flags as BatchFlags | undefined;
276+
}
277+
278+
function batchErrorResponse(
279+
code: string,
280+
message: string,
281+
details?: Record<string, unknown>,
282+
): Extract<DaemonResponse, { ok: false }> {
283+
return {
284+
ok: false,
285+
error: { code, message, ...(details ? { details } : {}) },
286+
};
287+
}

0 commit comments

Comments
 (0)