@midscene/test 1.15.4-beta-20261013090705.0 → 1.15.4

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.
Files changed (68) hide show
  1. package/dist/es/{1~214.mjs → 1~4.mjs} +96 -174
  2. package/dist/es/1~4.mjs.map +1 -0
  3. package/dist/es/cli/index.mjs +741 -1910
  4. package/dist/es/cli/index.mjs.map +1 -1
  5. package/dist/es/index.mjs +1 -20
  6. package/dist/es/index.mjs.map +1 -1
  7. package/dist/es/midscene/index.mjs +2 -2
  8. package/dist/es/midscene/index.mjs.map +1 -1
  9. package/dist/es/test-cli.mjs +70 -1265
  10. package/dist/es/test-cli.mjs.map +1 -1
  11. package/dist/lib/{0~160.js → 0~593.js} +95 -173
  12. package/dist/lib/0~593.js.map +1 -0
  13. package/dist/lib/cli/index.js +740 -1927
  14. package/dist/lib/cli/index.js.map +1 -1
  15. package/dist/lib/index.js +1 -20
  16. package/dist/lib/index.js.map +1 -1
  17. package/dist/lib/midscene/index.js +0 -3
  18. package/dist/lib/midscene/index.js.map +1 -1
  19. package/dist/lib/test-cli.js +70 -1265
  20. package/dist/lib/test-cli.js.map +1 -1
  21. package/dist/templates/manifest.json +60 -0
  22. package/dist/templates/test/android/.env.example +15 -0
  23. package/dist/templates/test/android/README.md +19 -0
  24. package/dist/templates/test/android/README.zh.md +11 -0
  25. package/dist/templates/test/android/cases/todo.yaml +26 -0
  26. package/dist/templates/test/android/gitignore +4 -0
  27. package/dist/templates/test/android/midscene.config.ts +69 -0
  28. package/dist/templates/test/android/package.json +20 -0
  29. package/dist/templates/test/android/tsconfig.json +12 -0
  30. package/dist/templates/test/computer/.env.example +17 -0
  31. package/dist/templates/test/computer/README.md +19 -0
  32. package/dist/templates/test/computer/README.zh.md +11 -0
  33. package/dist/templates/test/computer/cases/todo.yaml +31 -0
  34. package/dist/templates/test/computer/gitignore +4 -0
  35. package/dist/templates/test/computer/midscene.config.ts +74 -0
  36. package/dist/templates/test/computer/package.json +20 -0
  37. package/dist/templates/test/computer/tsconfig.json +12 -0
  38. package/dist/templates/test/harmony/.env.example +15 -0
  39. package/dist/templates/test/harmony/README.md +19 -0
  40. package/dist/templates/test/harmony/README.zh.md +11 -0
  41. package/dist/templates/test/harmony/cases/todo.yaml +26 -0
  42. package/dist/templates/test/harmony/gitignore +4 -0
  43. package/dist/templates/test/harmony/midscene.config.ts +69 -0
  44. package/dist/templates/test/harmony/package.json +20 -0
  45. package/dist/templates/test/harmony/tsconfig.json +12 -0
  46. package/dist/templates/test/ios/.env.example +18 -0
  47. package/dist/templates/test/ios/README.md +19 -0
  48. package/dist/templates/test/ios/README.zh.md +11 -0
  49. package/dist/templates/test/ios/cases/todo.yaml +26 -0
  50. package/dist/templates/test/ios/gitignore +4 -0
  51. package/dist/templates/test/ios/midscene.config.ts +95 -0
  52. package/dist/templates/test/ios/package.json +20 -0
  53. package/dist/templates/test/ios/tsconfig.json +12 -0
  54. package/dist/templates/test/web/.env.example +14 -0
  55. package/dist/templates/test/web/README.md +19 -0
  56. package/dist/templates/test/web/README.zh.md +13 -0
  57. package/dist/templates/test/web/cases/todo.yaml +62 -0
  58. package/dist/templates/test/web/gitignore +4 -0
  59. package/dist/templates/test/web/midscene.config.ts +56 -0
  60. package/dist/templates/test/web/nodes/todo.ts +215 -0
  61. package/dist/templates/test/web/package.json +21 -0
  62. package/dist/templates/test/web/tsconfig.json +12 -0
  63. package/dist/types/cli/index.d.ts +0 -556
  64. package/dist/types/index.d.ts +0 -20
  65. package/dist/types/midscene/index.d.ts +2 -6
  66. package/package.json +16 -15
  67. package/dist/es/1~214.mjs.map +0 -1
  68. package/dist/lib/0~160.js.map +0 -1
@@ -1,1278 +1,742 @@
1
- import { z } from "zod/v4";
2
- import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, isAbsolute, join, relative, resolve as external_node_path_resolve, sep } from "node:path";
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
4
  import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText } from "@midscene/core/internal/test-runner";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
6
  import { globSync } from "tinyglobby";
6
7
  import { createHash, randomUUID } from "node:crypto";
7
8
  import { JSON_SCHEMA, load } from "js-yaml";
8
9
  import { mkdir, writeFile } from "node:fs/promises";
9
- import { pathToFileURL } from "node:url";
10
- import { require as api_require } from "tsx/cjs/api";
11
10
  import { setMaxListeners } from "node:events";
12
11
  import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
13
12
  import { getDebug } from "@midscene/shared/logger";
14
- const isSafeResultPathSegment = (value)=>'string' == typeof value && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
15
- const json = z.lazy(()=>z.union([
16
- z["null"](),
17
- z.string(),
18
- z.number().finite(),
19
- z.boolean(),
20
- z.array(json),
21
- z.record(z.string(), json)
22
- ]));
23
- const time = z.string().refine((value)=>Number.isFinite(Date.parse(value)));
24
- const executor_runtime_schema_count = z.number().int().nonnegative();
25
- const executor_runtime_schema_value = z.strictObject({
26
- value: json,
27
- truncatedPaths: z.array(z.string()).optional(),
28
- redactedPaths: z.array(z.string()).optional()
29
- });
30
- const reportErrorSchema = z.lazy(()=>z.strictObject({
31
- format: z.literal('midscene-error').optional(),
32
- name: z.string(),
33
- message: z.string(),
34
- code: z.string().optional(),
35
- details: executor_runtime_schema_value.optional(),
36
- cause: reportErrorSchema.optional(),
37
- errors: z.array(reportErrorSchema).optional(),
38
- truncated: z.boolean().optional()
39
- }));
40
- const executor_runtime_schema_operation = z.strictObject({
41
- operationId: z.string().min(1),
42
- scopeId: z.string().optional(),
43
- stepId: z.string().optional(),
44
- node: z.string().optional()
45
- });
46
- const cleanupSchema = z.strictObject({
47
- status: z["enum"]([
48
- 'not-required',
49
- 'completed',
50
- 'pending',
51
- 'failed'
52
- ]),
53
- capturedAt: time,
54
- operationId: z.string().optional(),
55
- blockingOperations: z.array(executor_runtime_schema_operation).optional(),
56
- error: reportErrorSchema.optional()
57
- });
58
- const pendingOperationsSchema = z.array(executor_runtime_schema_operation);
59
- const collectionStatus = z["enum"]([
60
- 'complete',
61
- 'partial',
62
- 'unknown'
63
- ]);
64
- const metricsSchema = z.strictObject({
65
- modelCallCount: executor_runtime_schema_count,
66
- modelTimeMs: z.number().finite().nonnegative(),
67
- promptTokens: executor_runtime_schema_count,
68
- cachedInputTokens: executor_runtime_schema_count,
69
- completionTokens: executor_runtime_schema_count,
70
- totalTokens: executor_runtime_schema_count,
71
- collection: z.strictObject({
72
- status: collectionStatus,
73
- expectedExecutionCount: executor_runtime_schema_count,
74
- capturedExecutionCount: executor_runtime_schema_count,
75
- reasons: z.array(z.string()),
76
- fields: z.strictObject({
77
- modelCallCount: collectionStatus,
78
- modelTimeMs: collectionStatus,
79
- totalTokens: collectionStatus
80
- }).optional()
81
- }).optional()
82
- });
83
- const operationsSchema = z.strictObject({
84
- capturedAt: time,
85
- events: z.array(z.strictObject({
86
- operationId: z.string().min(1),
87
- operationKind: z["enum"]([
88
- 'node',
89
- 'resource',
90
- 'acquisition',
91
- 'cleanup',
92
- 'readiness'
93
- ]),
94
- kind: z["enum"]([
95
- 'started',
96
- 'cancel-requested',
97
- 'settled',
98
- 'runner-returned',
99
- 'cleanup-deferred'
100
- ]),
101
- at: time,
102
- stepId: z.string().optional(),
103
- scopeId: z.string().optional(),
104
- node: z.string().optional(),
105
- status: z["enum"]([
106
- 'success',
107
- 'failed',
108
- 'not-run'
109
- ]).optional(),
110
- pending: z.boolean().optional(),
111
- error: reportErrorSchema.optional(),
112
- blockingOperations: z.array(executor_runtime_schema_operation).optional()
113
- }))
114
- });
115
- const runtimeResultFields = {
116
- executionStatus: z["enum"]([
117
- 'success',
118
- 'failed',
119
- 'not-run'
120
- ]).optional(),
121
- phases: z.partialRecord(z["enum"]([
122
- 'beforeAll',
123
- 'beforeEach',
124
- 'steps',
125
- 'afterEach',
126
- 'afterAll'
127
- ]), z.strictObject({
128
- status: z["enum"]([
129
- 'success',
130
- 'failed',
131
- 'not-run',
132
- 'not-required'
133
- ]),
134
- plannedStepCount: executor_runtime_schema_count,
135
- executedStepCount: executor_runtime_schema_count
136
- })).optional(),
137
- cleanup: cleanupSchema.optional(),
138
- pendingOperations: pendingOperationsSchema.optional(),
139
- retryDecision: z.strictObject({
140
- scope: z["enum"]([
141
- 'case',
142
- 'document'
143
- ]),
144
- retryCount: executor_runtime_schema_count,
145
- attemptIndex: executor_runtime_schema_count,
146
- decision: z["enum"]([
147
- 'retry',
148
- 'stop'
149
- ]),
150
- reason: z["enum"]([
151
- 'passed',
152
- 'attempt-failed',
153
- 'budget-exhausted',
154
- 'cleanup-pending',
155
- 'cleanup-failed',
156
- 'operation-pending',
157
- 'fatal-error',
158
- 'interrupted',
159
- 'bail',
160
- 'infrastructure-error'
161
- ])
162
- }).optional()
13
+ const registry_NodeRegistry = NodeRegistry;
14
+ const projectIdFromIndex = (index)=>`project-${index}`;
15
+ const defineTestProject = (definition)=>definition;
16
+ const defineProjectSetup = (definition)=>definition;
17
+ const getDefaultExport = (loaded, absolutePath)=>{
18
+ if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
19
+ const defaultExport = loaded.default;
20
+ if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
21
+ if ('default' in defaultExport) return defaultExport.default;
22
+ throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
23
+ }
24
+ return defaultExport;
25
+ }
26
+ throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
163
27
  };
164
- const plannedStep = z.strictObject({
165
- id: z.string(),
166
- phase: z["enum"]([
167
- 'beforeAll',
168
- 'beforeEach',
169
- 'steps',
170
- 'afterEach',
171
- 'afterAll'
172
- ]),
173
- stepIndex: executor_runtime_schema_count,
174
- node: z.string(),
175
- title: z.string().optional(),
176
- input: executor_runtime_schema_value,
177
- inputMode: z["enum"]([
178
- 'template',
179
- 'resolved'
180
- ]),
181
- timeoutMs: z.number().positive().optional(),
182
- continueOnError: z.boolean()
183
- });
184
- const documentPlanSchema = z.strictObject({
185
- documentId: z.string(),
186
- sourcePath: z.string(),
187
- sourceYaml: executor_runtime_schema_value.optional(),
188
- retry: z.strictObject({
189
- scope: z["enum"]([
190
- 'case',
191
- 'document'
192
- ]),
193
- count: executor_runtime_schema_count
194
- }),
195
- beforeAll: z.array(plannedStep),
196
- afterAll: z.array(plannedStep),
197
- cases: z.array(z.strictObject({
198
- caseId: z.string(),
199
- name: z.string(),
200
- caseIndex: executor_runtime_schema_count,
201
- selected: z.boolean(),
202
- tags: z.array(z.string()),
203
- onFailure: z["enum"]([
204
- 'continue',
205
- 'stop-document'
206
- ]).optional(),
207
- beforeEach: z.array(plannedStep),
208
- steps: z.array(plannedStep),
209
- afterEach: z.array(plannedStep)
210
- }))
211
- });
212
- function _define_property(obj, key, value) {
213
- if (key in obj) Object.defineProperty(obj, key, {
214
- value: value,
215
- enumerable: true,
216
- configurable: true,
217
- writable: true
28
+ const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
29
+ const rejectUnknownKeys = (value, allowedKeys, label)=>{
30
+ const allowed = new Set(allowedKeys);
31
+ const unknown = Object.keys(value).find((key)=>!allowed.has(key));
32
+ if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
33
+ };
34
+ const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
35
+ const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
36
+ if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
37
+ if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
38
+ return value.map((pattern, index)=>{
39
+ if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
40
+ if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
41
+ if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
42
+ if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
43
+ if (pattern.startsWith('!')) {
44
+ if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
45
+ throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
46
+ }
47
+ return pattern;
218
48
  });
219
- else obj[key] = value;
220
- return obj;
221
- }
222
- const executorErrorKey = Symbol.for('@midscene/test/executor-error');
223
- let _executorErrorKey = executorErrorKey;
224
- class TestExecutorError extends Error {
225
- constructor(message, options = {}){
226
- super(message, {
227
- cause: options.cause
228
- }), _define_property(this, _executorErrorKey, true), _define_property(this, "kind", void 0), _define_property(this, "retryable", void 0);
229
- this.name = 'TestExecutorError';
230
- this.kind = options.kind ?? 'unknown';
231
- this.retryable = options.retryable ?? false;
232
- }
233
- }
234
- function isTestExecutorError(value) {
235
- return value instanceof Error && true === Reflect.get(value, executorErrorKey) && [
236
- 'provision',
237
- 'transport',
238
- 'cleanup',
239
- 'report',
240
- 'unknown'
241
- ].includes(Reflect.get(value, 'kind')) && 'boolean' == typeof Reflect.get(value, 'retryable');
242
- }
243
- const defineTestExecutor = (executor)=>executor;
244
- const localTestExecutor = Object.freeze({
245
- name: 'local',
246
- execute: (_task, context)=>context.runLocal()
247
- });
248
- const jsonValueSchema = z.lazy(()=>z.union([
249
- z["null"](),
250
- z.string(),
251
- z.number().finite(),
252
- z.boolean(),
253
- z.array(jsonValueSchema),
254
- z.record(z.string(), jsonValueSchema)
255
- ]));
256
- const timestampSchema = z.string().refine((value)=>Number.isFinite(Date.parse(value)), {
257
- message: 'Expected an ISO-compatible timestamp.'
258
- });
259
- const errorSchema = z.strictObject({
260
- format: z.literal('midscene-error').optional(),
261
- cause: reportErrorSchema.optional(),
262
- name: z.string().min(1),
263
- message: z.string(),
264
- code: z.string().min(1),
265
- details: jsonValueSchema.optional()
266
- });
267
- const stepBaseSchema = z.strictObject({
268
- phase: z["enum"]([
269
- 'beforeAll',
270
- 'beforeEach',
271
- 'steps',
272
- 'afterEach',
273
- 'afterAll'
274
- ]),
275
- stepIndex: z.number().int().nonnegative(),
276
- node: z.string().min(1),
277
- input: jsonValueSchema,
278
- meta: z.strictObject({
279
- timeoutMs: z.number().positive().finite().optional(),
280
- continueOnError: z.boolean()
281
- }),
282
- continuedAfterError: z.boolean(),
283
- waitForPreviousOperationMs: z.number().nonnegative().finite().optional(),
284
- operations: operationsSchema.optional(),
285
- output: z.strictObject({
286
- summary: z.string().optional(),
287
- data: jsonValueSchema.optional()
288
- }).optional(),
289
- error: errorSchema.optional(),
290
- report: z.strictObject({
291
- traces: z.array(z.strictObject({
292
- type: z.literal('midscene-execution'),
293
- executionId: z.string().min(1),
294
- metrics: metricsSchema.optional()
295
- })),
296
- collectionErrors: z.array(errorSchema).optional()
297
- }).optional()
298
- });
299
- const stepSchema = z.discriminatedUnion('status', [
300
- stepBaseSchema.extend({
301
- status: z["enum"]([
302
- 'success',
303
- 'failed'
304
- ]),
305
- startedAt: timestampSchema,
306
- endedAt: timestampSchema,
307
- durationMs: z.number().nonnegative().finite()
308
- }),
309
- stepBaseSchema.extend({
310
- status: z.literal('not-run'),
311
- notRunReason: z.string().min(1),
312
- blockedBy: z.string().optional()
313
- })
314
- ]);
315
- const validatedStepSchema = stepSchema.superRefine((step, context)=>{
316
- if ('success' === step.status && (step.error || step.continuedAfterError)) context.addIssue({
317
- code: 'custom',
318
- message: 'Successful steps cannot contain failure evidence.'
49
+ };
50
+ const validateTestFileSelection = (value, label = 'files')=>{
51
+ if (void 0 === value) return;
52
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
53
+ rejectUnknownKeys(value, [
54
+ 'include',
55
+ 'exclude'
56
+ ], label);
57
+ const include = validatePatterns(value.include, 'include', label);
58
+ const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
59
+ return Object.freeze({
60
+ include: Object.freeze(include),
61
+ ...exclude ? {
62
+ exclude: Object.freeze(exclude)
63
+ } : {}
319
64
  });
320
- if (step.continuedAfterError && !step.meta.continueOnError) context.addIssue({
321
- code: 'custom',
322
- message: 'Continuation requires continueOnError.'
65
+ };
66
+ const validateTagList = (value, field, label)=>{
67
+ if (void 0 === value) return [];
68
+ if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
69
+ return value.map((tag, index)=>{
70
+ if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
71
+ return tag;
323
72
  });
324
- });
325
- const attemptSchema = z.strictObject({
326
- ...runtimeResultFields,
327
- caseId: z.string().min(1),
328
- runId: z.string().min(1),
329
- projectName: z.string().min(1),
330
- attemptIndex: z.number().int().nonnegative(),
331
- name: z.string(),
332
- sourcePath: z.string().min(1),
333
- caseIndex: z.number().int().nonnegative(),
334
- status: z["enum"]([
335
- 'success',
336
- 'failed'
337
- ]),
338
- beforeEach: z.array(validatedStepSchema),
339
- steps: z.array(validatedStepSchema),
340
- afterEach: z.array(validatedStepSchema),
341
- executionErrors: z.array(errorSchema).optional(),
342
- teardownErrors: z.array(errorSchema).optional(),
343
- reportRefs: z.array(z.string().min(1)).optional(),
344
- startedAt: timestampSchema,
345
- endedAt: timestampSchema,
346
- durationMs: z.number().nonnegative().finite()
347
- });
348
- const notRunReasonSchema = z["enum"]([
349
- 'document-start-failed',
350
- 'project-preflight-failed',
351
- 'project-setup-failed',
352
- 'interrupted',
353
- 'bail',
354
- 'fatal-error',
355
- 'executor-failed',
356
- 'cleanup-pending',
357
- 'cleanup-failed',
358
- 'operation-pending'
359
- ]);
360
- const resultSchema = z.strictObject({
361
- case: z.strictObject({
362
- documentId: z.string().min(1),
363
- caseId: z.string().min(1),
364
- projectName: z.string().min(1),
365
- name: z.string(),
366
- sourcePath: z.string().min(1),
367
- caseIndex: z.number().int().nonnegative(),
368
- status: z["enum"]([
369
- 'success',
370
- 'failed',
371
- 'not-run'
372
- ]),
373
- run: attemptSchema.optional(),
374
- attempts: z.array(attemptSchema).optional(),
375
- notRunReason: notRunReasonSchema.optional()
376
- }),
377
- document: z.strictObject({
378
- ...runtimeResultFields,
379
- plan: documentPlanSchema.optional(),
380
- attemptIndex: z.number().int().nonnegative().optional(),
381
- hostErrors: z.array(z.strictObject({
382
- phase: z["enum"]([
383
- 'setup',
384
- 'execution',
385
- 'cleanup',
386
- 'report',
387
- 'publication',
388
- 'observer'
389
- ]),
390
- error: reportErrorSchema
391
- })).optional(),
392
- documentId: z.string().min(1),
393
- documentRunId: z.string().min(1),
394
- projectId: z.string().min(1),
395
- projectName: z.string().min(1),
396
- sourcePath: z.string().min(1),
397
- status: z["enum"]([
398
- 'success',
399
- 'failed'
400
- ]),
401
- startedAt: timestampSchema,
402
- endedAt: timestampSchema,
403
- durationMs: z.number().nonnegative().finite(),
404
- beforeAll: z.array(validatedStepSchema),
405
- afterAll: z.array(validatedStepSchema),
406
- executionErrors: z.array(errorSchema).optional(),
407
- teardownErrors: z.array(errorSchema).optional(),
408
- reportRefs: z.array(z.string().min(1)).optional()
409
- }).optional(),
410
- lifecycle: z.strictObject({
411
- cleanup: cleanupSchema.optional(),
412
- pendingOperations: pendingOperationsSchema.optional(),
413
- projectName: z.string().min(1),
414
- status: z["enum"]([
415
- 'success',
416
- 'failed'
417
- ]),
418
- setupError: errorSchema.optional(),
419
- teardownErrors: z.array(errorSchema).optional(),
420
- startedAt: timestampSchema,
421
- endedAt: timestampSchema,
422
- durationMs: z.number().nonnegative().finite()
423
- }).optional(),
424
- artifacts: z.array(z.strictObject({
425
- name: z.string().min(1),
426
- uri: z.string().min(1),
427
- mediaType: z.string().min(1).optional()
428
- })).optional(),
429
- metadata: z.record(z.string(), z.union([
430
- z.string(),
431
- z.number().finite(),
432
- z.boolean(),
433
- z["null"]()
434
- ])).optional()
435
- });
436
- const attemptMatchesTask = (task, attempt)=>attempt.caseId === task.caseId && attempt.projectName === task.projectName && attempt.name === task.caseName && attempt.sourcePath === task.sourcePath && attempt.caseIndex === task.caseIndex && isSafeResultPathSegment(attempt.runId);
437
- const hasUnfinishedWork = (result)=>void 0 !== result.executionStatus && 'success' !== result.executionStatus || void 0 !== result.cleanup && ![
438
- 'completed',
439
- 'not-required'
440
- ].includes(result.cleanup.status) || (result.pendingOperations?.length ?? 0) > 0;
441
- function assertTestCaseTaskRunResult(task, value) {
442
- const parsed = resultSchema.safeParse(value);
443
- if (!parsed.success) throw new TypeError(`Executor returned invalid transport data for ${task.caseId}: ${z.prettifyError(parsed.error)}`);
444
- const outcome = parsed.data.case;
445
- if (outcome.caseId !== task.caseId || outcome.documentId !== task.documentId || outcome.projectName !== task.projectName || outcome.name !== task.caseName || outcome.sourcePath !== task.sourcePath || outcome.caseIndex !== task.caseIndex) throw new Error(`Executor returned a result that does not match task ${task.caseId}.`);
446
- const attempts = outcome.attempts ?? [];
447
- for (const attempt of attempts){
448
- const steps = [
449
- ...attempt.beforeEach,
450
- ...attempt.steps,
451
- ...attempt.afterEach
452
- ];
453
- if ('success' === attempt.status && (0 === attempt.steps.length || (attempt.executionErrors?.length ?? 0) > 0 || steps.some((step)=>'success' !== step.status) || hasUnfinishedWork(attempt) || (attempt.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful attempt for ${task.caseId}.`);
454
- }
455
- if (new Set(attempts.map((attempt)=>attempt.runId)).size !== attempts.length) throw new TypeError(`Executor returned duplicate attempt IDs for ${task.caseId}.`);
456
- if (attempts.some((attempt)=>!attemptMatchesTask(task, attempt))) throw new TypeError(`Executor returned invalid attempts for ${task.caseId}.`);
457
- if (attempts.some((attempt, index)=>attempt.attemptIndex !== index) || attempts.length > task.retry + 1) throw new TypeError(`Executor returned a non-sequential attempt history for ${task.caseId}.`);
458
- const finalAttempt = attempts.at(-1);
459
- if ('not-run' === outcome.status) {
460
- if (outcome.run || attempts.length || !outcome.notRunReason) throw new TypeError(`Executor returned an inconsistent not-run result for ${task.caseId}.`);
461
- } else if (void 0 !== outcome.notRunReason || !outcome.run || !finalAttempt || finalAttempt.status !== outcome.status || outcome.run.runId !== finalAttempt.runId || JSON.stringify(outcome.run) !== JSON.stringify(finalAttempt) || attempts.slice(0, -1).some((attempt)=>'failed' !== attempt.status)) throw new TypeError(`Executor returned an inconsistent ${outcome.status} result for ${task.caseId}.`);
462
- const reportRefs = [
463
- ...attempts.flatMap((attempt)=>attempt.reportRefs ?? []),
464
- ...parsed.data.document?.reportRefs ?? []
465
- ];
466
- if (reportRefs.some((reference)=>!isSafeResultPathSegment(reference))) throw new TypeError(`Executor returned an invalid report reference for ${task.caseId}.`);
467
- const document = parsed.data.document;
468
- if (document?.status === 'success' && (hasUnfinishedWork(document) || (document.hostErrors?.length ?? 0) > 0 || (document.executionErrors?.length ?? 0) > 0 || [
469
- ...document.beforeAll,
470
- ...document.afterAll
471
- ].some((step)=>'success' !== step.status) || (document.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful document for ${task.caseId}.`);
472
- const lifecycle = parsed.data.lifecycle;
473
- if (lifecycle?.status === 'success' && (hasUnfinishedWork(lifecycle) || lifecycle.setupError || (lifecycle.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful lifecycle for ${task.caseId}.`);
474
- if (document && (document.documentId !== task.documentId || document.projectId !== task.projectId || document.projectName !== task.projectName || document.sourcePath !== task.sourcePath || !isSafeResultPathSegment(document.documentRunId))) throw new Error(`Executor returned a document that does not match task ${task.caseId}.`);
475
- if (parsed.data.lifecycle && parsed.data.lifecycle.projectName !== task.projectName) throw new Error(`Executor returned a lifecycle that does not match task ${task.caseId}.`);
476
- }
477
- const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
478
- projectId,
479
- sourcePath,
480
- caseIndex,
481
- ...invocationIndex ? [
482
- invocationIndex
483
- ] : []
484
- ])).digest('hex');
485
- const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
486
- projectId,
487
- sourcePath,
488
- ...invocationIndex ? [
489
- invocationIndex
490
- ] : []
491
- ])).digest('hex');
492
- const normalize_normalizeSteps = normalizeSteps;
493
- function parseWorkflowSource(source) {
494
- const stack = [];
495
- const nodes = [];
496
- const sensitiveNodes = new Set();
497
- const sensitiveValues = new Set();
498
- const redactedPaths = new Set();
499
- const sourceOffset = 0xfeff === source.charCodeAt(0) ? 1 : 0;
500
- const parsed = load(source, {
501
- schema: JSON_SCHEMA,
502
- listener (event, state) {
503
- if ('open' === event) return void stack.push({
504
- start: state.position + sourceOffset,
505
- end: 0,
506
- value: null,
507
- children: []
508
- });
509
- const node = stack.pop();
510
- node.end = state.position + sourceOffset;
511
- node.value = state.result;
512
- stack.at(-1)?.children.push(node);
513
- nodes.push(node);
514
- if ('mapping' !== state.kind) return;
515
- for(let index = 0; index < node.children.length; index += 2){
516
- const key = node.children[index].value;
517
- const value = node.children[index + 1];
518
- if ('string' == typeof key && isReportSecretKey(key) && value) {
519
- sensitiveNodes.add(value);
520
- if (null !== value.value) sensitiveValues.add(value.value);
521
- redactedPaths.add(`$..[${JSON.stringify(key)}]`);
522
- }
523
- }
524
- }
73
+ };
74
+ const validateTagSelection = (value, label)=>{
75
+ if (void 0 === value) return Object.freeze({
76
+ include: [],
77
+ exclude: []
525
78
  });
526
- const ranges = nodes.filter((node)=>sensitiveNodes.has(node) || sensitiveValues.has(node.value)).sort((left, right)=>left.start - right.start || right.end - left.end);
527
- let offset = 0;
528
- let redacted = '';
529
- for (const range of ranges){
530
- if (range.start < offset) continue;
531
- const text = source.slice(range.start, range.end);
532
- const leading = text.match(/^\s*/)?.[0] ?? '';
533
- const trailing = text.slice(leading.length).match(/\s*$/)?.[0] ?? '';
534
- redacted += `${source.slice(offset, range.start)}${leading}"[REDACTED]"${trailing}`;
535
- offset = range.end;
79
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
80
+ return Object.freeze({
81
+ include: Object.freeze(validateTagList(value.include, 'include', label)),
82
+ exclude: Object.freeze(validateTagList(value.exclude, 'exclude', label))
83
+ });
84
+ };
85
+ function assertJsonValue(value, path, seen) {
86
+ if (null === value || 'string' == typeof value || 'boolean' == typeof value) return;
87
+ if ('number' == typeof value) {
88
+ if (Number.isFinite(value)) return;
89
+ throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
536
90
  }
537
- redacted += source.slice(offset);
538
- const value = sanitizeReportText(redacted, 65536);
539
- return {
540
- parsed,
541
- sourceYaml: {
542
- value,
543
- ...redactedPaths.size ? {
544
- redactedPaths: [
545
- ...redactedPaths
546
- ]
547
- } : {},
548
- ...value !== redacted ? {
549
- truncatedPaths: [
550
- '$'
551
- ]
552
- } : {}
553
- }
554
- };
91
+ if ('object' != typeof value || void 0 === value) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
92
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && null !== Object.getPrototypeOf(value)) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
93
+ if (seen.has(value)) throw new TypeError(`Midscene config ${path} must not contain cycles.`);
94
+ seen.add(value);
95
+ if (Array.isArray(value)) value.forEach((item, index)=>assertJsonValue(item, `${path}[${index}]`, seen));
96
+ else for (const [key, child] of Object.entries(value))assertJsonValue(child, `${path}.${key}`, seen);
97
+ seen.delete(value);
555
98
  }
556
- const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
557
- const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
558
- const exactProjectExpression = /^\$\{([^{}]+)\}$/;
559
- const missingVariable = (kind, name, options)=>{
560
- throw new WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
561
- ...options.location,
562
- variable: name,
563
- variableKind: kind
564
- });
99
+ const deepFreezeJson = (value)=>{
100
+ if ('object' == typeof value && null !== value) {
101
+ for (const child of Object.values(value))deepFreezeJson(child);
102
+ Object.freeze(value);
103
+ }
104
+ return value;
565
105
  };
566
- const projectVariable = (name, options)=>{
567
- if (!Object.hasOwn(options.variables ?? {}, name)) return missingVariable('project', name, options);
568
- return options.variables[name];
106
+ const validateVariables = (value, label)=>{
107
+ if (void 0 === value) return Object.freeze({});
108
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
109
+ assertJsonValue(value, label, new Set());
110
+ return deepFreezeJson(value);
569
111
  };
570
- const environmentVariable = (name, options)=>{
571
- const value = options.env?.[name];
572
- if (void 0 === value) return missingVariable('environment', name, options);
112
+ const validateSetup = (value, label)=>{
113
+ if (void 0 === value) return;
114
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
115
+ rejectUnknownKeys(value, [
116
+ 'name',
117
+ 'setup'
118
+ ], label);
119
+ if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
120
+ if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
573
121
  return value;
574
122
  };
575
- const interpolationValue = (name, value, options)=>{
576
- if ('object' == typeof value && null !== value) throw new WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
577
- ...options.location,
578
- variable: name,
579
- variableKind: 'project'
580
- });
581
- return String(value);
582
- };
583
- const resolveString = (value, options)=>{
584
- const exactEnv = exactEnvExpression.exec(value);
585
- if (exactEnv) return environmentVariable(exactEnv[1], options);
586
- const exactProject = exactProjectExpression.exec(value);
587
- if (exactProject) return projectVariable(exactProject[1], options);
588
- return value.replace(interpolationExpression, (_match, environmentName, projectName)=>environmentName ? environmentVariable(environmentName, options) : interpolationValue(projectName, projectVariable(projectName, options), options));
123
+ const validatePositiveInteger = (value, fallback, label)=>{
124
+ if (void 0 === value) return fallback;
125
+ if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
126
+ return value;
589
127
  };
590
- const resolveWorkflowVariables = (value, options)=>{
591
- if ('string' == typeof value) return resolveString(value, options);
592
- if (Array.isArray(value)) return value.map((child)=>resolveWorkflowVariables(child, options));
593
- if ('object' == typeof value && null !== value) return Object.fromEntries(Object.entries(value).map(([key, child])=>[
594
- key,
595
- resolveWorkflowVariables(child, options)
596
- ]));
128
+ const validateNonNegativeInteger = (value, fallback, label)=>{
129
+ if (void 0 === value) return fallback;
130
+ if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
597
131
  return value;
598
132
  };
599
- function collect_define_property(obj, key, value) {
600
- if (key in obj) Object.defineProperty(obj, key, {
601
- value: value,
602
- enumerable: true,
603
- configurable: true,
604
- writable: true
605
- });
606
- else obj[key] = value;
607
- return obj;
608
- }
609
- const isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
610
- class NativeWorkflowFormatError extends WorkflowParseError {
611
- constructor(actualFormat, sourcePath){
612
- super('legacy' === actualFormat ? `Legacy tasks/flow YAML is not supported by midscene-test: ${sourcePath}. Run it with \`midscene ${sourcePath}\`, or migrate the file to cases/steps.` : 'legacy-batch' === actualFormat ? `Legacy YAML batch configuration is not a midscene-test workflow document: ${sourcePath}. Run it with \`midscene --config ${sourcePath}\`, or migrate it to a native project config.` : `A workflow file cannot mix legacy tasks/flow with native cases/steps or lifecycle hooks: ${sourcePath}. Keep the formats in separate files and run each file with its matching command.`, {
613
- sourcePath,
614
- actualFormat,
615
- expectedFormat: 'native'
616
- }), collect_define_property(this, "actualFormat", void 0);
617
- this.actualFormat = actualFormat;
618
- }
619
- }
620
- const rejectUnknownKeys = (value, allowed, label)=>{
621
- const allowedSet = new Set(allowed);
622
- const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
623
- if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
624
- field: unknown[0]
625
- });
626
- };
627
- const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
628
- const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
629
- function collectWorkflowDocument(source, options) {
630
- let snapshot;
631
- try {
632
- snapshot = parseWorkflowSource(readFileSync(source.absolutePath, 'utf8'));
633
- } catch (error) {
634
- throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
635
- sourcePath: source.sourcePath
636
- }, error);
133
+ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
134
+ if (void 0 === value) {
135
+ const setup = validateSetup(defaultSetup, 'setup');
136
+ return {
137
+ hasExplicitProjects: false,
138
+ projects: Object.freeze([
139
+ Object.freeze({
140
+ projectId: projectIdFromIndex(0),
141
+ name: 'default',
142
+ ...setup ? {
143
+ setup
144
+ } : {},
145
+ tags: Object.freeze({
146
+ include: [],
147
+ exclude: []
148
+ }),
149
+ retry: 0,
150
+ variables: Object.freeze({}),
151
+ nodes: new registry_NodeRegistry(globalNodes.definitions())
152
+ })
153
+ ])
154
+ };
637
155
  }
638
- const { parsed, sourceYaml } = snapshot;
639
- if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
640
- const format = classifyWorkflowDocumentFormat(parsed);
641
- if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
642
- rejectUnknownKeys(parsed, [
643
- 'beforeAll',
644
- 'beforeEach',
645
- 'cases',
646
- 'afterEach',
647
- 'afterAll'
648
- ], 'Workflow document');
649
- if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new WorkflowParseError('Workflow document must contain a non-empty cases array.');
650
- const sourcePath = source.sourcePath.split(sep).join('/');
651
- const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
652
- ...step,
653
- templateInput: step.input,
654
- input: resolveWorkflowVariables(step.input, {
655
- variables: options.variables,
656
- env: options.env ?? process.env,
657
- location: {
658
- projectName: source.projectName ?? source.projectId,
659
- sourcePath,
660
- phase,
661
- stepIndex,
662
- ...void 0 === caseIndex ? {} : {
663
- caseIndex
664
- }
665
- }
666
- })
667
- });
668
- const normalizeLifecycle = (phase)=>{
669
- const value = parsed[phase];
670
- if (void 0 === value) return [];
671
- if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
672
- phase
673
- });
674
- return normalize_normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
675
- const resolved = options.resolveNode(normalized.node);
676
- if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
677
- phase,
678
- stepIndex,
679
- node: normalized.node
680
- });
681
- return resolveStepVariables(normalized, phase, stepIndex);
682
- });
683
- };
684
- const lifecycle = {
685
- beforeAll: normalizeLifecycle('beforeAll'),
686
- beforeEach: normalizeLifecycle('beforeEach'),
687
- afterEach: normalizeLifecycle('afterEach'),
688
- afterAll: normalizeLifecycle('afterAll')
689
- };
690
- const ids = new Set();
691
- const cases = parsed.cases.map((definition, caseIndex)=>{
692
- if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
693
- caseIndex
694
- });
695
- rejectUnknownKeys(definition, [
696
- 'id',
156
+ if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
157
+ if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
158
+ const names = new Set();
159
+ const projects = value.map((candidate, index)=>{
160
+ const label = `projects[${index}]`;
161
+ if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
162
+ rejectUnknownKeys(candidate, [
697
163
  'name',
164
+ 'setup',
165
+ 'nodes',
166
+ 'files',
698
167
  'tags',
699
- 'resources',
700
- 'steps'
701
- ], `Case ${caseIndex + 1}`);
702
- if (void 0 !== definition.id && !isSafeResultPathSegment(definition.id)) throw new WorkflowParseError(`Case ${caseIndex + 1} id must start with an ASCII letter or number, contain only letters, numbers, dots, underscores, or hyphens, and be at most 128 characters.`, {
703
- caseIndex
704
- });
705
- if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
706
- caseIndex
707
- });
708
- if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
709
- caseIndex
710
- });
711
- const tags = definition.tags ?? [];
712
- 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.`, {
713
- caseIndex
714
- });
715
- const resources = definition.resources ?? [];
716
- if (!Array.isArray(resources) || resources.some((resource)=>'string' != typeof resource || 0 === resource.trim().length)) throw new WorkflowParseError(`Case ${caseIndex + 1} resources must be an array of non-empty strings.`, {
717
- caseIndex
718
- });
719
- if (new Set(resources).size !== resources.length) throw new WorkflowParseError(`Case ${caseIndex + 1} resources must not contain duplicates.`, {
720
- caseIndex
721
- });
722
- const steps = normalize_normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
723
- if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
724
- caseIndex,
725
- stepIndex,
726
- node: normalized.node
727
- });
728
- return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
729
- });
730
- const caseId = 'string' == typeof definition.id ? definition.id : createCaseId(source.projectId, sourcePath, caseIndex);
731
- if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
732
- caseId
733
- });
734
- ids.add(caseId);
735
- return {
736
- caseId,
737
- projectId: source.projectId,
738
- sourcePath,
739
- caseIndex,
740
- definition: {
741
- ...'string' == typeof definition.id ? {
742
- id: definition.id
743
- } : {},
744
- name: definition.name,
745
- tags: tags,
746
- ...resources.length > 0 ? {
747
- resources: resources
748
- } : {},
749
- steps
168
+ 'retry',
169
+ 'variables'
170
+ ], label);
171
+ if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
172
+ if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
173
+ names.add(candidate.name);
174
+ const files = validateTestFileSelection(candidate.files, `${label}.files`);
175
+ if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
176
+ const localNodes = new registry_NodeRegistry(candidate.nodes);
177
+ const nodes = new registry_NodeRegistry([
178
+ ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
179
+ ...localNodes.definitions()
180
+ ]);
181
+ return Object.freeze({
182
+ projectId: projectIdFromIndex(index),
183
+ name: candidate.name,
184
+ nodes,
185
+ ...files ? {
186
+ files
187
+ } : {},
188
+ tags: validateTagSelection(candidate.tags, `${label}.tags`),
189
+ retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
190
+ variables: validateVariables(candidate.variables, `${label}.variables`),
191
+ ...void 0 === candidate.setup ? {} : {
192
+ setup: validateSetup(candidate.setup, `${label}.setup`)
750
193
  }
751
- };
194
+ });
752
195
  });
753
196
  return {
754
- documentId: createWorkflowDocumentId(source.projectId, sourcePath),
755
- projectId: source.projectId,
756
- sourcePath,
757
- sourceYaml,
758
- lifecycle,
759
- cases
197
+ projects: Object.freeze(projects),
198
+ hasExplicitProjects: true
760
199
  };
761
- }
762
- const writeJson = async (path, value)=>{
763
- try {
764
- await mkdir(dirname(path), {
765
- recursive: true
766
- });
767
- await writeFile(path, JSON.stringify(value, null, 2));
768
- } catch (error) {
769
- throw new WorkflowPublicationError('write-result', path, error);
770
- }
771
200
  };
772
- const writeTestRunPlan = (runDir, projects)=>writeJson(join(runDir, 'plan.json'), {
773
- schemaVersion: 1,
774
- createdAt: new Date().toISOString(),
775
- projects: projects.map((item)=>({
776
- projectId: item.project.projectId,
777
- name: item.project.name,
778
- documents: item.plans ?? []
779
- }))
201
+ const validateTestOptions = (value)=>{
202
+ if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
203
+ const candidate = value ?? {};
204
+ return Object.freeze({
205
+ maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
206
+ bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
207
+ testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout')
780
208
  });
781
- const toPosix = (value)=>value.split(sep).join('/');
782
- const caseAttemptResultPath = (projectId, documentId, result)=>toPosix(join(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
783
- const workflowDocumentResultPath = (result)=>toPosix(join(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
784
- const collectionErrorPath = (projectId, sourcePath)=>{
785
- const id = createHash('sha256').update(JSON.stringify([
786
- projectId,
787
- sourcePath
788
- ])).digest('hex');
789
- return toPosix(join(projectId, 'collection-errors', `${id}.json`));
790
- };
791
- const writeWorkflowDocumentResult = (runDir, result)=>{
792
- const { documentRunId: _documentRunId, ...persistedResult } = result;
793
- return writeJson(join(runDir, workflowDocumentResultPath(result)), persistedResult);
794
209
  };
795
- const writeCaseAttemptResult = (runDir, projectId, documentId, result)=>{
796
- const { runId: attemptId, ...persistedResult } = result;
797
- return writeJson(join(runDir, caseAttemptResultPath(projectId, documentId, result)), {
798
- ...persistedResult,
799
- attemptId
800
- });
210
+ const validateOutputPath = (value, fallback, label)=>{
211
+ if (void 0 === value) return fallback;
212
+ if ('string' != typeof value || 0 === value.trim().length) throw new TypeError(`Midscene config ${label} must be non-empty.`);
213
+ return value;
801
214
  };
802
- const writeCollectionError = (runDir, collectionError)=>writeJson(join(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
803
- kind: 'collection-error',
804
- ...collectionError
215
+ const validateOutput = (value)=>{
216
+ if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config output must be an object.');
217
+ const candidate = value ?? {};
218
+ rejectUnknownKeys(candidate, [
219
+ 'reportDir'
220
+ ], 'output');
221
+ return Object.freeze({
222
+ reportDir: validateOutputPath(candidate.reportDir, './midscene_run/report', 'output.reportDir')
805
223
  });
806
- const relativeToSummary = (summaryPath, target)=>{
807
- const value = relative(dirname(summaryPath), target);
808
- return toPosix(value || '.');
809
224
  };
810
- const errorJson = sanitizeReportError;
811
- const writeTestProjectRunResult = (options)=>{
812
- const { result } = options;
813
- const fact = (path)=>toPosix(path);
814
- return writeJson(result.summaryPath, {
815
- schemaVersion: result.schemaVersion,
816
- runId: result.runId,
817
- startedAt: result.startedAt,
818
- endedAt: result.endedAt,
819
- durationMs: result.durationMs,
820
- status: result.status,
821
- exitCode: result.exitCode,
822
- ...result.errors?.length ? {
823
- errors: result.errors.map(errorJson)
824
- } : {},
825
- projectRoot: options.projectRoot,
826
- ...options.configPath ? {
827
- configPath: options.configPath
828
- } : {},
829
- factsRoot: '.',
830
- plan: 'plan.json',
831
- ...result.operationLog ? {
832
- operationLog: {
833
- ...result.operationLog,
834
- path: relativeToSummary(result.summaryPath, result.operationLog.path)
835
- }
836
- } : {},
837
- reportDir: relativeToSummary(result.summaryPath, result.reportDir),
838
- ...result.reportPath ? {
839
- report: relativeToSummary(result.summaryPath, result.reportPath)
840
- } : {},
841
- ...result.markdownPath ? {
842
- markdown: relativeToSummary(result.summaryPath, result.markdownPath)
843
- } : {},
844
- summary: result.summary,
845
- projects: result.projects.map((project)=>({
846
- projectId: project.projectId,
847
- name: project.name,
848
- status: project.status,
849
- retry: project.retry,
850
- fileSelection: project.fileSelection,
851
- tagSelection: project.tagSelection,
852
- sourceCount: project.sourceCount,
853
- selectedCaseCount: project.selectedCaseCount,
854
- filteredCaseCount: project.filteredCaseCount,
855
- ...project.lifecycle ? {
856
- lifecycle: {
857
- status: project.lifecycle.status,
858
- startedAt: project.lifecycle.startedAt,
859
- endedAt: project.lifecycle.endedAt,
860
- durationMs: project.lifecycle.durationMs,
861
- ...project.lifecycle.cleanup ? {
862
- cleanup: project.lifecycle.cleanup
863
- } : {},
864
- ...project.lifecycle.pendingOperations ? {
865
- pendingOperations: project.lifecycle.pendingOperations
866
- } : {},
867
- ...project.lifecycle.setupError ? {
868
- setupError: errorJson(project.lifecycle.setupError)
869
- } : {},
870
- ...project.lifecycle.teardownErrors ? {
871
- teardownErrors: project.lifecycle.teardownErrors.map(errorJson)
872
- } : {}
873
- }
874
- } : {},
875
- cases: project.cases.map((outcome)=>({
876
- ...outcome.documentRunId ? {
877
- documentRunId: outcome.documentRunId
878
- } : {},
879
- documentId: outcome.documentId,
880
- caseId: outcome.caseId,
881
- sourcePath: outcome.sourcePath,
882
- caseIndex: outcome.caseIndex,
883
- name: outcome.name,
884
- status: outcome.status,
885
- ...outcome.notRunReason ? {
886
- notRunReason: outcome.notRunReason
887
- } : {},
888
- ...outcome.execution ? {
889
- execution: {
890
- executor: outcome.execution.executor,
891
- resources: outcome.execution.resources,
892
- ...void 0 === outcome.execution.attempts ? {} : {
893
- attempts: outcome.execution.attempts
894
- },
895
- ...outcome.execution.failure ? {
896
- failure: outcome.execution.failure
897
- } : {},
898
- ...outcome.execution.artifacts ? {
899
- artifacts: outcome.execution.artifacts
900
- } : {},
901
- ...outcome.execution.metadata ? {
902
- metadata: outcome.execution.metadata
903
- } : {},
904
- ...outcome.execution.lifecycle ? {
905
- lifecycle: {
906
- status: outcome.execution.lifecycle.status,
907
- startedAt: outcome.execution.lifecycle.startedAt,
908
- endedAt: outcome.execution.lifecycle.endedAt,
909
- durationMs: outcome.execution.lifecycle.durationMs,
910
- ...outcome.execution.lifecycle.setupError ? {
911
- setupError: errorJson(outcome.execution.lifecycle.setupError)
912
- } : {},
913
- ...outcome.execution.lifecycle.teardownErrors ? {
914
- teardownErrors: outcome.execution.lifecycle.teardownErrors.map(errorJson)
915
- } : {}
916
- }
917
- } : {}
918
- }
919
- } : {},
920
- ...outcome.attempts ? {
921
- attempts: outcome.attempts.map((attempt)=>({
922
- attemptId: attempt.runId,
923
- attemptIndex: attempt.attemptIndex,
924
- status: attempt.status,
925
- ...attempt.cleanup ? {
926
- cleanup: attempt.cleanup
927
- } : {},
928
- ...attempt.pendingOperations ? {
929
- pendingOperations: attempt.pendingOperations
930
- } : {},
931
- ...attempt.executionStatus ? {
932
- executionStatus: attempt.executionStatus
933
- } : {},
934
- ...attempt.retryDecision ? {
935
- retryDecision: attempt.retryDecision
936
- } : {},
937
- resultFile: fact(caseAttemptResultPath(project.projectId, outcome.documentId, attempt)),
938
- ...attempt.reportPaths?.length ? {
939
- reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
940
- } : {}
941
- }))
942
- } : {}
943
- })),
944
- documents: project.documents.map((document)=>({
945
- documentId: document.documentId,
946
- ...void 0 === document.attemptIndex ? {} : {
947
- attemptIndex: document.attemptIndex,
948
- documentRunId: document.documentRunId
949
- },
950
- sourcePath: document.sourcePath,
951
- status: document.status,
952
- ...document.cleanup ? {
953
- cleanup: document.cleanup
954
- } : {},
955
- ...document.pendingOperations ? {
956
- pendingOperations: document.pendingOperations
957
- } : {},
958
- ...document.executionStatus ? {
959
- executionStatus: document.executionStatus
960
- } : {},
961
- ...document.retryDecision ? {
962
- retryDecision: document.retryDecision
963
- } : {},
964
- resultFile: fact(workflowDocumentResultPath(document)),
965
- ...document.reportPaths?.length ? {
966
- reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
967
- } : {}
968
- })),
969
- collectionErrors: project.collectionErrors.map((error)=>({
970
- sourcePath: error.sourcePath,
971
- errorFile: fact(collectionErrorPath(error.projectId, error.sourcePath))
972
- }))
973
- }))
974
- });
225
+ const validateTestProjectDefinition = (definition)=>{
226
+ if (!isRecord(definition)) throw new TypeError('Midscene config must default export an object.');
227
+ if (void 0 !== definition.nodes && !Array.isArray(definition.nodes)) throw new TypeError('Midscene config nodes must be an array.');
228
+ if ('setupWorkflow' in definition) throw new TypeError('Midscene config setupWorkflow is no longer supported. Use setup or projects[].setup instead.');
229
+ 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.');
230
+ if ('root' in definition) throw new TypeError('Midscene config root is not supported. Pass the Test Project directory to midscene-test instead.');
231
+ if ('files' in definition) throw new TypeError('Midscene config files is not supported at the root. Move it to projects[].files.');
232
+ if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
233
+ rejectUnknownKeys(definition, [
234
+ 'setup',
235
+ 'projects',
236
+ 'test',
237
+ 'output',
238
+ 'nodes'
239
+ ], 'root');
240
+ const nodes = new registry_NodeRegistry(definition.nodes);
241
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
242
+ return {
243
+ ...resolvedProjects,
244
+ test: validateTestOptions(definition.test),
245
+ output: validateOutput(definition.output),
246
+ nodes,
247
+ resolveNode: (name)=>nodes.get(name)
248
+ };
975
249
  };
976
- const registry_NodeRegistry = NodeRegistry;
977
- const projectIdFromIndex = (index)=>`project-${index}`;
978
- const defineTestProject = (definition)=>definition;
979
- const defineProjectSetup = (definition)=>definition;
980
- const getDefaultExport = (loaded, absolutePath)=>{
981
- if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
982
- const defaultExport = loaded.default;
983
- if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
984
- if ('default' in defaultExport) return defaultExport.default;
985
- throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
986
- }
987
- return defaultExport;
250
+ const assertSupportedConfig = (absolutePath)=>{
251
+ if (!absolutePath.endsWith('.ts') && !absolutePath.endsWith('.mjs')) {
252
+ const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
253
+ throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extensions: .ts, .mjs.`);
988
254
  }
989
- throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
990
- };
991
- const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
992
- const test_project_rejectUnknownKeys = (value, allowedKeys, label)=>{
993
- const allowed = new Set(allowedKeys);
994
- const unknown = Object.keys(value).find((key)=>!allowed.has(key));
995
- if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
996
- };
997
- const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
998
- const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
999
- if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
1000
- if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
1001
- return value.map((pattern, index)=>{
1002
- if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
1003
- if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
1004
- if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
1005
- if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
1006
- if (pattern.startsWith('!')) {
1007
- if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
1008
- throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
1009
- }
1010
- return pattern;
1011
- });
1012
- };
1013
- const validateTestFileSelection = (value, label = 'files')=>{
1014
- if (void 0 === value) return;
1015
- if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
1016
- test_project_rejectUnknownKeys(value, [
1017
- 'include',
1018
- 'exclude'
1019
- ], label);
1020
- const include = validatePatterns(value.include, 'include', label);
1021
- const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
1022
- return Object.freeze({
1023
- include: Object.freeze(include),
1024
- ...exclude ? {
1025
- exclude: Object.freeze(exclude)
1026
- } : {}
1027
- });
255
+ return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
1028
256
  };
1029
- const validateTagList = (value, field, label)=>{
1030
- if (void 0 === value) return [];
1031
- if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
1032
- return value.map((tag, index)=>{
1033
- if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
1034
- return tag;
1035
- });
257
+ const canRetryWithCjsLoader = (error)=>{
258
+ if (!(error instanceof Error)) return false;
259
+ const errorCode = error.code;
260
+ return error instanceof SyntaxError && /^Unexpected (?:identifier|reserved word|token)/.test(error.message) || 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' === errorCode || 'ERR_UNKNOWN_FILE_EXTENSION' === errorCode && /^Unknown file extension\b/.test(error.message);
1036
261
  };
1037
- const validateTagSelection = (value, label)=>{
1038
- if (void 0 === value) return Object.freeze({
1039
- include: [],
1040
- exclude: []
262
+ async function loadTestProject(configPath) {
263
+ if (!configPath) return validateTestProjectDefinition({
264
+ nodes: []
1041
265
  });
1042
- if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
1043
- return Object.freeze({
1044
- include: Object.freeze(validateTagList(value.include, 'include', label)),
1045
- exclude: Object.freeze(validateTagList(value.exclude, 'exclude', label))
266
+ const absolutePath = resolve(configPath);
267
+ const extension = assertSupportedConfig(absolutePath);
268
+ let loaded;
269
+ try {
270
+ if ('.mjs' === extension) loaded = await import(pathToFileURL(absolutePath).href);
271
+ else {
272
+ const { tsImport } = await import("tsx/esm/api");
273
+ loaded = await tsImport(pathToFileURL(absolutePath).href, {
274
+ parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
275
+ tsconfig: false
276
+ });
277
+ }
278
+ } catch (error) {
279
+ try {
280
+ if ('.mjs' === extension || !canRetryWithCjsLoader(error)) throw error;
281
+ loaded = api_require(absolutePath, pathToFileURL(absolutePath));
282
+ } catch (fallbackError) {
283
+ const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
284
+ throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
285
+ cause: fallbackError
286
+ });
287
+ }
288
+ }
289
+ return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
290
+ }
291
+ const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
292
+ projectId,
293
+ sourcePath,
294
+ caseIndex,
295
+ ...invocationIndex ? [
296
+ invocationIndex
297
+ ] : []
298
+ ])).digest('hex');
299
+ const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
300
+ projectId,
301
+ sourcePath,
302
+ ...invocationIndex ? [
303
+ invocationIndex
304
+ ] : []
305
+ ])).digest('hex');
306
+ const normalize_normalizeSteps = normalizeSteps;
307
+ function parseWorkflowSource(source) {
308
+ const stack = [];
309
+ const nodes = [];
310
+ const sensitiveNodes = new Set();
311
+ const sensitiveValues = new Set();
312
+ const redactedPaths = new Set();
313
+ const sourceOffset = 0xfeff === source.charCodeAt(0) ? 1 : 0;
314
+ const parsed = load(source, {
315
+ schema: JSON_SCHEMA,
316
+ listener (event, state) {
317
+ if ('open' === event) return void stack.push({
318
+ start: state.position + sourceOffset,
319
+ end: 0,
320
+ value: null,
321
+ children: []
322
+ });
323
+ const node = stack.pop();
324
+ node.end = state.position + sourceOffset;
325
+ node.value = state.result;
326
+ stack.at(-1)?.children.push(node);
327
+ nodes.push(node);
328
+ if ('mapping' !== state.kind) return;
329
+ for(let index = 0; index < node.children.length; index += 2){
330
+ const key = node.children[index].value;
331
+ const value = node.children[index + 1];
332
+ if ('string' == typeof key && isReportSecretKey(key) && value) {
333
+ sensitiveNodes.add(value);
334
+ if (null !== value.value) sensitiveValues.add(value.value);
335
+ redactedPaths.add(`$..[${JSON.stringify(key)}]`);
336
+ }
337
+ }
338
+ }
1046
339
  });
1047
- };
1048
- function assertJsonValue(value, path, seen) {
1049
- if (null === value || 'string' == typeof value || 'boolean' == typeof value) return;
1050
- if ('number' == typeof value) {
1051
- if (Number.isFinite(value)) return;
1052
- throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
340
+ const ranges = nodes.filter((node)=>sensitiveNodes.has(node) || sensitiveValues.has(node.value)).sort((left, right)=>left.start - right.start || right.end - left.end);
341
+ let offset = 0;
342
+ let redacted = '';
343
+ for (const range of ranges){
344
+ if (range.start < offset) continue;
345
+ const text = source.slice(range.start, range.end);
346
+ const leading = text.match(/^\s*/)?.[0] ?? '';
347
+ const trailing = text.slice(leading.length).match(/\s*$/)?.[0] ?? '';
348
+ redacted += `${source.slice(offset, range.start)}${leading}"[REDACTED]"${trailing}`;
349
+ offset = range.end;
1053
350
  }
1054
- if ('object' != typeof value || void 0 === value) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
1055
- if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && null !== Object.getPrototypeOf(value)) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
1056
- if (seen.has(value)) throw new TypeError(`Midscene config ${path} must not contain cycles.`);
1057
- seen.add(value);
1058
- if (Array.isArray(value)) value.forEach((item, index)=>assertJsonValue(item, `${path}[${index}]`, seen));
1059
- else for (const [key, child] of Object.entries(value))assertJsonValue(child, `${path}.${key}`, seen);
1060
- seen.delete(value);
351
+ redacted += source.slice(offset);
352
+ const value = sanitizeReportText(redacted, 65536);
353
+ return {
354
+ parsed,
355
+ sourceYaml: {
356
+ value,
357
+ ...redactedPaths.size ? {
358
+ redactedPaths: [
359
+ ...redactedPaths
360
+ ]
361
+ } : {},
362
+ ...value !== redacted ? {
363
+ truncatedPaths: [
364
+ '$'
365
+ ]
366
+ } : {}
367
+ }
368
+ };
1061
369
  }
1062
- const deepFreezeJson = (value)=>{
1063
- if ('object' == typeof value && null !== value) {
1064
- for (const child of Object.values(value))deepFreezeJson(child);
1065
- Object.freeze(value);
1066
- }
1067
- return value;
370
+ const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
371
+ const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
372
+ const exactProjectExpression = /^\$\{([^{}]+)\}$/;
373
+ const missingVariable = (kind, name, options)=>{
374
+ throw new WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
375
+ ...options.location,
376
+ variable: name,
377
+ variableKind: kind
378
+ });
1068
379
  };
1069
- const validateVariables = (value, label)=>{
1070
- if (void 0 === value) return Object.freeze({});
1071
- if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
1072
- assertJsonValue(value, label, new Set());
1073
- return deepFreezeJson(value);
380
+ const projectVariable = (name, options)=>{
381
+ if (!Object.hasOwn(options.variables ?? {}, name)) return missingVariable('project', name, options);
382
+ return options.variables[name];
1074
383
  };
1075
- const validateSetup = (value, label)=>{
1076
- if (void 0 === value) return;
1077
- if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
1078
- test_project_rejectUnknownKeys(value, [
1079
- 'name',
1080
- 'setup'
1081
- ], label);
1082
- if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
1083
- if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
384
+ const environmentVariable = (name, options)=>{
385
+ const value = options.env?.[name];
386
+ if (void 0 === value) return missingVariable('environment', name, options);
1084
387
  return value;
1085
388
  };
1086
- const validatePositiveInteger = (value, fallback, label)=>{
1087
- if (void 0 === value) return fallback;
1088
- if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
1089
- return value;
389
+ const interpolationValue = (name, value, options)=>{
390
+ if ('object' == typeof value && null !== value) throw new WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
391
+ ...options.location,
392
+ variable: name,
393
+ variableKind: 'project'
394
+ });
395
+ return String(value);
1090
396
  };
1091
- const validateNonNegativeInteger = (value, fallback, label)=>{
1092
- if (void 0 === value) return fallback;
1093
- if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
397
+ const resolveString = (value, options)=>{
398
+ const exactEnv = exactEnvExpression.exec(value);
399
+ if (exactEnv) return environmentVariable(exactEnv[1], options);
400
+ const exactProject = exactProjectExpression.exec(value);
401
+ if (exactProject) return projectVariable(exactProject[1], options);
402
+ return value.replace(interpolationExpression, (_match, environmentName, projectName)=>environmentName ? environmentVariable(environmentName, options) : interpolationValue(projectName, projectVariable(projectName, options), options));
403
+ };
404
+ const resolveWorkflowVariables = (value, options)=>{
405
+ if ('string' == typeof value) return resolveString(value, options);
406
+ if (Array.isArray(value)) return value.map((child)=>resolveWorkflowVariables(child, options));
407
+ if ('object' == typeof value && null !== value) return Object.fromEntries(Object.entries(value).map(([key, child])=>[
408
+ key,
409
+ resolveWorkflowVariables(child, options)
410
+ ]));
1094
411
  return value;
1095
412
  };
1096
- const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
1097
- if (void 0 === value) {
1098
- const setup = validateSetup(defaultSetup, 'setup');
1099
- return {
1100
- hasExplicitProjects: false,
1101
- projects: Object.freeze([
1102
- Object.freeze({
1103
- projectId: projectIdFromIndex(0),
1104
- name: 'default',
1105
- ...setup ? {
1106
- setup
1107
- } : {},
1108
- tags: Object.freeze({
1109
- include: [],
1110
- exclude: []
1111
- }),
1112
- retry: 0,
1113
- variables: Object.freeze({}),
1114
- nodes: new registry_NodeRegistry(globalNodes.definitions())
1115
- })
1116
- ])
1117
- };
413
+ function _define_property(obj, key, value) {
414
+ if (key in obj) Object.defineProperty(obj, key, {
415
+ value: value,
416
+ enumerable: true,
417
+ configurable: true,
418
+ writable: true
419
+ });
420
+ else obj[key] = value;
421
+ return obj;
422
+ }
423
+ const isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
424
+ class NativeWorkflowFormatError extends WorkflowParseError {
425
+ constructor(actualFormat, sourcePath){
426
+ super('legacy' === actualFormat ? `Legacy tasks/flow YAML is not supported by midscene-test: ${sourcePath}. Run it with \`midscene ${sourcePath}\`, or migrate the file to cases/steps.` : 'legacy-batch' === actualFormat ? `Legacy YAML batch configuration is not a midscene-test workflow document: ${sourcePath}. Run it with \`midscene --config ${sourcePath}\`, or migrate it to a native project config.` : `A workflow file cannot mix legacy tasks/flow with native cases/steps or lifecycle hooks: ${sourcePath}. Keep the formats in separate files and run each file with its matching command.`, {
427
+ sourcePath,
428
+ actualFormat,
429
+ expectedFormat: 'native'
430
+ }), _define_property(this, "actualFormat", void 0);
431
+ this.actualFormat = actualFormat;
1118
432
  }
1119
- if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
1120
- if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
1121
- const names = new Set();
1122
- const projects = value.map((candidate, index)=>{
1123
- const label = `projects[${index}]`;
1124
- if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
1125
- test_project_rejectUnknownKeys(candidate, [
433
+ }
434
+ const collect_rejectUnknownKeys = (value, allowed, label)=>{
435
+ const allowedSet = new Set(allowed);
436
+ const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
437
+ if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
438
+ field: unknown[0]
439
+ });
440
+ };
441
+ const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
442
+ const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
443
+ function collectWorkflowDocument(source, options) {
444
+ let snapshot;
445
+ try {
446
+ snapshot = parseWorkflowSource(readFileSync(source.absolutePath, 'utf8'));
447
+ } catch (error) {
448
+ throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
449
+ sourcePath: source.sourcePath
450
+ }, error);
451
+ }
452
+ const { parsed, sourceYaml } = snapshot;
453
+ if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
454
+ const format = classifyWorkflowDocumentFormat(parsed);
455
+ if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
456
+ collect_rejectUnknownKeys(parsed, [
457
+ 'beforeAll',
458
+ 'beforeEach',
459
+ 'cases',
460
+ 'afterEach',
461
+ 'afterAll'
462
+ ], 'Workflow document');
463
+ if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new WorkflowParseError('Workflow document must contain a non-empty cases array.');
464
+ const sourcePath = source.sourcePath.split(sep).join('/');
465
+ const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
466
+ ...step,
467
+ templateInput: step.input,
468
+ input: resolveWorkflowVariables(step.input, {
469
+ variables: options.variables,
470
+ env: options.env ?? process.env,
471
+ location: {
472
+ projectName: source.projectName ?? source.projectId,
473
+ sourcePath,
474
+ phase,
475
+ stepIndex,
476
+ ...void 0 === caseIndex ? {} : {
477
+ caseIndex
478
+ }
479
+ }
480
+ })
481
+ });
482
+ const normalizeLifecycle = (phase)=>{
483
+ const value = parsed[phase];
484
+ if (void 0 === value) return [];
485
+ if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
486
+ phase
487
+ });
488
+ return normalize_normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
489
+ const resolved = options.resolveNode(normalized.node);
490
+ if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
491
+ phase,
492
+ stepIndex,
493
+ node: normalized.node
494
+ });
495
+ return resolveStepVariables(normalized, phase, stepIndex);
496
+ });
497
+ };
498
+ const lifecycle = {
499
+ beforeAll: normalizeLifecycle('beforeAll'),
500
+ beforeEach: normalizeLifecycle('beforeEach'),
501
+ afterEach: normalizeLifecycle('afterEach'),
502
+ afterAll: normalizeLifecycle('afterAll')
503
+ };
504
+ const ids = new Set();
505
+ const cases = parsed.cases.map((definition, caseIndex)=>{
506
+ if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
507
+ caseIndex
508
+ });
509
+ collect_rejectUnknownKeys(definition, [
1126
510
  'name',
1127
- 'setup',
1128
- 'nodes',
1129
- 'files',
1130
511
  'tags',
1131
- 'retry',
1132
- 'variables'
1133
- ], label);
1134
- if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
1135
- if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
1136
- names.add(candidate.name);
1137
- const files = validateTestFileSelection(candidate.files, `${label}.files`);
1138
- if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
1139
- const localNodes = new registry_NodeRegistry(candidate.nodes);
1140
- const nodes = new registry_NodeRegistry([
1141
- ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
1142
- ...localNodes.definitions()
1143
- ]);
1144
- return Object.freeze({
1145
- projectId: projectIdFromIndex(index),
1146
- name: candidate.name,
1147
- nodes,
1148
- ...files ? {
1149
- files
1150
- } : {},
1151
- tags: validateTagSelection(candidate.tags, `${label}.tags`),
1152
- retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
1153
- variables: validateVariables(candidate.variables, `${label}.variables`),
1154
- ...void 0 === candidate.setup ? {} : {
1155
- setup: validateSetup(candidate.setup, `${label}.setup`)
1156
- }
512
+ 'steps'
513
+ ], `Case ${caseIndex + 1}`);
514
+ if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
515
+ caseIndex
516
+ });
517
+ if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
518
+ caseIndex
519
+ });
520
+ const tags = definition.tags ?? [];
521
+ 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.`, {
522
+ caseIndex
523
+ });
524
+ const steps = normalize_normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
525
+ if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
526
+ caseIndex,
527
+ stepIndex,
528
+ node: normalized.node
529
+ });
530
+ return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
1157
531
  });
532
+ const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
533
+ if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
534
+ caseId
535
+ });
536
+ ids.add(caseId);
537
+ return {
538
+ caseId,
539
+ projectId: source.projectId,
540
+ sourcePath,
541
+ caseIndex,
542
+ definition: {
543
+ name: definition.name,
544
+ tags: tags,
545
+ steps
546
+ }
547
+ };
1158
548
  });
1159
549
  return {
1160
- projects: Object.freeze(projects),
1161
- hasExplicitProjects: true
550
+ documentId: createWorkflowDocumentId(source.projectId, sourcePath),
551
+ projectId: source.projectId,
552
+ sourcePath,
553
+ sourceYaml,
554
+ lifecycle,
555
+ cases
1162
556
  };
557
+ }
558
+ const writeJson = async (path, value)=>{
559
+ try {
560
+ await mkdir(dirname(path), {
561
+ recursive: true
562
+ });
563
+ await writeFile(path, JSON.stringify(value, null, 2));
564
+ } catch (error) {
565
+ throw new WorkflowPublicationError('write-result', path, error);
566
+ }
1163
567
  };
1164
- const validateTestOptions = (value)=>{
1165
- if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
1166
- const candidate = value ?? {};
1167
- const executionUnit = candidate.executionUnit ?? 'project';
1168
- if ('project' !== executionUnit && 'case' !== executionUnit) throw new TypeError('Midscene config test.executionUnit must be "project" or "case".');
1169
- return Object.freeze({
1170
- maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
1171
- bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
1172
- testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout'),
1173
- executionUnit,
1174
- executorRetry: validateNonNegativeInteger(candidate.executorRetry, 0, 'test.executorRetry')
568
+ const writeTestRunPlan = (runDir, projects)=>writeJson(join(runDir, 'plan.json'), {
569
+ schemaVersion: 1,
570
+ createdAt: new Date().toISOString(),
571
+ projects: projects.map((item)=>({
572
+ projectId: item.project.projectId,
573
+ name: item.project.name,
574
+ documents: item.plans ?? []
575
+ }))
1175
576
  });
577
+ const toPosix = (value)=>value.split(sep).join('/');
578
+ const caseAttemptResultPath = (projectId, documentId, result)=>toPosix(join(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
579
+ const workflowDocumentResultPath = (result)=>toPosix(join(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
580
+ const collectionErrorPath = (projectId, sourcePath)=>{
581
+ const id = createHash('sha256').update(JSON.stringify([
582
+ projectId,
583
+ sourcePath
584
+ ])).digest('hex');
585
+ return toPosix(join(projectId, 'collection-errors', `${id}.json`));
1176
586
  };
1177
- const validateTestExecutor = (value)=>{
1178
- if (void 0 === value) return;
1179
- if (!isRecord(value)) throw new TypeError('Midscene config executor must be an object.');
1180
- test_project_rejectUnknownKeys(value, [
1181
- 'name',
1182
- 'execute'
1183
- ], 'executor');
1184
- if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError('Midscene config executor.name must be non-empty.');
1185
- if ('function' != typeof value.execute) throw new TypeError('Midscene config executor.execute must be a function.');
1186
- return Object.freeze(value);
587
+ const writeWorkflowDocumentResult = (runDir, result)=>{
588
+ const { documentRunId: _documentRunId, ...persistedResult } = result;
589
+ return writeJson(join(runDir, workflowDocumentResultPath(result)), persistedResult);
1187
590
  };
1188
- const validateOutputPath = (value, fallback, label)=>{
1189
- if (void 0 === value) return fallback;
1190
- if ('string' != typeof value || 0 === value.trim().length) throw new TypeError(`Midscene config ${label} must be non-empty.`);
1191
- return value;
591
+ const writeCaseAttemptResult = (runDir, projectId, documentId, result)=>{
592
+ const { runId: attemptId, ...persistedResult } = result;
593
+ return writeJson(join(runDir, caseAttemptResultPath(projectId, documentId, result)), {
594
+ ...persistedResult,
595
+ attemptId
596
+ });
1192
597
  };
1193
- const validateOutput = (value)=>{
1194
- if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config output must be an object.');
1195
- const candidate = value ?? {};
1196
- test_project_rejectUnknownKeys(candidate, [
1197
- 'reportDir'
1198
- ], 'output');
1199
- return Object.freeze({
1200
- reportDir: validateOutputPath(candidate.reportDir, './midscene_run/report', 'output.reportDir')
598
+ const writeCollectionError = (runDir, collectionError)=>writeJson(join(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
599
+ kind: 'collection-error',
600
+ ...collectionError
1201
601
  });
602
+ const relativeToSummary = (summaryPath, target)=>{
603
+ const value = relative(dirname(summaryPath), target);
604
+ return toPosix(value || '.');
1202
605
  };
1203
- const validateTestProjectDefinition = (definition)=>{
1204
- if (!isRecord(definition)) throw new TypeError('Midscene config must default export an object.');
1205
- if (void 0 !== definition.nodes && !Array.isArray(definition.nodes)) throw new TypeError('Midscene config nodes must be an array.');
1206
- if ('setupWorkflow' in definition) throw new TypeError('Midscene config setupWorkflow is no longer supported. Use setup or projects[].setup instead.');
1207
- 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.');
1208
- if ('root' in definition) throw new TypeError('Midscene config root is not supported. Pass the Test Project directory to midscene-test instead.');
1209
- if ('files' in definition) throw new TypeError('Midscene config files is not supported at the root. Move it to projects[].files.');
1210
- if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
1211
- test_project_rejectUnknownKeys(definition, [
1212
- 'setup',
1213
- 'projects',
1214
- 'test',
1215
- 'output',
1216
- 'nodes',
1217
- 'executor'
1218
- ], 'root');
1219
- const nodes = new registry_NodeRegistry(definition.nodes);
1220
- const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
1221
- const test = validateTestOptions(definition.test);
1222
- const executor = validateTestExecutor(definition.executor);
1223
- if (executor && 'case' !== test.executionUnit) throw new TypeError('Midscene config executor requires test.executionUnit to be "case".');
1224
- return {
1225
- ...resolvedProjects,
1226
- test,
1227
- output: validateOutput(definition.output),
1228
- nodes,
1229
- ...executor ? {
1230
- executor
606
+ const errorJson = sanitizeReportError;
607
+ const writeTestProjectRunResult = (options)=>{
608
+ const { result } = options;
609
+ const fact = (path)=>toPosix(path);
610
+ return writeJson(result.summaryPath, {
611
+ schemaVersion: result.schemaVersion,
612
+ runId: result.runId,
613
+ startedAt: result.startedAt,
614
+ endedAt: result.endedAt,
615
+ durationMs: result.durationMs,
616
+ status: result.status,
617
+ exitCode: result.exitCode,
618
+ ...result.errors?.length ? {
619
+ errors: result.errors.map(errorJson)
1231
620
  } : {},
1232
- resolveNode: (name)=>nodes.get(name)
1233
- };
1234
- };
1235
- const assertSupportedConfig = (absolutePath)=>{
1236
- if (!absolutePath.endsWith('.ts') && !absolutePath.endsWith('.mjs')) {
1237
- const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
1238
- throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extensions: .ts, .mjs.`);
1239
- }
1240
- return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
1241
- };
1242
- const canRetryWithCjsLoader = (error)=>{
1243
- if (!(error instanceof Error)) return false;
1244
- const errorCode = error.code;
1245
- return error instanceof SyntaxError && /^Unexpected (?:identifier|reserved word|token)/.test(error.message) || 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' === errorCode || 'ERR_UNKNOWN_FILE_EXTENSION' === errorCode && /^Unknown file extension\b/.test(error.message);
1246
- };
1247
- async function loadTestProject(configPath) {
1248
- if (!configPath) return validateTestProjectDefinition({
1249
- nodes: []
621
+ projectRoot: options.projectRoot,
622
+ ...options.configPath ? {
623
+ configPath: options.configPath
624
+ } : {},
625
+ factsRoot: '.',
626
+ plan: 'plan.json',
627
+ ...result.operationLog ? {
628
+ operationLog: {
629
+ ...result.operationLog,
630
+ path: relativeToSummary(result.summaryPath, result.operationLog.path)
631
+ }
632
+ } : {},
633
+ reportDir: relativeToSummary(result.summaryPath, result.reportDir),
634
+ ...result.reportPath ? {
635
+ report: relativeToSummary(result.summaryPath, result.reportPath)
636
+ } : {},
637
+ ...result.markdownPath ? {
638
+ markdown: relativeToSummary(result.summaryPath, result.markdownPath)
639
+ } : {},
640
+ summary: result.summary,
641
+ projects: result.projects.map((project)=>({
642
+ projectId: project.projectId,
643
+ name: project.name,
644
+ status: project.status,
645
+ retry: project.retry,
646
+ fileSelection: project.fileSelection,
647
+ tagSelection: project.tagSelection,
648
+ sourceCount: project.sourceCount,
649
+ selectedCaseCount: project.selectedCaseCount,
650
+ filteredCaseCount: project.filteredCaseCount,
651
+ ...project.lifecycle ? {
652
+ lifecycle: {
653
+ status: project.lifecycle.status,
654
+ startedAt: project.lifecycle.startedAt,
655
+ endedAt: project.lifecycle.endedAt,
656
+ durationMs: project.lifecycle.durationMs,
657
+ ...project.lifecycle.cleanup ? {
658
+ cleanup: project.lifecycle.cleanup
659
+ } : {},
660
+ ...project.lifecycle.pendingOperations ? {
661
+ pendingOperations: project.lifecycle.pendingOperations
662
+ } : {},
663
+ ...project.lifecycle.setupError ? {
664
+ setupError: errorJson(project.lifecycle.setupError)
665
+ } : {},
666
+ ...project.lifecycle.teardownErrors ? {
667
+ teardownErrors: project.lifecycle.teardownErrors.map(errorJson)
668
+ } : {}
669
+ }
670
+ } : {},
671
+ cases: project.cases.map((outcome)=>({
672
+ ...outcome.documentRunId ? {
673
+ documentRunId: outcome.documentRunId
674
+ } : {},
675
+ documentId: outcome.documentId,
676
+ caseId: outcome.caseId,
677
+ sourcePath: outcome.sourcePath,
678
+ caseIndex: outcome.caseIndex,
679
+ name: outcome.name,
680
+ status: outcome.status,
681
+ ...outcome.notRunReason ? {
682
+ notRunReason: outcome.notRunReason
683
+ } : {},
684
+ ...outcome.attempts ? {
685
+ attempts: outcome.attempts.map((attempt)=>({
686
+ attemptId: attempt.runId,
687
+ attemptIndex: attempt.attemptIndex,
688
+ status: attempt.status,
689
+ ...attempt.cleanup ? {
690
+ cleanup: attempt.cleanup
691
+ } : {},
692
+ ...attempt.pendingOperations ? {
693
+ pendingOperations: attempt.pendingOperations
694
+ } : {},
695
+ ...attempt.executionStatus ? {
696
+ executionStatus: attempt.executionStatus
697
+ } : {},
698
+ ...attempt.retryDecision ? {
699
+ retryDecision: attempt.retryDecision
700
+ } : {},
701
+ resultFile: fact(caseAttemptResultPath(project.projectId, outcome.documentId, attempt)),
702
+ ...attempt.reportPaths?.length ? {
703
+ reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
704
+ } : {}
705
+ }))
706
+ } : {}
707
+ })),
708
+ documents: project.documents.map((document)=>({
709
+ documentId: document.documentId,
710
+ ...void 0 === document.attemptIndex ? {} : {
711
+ attemptIndex: document.attemptIndex,
712
+ documentRunId: document.documentRunId
713
+ },
714
+ sourcePath: document.sourcePath,
715
+ status: document.status,
716
+ ...document.cleanup ? {
717
+ cleanup: document.cleanup
718
+ } : {},
719
+ ...document.pendingOperations ? {
720
+ pendingOperations: document.pendingOperations
721
+ } : {},
722
+ ...document.executionStatus ? {
723
+ executionStatus: document.executionStatus
724
+ } : {},
725
+ ...document.retryDecision ? {
726
+ retryDecision: document.retryDecision
727
+ } : {},
728
+ resultFile: fact(workflowDocumentResultPath(document)),
729
+ ...document.reportPaths?.length ? {
730
+ reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
731
+ } : {}
732
+ })),
733
+ collectionErrors: project.collectionErrors.map((error)=>({
734
+ sourcePath: error.sourcePath,
735
+ errorFile: fact(collectionErrorPath(error.projectId, error.sourcePath))
736
+ }))
737
+ }))
1250
738
  });
1251
- const absolutePath = external_node_path_resolve(configPath);
1252
- const extension = assertSupportedConfig(absolutePath);
1253
- let loaded;
1254
- try {
1255
- if ('.mjs' === extension) loaded = await import(pathToFileURL(absolutePath).href);
1256
- else {
1257
- const { tsImport } = await import("tsx/esm/api");
1258
- loaded = await tsImport(pathToFileURL(absolutePath).href, {
1259
- parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
1260
- tsconfig: false
1261
- });
1262
- }
1263
- } catch (error) {
1264
- try {
1265
- if ('.mjs' === extension || !canRetryWithCjsLoader(error)) throw error;
1266
- loaded = api_require(absolutePath, pathToFileURL(absolutePath));
1267
- } catch (fallbackError) {
1268
- const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
1269
- throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
1270
- cause: fallbackError
1271
- });
1272
- }
1273
- }
1274
- return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
1275
- }
739
+ };
1276
740
  const CONFIG_NAMES = [
1277
741
  'midscene.config.ts',
1278
742
  'midscene.config.mjs'
@@ -1294,29 +758,8 @@ const DEFAULT_TEST_FILE_SELECTION = {
1294
758
  ]
1295
759
  };
1296
760
  const project_preparation_toPosix = (value)=>value.split(sep).join('/');
1297
- const discoverSelectedTestFiles = (projectRoot, paths)=>{
1298
- const root = external_node_path_resolve(projectRoot);
1299
- const files = new Set();
1300
- for (const requestedPath of paths){
1301
- if ('string' != typeof requestedPath || 0 === requestedPath.trim().length) throw new TypeError('Test selection paths must be non-empty strings.');
1302
- const absolutePath = external_node_path_resolve(root, requestedPath);
1303
- const relativePath = relative(root, absolutePath);
1304
- if ('..' === relativePath || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) throw new Error(`Test selection path is outside the project: ${requestedPath}`);
1305
- if (!existsSync(absolutePath)) throw new Error(`Test selection path does not exist: ${requestedPath}`);
1306
- if (statSync(absolutePath).isDirectory()) for (const file of discoverTestFiles(absolutePath))files.add(file);
1307
- else if (/\.ya?ml$/i.test(absolutePath)) files.add(absolutePath);
1308
- else throw new Error(`Test selection file must be YAML: ${requestedPath}`);
1309
- }
1310
- return [
1311
- ...files
1312
- ].sort((a, b)=>{
1313
- const relativeA = project_preparation_toPosix(relative(root, a));
1314
- const relativeB = project_preparation_toPosix(relative(root, b));
1315
- return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
1316
- });
1317
- };
1318
761
  const discoverResolvedTestFiles = (projectRoot, selection)=>{
1319
- const root = external_node_path_resolve(projectRoot);
762
+ const root = resolve(projectRoot);
1320
763
  const match = (patterns)=>globSync(patterns, {
1321
764
  absolute: true,
1322
765
  caseSensitiveMatch: false,
@@ -1332,7 +775,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
1332
775
  }).filter((file)=>/\.ya?ml$/i.test(file));
1333
776
  const files = match(selection.include);
1334
777
  return [
1335
- ...new Set(files.map((file)=>external_node_path_resolve(file)))
778
+ ...new Set(files.map((file)=>resolve(file)))
1336
779
  ].sort((a, b)=>{
1337
780
  const relativeA = project_preparation_toPosix(relative(root, a));
1338
781
  const relativeB = project_preparation_toPosix(relative(root, b));
@@ -1345,7 +788,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
1345
788
  return discoverResolvedTestFiles(projectRoot, normalized);
1346
789
  };
1347
790
  const discoverTestConfig = (projectRoot)=>{
1348
- const root = external_node_path_resolve(projectRoot);
791
+ const root = resolve(projectRoot);
1349
792
  const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
1350
793
  const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
1351
794
  const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
@@ -1374,14 +817,8 @@ const matchesTags = (tags, selection)=>{
1374
817
  if (selection.exclude.some((tag)=>tags.includes(tag))) return false;
1375
818
  return 0 === selection.include.length || selection.include.some((tag)=>tags.includes(tag));
1376
819
  };
1377
- const filterDocumentCases = (document, project, selection)=>{
1378
- const cases = document.cases.filter((item)=>{
1379
- const tags = item.definition.tags ?? [];
1380
- return matchesTags(tags, project.tags) && matchesTags(tags, {
1381
- include: selection?.tags?.include ?? [],
1382
- exclude: selection?.tags?.exclude ?? []
1383
- }) && (!selection?.caseIds?.length || selection.caseIds.includes(item.caseId));
1384
- });
820
+ const filterDocumentCases = (document, project)=>{
821
+ const cases = document.cases.filter((item)=>matchesTags(item.definition.tags ?? [], project.tags));
1385
822
  const filtered = document.cases.length - cases.length;
1386
823
  return 0 === cases.length ? {
1387
824
  filtered
@@ -1405,10 +842,8 @@ const selectProjects = (projects, names)=>{
1405
842
  async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
1406
843
  const platform = options.platform?.trim();
1407
844
  const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
1408
- const prerequisiteFile = options.prerequisiteFile ? external_node_path_resolve(options.prerequisiteFile) : void 0;
1409
- const projectFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
1410
- const selectedFiles = options.selection?.paths?.length ? new Set(discoverSelectedTestFiles(projectRoot, options.selection.paths)) : void 0;
1411
- const mainFiles = selectedFiles ? projectFiles.filter((file)=>selectedFiles.has(file)) : projectFiles;
845
+ const prerequisiteFile = options.prerequisiteFile ? resolve(options.prerequisiteFile) : void 0;
846
+ const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
1412
847
  const files = [
1413
848
  ...prerequisiteFile ? [
1414
849
  prerequisiteFile
@@ -1431,7 +866,6 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
1431
866
  const invocations = [];
1432
867
  const plans = [];
1433
868
  let filteredCaseCount = 0;
1434
- const availableCaseIds = new Set();
1435
869
  if (0 === sources.length) {
1436
870
  const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
1437
871
  projectName: project.name,
@@ -1462,16 +896,10 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
1462
896
  variables: project.variables,
1463
897
  env: process.env
1464
898
  });
1465
- const duplicate = document.cases.find((item)=>availableCaseIds.has(item.caseId));
1466
- if (duplicate) throw new WorkflowParseError(`Case id collision in project "${project.name}": ${duplicate.caseId}.`, {
1467
- caseId: duplicate.caseId,
1468
- projectName: project.name
1469
- });
1470
- for (const item of document.cases)availableCaseIds.add(item.caseId);
1471
899
  const filtered = source.absolutePath === prerequisiteFile ? {
1472
900
  document,
1473
901
  filtered: 0
1474
- } : filterDocumentCases(document, project, options.selection);
902
+ } : filterDocumentCases(document, project);
1475
903
  const plan = buildTestRunReportPlan(document, {
1476
904
  selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
1477
905
  defaultTimeoutMs,
@@ -1519,42 +947,9 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
1519
947
  plans,
1520
948
  collectionErrors,
1521
949
  selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
1522
- filteredCaseCount,
1523
- availableCaseIds: [
1524
- ...availableCaseIds
1525
- ]
950
+ filteredCaseCount
1526
951
  };
1527
952
  }
1528
- const validateUniqueStrings = (values, label)=>{
1529
- if (void 0 === values) return;
1530
- if (!Array.isArray(values) || values.some((value)=>'string' != typeof value || 0 === value.trim().length)) throw new TypeError(`${label} must be an array of non-empty strings.`);
1531
- if (new Set(values).size !== values.length) throw new TypeError(`${label} must not contain duplicates.`);
1532
- return values;
1533
- };
1534
- const validateRunSelection = (options)=>{
1535
- const paths = validateUniqueStrings(options.paths, 'Test selection paths');
1536
- const caseIds = validateUniqueStrings(options.caseIds, 'Test selection caseIds');
1537
- const include = validateUniqueStrings(options.tags?.include, 'Test selection tags.include');
1538
- const exclude = validateUniqueStrings(options.tags?.exclude, 'Test selection tags.exclude');
1539
- return {
1540
- ...paths ? {
1541
- paths
1542
- } : {},
1543
- ...caseIds ? {
1544
- caseIds
1545
- } : {},
1546
- ...include || exclude ? {
1547
- tags: {
1548
- ...include ? {
1549
- include
1550
- } : {},
1551
- ...exclude ? {
1552
- exclude
1553
- } : {}
1554
- }
1555
- } : {}
1556
- };
1557
- };
1558
953
  const padDatePart = (value)=>String(value).padStart(2, '0');
1559
954
  const createTestRunId = (date = new Date(), uuid = randomUUID())=>{
1560
955
  const timestamp = [
@@ -1571,9 +966,9 @@ const assertDirectory = (path, label)=>{
1571
966
  if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
1572
967
  };
1573
968
  const resolveRunInput = (options)=>{
1574
- const cwd = external_node_path_resolve(options.cwd ?? process.cwd());
969
+ const cwd = resolve(options.cwd ?? process.cwd());
1575
970
  assertDirectory(cwd, 'Test working directory');
1576
- const inputPath = options.projectRoot ? external_node_path_resolve(cwd, options.projectRoot) : void 0;
971
+ const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
1577
972
  const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
1578
973
  if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
1579
974
  const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
@@ -1592,34 +987,24 @@ async function prepareTestRun(options) {
1592
987
  const startedAt = new Date();
1593
988
  const runId = createTestRunId(startedAt);
1594
989
  const input = resolveRunInput(options);
1595
- const selection = validateRunSelection(options);
1596
- const configPath = options.configPath ? external_node_path_resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
990
+ const configPath = options.configPath ? resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
1597
991
  if (options.configPath && /\.ya?ml$/i.test(configPath)) throw new Error(`midscene-test --config only accepts a native TypeScript or JavaScript project config: ${configPath}. Run legacy batch YAML with \`midscene --config ${options.configPath}\`.`);
1598
992
  if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
1599
993
  const definition = await loadTestProject(configPath);
1600
- const resultDir = options.resultDir ? external_node_path_resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
994
+ const resultDir = options.resultDir ? resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
1601
995
  const runDir = join(resultDir, runId);
1602
996
  const summaryPath = join(runDir, 'summary.json');
1603
- const reportDir = external_node_path_resolve(input.projectRoot, definition.output.reportDir);
997
+ const reportDir = resolve(input.projectRoot, definition.output.reportDir);
1604
998
  mkdirSync(resultDir, {
1605
999
  recursive: true
1606
1000
  });
1607
1001
  mkdirSync(runDir);
1608
1002
  const projects = [];
1609
- for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, {
1610
- selection,
1611
- ...input.singleFile ? {
1612
- files: [
1613
- input.singleFile
1614
- ]
1615
- } : {}
1616
- }));
1617
- if (selection.caseIds?.length) {
1618
- const availableCaseIds = new Set(projects.flatMap((project)=>project.availableCaseIds ?? []));
1619
- const unknown = selection.caseIds.find((id)=>!availableCaseIds.has(id));
1620
- if (unknown) throw new Error(`Unknown Midscene case id: ${unknown}`);
1621
- }
1622
- if ((selection.paths?.length || selection.caseIds?.length || selection.tags?.include?.length || selection.tags?.exclude?.length) && projects.every((project)=>0 === project.selectedCaseCount && 0 === project.collectionErrors.length)) throw new Error('No Midscene cases matched the requested selection.');
1003
+ for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, input.singleFile ? {
1004
+ files: [
1005
+ input.singleFile
1006
+ ]
1007
+ } : void 0));
1623
1008
  const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
1624
1009
  return {
1625
1010
  startedAt,
@@ -1715,601 +1100,20 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
1715
1100
  }));
1716
1101
  return output;
1717
1102
  }
1718
- const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1719
- documentId,
1720
- caseId: collectedCase.caseId,
1721
- projectName,
1722
- name: collectedCase.definition.name,
1723
- sourcePath: collectedCase.sourcePath,
1724
- caseIndex: collectedCase.caseIndex,
1725
- status: 'not-run',
1726
- notRunReason: reason
1727
- });
1103
+ const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1728
1104
  const formatStep = (info)=>{
1729
- const position = 'case' === info.scope ? info.case : info.document;
1105
+ const position = stepPosition(info);
1730
1106
  const phase = 'steps' === position.phase ? 'step' : position.phase;
1731
1107
  return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
1732
1108
  };
1733
- const formatStepResult = (info, result, indent = '')=>{
1109
+ const formatStepResult = (info, result)=>{
1110
+ const indent = 'case' === info.scope ? ' ' : ' ';
1734
1111
  if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
1735
1112
  const symbol = 'success' === result.status ? '✓' : '✗';
1736
1113
  const error = result.error ? ` — ${result.error.message}` : '';
1737
1114
  const continuation = result.continuedAfterError ? '; continuing' : '';
1738
1115
  return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
1739
1116
  };
1740
- const latestById = (items, id)=>[
1741
- ...new Map(items.map((item)=>[
1742
- id(item),
1743
- item
1744
- ])).values()
1745
- ];
1746
- const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
1747
- const { project } = prepared;
1748
- const projectFailed = prepared.collectionErrors.length > 0 || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status || item.execution?.lifecycle?.status === 'failed') || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
1749
- return {
1750
- projectId: project.projectId,
1751
- name: project.name,
1752
- ...prepared.platform ? {
1753
- platform: prepared.platform
1754
- } : {},
1755
- status: projectFailed ? 'failed' : 'success',
1756
- retry: project.retry,
1757
- fileSelection: prepared.fileSelection,
1758
- tagSelection: project.tags,
1759
- sourceCount: prepared.sources.length,
1760
- selectedCaseCount: prepared.selectedCaseCount,
1761
- filteredCaseCount: prepared.filteredCaseCount,
1762
- ...lifecycle ? {
1763
- lifecycle
1764
- } : {},
1765
- cases,
1766
- plans: prepared.plans,
1767
- documents,
1768
- collectionErrors: prepared.collectionErrors
1769
- };
1770
- };
1771
- function createExecutorReports(outputDir) {
1772
- const paths = new Map();
1773
- const sources = new Map();
1774
- return {
1775
- materializeSource (source) {
1776
- if (!source.dumpPath) {
1777
- const reference = this.materialize(source.sourcePath);
1778
- sources.set(reference, {
1779
- sourcePath: this.resolve(reference),
1780
- collection: source.collection
1781
- });
1782
- return reference;
1783
- }
1784
- const dump = realpathSync(source.dumpPath);
1785
- const reference = randomUUID();
1786
- const destination = join(outputDir, 'reports', reference);
1787
- mkdirSync(destination, {
1788
- recursive: true
1789
- });
1790
- const dumpPath = join(destination, 'snapshot.json');
1791
- cpSync(dump, dumpPath);
1792
- const screenshots = join(dirname(dump), 'screenshots');
1793
- if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
1794
- recursive: true,
1795
- dereference: true
1796
- });
1797
- const sourcePath = join(destination, 'index.html');
1798
- sources.set(reference, {
1799
- sourcePath,
1800
- dumpPath,
1801
- collection: source.collection
1802
- });
1803
- paths.set(reference, sourcePath);
1804
- return reference;
1805
- },
1806
- resolveSource (reference) {
1807
- return sources.get(reference);
1808
- },
1809
- materialize (sourcePath) {
1810
- const source = realpathSync(sourcePath);
1811
- const entry = statSync(source).isDirectory() ? join(source, 'index.html') : source;
1812
- if (!statSync(entry).isFile()) throw new TestExecutorError(`Executor report must have a regular HTML entry: ${sourcePath}`, {
1813
- kind: 'report'
1814
- });
1815
- const reference = randomUUID();
1816
- const destination = join(outputDir, 'reports', reference);
1817
- mkdirSync(destination, {
1818
- recursive: true
1819
- });
1820
- const isDirectoryReport = 'index.html' === basename(entry);
1821
- const reportPath = join(destination, isDirectoryReport ? 'index.html' : 'report.html');
1822
- cpSync(isDirectoryReport ? dirname(entry) : entry, isDirectoryReport ? destination : reportPath, {
1823
- recursive: isDirectoryReport,
1824
- dereference: true
1825
- });
1826
- if (!isDirectoryReport) {
1827
- const screenshots = join(dirname(entry), 'screenshots');
1828
- if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
1829
- recursive: true,
1830
- dereference: true
1831
- });
1832
- }
1833
- paths.set(reference, reportPath);
1834
- return reference;
1835
- },
1836
- resolve (reference) {
1837
- const reportPath = paths.get(reference);
1838
- if (!reportPath) throw new TestExecutorError(`Executor returned unknown report reference ${reference}.`, {
1839
- kind: 'report'
1840
- });
1841
- return reportPath;
1842
- }
1843
- };
1844
- }
1845
- async function runTaskPool(options) {
1846
- if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) throw new TypeError('Task scheduler maxConcurrency must be a positive integer.');
1847
- const pending = options.tasks.map((_, index)=>index);
1848
- const results = new Array(options.tasks.length);
1849
- const activeResources = new Set();
1850
- let active = 0;
1851
- let firstError;
1852
- let hasError = false;
1853
- return await new Promise((resolve, reject)=>{
1854
- let settled = false;
1855
- const stopRequested = ()=>hasError || options.signal?.aborted === true || options.shouldStop?.() === true;
1856
- const finish = ()=>{
1857
- if (settled) return;
1858
- settled = true;
1859
- options.signal?.removeEventListener('abort', pump);
1860
- if (hasError) reject(firstError);
1861
- else resolve(results);
1862
- };
1863
- const release = (resources)=>{
1864
- for (const resource of resources)activeResources.delete(resource);
1865
- };
1866
- const claimRunnable = ()=>{
1867
- const pendingIndex = pending.findIndex((taskIndex)=>options.tasks[taskIndex].resources.every((resource)=>!activeResources.has(resource)));
1868
- if (pendingIndex < 0) return;
1869
- const [taskIndex] = pending.splice(pendingIndex, 1);
1870
- return taskIndex;
1871
- };
1872
- function pump() {
1873
- if (settled) return;
1874
- while(active < options.maxConcurrency && !stopRequested()){
1875
- const taskIndex = claimRunnable();
1876
- if (void 0 === taskIndex) break;
1877
- const task = options.tasks[taskIndex];
1878
- for (const resource of task.resources)activeResources.add(resource);
1879
- active += 1;
1880
- Promise.resolve().then(()=>options.run(task, taskIndex)).then((result)=>{
1881
- results[taskIndex] = result;
1882
- }).catch((error)=>{
1883
- if (!hasError) {
1884
- hasError = true;
1885
- firstError = error;
1886
- }
1887
- }).finally(()=>{
1888
- release(task.resources);
1889
- active -= 1;
1890
- pump();
1891
- });
1892
- }
1893
- if (0 === active && (0 === pending.length || stopRequested())) finish();
1894
- }
1895
- options.signal?.addEventListener('abort', pump, {
1896
- once: true
1897
- });
1898
- pump();
1899
- });
1900
- }
1901
- const isolatedDocumentId = (document, collectedCase)=>`${document.documentId}-case-${collectedCase.caseIndex}`;
1902
- const prepareCaseTasks = (projects)=>projects.flatMap((prepared, projectIndex)=>prepared.documents.flatMap((document)=>document.cases.map((collectedCase)=>{
1903
- const documentId = isolatedDocumentId(document, collectedCase);
1904
- return {
1905
- projectIndex,
1906
- prepared,
1907
- document,
1908
- collectedCase,
1909
- task: Object.freeze({
1910
- taskId: `${prepared.project.projectId}:${collectedCase.caseId}`,
1911
- projectId: prepared.project.projectId,
1912
- projectName: prepared.project.name,
1913
- documentId,
1914
- sourcePath: collectedCase.sourcePath,
1915
- caseId: collectedCase.caseId,
1916
- caseName: collectedCase.definition.name,
1917
- caseIndex: collectedCase.caseIndex,
1918
- tags: Object.freeze([
1919
- ...collectedCase.definition.tags ?? []
1920
- ]),
1921
- resources: Object.freeze([
1922
- ...collectedCase.definition.resources ?? []
1923
- ]),
1924
- retry: prepared.project.retry
1925
- })
1926
- };
1927
- })));
1928
- const reviveError = (error)=>{
1929
- const revived = new WorkflowError(error.message, {
1930
- code: error.code,
1931
- details: error.details,
1932
- cause: error.cause
1933
- });
1934
- revived.name = error.name;
1935
- return revived;
1936
- };
1937
- const reviveStep = (step)=>{
1938
- const { error, output, report, ...base } = step;
1939
- return {
1940
- ...base,
1941
- input: step.input,
1942
- meta: {
1943
- ...step.meta
1944
- },
1945
- ...output ? {
1946
- output: {
1947
- ...output
1948
- }
1949
- } : {},
1950
- ...error ? {
1951
- error: reviveError(error)
1952
- } : {},
1953
- ...report ? {
1954
- report: {
1955
- traces: report.traces.map((trace)=>({
1956
- ...trace
1957
- })),
1958
- ...report.collectionErrors ? {
1959
- collectionErrors: report.collectionErrors.map(reviveError)
1960
- } : {}
1961
- }
1962
- } : {}
1963
- };
1964
- };
1965
- const reviveAttempt = (attempt)=>{
1966
- const { executionErrors, teardownErrors, reportRefs, beforeEach, steps, afterEach, ...base } = attempt;
1967
- return {
1968
- ...base,
1969
- beforeEach: beforeEach.map(reviveStep),
1970
- steps: steps.map(reviveStep),
1971
- afterEach: afterEach.map(reviveStep),
1972
- ...executionErrors ? {
1973
- executionErrors: executionErrors.map(reviveError)
1974
- } : {},
1975
- ...teardownErrors ? {
1976
- teardownErrors: teardownErrors.map(reviveError)
1977
- } : {}
1978
- };
1979
- };
1980
- const reviveDocument = (document)=>{
1981
- const { executionErrors, teardownErrors, reportRefs, beforeAll, afterAll, hostErrors, ...base } = document;
1982
- return {
1983
- ...base,
1984
- ...hostErrors ? {
1985
- hostErrors: hostErrors.map((error)=>({
1986
- ...error
1987
- }))
1988
- } : {},
1989
- beforeAll: beforeAll.map(reviveStep),
1990
- afterAll: afterAll.map(reviveStep),
1991
- ...executionErrors ? {
1992
- executionErrors: executionErrors.map(reviveError)
1993
- } : {},
1994
- ...teardownErrors ? {
1995
- teardownErrors: teardownErrors.map(reviveError)
1996
- } : {}
1997
- };
1998
- };
1999
- const reviveLifecycle = (lifecycle)=>{
2000
- const { setupError, teardownErrors, ...base } = lifecycle;
2001
- return {
2002
- ...base,
2003
- ...setupError ? {
2004
- setupError: reviveError(setupError)
2005
- } : {},
2006
- ...teardownErrors ? {
2007
- teardownErrors: teardownErrors.map(reviveError)
2008
- } : {}
2009
- };
2010
- };
2011
- const toTransportResult = (result, materializeReport, materializeSource)=>{
2012
- const transportReports = (scope)=>[
2013
- ...(scope.reportSources ?? []).map(materializeSource),
2014
- ...(scope.reportPaths ?? []).filter((path)=>!scope.reportSources?.some((source)=>source.sourcePath === path)).map(materializeReport)
2015
- ];
2016
- const mapStep = (step)=>{
2017
- if (!step.report) return step;
2018
- const { sources: _sources, ...report } = step.report;
2019
- return {
2020
- ...step,
2021
- report
2022
- };
2023
- };
2024
- const mapAttempt = (attempt)=>{
2025
- const { reportPaths, reportSources, reportScopeId, ...transportAttempt } = attempt;
2026
- const reportRefs = transportReports(attempt);
2027
- return {
2028
- ...transportAttempt,
2029
- beforeEach: attempt.beforeEach.map(mapStep),
2030
- steps: attempt.steps.map(mapStep),
2031
- afterEach: attempt.afterEach.map(mapStep),
2032
- ...reportRefs.length ? {
2033
- reportRefs
2034
- } : {}
2035
- };
2036
- };
2037
- const attempts = result.case.attempts?.map(mapAttempt);
2038
- const run = attempts?.at(-1);
2039
- const { run: _run, attempts: _attempts, ...transportCase } = result.case;
2040
- const document = result.document ? (()=>{
2041
- const { reportPaths, reportSources, reportScopeId, ...transportDocument } = result.document;
2042
- const reportRefs = transportReports(result.document);
2043
- return {
2044
- ...transportDocument,
2045
- ...result.document.hostErrors ? {
2046
- hostErrors: result.document.hostErrors.map(({ phase, error })=>({
2047
- phase,
2048
- error: sanitizeReportError(error)
2049
- }))
2050
- } : {},
2051
- beforeAll: result.document.beforeAll.map(mapStep),
2052
- afterAll: result.document.afterAll.map(mapStep),
2053
- ...reportRefs.length ? {
2054
- reportRefs
2055
- } : {}
2056
- };
2057
- })() : void 0;
2058
- return JSON.parse(JSON.stringify({
2059
- case: {
2060
- ...transportCase,
2061
- ...run ? {
2062
- run
2063
- } : {},
2064
- ...attempts ? {
2065
- attempts
2066
- } : {}
2067
- },
2068
- ...document ? {
2069
- document
2070
- } : {},
2071
- ...result.lifecycle ? {
2072
- lifecycle: result.lifecycle
2073
- } : {}
2074
- }));
2075
- };
2076
- const fromTransportResult = (result, resolveReportReference, resolveSource)=>{
2077
- const resolveReports = (refs)=>{
2078
- const reportPaths = [];
2079
- const reportSources = [];
2080
- for (const ref of refs ?? []){
2081
- const path = resolveReportReference(ref);
2082
- const source = resolveSource(ref);
2083
- if (source) reportSources.push(source);
2084
- else reportPaths.push(path);
2085
- }
2086
- return {
2087
- ...reportPaths.length ? {
2088
- reportPaths
2089
- } : {},
2090
- ...reportSources.length ? {
2091
- reportSources
2092
- } : {}
2093
- };
2094
- };
2095
- const attempts = result.case.attempts?.map((attempt)=>({
2096
- ...reviveAttempt(attempt),
2097
- ...resolveReports(attempt.reportRefs)
2098
- }));
2099
- const run = attempts?.at(-1);
2100
- const { run: _run, attempts: _attempts, ...caseBase } = result.case;
2101
- return {
2102
- case: {
2103
- ...caseBase,
2104
- ...run ? {
2105
- run
2106
- } : {},
2107
- ...attempts ? {
2108
- attempts
2109
- } : {}
2110
- },
2111
- ...result.document ? {
2112
- document: {
2113
- ...reviveDocument(result.document),
2114
- ...resolveReports(result.document.reportRefs)
2115
- }
2116
- } : {},
2117
- ...result.lifecycle ? {
2118
- lifecycle: reviveLifecycle(result.lifecycle)
2119
- } : {}
2120
- };
2121
- };
2122
- const runLocalCase = async (preparedTask, options, taskProgress, materializeReport, materializeSource)=>{
2123
- const { prepared, document, collectedCase, task } = preparedTask;
2124
- const { project } = prepared;
2125
- const isolatedDocument = {
2126
- ...document,
2127
- documentId: task.documentId,
2128
- cases: [
2129
- collectedCase
2130
- ]
2131
- };
2132
- const runtime = createProjectRuntime({
2133
- project,
2134
- setup: project.setup,
2135
- signal: options.signal
2136
- });
2137
- let lifecycle;
2138
- let execution;
2139
- let executionError;
2140
- try {
2141
- await runtime.start();
2142
- if (runtime.canRun) execution = await runWorkflowDocument(isolatedDocument, {
2143
- resolveNode: project.nodes.require.bind(project.nodes),
2144
- project,
2145
- projectContext: runtime.context,
2146
- retry: project.retry,
2147
- signal: runtime.signal,
2148
- defaultTimeoutMs: options.test.testTimeout,
2149
- shouldStop: ()=>options.signal.aborted,
2150
- stopReason: ()=>'interrupted',
2151
- isFatalError: (run)=>[
2152
- ...run.beforeEach,
2153
- ...run.steps,
2154
- ...run.afterEach
2155
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
2156
- onStepStart: (info)=>taskProgress(`→ ${formatStep(info)}`),
2157
- onStepResult: (info, result)=>taskProgress(formatStepResult(info, result)),
2158
- onCaseResult: (attempt)=>taskProgress(`${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`)
2159
- });
2160
- } catch (error) {
2161
- executionError = error;
2162
- if (error instanceof WorkflowExecutionFailure) execution = error.result;
2163
- } finally{
2164
- const failed = void 0 !== executionError || !runtime.canRun || execution?.cases[0]?.status !== 'success' || execution?.document.status === 'failed';
2165
- lifecycle = await runtime.finish(failed ? 'failed' : 'success');
2166
- }
2167
- if (executionError) {
2168
- if (!execution) throw executionError;
2169
- options.onInfrastructureError(executionError);
2170
- }
2171
- if (!execution) return toTransportResult({
2172
- case: asNotRun(task.documentId, collectedCase, project.name, options.signal.aborted ? 'interrupted' : 'project-setup-failed'),
2173
- lifecycle
2174
- }, materializeReport, materializeSource);
2175
- const outcome = execution.cases[0];
2176
- if (!outcome) throw new Error(`Executor did not produce case result for ${task.caseId}.`);
2177
- return toTransportResult({
2178
- case: {
2179
- ...outcome,
2180
- documentId: task.documentId
2181
- },
2182
- document: execution.document,
2183
- lifecycle
2184
- }, materializeReport, materializeSource);
2185
- };
2186
- const runCaseTask = async (preparedTask, taskIndex, taskCount, options, onCompleted)=>{
2187
- const { prepared, task } = preparedTask;
2188
- const taskProgress = (message)=>options.progress(`[case ${taskIndex + 1}/${taskCount}] ${task.projectName} / ${task.sourcePath} / ${task.caseName}: ${message}`);
2189
- taskProgress('started');
2190
- const outputDir = join(options.runDir, task.projectId, 'executor-output', task.documentId, task.caseId);
2191
- mkdirSync(outputDir, {
2192
- recursive: true
2193
- });
2194
- const { materialize: materializeReport, materializeSource, resolve: resolveReportReference, resolveSource } = createExecutorReports(outputDir);
2195
- if (prepared.collectionErrors.length > 0) return {
2196
- case: asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'project-preflight-failed')
2197
- };
2198
- const executor = options.executor ?? localTestExecutor;
2199
- let result;
2200
- let executorFailure;
2201
- let executorAttempts = 0;
2202
- for(let attemptIndex = 0; attemptIndex <= options.test.executorRetry; attemptIndex += 1){
2203
- executorAttempts = attemptIndex + 1;
2204
- try {
2205
- result = await executor.execute(task, {
2206
- signal: options.signal,
2207
- onProgress: taskProgress,
2208
- outputDir,
2209
- materializeReport,
2210
- runLocal: ()=>runLocalCase(preparedTask, options, taskProgress, materializeReport, materializeSource)
2211
- });
2212
- executorFailure = void 0;
2213
- break;
2214
- } catch (error) {
2215
- if (!isTestExecutorError(error)) throw error;
2216
- executorFailure = error;
2217
- if (!error.retryable || attemptIndex >= options.test.executorRetry || options.signal.aborted) break;
2218
- taskProgress(`executor retry ${attemptIndex + 1}/${options.test.executorRetry}: ${error.message}`);
2219
- }
2220
- }
2221
- if (!result) {
2222
- if (!executorFailure) throw new Error(`Executor did not return a result for ${task.caseId}.`);
2223
- taskProgress(`executor-failed: ${executorFailure.message}`);
2224
- return {
2225
- case: {
2226
- ...asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'executor-failed'),
2227
- execution: {
2228
- executor: executor.name,
2229
- resources: task.resources,
2230
- attempts: executorAttempts,
2231
- failure: {
2232
- kind: executorFailure.kind,
2233
- message: executorFailure.message,
2234
- retryable: executorFailure.retryable
2235
- }
2236
- }
2237
- }
2238
- };
2239
- }
2240
- assertTestCaseTaskRunResult(task, result);
2241
- const internalResult = fromTransportResult(result, resolveReportReference, resolveSource);
2242
- const completed = {
2243
- ...internalResult,
2244
- case: {
2245
- ...internalResult.case,
2246
- execution: {
2247
- executor: executor.name,
2248
- resources: task.resources,
2249
- attempts: executorAttempts,
2250
- ...internalResult.lifecycle ? {
2251
- lifecycle: internalResult.lifecycle
2252
- } : {},
2253
- ...result.artifacts ? {
2254
- artifacts: result.artifacts
2255
- } : {},
2256
- ...result.metadata ? {
2257
- metadata: result.metadata
2258
- } : {}
2259
- }
2260
- }
2261
- };
2262
- onCompleted(completed);
2263
- for (const attempt of internalResult.case.attempts ?? [])await writeCaseAttemptResult(options.runDir, task.projectId, task.documentId, attempt);
2264
- if (internalResult.document) await writeWorkflowDocumentResult(options.runDir, internalResult.document);
2265
- taskProgress(internalResult.case.status);
2266
- return completed;
2267
- };
2268
- const runCaseExecution = async (options)=>{
2269
- const tasks = prepareCaseTasks(options.projects);
2270
- let failedCaseCount = 0;
2271
- const bailReached = ()=>options.test.bail > 0 && failedCaseCount >= options.test.bail;
2272
- const results = new Array(tasks.length);
2273
- if (tasks.length > 0) try {
2274
- await runTaskPool({
2275
- tasks: tasks.map((preparedTask)=>preparedTask.task),
2276
- maxConcurrency: Math.min(options.test.maxConcurrency, tasks.length),
2277
- signal: options.signal,
2278
- shouldStop: bailReached,
2279
- run: async (_task, taskIndex)=>{
2280
- try {
2281
- const result = await runCaseTask(tasks[taskIndex], taskIndex, tasks.length, options, (completed)=>{
2282
- results[taskIndex] = completed;
2283
- });
2284
- results[taskIndex] = result;
2285
- if ('failed' === result.case.status) failedCaseCount += 1;
2286
- return result;
2287
- } catch (error) {
2288
- options.onInfrastructureError(error);
2289
- throw error;
2290
- }
2291
- }
2292
- });
2293
- } catch (error) {
2294
- options.onInfrastructureError(error);
2295
- }
2296
- return options.projects.map((prepared, projectIndex)=>{
2297
- const projectTasks = tasks.map((task, taskIndex)=>({
2298
- task,
2299
- result: results[taskIndex]
2300
- })).filter(({ task })=>task.projectIndex === projectIndex);
2301
- const reason = prepared.collectionErrors.length ? 'project-preflight-failed' : options.signal.aborted ? 'interrupted' : bailReached() ? 'bail' : void 0;
2302
- const cases = projectTasks.map(({ task, result })=>{
2303
- if (result) return result.case;
2304
- if (!reason) throw new Error(`Case scheduler did not produce a result for "${task.task.caseId}".`);
2305
- return asNotRun(task.task.documentId, task.collectedCase, prepared.project.name, reason);
2306
- });
2307
- const documents = projectTasks.flatMap(({ result })=>result?.document ? [
2308
- result.document
2309
- ] : []);
2310
- return buildProjectResult(prepared, cases, documents);
2311
- });
2312
- };
2313
1117
  const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
2314
1118
  ...attempt.beforeEach,
2315
1119
  ...attempt.steps,
@@ -2355,7 +1159,7 @@ async function executeDocumentInvocation(options) {
2355
1159
  },
2356
1160
  onStepResult: async (info, result)=>{
2357
1161
  await invocation.bindings.onStepResult?.(info, result);
2358
- options.onProgress(formatStepResult(info, result, 'case' === info.scope ? ' ' : ' '));
1162
+ options.onProgress(formatStepResult(info, result));
2359
1163
  },
2360
1164
  onCaseResult: async (attempt)=>{
2361
1165
  await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
@@ -2473,6 +1277,16 @@ function createOperationJournal(runDir) {
2473
1277
  }
2474
1278
  };
2475
1279
  }
1280
+ const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1281
+ documentId,
1282
+ caseId: collectedCase.caseId,
1283
+ projectName,
1284
+ name: collectedCase.definition.name,
1285
+ sourcePath: collectedCase.sourcePath,
1286
+ caseIndex: collectedCase.caseIndex,
1287
+ status: 'not-run',
1288
+ notRunReason: reason
1289
+ });
2476
1290
  const summarize = (projects)=>{
2477
1291
  const healthCases = projects.flatMap((project)=>{
2478
1292
  const documentAttemptByRunId = new Map(project.documents.map((document)=>[
@@ -2506,6 +1320,12 @@ const summarize = (projects)=>{
2506
1320
  projectFailures: projects.filter((project)=>'failed' === project.status).length
2507
1321
  };
2508
1322
  };
1323
+ const latestById = (items, id)=>[
1324
+ ...new Map(items.map((item)=>[
1325
+ id(item),
1326
+ item
1327
+ ])).values()
1328
+ ];
2509
1329
  const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
2510
1330
  async function runPreparedTestProject(plan) {
2511
1331
  const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
@@ -2518,7 +1338,7 @@ async function runPreparedTestProject(plan) {
2518
1338
  await writeTestRunPlan(runDir, preparedProjects);
2519
1339
  const journal = createOperationJournal(runDir);
2520
1340
  const rootController = new AbortController();
2521
- setMaxListeners(Math.max(10, ('case' === definition.test.executionUnit ? Math.min(definition.test.maxConcurrency, totalCases) : effectiveConcurrency) + 1), rootController.signal);
1341
+ setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
2522
1342
  const handleSignal = (signal)=>{
2523
1343
  rootController.abort(new Error(`Workflow interrupted by ${signal}.`));
2524
1344
  };
@@ -2560,6 +1380,31 @@ async function runPreparedTestProject(plan) {
2560
1380
  const { project } = prepared;
2561
1381
  runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
2562
1382
  };
1383
+ const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
1384
+ const { project } = prepared;
1385
+ const projectFailed = prepared.collectionErrors.length > 0 || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status) || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
1386
+ return {
1387
+ projectId: project.projectId,
1388
+ name: project.name,
1389
+ ...prepared.platform ? {
1390
+ platform: prepared.platform
1391
+ } : {},
1392
+ status: projectFailed ? 'failed' : 'success',
1393
+ retry: project.retry,
1394
+ fileSelection: prepared.fileSelection,
1395
+ tagSelection: project.tags,
1396
+ sourceCount: prepared.sources.length,
1397
+ selectedCaseCount: prepared.selectedCaseCount,
1398
+ filteredCaseCount: prepared.filteredCaseCount,
1399
+ ...lifecycle ? {
1400
+ lifecycle
1401
+ } : {},
1402
+ cases,
1403
+ plans: prepared.plans,
1404
+ documents,
1405
+ collectionErrors: prepared.collectionErrors
1406
+ };
1407
+ };
2563
1408
  const buildSkippedProjectResult = (prepared, projectIndex, reason)=>{
2564
1409
  announceProject(prepared, projectIndex);
2565
1410
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
@@ -2660,7 +1505,7 @@ async function runPreparedTestProject(plan) {
2660
1505
  };
2661
1506
  const projectResults = new Array(preparedProjects.length);
2662
1507
  try {
2663
- if ('case' !== definition.test.executionUnit || 'run' === plan.preflightScope && totalErrors > 0) await runConcurrentJobs(preparedProjects, {
1508
+ await runConcurrentJobs(preparedProjects, {
2664
1509
  concurrency: effectiveConcurrency,
2665
1510
  shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
2666
1511
  }, async (prepared, index)=>{
@@ -2671,20 +1516,6 @@ async function runPreparedTestProject(plan) {
2671
1516
  throw error;
2672
1517
  }
2673
1518
  });
2674
- else {
2675
- const results = await runCaseExecution({
2676
- projects: preparedProjects,
2677
- test: definition.test,
2678
- executor: definition.executor,
2679
- runDir,
2680
- signal: rootController.signal,
2681
- progress,
2682
- onInfrastructureError: recordInfrastructureError
2683
- });
2684
- results.forEach((result, index)=>{
2685
- projectResults[index] = result;
2686
- });
2687
- }
2688
1519
  } catch (error) {
2689
1520
  recordInfrastructureError(error);
2690
1521
  } finally{
@@ -2790,6 +1621,6 @@ async function runPreparedTestProject(plan) {
2790
1621
  async function runTestProject(options = {}) {
2791
1622
  return runPreparedTestProject(await prepareTestRun(options));
2792
1623
  }
2793
- export { DEFAULT_TEST_FILE_SELECTION, TestExecutorError, assertTestCaseTaskRunResult, createTestRunId, defineProjectSetup, defineTestExecutor, defineTestProject, discoverSelectedTestFiles, discoverTestConfig, discoverTestFiles, isTestExecutorError, loadTestProject, localTestExecutor, runTestProject, validateTestFileSelection };
1624
+ export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
2794
1625
 
2795
1626
  //# sourceMappingURL=index.mjs.map