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