@midscene/test 1.15.3-beta-20261013025343.0 → 1.15.3

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.
@@ -1,1155 +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
- function _define_property(obj, key, value) {
16
- if (key in obj) Object.defineProperty(obj, key, {
17
- value: value,
18
- enumerable: true,
19
- configurable: true,
20
- writable: true
21
- });
22
- else obj[key] = value;
23
- return obj;
24
- }
25
- const executorErrorBrand = Symbol.for('@midscene/test/executor-error');
26
- let _executorErrorBrand = executorErrorBrand;
27
- class TestExecutorError extends Error {
28
- constructor(message, options = {}){
29
- super(message, {
30
- cause: options.cause
31
- }), _define_property(this, _executorErrorBrand, true), _define_property(this, "kind", void 0), _define_property(this, "retryable", void 0);
32
- this.name = 'TestExecutorError';
33
- this.kind = options.kind ?? 'unknown';
34
- this.retryable = options.retryable ?? false;
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;
35
25
  }
36
- }
37
- function isTestExecutorError(error) {
38
- return error instanceof Error && (error instanceof TestExecutorError || Object.getOwnPropertyDescriptor(error, Symbol.for('@midscene/test/executor-error'))?.value === true);
39
- }
40
- const defineTestExecutor = (executor)=>executor;
41
- const localTestExecutor = Object.freeze({
42
- name: 'local',
43
- execute: (_task, context)=>context.runLocal()
44
- });
45
- const jsonValueSchema = z.lazy(()=>z.union([
46
- z["null"](),
47
- z.string(),
48
- z.number().finite(),
49
- z.boolean(),
50
- z.array(jsonValueSchema),
51
- z.record(z.string(), jsonValueSchema)
52
- ]));
53
- const timestampSchema = z.string().refine((value)=>Number.isFinite(Date.parse(value)), {
54
- message: 'Expected an ISO-compatible timestamp.'
55
- });
56
- const errorSchema = z.strictObject({
57
- name: z.string().min(1),
58
- message: z.string(),
59
- code: z.string().min(1),
60
- details: jsonValueSchema.optional()
61
- });
62
- const operationReferenceSchema = z.strictObject({
63
- operationId: z.string().min(1),
64
- scopeId: z.string().optional(),
65
- stepId: z.string().optional(),
66
- node: z.string().optional()
67
- });
68
- const reportErrorSchema = z.lazy(()=>z.strictObject({
69
- name: z.string(),
70
- message: z.string(),
71
- code: z.string().optional(),
72
- details: z.strictObject({
73
- value: jsonValueSchema,
74
- truncatedPaths: z.array(z.string()).optional(),
75
- redactedPaths: z.array(z.string()).optional()
76
- }).optional(),
77
- format: z.literal('midscene-error').optional(),
78
- cause: reportErrorSchema.optional(),
79
- errors: z.array(reportErrorSchema).optional(),
80
- truncated: z.boolean().optional()
81
- }));
82
- const cleanupSchema = z.strictObject({
83
- status: z["enum"]([
84
- 'not-required',
85
- 'completed',
86
- 'pending',
87
- 'failed'
88
- ]),
89
- capturedAt: timestampSchema,
90
- operationId: z.string().optional(),
91
- blockingOperations: z.array(operationReferenceSchema).optional(),
92
- error: reportErrorSchema.optional()
93
- });
94
- const phaseSchema = z.strictObject({
95
- status: z["enum"]([
96
- 'success',
97
- 'failed',
98
- 'not-run',
99
- 'not-required'
100
- ]),
101
- plannedStepCount: z.number().int().nonnegative(),
102
- executedStepCount: z.number().int().nonnegative()
103
- });
104
- const diagnosticsSchema = {
105
- executionStatus: z["enum"]([
106
- 'success',
107
- 'failed',
108
- 'not-run'
109
- ]).optional(),
110
- phases: z.strictObject({
111
- beforeAll: phaseSchema.optional(),
112
- beforeEach: phaseSchema.optional(),
113
- steps: phaseSchema.optional(),
114
- afterEach: phaseSchema.optional(),
115
- afterAll: phaseSchema.optional()
116
- }).optional(),
117
- cleanup: cleanupSchema.optional(),
118
- pendingOperations: z.array(operationReferenceSchema).optional(),
119
- retryDecision: z.strictObject({
120
- scope: z["enum"]([
121
- 'case',
122
- 'document'
123
- ]),
124
- retryCount: z.number().int().nonnegative(),
125
- attemptIndex: z.number().int().nonnegative(),
126
- decision: z["enum"]([
127
- 'retry',
128
- 'stop'
129
- ]),
130
- reason: z["enum"]([
131
- 'passed',
132
- 'attempt-failed',
133
- 'budget-exhausted',
134
- 'cleanup-pending',
135
- 'cleanup-failed',
136
- 'operation-pending',
137
- 'fatal-error',
138
- 'interrupted',
139
- 'bail',
140
- 'infrastructure-error'
141
- ])
142
- }).optional()
26
+ throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
143
27
  };
144
- const stepBaseSchema = z.strictObject({
145
- waitForPreviousOperationMs: z.number().nonnegative().finite().optional(),
146
- operations: z.strictObject({
147
- capturedAt: timestampSchema,
148
- events: z.array(z.strictObject({
149
- operationId: z.string().min(1),
150
- operationKind: z["enum"]([
151
- 'node',
152
- 'resource',
153
- 'acquisition',
154
- 'cleanup',
155
- 'readiness'
156
- ]),
157
- kind: z["enum"]([
158
- 'started',
159
- 'cancel-requested',
160
- 'settled',
161
- 'runner-returned',
162
- 'cleanup-deferred'
163
- ]),
164
- at: timestampSchema,
165
- stepId: z.string().optional(),
166
- scopeId: z.string().optional(),
167
- node: z.string().optional(),
168
- status: z["enum"]([
169
- 'success',
170
- 'failed',
171
- 'not-run'
172
- ]).optional(),
173
- pending: z.boolean().optional(),
174
- error: reportErrorSchema.optional(),
175
- blockingOperations: z.array(operationReferenceSchema).optional()
176
- }))
177
- }).optional(),
178
- phase: z["enum"]([
179
- 'beforeAll',
180
- 'beforeEach',
181
- 'steps',
182
- 'afterEach',
183
- 'afterAll'
184
- ]),
185
- stepIndex: z.number().int().nonnegative(),
186
- node: z.string().min(1),
187
- input: jsonValueSchema,
188
- meta: z.strictObject({
189
- timeoutMs: z.number().positive().finite().optional(),
190
- continueOnError: z.boolean()
191
- }),
192
- continuedAfterError: z.boolean(),
193
- output: z.strictObject({
194
- summary: z.string().optional(),
195
- data: jsonValueSchema.optional()
196
- }).optional(),
197
- error: errorSchema.optional(),
198
- report: z.strictObject({
199
- traces: z.array(z.strictObject({
200
- type: z.literal('midscene-execution'),
201
- executionId: z.string().min(1)
202
- }))
203
- }).optional()
204
- });
205
- const stepSchema = z.discriminatedUnion('status', [
206
- stepBaseSchema.extend({
207
- status: z["enum"]([
208
- 'success',
209
- 'failed'
210
- ]),
211
- startedAt: timestampSchema,
212
- endedAt: timestampSchema,
213
- durationMs: z.number().nonnegative().finite()
214
- }),
215
- stepBaseSchema.extend({
216
- status: z.literal('not-run'),
217
- notRunReason: z.string().min(1),
218
- blockedBy: z.string().optional()
219
- })
220
- ]);
221
- const attemptSchema = z.strictObject({
222
- ...diagnosticsSchema,
223
- caseId: z.string().min(1),
224
- runId: z.string().min(1),
225
- projectName: z.string().min(1),
226
- attemptIndex: z.number().int().nonnegative(),
227
- name: z.string(),
228
- sourcePath: z.string().min(1),
229
- caseIndex: z.number().int().nonnegative(),
230
- status: z["enum"]([
231
- 'success',
232
- 'failed'
233
- ]),
234
- beforeEach: z.array(stepSchema),
235
- steps: z.array(stepSchema),
236
- afterEach: z.array(stepSchema),
237
- executionErrors: z.array(errorSchema).optional(),
238
- teardownErrors: z.array(errorSchema).optional(),
239
- reportRefs: z.array(z.string().min(1)).optional(),
240
- startedAt: timestampSchema,
241
- endedAt: timestampSchema,
242
- durationMs: z.number().nonnegative().finite()
243
- });
244
- const notRunReasonSchema = z["enum"]([
245
- 'document-start-failed',
246
- 'project-preflight-failed',
247
- 'project-setup-failed',
248
- 'interrupted',
249
- 'bail',
250
- 'fatal-error',
251
- 'executor-failed',
252
- 'cleanup-pending',
253
- 'cleanup-failed',
254
- 'operation-pending'
255
- ]);
256
- const resultSchema = z.strictObject({
257
- case: z.strictObject({
258
- documentId: z.string().min(1),
259
- caseId: z.string().min(1),
260
- projectName: z.string().min(1),
261
- name: z.string(),
262
- sourcePath: z.string().min(1),
263
- caseIndex: z.number().int().nonnegative(),
264
- status: z["enum"]([
265
- 'success',
266
- 'failed',
267
- 'not-run'
268
- ]),
269
- run: attemptSchema.optional(),
270
- attempts: z.array(attemptSchema).optional(),
271
- notRunReason: notRunReasonSchema.optional()
272
- }),
273
- document: z.strictObject({
274
- ...diagnosticsSchema,
275
- attemptIndex: z.number().int().nonnegative().optional(),
276
- documentId: z.string().min(1),
277
- documentRunId: z.string().min(1),
278
- projectId: z.string().min(1),
279
- projectName: z.string().min(1),
280
- sourcePath: z.string().min(1),
281
- status: z["enum"]([
282
- 'success',
283
- 'failed'
284
- ]),
285
- startedAt: timestampSchema,
286
- endedAt: timestampSchema,
287
- durationMs: z.number().nonnegative().finite(),
288
- beforeAll: z.array(stepSchema),
289
- afterAll: z.array(stepSchema),
290
- executionErrors: z.array(errorSchema).optional(),
291
- teardownErrors: z.array(errorSchema).optional(),
292
- reportRefs: z.array(z.string().min(1)).optional()
293
- }).optional(),
294
- lifecycle: z.strictObject({
295
- cleanup: cleanupSchema.optional(),
296
- pendingOperations: z.array(operationReferenceSchema).optional(),
297
- projectName: z.string().min(1),
298
- status: z["enum"]([
299
- 'success',
300
- 'failed'
301
- ]),
302
- setupError: errorSchema.optional(),
303
- teardownErrors: z.array(errorSchema).optional(),
304
- startedAt: timestampSchema,
305
- endedAt: timestampSchema,
306
- durationMs: z.number().nonnegative().finite()
307
- }).optional(),
308
- artifacts: z.array(z.strictObject({
309
- name: z.string().min(1),
310
- uri: z.string().min(1),
311
- mediaType: z.string().min(1).optional()
312
- })).optional(),
313
- metadata: z.record(z.string(), z.union([
314
- z.string(),
315
- z.number().finite(),
316
- z.boolean(),
317
- z["null"]()
318
- ])).optional()
319
- });
320
- 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);
321
- function assertTestCaseTaskRunResult(task, value) {
322
- const parsed = resultSchema.safeParse(value);
323
- if (!parsed.success) throw new TypeError(`Executor returned invalid transport data for ${task.caseId}: ${z.prettifyError(parsed.error)}`);
324
- const outcome = parsed.data.case;
325
- 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}.`);
326
- const attempts = outcome.attempts ?? [];
327
- for (const attempt of attempts){
328
- const steps = [
329
- ...attempt.beforeEach,
330
- ...attempt.steps,
331
- ...attempt.afterEach
332
- ];
333
- if ('success' === attempt.status && (0 === attempt.steps.length || (attempt.executionErrors?.length ?? 0) > 0 || steps.some((step)=>'success' !== step.status) || (attempt.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful attempt for ${task.caseId}.`);
334
- }
335
- if (new Set(attempts.map((attempt)=>attempt.runId)).size !== attempts.length) throw new TypeError(`Executor returned duplicate attempt IDs for ${task.caseId}.`);
336
- if (attempts.some((attempt)=>!attemptMatchesTask(task, attempt))) throw new TypeError(`Executor returned invalid attempts for ${task.caseId}.`);
337
- 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}.`);
338
- const finalAttempt = attempts.at(-1);
339
- if ('not-run' === outcome.status) {
340
- if (outcome.run || attempts.length || !outcome.notRunReason) throw new TypeError(`Executor returned an inconsistent not-run result for ${task.caseId}.`);
341
- } 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}.`);
342
- const reportRefs = [
343
- ...attempts.flatMap((attempt)=>attempt.reportRefs ?? []),
344
- ...parsed.data.document?.reportRefs ?? []
345
- ];
346
- if (reportRefs.some((reference)=>!isSafeResultPathSegment(reference))) throw new TypeError(`Executor returned an invalid report reference for ${task.caseId}.`);
347
- const document = parsed.data.document;
348
- if (document?.status === 'success' && ((document.executionErrors?.length ?? 0) > 0 || [
349
- ...document.beforeAll,
350
- ...document.afterAll
351
- ].some((step)=>'success' !== step.status) || (document.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful document for ${task.caseId}.`);
352
- const lifecycle = parsed.data.lifecycle;
353
- if (lifecycle?.status === 'success' && (lifecycle.setupError || (lifecycle.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful lifecycle for ${task.caseId}.`);
354
- 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}.`);
355
- if (parsed.data.lifecycle && parsed.data.lifecycle.projectName !== task.projectName) throw new Error(`Executor returned a lifecycle that does not match task ${task.caseId}.`);
356
- }
357
- const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
358
- projectId,
359
- sourcePath,
360
- caseIndex,
361
- ...invocationIndex ? [
362
- invocationIndex
363
- ] : []
364
- ])).digest('hex');
365
- const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
366
- projectId,
367
- sourcePath,
368
- ...invocationIndex ? [
369
- invocationIndex
370
- ] : []
371
- ])).digest('hex');
372
- const normalize_normalizeSteps = normalizeSteps;
373
- function parseWorkflowSource(source) {
374
- const stack = [];
375
- const nodes = [];
376
- const sensitiveNodes = new Set();
377
- const sensitiveValues = new Set();
378
- const redactedPaths = new Set();
379
- const sourceOffset = 0xfeff === source.charCodeAt(0) ? 1 : 0;
380
- const parsed = load(source, {
381
- schema: JSON_SCHEMA,
382
- listener (event, state) {
383
- if ('open' === event) return void stack.push({
384
- start: state.position + sourceOffset,
385
- end: 0,
386
- value: null,
387
- children: []
388
- });
389
- const node = stack.pop();
390
- node.end = state.position + sourceOffset;
391
- node.value = state.result;
392
- stack.at(-1)?.children.push(node);
393
- nodes.push(node);
394
- if ('mapping' !== state.kind) return;
395
- for(let index = 0; index < node.children.length; index += 2){
396
- const key = node.children[index].value;
397
- const value = node.children[index + 1];
398
- if ('string' == typeof key && isReportSecretKey(key) && value) {
399
- sensitiveNodes.add(value);
400
- if (null !== value.value) sensitiveValues.add(value.value);
401
- redactedPaths.add(`$..[${JSON.stringify(key)}]`);
402
- }
403
- }
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.`);
404
46
  }
47
+ return pattern;
405
48
  });
406
- const ranges = nodes.filter((node)=>sensitiveNodes.has(node) || sensitiveValues.has(node.value)).sort((left, right)=>left.start - right.start || right.end - left.end);
407
- let offset = 0;
408
- let redacted = '';
409
- for (const range of ranges){
410
- if (range.start < offset) continue;
411
- const text = source.slice(range.start, range.end);
412
- const leading = text.match(/^\s*/)?.[0] ?? '';
413
- const trailing = text.slice(leading.length).match(/\s*$/)?.[0] ?? '';
414
- redacted += `${source.slice(offset, range.start)}${leading}"[REDACTED]"${trailing}`;
415
- offset = range.end;
416
- }
417
- redacted += source.slice(offset);
418
- const value = sanitizeReportText(redacted, 65536);
419
- return {
420
- parsed,
421
- sourceYaml: {
422
- value,
423
- ...redactedPaths.size ? {
424
- redactedPaths: [
425
- ...redactedPaths
426
- ]
427
- } : {},
428
- ...value !== redacted ? {
429
- truncatedPaths: [
430
- '$'
431
- ]
432
- } : {}
433
- }
434
- };
435
- }
436
- const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
437
- const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
438
- const exactProjectExpression = /^\$\{([^{}]+)\}$/;
439
- const missingVariable = (kind, name, options)=>{
440
- throw new WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
441
- ...options.location,
442
- variable: name,
443
- variableKind: kind
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
+ } : {}
444
64
  });
445
65
  };
446
- const projectVariable = (name, options)=>{
447
- if (!Object.hasOwn(options.variables ?? {}, name)) return missingVariable('project', name, options);
448
- return options.variables[name];
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;
72
+ });
449
73
  };
450
- const environmentVariable = (name, options)=>{
451
- const value = options.env?.[name];
452
- if (void 0 === value) return missingVariable('environment', name, options);
74
+ const validateTagSelection = (value, label)=>{
75
+ if (void 0 === value) return Object.freeze({
76
+ include: [],
77
+ exclude: []
78
+ });
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.`);
90
+ }
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);
98
+ }
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
+ }
453
104
  return value;
454
105
  };
455
- const interpolationValue = (name, value, options)=>{
456
- if ('object' == typeof value && null !== value) throw new WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
457
- ...options.location,
458
- variable: name,
459
- variableKind: 'project'
460
- });
461
- return String(value);
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);
462
111
  };
463
- const resolveString = (value, options)=>{
464
- const exactEnv = exactEnvExpression.exec(value);
465
- if (exactEnv) return environmentVariable(exactEnv[1], options);
466
- const exactProject = exactProjectExpression.exec(value);
467
- if (exactProject) return projectVariable(exactProject[1], options);
468
- return value.replace(interpolationExpression, (_match, environmentName, projectName)=>environmentName ? environmentVariable(environmentName, options) : interpolationValue(projectName, projectVariable(projectName, options), 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.`);
121
+ return value;
469
122
  };
470
- const resolveWorkflowVariables = (value, options)=>{
471
- if ('string' == typeof value) return resolveString(value, options);
472
- if (Array.isArray(value)) return value.map((child)=>resolveWorkflowVariables(child, options));
473
- if ('object' == typeof value && null !== value) return Object.fromEntries(Object.entries(value).map(([key, child])=>[
474
- key,
475
- resolveWorkflowVariables(child, options)
476
- ]));
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.`);
477
126
  return value;
478
127
  };
479
- function collect_define_property(obj, key, value) {
480
- if (key in obj) Object.defineProperty(obj, key, {
481
- value: value,
482
- enumerable: true,
483
- configurable: true,
484
- writable: true
485
- });
486
- else obj[key] = value;
487
- return obj;
488
- }
489
- const isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
490
- class NativeWorkflowFormatError extends WorkflowParseError {
491
- constructor(actualFormat, sourcePath){
492
- 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.`, {
493
- sourcePath,
494
- actualFormat,
495
- expectedFormat: 'native'
496
- }), collect_define_property(this, "actualFormat", void 0);
497
- this.actualFormat = actualFormat;
498
- }
499
- }
500
- const rejectUnknownKeys = (value, allowed, label)=>{
501
- const allowedSet = new Set(allowed);
502
- const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
503
- if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
504
- field: unknown[0]
505
- });
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.`);
131
+ return value;
506
132
  };
507
- const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
508
- const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
509
- function collectWorkflowDocument(source, options) {
510
- let snapshot;
511
- try {
512
- snapshot = parseWorkflowSource(readFileSync(source.absolutePath, 'utf8'));
513
- } catch (error) {
514
- throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
515
- sourcePath: source.sourcePath
516
- }, 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
+ };
517
155
  }
518
- const { parsed, sourceYaml } = snapshot;
519
- if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
520
- const format = classifyWorkflowDocumentFormat(parsed);
521
- if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
522
- rejectUnknownKeys(parsed, [
523
- 'beforeAll',
524
- 'beforeEach',
525
- 'cases',
526
- 'afterEach',
527
- 'afterAll'
528
- ], 'Workflow document');
529
- if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new WorkflowParseError('Workflow document must contain a non-empty cases array.');
530
- const sourcePath = source.sourcePath.split(sep).join('/');
531
- const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
532
- ...step,
533
- templateInput: step.input,
534
- input: resolveWorkflowVariables(step.input, {
535
- variables: options.variables,
536
- env: options.env ?? process.env,
537
- location: {
538
- projectName: source.projectName ?? source.projectId,
539
- sourcePath,
540
- phase,
541
- stepIndex,
542
- ...void 0 === caseIndex ? {} : {
543
- caseIndex
544
- }
545
- }
546
- })
547
- });
548
- const normalizeLifecycle = (phase)=>{
549
- const value = parsed[phase];
550
- if (void 0 === value) return [];
551
- if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
552
- phase
553
- });
554
- return normalize_normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
555
- const resolved = options.resolveNode(normalized.node);
556
- if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
557
- phase,
558
- stepIndex,
559
- node: normalized.node
560
- });
561
- return resolveStepVariables(normalized, phase, stepIndex);
562
- });
563
- };
564
- const lifecycle = {
565
- beforeAll: normalizeLifecycle('beforeAll'),
566
- beforeEach: normalizeLifecycle('beforeEach'),
567
- afterEach: normalizeLifecycle('afterEach'),
568
- afterAll: normalizeLifecycle('afterAll')
569
- };
570
- const ids = new Set();
571
- const cases = parsed.cases.map((definition, caseIndex)=>{
572
- if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
573
- caseIndex
574
- });
575
- rejectUnknownKeys(definition, [
576
- '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, [
577
163
  'name',
164
+ 'setup',
165
+ 'nodes',
166
+ 'files',
578
167
  'tags',
579
- 'resources',
580
- 'steps'
581
- ], `Case ${caseIndex + 1}`);
582
- 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.`, {
583
- caseIndex
584
- });
585
- if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
586
- caseIndex
587
- });
588
- if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
589
- caseIndex
590
- });
591
- const tags = definition.tags ?? [];
592
- 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.`, {
593
- caseIndex
594
- });
595
- const resources = definition.resources ?? [];
596
- 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.`, {
597
- caseIndex
598
- });
599
- if (new Set(resources).size !== resources.length) throw new WorkflowParseError(`Case ${caseIndex + 1} resources must not contain duplicates.`, {
600
- caseIndex
601
- });
602
- const steps = normalize_normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
603
- if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
604
- caseIndex,
605
- stepIndex,
606
- node: normalized.node
607
- });
608
- return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
609
- });
610
- const caseId = 'string' == typeof definition.id ? definition.id : createCaseId(source.projectId, sourcePath, caseIndex);
611
- if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
612
- caseId
613
- });
614
- ids.add(caseId);
615
- return {
616
- caseId,
617
- projectId: source.projectId,
618
- sourcePath,
619
- caseIndex,
620
- definition: {
621
- ...'string' == typeof definition.id ? {
622
- id: definition.id
623
- } : {},
624
- name: definition.name,
625
- tags: tags,
626
- ...resources.length > 0 ? {
627
- resources: resources
628
- } : {},
629
- 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`)
630
193
  }
631
- };
194
+ });
632
195
  });
633
196
  return {
634
- documentId: createWorkflowDocumentId(source.projectId, sourcePath),
635
- projectId: source.projectId,
636
- sourcePath,
637
- sourceYaml,
638
- lifecycle,
639
- cases
197
+ projects: Object.freeze(projects),
198
+ hasExplicitProjects: true
640
199
  };
641
- }
642
- const writeJson = async (path, value)=>{
643
- try {
644
- await mkdir(dirname(path), {
645
- recursive: true
646
- });
647
- await writeFile(path, JSON.stringify(value, null, 2));
648
- } catch (error) {
649
- throw new WorkflowPublicationError('write-result', path, error);
650
- }
651
200
  };
652
- const writeTestRunPlan = (runDir, projects)=>writeJson(join(runDir, 'plan.json'), {
653
- schemaVersion: 1,
654
- createdAt: new Date().toISOString(),
655
- projects: projects.map((item)=>({
656
- projectId: item.project.projectId,
657
- name: item.project.name,
658
- documents: item.plans ?? []
659
- }))
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')
660
208
  });
661
- const toPosix = (value)=>value.split(sep).join('/');
662
- const caseAttemptResultPath = (projectId, documentId, result)=>toPosix(join(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
663
- const workflowDocumentResultPath = (result)=>toPosix(join(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
664
- const collectionErrorPath = (projectId, sourcePath)=>{
665
- const id = createHash('sha256').update(JSON.stringify([
666
- projectId,
667
- sourcePath
668
- ])).digest('hex');
669
- return toPosix(join(projectId, 'collection-errors', `${id}.json`));
670
- };
671
- const writeWorkflowDocumentResult = (runDir, result)=>{
672
- const { documentRunId: _documentRunId, ...persistedResult } = result;
673
- return writeJson(join(runDir, workflowDocumentResultPath(result)), persistedResult);
674
209
  };
675
- const writeCaseAttemptResult = (runDir, projectId, documentId, result)=>{
676
- const { runId: attemptId, ...persistedResult } = result;
677
- return writeJson(join(runDir, caseAttemptResultPath(projectId, documentId, result)), {
678
- ...persistedResult,
679
- attemptId
680
- });
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;
681
214
  };
682
- const writeCollectionError = (runDir, collectionError)=>writeJson(join(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
683
- kind: 'collection-error',
684
- ...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')
685
223
  });
686
- const relativeToSummary = (summaryPath, target)=>{
687
- const value = relative(dirname(summaryPath), target);
688
- return toPosix(value || '.');
689
224
  };
690
- const errorJson = sanitizeReportError;
691
- const writeTestProjectRunResult = (options)=>{
692
- const { result } = options;
693
- const fact = (path)=>toPosix(path);
694
- return writeJson(result.summaryPath, {
695
- schemaVersion: result.schemaVersion,
696
- runId: result.runId,
697
- startedAt: result.startedAt,
698
- endedAt: result.endedAt,
699
- durationMs: result.durationMs,
700
- status: result.status,
701
- exitCode: result.exitCode,
702
- ...result.errors?.length ? {
703
- errors: result.errors.map(errorJson)
704
- } : {},
705
- projectRoot: options.projectRoot,
706
- ...options.configPath ? {
707
- configPath: options.configPath
708
- } : {},
709
- factsRoot: '.',
710
- plan: 'plan.json',
711
- ...result.operationLog ? {
712
- operationLog: {
713
- ...result.operationLog,
714
- path: relativeToSummary(result.summaryPath, result.operationLog.path)
715
- }
716
- } : {},
717
- reportDir: relativeToSummary(result.summaryPath, result.reportDir),
718
- ...result.reportPath ? {
719
- report: relativeToSummary(result.summaryPath, result.reportPath)
720
- } : {},
721
- summary: result.summary,
722
- projects: result.projects.map((project)=>({
723
- projectId: project.projectId,
724
- name: project.name,
725
- status: project.status,
726
- retry: project.retry,
727
- fileSelection: project.fileSelection,
728
- tagSelection: project.tagSelection,
729
- sourceCount: project.sourceCount,
730
- selectedCaseCount: project.selectedCaseCount,
731
- filteredCaseCount: project.filteredCaseCount,
732
- ...project.lifecycle ? {
733
- lifecycle: {
734
- status: project.lifecycle.status,
735
- startedAt: project.lifecycle.startedAt,
736
- endedAt: project.lifecycle.endedAt,
737
- durationMs: project.lifecycle.durationMs,
738
- ...project.lifecycle.cleanup ? {
739
- cleanup: project.lifecycle.cleanup
740
- } : {},
741
- ...project.lifecycle.pendingOperations ? {
742
- pendingOperations: project.lifecycle.pendingOperations
743
- } : {},
744
- ...project.lifecycle.setupError ? {
745
- setupError: errorJson(project.lifecycle.setupError)
746
- } : {},
747
- ...project.lifecycle.teardownErrors ? {
748
- teardownErrors: project.lifecycle.teardownErrors.map(errorJson)
749
- } : {}
750
- }
751
- } : {},
752
- cases: project.cases.map((outcome)=>({
753
- ...outcome.documentRunId ? {
754
- documentRunId: outcome.documentRunId
755
- } : {},
756
- documentId: outcome.documentId,
757
- caseId: outcome.caseId,
758
- sourcePath: outcome.sourcePath,
759
- caseIndex: outcome.caseIndex,
760
- name: outcome.name,
761
- status: outcome.status,
762
- ...outcome.notRunReason ? {
763
- notRunReason: outcome.notRunReason
764
- } : {},
765
- ...outcome.execution ? {
766
- execution: {
767
- executor: outcome.execution.executor,
768
- resources: outcome.execution.resources,
769
- ...void 0 === outcome.execution.attempts ? {} : {
770
- attempts: outcome.execution.attempts
771
- },
772
- ...outcome.execution.failure ? {
773
- failure: outcome.execution.failure
774
- } : {},
775
- ...outcome.execution.artifacts ? {
776
- artifacts: outcome.execution.artifacts
777
- } : {},
778
- ...outcome.execution.metadata ? {
779
- metadata: outcome.execution.metadata
780
- } : {},
781
- ...outcome.execution.lifecycle ? {
782
- lifecycle: {
783
- status: outcome.execution.lifecycle.status,
784
- startedAt: outcome.execution.lifecycle.startedAt,
785
- endedAt: outcome.execution.lifecycle.endedAt,
786
- durationMs: outcome.execution.lifecycle.durationMs,
787
- ...outcome.execution.lifecycle.setupError ? {
788
- setupError: errorJson(outcome.execution.lifecycle.setupError)
789
- } : {},
790
- ...outcome.execution.lifecycle.teardownErrors ? {
791
- teardownErrors: outcome.execution.lifecycle.teardownErrors.map(errorJson)
792
- } : {}
793
- }
794
- } : {}
795
- }
796
- } : {},
797
- ...outcome.attempts ? {
798
- attempts: outcome.attempts.map((attempt)=>({
799
- attemptId: attempt.runId,
800
- attemptIndex: attempt.attemptIndex,
801
- status: attempt.status,
802
- ...attempt.cleanup ? {
803
- cleanup: attempt.cleanup
804
- } : {},
805
- ...attempt.pendingOperations ? {
806
- pendingOperations: attempt.pendingOperations
807
- } : {},
808
- ...attempt.executionStatus ? {
809
- executionStatus: attempt.executionStatus
810
- } : {},
811
- ...attempt.retryDecision ? {
812
- retryDecision: attempt.retryDecision
813
- } : {},
814
- resultFile: fact(caseAttemptResultPath(project.projectId, outcome.documentId, attempt)),
815
- ...attempt.reportPaths?.length ? {
816
- reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
817
- } : {}
818
- }))
819
- } : {}
820
- })),
821
- documents: project.documents.map((document)=>({
822
- documentId: document.documentId,
823
- ...void 0 === document.attemptIndex ? {} : {
824
- attemptIndex: document.attemptIndex,
825
- documentRunId: document.documentRunId
826
- },
827
- sourcePath: document.sourcePath,
828
- status: document.status,
829
- ...document.cleanup ? {
830
- cleanup: document.cleanup
831
- } : {},
832
- ...document.pendingOperations ? {
833
- pendingOperations: document.pendingOperations
834
- } : {},
835
- ...document.executionStatus ? {
836
- executionStatus: document.executionStatus
837
- } : {},
838
- ...document.retryDecision ? {
839
- retryDecision: document.retryDecision
840
- } : {},
841
- resultFile: fact(workflowDocumentResultPath(document)),
842
- ...document.reportPaths?.length ? {
843
- reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
844
- } : {}
845
- })),
846
- collectionErrors: project.collectionErrors.map((error)=>({
847
- sourcePath: error.sourcePath,
848
- errorFile: fact(collectionErrorPath(error.projectId, error.sourcePath))
849
- }))
850
- }))
851
- });
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
+ };
852
249
  };
853
- const registry_NodeRegistry = NodeRegistry;
854
- const projectIdFromIndex = (index)=>`project-${index}`;
855
- const defineTestProject = (definition)=>definition;
856
- const defineProjectSetup = (definition)=>definition;
857
- const getDefaultExport = (loaded, absolutePath)=>{
858
- if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
859
- const defaultExport = loaded.default;
860
- if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
861
- if ('default' in defaultExport) return defaultExport.default;
862
- throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
863
- }
864
- 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.`);
865
254
  }
866
- throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
255
+ return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
867
256
  };
868
- const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
869
- const test_project_rejectUnknownKeys = (value, allowedKeys, label)=>{
870
- const allowed = new Set(allowedKeys);
871
- const unknown = Object.keys(value).find((key)=>!allowed.has(key));
872
- if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
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);
873
261
  };
874
- const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
875
- const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
876
- if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
877
- if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
878
- return value.map((pattern, index)=>{
879
- if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
880
- if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
881
- if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
882
- if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
883
- if (pattern.startsWith('!')) {
884
- if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
885
- throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
886
- }
887
- return pattern;
262
+ async function loadTestProject(configPath) {
263
+ if (!configPath) return validateTestProjectDefinition({
264
+ nodes: []
888
265
  });
889
- };
890
- const validateTestFileSelection = (value, label = 'files')=>{
891
- if (void 0 === value) return;
892
- if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
893
- test_project_rejectUnknownKeys(value, [
894
- 'include',
895
- 'exclude'
896
- ], label);
897
- const include = validatePatterns(value.include, 'include', label);
898
- const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
899
- return Object.freeze({
900
- include: Object.freeze(include),
901
- ...exclude ? {
902
- exclude: Object.freeze(exclude)
903
- } : {}
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
+ }
904
339
  });
905
- };
906
- const validateTagList = (value, field, label)=>{
907
- if (void 0 === value) return [];
908
- if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
909
- return value.map((tag, index)=>{
910
- if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
911
- return tag;
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;
350
+ }
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
+ };
369
+ }
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
912
378
  });
913
379
  };
914
- const validateTagSelection = (value, label)=>{
915
- if (void 0 === value) return Object.freeze({
916
- include: [],
917
- exclude: []
918
- });
919
- if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
920
- return Object.freeze({
921
- include: Object.freeze(validateTagList(value.include, 'include', label)),
922
- exclude: Object.freeze(validateTagList(value.exclude, 'exclude', label))
923
- });
380
+ const projectVariable = (name, options)=>{
381
+ if (!Object.hasOwn(options.variables ?? {}, name)) return missingVariable('project', name, options);
382
+ return options.variables[name];
924
383
  };
925
- function assertJsonValue(value, path, seen) {
926
- if (null === value || 'string' == typeof value || 'boolean' == typeof value) return;
927
- if ('number' == typeof value) {
928
- if (Number.isFinite(value)) return;
929
- throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
930
- }
931
- if ('object' != typeof value || void 0 === value) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
932
- if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && null !== Object.getPrototypeOf(value)) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
933
- if (seen.has(value)) throw new TypeError(`Midscene config ${path} must not contain cycles.`);
934
- seen.add(value);
935
- if (Array.isArray(value)) value.forEach((item, index)=>assertJsonValue(item, `${path}[${index}]`, seen));
936
- else for (const [key, child] of Object.entries(value))assertJsonValue(child, `${path}.${key}`, seen);
937
- seen.delete(value);
938
- }
939
- const deepFreezeJson = (value)=>{
940
- if ('object' == typeof value && null !== value) {
941
- for (const child of Object.values(value))deepFreezeJson(child);
942
- Object.freeze(value);
943
- }
384
+ const environmentVariable = (name, options)=>{
385
+ const value = options.env?.[name];
386
+ if (void 0 === value) return missingVariable('environment', name, options);
944
387
  return value;
945
388
  };
946
- const validateVariables = (value, label)=>{
947
- if (void 0 === value) return Object.freeze({});
948
- if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
949
- assertJsonValue(value, label, new Set());
950
- return deepFreezeJson(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);
951
396
  };
952
- const validateSetup = (value, label)=>{
953
- if (void 0 === value) return;
954
- if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
955
- test_project_rejectUnknownKeys(value, [
956
- 'name',
957
- 'setup'
958
- ], label);
959
- if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
960
- if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
961
- return value;
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));
962
403
  };
963
- const validatePositiveInteger = (value, fallback, label)=>{
964
- if (void 0 === value) return fallback;
965
- if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
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
+ ]));
966
411
  return value;
967
412
  };
968
- const validateNonNegativeInteger = (value, fallback, label)=>{
969
- if (void 0 === value) return fallback;
970
- if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
971
- return value;
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;
432
+ }
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
+ });
972
440
  };
973
- const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
974
- if (void 0 === value) {
975
- const setup = validateSetup(defaultSetup, 'setup');
976
- return {
977
- hasExplicitProjects: false,
978
- projects: Object.freeze([
979
- Object.freeze({
980
- projectId: projectIdFromIndex(0),
981
- name: 'default',
982
- ...setup ? {
983
- setup
984
- } : {},
985
- tags: Object.freeze({
986
- include: [],
987
- exclude: []
988
- }),
989
- retry: 0,
990
- variables: Object.freeze({}),
991
- nodes: new registry_NodeRegistry(globalNodes.definitions())
992
- })
993
- ])
994
- };
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);
995
451
  }
996
- if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
997
- if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
998
- const names = new Set();
999
- const projects = value.map((candidate, index)=>{
1000
- const label = `projects[${index}]`;
1001
- if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
1002
- test_project_rejectUnknownKeys(candidate, [
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, [
1003
510
  'name',
1004
- 'setup',
1005
- 'nodes',
1006
- 'files',
1007
511
  'tags',
1008
- 'retry',
1009
- 'variables'
1010
- ], label);
1011
- if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
1012
- if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
1013
- names.add(candidate.name);
1014
- const files = validateTestFileSelection(candidate.files, `${label}.files`);
1015
- if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
1016
- const localNodes = new registry_NodeRegistry(candidate.nodes);
1017
- const nodes = new registry_NodeRegistry([
1018
- ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
1019
- ...localNodes.definitions()
1020
- ]);
1021
- return Object.freeze({
1022
- projectId: projectIdFromIndex(index),
1023
- name: candidate.name,
1024
- nodes,
1025
- ...files ? {
1026
- files
1027
- } : {},
1028
- tags: validateTagSelection(candidate.tags, `${label}.tags`),
1029
- retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
1030
- variables: validateVariables(candidate.variables, `${label}.variables`),
1031
- ...void 0 === candidate.setup ? {} : {
1032
- setup: validateSetup(candidate.setup, `${label}.setup`)
1033
- }
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);
531
+ });
532
+ const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
533
+ if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
534
+ caseId
1034
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
+ };
1035
548
  });
1036
549
  return {
1037
- projects: Object.freeze(projects),
1038
- hasExplicitProjects: true
550
+ documentId: createWorkflowDocumentId(source.projectId, sourcePath),
551
+ projectId: source.projectId,
552
+ sourcePath,
553
+ sourceYaml,
554
+ lifecycle,
555
+ cases
1039
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
+ }
1040
567
  };
1041
- const validateTestOptions = (value)=>{
1042
- if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
1043
- const candidate = value ?? {};
1044
- const executionUnit = candidate.executionUnit ?? 'project';
1045
- if ('project' !== executionUnit && 'case' !== executionUnit) throw new TypeError('Midscene config test.executionUnit must be "project" or "case".');
1046
- return Object.freeze({
1047
- maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
1048
- bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
1049
- testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout'),
1050
- executionUnit,
1051
- 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
+ }))
1052
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`));
1053
586
  };
1054
- const validateTestExecutor = (value)=>{
1055
- if (void 0 === value) return;
1056
- if (!isRecord(value)) throw new TypeError('Midscene config executor must be an object.');
1057
- test_project_rejectUnknownKeys(value, [
1058
- 'name',
1059
- 'execute'
1060
- ], 'executor');
1061
- if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError('Midscene config executor.name must be non-empty.');
1062
- if ('function' != typeof value.execute) throw new TypeError('Midscene config executor.execute must be a function.');
1063
- return Object.freeze(value);
587
+ const writeWorkflowDocumentResult = (runDir, result)=>{
588
+ const { documentRunId: _documentRunId, ...persistedResult } = result;
589
+ return writeJson(join(runDir, workflowDocumentResultPath(result)), persistedResult);
1064
590
  };
1065
- const validateOutputPath = (value, fallback, label)=>{
1066
- if (void 0 === value) return fallback;
1067
- if ('string' != typeof value || 0 === value.trim().length) throw new TypeError(`Midscene config ${label} must be non-empty.`);
1068
- 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
+ });
1069
597
  };
1070
- const validateOutput = (value)=>{
1071
- if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config output must be an object.');
1072
- const candidate = value ?? {};
1073
- test_project_rejectUnknownKeys(candidate, [
1074
- 'reportDir'
1075
- ], 'output');
1076
- return Object.freeze({
1077
- 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
1078
601
  });
602
+ const relativeToSummary = (summaryPath, target)=>{
603
+ const value = relative(dirname(summaryPath), target);
604
+ return toPosix(value || '.');
1079
605
  };
1080
- const validateTestProjectDefinition = (definition)=>{
1081
- if (!isRecord(definition)) throw new TypeError('Midscene config must default export an object.');
1082
- if (void 0 !== definition.nodes && !Array.isArray(definition.nodes)) throw new TypeError('Midscene config nodes must be an array.');
1083
- if ('setupWorkflow' in definition) throw new TypeError('Midscene config setupWorkflow is no longer supported. Use setup or projects[].setup instead.');
1084
- 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.');
1085
- if ('root' in definition) throw new TypeError('Midscene config root is not supported. Pass the Test Project directory to midscene-test instead.');
1086
- if ('files' in definition) throw new TypeError('Midscene config files is not supported at the root. Move it to projects[].files.');
1087
- if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
1088
- test_project_rejectUnknownKeys(definition, [
1089
- 'setup',
1090
- 'projects',
1091
- 'test',
1092
- 'output',
1093
- 'nodes',
1094
- 'executor'
1095
- ], 'root');
1096
- const nodes = new registry_NodeRegistry(definition.nodes);
1097
- const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
1098
- const test = validateTestOptions(definition.test);
1099
- const executor = validateTestExecutor(definition.executor);
1100
- if (executor && 'case' !== test.executionUnit) throw new TypeError('Midscene config executor requires test.executionUnit to be "case".');
1101
- return {
1102
- ...resolvedProjects,
1103
- test,
1104
- output: validateOutput(definition.output),
1105
- nodes,
1106
- ...executor ? {
1107
- 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)
1108
620
  } : {},
1109
- resolveNode: (name)=>nodes.get(name)
1110
- };
1111
- };
1112
- const assertSupportedConfig = (absolutePath)=>{
1113
- if (!absolutePath.endsWith('.ts') && !absolutePath.endsWith('.mjs')) {
1114
- const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
1115
- throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extensions: .ts, .mjs.`);
1116
- }
1117
- return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
1118
- };
1119
- const canRetryWithCjsLoader = (error)=>{
1120
- if (!(error instanceof Error)) return false;
1121
- const errorCode = error.code;
1122
- 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);
1123
- };
1124
- async function loadTestProject(configPath) {
1125
- if (!configPath) return validateTestProjectDefinition({
1126
- nodes: []
1127
- });
1128
- const absolutePath = external_node_path_resolve(configPath);
1129
- const extension = assertSupportedConfig(absolutePath);
1130
- let loaded;
1131
- try {
1132
- if ('.mjs' === extension) loaded = await import(pathToFileURL(absolutePath).href);
1133
- else {
1134
- const { tsImport } = await import("tsx/esm/api");
1135
- loaded = await tsImport(pathToFileURL(absolutePath).href, {
1136
- parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
1137
- tsconfig: false
1138
- });
1139
- }
1140
- } catch (error) {
1141
- try {
1142
- if ('.mjs' === extension || !canRetryWithCjsLoader(error)) throw error;
1143
- loaded = api_require(absolutePath, pathToFileURL(absolutePath));
1144
- } catch (fallbackError) {
1145
- const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
1146
- throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
1147
- cause: fallbackError
1148
- });
1149
- }
1150
- }
1151
- return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
1152
- }
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
+ }))
738
+ });
739
+ };
1153
740
  const CONFIG_NAMES = [
1154
741
  'midscene.config.ts',
1155
742
  'midscene.config.mjs'
@@ -1171,29 +758,8 @@ const DEFAULT_TEST_FILE_SELECTION = {
1171
758
  ]
1172
759
  };
1173
760
  const project_preparation_toPosix = (value)=>value.split(sep).join('/');
1174
- const discoverSelectedTestFiles = (projectRoot, paths)=>{
1175
- const root = external_node_path_resolve(projectRoot);
1176
- const files = new Set();
1177
- for (const requestedPath of paths){
1178
- if ('string' != typeof requestedPath || 0 === requestedPath.trim().length) throw new TypeError('Test selection paths must be non-empty strings.');
1179
- const absolutePath = external_node_path_resolve(root, requestedPath);
1180
- const relativePath = relative(root, absolutePath);
1181
- if ('..' === relativePath || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) throw new Error(`Test selection path is outside the project: ${requestedPath}`);
1182
- if (!existsSync(absolutePath)) throw new Error(`Test selection path does not exist: ${requestedPath}`);
1183
- if (statSync(absolutePath).isDirectory()) for (const file of discoverTestFiles(absolutePath))files.add(file);
1184
- else if (/\.ya?ml$/i.test(absolutePath)) files.add(absolutePath);
1185
- else throw new Error(`Test selection file must be YAML: ${requestedPath}`);
1186
- }
1187
- return [
1188
- ...files
1189
- ].sort((a, b)=>{
1190
- const relativeA = project_preparation_toPosix(relative(root, a));
1191
- const relativeB = project_preparation_toPosix(relative(root, b));
1192
- return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
1193
- });
1194
- };
1195
761
  const discoverResolvedTestFiles = (projectRoot, selection)=>{
1196
- const root = external_node_path_resolve(projectRoot);
762
+ const root = resolve(projectRoot);
1197
763
  const match = (patterns)=>globSync(patterns, {
1198
764
  absolute: true,
1199
765
  caseSensitiveMatch: false,
@@ -1209,7 +775,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
1209
775
  }).filter((file)=>/\.ya?ml$/i.test(file));
1210
776
  const files = match(selection.include);
1211
777
  return [
1212
- ...new Set(files.map((file)=>external_node_path_resolve(file)))
778
+ ...new Set(files.map((file)=>resolve(file)))
1213
779
  ].sort((a, b)=>{
1214
780
  const relativeA = project_preparation_toPosix(relative(root, a));
1215
781
  const relativeB = project_preparation_toPosix(relative(root, b));
@@ -1222,7 +788,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
1222
788
  return discoverResolvedTestFiles(projectRoot, normalized);
1223
789
  };
1224
790
  const discoverTestConfig = (projectRoot)=>{
1225
- const root = external_node_path_resolve(projectRoot);
791
+ const root = resolve(projectRoot);
1226
792
  const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
1227
793
  const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
1228
794
  const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
@@ -1251,14 +817,8 @@ const matchesTags = (tags, selection)=>{
1251
817
  if (selection.exclude.some((tag)=>tags.includes(tag))) return false;
1252
818
  return 0 === selection.include.length || selection.include.some((tag)=>tags.includes(tag));
1253
819
  };
1254
- const filterDocumentCases = (document, project, selection)=>{
1255
- const cases = document.cases.filter((item)=>{
1256
- const tags = item.definition.tags ?? [];
1257
- return matchesTags(tags, project.tags) && matchesTags(tags, {
1258
- include: selection?.tags?.include ?? [],
1259
- exclude: selection?.tags?.exclude ?? []
1260
- }) && (!selection?.caseIds?.length || selection.caseIds.includes(item.caseId));
1261
- });
820
+ const filterDocumentCases = (document, project)=>{
821
+ const cases = document.cases.filter((item)=>matchesTags(item.definition.tags ?? [], project.tags));
1262
822
  const filtered = document.cases.length - cases.length;
1263
823
  return 0 === cases.length ? {
1264
824
  filtered
@@ -1282,10 +842,8 @@ const selectProjects = (projects, names)=>{
1282
842
  async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
1283
843
  const platform = options.platform?.trim();
1284
844
  const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
1285
- const prerequisiteFile = options.prerequisiteFile ? external_node_path_resolve(options.prerequisiteFile) : void 0;
1286
- const projectFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
1287
- const selectedFiles = options.selection?.paths?.length ? new Set(discoverSelectedTestFiles(projectRoot, options.selection.paths)) : void 0;
1288
- 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);
1289
847
  const files = [
1290
848
  ...prerequisiteFile ? [
1291
849
  prerequisiteFile
@@ -1308,7 +866,6 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
1308
866
  const invocations = [];
1309
867
  const plans = [];
1310
868
  let filteredCaseCount = 0;
1311
- const availableCaseIds = new Set();
1312
869
  if (0 === sources.length) {
1313
870
  const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
1314
871
  projectName: project.name,
@@ -1339,16 +896,10 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
1339
896
  variables: project.variables,
1340
897
  env: process.env
1341
898
  });
1342
- const duplicate = document.cases.find((item)=>availableCaseIds.has(item.caseId));
1343
- if (duplicate) throw new WorkflowParseError(`Case id collision in project "${project.name}": ${duplicate.caseId}.`, {
1344
- caseId: duplicate.caseId,
1345
- projectName: project.name
1346
- });
1347
- for (const item of document.cases)availableCaseIds.add(item.caseId);
1348
899
  const filtered = source.absolutePath === prerequisiteFile ? {
1349
900
  document,
1350
901
  filtered: 0
1351
- } : filterDocumentCases(document, project, options.selection);
902
+ } : filterDocumentCases(document, project);
1352
903
  const plan = buildTestRunReportPlan(document, {
1353
904
  selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
1354
905
  defaultTimeoutMs,
@@ -1396,42 +947,9 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
1396
947
  plans,
1397
948
  collectionErrors,
1398
949
  selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
1399
- filteredCaseCount,
1400
- availableCaseIds: [
1401
- ...availableCaseIds
1402
- ]
950
+ filteredCaseCount
1403
951
  };
1404
952
  }
1405
- const validateUniqueStrings = (values, label)=>{
1406
- if (void 0 === values) return;
1407
- 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.`);
1408
- if (new Set(values).size !== values.length) throw new TypeError(`${label} must not contain duplicates.`);
1409
- return values;
1410
- };
1411
- const validateRunSelection = (options)=>{
1412
- const paths = validateUniqueStrings(options.paths, 'Test selection paths');
1413
- const caseIds = validateUniqueStrings(options.caseIds, 'Test selection caseIds');
1414
- const include = validateUniqueStrings(options.tags?.include, 'Test selection tags.include');
1415
- const exclude = validateUniqueStrings(options.tags?.exclude, 'Test selection tags.exclude');
1416
- return {
1417
- ...paths ? {
1418
- paths
1419
- } : {},
1420
- ...caseIds ? {
1421
- caseIds
1422
- } : {},
1423
- ...include || exclude ? {
1424
- tags: {
1425
- ...include ? {
1426
- include
1427
- } : {},
1428
- ...exclude ? {
1429
- exclude
1430
- } : {}
1431
- }
1432
- } : {}
1433
- };
1434
- };
1435
953
  const padDatePart = (value)=>String(value).padStart(2, '0');
1436
954
  const createTestRunId = (date = new Date(), uuid = randomUUID())=>{
1437
955
  const timestamp = [
@@ -1448,9 +966,9 @@ const assertDirectory = (path, label)=>{
1448
966
  if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
1449
967
  };
1450
968
  const resolveRunInput = (options)=>{
1451
- const cwd = external_node_path_resolve(options.cwd ?? process.cwd());
969
+ const cwd = resolve(options.cwd ?? process.cwd());
1452
970
  assertDirectory(cwd, 'Test working directory');
1453
- const inputPath = options.projectRoot ? external_node_path_resolve(cwd, options.projectRoot) : void 0;
971
+ const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
1454
972
  const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
1455
973
  if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
1456
974
  const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
@@ -1469,34 +987,24 @@ async function prepareTestRun(options) {
1469
987
  const startedAt = new Date();
1470
988
  const runId = createTestRunId(startedAt);
1471
989
  const input = resolveRunInput(options);
1472
- const selection = validateRunSelection(options);
1473
- 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);
1474
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}\`.`);
1475
992
  if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
1476
993
  const definition = await loadTestProject(configPath);
1477
- 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');
1478
995
  const runDir = join(resultDir, runId);
1479
996
  const summaryPath = join(runDir, 'summary.json');
1480
- const reportDir = external_node_path_resolve(input.projectRoot, definition.output.reportDir);
997
+ const reportDir = resolve(input.projectRoot, definition.output.reportDir);
1481
998
  mkdirSync(resultDir, {
1482
999
  recursive: true
1483
1000
  });
1484
1001
  mkdirSync(runDir);
1485
1002
  const projects = [];
1486
- for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, {
1487
- selection,
1488
- ...input.singleFile ? {
1489
- files: [
1490
- input.singleFile
1491
- ]
1492
- } : {}
1493
- }));
1494
- if (selection.caseIds?.length) {
1495
- const availableCaseIds = new Set(projects.flatMap((project)=>project.availableCaseIds ?? []));
1496
- const unknown = selection.caseIds.find((id)=>!availableCaseIds.has(id));
1497
- if (unknown) throw new Error(`Unknown Midscene case id: ${unknown}`);
1498
- }
1499
- 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));
1500
1008
  const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
1501
1009
  return {
1502
1010
  startedAt,
@@ -1517,611 +1025,94 @@ async function prepareTestRun(options) {
1517
1025
  onProgress: options.onProgress ?? (()=>{})
1518
1026
  };
1519
1027
  }
1520
- const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1521
- documentId,
1522
- caseId: collectedCase.caseId,
1523
- projectName,
1524
- name: collectedCase.definition.name,
1525
- sourcePath: collectedCase.sourcePath,
1526
- caseIndex: collectedCase.caseIndex,
1527
- status: 'not-run',
1528
- notRunReason: reason
1529
- });
1530
- const formatStep = (info)=>{
1531
- const position = 'case' === info.scope ? info.case : info.document;
1532
- const phase = 'steps' === position.phase ? 'step' : position.phase;
1533
- return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
1028
+ const result_markdown_text = (value)=>value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('|', '&#124;').replaceAll('[', '&#91;').replaceAll(']', '&#93;').replaceAll('*', '&#42;').replaceAll('_', '&#95;').replaceAll('`', '&#96;').replaceAll('\\', '&#92;').replace(/[\r\n]+/g, ' ');
1029
+ const duration = (ms)=>{
1030
+ const seconds = Math.round(ms / 1000);
1031
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
1534
1032
  };
1535
- const formatStepResult = (info, result, indent = '')=>{
1536
- if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
1537
- const symbol = 'success' === result.status ? '✓' : '✗';
1538
- const error = result.error ? ` — ${result.error.message}` : '';
1539
- const continuation = result.continuedAfterError ? '; continuing' : '';
1540
- return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
1541
- };
1542
- const latestById = (items, id)=>[
1543
- ...new Map(items.map((item)=>[
1544
- id(item),
1545
- item
1546
- ])).values()
1033
+ const href = (from, target)=>relative(dirname(from), target).split(sep).map(encodeURIComponent).join('/');
1034
+ function renderTestResultMarkdown(dump, options = {}) {
1035
+ const { summary } = dump;
1036
+ const outcome = 'success' === dump.status ? `✅ **${summary.passed}/${summary.total} passed**` : `❌ **${summary.failed} failed** · ${summary.passed} passed · ${summary.notRun} not run`;
1037
+ const lines = [
1038
+ '## Midscene Test',
1039
+ '',
1040
+ `${outcome} · ${duration(dump.durationMs)}`,
1041
+ ''
1547
1042
  ];
1548
- const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
1549
- const { project } = prepared;
1550
- 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';
1551
- return {
1552
- projectId: project.projectId,
1553
- name: project.name,
1554
- ...prepared.platform ? {
1555
- platform: prepared.platform
1556
- } : {},
1557
- status: projectFailed ? 'failed' : 'success',
1558
- retry: project.retry,
1559
- fileSelection: prepared.fileSelection,
1560
- tagSelection: project.tags,
1561
- sourceCount: prepared.sources.length,
1562
- selectedCaseCount: prepared.selectedCaseCount,
1563
- filteredCaseCount: prepared.filteredCaseCount,
1564
- ...lifecycle ? {
1565
- lifecycle
1566
- } : {},
1567
- cases,
1568
- ...prepared.plans ? {
1569
- plans: prepared.plans
1570
- } : {},
1571
- documents,
1572
- collectionErrors: prepared.collectionErrors
1573
- };
1574
- };
1575
- function createExecutorReports(outputDir) {
1576
- const paths = new Map();
1577
- const sources = new Map();
1578
- return {
1579
- materializeSource (source) {
1580
- if (!source.dumpPath) {
1581
- const reference = this.materialize(source.sourcePath);
1582
- sources.set(reference, {
1583
- sourcePath: this.resolve(reference),
1584
- collection: source.collection
1585
- });
1586
- return reference;
1587
- }
1588
- const dump = realpathSync(source.dumpPath);
1589
- const reference = randomUUID();
1590
- const destination = join(outputDir, 'reports', reference);
1591
- const sourcePath = join(destination, 'index.html');
1592
- mkdirSync(destination, {
1593
- recursive: true
1594
- });
1595
- const dumpPath = join(destination, 'snapshot.json');
1596
- cpSync(dump, dumpPath);
1597
- const screenshots = join(dirname(dump), 'screenshots');
1598
- if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
1599
- recursive: true,
1600
- dereference: true
1601
- });
1602
- sources.set(reference, {
1603
- sourcePath,
1604
- dumpPath,
1605
- collection: source.collection
1606
- });
1607
- paths.set(reference, sourcePath);
1608
- return reference;
1609
- },
1610
- resolveSource (reference) {
1611
- return sources.get(reference);
1612
- },
1613
- materialize (sourcePath) {
1614
- const source = realpathSync(sourcePath);
1615
- const entry = statSync(source).isDirectory() ? join(source, 'index.html') : source;
1616
- if (!statSync(entry).isFile()) throw new TestExecutorError(`Executor report must have a regular HTML entry: ${sourcePath}`, {
1617
- kind: 'report'
1618
- });
1619
- const reference = randomUUID();
1620
- const destination = join(outputDir, 'reports', reference);
1621
- mkdirSync(destination, {
1622
- recursive: true
1623
- });
1624
- const isDirectoryReport = 'index.html' === basename(entry);
1625
- const reportPath = join(destination, isDirectoryReport ? 'index.html' : 'report.html');
1626
- cpSync(isDirectoryReport ? dirname(entry) : entry, isDirectoryReport ? destination : reportPath, {
1627
- recursive: isDirectoryReport,
1628
- dereference: true
1629
- });
1630
- paths.set(reference, reportPath);
1631
- return reference;
1632
- },
1633
- resolve (reference) {
1634
- const reportPath = paths.get(reference);
1635
- if (!reportPath) throw new TestExecutorError(`Executor returned unknown report reference ${reference}.`, {
1636
- kind: 'report'
1637
- });
1638
- return reportPath;
1639
- }
1043
+ if (summary.filtered) lines.push(`**Filtered:** ${summary.filtered}`, '');
1044
+ const infrastructure = [
1045
+ summary.collectionErrors ? `${summary.collectionErrors} collection errors` : '',
1046
+ summary.documentFailures ? `${summary.documentFailures} document failures` : '',
1047
+ summary.projectFailures ? `${summary.projectFailures} project failures` : ''
1048
+ ].filter(Boolean);
1049
+ if (infrastructure.length) lines.push(`**Infrastructure:** ${infrastructure.join(' · ')}`, '');
1050
+ const groups = [];
1051
+ const errors = [];
1052
+ const recordSteps = (label, steps)=>{
1053
+ for (const step of steps)if ('failed' === step.status) errors.push(`${label} / ${step.node}: ${step.error?.message ?? 'Step failed'}`);
1640
1054
  };
1641
- }
1642
- async function runTaskPool(options) {
1643
- if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) throw new TypeError('Task scheduler maxConcurrency must be a positive integer.');
1644
- const pending = options.tasks.map((_, index)=>index);
1645
- const results = new Array(options.tasks.length);
1646
- const activeResources = new Set();
1647
- let active = 0;
1648
- let firstError;
1649
- let hasError = false;
1650
- return await new Promise((resolve, reject)=>{
1651
- let settled = false;
1652
- const stopRequested = ()=>hasError || options.signal?.aborted === true || options.shouldStop?.() === true;
1653
- const finish = ()=>{
1654
- if (settled) return;
1655
- settled = true;
1656
- options.signal?.removeEventListener('abort', pump);
1657
- if (hasError) reject(firstError);
1658
- else resolve(results);
1659
- };
1660
- const release = (resources)=>{
1661
- for (const resource of resources)activeResources.delete(resource);
1662
- };
1663
- const claimRunnable = ()=>{
1664
- const pendingIndex = pending.findIndex((taskIndex)=>options.tasks[taskIndex].resources.every((resource)=>!activeResources.has(resource)));
1665
- if (pendingIndex < 0) return;
1666
- const [taskIndex] = pending.splice(pendingIndex, 1);
1667
- return taskIndex;
1668
- };
1669
- function pump() {
1670
- if (settled) return;
1671
- while(active < options.maxConcurrency && !stopRequested()){
1672
- const taskIndex = claimRunnable();
1673
- if (void 0 === taskIndex) break;
1674
- const task = options.tasks[taskIndex];
1675
- for (const resource of task.resources)activeResources.add(resource);
1676
- active += 1;
1677
- Promise.resolve().then(()=>options.run(task, taskIndex)).then((result)=>{
1678
- results[taskIndex] = result;
1679
- }).catch((error)=>{
1680
- if (!hasError) {
1681
- hasError = true;
1682
- firstError = error;
1683
- }
1684
- }).finally(()=>{
1685
- release(task.resources);
1686
- active -= 1;
1687
- pump();
1688
- });
1689
- }
1690
- if (0 === active && (0 === pending.length || stopRequested())) finish();
1055
+ for (const project of dump.projects){
1056
+ if (project.lifecycle?.setupError) errors.push(`${project.name}: ${project.lifecycle.setupError.message}`);
1057
+ for (const error of project.lifecycle?.teardownErrors ?? [])errors.push(`${project.name}: ${error.message}`);
1058
+ for (const error of project.collectionErrors)errors.push(`${project.name} / ${error.sourcePath}: ${error.error.message}`);
1059
+ for (const document of project.documents){
1060
+ const name = document.sourcePath.replace(/\\/g, '/').replace(/\.(?:yaml|yml)$/, '');
1061
+ const group = {
1062
+ name: dump.projects.length > 1 ? `${project.name} / ${name}` : name,
1063
+ rows: []
1064
+ };
1065
+ groups.push(group);
1066
+ recordSteps(`${project.name} / ${document.sourcePath}`, [
1067
+ ...document.beforeAll,
1068
+ ...document.afterAll
1069
+ ]);
1070
+ for (const error of document.teardownErrors ?? [])errors.push(`${document.sourcePath}: ${error.message}`);
1071
+ for (const failure of document.hostErrors ?? [])errors.push(`${document.sourcePath} / ${failure.phase}: ${failure.error.message}`);
1072
+ for (const testCase of document.cases)group.rows.push(` - ${'success' === testCase.status ? '✅' : 'not-run' === testCase.status ? '⏭️' : '❌'} ${result_markdown_text(testCase.name)}`);
1691
1073
  }
1692
- options.signal?.addEventListener('abort', pump, {
1693
- once: true
1694
- });
1695
- pump();
1696
- });
1074
+ }
1075
+ for (const diagnostic of dump.diagnostics ?? [])if ('error' === diagnostic.level) errors.push(diagnostic.message);
1076
+ if (errors.length) lines.push('### Execution errors', '', ...errors.map((error)=>`- ${result_markdown_text(error)}`), '');
1077
+ for (const group of groups)if (group.rows.length) lines.push(`- ${result_markdown_text(group.name)}`, ...group.rows);
1078
+ if (!groups.some((group)=>group.rows.length)) lines.push('No cases were reported.');
1079
+ if (options.reportHref) lines.push('', `[Open the Midscene Test report ↗](<${options.reportHref}>)`);
1080
+ return `${lines.join('\n')}\n`;
1697
1081
  }
1698
- const isolatedDocumentId = (document, collectedCase)=>`${document.documentId}-case-${collectedCase.caseIndex}`;
1699
- const prepareCaseTasks = (projects)=>projects.flatMap((prepared, projectIndex)=>prepared.documents.flatMap((document)=>document.cases.map((collectedCase)=>{
1700
- const documentId = isolatedDocumentId(document, collectedCase);
1701
- return {
1702
- projectIndex,
1703
- prepared,
1704
- document,
1705
- collectedCase,
1706
- task: Object.freeze({
1707
- taskId: `${prepared.project.projectId}:${collectedCase.caseId}`,
1708
- projectId: prepared.project.projectId,
1709
- projectName: prepared.project.name,
1710
- documentId,
1711
- sourcePath: collectedCase.sourcePath,
1712
- caseId: collectedCase.caseId,
1713
- caseName: collectedCase.definition.name,
1714
- caseIndex: collectedCase.caseIndex,
1715
- tags: Object.freeze([
1716
- ...collectedCase.definition.tags ?? []
1717
- ]),
1718
- resources: Object.freeze([
1719
- ...collectedCase.definition.resources ?? []
1720
- ]),
1721
- retry: prepared.project.retry
1722
- })
1723
- };
1724
- })));
1725
- const reviveError = (error)=>{
1726
- const revived = new WorkflowError(error.message, {
1727
- code: error.code,
1728
- details: error.details
1729
- });
1730
- revived.name = error.name;
1731
- return revived;
1732
- };
1733
- const reviveStep = (step)=>{
1734
- const { error, output, report, ...base } = step;
1735
- return {
1736
- ...base,
1737
- input: step.input,
1738
- meta: {
1739
- ...step.meta
1740
- },
1741
- ...output ? {
1742
- output: {
1743
- ...output
1744
- }
1745
- } : {},
1746
- ...error ? {
1747
- error: reviveError(error)
1748
- } : {},
1749
- ...report ? {
1750
- report: {
1751
- traces: report.traces.map((trace)=>({
1752
- ...trace
1753
- }))
1754
- }
1755
- } : {}
1756
- };
1757
- };
1758
- const reviveAttempt = (attempt)=>{
1759
- const { executionErrors, teardownErrors, reportRefs, beforeEach, steps, afterEach, ...base } = attempt;
1760
- return {
1761
- ...base,
1762
- beforeEach: beforeEach.map(reviveStep),
1763
- steps: steps.map(reviveStep),
1764
- afterEach: afterEach.map(reviveStep),
1765
- ...executionErrors ? {
1766
- executionErrors: executionErrors.map(reviveError)
1767
- } : {},
1768
- ...teardownErrors ? {
1769
- teardownErrors: teardownErrors.map(reviveError)
1770
- } : {}
1771
- };
1772
- };
1773
- const reviveDocument = (document)=>{
1774
- const { executionErrors, teardownErrors, reportRefs, beforeAll, afterAll, ...base } = document;
1775
- return {
1776
- ...base,
1777
- beforeAll: beforeAll.map(reviveStep),
1778
- afterAll: afterAll.map(reviveStep),
1779
- ...executionErrors ? {
1780
- executionErrors: executionErrors.map(reviveError)
1781
- } : {},
1782
- ...teardownErrors ? {
1783
- teardownErrors: teardownErrors.map(reviveError)
1784
- } : {}
1785
- };
1786
- };
1787
- const reviveLifecycle = (lifecycle)=>{
1788
- const { setupError, teardownErrors, ...base } = lifecycle;
1789
- return {
1790
- ...base,
1791
- ...setupError ? {
1792
- setupError: reviveError(setupError)
1793
- } : {},
1794
- ...teardownErrors ? {
1795
- teardownErrors: teardownErrors.map(reviveError)
1796
- } : {}
1797
- };
1798
- };
1799
- const mapError = (error)=>({
1800
- name: error.name,
1801
- message: error.message,
1802
- code: error.code,
1803
- ...void 0 === error.details ? {} : {
1804
- details: error.details
1082
+ const emptyIndex = ()=>({
1083
+ sources: [],
1084
+ metrics: {
1085
+ modelCallCount: 0,
1086
+ modelTimeMs: 0,
1087
+ promptTokens: 0,
1088
+ cachedInputTokens: 0,
1089
+ completionTokens: 0,
1090
+ totalTokens: 0
1805
1091
  }
1806
1092
  });
1807
- const mapStep = (step)=>({
1808
- ...step,
1809
- ...step.error ? {
1810
- error: mapError(step.error)
1811
- } : {},
1812
- ...step.report ? {
1813
- report: {
1814
- traces: step.report.traces
1815
- }
1816
- } : {}
1817
- });
1818
- const toTransportResult = (result, materializeReport, materializeSource)=>{
1819
- const transportReports = (scope)=>[
1820
- ...(scope.reportSources ?? []).map(materializeSource),
1821
- ...(scope.reportPaths ?? []).filter((path)=>!scope.reportSources?.some((source)=>source.sourcePath === path)).map(materializeReport)
1822
- ];
1823
- const mapAttempt = (attempt)=>{
1824
- const { reportPaths, reportSources, reportScopeId, ...transportAttempt } = attempt;
1825
- const reportRefs = transportReports(attempt);
1826
- return {
1827
- ...transportAttempt,
1828
- beforeEach: attempt.beforeEach.map(mapStep),
1829
- steps: attempt.steps.map(mapStep),
1830
- afterEach: attempt.afterEach.map(mapStep),
1831
- ...attempt.executionErrors ? {
1832
- executionErrors: attempt.executionErrors.map(mapError)
1833
- } : {},
1834
- ...attempt.teardownErrors ? {
1835
- teardownErrors: attempt.teardownErrors.map(mapError)
1836
- } : {},
1837
- ...reportRefs.length ? {
1838
- reportRefs
1839
- } : {}
1840
- };
1841
- };
1842
- const attempts = result.case.attempts?.map(mapAttempt);
1843
- const run = attempts?.at(-1);
1844
- const { run: _run, attempts: _attempts, ...transportCase } = result.case;
1845
- const document = result.document ? (()=>{
1846
- const { reportPaths, reportSources, reportScopeId, plan: _plan, ...transportDocument } = result.document;
1847
- const reportRefs = transportReports(result.document);
1848
- return {
1849
- ...transportDocument,
1850
- beforeAll: result.document.beforeAll.map(mapStep),
1851
- afterAll: result.document.afterAll.map(mapStep),
1852
- ...result.document.executionErrors ? {
1853
- executionErrors: result.document.executionErrors.map(mapError)
1854
- } : {},
1855
- ...result.document.teardownErrors ? {
1856
- teardownErrors: result.document.teardownErrors.map(mapError)
1857
- } : {},
1858
- ...reportRefs.length ? {
1859
- reportRefs
1860
- } : {}
1861
- };
1862
- })() : void 0;
1863
- return JSON.parse(JSON.stringify({
1864
- case: {
1865
- ...transportCase,
1866
- ...run ? {
1867
- run
1868
- } : {},
1869
- ...attempts ? {
1870
- attempts
1871
- } : {}
1872
- },
1873
- ...document ? {
1874
- document
1875
- } : {},
1876
- ...result.lifecycle ? {
1877
- lifecycle: {
1878
- ...result.lifecycle,
1879
- ...result.lifecycle.setupError ? {
1880
- setupError: mapError(result.lifecycle.setupError)
1881
- } : {},
1882
- ...result.lifecycle.teardownErrors ? {
1883
- teardownErrors: result.lifecycle.teardownErrors.map(mapError)
1884
- } : {}
1885
- }
1093
+ async function writeTestResultMarkdown(result, index = emptyIndex()) {
1094
+ const output = join(dirname(result.summaryPath), 'result.md');
1095
+ const dump = buildTestRunReportDump(result, index);
1096
+ await writeFile(output, renderTestResultMarkdown(dump, {
1097
+ ...result.reportPath ? {
1098
+ reportHref: href(output, result.reportPath)
1886
1099
  } : {}
1887
1100
  }));
1101
+ return output;
1102
+ }
1103
+ const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1104
+ const formatStep = (info)=>{
1105
+ const position = stepPosition(info);
1106
+ const phase = 'steps' === position.phase ? 'step' : position.phase;
1107
+ return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
1888
1108
  };
1889
- const fromTransportResult = (result, resolveReportReference, resolveSource)=>{
1890
- const resolveReports = (refs)=>{
1891
- const reportPaths = [];
1892
- const reportSources = [];
1893
- for (const ref of refs ?? []){
1894
- const path = resolveReportReference(ref);
1895
- const source = resolveSource(ref);
1896
- if (source) reportSources.push(source);
1897
- else reportPaths.push(path);
1898
- }
1899
- return {
1900
- ...reportPaths.length ? {
1901
- reportPaths
1902
- } : {},
1903
- ...reportSources.length ? {
1904
- reportSources
1905
- } : {}
1906
- };
1907
- };
1908
- const attempts = result.case.attempts?.map((attempt)=>({
1909
- ...reviveAttempt(attempt),
1910
- ...resolveReports(attempt.reportRefs)
1911
- }));
1912
- const run = attempts?.at(-1);
1913
- const { run: _run, attempts: _attempts, ...caseBase } = result.case;
1914
- return {
1915
- case: {
1916
- ...caseBase,
1917
- ...run ? {
1918
- run
1919
- } : {},
1920
- ...attempts ? {
1921
- attempts
1922
- } : {}
1923
- },
1924
- ...result.document ? {
1925
- document: {
1926
- ...reviveDocument(result.document),
1927
- ...resolveReports(result.document.reportRefs)
1928
- }
1929
- } : {},
1930
- ...result.lifecycle ? {
1931
- lifecycle: reviveLifecycle(result.lifecycle)
1932
- } : {}
1933
- };
1934
- };
1935
- const runLocalCase = async (preparedTask, options, taskProgress, materializeReport, materializeSource)=>{
1936
- const { prepared, document, collectedCase, task } = preparedTask;
1937
- const { project } = prepared;
1938
- const isolatedDocument = {
1939
- ...document,
1940
- documentId: task.documentId,
1941
- cases: [
1942
- collectedCase
1943
- ]
1944
- };
1945
- const runtime = createProjectRuntime({
1946
- project,
1947
- setup: project.setup,
1948
- signal: options.signal
1949
- });
1950
- let lifecycle;
1951
- let execution;
1952
- let executionError;
1953
- try {
1954
- await runtime.start();
1955
- if (runtime.canRun) execution = await runWorkflowDocument(isolatedDocument, {
1956
- resolveNode: project.nodes.require.bind(project.nodes),
1957
- project,
1958
- projectContext: runtime.context,
1959
- retry: project.retry,
1960
- signal: runtime.signal,
1961
- defaultTimeoutMs: options.test.testTimeout,
1962
- shouldStop: ()=>options.signal.aborted,
1963
- stopReason: ()=>'interrupted',
1964
- isFatalError: (run)=>[
1965
- ...run.beforeEach,
1966
- ...run.steps,
1967
- ...run.afterEach
1968
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
1969
- onStepStart: (info)=>taskProgress(`→ ${formatStep(info)}`),
1970
- onStepResult: (info, result)=>taskProgress(formatStepResult(info, result)),
1971
- onCaseResult: (attempt)=>taskProgress(`${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`)
1972
- });
1973
- } catch (error) {
1974
- executionError = error;
1975
- if (error instanceof WorkflowExecutionFailure) execution = error.result;
1976
- } finally{
1977
- const failed = void 0 !== executionError || !runtime.canRun || execution?.cases[0]?.status !== 'success' || execution?.document.status === 'failed';
1978
- lifecycle = await runtime.finish(failed ? 'failed' : 'success');
1979
- }
1980
- if (executionError) {
1981
- if (!execution) throw executionError;
1982
- options.onInfrastructureError(executionError);
1983
- }
1984
- if (!execution) return toTransportResult({
1985
- case: asNotRun(task.documentId, collectedCase, project.name, options.signal.aborted ? 'interrupted' : 'project-setup-failed'),
1986
- lifecycle
1987
- }, materializeReport, materializeSource);
1988
- const outcome = execution.cases[0];
1989
- if (!outcome) throw new Error(`Executor did not produce case result for ${task.caseId}.`);
1990
- return toTransportResult({
1991
- case: {
1992
- ...outcome,
1993
- documentId: task.documentId
1994
- },
1995
- document: execution.document,
1996
- lifecycle
1997
- }, materializeReport, materializeSource);
1998
- };
1999
- const runCaseTask = async (preparedTask, taskIndex, taskCount, options, onCompleted)=>{
2000
- const { prepared, task } = preparedTask;
2001
- const taskProgress = (message)=>options.progress(`[case ${taskIndex + 1}/${taskCount}] ${task.projectName} / ${task.sourcePath} / ${task.caseName}: ${message}`);
2002
- taskProgress('started');
2003
- const outputDir = join(options.runDir, task.projectId, 'executor-output', task.documentId, task.caseId);
2004
- mkdirSync(outputDir, {
2005
- recursive: true
2006
- });
2007
- const { materialize: materializeReport, materializeSource, resolve: resolveReportReference, resolveSource } = createExecutorReports(outputDir);
2008
- if (prepared.collectionErrors.length > 0) return {
2009
- case: asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'project-preflight-failed')
2010
- };
2011
- const executor = options.executor ?? localTestExecutor;
2012
- let result;
2013
- let executorFailure;
2014
- let executorAttempts = 0;
2015
- for(let attemptIndex = 0; attemptIndex <= options.test.executorRetry; attemptIndex += 1){
2016
- executorAttempts = attemptIndex + 1;
2017
- try {
2018
- result = await executor.execute(task, {
2019
- signal: options.signal,
2020
- onProgress: taskProgress,
2021
- outputDir,
2022
- materializeReport,
2023
- runLocal: ()=>runLocalCase(preparedTask, options, taskProgress, materializeReport, materializeSource)
2024
- });
2025
- executorFailure = void 0;
2026
- break;
2027
- } catch (error) {
2028
- if (!isTestExecutorError(error)) throw error;
2029
- executorFailure = error;
2030
- if (!error.retryable || attemptIndex >= options.test.executorRetry || options.signal.aborted) break;
2031
- taskProgress(`executor retry ${attemptIndex + 1}/${options.test.executorRetry}: ${error.message}`);
2032
- }
2033
- }
2034
- if (!result) {
2035
- if (!executorFailure) throw new Error(`Executor did not return a result for ${task.caseId}.`);
2036
- taskProgress(`executor-failed: ${executorFailure.message}`);
2037
- return {
2038
- case: {
2039
- ...asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'executor-failed'),
2040
- execution: {
2041
- executor: executor.name,
2042
- resources: task.resources,
2043
- attempts: executorAttempts,
2044
- failure: {
2045
- kind: executorFailure.kind,
2046
- message: executorFailure.message,
2047
- retryable: executorFailure.retryable
2048
- }
2049
- }
2050
- }
2051
- };
2052
- }
2053
- assertTestCaseTaskRunResult(task, result);
2054
- const internalResult = fromTransportResult(result, resolveReportReference, resolveSource);
2055
- const completed = {
2056
- ...internalResult,
2057
- case: {
2058
- ...internalResult.case,
2059
- execution: {
2060
- executor: executor.name,
2061
- resources: task.resources,
2062
- attempts: executorAttempts,
2063
- ...internalResult.lifecycle ? {
2064
- lifecycle: internalResult.lifecycle
2065
- } : {},
2066
- ...result.artifacts ? {
2067
- artifacts: result.artifacts
2068
- } : {},
2069
- ...result.metadata ? {
2070
- metadata: result.metadata
2071
- } : {}
2072
- }
2073
- }
2074
- };
2075
- onCompleted(completed);
2076
- for (const attempt of internalResult.case.attempts ?? [])await writeCaseAttemptResult(options.runDir, task.projectId, task.documentId, attempt);
2077
- if (internalResult.document) await writeWorkflowDocumentResult(options.runDir, internalResult.document);
2078
- taskProgress(internalResult.case.status);
2079
- return completed;
2080
- };
2081
- const runCaseExecution = async (options)=>{
2082
- const tasks = prepareCaseTasks(options.projects);
2083
- let failedCaseCount = 0;
2084
- const bailReached = ()=>options.test.bail > 0 && failedCaseCount >= options.test.bail;
2085
- const results = new Array(tasks.length);
2086
- if (tasks.length > 0) try {
2087
- await runTaskPool({
2088
- tasks: tasks.map((preparedTask)=>preparedTask.task),
2089
- maxConcurrency: Math.min(options.test.maxConcurrency, tasks.length),
2090
- signal: options.signal,
2091
- shouldStop: bailReached,
2092
- run: async (_task, taskIndex)=>{
2093
- try {
2094
- const result = await runCaseTask(tasks[taskIndex], taskIndex, tasks.length, options, (completed)=>{
2095
- results[taskIndex] = completed;
2096
- });
2097
- results[taskIndex] = result;
2098
- if ('failed' === result.case.status) failedCaseCount += 1;
2099
- return result;
2100
- } catch (error) {
2101
- options.onInfrastructureError(error);
2102
- throw error;
2103
- }
2104
- }
2105
- });
2106
- } catch (error) {
2107
- options.onInfrastructureError(error);
2108
- }
2109
- return options.projects.map((prepared, projectIndex)=>{
2110
- const projectTasks = tasks.map((task, taskIndex)=>({
2111
- task,
2112
- result: results[taskIndex]
2113
- })).filter(({ task })=>task.projectIndex === projectIndex);
2114
- const reason = prepared.collectionErrors.length ? 'project-preflight-failed' : options.signal.aborted ? 'interrupted' : bailReached() ? 'bail' : void 0;
2115
- const cases = projectTasks.map(({ task, result })=>{
2116
- if (result) return result.case;
2117
- if (!reason) throw new Error(`Case scheduler did not produce a result for "${task.task.caseId}".`);
2118
- return asNotRun(task.task.documentId, task.collectedCase, prepared.project.name, reason);
2119
- });
2120
- const documents = projectTasks.flatMap(({ result })=>result?.document ? [
2121
- result.document
2122
- ] : []);
2123
- return buildProjectResult(prepared, cases, documents);
2124
- });
1109
+ const formatStepResult = (info, result)=>{
1110
+ const indent = 'case' === info.scope ? ' ' : ' ';
1111
+ if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
1112
+ const symbol = 'success' === result.status ? '✓' : '✗';
1113
+ const error = result.error ? ` — ${result.error.message}` : '';
1114
+ const continuation = result.continuedAfterError ? '; continuing' : '';
1115
+ return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
2125
1116
  };
2126
1117
  const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
2127
1118
  ...attempt.beforeEach,
@@ -2168,7 +1159,7 @@ async function executeDocumentInvocation(options) {
2168
1159
  },
2169
1160
  onStepResult: async (info, result)=>{
2170
1161
  await invocation.bindings.onStepResult?.(info, result);
2171
- options.onProgress(formatStepResult(info, result, 'case' === info.scope ? ' ' : ' '));
1162
+ options.onProgress(formatStepResult(info, result));
2172
1163
  },
2173
1164
  onCaseResult: async (attempt)=>{
2174
1165
  await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
@@ -2286,6 +1277,16 @@ function createOperationJournal(runDir) {
2286
1277
  }
2287
1278
  };
2288
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
+ });
2289
1290
  const summarize = (projects)=>{
2290
1291
  const healthCases = projects.flatMap((project)=>{
2291
1292
  const documentAttemptByRunId = new Map(project.documents.map((document)=>[
@@ -2319,6 +1320,12 @@ const summarize = (projects)=>{
2319
1320
  projectFailures: projects.filter((project)=>'failed' === project.status).length
2320
1321
  };
2321
1322
  };
1323
+ const latestById = (items, id)=>[
1324
+ ...new Map(items.map((item)=>[
1325
+ id(item),
1326
+ item
1327
+ ])).values()
1328
+ ];
2322
1329
  const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
2323
1330
  async function runPreparedTestProject(plan) {
2324
1331
  const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
@@ -2331,7 +1338,7 @@ async function runPreparedTestProject(plan) {
2331
1338
  await writeTestRunPlan(runDir, preparedProjects);
2332
1339
  const journal = createOperationJournal(runDir);
2333
1340
  const rootController = new AbortController();
2334
- 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);
2335
1342
  const handleSignal = (signal)=>{
2336
1343
  rootController.abort(new Error(`Workflow interrupted by ${signal}.`));
2337
1344
  };
@@ -2373,6 +1380,31 @@ async function runPreparedTestProject(plan) {
2373
1380
  const { project } = prepared;
2374
1381
  runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
2375
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
+ };
2376
1408
  const buildSkippedProjectResult = (prepared, projectIndex, reason)=>{
2377
1409
  announceProject(prepared, projectIndex);
2378
1410
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
@@ -2473,7 +1505,7 @@ async function runPreparedTestProject(plan) {
2473
1505
  };
2474
1506
  const projectResults = new Array(preparedProjects.length);
2475
1507
  try {
2476
- if ('case' !== definition.test.executionUnit || 'run' === plan.preflightScope && totalErrors > 0) await runConcurrentJobs(preparedProjects, {
1508
+ await runConcurrentJobs(preparedProjects, {
2477
1509
  concurrency: effectiveConcurrency,
2478
1510
  shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
2479
1511
  }, async (prepared, index)=>{
@@ -2484,20 +1516,6 @@ async function runPreparedTestProject(plan) {
2484
1516
  throw error;
2485
1517
  }
2486
1518
  });
2487
- else {
2488
- const results = await runCaseExecution({
2489
- projects: preparedProjects,
2490
- test: definition.test,
2491
- executor: definition.executor,
2492
- runDir,
2493
- signal: rootController.signal,
2494
- progress,
2495
- onInfrastructureError: recordInfrastructureError
2496
- });
2497
- results.forEach((result, index)=>{
2498
- projectResults[index] = result;
2499
- });
2500
- }
2501
1519
  } catch (error) {
2502
1520
  recordInfrastructureError(error);
2503
1521
  } finally{
@@ -2540,6 +1558,7 @@ async function runPreparedTestProject(plan) {
2540
1558
  } : {}
2541
1559
  };
2542
1560
  let completedResult = result;
1561
+ let reportIndex;
2543
1562
  const publish = async (operation, path, callback)=>{
2544
1563
  try {
2545
1564
  await callback();
@@ -2570,6 +1589,7 @@ async function runPreparedTestProject(plan) {
2570
1589
  overwrite: false,
2571
1590
  sources: collectTestRunReportSources(completedResult),
2572
1591
  buildRunnerDump: (index)=>{
1592
+ reportIndex = index;
2573
1593
  const dump = buildTestRunReportDump(completedResult, index);
2574
1594
  const warn = getDebug('test-runner:report-assembler', {
2575
1595
  console: true
@@ -2587,6 +1607,13 @@ async function runPreparedTestProject(plan) {
2587
1607
  for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
2588
1608
  await publication.publish(completedResult);
2589
1609
  });
1610
+ await publish('write-result', join(dirname(summaryPath), 'result.md'), async ()=>{
1611
+ const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1612
+ completedResult = {
1613
+ ...completedResult,
1614
+ markdownPath
1615
+ };
1616
+ });
2590
1617
  await writeSummary();
2591
1618
  if (hasInfrastructureError) throw new WorkflowExecutionFailure(completedResult, infrastructureErrors);
2592
1619
  return completedResult;
@@ -2594,6 +1621,6 @@ async function runPreparedTestProject(plan) {
2594
1621
  async function runTestProject(options = {}) {
2595
1622
  return runPreparedTestProject(await prepareTestRun(options));
2596
1623
  }
2597
- 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 };
2598
1625
 
2599
1626
  //# sourceMappingURL=index.mjs.map