@midscene/test 0.0.0 → 1.10.4-beta-20260723055511.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2094 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { z } from "zod/v4";
4
+ import { pathToFileURL } from "node:url";
5
+ import { require as api_require } from "tsx/cjs/api";
6
+ import { tsImport } from "tsx/esm/api";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { globSync } from "tinyglobby";
9
+ import { JSON_SCHEMA, load } from "js-yaml";
10
+ const GENERATED_COMMENT = '<!-- Generated by `midscene-test describe-nodes`. Do not edit directly. -->';
11
+ const WORKFLOW_STEP_CONTRACT = `## Workflow Step Contract
12
+
13
+ Each step uses a node name as its key. The \`$\` object contains engine metadata and is not part of the node input.
14
+
15
+ \`\`\`yaml
16
+ workflow:
17
+ - node.name:
18
+ prompt: Describe the task.
19
+ $:
20
+ timeout: 30000
21
+ continue-on-error: false
22
+ \`\`\`
23
+
24
+ A string step value is shorthand for \`{ prompt: value }\`. Whether that input is valid depends on the node input schema.`;
25
+ const sortJsonValue = (value)=>{
26
+ if (Array.isArray(value)) return value.map(sortJsonValue);
27
+ if ('object' != typeof value || null === value) return value;
28
+ return Object.fromEntries(Object.entries(value).sort(([left], [right])=>left < right ? -1 : left > right ? 1 : 0).map(([key, child])=>[
29
+ key,
30
+ sortJsonValue(child)
31
+ ]));
32
+ };
33
+ const assertSelfContainedRefs = (value, node)=>{
34
+ if (Array.isArray(value)) {
35
+ for (const child of value)assertSelfContainedRefs(child, node);
36
+ return;
37
+ }
38
+ if ('object' != typeof value || null === value) return;
39
+ for (const [key, child] of Object.entries(value)){
40
+ if ('$ref' === key && 'string' == typeof child && !child.startsWith('#')) throw new Error(`Cannot describe node "${node}": inputSchema generated an external $ref.`);
41
+ assertSelfContainedRefs(child, node);
42
+ }
43
+ };
44
+ const toJsonSchema = (node)=>{
45
+ try {
46
+ const schema = z.toJSONSchema(node.inputSchema, {
47
+ target: 'draft-2020-12',
48
+ io: 'input',
49
+ unrepresentable: 'throw',
50
+ cycles: 'ref',
51
+ reused: 'ref'
52
+ });
53
+ assertSelfContainedRefs(schema, node.name);
54
+ return sortJsonValue(schema);
55
+ } catch (error) {
56
+ if (error instanceof Error && error.message.startsWith(`Cannot describe node "${node.name}":`)) throw error;
57
+ throw new Error(`Cannot describe node "${node.name}": inputSchema contains a Zod type that cannot be represented as JSON Schema.`, {
58
+ cause: error
59
+ });
60
+ }
61
+ };
62
+ const renderNode = (node, warnings)=>{
63
+ const sections = [
64
+ `## \`${node.name}\``
65
+ ];
66
+ if (node.title) sections.push(`**Title:** ${node.title.trim()}`);
67
+ if (node.description) sections.push(node.description.trim());
68
+ else {
69
+ warnings.push(`node "${node.name}" has no description`);
70
+ sections.push("> Description not declared. Do not infer this node's behavior.");
71
+ }
72
+ sections.push('### Input Schema');
73
+ if (node.inputSchema) sections.push(`\`\`\`json\n${JSON.stringify(toJsonSchema(node), null, 2)}\n\`\`\``);
74
+ else {
75
+ warnings.push(`node "${node.name}" has no inputSchema`);
76
+ sections.push('> Input schema not declared. Do not invent input fields for this node.');
77
+ }
78
+ return sections.join('\n\n');
79
+ };
80
+ const renderNodeReference = (nodes)=>{
81
+ const warnings = [];
82
+ const sortedNodes = [
83
+ ...nodes
84
+ ].sort((left, right)=>left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
85
+ const sections = [
86
+ GENERATED_COMMENT,
87
+ '# Midscene Test Node Reference',
88
+ 'This document describes the nodes registered by the current Test Project.',
89
+ WORKFLOW_STEP_CONTRACT
90
+ ];
91
+ if (0 === sortedNodes.length) sections.push('No nodes are registered by the current Test Project.');
92
+ else for (const node of sortedNodes)sections.push(renderNode(node, warnings));
93
+ return {
94
+ markdown: `${sections.join('\n\n')}\n`,
95
+ warnings
96
+ };
97
+ };
98
+ function _define_property(obj, key, value) {
99
+ if (key in obj) Object.defineProperty(obj, key, {
100
+ value: value,
101
+ enumerable: true,
102
+ configurable: true,
103
+ writable: true
104
+ });
105
+ else obj[key] = value;
106
+ return obj;
107
+ }
108
+ class WorkflowError extends Error {
109
+ toJSON() {
110
+ return {
111
+ name: this.name,
112
+ message: this.message,
113
+ code: this.code,
114
+ ...void 0 === this.details ? {} : {
115
+ details: this.details
116
+ }
117
+ };
118
+ }
119
+ constructor(message, options = {}){
120
+ super(message, {
121
+ cause: options.cause
122
+ }), _define_property(this, "code", void 0), _define_property(this, "details", void 0);
123
+ this.name = new.target.name;
124
+ this.code = options.code ?? 'WORKFLOW_ERROR';
125
+ this.details = options.details;
126
+ }
127
+ }
128
+ class WorkflowParseError extends WorkflowError {
129
+ constructor(message, details, cause){
130
+ super(message, {
131
+ code: 'WORKFLOW_PARSE_ERROR',
132
+ details,
133
+ cause
134
+ });
135
+ }
136
+ }
137
+ class NodeDefinitionError extends WorkflowError {
138
+ constructor(message, details){
139
+ super(message, {
140
+ code: 'NODE_DEFINITION_ERROR',
141
+ details
142
+ });
143
+ }
144
+ }
145
+ class DuplicateNodeError extends WorkflowError {
146
+ constructor(node){
147
+ super(`Node "${node}" is already registered.`, {
148
+ code: 'DUPLICATE_NODE',
149
+ details: {
150
+ node
151
+ }
152
+ }), _define_property(this, "node", void 0);
153
+ this.node = node;
154
+ }
155
+ }
156
+ class NodeNotFoundError extends WorkflowError {
157
+ constructor(node){
158
+ super(`Node "${node}" is not registered.`, {
159
+ code: 'NODE_NOT_FOUND',
160
+ details: {
161
+ node
162
+ }
163
+ }), _define_property(this, "node", void 0);
164
+ this.node = node;
165
+ }
166
+ }
167
+ class NodeInputValidationError extends WorkflowError {
168
+ static fromZod(node, error) {
169
+ const issues = error.issues.map((issue)=>({
170
+ code: issue.code,
171
+ path: issue.path.map(String).join('.'),
172
+ message: issue.message
173
+ }));
174
+ const firstIssue = issues[0];
175
+ const path = firstIssue?.path || '<root>';
176
+ const message = firstIssue?.message ?? 'invalid input';
177
+ return new NodeInputValidationError(`Node "${node}" input validation failed at "${path}": ${message}`, {
178
+ node,
179
+ issues
180
+ });
181
+ }
182
+ constructor(message, details){
183
+ super(message, {
184
+ code: 'NODE_INPUT_VALIDATION_ERROR',
185
+ details
186
+ });
187
+ }
188
+ }
189
+ class StepTimeoutError extends WorkflowError {
190
+ constructor(timeoutMs, node){
191
+ super(node ? `Node "${node}" timed out after ${timeoutMs}ms.` : `Step timed out after ${timeoutMs}ms.`, {
192
+ code: 'STEP_TIMEOUT',
193
+ details: {
194
+ timeoutMs,
195
+ ...void 0 === node ? {} : {
196
+ node
197
+ }
198
+ }
199
+ }), _define_property(this, "timeoutMs", void 0), _define_property(this, "node", void 0);
200
+ this.timeoutMs = timeoutMs;
201
+ this.node = node;
202
+ }
203
+ }
204
+ class NodeExecutionError extends WorkflowError {
205
+ constructor(node, cause){
206
+ const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
207
+ super(`Node "${node}" failed: ${causeMessage}`, {
208
+ code: 'NODE_EXECUTION_ERROR',
209
+ details: {
210
+ node
211
+ },
212
+ cause
213
+ }), _define_property(this, "node", void 0);
214
+ this.node = node;
215
+ }
216
+ }
217
+ class WorkflowLifecycleError extends WorkflowError {
218
+ constructor(message, details){
219
+ super(message, {
220
+ code: 'WORKFLOW_LIFECYCLE_ERROR',
221
+ details
222
+ });
223
+ }
224
+ }
225
+ class ProjectSetupError extends WorkflowError {
226
+ constructor(cause, details){
227
+ const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
228
+ super(`Project "${details.projectName}" setup failed: ${causeMessage}`, {
229
+ code: 'PROJECT_SETUP_ERROR',
230
+ details,
231
+ cause
232
+ });
233
+ }
234
+ }
235
+ class ProjectTeardownError extends WorkflowError {
236
+ constructor(cause, details){
237
+ const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
238
+ super(`Project "${details.projectName}" teardown failed: ${causeMessage}`, {
239
+ code: 'PROJECT_TEARDOWN_ERROR',
240
+ details,
241
+ cause
242
+ });
243
+ }
244
+ }
245
+ class NodeScopeTeardownError extends WorkflowError {
246
+ constructor(cause, details){
247
+ const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
248
+ super(`${'case' === details.scope ? 'Case attempt' : 'Workflow document'} node teardown failed for "${details.node}": ${causeMessage}`, {
249
+ code: 'NODE_SCOPE_TEARDOWN_ERROR',
250
+ details,
251
+ cause
252
+ });
253
+ }
254
+ }
255
+ class FatalDeviceError extends WorkflowError {
256
+ constructor(message, cause){
257
+ super(message, {
258
+ code: 'FATAL_DEVICE_ERROR',
259
+ cause
260
+ });
261
+ }
262
+ }
263
+ const isFatalDeviceError = (error)=>{
264
+ if (error instanceof FatalDeviceError) return true;
265
+ if (error instanceof WorkflowError && 'FATAL_DEVICE_ERROR' === error.code) return true;
266
+ if (error instanceof Error && /device offline|device not found|(?:adb|bdc|device) connection (?:was )?closed/i.test(error.message)) return true;
267
+ return error instanceof Error && void 0 !== error.cause ? isFatalDeviceError(error.cause) : false;
268
+ };
269
+ function normalizeNodeExecutionError(error, node) {
270
+ return error instanceof WorkflowError ? error : new NodeExecutionError(node, error);
271
+ }
272
+ const validateOptionalText = (value, field, node)=>{
273
+ if (void 0 !== value && ('string' != typeof value || 0 === value.trim().length)) throw new NodeDefinitionError(`Node "${node}" ${field} must be a non-empty string.`, {
274
+ node,
275
+ field
276
+ });
277
+ };
278
+ const validateInputSchema = (schema, node)=>{
279
+ if (void 0 === schema) return;
280
+ if (!(schema instanceof z.ZodObject)) throw new NodeDefinitionError(`Node "${node}" inputSchema must be a Zod object schema.`, {
281
+ node,
282
+ field: 'inputSchema'
283
+ });
284
+ if ('$' in schema.shape) throw new NodeDefinitionError(`Node "${node}" inputSchema must not declare "$" as an input property.`, {
285
+ node,
286
+ field: 'inputSchema.$'
287
+ });
288
+ };
289
+ const validateDefinition = (options)=>{
290
+ if (!options || 'object' != typeof options) throw new NodeDefinitionError('Node definition must be an object.');
291
+ if ('string' != typeof options.name || 0 === options.name.trim().length) throw new NodeDefinitionError('Node name must be a non-empty string.');
292
+ validateOptionalText(options.title, 'title', options.name);
293
+ validateOptionalText(options.description, "description", options.name);
294
+ validateInputSchema(options.inputSchema, options.name);
295
+ if ('function' != typeof options.execute) throw new NodeDefinitionError(`Node "${options.name}" must provide an execute function.`, {
296
+ node: options.name
297
+ });
298
+ };
299
+ function defineNode(options) {
300
+ validateDefinition(options);
301
+ return options;
302
+ }
303
+ function _check_private_redeclaration(obj, privateCollection) {
304
+ if (privateCollection.has(obj)) throw new TypeError("Cannot initialize the same private elements twice on an object");
305
+ }
306
+ function _class_apply_descriptor_get(receiver, descriptor) {
307
+ if (descriptor.get) return descriptor.get.call(receiver);
308
+ return descriptor.value;
309
+ }
310
+ function _class_extract_field_descriptor(receiver, privateMap, action) {
311
+ if (!privateMap.has(receiver)) throw new TypeError("attempted to " + action + " private field on non-instance");
312
+ return privateMap.get(receiver);
313
+ }
314
+ function _class_private_field_get(receiver, privateMap) {
315
+ var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get");
316
+ return _class_apply_descriptor_get(receiver, descriptor);
317
+ }
318
+ function _class_private_field_init(obj, privateMap, value) {
319
+ _check_private_redeclaration(obj, privateMap);
320
+ privateMap.set(obj, value);
321
+ }
322
+ var _nodes = /*#__PURE__*/ new WeakMap();
323
+ class NodeRegistry {
324
+ register(node) {
325
+ defineNode(node);
326
+ if (_class_private_field_get(this, _nodes).has(node.name)) throw new DuplicateNodeError(node.name);
327
+ _class_private_field_get(this, _nodes).set(node.name, node);
328
+ return this;
329
+ }
330
+ get(name) {
331
+ return _class_private_field_get(this, _nodes).get(name);
332
+ }
333
+ require(name) {
334
+ const node = this.get(name);
335
+ if (!node) throw new NodeNotFoundError(name);
336
+ return node;
337
+ }
338
+ has(name) {
339
+ return _class_private_field_get(this, _nodes).has(name);
340
+ }
341
+ names() {
342
+ return [
343
+ ..._class_private_field_get(this, _nodes).keys()
344
+ ];
345
+ }
346
+ definitions() {
347
+ return [
348
+ ..._class_private_field_get(this, _nodes).values()
349
+ ];
350
+ }
351
+ constructor(nodes = []){
352
+ _class_private_field_init(this, _nodes, {
353
+ writable: true,
354
+ value: new Map()
355
+ });
356
+ for (const node of nodes)this.register(node);
357
+ }
358
+ }
359
+ const getDefaultExport = (loaded, absolutePath)=>{
360
+ if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
361
+ const defaultExport = loaded.default;
362
+ if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
363
+ if ('default' in defaultExport) return defaultExport.default;
364
+ throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
365
+ }
366
+ return defaultExport;
367
+ }
368
+ throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
369
+ };
370
+ const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
371
+ const rejectUnknownKeys = (value, allowedKeys, label)=>{
372
+ const allowed = new Set(allowedKeys);
373
+ const unknown = Object.keys(value).find((key)=>!allowed.has(key));
374
+ if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
375
+ };
376
+ const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
377
+ const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
378
+ if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
379
+ if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
380
+ return value.map((pattern, index)=>{
381
+ if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
382
+ if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
383
+ if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
384
+ if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
385
+ if (pattern.startsWith('!')) {
386
+ if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
387
+ throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
388
+ }
389
+ return pattern;
390
+ });
391
+ };
392
+ const validateTestFileSelection = (value, label = 'files')=>{
393
+ if (void 0 === value) return;
394
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
395
+ const include = validatePatterns(value.include, 'include', label);
396
+ const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
397
+ return Object.freeze({
398
+ include: Object.freeze(include),
399
+ ...exclude ? {
400
+ exclude: Object.freeze(exclude)
401
+ } : {}
402
+ });
403
+ };
404
+ const validateTagList = (value, field, label)=>{
405
+ if (void 0 === value) return [];
406
+ if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
407
+ return value.map((tag, index)=>{
408
+ if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
409
+ return tag;
410
+ });
411
+ };
412
+ const validateTagSelection = (value, label)=>{
413
+ if (void 0 === value) return Object.freeze({
414
+ include: [],
415
+ exclude: []
416
+ });
417
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
418
+ return Object.freeze({
419
+ include: Object.freeze(validateTagList(value.include, 'include', label)),
420
+ exclude: Object.freeze(validateTagList(value.exclude, 'exclude', label))
421
+ });
422
+ };
423
+ function assertJsonValue(value, path, seen) {
424
+ if (null === value || 'string' == typeof value || 'boolean' == typeof value) return;
425
+ if ('number' == typeof value) {
426
+ if (Number.isFinite(value)) return;
427
+ throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
428
+ }
429
+ if ('object' != typeof value || void 0 === value) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
430
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && null !== Object.getPrototypeOf(value)) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
431
+ if (seen.has(value)) throw new TypeError(`Midscene config ${path} must not contain cycles.`);
432
+ seen.add(value);
433
+ if (Array.isArray(value)) value.forEach((item, index)=>assertJsonValue(item, `${path}[${index}]`, seen));
434
+ else for (const [key, child] of Object.entries(value))assertJsonValue(child, `${path}.${key}`, seen);
435
+ seen.delete(value);
436
+ }
437
+ const deepFreezeJson = (value)=>{
438
+ if ('object' == typeof value && null !== value) {
439
+ for (const child of Object.values(value))deepFreezeJson(child);
440
+ Object.freeze(value);
441
+ }
442
+ return value;
443
+ };
444
+ const validateVariables = (value, label)=>{
445
+ if (void 0 === value) return Object.freeze({});
446
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
447
+ assertJsonValue(value, label, new Set());
448
+ return deepFreezeJson(value);
449
+ };
450
+ const platforms = new Set([
451
+ 'web',
452
+ 'android',
453
+ 'ios',
454
+ 'computer'
455
+ ]);
456
+ const validatePlatform = (value, label)=>{
457
+ if ('string' != typeof value || !platforms.has(value)) throw new TypeError(`Midscene config ${label} must be one of web, android, ios, computer.`);
458
+ return value;
459
+ };
460
+ const validateProjectSetup = (value, projectPlatform, label)=>{
461
+ if (void 0 === value) return;
462
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
463
+ if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
464
+ if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
465
+ const supported = void 0 === value.platform ? void 0 : Array.isArray(value.platform) ? value.platform.map((item, index)=>validatePlatform(item, `${label}.platform[${index}]`)) : [
466
+ validatePlatform(value.platform, `${label}.platform`)
467
+ ];
468
+ if (supported && !supported.includes(projectPlatform)) throw new TypeError(`Midscene config ${label} does not support project platform "${projectPlatform}".`);
469
+ return value;
470
+ };
471
+ const validatePositiveInteger = (value, fallback, label)=>{
472
+ if (void 0 === value) return fallback;
473
+ if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
474
+ return value;
475
+ };
476
+ const validateNonNegativeInteger = (value, fallback, label)=>{
477
+ if (void 0 === value) return fallback;
478
+ if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
479
+ return value;
480
+ };
481
+ const validateExecutionProjects = (value, defaultSetup)=>{
482
+ if (void 0 === value) {
483
+ let platform = 'web';
484
+ if (void 0 !== defaultSetup) {
485
+ if (!isRecord(defaultSetup)) throw new TypeError('Midscene config setup must be an object.');
486
+ if (Array.isArray(defaultSetup.platform)) {
487
+ if (1 !== defaultSetup.platform.length) throw new TypeError('Midscene config setup.platform must select exactly one platform when projects is omitted.');
488
+ platform = validatePlatform(defaultSetup.platform[0], 'setup.platform[0]');
489
+ } else if (void 0 !== defaultSetup.platform) platform = validatePlatform(defaultSetup.platform, 'setup.platform');
490
+ }
491
+ const setup = validateProjectSetup(defaultSetup, platform, 'setup');
492
+ return {
493
+ hasExplicitProjects: false,
494
+ projects: Object.freeze([
495
+ Object.freeze({
496
+ name: 'default',
497
+ platform,
498
+ ...setup ? {
499
+ setup
500
+ } : {},
501
+ tags: Object.freeze({
502
+ include: [],
503
+ exclude: []
504
+ }),
505
+ retry: 0,
506
+ variables: Object.freeze({})
507
+ })
508
+ ])
509
+ };
510
+ }
511
+ if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
512
+ if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
513
+ const names = new Set();
514
+ const projects = value.map((candidate, index)=>{
515
+ const label = `projects[${index}]`;
516
+ if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
517
+ rejectUnknownKeys(candidate, [
518
+ 'name',
519
+ 'platform',
520
+ 'setup',
521
+ 'files',
522
+ 'tags',
523
+ 'retry',
524
+ 'variables'
525
+ ], label);
526
+ if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
527
+ if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
528
+ names.add(candidate.name);
529
+ const platform = validatePlatform(candidate.platform, `${label}.platform`);
530
+ const files = validateTestFileSelection(candidate.files, `${label}.files`);
531
+ return Object.freeze({
532
+ name: candidate.name,
533
+ platform,
534
+ ...files ? {
535
+ files
536
+ } : {},
537
+ tags: validateTagSelection(candidate.tags, `${label}.tags`),
538
+ retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
539
+ variables: validateVariables(candidate.variables, `${label}.variables`),
540
+ ...void 0 === candidate.setup ? {} : {
541
+ setup: validateProjectSetup(candidate.setup, platform, `${label}.setup`)
542
+ }
543
+ });
544
+ });
545
+ return {
546
+ projects: Object.freeze(projects),
547
+ hasExplicitProjects: true
548
+ };
549
+ };
550
+ const validateTestOptions = (value)=>{
551
+ if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
552
+ const candidate = value ?? {};
553
+ const maxConcurrency = candidate.maxConcurrency;
554
+ if (void 0 !== maxConcurrency && 1 !== maxConcurrency) throw new TypeError('Midscene config test.maxConcurrency currently only supports 1.');
555
+ return Object.freeze({
556
+ maxConcurrency: 1,
557
+ bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
558
+ testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
559
+ });
560
+ };
561
+ const validateOutputPath = (value, fallback, label)=>{
562
+ if (void 0 === value) return fallback;
563
+ if ('string' != typeof value || 0 === value.trim().length) throw new TypeError(`Midscene config ${label} must be non-empty.`);
564
+ return value;
565
+ };
566
+ const validateOutput = (value)=>{
567
+ if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config output must be an object.');
568
+ const candidate = value ?? {};
569
+ return Object.freeze({
570
+ summary: validateOutputPath(candidate.summary, './midscene_run/output/summary.json', 'output.summary'),
571
+ reportDir: validateOutputPath(candidate.reportDir, './midscene_run/report', 'output.reportDir')
572
+ });
573
+ };
574
+ const validateTestProjectDefinition = (definition)=>{
575
+ if (!isRecord(definition) || !('nodes' in definition) || !Array.isArray(definition.nodes)) throw new TypeError('Midscene config must default export an object with a nodes array.');
576
+ if ('setupWorkflow' in definition) throw new TypeError('Midscene config setupWorkflow is no longer supported. Use setup or projects[].setup instead.');
577
+ if ('setupDocument' in definition) throw new TypeError('Midscene config setupDocument is not supported. Use setup or projects[].setup for resources, and beforeAll/afterAll for document lifecycle steps.');
578
+ if ('root' in definition) throw new TypeError('Midscene config root is not supported. Pass the Test Project directory to midscene-test instead.');
579
+ if ('files' in definition) throw new TypeError('Midscene config files is not supported at the root. Move it to projects[].files.');
580
+ if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
581
+ rejectUnknownKeys(definition, [
582
+ 'setup',
583
+ 'projects',
584
+ 'test',
585
+ 'output',
586
+ 'nodes'
587
+ ], 'root');
588
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup);
589
+ const nodes = new NodeRegistry(definition.nodes);
590
+ return {
591
+ ...resolvedProjects,
592
+ test: validateTestOptions(definition.test),
593
+ output: validateOutput(definition.output),
594
+ nodes,
595
+ resolveNode: (name)=>nodes.get(name)
596
+ };
597
+ };
598
+ const assertTypeScriptConfig = (absolutePath)=>{
599
+ if (!absolutePath.endsWith('.ts')) {
600
+ const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
601
+ throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extension: .ts.`);
602
+ }
603
+ };
604
+ const canRetryWithCjsLoader = (error)=>error instanceof SyntaxError && (/^Unexpected (?:identifier|reserved word|token)/.test(error.message) || 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' === error.code);
605
+ async function loadTestProject(configPath) {
606
+ if (!configPath) return validateTestProjectDefinition({
607
+ nodes: []
608
+ });
609
+ const absolutePath = resolve(configPath);
610
+ assertTypeScriptConfig(absolutePath);
611
+ let loaded;
612
+ try {
613
+ loaded = await tsImport(absolutePath, {
614
+ parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
615
+ tsconfig: false
616
+ });
617
+ } catch (error) {
618
+ try {
619
+ if (!canRetryWithCjsLoader(error)) throw error;
620
+ loaded = api_require(absolutePath, pathToFileURL(absolutePath));
621
+ } catch (fallbackError) {
622
+ const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
623
+ throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
624
+ cause: fallbackError
625
+ });
626
+ }
627
+ }
628
+ return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
629
+ }
630
+ const createProjectRuntime = (options)=>{
631
+ const startedAt = new Date();
632
+ const controller = new AbortController();
633
+ const parentSignal = options.signal;
634
+ const abortFromParent = ()=>controller.abort(parentSignal?.reason);
635
+ if (parentSignal?.aborted) abortFromParent();
636
+ else parentSignal?.addEventListener('abort', abortFromParent, {
637
+ once: true
638
+ });
639
+ const teardownStack = [];
640
+ let context;
641
+ let setupError;
642
+ let started = false;
643
+ let acceptingTeardowns = void 0 !== options.setup;
644
+ let finishedResult;
645
+ const createResult = (requestedStatus, teardownErrors = [])=>{
646
+ const endedAt = new Date();
647
+ return {
648
+ projectName: options.project.name,
649
+ platform: options.project.platform,
650
+ status: 'failed' === requestedStatus || setupError || teardownErrors.length > 0 ? 'failed' : 'success',
651
+ ...setupError ? {
652
+ setupError
653
+ } : {},
654
+ ...teardownErrors.length > 0 ? {
655
+ teardownErrors
656
+ } : {},
657
+ startedAt: startedAt.toISOString(),
658
+ endedAt: endedAt.toISOString(),
659
+ durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
660
+ };
661
+ };
662
+ const onTeardown = (teardown)=>{
663
+ if (!acceptingTeardowns) throw new WorkflowLifecycleError('onTeardown() can only be called while Project setup is running.', {
664
+ projectName: options.project.name
665
+ });
666
+ if ('function' != typeof teardown) throw new WorkflowLifecycleError('Project onTeardown() requires a teardown function.', {
667
+ projectName: options.project.name
668
+ });
669
+ teardownStack.push({
670
+ registrationIndex: teardownStack.length,
671
+ teardown
672
+ });
673
+ };
674
+ return {
675
+ get context () {
676
+ return context;
677
+ },
678
+ get signal () {
679
+ return controller.signal;
680
+ },
681
+ get canRun () {
682
+ return started && !setupError && !controller.signal.aborted;
683
+ },
684
+ async start () {
685
+ if (started) throw new WorkflowLifecycleError('Project runtime has already started.', {
686
+ projectName: options.project.name
687
+ });
688
+ started = true;
689
+ if (controller.signal.aborted) {
690
+ acceptingTeardowns = false;
691
+ return createResult('failed');
692
+ }
693
+ if (options.setup) try {
694
+ context = await options.setup.setup({
695
+ project: options.project,
696
+ env: Object.freeze({
697
+ ...process.env
698
+ }),
699
+ signal: controller.signal,
700
+ onTeardown
701
+ });
702
+ } catch (error) {
703
+ setupError = new ProjectSetupError(error, {
704
+ projectName: options.project.name
705
+ });
706
+ } finally{
707
+ acceptingTeardowns = false;
708
+ }
709
+ return createResult(setupError ? 'failed' : 'success');
710
+ },
711
+ async finish (requestedStatus = 'success') {
712
+ if (finishedResult) return finishedResult;
713
+ if (!started) throw new WorkflowLifecycleError('Project runtime must start before it can finish.', {
714
+ projectName: options.project.name
715
+ });
716
+ const statusBeforeTeardown = createResult(requestedStatus).status;
717
+ const teardownErrors = [];
718
+ for (const { registrationIndex, teardown } of teardownStack.reverse())try {
719
+ await teardown({
720
+ project: options.project,
721
+ context,
722
+ status: statusBeforeTeardown,
723
+ ...setupError ? {
724
+ setupError
725
+ } : {}
726
+ });
727
+ } catch (error) {
728
+ teardownErrors.push(new ProjectTeardownError(error, {
729
+ projectName: options.project.name,
730
+ registrationIndex
731
+ }));
732
+ }
733
+ parentSignal?.removeEventListener('abort', abortFromParent);
734
+ finishedResult = createResult(requestedStatus, teardownErrors);
735
+ return finishedResult;
736
+ },
737
+ abort (reason) {
738
+ controller.abort(reason);
739
+ }
740
+ };
741
+ };
742
+ const supportedMetaKeys = new Set([
743
+ 'timeout',
744
+ 'continue-on-error'
745
+ ]);
746
+ function isMapping(value) {
747
+ return 'object' == typeof value && null !== value && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || null === Object.getPrototypeOf(value));
748
+ }
749
+ function formatStep(index) {
750
+ return `step ${index + 1}`;
751
+ }
752
+ function normalizeMeta(value, index) {
753
+ if (void 0 === value) return {
754
+ continueOnError: false
755
+ };
756
+ if (!isMapping(value)) throw new WorkflowParseError(`${formatStep(index)} "$" must be a mapping.`, {
757
+ index
758
+ });
759
+ for (const key of Object.keys(value))if (!supportedMetaKeys.has(key)) throw new WorkflowParseError(`${formatStep(index)} has unsupported engine metadata "${key}".`, {
760
+ index,
761
+ key
762
+ });
763
+ const timeout = value.timeout;
764
+ if (void 0 !== timeout && ('number' != typeof timeout || !Number.isFinite(timeout) || timeout <= 0)) throw new WorkflowParseError(`${formatStep(index)} "$.timeout" must be a positive number of milliseconds.`, {
765
+ index,
766
+ timeout
767
+ });
768
+ const continueOnError = value['continue-on-error'];
769
+ if (void 0 !== continueOnError && 'boolean' != typeof continueOnError) throw new WorkflowParseError(`${formatStep(index)} "$.continue-on-error" must be a boolean.`, {
770
+ index,
771
+ continueOnError
772
+ });
773
+ return {
774
+ ...void 0 === timeout ? {} : {
775
+ timeoutMs: timeout
776
+ },
777
+ continueOnError: continueOnError ?? false
778
+ };
779
+ }
780
+ function validateCommonNodeInput(input, index) {
781
+ if (void 0 !== input.prompt && 'string' != typeof input.prompt) throw new WorkflowParseError(`${formatStep(index)} "prompt" must be a string.`, {
782
+ index,
783
+ prompt: input.prompt
784
+ });
785
+ }
786
+ function normalizeStep(value, index = 0) {
787
+ if (!isMapping(value)) throw new WorkflowParseError(`${formatStep(index)} must be a mapping.`, {
788
+ index
789
+ });
790
+ const entries = Object.entries(value);
791
+ if (1 !== entries.length) throw new WorkflowParseError(`${formatStep(index)} must contain exactly one node.`, {
792
+ index,
793
+ nodes: Object.keys(value)
794
+ });
795
+ const [node, rawValue] = entries[0];
796
+ if (0 === node.trim().length) throw new WorkflowParseError(`${formatStep(index)} node name must not be empty.`, {
797
+ index
798
+ });
799
+ if ('string' == typeof rawValue) return {
800
+ node,
801
+ input: {
802
+ prompt: rawValue
803
+ },
804
+ meta: {
805
+ continueOnError: false
806
+ }
807
+ };
808
+ if (!isMapping(rawValue)) throw new WorkflowParseError(`${formatStep(index)} value must be a string shorthand or a mapping.`, {
809
+ index,
810
+ node
811
+ });
812
+ const { $: rawMeta, ...input } = rawValue;
813
+ validateCommonNodeInput(input, index);
814
+ return {
815
+ node,
816
+ input,
817
+ meta: normalizeMeta(rawMeta, index)
818
+ };
819
+ }
820
+ function normalizeSteps(steps) {
821
+ if (!Array.isArray(steps)) throw new WorkflowParseError('Steps must be an array.');
822
+ return steps.map((step, index)=>normalizeStep(step, index));
823
+ }
824
+ const assertJsonSerializable = (value, label, node)=>{
825
+ const visit = (candidate, path, seen)=>{
826
+ if (null === candidate || 'string' == typeof candidate || 'boolean' == typeof candidate) return;
827
+ if ('number' == typeof candidate) {
828
+ if (Number.isFinite(candidate)) return;
829
+ throw new TypeError(`${path} contains a non-finite number.`);
830
+ }
831
+ if (void 0 === candidate) throw new TypeError(`${path} contains undefined.`);
832
+ if ('object' != typeof candidate) throw new TypeError(`${path} is not JSON-serializable.`);
833
+ if (!Array.isArray(candidate) && Object.getPrototypeOf(candidate) !== Object.prototype && null !== Object.getPrototypeOf(candidate)) throw new TypeError(`${path} must contain only plain objects.`);
834
+ if (seen.has(candidate)) throw new TypeError(`${path} contains a cycle.`);
835
+ seen.add(candidate);
836
+ if (Array.isArray(candidate)) candidate.forEach((child, index)=>visit(child, `${path}[${index}]`, seen));
837
+ else for (const [key, child] of Object.entries(candidate))visit(child, `${path}.${key}`, seen);
838
+ seen.delete(candidate);
839
+ };
840
+ try {
841
+ visit(value, label, new Set());
842
+ } catch (error) {
843
+ throw new NodeExecutionError(node, error);
844
+ }
845
+ };
846
+ const cloneAndFreezeJson = (value)=>{
847
+ if (Array.isArray(value)) return Object.freeze(value.map(cloneAndFreezeJson));
848
+ if ('object' == typeof value && null !== value) return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, child])=>[
849
+ key,
850
+ cloneAndFreezeJson(child)
851
+ ])));
852
+ return value;
853
+ };
854
+ const toHistoryEntry = (result, scope)=>{
855
+ assertJsonSerializable(result.input, 'input', result.node);
856
+ if (result.output?.data !== void 0) assertJsonSerializable(result.output.data, 'output.data', result.node);
857
+ return Object.freeze({
858
+ scope,
859
+ phase: result.phase,
860
+ stepIndex: result.stepIndex,
861
+ node: result.node,
862
+ input: cloneAndFreezeJson(result.input),
863
+ ...'string' == typeof result.input?.prompt ? {
864
+ intent: result.input.prompt
865
+ } : {},
866
+ status: 'success' === result.status ? 'passed' : 'failed',
867
+ ...result.output?.summary === void 0 ? {} : {
868
+ summary: result.output.summary
869
+ },
870
+ ...result.output?.data === void 0 ? {} : {
871
+ data: cloneAndFreezeJson(result.output.data)
872
+ },
873
+ ...result.error ? {
874
+ error: Object.freeze({
875
+ name: result.error.name,
876
+ message: result.error.message
877
+ })
878
+ } : {}
879
+ });
880
+ };
881
+ const createHistory = (prefix, results, scope)=>Object.freeze([
882
+ ...prefix,
883
+ ...results.map((result)=>toHistoryEntry(result, scope))
884
+ ]);
885
+ function validateNodeOutput(output, node) {
886
+ if (void 0 === output) return;
887
+ if ('object' != typeof output || null === output || Array.isArray(output)) throw new NodeExecutionError(node, new TypeError('Node output must be an object or undefined.'));
888
+ if ('summary' in output && void 0 !== output.summary && 'string' != typeof output.summary) throw new NodeExecutionError(node, new TypeError('Node output summary must be a string.'));
889
+ if ('data' in output && void 0 !== output.data) assertJsonSerializable(output.data, 'output.data', node);
890
+ return output;
891
+ }
892
+ function createStepResultBase(step, startedAt, phase, stepIndex) {
893
+ const endedAt = new Date();
894
+ return {
895
+ node: step.node,
896
+ phase,
897
+ stepIndex,
898
+ input: step.input,
899
+ meta: step.meta,
900
+ startedAt: startedAt.toISOString(),
901
+ endedAt: endedAt.toISOString(),
902
+ durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
903
+ };
904
+ }
905
+ async function executeNode(step, execute, phase, stepIndex, options) {
906
+ validateCommonNodeInput(step.input, stepIndex);
907
+ const startedAt = new Date();
908
+ const abortController = new AbortController();
909
+ const parentSignal = options.parentSignal;
910
+ const abortFromParent = ()=>abortController.abort(parentSignal?.reason ?? new Error('Workflow execution aborted.'));
911
+ if (parentSignal?.aborted) abortFromParent();
912
+ else parentSignal?.addEventListener('abort', abortFromParent, {
913
+ once: true
914
+ });
915
+ const timeoutMs = step.meta.timeoutMs ?? options.defaultTimeoutMs;
916
+ let timeout;
917
+ let timeoutError;
918
+ const execution = Promise.resolve().then(()=>{
919
+ if (abortController.signal.aborted) throw abortController.signal.reason ?? new Error('Workflow execution aborted.');
920
+ return execute(abortController.signal);
921
+ });
922
+ const timedExecution = void 0 === timeoutMs ? execution : Promise.race([
923
+ execution,
924
+ new Promise((_, reject)=>{
925
+ timeout = setTimeout(()=>{
926
+ timeoutError = new StepTimeoutError(timeoutMs, step.node);
927
+ abortController.abort(timeoutError);
928
+ reject(timeoutError);
929
+ }, timeoutMs);
930
+ })
931
+ ]);
932
+ try {
933
+ const output = validateNodeOutput(await timedExecution, step.node);
934
+ return {
935
+ ...createStepResultBase(step, startedAt, phase, stepIndex),
936
+ status: 'success',
937
+ continuedAfterError: false,
938
+ ...void 0 === output ? {} : {
939
+ output
940
+ }
941
+ };
942
+ } catch (error) {
943
+ return {
944
+ ...createStepResultBase(step, startedAt, phase, stepIndex),
945
+ status: 'failed',
946
+ continuedAfterError: step.meta.continueOnError,
947
+ error: normalizeNodeExecutionError(timeoutError ?? error, step.node)
948
+ };
949
+ } finally{
950
+ if (void 0 !== timeout) clearTimeout(timeout);
951
+ parentSignal?.removeEventListener('abort', abortFromParent);
952
+ }
953
+ }
954
+ async function parseNodeInput(node, input) {
955
+ if (!node.inputSchema) return input;
956
+ const parsed = await node.inputSchema.safeParseAsync(input);
957
+ if (!parsed.success) throw NodeInputValidationError.fromZod(node.name, parsed.error);
958
+ return parsed.data;
959
+ }
960
+ async function executeStep(step, node, target, context, execution = {}) {
961
+ const phase = 'case' === target.scope ? target.case.phase : target.document.phase;
962
+ const stepIndex = 'case' === target.scope ? target.case.stepIndex : target.document.stepIndex;
963
+ return executeNode(step, async (signal)=>{
964
+ const input = await parseNodeInput(node, step.input);
965
+ const common = {
966
+ input,
967
+ $: step.meta,
968
+ signal,
969
+ context,
970
+ history: execution.history ?? Object.freeze([]),
971
+ onTeardown: (teardown)=>{
972
+ if (!execution.onTeardown) throw new NodeExecutionError(step.node, new Error('The current execution scope cannot register teardown.'));
973
+ execution.onTeardown(step.node, teardown);
974
+ }
975
+ };
976
+ return 'case' === target.scope ? node.execute({
977
+ ...common,
978
+ scope: 'case',
979
+ case: target.case
980
+ }) : node.execute({
981
+ ...common,
982
+ scope: 'document',
983
+ document: target.document
984
+ });
985
+ }, phase, stepIndex, {
986
+ parentSignal: execution.signal,
987
+ defaultTimeoutMs: execution.defaultTimeoutMs
988
+ });
989
+ }
990
+ const reportPathsFromTeardown = (result)=>{
991
+ if (void 0 === result) return [];
992
+ if ('object' != typeof result || null === result || Array.isArray(result)) throw new TypeError('Node teardown result must be an object or undefined.');
993
+ const paths = result.reportPaths;
994
+ if (void 0 === paths) return [];
995
+ if (!Array.isArray(paths)) throw new TypeError('Node teardown reportPaths must be an array.');
996
+ return paths.map((path, index)=>{
997
+ if ('string' != typeof path || 0 === path.trim().length) throw new TypeError(`Node teardown reportPaths[${index}] must be a non-empty string.`);
998
+ if (!isAbsolute(path)) throw new TypeError(`Node teardown reportPaths[${index}] must be an absolute path.`);
999
+ return path;
1000
+ });
1001
+ };
1002
+ function createDocumentRuntime(document, options) {
1003
+ const documentRunId = options.createDocumentRunId?.() ?? randomUUID();
1004
+ const startedAt = new Date();
1005
+ const beforeAll = [];
1006
+ const afterAll = [];
1007
+ const completedNodes = [];
1008
+ const nodes = {
1009
+ beforeAll: document.lifecycle.beforeAll.map((step)=>options.resolveNode(step.node)),
1010
+ afterAll: document.lifecycle.afterAll.map((step)=>options.resolveNode(step.node))
1011
+ };
1012
+ const project = options.project ?? Object.freeze({
1013
+ name: document.projectId,
1014
+ platform: 'web',
1015
+ tags: Object.freeze({
1016
+ include: [],
1017
+ exclude: []
1018
+ }),
1019
+ retry: 0,
1020
+ variables: Object.freeze({})
1021
+ });
1022
+ const context = options.projectContext;
1023
+ const signal = options.signal ?? new AbortController().signal;
1024
+ const nodeTeardownStack = [];
1025
+ let acceptingNodeTeardowns = true;
1026
+ let started = false;
1027
+ let finishedResult;
1028
+ const reportPaths = new Set();
1029
+ const createResult = (teardownErrors = [])=>{
1030
+ const endedAt = new Date();
1031
+ return {
1032
+ documentId: document.documentId,
1033
+ documentRunId,
1034
+ projectId: document.projectId,
1035
+ projectName: project.name,
1036
+ sourcePath: document.sourcePath,
1037
+ status: completedNodes.some((step)=>'failed' === step.status) || teardownErrors.length > 0 ? 'failed' : 'success',
1038
+ startedAt: startedAt.toISOString(),
1039
+ endedAt: endedAt.toISOString(),
1040
+ durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
1041
+ beforeAll: [
1042
+ ...beforeAll
1043
+ ],
1044
+ afterAll: [
1045
+ ...afterAll
1046
+ ],
1047
+ ...teardownErrors.length > 0 ? {
1048
+ teardownErrors
1049
+ } : {},
1050
+ ...reportPaths.size > 0 ? {
1051
+ reportPaths: [
1052
+ ...reportPaths
1053
+ ]
1054
+ } : {}
1055
+ };
1056
+ };
1057
+ const runPhase = async (phase, results, executionSignal = signal)=>{
1058
+ for (const [stepIndex, step] of document.lifecycle[phase].entries()){
1059
+ const documentContext = {
1060
+ documentId: document.documentId,
1061
+ documentRunId,
1062
+ projectId: document.projectId,
1063
+ projectName: project.name,
1064
+ sourcePath: document.sourcePath,
1065
+ phase,
1066
+ stepIndex,
1067
+ completedNodes: Object.freeze([
1068
+ ...completedNodes
1069
+ ])
1070
+ };
1071
+ const stepInfo = {
1072
+ scope: 'document',
1073
+ node: step.node,
1074
+ stepCount: document.lifecycle[phase].length,
1075
+ document: documentContext
1076
+ };
1077
+ await options.onStepStart?.(stepInfo);
1078
+ const result = await executeStep(step, nodes[phase][stepIndex], {
1079
+ scope: 'document',
1080
+ document: documentContext
1081
+ }, context, {
1082
+ history: createHistory([], completedNodes, 'document'),
1083
+ signal: executionSignal,
1084
+ defaultTimeoutMs: options.defaultTimeoutMs,
1085
+ onTeardown (node, teardown) {
1086
+ if (!acceptingNodeTeardowns) throw new WorkflowLifecycleError('Node teardown can only be registered while a workflow document is running.', {
1087
+ documentId: document.documentId,
1088
+ documentRunId,
1089
+ node
1090
+ });
1091
+ if ('function' != typeof teardown) throw new WorkflowLifecycleError('Node onTeardown() requires a teardown function.', {
1092
+ documentId: document.documentId,
1093
+ documentRunId,
1094
+ node
1095
+ });
1096
+ nodeTeardownStack.push({
1097
+ registrationIndex: nodeTeardownStack.length,
1098
+ node,
1099
+ teardown
1100
+ });
1101
+ }
1102
+ });
1103
+ await options.onStepResult?.(stepInfo, result);
1104
+ results.push(result);
1105
+ completedNodes.push(result);
1106
+ if ('failed' === result.status && !result.continuedAfterError) break;
1107
+ }
1108
+ };
1109
+ return {
1110
+ get context () {
1111
+ return context;
1112
+ },
1113
+ get history () {
1114
+ return createHistory([], completedNodes, 'document');
1115
+ },
1116
+ get canRunCases () {
1117
+ return started && !signal.aborted && !beforeAll.some((step)=>'failed' === step.status);
1118
+ },
1119
+ async start () {
1120
+ if (started) throw new WorkflowLifecycleError('Workflow document runtime has already started.', {
1121
+ documentId: document.documentId,
1122
+ documentRunId
1123
+ });
1124
+ started = true;
1125
+ if (!signal.aborted) await runPhase('beforeAll', beforeAll);
1126
+ return createResult();
1127
+ },
1128
+ async finish () {
1129
+ if (finishedResult) return finishedResult;
1130
+ if (!started) throw new WorkflowLifecycleError('Workflow document runtime must start before it can finish.', {
1131
+ documentId: document.documentId,
1132
+ documentRunId
1133
+ });
1134
+ let finishError;
1135
+ try {
1136
+ await runPhase('afterAll', afterAll, signal.aborted ? new AbortController().signal : signal);
1137
+ } catch (error) {
1138
+ finishError = error;
1139
+ }
1140
+ acceptingNodeTeardowns = false;
1141
+ const teardownErrors = [];
1142
+ for (const { registrationIndex, node, teardown } of nodeTeardownStack.reverse())try {
1143
+ for (const path of reportPathsFromTeardown(await teardown()))reportPaths.add(path);
1144
+ } catch (error) {
1145
+ teardownErrors.push(new NodeScopeTeardownError(error, {
1146
+ scope: 'document',
1147
+ scopeId: documentRunId,
1148
+ node,
1149
+ registrationIndex
1150
+ }));
1151
+ }
1152
+ finishedResult = createResult(teardownErrors);
1153
+ if (finishError) throw finishError;
1154
+ await options.onResult?.(finishedResult);
1155
+ return finishedResult;
1156
+ }
1157
+ };
1158
+ }
1159
+ async function runCollectedCase(collectedCase, options) {
1160
+ const phases = {
1161
+ beforeEach: options.beforeEach ?? [],
1162
+ steps: collectedCase.definition.steps,
1163
+ afterEach: options.afterEach ?? []
1164
+ };
1165
+ const nodes = {
1166
+ beforeEach: phases.beforeEach.map((step)=>options.resolveNode(step.node)),
1167
+ steps: phases.steps.map((step)=>options.resolveNode(step.node)),
1168
+ afterEach: phases.afterEach.map((step)=>options.resolveNode(step.node))
1169
+ };
1170
+ const runId = options.createRunId?.() ?? randomUUID();
1171
+ const startedAt = new Date();
1172
+ const beforeEach = [];
1173
+ const steps = [];
1174
+ const afterEach = [];
1175
+ const completedNodes = [];
1176
+ const teardownStack = [];
1177
+ let acceptingTeardowns = true;
1178
+ const onTeardown = (node, teardown)=>{
1179
+ if (!acceptingTeardowns) throw new WorkflowLifecycleError('Node teardown can only be registered while a case attempt is running.', {
1180
+ runId,
1181
+ node
1182
+ });
1183
+ if ('function' != typeof teardown) throw new WorkflowLifecycleError('Node onTeardown() requires a teardown function.', {
1184
+ runId,
1185
+ node
1186
+ });
1187
+ teardownStack.push({
1188
+ registrationIndex: teardownStack.length,
1189
+ node,
1190
+ teardown
1191
+ });
1192
+ };
1193
+ const runPhase = async (phase, results, signal = options.signal)=>{
1194
+ for (const [stepIndex, step] of phases[phase].entries()){
1195
+ const caseContext = {
1196
+ caseId: collectedCase.caseId,
1197
+ runId,
1198
+ projectName: options.projectName ?? collectedCase.projectId,
1199
+ attemptIndex: options.attemptIndex ?? 0,
1200
+ name: collectedCase.definition.name,
1201
+ sourcePath: collectedCase.sourcePath,
1202
+ caseIndex: collectedCase.caseIndex,
1203
+ phase,
1204
+ stepIndex,
1205
+ completedSteps: Object.freeze([
1206
+ ...steps
1207
+ ]),
1208
+ completedNodes: Object.freeze([
1209
+ ...completedNodes
1210
+ ])
1211
+ };
1212
+ const stepInfo = {
1213
+ scope: 'case',
1214
+ node: step.node,
1215
+ stepCount: phases[phase].length,
1216
+ case: caseContext
1217
+ };
1218
+ await options.onStepStart?.(stepInfo);
1219
+ const result = await executeStep(step, nodes[phase][stepIndex], {
1220
+ scope: 'case',
1221
+ case: caseContext
1222
+ }, options.context, {
1223
+ history: createHistory(options.documentHistory ?? [], completedNodes, 'case'),
1224
+ signal,
1225
+ defaultTimeoutMs: options.defaultTimeoutMs,
1226
+ onTeardown
1227
+ });
1228
+ await options.onStepResult?.(stepInfo, result);
1229
+ results.push(result);
1230
+ completedNodes.push(result);
1231
+ if ('failed' === result.status && !result.continuedAfterError) break;
1232
+ }
1233
+ };
1234
+ let executionError;
1235
+ try {
1236
+ await runPhase('beforeEach', beforeEach);
1237
+ if (!beforeEach.some((step)=>'failed' === step.status)) await runPhase('steps', steps);
1238
+ } catch (error) {
1239
+ executionError = error;
1240
+ }
1241
+ try {
1242
+ const cleanupSignal = options.signal?.aborted ? new AbortController().signal : options.signal;
1243
+ await runPhase('afterEach', afterEach, cleanupSignal);
1244
+ } catch (error) {
1245
+ executionError ??= error;
1246
+ }
1247
+ acceptingTeardowns = false;
1248
+ const teardownErrors = [];
1249
+ const reportPaths = new Set();
1250
+ for (const { registrationIndex, node, teardown } of teardownStack.reverse())try {
1251
+ for (const path of reportPathsFromTeardown(await teardown()))reportPaths.add(path);
1252
+ } catch (error) {
1253
+ teardownErrors.push(new NodeScopeTeardownError(error, {
1254
+ scope: 'case',
1255
+ scopeId: runId,
1256
+ node,
1257
+ registrationIndex
1258
+ }));
1259
+ }
1260
+ if (executionError) throw executionError;
1261
+ const endedAt = new Date();
1262
+ const result = {
1263
+ caseId: collectedCase.caseId,
1264
+ runId,
1265
+ projectName: options.projectName ?? collectedCase.projectId,
1266
+ attemptIndex: options.attemptIndex ?? 0,
1267
+ name: collectedCase.definition.name,
1268
+ sourcePath: collectedCase.sourcePath,
1269
+ caseIndex: collectedCase.caseIndex,
1270
+ status: completedNodes.some((step)=>'failed' === step.status) || teardownErrors.length > 0 ? 'failed' : 'success',
1271
+ beforeEach,
1272
+ steps,
1273
+ afterEach,
1274
+ ...teardownErrors.length > 0 ? {
1275
+ teardownErrors
1276
+ } : {},
1277
+ ...reportPaths.size > 0 ? {
1278
+ reportPaths: [
1279
+ ...reportPaths
1280
+ ]
1281
+ } : {},
1282
+ startedAt: startedAt.toISOString(),
1283
+ endedAt: endedAt.toISOString(),
1284
+ durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
1285
+ };
1286
+ await options.onResult?.(result);
1287
+ return result;
1288
+ }
1289
+ const asNotRun = (collectedCase, projectName, reason)=>({
1290
+ caseId: collectedCase.caseId,
1291
+ projectName,
1292
+ name: collectedCase.definition.name,
1293
+ sourcePath: collectedCase.sourcePath,
1294
+ caseIndex: collectedCase.caseIndex,
1295
+ status: 'not-run',
1296
+ notRunReason: reason
1297
+ });
1298
+ async function runWorkflowDocument(document, options) {
1299
+ const createDocumentRunId = options.createDocumentRunId;
1300
+ const createCaseRunId = options.createCaseRunId;
1301
+ const projectName = options.project?.name ?? document.projectId;
1302
+ const retry = options.retry ?? options.project?.retry ?? 0;
1303
+ if (!Number.isInteger(retry) || retry < 0) throw new TypeError('Workflow document retry must be a non-negative integer.');
1304
+ const runtime = createDocumentRuntime(document, {
1305
+ resolveNode: options.resolveNode,
1306
+ project: options.project,
1307
+ projectContext: options.projectContext,
1308
+ signal: options.signal,
1309
+ defaultTimeoutMs: options.defaultTimeoutMs,
1310
+ onStepStart: options.onStepStart,
1311
+ onStepResult: options.onStepResult,
1312
+ onResult: options.onDocumentResult,
1313
+ createDocumentRunId: createDocumentRunId ? ()=>createDocumentRunId(document) : void 0
1314
+ });
1315
+ const cases = [];
1316
+ let runtimeStarted = false;
1317
+ let documentResult;
1318
+ let executionError;
1319
+ let fatalError = false;
1320
+ try {
1321
+ runtimeStarted = true;
1322
+ await runtime.start();
1323
+ if (runtime.canRunCases) for (const collectedCase of document.cases){
1324
+ if (fatalError || options.shouldStop?.()) {
1325
+ const outcome = asNotRun(collectedCase, projectName, fatalError ? 'fatal-error' : options.stopReason?.() ?? 'interrupted');
1326
+ cases.push(outcome);
1327
+ await options.onCaseOutcome?.(outcome);
1328
+ continue;
1329
+ }
1330
+ await options.onCaseStart?.(collectedCase);
1331
+ const attempts = [];
1332
+ for(let attemptIndex = 0; attemptIndex <= retry; attemptIndex += 1){
1333
+ const run = await runCollectedCase(collectedCase, {
1334
+ resolveNode: options.resolveNode,
1335
+ beforeEach: document.lifecycle.beforeEach,
1336
+ afterEach: document.lifecycle.afterEach,
1337
+ context: runtime.context,
1338
+ projectName,
1339
+ attemptIndex,
1340
+ documentHistory: runtime.history,
1341
+ signal: options.signal,
1342
+ defaultTimeoutMs: options.defaultTimeoutMs,
1343
+ onStepStart: options.onStepStart,
1344
+ onStepResult: options.onStepResult,
1345
+ onResult: options.onCaseResult,
1346
+ createRunId: createCaseRunId ? ()=>createCaseRunId(collectedCase, attemptIndex) : void 0
1347
+ });
1348
+ attempts.push(run);
1349
+ fatalError = options.isFatalError?.(run) ?? false;
1350
+ if ('success' === run.status || fatalError) break;
1351
+ }
1352
+ const run = attempts.at(-1);
1353
+ const outcome = {
1354
+ caseId: collectedCase.caseId,
1355
+ projectName,
1356
+ name: collectedCase.definition.name,
1357
+ sourcePath: collectedCase.sourcePath,
1358
+ caseIndex: collectedCase.caseIndex,
1359
+ status: run.status,
1360
+ run,
1361
+ attempts
1362
+ };
1363
+ cases.push(outcome);
1364
+ await options.onCaseOutcome?.(outcome);
1365
+ }
1366
+ else {
1367
+ const reason = options.signal?.aborted ? options.stopReason?.() ?? 'interrupted' : 'document-start-failed';
1368
+ cases.push(...document.cases.map((collectedCase)=>asNotRun(collectedCase, projectName, reason)));
1369
+ }
1370
+ } catch (error) {
1371
+ executionError = error;
1372
+ } finally{
1373
+ if (runtimeStarted) try {
1374
+ documentResult = await runtime.finish();
1375
+ } catch (error) {
1376
+ executionError ??= error;
1377
+ }
1378
+ }
1379
+ if (executionError) throw executionError;
1380
+ if (!documentResult) throw new Error(`Workflow document "${document.sourcePath}" did not produce a result.`);
1381
+ return {
1382
+ document: documentResult,
1383
+ cases
1384
+ };
1385
+ }
1386
+ const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
1387
+ const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
1388
+ const exactProjectExpression = /^\$\{([^{}]+)\}$/;
1389
+ const missingVariable = (kind, name, options)=>{
1390
+ throw new WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
1391
+ ...options.location,
1392
+ variable: name,
1393
+ variableKind: kind
1394
+ });
1395
+ };
1396
+ const projectVariable = (name, options)=>{
1397
+ if (!Object.hasOwn(options.variables ?? {}, name)) return missingVariable('project', name, options);
1398
+ return options.variables[name];
1399
+ };
1400
+ const environmentVariable = (name, options)=>{
1401
+ const value = options.env?.[name];
1402
+ if (void 0 === value) return missingVariable('environment', name, options);
1403
+ return value;
1404
+ };
1405
+ const interpolationValue = (name, value, options)=>{
1406
+ if ('object' == typeof value && null !== value) throw new WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
1407
+ ...options.location,
1408
+ variable: name,
1409
+ variableKind: 'project'
1410
+ });
1411
+ return String(value);
1412
+ };
1413
+ const resolveString = (value, options)=>{
1414
+ const exactEnv = exactEnvExpression.exec(value);
1415
+ if (exactEnv) return environmentVariable(exactEnv[1], options);
1416
+ const exactProject = exactProjectExpression.exec(value);
1417
+ if (exactProject) return projectVariable(exactProject[1], options);
1418
+ return value.replace(interpolationExpression, (_match, environmentName, projectName)=>environmentName ? environmentVariable(environmentName, options) : interpolationValue(projectName, projectVariable(projectName, options), options));
1419
+ };
1420
+ const resolveWorkflowVariables = (value, options)=>{
1421
+ if ('string' == typeof value) return resolveString(value, options);
1422
+ if (Array.isArray(value)) return value.map((child)=>resolveWorkflowVariables(child, options));
1423
+ if ('object' == typeof value && null !== value) return Object.fromEntries(Object.entries(value).map(([key, child])=>[
1424
+ key,
1425
+ resolveWorkflowVariables(child, options)
1426
+ ]));
1427
+ return value;
1428
+ };
1429
+ const collect_isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
1430
+ const collect_rejectUnknownKeys = (value, allowed, label)=>{
1431
+ const allowedSet = new Set(allowed);
1432
+ const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
1433
+ if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
1434
+ field: unknown[0]
1435
+ });
1436
+ };
1437
+ const createCaseId = (projectId, sourcePath, caseIndex)=>createHash('sha256').update(JSON.stringify([
1438
+ projectId,
1439
+ sourcePath,
1440
+ caseIndex
1441
+ ])).digest('hex');
1442
+ const createWorkflowDocumentId = (projectId, sourcePath)=>createHash('sha256').update(JSON.stringify([
1443
+ projectId,
1444
+ sourcePath
1445
+ ])).digest('hex');
1446
+ function collectWorkflowDocument(source, options) {
1447
+ let parsed;
1448
+ try {
1449
+ parsed = load(readFileSync(source.absolutePath, 'utf8'), {
1450
+ schema: JSON_SCHEMA
1451
+ });
1452
+ } catch (error) {
1453
+ throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
1454
+ sourcePath: source.sourcePath
1455
+ }, error);
1456
+ }
1457
+ if (!collect_isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
1458
+ collect_rejectUnknownKeys(parsed, [
1459
+ 'beforeAll',
1460
+ 'beforeEach',
1461
+ 'cases',
1462
+ 'afterEach',
1463
+ 'afterAll'
1464
+ ], 'Workflow document');
1465
+ if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new WorkflowParseError('Workflow document must contain a non-empty cases array.');
1466
+ const sourcePath = source.sourcePath.split(sep).join('/');
1467
+ const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
1468
+ ...step,
1469
+ input: resolveWorkflowVariables(step.input, {
1470
+ variables: options.variables,
1471
+ env: options.env ?? process.env,
1472
+ location: {
1473
+ projectName: source.projectId,
1474
+ sourcePath,
1475
+ phase,
1476
+ stepIndex,
1477
+ ...void 0 === caseIndex ? {} : {
1478
+ caseIndex
1479
+ }
1480
+ }
1481
+ })
1482
+ });
1483
+ const normalizeLifecycle = (phase)=>{
1484
+ const value = parsed[phase];
1485
+ if (void 0 === value) return [];
1486
+ if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
1487
+ phase
1488
+ });
1489
+ return normalizeSteps(value).map((normalized, stepIndex)=>{
1490
+ const resolved = options.resolveNode(normalized.node);
1491
+ if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
1492
+ phase,
1493
+ stepIndex,
1494
+ node: normalized.node
1495
+ });
1496
+ return resolveStepVariables(normalized, phase, stepIndex);
1497
+ });
1498
+ };
1499
+ const lifecycle = {
1500
+ beforeAll: normalizeLifecycle('beforeAll'),
1501
+ beforeEach: normalizeLifecycle('beforeEach'),
1502
+ afterEach: normalizeLifecycle('afterEach'),
1503
+ afterAll: normalizeLifecycle('afterAll')
1504
+ };
1505
+ const ids = new Set();
1506
+ const cases = parsed.cases.map((definition, caseIndex)=>{
1507
+ if (!collect_isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
1508
+ caseIndex
1509
+ });
1510
+ collect_rejectUnknownKeys(definition, [
1511
+ 'name',
1512
+ 'tags',
1513
+ 'steps'
1514
+ ], `Case ${caseIndex + 1}`);
1515
+ if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
1516
+ caseIndex
1517
+ });
1518
+ if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
1519
+ caseIndex
1520
+ });
1521
+ const tags = definition.tags ?? [];
1522
+ if (!Array.isArray(tags) || tags.some((tag)=>'string' != typeof tag || 0 === tag.trim().length)) throw new WorkflowParseError(`Case ${caseIndex + 1} tags must be an array of non-empty strings.`, {
1523
+ caseIndex
1524
+ });
1525
+ const steps = normalizeSteps(definition.steps).map((normalized, stepIndex)=>{
1526
+ if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
1527
+ caseIndex,
1528
+ stepIndex,
1529
+ node: normalized.node
1530
+ });
1531
+ return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
1532
+ });
1533
+ const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
1534
+ if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
1535
+ caseId
1536
+ });
1537
+ ids.add(caseId);
1538
+ return {
1539
+ caseId,
1540
+ projectId: source.projectId,
1541
+ sourcePath,
1542
+ caseIndex,
1543
+ definition: {
1544
+ name: definition.name,
1545
+ tags: tags,
1546
+ steps
1547
+ }
1548
+ };
1549
+ });
1550
+ return {
1551
+ documentId: createWorkflowDocumentId(source.projectId, sourcePath),
1552
+ projectId: source.projectId,
1553
+ sourcePath,
1554
+ lifecycle,
1555
+ cases
1556
+ };
1557
+ }
1558
+ const writeJson = (path, value)=>{
1559
+ mkdirSync(dirname(path), {
1560
+ recursive: true
1561
+ });
1562
+ writeFileSync(path, JSON.stringify(value, null, 2));
1563
+ };
1564
+ const toPosix = (value)=>value.split(sep).join('/');
1565
+ const caseRunResultPath = (result)=>toPosix(join('runs', result.caseId, `${result.runId}.json`));
1566
+ const workflowDocumentRunResultPath = (result)=>toPosix(join('documents', result.documentId, `${result.documentRunId}.json`));
1567
+ const collectionErrorPath = (projectName, sourcePath)=>{
1568
+ const id = createHash('sha256').update(JSON.stringify([
1569
+ projectName,
1570
+ sourcePath
1571
+ ])).digest('hex');
1572
+ return toPosix(join('collection-errors', `${id}.json`));
1573
+ };
1574
+ const writeWorkflowDocumentRunResult = (resultDir, result)=>{
1575
+ writeJson(join(resultDir, workflowDocumentRunResultPath(result)), result);
1576
+ };
1577
+ const writeCaseRunResult = (resultDir, result)=>{
1578
+ writeJson(join(resultDir, caseRunResultPath(result)), result);
1579
+ };
1580
+ const writeCollectionError = (resultDir, collectionError)=>{
1581
+ writeJson(join(resultDir, collectionErrorPath(collectionError.projectName, collectionError.sourcePath)), {
1582
+ kind: 'collection-error',
1583
+ ...collectionError
1584
+ });
1585
+ };
1586
+ const relativeToSummary = (summaryPath, target)=>{
1587
+ const value = relative(dirname(summaryPath), target);
1588
+ return toPosix(value || '.');
1589
+ };
1590
+ const errorJson = (error)=>error instanceof Error && 'toJSON' in error ? error.toJSON() : error;
1591
+ const writeTestProjectRunResult = (options)=>{
1592
+ const { result } = options;
1593
+ const fact = (path)=>toPosix(join(relativeToSummary(result.summaryPath, result.resultDir), path));
1594
+ writeJson(result.summaryPath, {
1595
+ schemaVersion: result.schemaVersion,
1596
+ runId: result.runId,
1597
+ startedAt: result.startedAt,
1598
+ endedAt: result.endedAt,
1599
+ durationMs: result.durationMs,
1600
+ status: result.status,
1601
+ exitCode: result.exitCode,
1602
+ projectRoot: options.projectRoot,
1603
+ ...options.configPath ? {
1604
+ configPath: options.configPath
1605
+ } : {},
1606
+ factsRoot: relativeToSummary(result.summaryPath, result.resultDir),
1607
+ reportDir: relativeToSummary(result.summaryPath, result.reportDir),
1608
+ summary: result.summary,
1609
+ projects: result.projects.map((project)=>({
1610
+ name: project.name,
1611
+ platform: project.platform,
1612
+ status: project.status,
1613
+ retry: project.retry,
1614
+ fileSelection: project.fileSelection,
1615
+ tagSelection: project.tagSelection,
1616
+ sourceCount: project.sourceCount,
1617
+ selectedCaseCount: project.selectedCaseCount,
1618
+ filteredCaseCount: project.filteredCaseCount,
1619
+ ...project.lifecycle ? {
1620
+ lifecycle: {
1621
+ status: project.lifecycle.status,
1622
+ startedAt: project.lifecycle.startedAt,
1623
+ endedAt: project.lifecycle.endedAt,
1624
+ durationMs: project.lifecycle.durationMs,
1625
+ ...project.lifecycle.setupError ? {
1626
+ setupError: errorJson(project.lifecycle.setupError)
1627
+ } : {},
1628
+ ...project.lifecycle.teardownErrors ? {
1629
+ teardownErrors: project.lifecycle.teardownErrors.map(errorJson)
1630
+ } : {}
1631
+ }
1632
+ } : {},
1633
+ cases: project.cases.map((outcome)=>({
1634
+ caseId: outcome.caseId,
1635
+ sourcePath: outcome.sourcePath,
1636
+ caseIndex: outcome.caseIndex,
1637
+ name: outcome.name,
1638
+ status: outcome.status,
1639
+ ...outcome.notRunReason ? {
1640
+ notRunReason: outcome.notRunReason
1641
+ } : {},
1642
+ ...outcome.attempts ? {
1643
+ attempts: outcome.attempts.map((attempt)=>({
1644
+ runId: attempt.runId,
1645
+ attemptIndex: attempt.attemptIndex,
1646
+ status: attempt.status,
1647
+ resultFile: fact(caseRunResultPath(attempt)),
1648
+ ...attempt.reportPaths?.length ? {
1649
+ reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
1650
+ } : {}
1651
+ }))
1652
+ } : {}
1653
+ })),
1654
+ documents: project.documents.map((document)=>({
1655
+ documentId: document.documentId,
1656
+ documentRunId: document.documentRunId,
1657
+ sourcePath: document.sourcePath,
1658
+ status: document.status,
1659
+ resultFile: fact(workflowDocumentRunResultPath(document)),
1660
+ ...document.reportPaths?.length ? {
1661
+ reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
1662
+ } : {}
1663
+ })),
1664
+ collectionErrors: project.collectionErrors.map((error)=>({
1665
+ sourcePath: error.sourcePath,
1666
+ errorFile: fact(collectionErrorPath(error.projectName, error.sourcePath))
1667
+ }))
1668
+ }))
1669
+ });
1670
+ };
1671
+ const CONFIG_NAME = 'midscene.config.ts';
1672
+ const CONFIG_PREFIX = 'midscene.config.';
1673
+ const ALWAYS_IGNORED_PATTERNS = [
1674
+ '.git/**',
1675
+ '.midscene/**',
1676
+ 'midscene_run/**',
1677
+ 'node_modules/**',
1678
+ '**/.git/**',
1679
+ '**/.midscene/**',
1680
+ '**/midscene_run/**',
1681
+ '**/node_modules/**'
1682
+ ];
1683
+ const DEFAULT_TEST_FILE_SELECTION = {
1684
+ include: [
1685
+ '**/*.{yaml,yml}'
1686
+ ]
1687
+ };
1688
+ const test_project_runner_toPosix = (value)=>value.split(sep).join('/');
1689
+ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)=>{
1690
+ const root = resolve(projectRoot);
1691
+ const normalized = validateTestFileSelection(selection);
1692
+ if (!normalized) throw new TypeError('Test file selection is required.');
1693
+ const files = globSync(normalized.include, {
1694
+ absolute: true,
1695
+ caseSensitiveMatch: false,
1696
+ cwd: root,
1697
+ dot: true,
1698
+ expandDirectories: false,
1699
+ followSymbolicLinks: false,
1700
+ ignore: [
1701
+ ...ALWAYS_IGNORED_PATTERNS,
1702
+ ...normalized.exclude ?? []
1703
+ ],
1704
+ onlyFiles: true
1705
+ }).filter((file)=>/\.ya?ml$/i.test(file));
1706
+ return [
1707
+ ...new Set(files.map((file)=>resolve(file)))
1708
+ ].sort((a, b)=>{
1709
+ const relativeA = test_project_runner_toPosix(relative(root, a));
1710
+ const relativeB = test_project_runner_toPosix(relative(root, b));
1711
+ return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
1712
+ });
1713
+ };
1714
+ const discoverTestConfig = (projectRoot)=>{
1715
+ const root = resolve(projectRoot);
1716
+ const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
1717
+ const unsupported = candidates.filter((name)=>name !== CONFIG_NAME);
1718
+ if (unsupported.length > 0) throw new Error([
1719
+ `Unsupported or conflicting Midscene configs found in ${root}:`,
1720
+ ...candidates.map((name)=>`- ${name}`),
1721
+ `Only ${CONFIG_NAME} is supported.`
1722
+ ].join('\n'));
1723
+ return candidates.includes(CONFIG_NAME) ? join(root, CONFIG_NAME) : void 0;
1724
+ };
1725
+ const defaultResultDir = (projectRoot)=>join(projectRoot, '.midscene', 'test-results', `${Date.now()}-${process.pid}`);
1726
+ const assertDirectory = (path, label)=>{
1727
+ if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
1728
+ };
1729
+ const asCollectionError = (projectName, sourcePath, error)=>({
1730
+ projectName,
1731
+ sourcePath,
1732
+ error: error instanceof WorkflowError ? error : new WorkflowParseError(`Failed to collect workflow document "${sourcePath}": ${error instanceof Error ? error.message : String(error)}`, {
1733
+ projectName,
1734
+ sourcePath
1735
+ }, error)
1736
+ });
1737
+ const matchesTags = (tags, selection)=>{
1738
+ if (selection.exclude.some((tag)=>tags.includes(tag))) return false;
1739
+ return 0 === selection.include.length || selection.include.some((tag)=>tags.includes(tag));
1740
+ };
1741
+ const filterDocumentCases = (document, project)=>{
1742
+ const cases = document.cases.filter((item)=>matchesTags(item.definition.tags ?? [], project.tags));
1743
+ const filtered = document.cases.length - cases.length;
1744
+ return 0 === cases.length ? {
1745
+ filtered
1746
+ } : {
1747
+ document: {
1748
+ ...document,
1749
+ cases
1750
+ },
1751
+ filtered
1752
+ };
1753
+ };
1754
+ const test_project_runner_asNotRun = (collectedCase, projectName, reason)=>({
1755
+ caseId: collectedCase.caseId,
1756
+ projectName,
1757
+ name: collectedCase.definition.name,
1758
+ sourcePath: collectedCase.sourcePath,
1759
+ caseIndex: collectedCase.caseIndex,
1760
+ status: 'not-run',
1761
+ notRunReason: reason
1762
+ });
1763
+ const summarize = (projects)=>{
1764
+ const cases = projects.flatMap((project)=>project.cases);
1765
+ const documents = projects.flatMap((project)=>project.documents);
1766
+ return {
1767
+ total: cases.length,
1768
+ passed: cases.filter((item)=>'success' === item.status).length,
1769
+ failed: cases.filter((item)=>'failed' === item.status).length,
1770
+ notRun: cases.filter((item)=>'not-run' === item.status).length,
1771
+ filtered: projects.reduce((total, project)=>total + project.filteredCaseCount, 0),
1772
+ collectionErrors: projects.reduce((total, project)=>total + project.collectionErrors.length, 0),
1773
+ documentFailures: documents.filter((document)=>'failed' === document.status).length,
1774
+ projectFailures: projects.filter((project)=>'failed' === project.status).length
1775
+ };
1776
+ };
1777
+ const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1778
+ const test_project_runner_formatStep = (info)=>{
1779
+ const position = stepPosition(info);
1780
+ const phase = 'steps' === position.phase ? 'step' : position.phase;
1781
+ return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
1782
+ };
1783
+ const formatStepResult = (info, result)=>{
1784
+ const indent = 'case' === info.scope ? ' ' : ' ';
1785
+ const symbol = 'success' === result.status ? '✓' : '✗';
1786
+ const error = result.error ? ` — ${result.error.message}` : '';
1787
+ const continuation = result.continuedAfterError ? '; continuing' : '';
1788
+ return `${indent}${symbol} ${test_project_runner_formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
1789
+ };
1790
+ const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
1791
+ ...attempt.beforeEach,
1792
+ ...attempt.steps,
1793
+ ...attempt.afterEach
1794
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError));
1795
+ const selectProjects = (projects, names)=>{
1796
+ if (!names || 0 === names.length) return projects;
1797
+ const requested = new Set(names);
1798
+ if (requested.size !== names.length) throw new Error('Each --project name may only be specified once.');
1799
+ const known = new Set(projects.map((project)=>project.name));
1800
+ const unknown = names.find((name)=>!known.has(name));
1801
+ if (unknown) throw new Error(`Unknown Midscene project: ${unknown}`);
1802
+ return projects.filter((project)=>requested.has(project.name));
1803
+ };
1804
+ const prepareProject = (project, projectRoot, resolveNode, resultDir)=>{
1805
+ const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
1806
+ const files = discoverTestFiles(projectRoot, fileSelection);
1807
+ const sources = files.map((absolutePath)=>({
1808
+ projectId: project.name,
1809
+ sourcePath: test_project_runner_toPosix(relative(projectRoot, absolutePath)),
1810
+ absolutePath
1811
+ }));
1812
+ const collectionErrors = [];
1813
+ const documents = [];
1814
+ let filteredCaseCount = 0;
1815
+ if (0 === sources.length) {
1816
+ const error = asCollectionError(project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
1817
+ projectName: project.name,
1818
+ projectRoot
1819
+ }));
1820
+ collectionErrors.push(error);
1821
+ writeCollectionError(resultDir, error);
1822
+ }
1823
+ for (const source of sources)try {
1824
+ const collected = collectWorkflowDocument(source, {
1825
+ resolveNode,
1826
+ variables: project.variables,
1827
+ env: process.env
1828
+ });
1829
+ const filtered = filterDocumentCases(collected, project);
1830
+ filteredCaseCount += filtered.filtered;
1831
+ if (filtered.document) documents.push(filtered.document);
1832
+ } catch (error) {
1833
+ const collectionError = asCollectionError(project.name, source.sourcePath, error);
1834
+ collectionErrors.push(collectionError);
1835
+ writeCollectionError(resultDir, collectionError);
1836
+ }
1837
+ return {
1838
+ project,
1839
+ fileSelection,
1840
+ sources,
1841
+ documents,
1842
+ collectionErrors,
1843
+ selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
1844
+ filteredCaseCount
1845
+ };
1846
+ };
1847
+ const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>test_project_runner_asNotRun(item, prepared.project.name, reason)));
1848
+ async function runTestProject(options = {}) {
1849
+ const startedAt = new Date();
1850
+ const runId = randomUUID();
1851
+ const cwd = resolve(options.cwd ?? process.cwd());
1852
+ assertDirectory(cwd, 'Test working directory');
1853
+ const cliProjectRoot = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
1854
+ if (cliProjectRoot) assertDirectory(cliProjectRoot, 'Test project directory');
1855
+ const configSearchRoot = cliProjectRoot ?? cwd;
1856
+ const configPath = options.configPath ? resolve(configSearchRoot, options.configPath) : discoverTestConfig(configSearchRoot);
1857
+ if (options.configPath && (!configPath || !existsSync(configPath))) throw new Error(`Midscene config does not exist: ${configPath}`);
1858
+ const definition = await loadTestProject(configPath);
1859
+ const projectRoot = cliProjectRoot ?? cwd;
1860
+ const resultDir = options.resultDir ? resolve(cwd, options.resultDir) : defaultResultDir(projectRoot);
1861
+ const summaryPath = resolve(projectRoot, definition.output.summary);
1862
+ const reportDir = resolve(projectRoot, definition.output.reportDir);
1863
+ mkdirSync(resultDir, {
1864
+ recursive: true
1865
+ });
1866
+ mkdirSync(reportDir, {
1867
+ recursive: true
1868
+ });
1869
+ const selectedProjects = selectProjects(definition.projects, options.projectNames);
1870
+ const preparedProjects = selectedProjects.map((project)=>prepareProject(project, projectRoot, definition.resolveNode, resultDir));
1871
+ const progress = options.onProgress ?? (()=>{});
1872
+ const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
1873
+ const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
1874
+ const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1875
+ progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1876
+ const rootController = new AbortController();
1877
+ const handleSignal = (signal)=>{
1878
+ rootController.abort(new Error(`Workflow interrupted by ${signal}.`));
1879
+ };
1880
+ const sigint = ()=>handleSignal('SIGINT');
1881
+ const sigterm = ()=>handleSignal('SIGTERM');
1882
+ process.on('SIGINT', sigint);
1883
+ process.on('SIGTERM', sigterm);
1884
+ const projectResults = [];
1885
+ let failedCaseCount = 0;
1886
+ const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
1887
+ try {
1888
+ for (const [projectIndex, prepared] of preparedProjects.entries()){
1889
+ const { project } = prepared;
1890
+ progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name} (${project.platform})`);
1891
+ const cases = [];
1892
+ const documents = [];
1893
+ let lifecycle;
1894
+ let projectFatal = false;
1895
+ if (prepared.collectionErrors.length > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
1896
+ else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
1897
+ else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
1898
+ else {
1899
+ const runtime = createProjectRuntime({
1900
+ project,
1901
+ setup: project.setup,
1902
+ signal: rootController.signal
1903
+ });
1904
+ let projectExecutionError;
1905
+ try {
1906
+ await runtime.start();
1907
+ if (runtime.canRun) for (const [documentIndex, document] of prepared.documents.entries()){
1908
+ if (rootController.signal.aborted || bailReached() || projectFatal) {
1909
+ const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1910
+ cases.push(...document.cases.map((item)=>test_project_runner_asNotRun(item, project.name, reason)));
1911
+ continue;
1912
+ }
1913
+ progress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
1914
+ const execution = await runWorkflowDocument(document, {
1915
+ resolveNode: definition.nodes.require.bind(definition.nodes),
1916
+ project,
1917
+ projectContext: runtime.context,
1918
+ retry: project.retry,
1919
+ signal: runtime.signal,
1920
+ defaultTimeoutMs: definition.test.testTimeout,
1921
+ shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal,
1922
+ stopReason: ()=>rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail',
1923
+ isFatalError: (run)=>[
1924
+ ...run.beforeEach,
1925
+ ...run.steps,
1926
+ ...run.afterEach
1927
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
1928
+ onCaseStart: (collectedCase)=>{
1929
+ progress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1930
+ },
1931
+ onStepStart: (info)=>{
1932
+ const indent = 'case' === info.scope ? ' ' : ' ';
1933
+ progress(`${indent}→ ${test_project_runner_formatStep(info)}`);
1934
+ },
1935
+ onStepResult: (info, result)=>progress(formatStepResult(info, result)),
1936
+ onCaseResult: (attempt)=>{
1937
+ writeCaseRunResult(resultDir, attempt);
1938
+ progress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1939
+ },
1940
+ onCaseOutcome: (outcome)=>{
1941
+ if ('failed' === outcome.status) failedCaseCount += 1;
1942
+ if (caseHasFatalError(outcome)) projectFatal = true;
1943
+ },
1944
+ onDocumentResult: (documentResult)=>writeWorkflowDocumentRunResult(resultDir, documentResult)
1945
+ });
1946
+ cases.push(...execution.cases);
1947
+ documents.push(execution.document);
1948
+ }
1949
+ else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
1950
+ } catch (error) {
1951
+ projectExecutionError = error;
1952
+ } finally{
1953
+ const hasFailure = void 0 !== projectExecutionError || cases.some((item)=>'success' !== item.status) || documents.some((item)=>'failed' === item.status) || projectFatal || rootController.signal.aborted;
1954
+ lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
1955
+ }
1956
+ if (projectExecutionError) throw projectExecutionError;
1957
+ }
1958
+ const projectFailed = prepared.collectionErrors.length > 0 || cases.some((item)=>'success' !== item.status) || documents.some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
1959
+ projectResults.push({
1960
+ name: project.name,
1961
+ platform: project.platform,
1962
+ status: projectFailed ? 'failed' : 'success',
1963
+ retry: project.retry,
1964
+ fileSelection: prepared.fileSelection,
1965
+ tagSelection: project.tags,
1966
+ sourceCount: prepared.sources.length,
1967
+ selectedCaseCount: prepared.selectedCaseCount,
1968
+ filteredCaseCount: prepared.filteredCaseCount,
1969
+ ...lifecycle ? {
1970
+ lifecycle
1971
+ } : {},
1972
+ cases,
1973
+ documents,
1974
+ collectionErrors: prepared.collectionErrors
1975
+ });
1976
+ }
1977
+ } finally{
1978
+ process.off('SIGINT', sigint);
1979
+ process.off('SIGTERM', sigterm);
1980
+ }
1981
+ const summary = summarize(projectResults);
1982
+ const failed = rootController.signal.aborted || summary.failed > 0 || summary.notRun > 0 || summary.collectionErrors > 0 || summary.documentFailures > 0 || summary.projectFailures > 0;
1983
+ const endedAt = new Date();
1984
+ const result = {
1985
+ schemaVersion: 1,
1986
+ runId,
1987
+ startedAt: startedAt.toISOString(),
1988
+ endedAt: endedAt.toISOString(),
1989
+ durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
1990
+ status: failed ? 'failed' : 'success',
1991
+ exitCode: failed ? 1 : 0,
1992
+ resultDir,
1993
+ summaryPath,
1994
+ reportDir,
1995
+ summary,
1996
+ projects: projectResults,
1997
+ cases: projectResults.flatMap((project)=>project.cases),
1998
+ documents: projectResults.flatMap((project)=>project.documents),
1999
+ collectionErrors: projectResults.flatMap((project)=>project.collectionErrors)
2000
+ };
2001
+ writeTestProjectRunResult({
2002
+ projectRoot,
2003
+ ...configPath ? {
2004
+ configPath
2005
+ } : {},
2006
+ result
2007
+ });
2008
+ return result;
2009
+ }
2010
+ const parseTestCliArgs = (args, cwd = process.cwd())=>{
2011
+ const command = 'describe-nodes' === args[0] ? args[0] : void 0;
2012
+ const commandOffset = command ? 1 : 0;
2013
+ let projectRoot;
2014
+ let configPath;
2015
+ let resultDir;
2016
+ const projectNames = [];
2017
+ for(let index = commandOffset; index < args.length; index += 1){
2018
+ const arg = args[index];
2019
+ if (!arg.startsWith('-')) {
2020
+ if (projectRoot) throw new Error('Only one test project directory is allowed.');
2021
+ projectRoot = resolve(cwd, arg);
2022
+ continue;
2023
+ }
2024
+ if ('--config' === arg || '--result-dir' === arg || '--project' === arg) {
2025
+ const value = args[index + 1];
2026
+ if (!value) throw new Error(`${arg} requires a value.`);
2027
+ if ('--config' === arg) configPath = value;
2028
+ else if ('--result-dir' === arg) resultDir = resolve(cwd, value);
2029
+ else projectNames.push(value);
2030
+ index += 1;
2031
+ } else throw new Error(`Unknown option: ${arg}`);
2032
+ }
2033
+ if ('describe-nodes' === command && resultDir) throw new Error('--result-dir is not supported by describe-nodes.');
2034
+ if ('describe-nodes' === command && projectNames.length > 0) throw new Error('--project is not supported by describe-nodes.');
2035
+ return {
2036
+ ...command ? {
2037
+ command
2038
+ } : {},
2039
+ cwd,
2040
+ projectRoot,
2041
+ configPath,
2042
+ resultDir,
2043
+ ...projectNames.length > 0 ? {
2044
+ projectNames
2045
+ } : {}
2046
+ };
2047
+ };
2048
+ const defaultCliIO = {
2049
+ log: console.log,
2050
+ error: console.error,
2051
+ write: (message)=>process.stdout.write(message)
2052
+ };
2053
+ const test_command_assertDirectory = (path, label)=>{
2054
+ if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
2055
+ };
2056
+ const describeNodes = async (options, io)=>{
2057
+ const cwd = resolve(options.cwd);
2058
+ test_command_assertDirectory(cwd, 'Test working directory');
2059
+ const projectRoot = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
2060
+ if (projectRoot) test_command_assertDirectory(projectRoot, 'Test project directory');
2061
+ const configSearchRoot = projectRoot ?? cwd;
2062
+ const configPath = options.configPath ? resolve(configSearchRoot, options.configPath) : discoverTestConfig(configSearchRoot);
2063
+ if (options.configPath && (!configPath || !existsSync(configPath))) throw new Error(`Midscene config does not exist: ${configPath}`);
2064
+ const project = await loadTestProject(configPath);
2065
+ const document = renderNodeReference(project.nodes.definitions());
2066
+ for (const warning of document.warnings)io.error(`midscene-test describe-nodes: ${warning}`);
2067
+ if (io.write) io.write(document.markdown);
2068
+ else io.log(document.markdown.trimEnd());
2069
+ };
2070
+ async function runTestCli(args, io = defaultCliIO) {
2071
+ try {
2072
+ const options = parseTestCliArgs(args);
2073
+ if ('describe-nodes' === options.command) {
2074
+ await describeNodes(options, io);
2075
+ return 0;
2076
+ }
2077
+ const result = await runTestProject({
2078
+ ...options,
2079
+ onProgress: (message)=>io.log(message)
2080
+ });
2081
+ io.log(`midscene-test: ${result.summary.passed}/${result.summary.total} cases passed, ${result.summary.failed} failed, ${result.summary.notRun} not run`);
2082
+ io.log(`Results: ${result.resultDir}`);
2083
+ io.log(`Summary: ${result.summaryPath}`);
2084
+ return result.exitCode;
2085
+ } catch (error) {
2086
+ io.error(error instanceof Error ? error.message : String(error));
2087
+ return 1;
2088
+ }
2089
+ }
2090
+ runTestCli(process.argv.slice(2)).then((exitCode)=>{
2091
+ process.exitCode = exitCode;
2092
+ });
2093
+
2094
+ //# sourceMappingURL=test-cli.mjs.map