@midscene/test 1.15.3 → 1.15.4-beta-20261013083552.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,292 +1,467 @@
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";
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";
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";
6
5
  import { globSync } from "tinyglobby";
7
6
  import { createHash, randomUUID } from "node:crypto";
8
7
  import { JSON_SCHEMA, load } from "js-yaml";
9
8
  import { mkdir, writeFile } from "node:fs/promises";
9
+ import { pathToFileURL } from "node:url";
10
+ import { require as api_require } from "tsx/cjs/api";
10
11
  import { setMaxListeners } from "node:events";
11
12
  import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
12
13
  import { getDebug } from "@midscene/shared/logger";
13
- const registry_NodeRegistry = NodeRegistry;
14
- const projectIdFromIndex = (index)=>`project-${index}`;
15
- const defineTestProject = (definition)=>definition;
16
- const defineProjectSetup = (definition)=>definition;
17
- const getDefaultExport = (loaded, absolutePath)=>{
18
- if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
19
- const defaultExport = loaded.default;
20
- if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
21
- if ('default' in defaultExport) return defaultExport.default;
22
- throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
23
- }
24
- return defaultExport;
25
- }
26
- throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
27
- };
28
- const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
29
- const rejectUnknownKeys = (value, allowedKeys, label)=>{
30
- const allowed = new Set(allowedKeys);
31
- const unknown = Object.keys(value).find((key)=>!allowed.has(key));
32
- if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
33
- };
34
- const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
35
- const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
36
- if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
37
- if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
38
- return value.map((pattern, index)=>{
39
- if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
40
- if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
41
- if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
42
- if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
43
- if (pattern.startsWith('!')) {
44
- if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
45
- throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
46
- }
47
- return pattern;
48
- });
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
- } : {}
64
- });
65
- };
66
- const validateTagList = (value, field, label)=>{
67
- if (void 0 === value) return [];
68
- if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
69
- return value.map((tag, index)=>{
70
- if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
71
- return tag;
72
- });
73
- };
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
- }
104
- return value;
105
- };
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);
111
- };
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;
122
- };
123
- const validatePositiveInteger = (value, fallback, label)=>{
124
- if (void 0 === value) return fallback;
125
- if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
126
- return value;
127
- };
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;
132
- };
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
- };
155
- }
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, [
163
- 'name',
164
- 'setup',
165
- 'nodes',
166
- 'files',
167
- 'tags',
14
+ const isSafeResultPathSegment = (value)=>'string' == typeof value && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
15
+ const json = z.lazy(()=>z.union([
16
+ z["null"](),
17
+ z.string(),
18
+ z.number().finite(),
19
+ z.boolean(),
20
+ z.array(json),
21
+ z.record(z.string(), json)
22
+ ]));
23
+ const time = z.string().refine((value)=>Number.isFinite(Date.parse(value)));
24
+ const executor_runtime_schema_count = z.number().int().nonnegative();
25
+ const executor_runtime_schema_value = z.strictObject({
26
+ value: json,
27
+ truncatedPaths: z.array(z.string()).optional(),
28
+ redactedPaths: z.array(z.string()).optional()
29
+ });
30
+ const reportErrorSchema = z.lazy(()=>z.strictObject({
31
+ format: z.literal('midscene-error').optional(),
32
+ name: z.string(),
33
+ message: z.string(),
34
+ code: z.string().optional(),
35
+ details: executor_runtime_schema_value.optional(),
36
+ cause: reportErrorSchema.optional(),
37
+ errors: z.array(reportErrorSchema).optional(),
38
+ truncated: z.boolean().optional()
39
+ }));
40
+ const executor_runtime_schema_operation = z.strictObject({
41
+ operationId: z.string().min(1),
42
+ scopeId: z.string().optional(),
43
+ stepId: z.string().optional(),
44
+ node: z.string().optional()
45
+ });
46
+ const cleanupSchema = z.strictObject({
47
+ status: z["enum"]([
48
+ 'not-required',
49
+ 'completed',
50
+ 'pending',
51
+ 'failed'
52
+ ]),
53
+ capturedAt: time,
54
+ operationId: z.string().optional(),
55
+ blockingOperations: z.array(executor_runtime_schema_operation).optional(),
56
+ error: reportErrorSchema.optional()
57
+ });
58
+ const pendingOperationsSchema = z.array(executor_runtime_schema_operation);
59
+ const collectionStatus = z["enum"]([
60
+ 'complete',
61
+ 'partial',
62
+ 'unknown'
63
+ ]);
64
+ const metricsSchema = z.strictObject({
65
+ modelCallCount: executor_runtime_schema_count,
66
+ modelTimeMs: z.number().finite().nonnegative(),
67
+ promptTokens: executor_runtime_schema_count,
68
+ cachedInputTokens: executor_runtime_schema_count,
69
+ completionTokens: executor_runtime_schema_count,
70
+ totalTokens: executor_runtime_schema_count,
71
+ collection: z.strictObject({
72
+ status: collectionStatus,
73
+ expectedExecutionCount: executor_runtime_schema_count,
74
+ capturedExecutionCount: executor_runtime_schema_count,
75
+ reasons: z.array(z.string()),
76
+ fields: z.strictObject({
77
+ modelCallCount: collectionStatus,
78
+ modelTimeMs: collectionStatus,
79
+ totalTokens: collectionStatus
80
+ }).optional()
81
+ }).optional()
82
+ });
83
+ const operationsSchema = z.strictObject({
84
+ capturedAt: time,
85
+ events: z.array(z.strictObject({
86
+ operationId: z.string().min(1),
87
+ operationKind: z["enum"]([
88
+ 'node',
89
+ 'resource',
90
+ 'acquisition',
91
+ 'cleanup',
92
+ 'readiness'
93
+ ]),
94
+ kind: z["enum"]([
95
+ 'started',
96
+ 'cancel-requested',
97
+ 'settled',
98
+ 'runner-returned',
99
+ 'cleanup-deferred'
100
+ ]),
101
+ at: time,
102
+ stepId: z.string().optional(),
103
+ scopeId: z.string().optional(),
104
+ node: z.string().optional(),
105
+ status: z["enum"]([
106
+ 'success',
107
+ 'failed',
108
+ 'not-run'
109
+ ]).optional(),
110
+ pending: z.boolean().optional(),
111
+ error: reportErrorSchema.optional(),
112
+ blockingOperations: z.array(executor_runtime_schema_operation).optional()
113
+ }))
114
+ });
115
+ const runtimeResultFields = {
116
+ executionStatus: z["enum"]([
117
+ 'success',
118
+ 'failed',
119
+ 'not-run'
120
+ ]).optional(),
121
+ phases: z.partialRecord(z["enum"]([
122
+ 'beforeAll',
123
+ 'beforeEach',
124
+ 'steps',
125
+ 'afterEach',
126
+ 'afterAll'
127
+ ]), z.strictObject({
128
+ status: z["enum"]([
129
+ 'success',
130
+ 'failed',
131
+ 'not-run',
132
+ 'not-required'
133
+ ]),
134
+ plannedStepCount: executor_runtime_schema_count,
135
+ executedStepCount: executor_runtime_schema_count
136
+ })).optional(),
137
+ cleanup: cleanupSchema.optional(),
138
+ pendingOperations: pendingOperationsSchema.optional(),
139
+ retryDecision: z.strictObject({
140
+ scope: z["enum"]([
141
+ 'case',
142
+ 'document'
143
+ ]),
144
+ retryCount: executor_runtime_schema_count,
145
+ attemptIndex: executor_runtime_schema_count,
146
+ decision: z["enum"]([
168
147
  '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`)
193
- }
194
- });
195
- });
196
- return {
197
- projects: Object.freeze(projects),
198
- hasExplicitProjects: true
199
- };
200
- };
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')
208
- });
148
+ 'stop'
149
+ ]),
150
+ reason: z["enum"]([
151
+ 'passed',
152
+ 'attempt-failed',
153
+ 'budget-exhausted',
154
+ 'cleanup-pending',
155
+ 'cleanup-failed',
156
+ 'operation-pending',
157
+ 'fatal-error',
158
+ 'interrupted',
159
+ 'bail',
160
+ 'infrastructure-error'
161
+ ])
162
+ }).optional()
209
163
  };
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;
214
- };
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')
164
+ const plannedStep = z.strictObject({
165
+ id: z.string(),
166
+ phase: z["enum"]([
167
+ 'beforeAll',
168
+ 'beforeEach',
169
+ 'steps',
170
+ 'afterEach',
171
+ 'afterAll'
172
+ ]),
173
+ stepIndex: executor_runtime_schema_count,
174
+ node: z.string(),
175
+ title: z.string().optional(),
176
+ input: executor_runtime_schema_value,
177
+ inputMode: z["enum"]([
178
+ 'template',
179
+ 'resolved'
180
+ ]),
181
+ timeoutMs: z.number().positive().optional(),
182
+ continueOnError: z.boolean()
183
+ });
184
+ const documentPlanSchema = z.strictObject({
185
+ documentId: z.string(),
186
+ sourcePath: z.string(),
187
+ sourceYaml: executor_runtime_schema_value.optional(),
188
+ retry: z.strictObject({
189
+ scope: z["enum"]([
190
+ 'case',
191
+ 'document'
192
+ ]),
193
+ count: executor_runtime_schema_count
194
+ }),
195
+ beforeAll: z.array(plannedStep),
196
+ afterAll: z.array(plannedStep),
197
+ cases: z.array(z.strictObject({
198
+ caseId: z.string(),
199
+ name: z.string(),
200
+ caseIndex: executor_runtime_schema_count,
201
+ selected: z.boolean(),
202
+ tags: z.array(z.string()),
203
+ onFailure: z["enum"]([
204
+ 'continue',
205
+ 'stop-document'
206
+ ]).optional(),
207
+ beforeEach: z.array(plannedStep),
208
+ steps: z.array(plannedStep),
209
+ afterEach: z.array(plannedStep)
210
+ }))
211
+ });
212
+ function _define_property(obj, key, value) {
213
+ if (key in obj) Object.defineProperty(obj, key, {
214
+ value: value,
215
+ enumerable: true,
216
+ configurable: true,
217
+ writable: true
223
218
  });
224
- };
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
- };
249
- };
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.`);
219
+ else obj[key] = value;
220
+ return obj;
221
+ }
222
+ class TestExecutorError extends Error {
223
+ constructor(message, options = {}){
224
+ super(message, {
225
+ cause: options.cause
226
+ }), _define_property(this, "kind", void 0), _define_property(this, "retryable", void 0);
227
+ this.name = 'TestExecutorError';
228
+ this.kind = options.kind ?? 'unknown';
229
+ this.retryable = options.retryable ?? false;
254
230
  }
255
- return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
256
- };
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);
261
- };
262
- async function loadTestProject(configPath) {
263
- if (!configPath) return validateTestProjectDefinition({
264
- nodes: []
265
- });
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
- }
231
+ }
232
+ const defineTestExecutor = (executor)=>executor;
233
+ const localTestExecutor = Object.freeze({
234
+ name: 'local',
235
+ execute: (_task, context)=>context.runLocal()
236
+ });
237
+ const jsonValueSchema = z.lazy(()=>z.union([
238
+ z["null"](),
239
+ z.string(),
240
+ z.number().finite(),
241
+ z.boolean(),
242
+ z.array(jsonValueSchema),
243
+ z.record(z.string(), jsonValueSchema)
244
+ ]));
245
+ const timestampSchema = z.string().refine((value)=>Number.isFinite(Date.parse(value)), {
246
+ message: 'Expected an ISO-compatible timestamp.'
247
+ });
248
+ const errorSchema = z.strictObject({
249
+ format: z.literal('midscene-error').optional(),
250
+ cause: reportErrorSchema.optional(),
251
+ name: z.string().min(1),
252
+ message: z.string(),
253
+ code: z.string().min(1),
254
+ details: jsonValueSchema.optional()
255
+ });
256
+ const stepBaseSchema = z.strictObject({
257
+ phase: z["enum"]([
258
+ 'beforeAll',
259
+ 'beforeEach',
260
+ 'steps',
261
+ 'afterEach',
262
+ 'afterAll'
263
+ ]),
264
+ stepIndex: z.number().int().nonnegative(),
265
+ node: z.string().min(1),
266
+ input: jsonValueSchema,
267
+ meta: z.strictObject({
268
+ timeoutMs: z.number().positive().finite().optional(),
269
+ continueOnError: z.boolean()
270
+ }),
271
+ continuedAfterError: z.boolean(),
272
+ waitForPreviousOperationMs: z.number().nonnegative().finite().optional(),
273
+ operations: operationsSchema.optional(),
274
+ output: z.strictObject({
275
+ summary: z.string().optional(),
276
+ data: jsonValueSchema.optional()
277
+ }).optional(),
278
+ error: errorSchema.optional(),
279
+ report: z.strictObject({
280
+ traces: z.array(z.strictObject({
281
+ type: z.literal('midscene-execution'),
282
+ executionId: z.string().min(1),
283
+ metrics: metricsSchema.optional()
284
+ })),
285
+ collectionErrors: z.array(errorSchema).optional()
286
+ }).optional()
287
+ });
288
+ const stepSchema = z.discriminatedUnion('status', [
289
+ stepBaseSchema.extend({
290
+ status: z["enum"]([
291
+ 'success',
292
+ 'failed'
293
+ ]),
294
+ startedAt: timestampSchema,
295
+ endedAt: timestampSchema,
296
+ durationMs: z.number().nonnegative().finite()
297
+ }),
298
+ stepBaseSchema.extend({
299
+ status: z.literal('not-run'),
300
+ notRunReason: z.string().min(1),
301
+ blockedBy: z.string().optional()
302
+ })
303
+ ]);
304
+ const validatedStepSchema = stepSchema.superRefine((step, context)=>{
305
+ if ('success' === step.status && (step.error || step.continuedAfterError)) context.addIssue({
306
+ code: 'custom',
307
+ message: 'Successful steps cannot contain failure evidence.'
308
+ });
309
+ if (step.continuedAfterError && !step.meta.continueOnError) context.addIssue({
310
+ code: 'custom',
311
+ message: 'Continuation requires continueOnError.'
312
+ });
313
+ });
314
+ const attemptSchema = z.strictObject({
315
+ ...runtimeResultFields,
316
+ caseId: z.string().min(1),
317
+ runId: z.string().min(1),
318
+ projectName: z.string().min(1),
319
+ attemptIndex: z.number().int().nonnegative(),
320
+ name: z.string(),
321
+ sourcePath: z.string().min(1),
322
+ caseIndex: z.number().int().nonnegative(),
323
+ status: z["enum"]([
324
+ 'success',
325
+ 'failed'
326
+ ]),
327
+ beforeEach: z.array(validatedStepSchema),
328
+ steps: z.array(validatedStepSchema),
329
+ afterEach: z.array(validatedStepSchema),
330
+ executionErrors: z.array(errorSchema).optional(),
331
+ teardownErrors: z.array(errorSchema).optional(),
332
+ reportRefs: z.array(z.string().min(1)).optional(),
333
+ startedAt: timestampSchema,
334
+ endedAt: timestampSchema,
335
+ durationMs: z.number().nonnegative().finite()
336
+ });
337
+ const notRunReasonSchema = z["enum"]([
338
+ 'document-start-failed',
339
+ 'project-preflight-failed',
340
+ 'project-setup-failed',
341
+ 'interrupted',
342
+ 'bail',
343
+ 'fatal-error',
344
+ 'executor-failed',
345
+ 'cleanup-pending',
346
+ 'cleanup-failed',
347
+ 'operation-pending'
348
+ ]);
349
+ const resultSchema = z.strictObject({
350
+ case: z.strictObject({
351
+ documentId: z.string().min(1),
352
+ caseId: z.string().min(1),
353
+ projectName: z.string().min(1),
354
+ name: z.string(),
355
+ sourcePath: z.string().min(1),
356
+ caseIndex: z.number().int().nonnegative(),
357
+ status: z["enum"]([
358
+ 'success',
359
+ 'failed',
360
+ 'not-run'
361
+ ]),
362
+ run: attemptSchema.optional(),
363
+ attempts: z.array(attemptSchema).optional(),
364
+ notRunReason: notRunReasonSchema.optional()
365
+ }),
366
+ document: z.strictObject({
367
+ ...runtimeResultFields,
368
+ plan: documentPlanSchema.optional(),
369
+ attemptIndex: z.number().int().nonnegative().optional(),
370
+ hostErrors: z.array(z.strictObject({
371
+ phase: z["enum"]([
372
+ 'setup',
373
+ 'execution',
374
+ 'cleanup',
375
+ 'report',
376
+ 'publication',
377
+ 'observer'
378
+ ]),
379
+ error: reportErrorSchema
380
+ })).optional(),
381
+ documentId: z.string().min(1),
382
+ documentRunId: z.string().min(1),
383
+ projectId: z.string().min(1),
384
+ projectName: z.string().min(1),
385
+ sourcePath: z.string().min(1),
386
+ status: z["enum"]([
387
+ 'success',
388
+ 'failed'
389
+ ]),
390
+ startedAt: timestampSchema,
391
+ endedAt: timestampSchema,
392
+ durationMs: z.number().nonnegative().finite(),
393
+ beforeAll: z.array(validatedStepSchema),
394
+ afterAll: z.array(validatedStepSchema),
395
+ executionErrors: z.array(errorSchema).optional(),
396
+ teardownErrors: z.array(errorSchema).optional(),
397
+ reportRefs: z.array(z.string().min(1)).optional()
398
+ }).optional(),
399
+ lifecycle: z.strictObject({
400
+ cleanup: cleanupSchema.optional(),
401
+ pendingOperations: pendingOperationsSchema.optional(),
402
+ projectName: z.string().min(1),
403
+ status: z["enum"]([
404
+ 'success',
405
+ 'failed'
406
+ ]),
407
+ setupError: errorSchema.optional(),
408
+ teardownErrors: z.array(errorSchema).optional(),
409
+ startedAt: timestampSchema,
410
+ endedAt: timestampSchema,
411
+ durationMs: z.number().nonnegative().finite()
412
+ }).optional(),
413
+ artifacts: z.array(z.strictObject({
414
+ name: z.string().min(1),
415
+ uri: z.string().min(1),
416
+ mediaType: z.string().min(1).optional()
417
+ })).optional(),
418
+ metadata: z.record(z.string(), z.union([
419
+ z.string(),
420
+ z.number().finite(),
421
+ z.boolean(),
422
+ z["null"]()
423
+ ])).optional()
424
+ });
425
+ 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);
426
+ const hasUnfinishedWork = (result)=>void 0 !== result.executionStatus && 'success' !== result.executionStatus || void 0 !== result.cleanup && ![
427
+ 'completed',
428
+ 'not-required'
429
+ ].includes(result.cleanup.status) || (result.pendingOperations?.length ?? 0) > 0;
430
+ function assertTestCaseTaskRunResult(task, value) {
431
+ const parsed = resultSchema.safeParse(value);
432
+ if (!parsed.success) throw new TypeError(`Executor returned invalid transport data for ${task.caseId}: ${z.prettifyError(parsed.error)}`);
433
+ const outcome = parsed.data.case;
434
+ 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}.`);
435
+ const attempts = outcome.attempts ?? [];
436
+ for (const attempt of attempts){
437
+ const steps = [
438
+ ...attempt.beforeEach,
439
+ ...attempt.steps,
440
+ ...attempt.afterEach
441
+ ];
442
+ if ('success' === attempt.status && (0 === attempt.steps.length || (attempt.executionErrors?.length ?? 0) > 0 || steps.some((step)=>'success' !== step.status) || hasUnfinishedWork(attempt) || (attempt.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful attempt for ${task.caseId}.`);
288
443
  }
289
- return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
444
+ if (new Set(attempts.map((attempt)=>attempt.runId)).size !== attempts.length) throw new TypeError(`Executor returned duplicate attempt IDs for ${task.caseId}.`);
445
+ if (attempts.some((attempt)=>!attemptMatchesTask(task, attempt))) throw new TypeError(`Executor returned invalid attempts for ${task.caseId}.`);
446
+ 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}.`);
447
+ const finalAttempt = attempts.at(-1);
448
+ if ('not-run' === outcome.status) {
449
+ if (outcome.run || attempts.length || !outcome.notRunReason) throw new TypeError(`Executor returned an inconsistent not-run result for ${task.caseId}.`);
450
+ } 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}.`);
451
+ const reportRefs = [
452
+ ...attempts.flatMap((attempt)=>attempt.reportRefs ?? []),
453
+ ...parsed.data.document?.reportRefs ?? []
454
+ ];
455
+ if (reportRefs.some((reference)=>!isSafeResultPathSegment(reference))) throw new TypeError(`Executor returned an invalid report reference for ${task.caseId}.`);
456
+ const document = parsed.data.document;
457
+ if (document?.status === 'success' && (hasUnfinishedWork(document) || (document.hostErrors?.length ?? 0) > 0 || (document.executionErrors?.length ?? 0) > 0 || [
458
+ ...document.beforeAll,
459
+ ...document.afterAll
460
+ ].some((step)=>'success' !== step.status) || (document.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful document for ${task.caseId}.`);
461
+ const lifecycle = parsed.data.lifecycle;
462
+ if (lifecycle?.status === 'success' && (hasUnfinishedWork(lifecycle) || lifecycle.setupError || (lifecycle.teardownErrors?.length ?? 0) > 0)) throw new TypeError(`Executor returned an inconsistent successful lifecycle for ${task.caseId}.`);
463
+ 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}.`);
464
+ if (parsed.data.lifecycle && parsed.data.lifecycle.projectName !== task.projectName) throw new Error(`Executor returned a lifecycle that does not match task ${task.caseId}.`);
290
465
  }
291
466
  const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
292
467
  projectId,
@@ -410,7 +585,7 @@ const resolveWorkflowVariables = (value, options)=>{
410
585
  ]));
411
586
  return value;
412
587
  };
413
- function _define_property(obj, key, value) {
588
+ function collect_define_property(obj, key, value) {
414
589
  if (key in obj) Object.defineProperty(obj, key, {
415
590
  value: value,
416
591
  enumerable: true,
@@ -427,11 +602,11 @@ class NativeWorkflowFormatError extends WorkflowParseError {
427
602
  sourcePath,
428
603
  actualFormat,
429
604
  expectedFormat: 'native'
430
- }), _define_property(this, "actualFormat", void 0);
605
+ }), collect_define_property(this, "actualFormat", void 0);
431
606
  this.actualFormat = actualFormat;
432
607
  }
433
608
  }
434
- const collect_rejectUnknownKeys = (value, allowed, label)=>{
609
+ const rejectUnknownKeys = (value, allowed, label)=>{
435
610
  const allowedSet = new Set(allowed);
436
611
  const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
437
612
  if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
@@ -453,7 +628,7 @@ function collectWorkflowDocument(source, options) {
453
628
  if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
454
629
  const format = classifyWorkflowDocumentFormat(parsed);
455
630
  if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
456
- collect_rejectUnknownKeys(parsed, [
631
+ rejectUnknownKeys(parsed, [
457
632
  'beforeAll',
458
633
  'beforeEach',
459
634
  'cases',
@@ -506,11 +681,16 @@ function collectWorkflowDocument(source, options) {
506
681
  if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
507
682
  caseIndex
508
683
  });
509
- collect_rejectUnknownKeys(definition, [
684
+ rejectUnknownKeys(definition, [
685
+ 'id',
510
686
  'name',
511
687
  'tags',
688
+ 'resources',
512
689
  'steps'
513
690
  ], `Case ${caseIndex + 1}`);
691
+ 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.`, {
692
+ caseIndex
693
+ });
514
694
  if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
515
695
  caseIndex
516
696
  });
@@ -521,6 +701,13 @@ function collectWorkflowDocument(source, options) {
521
701
  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
702
  caseIndex
523
703
  });
704
+ const resources = definition.resources ?? [];
705
+ 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.`, {
706
+ caseIndex
707
+ });
708
+ if (new Set(resources).size !== resources.length) throw new WorkflowParseError(`Case ${caseIndex + 1} resources must not contain duplicates.`, {
709
+ caseIndex
710
+ });
524
711
  const steps = normalize_normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
525
712
  if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
526
713
  caseIndex,
@@ -529,7 +716,7 @@ function collectWorkflowDocument(source, options) {
529
716
  });
530
717
  return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
531
718
  });
532
- const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
719
+ const caseId = 'string' == typeof definition.id ? definition.id : createCaseId(source.projectId, sourcePath, caseIndex);
533
720
  if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
534
721
  caseId
535
722
  });
@@ -540,8 +727,14 @@ function collectWorkflowDocument(source, options) {
540
727
  sourcePath,
541
728
  caseIndex,
542
729
  definition: {
730
+ ...'string' == typeof definition.id ? {
731
+ id: definition.id
732
+ } : {},
543
733
  name: definition.name,
544
734
  tags: tags,
735
+ ...resources.length > 0 ? {
736
+ resources: resources
737
+ } : {},
545
738
  steps
546
739
  }
547
740
  };
@@ -681,6 +874,38 @@ const writeTestProjectRunResult = (options)=>{
681
874
  ...outcome.notRunReason ? {
682
875
  notRunReason: outcome.notRunReason
683
876
  } : {},
877
+ ...outcome.execution ? {
878
+ execution: {
879
+ executor: outcome.execution.executor,
880
+ resources: outcome.execution.resources,
881
+ ...void 0 === outcome.execution.attempts ? {} : {
882
+ attempts: outcome.execution.attempts
883
+ },
884
+ ...outcome.execution.failure ? {
885
+ failure: outcome.execution.failure
886
+ } : {},
887
+ ...outcome.execution.artifacts ? {
888
+ artifacts: outcome.execution.artifacts
889
+ } : {},
890
+ ...outcome.execution.metadata ? {
891
+ metadata: outcome.execution.metadata
892
+ } : {},
893
+ ...outcome.execution.lifecycle ? {
894
+ lifecycle: {
895
+ status: outcome.execution.lifecycle.status,
896
+ startedAt: outcome.execution.lifecycle.startedAt,
897
+ endedAt: outcome.execution.lifecycle.endedAt,
898
+ durationMs: outcome.execution.lifecycle.durationMs,
899
+ ...outcome.execution.lifecycle.setupError ? {
900
+ setupError: errorJson(outcome.execution.lifecycle.setupError)
901
+ } : {},
902
+ ...outcome.execution.lifecycle.teardownErrors ? {
903
+ teardownErrors: outcome.execution.lifecycle.teardownErrors.map(errorJson)
904
+ } : {}
905
+ }
906
+ } : {}
907
+ }
908
+ } : {},
684
909
  ...outcome.attempts ? {
685
910
  attempts: outcome.attempts.map((attempt)=>({
686
911
  attemptId: attempt.runId,
@@ -737,6 +962,306 @@ const writeTestProjectRunResult = (options)=>{
737
962
  }))
738
963
  });
739
964
  };
965
+ const registry_NodeRegistry = NodeRegistry;
966
+ const projectIdFromIndex = (index)=>`project-${index}`;
967
+ const defineTestProject = (definition)=>definition;
968
+ const defineProjectSetup = (definition)=>definition;
969
+ const getDefaultExport = (loaded, absolutePath)=>{
970
+ if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
971
+ const defaultExport = loaded.default;
972
+ if ('object' == typeof defaultExport && null !== defaultExport && '__esModule' in defaultExport) {
973
+ if ('default' in defaultExport) return defaultExport.default;
974
+ throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
975
+ }
976
+ return defaultExport;
977
+ }
978
+ throw new TypeError(`Midscene config "${absolutePath}" must have a default export.`);
979
+ };
980
+ const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
981
+ const test_project_rejectUnknownKeys = (value, allowedKeys, label)=>{
982
+ const allowed = new Set(allowedKeys);
983
+ const unknown = Object.keys(value).find((key)=>!allowed.has(key));
984
+ if (unknown) throw new TypeError(`Midscene config ${label}.${unknown} is not supported.`);
985
+ };
986
+ const isAbsolutePattern = (pattern)=>isAbsolute(pattern) || /^[A-Za-z]:[\\/]/.test(pattern) || pattern.startsWith('\\\\');
987
+ const validatePatterns = (value, field, label = 'files', allowEmptyInclude = false)=>{
988
+ if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
989
+ if ('include' === field && 0 === value.length && !allowEmptyInclude) throw new TypeError(`Midscene config ${label}.include must be a non-empty array.`);
990
+ return value.map((pattern, index)=>{
991
+ if ('string' != typeof pattern || 0 === pattern.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
992
+ if (isAbsolutePattern(pattern)) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be relative to the project root.`);
993
+ if (pattern.split(/[\\/]/).includes('..')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must not contain a ".." path segment.`);
994
+ if (pattern.includes('\\')) throw new TypeError(`Midscene config ${label}.${field}[${index}] must use POSIX path separators (/).`);
995
+ if (pattern.startsWith('!')) {
996
+ if ('include' === field) throw new TypeError(`Midscene config ${label}.include[${index}] must not be a negated pattern. Use files.exclude instead.`);
997
+ throw new TypeError(`Midscene config ${label}.exclude[${index}] must not be a negated pattern.`);
998
+ }
999
+ return pattern;
1000
+ });
1001
+ };
1002
+ const validateTestFileSelection = (value, label = 'files')=>{
1003
+ if (void 0 === value) return;
1004
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
1005
+ test_project_rejectUnknownKeys(value, [
1006
+ 'include',
1007
+ 'exclude'
1008
+ ], label);
1009
+ const include = validatePatterns(value.include, 'include', label);
1010
+ const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
1011
+ return Object.freeze({
1012
+ include: Object.freeze(include),
1013
+ ...exclude ? {
1014
+ exclude: Object.freeze(exclude)
1015
+ } : {}
1016
+ });
1017
+ };
1018
+ const validateTagList = (value, field, label)=>{
1019
+ if (void 0 === value) return [];
1020
+ if (!Array.isArray(value)) throw new TypeError(`Midscene config ${label}.${field} must be an array.`);
1021
+ return value.map((tag, index)=>{
1022
+ if ('string' != typeof tag || 0 === tag.trim().length) throw new TypeError(`Midscene config ${label}.${field}[${index}] must be a non-empty string.`);
1023
+ return tag;
1024
+ });
1025
+ };
1026
+ const validateTagSelection = (value, label)=>{
1027
+ if (void 0 === value) return Object.freeze({
1028
+ include: [],
1029
+ exclude: []
1030
+ });
1031
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
1032
+ return Object.freeze({
1033
+ include: Object.freeze(validateTagList(value.include, 'include', label)),
1034
+ exclude: Object.freeze(validateTagList(value.exclude, 'exclude', label))
1035
+ });
1036
+ };
1037
+ function assertJsonValue(value, path, seen) {
1038
+ if (null === value || 'string' == typeof value || 'boolean' == typeof value) return;
1039
+ if ('number' == typeof value) {
1040
+ if (Number.isFinite(value)) return;
1041
+ throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
1042
+ }
1043
+ if ('object' != typeof value || void 0 === value) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
1044
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && null !== Object.getPrototypeOf(value)) throw new TypeError(`Midscene config ${path} must be JSON-compatible.`);
1045
+ if (seen.has(value)) throw new TypeError(`Midscene config ${path} must not contain cycles.`);
1046
+ seen.add(value);
1047
+ if (Array.isArray(value)) value.forEach((item, index)=>assertJsonValue(item, `${path}[${index}]`, seen));
1048
+ else for (const [key, child] of Object.entries(value))assertJsonValue(child, `${path}.${key}`, seen);
1049
+ seen.delete(value);
1050
+ }
1051
+ const deepFreezeJson = (value)=>{
1052
+ if ('object' == typeof value && null !== value) {
1053
+ for (const child of Object.values(value))deepFreezeJson(child);
1054
+ Object.freeze(value);
1055
+ }
1056
+ return value;
1057
+ };
1058
+ const validateVariables = (value, label)=>{
1059
+ if (void 0 === value) return Object.freeze({});
1060
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
1061
+ assertJsonValue(value, label, new Set());
1062
+ return deepFreezeJson(value);
1063
+ };
1064
+ const validateSetup = (value, label)=>{
1065
+ if (void 0 === value) return;
1066
+ if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
1067
+ test_project_rejectUnknownKeys(value, [
1068
+ 'name',
1069
+ 'setup'
1070
+ ], label);
1071
+ if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
1072
+ if ('function' != typeof value.setup) throw new TypeError(`Midscene config ${label}.setup must be a function.`);
1073
+ return value;
1074
+ };
1075
+ const validatePositiveInteger = (value, fallback, label)=>{
1076
+ if (void 0 === value) return fallback;
1077
+ if (!Number.isInteger(value) || value <= 0) throw new TypeError(`Midscene config ${label} must be a positive integer.`);
1078
+ return value;
1079
+ };
1080
+ const validateNonNegativeInteger = (value, fallback, label)=>{
1081
+ if (void 0 === value) return fallback;
1082
+ if (!Number.isInteger(value) || value < 0) throw new TypeError(`Midscene config ${label} must be a non-negative integer.`);
1083
+ return value;
1084
+ };
1085
+ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
1086
+ if (void 0 === value) {
1087
+ const setup = validateSetup(defaultSetup, 'setup');
1088
+ return {
1089
+ hasExplicitProjects: false,
1090
+ projects: Object.freeze([
1091
+ Object.freeze({
1092
+ projectId: projectIdFromIndex(0),
1093
+ name: 'default',
1094
+ ...setup ? {
1095
+ setup
1096
+ } : {},
1097
+ tags: Object.freeze({
1098
+ include: [],
1099
+ exclude: []
1100
+ }),
1101
+ retry: 0,
1102
+ variables: Object.freeze({}),
1103
+ nodes: new registry_NodeRegistry(globalNodes.definitions())
1104
+ })
1105
+ ])
1106
+ };
1107
+ }
1108
+ if (void 0 !== defaultSetup) throw new TypeError('Midscene config setup cannot be used together with projects. Move setup to projects[].setup.');
1109
+ if (!Array.isArray(value) || 0 === value.length) throw new TypeError('Midscene config projects must be a non-empty array when declared.');
1110
+ const names = new Set();
1111
+ const projects = value.map((candidate, index)=>{
1112
+ const label = `projects[${index}]`;
1113
+ if (!isRecord(candidate)) throw new TypeError(`Midscene config ${label} must be an object.`);
1114
+ test_project_rejectUnknownKeys(candidate, [
1115
+ 'name',
1116
+ 'setup',
1117
+ 'nodes',
1118
+ 'files',
1119
+ 'tags',
1120
+ 'retry',
1121
+ 'variables'
1122
+ ], label);
1123
+ if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
1124
+ if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
1125
+ names.add(candidate.name);
1126
+ const files = validateTestFileSelection(candidate.files, `${label}.files`);
1127
+ if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
1128
+ const localNodes = new registry_NodeRegistry(candidate.nodes);
1129
+ const nodes = new registry_NodeRegistry([
1130
+ ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
1131
+ ...localNodes.definitions()
1132
+ ]);
1133
+ return Object.freeze({
1134
+ projectId: projectIdFromIndex(index),
1135
+ name: candidate.name,
1136
+ nodes,
1137
+ ...files ? {
1138
+ files
1139
+ } : {},
1140
+ tags: validateTagSelection(candidate.tags, `${label}.tags`),
1141
+ retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
1142
+ variables: validateVariables(candidate.variables, `${label}.variables`),
1143
+ ...void 0 === candidate.setup ? {} : {
1144
+ setup: validateSetup(candidate.setup, `${label}.setup`)
1145
+ }
1146
+ });
1147
+ });
1148
+ return {
1149
+ projects: Object.freeze(projects),
1150
+ hasExplicitProjects: true
1151
+ };
1152
+ };
1153
+ const validateTestOptions = (value)=>{
1154
+ if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config test must be an object.');
1155
+ const candidate = value ?? {};
1156
+ const executionUnit = candidate.executionUnit ?? 'project';
1157
+ if ('project' !== executionUnit && 'case' !== executionUnit) throw new TypeError('Midscene config test.executionUnit must be "project" or "case".');
1158
+ return Object.freeze({
1159
+ maxConcurrency: validatePositiveInteger(candidate.maxConcurrency, 1, 'test.maxConcurrency'),
1160
+ bail: validateNonNegativeInteger(candidate.bail, 0, 'test.bail'),
1161
+ testTimeout: validatePositiveInteger(candidate.testTimeout, 120000, 'test.testTimeout'),
1162
+ executionUnit,
1163
+ executorRetry: validateNonNegativeInteger(candidate.executorRetry, 0, 'test.executorRetry')
1164
+ });
1165
+ };
1166
+ const validateTestExecutor = (value)=>{
1167
+ if (void 0 === value) return;
1168
+ if (!isRecord(value)) throw new TypeError('Midscene config executor must be an object.');
1169
+ test_project_rejectUnknownKeys(value, [
1170
+ 'name',
1171
+ 'execute'
1172
+ ], 'executor');
1173
+ if ('string' != typeof value.name || 0 === value.name.trim().length) throw new TypeError('Midscene config executor.name must be non-empty.');
1174
+ if ('function' != typeof value.execute) throw new TypeError('Midscene config executor.execute must be a function.');
1175
+ return Object.freeze(value);
1176
+ };
1177
+ const validateOutputPath = (value, fallback, label)=>{
1178
+ if (void 0 === value) return fallback;
1179
+ if ('string' != typeof value || 0 === value.trim().length) throw new TypeError(`Midscene config ${label} must be non-empty.`);
1180
+ return value;
1181
+ };
1182
+ const validateOutput = (value)=>{
1183
+ if (void 0 !== value && !isRecord(value)) throw new TypeError('Midscene config output must be an object.');
1184
+ const candidate = value ?? {};
1185
+ test_project_rejectUnknownKeys(candidate, [
1186
+ 'reportDir'
1187
+ ], 'output');
1188
+ return Object.freeze({
1189
+ reportDir: validateOutputPath(candidate.reportDir, './midscene_run/report', 'output.reportDir')
1190
+ });
1191
+ };
1192
+ const validateTestProjectDefinition = (definition)=>{
1193
+ if (!isRecord(definition)) throw new TypeError('Midscene config must default export an object.');
1194
+ if (void 0 !== definition.nodes && !Array.isArray(definition.nodes)) throw new TypeError('Midscene config nodes must be an array.');
1195
+ if ('setupWorkflow' in definition) throw new TypeError('Midscene config setupWorkflow is no longer supported. Use setup or projects[].setup instead.');
1196
+ 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.');
1197
+ if ('root' in definition) throw new TypeError('Midscene config root is not supported. Pass the Test Project directory to midscene-test instead.');
1198
+ if ('files' in definition) throw new TypeError('Midscene config files is not supported at the root. Move it to projects[].files.');
1199
+ if ('testRunner' in definition) throw new TypeError('Midscene config testRunner is not supported. Rename it to test.');
1200
+ test_project_rejectUnknownKeys(definition, [
1201
+ 'setup',
1202
+ 'projects',
1203
+ 'test',
1204
+ 'output',
1205
+ 'nodes',
1206
+ 'executor'
1207
+ ], 'root');
1208
+ const nodes = new registry_NodeRegistry(definition.nodes);
1209
+ const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
1210
+ const test = validateTestOptions(definition.test);
1211
+ const executor = validateTestExecutor(definition.executor);
1212
+ if (executor && 'case' !== test.executionUnit) throw new TypeError('Midscene config executor requires test.executionUnit to be "case".');
1213
+ return {
1214
+ ...resolvedProjects,
1215
+ test,
1216
+ output: validateOutput(definition.output),
1217
+ nodes,
1218
+ ...executor ? {
1219
+ executor
1220
+ } : {},
1221
+ resolveNode: (name)=>nodes.get(name)
1222
+ };
1223
+ };
1224
+ const assertSupportedConfig = (absolutePath)=>{
1225
+ if (!absolutePath.endsWith('.ts') && !absolutePath.endsWith('.mjs')) {
1226
+ const extension = absolutePath.match(/(\.[^./\\]+)$/)?.[1] ?? '(none)';
1227
+ throw new TypeError(`Unsupported Midscene config extension: ${extension}. Supported extensions: .ts, .mjs.`);
1228
+ }
1229
+ return absolutePath.endsWith('.mjs') ? '.mjs' : '.ts';
1230
+ };
1231
+ const canRetryWithCjsLoader = (error)=>{
1232
+ if (!(error instanceof Error)) return false;
1233
+ const errorCode = error.code;
1234
+ 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);
1235
+ };
1236
+ async function loadTestProject(configPath) {
1237
+ if (!configPath) return validateTestProjectDefinition({
1238
+ nodes: []
1239
+ });
1240
+ const absolutePath = external_node_path_resolve(configPath);
1241
+ const extension = assertSupportedConfig(absolutePath);
1242
+ let loaded;
1243
+ try {
1244
+ if ('.mjs' === extension) loaded = await import(pathToFileURL(absolutePath).href);
1245
+ else {
1246
+ const { tsImport } = await import("tsx/esm/api");
1247
+ loaded = await tsImport(pathToFileURL(absolutePath).href, {
1248
+ parentURL: pathToFileURL(`${dirname(absolutePath)}${sep}`).href,
1249
+ tsconfig: false
1250
+ });
1251
+ }
1252
+ } catch (error) {
1253
+ try {
1254
+ if ('.mjs' === extension || !canRetryWithCjsLoader(error)) throw error;
1255
+ loaded = api_require(absolutePath, pathToFileURL(absolutePath));
1256
+ } catch (fallbackError) {
1257
+ const message = fallbackError instanceof Error ? `: ${fallbackError.message}` : '';
1258
+ throw new Error(`Failed to load Midscene config "${absolutePath}"${message}`, {
1259
+ cause: fallbackError
1260
+ });
1261
+ }
1262
+ }
1263
+ return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
1264
+ }
740
1265
  const CONFIG_NAMES = [
741
1266
  'midscene.config.ts',
742
1267
  'midscene.config.mjs'
@@ -758,8 +1283,29 @@ const DEFAULT_TEST_FILE_SELECTION = {
758
1283
  ]
759
1284
  };
760
1285
  const project_preparation_toPosix = (value)=>value.split(sep).join('/');
1286
+ const discoverSelectedTestFiles = (projectRoot, paths)=>{
1287
+ const root = external_node_path_resolve(projectRoot);
1288
+ const files = new Set();
1289
+ for (const requestedPath of paths){
1290
+ if ('string' != typeof requestedPath || 0 === requestedPath.trim().length) throw new TypeError('Test selection paths must be non-empty strings.');
1291
+ const absolutePath = external_node_path_resolve(root, requestedPath);
1292
+ const relativePath = relative(root, absolutePath);
1293
+ if ('..' === relativePath || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) throw new Error(`Test selection path is outside the project: ${requestedPath}`);
1294
+ if (!existsSync(absolutePath)) throw new Error(`Test selection path does not exist: ${requestedPath}`);
1295
+ if (statSync(absolutePath).isDirectory()) for (const file of discoverTestFiles(absolutePath))files.add(file);
1296
+ else if (/\.ya?ml$/i.test(absolutePath)) files.add(absolutePath);
1297
+ else throw new Error(`Test selection file must be YAML: ${requestedPath}`);
1298
+ }
1299
+ return [
1300
+ ...files
1301
+ ].sort((a, b)=>{
1302
+ const relativeA = project_preparation_toPosix(relative(root, a));
1303
+ const relativeB = project_preparation_toPosix(relative(root, b));
1304
+ return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
1305
+ });
1306
+ };
761
1307
  const discoverResolvedTestFiles = (projectRoot, selection)=>{
762
- const root = resolve(projectRoot);
1308
+ const root = external_node_path_resolve(projectRoot);
763
1309
  const match = (patterns)=>globSync(patterns, {
764
1310
  absolute: true,
765
1311
  caseSensitiveMatch: false,
@@ -775,7 +1321,7 @@ const discoverResolvedTestFiles = (projectRoot, selection)=>{
775
1321
  }).filter((file)=>/\.ya?ml$/i.test(file));
776
1322
  const files = match(selection.include);
777
1323
  return [
778
- ...new Set(files.map((file)=>resolve(file)))
1324
+ ...new Set(files.map((file)=>external_node_path_resolve(file)))
779
1325
  ].sort((a, b)=>{
780
1326
  const relativeA = project_preparation_toPosix(relative(root, a));
781
1327
  const relativeB = project_preparation_toPosix(relative(root, b));
@@ -788,7 +1334,7 @@ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)
788
1334
  return discoverResolvedTestFiles(projectRoot, normalized);
789
1335
  };
790
1336
  const discoverTestConfig = (projectRoot)=>{
791
- const root = resolve(projectRoot);
1337
+ const root = external_node_path_resolve(projectRoot);
792
1338
  const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
793
1339
  const supported = candidates.filter((name)=>CONFIG_NAMES.includes(name));
794
1340
  const unsupported = candidates.filter((name)=>!CONFIG_NAMES.includes(name));
@@ -817,8 +1363,14 @@ const matchesTags = (tags, selection)=>{
817
1363
  if (selection.exclude.some((tag)=>tags.includes(tag))) return false;
818
1364
  return 0 === selection.include.length || selection.include.some((tag)=>tags.includes(tag));
819
1365
  };
820
- const filterDocumentCases = (document, project)=>{
821
- const cases = document.cases.filter((item)=>matchesTags(item.definition.tags ?? [], project.tags));
1366
+ const filterDocumentCases = (document, project, selection)=>{
1367
+ const cases = document.cases.filter((item)=>{
1368
+ const tags = item.definition.tags ?? [];
1369
+ return matchesTags(tags, project.tags) && matchesTags(tags, {
1370
+ include: selection?.tags?.include ?? [],
1371
+ exclude: selection?.tags?.exclude ?? []
1372
+ }) && (!selection?.caseIds?.length || selection.caseIds.includes(item.caseId));
1373
+ });
822
1374
  const filtered = document.cases.length - cases.length;
823
1375
  return 0 === cases.length ? {
824
1376
  filtered
@@ -842,8 +1394,10 @@ const selectProjects = (projects, names)=>{
842
1394
  async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
843
1395
  const platform = options.platform?.trim();
844
1396
  const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
845
- const prerequisiteFile = options.prerequisiteFile ? resolve(options.prerequisiteFile) : void 0;
846
- const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
1397
+ const prerequisiteFile = options.prerequisiteFile ? external_node_path_resolve(options.prerequisiteFile) : void 0;
1398
+ const projectFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
1399
+ const selectedFiles = options.selection?.paths?.length ? new Set(discoverSelectedTestFiles(projectRoot, options.selection.paths)) : void 0;
1400
+ const mainFiles = selectedFiles ? projectFiles.filter((file)=>selectedFiles.has(file)) : projectFiles;
847
1401
  const files = [
848
1402
  ...prerequisiteFile ? [
849
1403
  prerequisiteFile
@@ -866,6 +1420,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
866
1420
  const invocations = [];
867
1421
  const plans = [];
868
1422
  let filteredCaseCount = 0;
1423
+ const availableCaseIds = new Set();
869
1424
  if (0 === sources.length) {
870
1425
  const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
871
1426
  projectName: project.name,
@@ -896,10 +1451,16 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
896
1451
  variables: project.variables,
897
1452
  env: process.env
898
1453
  });
1454
+ const duplicate = document.cases.find((item)=>availableCaseIds.has(item.caseId));
1455
+ if (duplicate) throw new WorkflowParseError(`Case id collision in project "${project.name}": ${duplicate.caseId}.`, {
1456
+ caseId: duplicate.caseId,
1457
+ projectName: project.name
1458
+ });
1459
+ for (const item of document.cases)availableCaseIds.add(item.caseId);
899
1460
  const filtered = source.absolutePath === prerequisiteFile ? {
900
1461
  document,
901
1462
  filtered: 0
902
- } : filterDocumentCases(document, project);
1463
+ } : filterDocumentCases(document, project, options.selection);
903
1464
  const plan = buildTestRunReportPlan(document, {
904
1465
  selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
905
1466
  defaultTimeoutMs,
@@ -947,9 +1508,42 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
947
1508
  plans,
948
1509
  collectionErrors,
949
1510
  selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
950
- filteredCaseCount
1511
+ filteredCaseCount,
1512
+ availableCaseIds: [
1513
+ ...availableCaseIds
1514
+ ]
951
1515
  };
952
1516
  }
1517
+ const validateUniqueStrings = (values, label)=>{
1518
+ if (void 0 === values) return;
1519
+ 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.`);
1520
+ if (new Set(values).size !== values.length) throw new TypeError(`${label} must not contain duplicates.`);
1521
+ return values;
1522
+ };
1523
+ const validateRunSelection = (options)=>{
1524
+ const paths = validateUniqueStrings(options.paths, 'Test selection paths');
1525
+ const caseIds = validateUniqueStrings(options.caseIds, 'Test selection caseIds');
1526
+ const include = validateUniqueStrings(options.tags?.include, 'Test selection tags.include');
1527
+ const exclude = validateUniqueStrings(options.tags?.exclude, 'Test selection tags.exclude');
1528
+ return {
1529
+ ...paths ? {
1530
+ paths
1531
+ } : {},
1532
+ ...caseIds ? {
1533
+ caseIds
1534
+ } : {},
1535
+ ...include || exclude ? {
1536
+ tags: {
1537
+ ...include ? {
1538
+ include
1539
+ } : {},
1540
+ ...exclude ? {
1541
+ exclude
1542
+ } : {}
1543
+ }
1544
+ } : {}
1545
+ };
1546
+ };
953
1547
  const padDatePart = (value)=>String(value).padStart(2, '0');
954
1548
  const createTestRunId = (date = new Date(), uuid = randomUUID())=>{
955
1549
  const timestamp = [
@@ -966,9 +1560,9 @@ const assertDirectory = (path, label)=>{
966
1560
  if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
967
1561
  };
968
1562
  const resolveRunInput = (options)=>{
969
- const cwd = resolve(options.cwd ?? process.cwd());
1563
+ const cwd = external_node_path_resolve(options.cwd ?? process.cwd());
970
1564
  assertDirectory(cwd, 'Test working directory');
971
- const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
1565
+ const inputPath = options.projectRoot ? external_node_path_resolve(cwd, options.projectRoot) : void 0;
972
1566
  const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
973
1567
  if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
974
1568
  const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
@@ -987,24 +1581,34 @@ async function prepareTestRun(options) {
987
1581
  const startedAt = new Date();
988
1582
  const runId = createTestRunId(startedAt);
989
1583
  const input = resolveRunInput(options);
990
- const configPath = options.configPath ? resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
1584
+ const selection = validateRunSelection(options);
1585
+ const configPath = options.configPath ? external_node_path_resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
991
1586
  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}\`.`);
992
1587
  if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
993
1588
  const definition = await loadTestProject(configPath);
994
- const resultDir = options.resultDir ? resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
1589
+ const resultDir = options.resultDir ? external_node_path_resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
995
1590
  const runDir = join(resultDir, runId);
996
1591
  const summaryPath = join(runDir, 'summary.json');
997
- const reportDir = resolve(input.projectRoot, definition.output.reportDir);
1592
+ const reportDir = external_node_path_resolve(input.projectRoot, definition.output.reportDir);
998
1593
  mkdirSync(resultDir, {
999
1594
  recursive: true
1000
1595
  });
1001
1596
  mkdirSync(runDir);
1002
1597
  const projects = [];
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));
1598
+ for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, {
1599
+ selection,
1600
+ ...input.singleFile ? {
1601
+ files: [
1602
+ input.singleFile
1603
+ ]
1604
+ } : {}
1605
+ }));
1606
+ if (selection.caseIds?.length) {
1607
+ const availableCaseIds = new Set(projects.flatMap((project)=>project.availableCaseIds ?? []));
1608
+ const unknown = selection.caseIds.find((id)=>!availableCaseIds.has(id));
1609
+ if (unknown) throw new Error(`Unknown Midscene case id: ${unknown}`);
1610
+ }
1611
+ 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.');
1008
1612
  const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
1009
1613
  return {
1010
1614
  startedAt,
@@ -1100,20 +1704,601 @@ async function writeTestResultMarkdown(result, index = emptyIndex()) {
1100
1704
  }));
1101
1705
  return output;
1102
1706
  }
1103
- const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1707
+ const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1708
+ documentId,
1709
+ caseId: collectedCase.caseId,
1710
+ projectName,
1711
+ name: collectedCase.definition.name,
1712
+ sourcePath: collectedCase.sourcePath,
1713
+ caseIndex: collectedCase.caseIndex,
1714
+ status: 'not-run',
1715
+ notRunReason: reason
1716
+ });
1104
1717
  const formatStep = (info)=>{
1105
- const position = stepPosition(info);
1718
+ const position = 'case' === info.scope ? info.case : info.document;
1106
1719
  const phase = 'steps' === position.phase ? 'step' : position.phase;
1107
1720
  return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
1108
1721
  };
1109
- const formatStepResult = (info, result)=>{
1110
- const indent = 'case' === info.scope ? ' ' : ' ';
1722
+ const formatStepResult = (info, result, indent = '')=>{
1111
1723
  if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
1112
1724
  const symbol = 'success' === result.status ? '✓' : '✗';
1113
1725
  const error = result.error ? ` — ${result.error.message}` : '';
1114
1726
  const continuation = result.continuedAfterError ? '; continuing' : '';
1115
1727
  return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
1116
1728
  };
1729
+ const latestById = (items, id)=>[
1730
+ ...new Map(items.map((item)=>[
1731
+ id(item),
1732
+ item
1733
+ ])).values()
1734
+ ];
1735
+ const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
1736
+ const { project } = prepared;
1737
+ 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';
1738
+ return {
1739
+ projectId: project.projectId,
1740
+ name: project.name,
1741
+ ...prepared.platform ? {
1742
+ platform: prepared.platform
1743
+ } : {},
1744
+ status: projectFailed ? 'failed' : 'success',
1745
+ retry: project.retry,
1746
+ fileSelection: prepared.fileSelection,
1747
+ tagSelection: project.tags,
1748
+ sourceCount: prepared.sources.length,
1749
+ selectedCaseCount: prepared.selectedCaseCount,
1750
+ filteredCaseCount: prepared.filteredCaseCount,
1751
+ ...lifecycle ? {
1752
+ lifecycle
1753
+ } : {},
1754
+ cases,
1755
+ plans: prepared.plans,
1756
+ documents,
1757
+ collectionErrors: prepared.collectionErrors
1758
+ };
1759
+ };
1760
+ function createExecutorReports(outputDir) {
1761
+ const paths = new Map();
1762
+ const sources = new Map();
1763
+ return {
1764
+ materializeSource (source) {
1765
+ if (!source.dumpPath) {
1766
+ const reference = this.materialize(source.sourcePath);
1767
+ sources.set(reference, {
1768
+ sourcePath: this.resolve(reference),
1769
+ collection: source.collection
1770
+ });
1771
+ return reference;
1772
+ }
1773
+ const dump = realpathSync(source.dumpPath);
1774
+ const reference = randomUUID();
1775
+ const destination = join(outputDir, 'reports', reference);
1776
+ mkdirSync(destination, {
1777
+ recursive: true
1778
+ });
1779
+ const dumpPath = join(destination, 'snapshot.json');
1780
+ cpSync(dump, dumpPath);
1781
+ const screenshots = join(dirname(dump), 'screenshots');
1782
+ if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
1783
+ recursive: true,
1784
+ dereference: true
1785
+ });
1786
+ const sourcePath = join(destination, 'index.html');
1787
+ sources.set(reference, {
1788
+ sourcePath,
1789
+ dumpPath,
1790
+ collection: source.collection
1791
+ });
1792
+ paths.set(reference, sourcePath);
1793
+ return reference;
1794
+ },
1795
+ resolveSource (reference) {
1796
+ return sources.get(reference);
1797
+ },
1798
+ materialize (sourcePath) {
1799
+ const source = realpathSync(sourcePath);
1800
+ const entry = statSync(source).isDirectory() ? join(source, 'index.html') : source;
1801
+ if (!statSync(entry).isFile()) throw new TestExecutorError(`Executor report must have a regular HTML entry: ${sourcePath}`, {
1802
+ kind: 'report'
1803
+ });
1804
+ const reference = randomUUID();
1805
+ const destination = join(outputDir, 'reports', reference);
1806
+ mkdirSync(destination, {
1807
+ recursive: true
1808
+ });
1809
+ const isDirectoryReport = 'index.html' === basename(entry);
1810
+ const reportPath = join(destination, isDirectoryReport ? 'index.html' : 'report.html');
1811
+ cpSync(isDirectoryReport ? dirname(entry) : entry, isDirectoryReport ? destination : reportPath, {
1812
+ recursive: isDirectoryReport,
1813
+ dereference: true
1814
+ });
1815
+ if (!isDirectoryReport) {
1816
+ const screenshots = join(dirname(entry), 'screenshots');
1817
+ if (existsSync(screenshots)) cpSync(screenshots, join(destination, 'screenshots'), {
1818
+ recursive: true,
1819
+ dereference: true
1820
+ });
1821
+ }
1822
+ paths.set(reference, reportPath);
1823
+ return reference;
1824
+ },
1825
+ resolve (reference) {
1826
+ const reportPath = paths.get(reference);
1827
+ if (!reportPath) throw new TestExecutorError(`Executor returned unknown report reference ${reference}.`, {
1828
+ kind: 'report'
1829
+ });
1830
+ return reportPath;
1831
+ }
1832
+ };
1833
+ }
1834
+ async function runTaskPool(options) {
1835
+ if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) throw new TypeError('Task scheduler maxConcurrency must be a positive integer.');
1836
+ const pending = options.tasks.map((_, index)=>index);
1837
+ const results = new Array(options.tasks.length);
1838
+ const activeResources = new Set();
1839
+ let active = 0;
1840
+ let firstError;
1841
+ let hasError = false;
1842
+ return await new Promise((resolve, reject)=>{
1843
+ let settled = false;
1844
+ const stopRequested = ()=>hasError || options.signal?.aborted === true || options.shouldStop?.() === true;
1845
+ const finish = ()=>{
1846
+ if (settled) return;
1847
+ settled = true;
1848
+ options.signal?.removeEventListener('abort', pump);
1849
+ if (hasError) reject(firstError);
1850
+ else resolve(results);
1851
+ };
1852
+ const release = (resources)=>{
1853
+ for (const resource of resources)activeResources.delete(resource);
1854
+ };
1855
+ const claimRunnable = ()=>{
1856
+ const pendingIndex = pending.findIndex((taskIndex)=>options.tasks[taskIndex].resources.every((resource)=>!activeResources.has(resource)));
1857
+ if (pendingIndex < 0) return;
1858
+ const [taskIndex] = pending.splice(pendingIndex, 1);
1859
+ return taskIndex;
1860
+ };
1861
+ function pump() {
1862
+ if (settled) return;
1863
+ while(active < options.maxConcurrency && !stopRequested()){
1864
+ const taskIndex = claimRunnable();
1865
+ if (void 0 === taskIndex) break;
1866
+ const task = options.tasks[taskIndex];
1867
+ for (const resource of task.resources)activeResources.add(resource);
1868
+ active += 1;
1869
+ Promise.resolve().then(()=>options.run(task, taskIndex)).then((result)=>{
1870
+ results[taskIndex] = result;
1871
+ }).catch((error)=>{
1872
+ if (!hasError) {
1873
+ hasError = true;
1874
+ firstError = error;
1875
+ }
1876
+ }).finally(()=>{
1877
+ release(task.resources);
1878
+ active -= 1;
1879
+ pump();
1880
+ });
1881
+ }
1882
+ if (0 === active && (0 === pending.length || stopRequested())) finish();
1883
+ }
1884
+ options.signal?.addEventListener('abort', pump, {
1885
+ once: true
1886
+ });
1887
+ pump();
1888
+ });
1889
+ }
1890
+ const isolatedDocumentId = (document, collectedCase)=>`${document.documentId}-case-${collectedCase.caseIndex}`;
1891
+ const prepareCaseTasks = (projects)=>projects.flatMap((prepared, projectIndex)=>prepared.documents.flatMap((document)=>document.cases.map((collectedCase)=>{
1892
+ const documentId = isolatedDocumentId(document, collectedCase);
1893
+ return {
1894
+ projectIndex,
1895
+ prepared,
1896
+ document,
1897
+ collectedCase,
1898
+ task: Object.freeze({
1899
+ taskId: `${prepared.project.projectId}:${collectedCase.caseId}`,
1900
+ projectId: prepared.project.projectId,
1901
+ projectName: prepared.project.name,
1902
+ documentId,
1903
+ sourcePath: collectedCase.sourcePath,
1904
+ caseId: collectedCase.caseId,
1905
+ caseName: collectedCase.definition.name,
1906
+ caseIndex: collectedCase.caseIndex,
1907
+ tags: Object.freeze([
1908
+ ...collectedCase.definition.tags ?? []
1909
+ ]),
1910
+ resources: Object.freeze([
1911
+ ...collectedCase.definition.resources ?? []
1912
+ ]),
1913
+ retry: prepared.project.retry
1914
+ })
1915
+ };
1916
+ })));
1917
+ const reviveError = (error)=>{
1918
+ const revived = new WorkflowError(error.message, {
1919
+ code: error.code,
1920
+ details: error.details,
1921
+ cause: error.cause
1922
+ });
1923
+ revived.name = error.name;
1924
+ return revived;
1925
+ };
1926
+ const reviveStep = (step)=>{
1927
+ const { error, output, report, ...base } = step;
1928
+ return {
1929
+ ...base,
1930
+ input: step.input,
1931
+ meta: {
1932
+ ...step.meta
1933
+ },
1934
+ ...output ? {
1935
+ output: {
1936
+ ...output
1937
+ }
1938
+ } : {},
1939
+ ...error ? {
1940
+ error: reviveError(error)
1941
+ } : {},
1942
+ ...report ? {
1943
+ report: {
1944
+ traces: report.traces.map((trace)=>({
1945
+ ...trace
1946
+ })),
1947
+ ...report.collectionErrors ? {
1948
+ collectionErrors: report.collectionErrors.map(reviveError)
1949
+ } : {}
1950
+ }
1951
+ } : {}
1952
+ };
1953
+ };
1954
+ const reviveAttempt = (attempt)=>{
1955
+ const { executionErrors, teardownErrors, reportRefs, beforeEach, steps, afterEach, ...base } = attempt;
1956
+ return {
1957
+ ...base,
1958
+ beforeEach: beforeEach.map(reviveStep),
1959
+ steps: steps.map(reviveStep),
1960
+ afterEach: afterEach.map(reviveStep),
1961
+ ...executionErrors ? {
1962
+ executionErrors: executionErrors.map(reviveError)
1963
+ } : {},
1964
+ ...teardownErrors ? {
1965
+ teardownErrors: teardownErrors.map(reviveError)
1966
+ } : {}
1967
+ };
1968
+ };
1969
+ const reviveDocument = (document)=>{
1970
+ const { executionErrors, teardownErrors, reportRefs, beforeAll, afterAll, hostErrors, ...base } = document;
1971
+ return {
1972
+ ...base,
1973
+ ...hostErrors ? {
1974
+ hostErrors: hostErrors.map((error)=>({
1975
+ ...error
1976
+ }))
1977
+ } : {},
1978
+ beforeAll: beforeAll.map(reviveStep),
1979
+ afterAll: afterAll.map(reviveStep),
1980
+ ...executionErrors ? {
1981
+ executionErrors: executionErrors.map(reviveError)
1982
+ } : {},
1983
+ ...teardownErrors ? {
1984
+ teardownErrors: teardownErrors.map(reviveError)
1985
+ } : {}
1986
+ };
1987
+ };
1988
+ const reviveLifecycle = (lifecycle)=>{
1989
+ const { setupError, teardownErrors, ...base } = lifecycle;
1990
+ return {
1991
+ ...base,
1992
+ ...setupError ? {
1993
+ setupError: reviveError(setupError)
1994
+ } : {},
1995
+ ...teardownErrors ? {
1996
+ teardownErrors: teardownErrors.map(reviveError)
1997
+ } : {}
1998
+ };
1999
+ };
2000
+ const toTransportResult = (result, materializeReport, materializeSource)=>{
2001
+ const transportReports = (scope)=>[
2002
+ ...(scope.reportSources ?? []).map(materializeSource),
2003
+ ...(scope.reportPaths ?? []).filter((path)=>!scope.reportSources?.some((source)=>source.sourcePath === path)).map(materializeReport)
2004
+ ];
2005
+ const mapStep = (step)=>{
2006
+ if (!step.report) return step;
2007
+ const { sources: _sources, ...report } = step.report;
2008
+ return {
2009
+ ...step,
2010
+ report
2011
+ };
2012
+ };
2013
+ const mapAttempt = (attempt)=>{
2014
+ const { reportPaths, reportSources, reportScopeId, ...transportAttempt } = attempt;
2015
+ const reportRefs = transportReports(attempt);
2016
+ return {
2017
+ ...transportAttempt,
2018
+ beforeEach: attempt.beforeEach.map(mapStep),
2019
+ steps: attempt.steps.map(mapStep),
2020
+ afterEach: attempt.afterEach.map(mapStep),
2021
+ ...reportRefs.length ? {
2022
+ reportRefs
2023
+ } : {}
2024
+ };
2025
+ };
2026
+ const attempts = result.case.attempts?.map(mapAttempt);
2027
+ const run = attempts?.at(-1);
2028
+ const { run: _run, attempts: _attempts, ...transportCase } = result.case;
2029
+ const document = result.document ? (()=>{
2030
+ const { reportPaths, reportSources, reportScopeId, ...transportDocument } = result.document;
2031
+ const reportRefs = transportReports(result.document);
2032
+ return {
2033
+ ...transportDocument,
2034
+ ...result.document.hostErrors ? {
2035
+ hostErrors: result.document.hostErrors.map(({ phase, error })=>({
2036
+ phase,
2037
+ error: sanitizeReportError(error)
2038
+ }))
2039
+ } : {},
2040
+ beforeAll: result.document.beforeAll.map(mapStep),
2041
+ afterAll: result.document.afterAll.map(mapStep),
2042
+ ...reportRefs.length ? {
2043
+ reportRefs
2044
+ } : {}
2045
+ };
2046
+ })() : void 0;
2047
+ return JSON.parse(JSON.stringify({
2048
+ case: {
2049
+ ...transportCase,
2050
+ ...run ? {
2051
+ run
2052
+ } : {},
2053
+ ...attempts ? {
2054
+ attempts
2055
+ } : {}
2056
+ },
2057
+ ...document ? {
2058
+ document
2059
+ } : {},
2060
+ ...result.lifecycle ? {
2061
+ lifecycle: result.lifecycle
2062
+ } : {}
2063
+ }));
2064
+ };
2065
+ const fromTransportResult = (result, resolveReportReference, resolveSource)=>{
2066
+ const resolveReports = (refs)=>{
2067
+ const reportPaths = [];
2068
+ const reportSources = [];
2069
+ for (const ref of refs ?? []){
2070
+ const path = resolveReportReference(ref);
2071
+ const source = resolveSource(ref);
2072
+ if (source) reportSources.push(source);
2073
+ else reportPaths.push(path);
2074
+ }
2075
+ return {
2076
+ ...reportPaths.length ? {
2077
+ reportPaths
2078
+ } : {},
2079
+ ...reportSources.length ? {
2080
+ reportSources
2081
+ } : {}
2082
+ };
2083
+ };
2084
+ const attempts = result.case.attempts?.map((attempt)=>({
2085
+ ...reviveAttempt(attempt),
2086
+ ...resolveReports(attempt.reportRefs)
2087
+ }));
2088
+ const run = attempts?.at(-1);
2089
+ const { run: _run, attempts: _attempts, ...caseBase } = result.case;
2090
+ return {
2091
+ case: {
2092
+ ...caseBase,
2093
+ ...run ? {
2094
+ run
2095
+ } : {},
2096
+ ...attempts ? {
2097
+ attempts
2098
+ } : {}
2099
+ },
2100
+ ...result.document ? {
2101
+ document: {
2102
+ ...reviveDocument(result.document),
2103
+ ...resolveReports(result.document.reportRefs)
2104
+ }
2105
+ } : {},
2106
+ ...result.lifecycle ? {
2107
+ lifecycle: reviveLifecycle(result.lifecycle)
2108
+ } : {}
2109
+ };
2110
+ };
2111
+ const runLocalCase = async (preparedTask, options, taskProgress, materializeReport, materializeSource)=>{
2112
+ const { prepared, document, collectedCase, task } = preparedTask;
2113
+ const { project } = prepared;
2114
+ const isolatedDocument = {
2115
+ ...document,
2116
+ documentId: task.documentId,
2117
+ cases: [
2118
+ collectedCase
2119
+ ]
2120
+ };
2121
+ const runtime = createProjectRuntime({
2122
+ project,
2123
+ setup: project.setup,
2124
+ signal: options.signal
2125
+ });
2126
+ let lifecycle;
2127
+ let execution;
2128
+ let executionError;
2129
+ try {
2130
+ await runtime.start();
2131
+ if (runtime.canRun) execution = await runWorkflowDocument(isolatedDocument, {
2132
+ resolveNode: project.nodes.require.bind(project.nodes),
2133
+ project,
2134
+ projectContext: runtime.context,
2135
+ retry: project.retry,
2136
+ signal: runtime.signal,
2137
+ defaultTimeoutMs: options.test.testTimeout,
2138
+ shouldStop: ()=>options.signal.aborted,
2139
+ stopReason: ()=>'interrupted',
2140
+ isFatalError: (run)=>[
2141
+ ...run.beforeEach,
2142
+ ...run.steps,
2143
+ ...run.afterEach
2144
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
2145
+ onStepStart: (info)=>taskProgress(`→ ${formatStep(info)}`),
2146
+ onStepResult: (info, result)=>taskProgress(formatStepResult(info, result)),
2147
+ onCaseResult: (attempt)=>taskProgress(`${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`)
2148
+ });
2149
+ } catch (error) {
2150
+ executionError = error;
2151
+ if (error instanceof WorkflowExecutionFailure) execution = error.result;
2152
+ } finally{
2153
+ const failed = void 0 !== executionError || !runtime.canRun || execution?.cases[0]?.status !== 'success' || execution?.document.status === 'failed';
2154
+ lifecycle = await runtime.finish(failed ? 'failed' : 'success');
2155
+ }
2156
+ if (executionError) {
2157
+ if (!execution) throw executionError;
2158
+ options.onInfrastructureError(executionError);
2159
+ }
2160
+ if (!execution) return toTransportResult({
2161
+ case: asNotRun(task.documentId, collectedCase, project.name, options.signal.aborted ? 'interrupted' : 'project-setup-failed'),
2162
+ lifecycle
2163
+ }, materializeReport, materializeSource);
2164
+ const outcome = execution.cases[0];
2165
+ if (!outcome) throw new Error(`Executor did not produce case result for ${task.caseId}.`);
2166
+ return toTransportResult({
2167
+ case: {
2168
+ ...outcome,
2169
+ documentId: task.documentId
2170
+ },
2171
+ document: execution.document,
2172
+ lifecycle
2173
+ }, materializeReport, materializeSource);
2174
+ };
2175
+ const runCaseTask = async (preparedTask, taskIndex, taskCount, options, onCompleted)=>{
2176
+ const { prepared, task } = preparedTask;
2177
+ const taskProgress = (message)=>options.progress(`[case ${taskIndex + 1}/${taskCount}] ${task.projectName} / ${task.sourcePath} / ${task.caseName}: ${message}`);
2178
+ taskProgress('started');
2179
+ const outputDir = join(options.runDir, task.projectId, 'executor-output', task.documentId, task.caseId);
2180
+ mkdirSync(outputDir, {
2181
+ recursive: true
2182
+ });
2183
+ const { materialize: materializeReport, materializeSource, resolve: resolveReportReference, resolveSource } = createExecutorReports(outputDir);
2184
+ if (prepared.collectionErrors.length > 0) return {
2185
+ case: asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'project-preflight-failed')
2186
+ };
2187
+ const executor = options.executor ?? localTestExecutor;
2188
+ let result;
2189
+ let executorFailure;
2190
+ let executorAttempts = 0;
2191
+ for(let attemptIndex = 0; attemptIndex <= options.test.executorRetry; attemptIndex += 1){
2192
+ executorAttempts = attemptIndex + 1;
2193
+ try {
2194
+ result = await executor.execute(task, {
2195
+ signal: options.signal,
2196
+ onProgress: taskProgress,
2197
+ outputDir,
2198
+ materializeReport,
2199
+ runLocal: ()=>runLocalCase(preparedTask, options, taskProgress, materializeReport, materializeSource)
2200
+ });
2201
+ executorFailure = void 0;
2202
+ break;
2203
+ } catch (error) {
2204
+ if (!(error instanceof TestExecutorError)) throw error;
2205
+ executorFailure = error;
2206
+ if (!error.retryable || attemptIndex >= options.test.executorRetry || options.signal.aborted) break;
2207
+ taskProgress(`executor retry ${attemptIndex + 1}/${options.test.executorRetry}: ${error.message}`);
2208
+ }
2209
+ }
2210
+ if (!result) {
2211
+ if (!executorFailure) throw new Error(`Executor did not return a result for ${task.caseId}.`);
2212
+ taskProgress(`executor-failed: ${executorFailure.message}`);
2213
+ return {
2214
+ case: {
2215
+ ...asNotRun(task.documentId, preparedTask.collectedCase, task.projectName, 'executor-failed'),
2216
+ execution: {
2217
+ executor: executor.name,
2218
+ resources: task.resources,
2219
+ attempts: executorAttempts,
2220
+ failure: {
2221
+ kind: executorFailure.kind,
2222
+ message: executorFailure.message,
2223
+ retryable: executorFailure.retryable
2224
+ }
2225
+ }
2226
+ }
2227
+ };
2228
+ }
2229
+ assertTestCaseTaskRunResult(task, result);
2230
+ const internalResult = fromTransportResult(result, resolveReportReference, resolveSource);
2231
+ const completed = {
2232
+ ...internalResult,
2233
+ case: {
2234
+ ...internalResult.case,
2235
+ execution: {
2236
+ executor: executor.name,
2237
+ resources: task.resources,
2238
+ attempts: executorAttempts,
2239
+ ...internalResult.lifecycle ? {
2240
+ lifecycle: internalResult.lifecycle
2241
+ } : {},
2242
+ ...result.artifacts ? {
2243
+ artifacts: result.artifacts
2244
+ } : {},
2245
+ ...result.metadata ? {
2246
+ metadata: result.metadata
2247
+ } : {}
2248
+ }
2249
+ }
2250
+ };
2251
+ onCompleted(completed);
2252
+ for (const attempt of internalResult.case.attempts ?? [])await writeCaseAttemptResult(options.runDir, task.projectId, task.documentId, attempt);
2253
+ if (internalResult.document) await writeWorkflowDocumentResult(options.runDir, internalResult.document);
2254
+ taskProgress(internalResult.case.status);
2255
+ return completed;
2256
+ };
2257
+ const runCaseExecution = async (options)=>{
2258
+ const tasks = prepareCaseTasks(options.projects);
2259
+ let failedCaseCount = 0;
2260
+ const bailReached = ()=>options.test.bail > 0 && failedCaseCount >= options.test.bail;
2261
+ const results = new Array(tasks.length);
2262
+ if (tasks.length > 0) try {
2263
+ await runTaskPool({
2264
+ tasks: tasks.map((preparedTask)=>preparedTask.task),
2265
+ maxConcurrency: Math.min(options.test.maxConcurrency, tasks.length),
2266
+ signal: options.signal,
2267
+ shouldStop: bailReached,
2268
+ run: async (_task, taskIndex)=>{
2269
+ try {
2270
+ const result = await runCaseTask(tasks[taskIndex], taskIndex, tasks.length, options, (completed)=>{
2271
+ results[taskIndex] = completed;
2272
+ });
2273
+ results[taskIndex] = result;
2274
+ if ('failed' === result.case.status) failedCaseCount += 1;
2275
+ return result;
2276
+ } catch (error) {
2277
+ options.onInfrastructureError(error);
2278
+ throw error;
2279
+ }
2280
+ }
2281
+ });
2282
+ } catch (error) {
2283
+ options.onInfrastructureError(error);
2284
+ }
2285
+ return options.projects.map((prepared, projectIndex)=>{
2286
+ const projectTasks = tasks.map((task, taskIndex)=>({
2287
+ task,
2288
+ result: results[taskIndex]
2289
+ })).filter(({ task })=>task.projectIndex === projectIndex);
2290
+ const reason = prepared.collectionErrors.length ? 'project-preflight-failed' : options.signal.aborted ? 'interrupted' : bailReached() ? 'bail' : void 0;
2291
+ const cases = projectTasks.map(({ task, result })=>{
2292
+ if (result) return result.case;
2293
+ if (!reason) throw new Error(`Case scheduler did not produce a result for "${task.task.caseId}".`);
2294
+ return asNotRun(task.task.documentId, task.collectedCase, prepared.project.name, reason);
2295
+ });
2296
+ const documents = projectTasks.flatMap(({ result })=>result?.document ? [
2297
+ result.document
2298
+ ] : []);
2299
+ return buildProjectResult(prepared, cases, documents);
2300
+ });
2301
+ };
1117
2302
  const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
1118
2303
  ...attempt.beforeEach,
1119
2304
  ...attempt.steps,
@@ -1159,7 +2344,7 @@ async function executeDocumentInvocation(options) {
1159
2344
  },
1160
2345
  onStepResult: async (info, result)=>{
1161
2346
  await invocation.bindings.onStepResult?.(info, result);
1162
- options.onProgress(formatStepResult(info, result));
2347
+ options.onProgress(formatStepResult(info, result, 'case' === info.scope ? ' ' : ' '));
1163
2348
  },
1164
2349
  onCaseResult: async (attempt)=>{
1165
2350
  await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
@@ -1277,16 +2462,6 @@ function createOperationJournal(runDir) {
1277
2462
  }
1278
2463
  };
1279
2464
  }
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
- });
1290
2465
  const summarize = (projects)=>{
1291
2466
  const healthCases = projects.flatMap((project)=>{
1292
2467
  const documentAttemptByRunId = new Map(project.documents.map((document)=>[
@@ -1320,12 +2495,6 @@ const summarize = (projects)=>{
1320
2495
  projectFailures: projects.filter((project)=>'failed' === project.status).length
1321
2496
  };
1322
2497
  };
1323
- const latestById = (items, id)=>[
1324
- ...new Map(items.map((item)=>[
1325
- id(item),
1326
- item
1327
- ])).values()
1328
- ];
1329
2498
  const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
1330
2499
  async function runPreparedTestProject(plan) {
1331
2500
  const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
@@ -1338,7 +2507,7 @@ async function runPreparedTestProject(plan) {
1338
2507
  await writeTestRunPlan(runDir, preparedProjects);
1339
2508
  const journal = createOperationJournal(runDir);
1340
2509
  const rootController = new AbortController();
1341
- setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
2510
+ setMaxListeners(Math.max(10, ('case' === definition.test.executionUnit ? Math.min(definition.test.maxConcurrency, totalCases) : effectiveConcurrency) + 1), rootController.signal);
1342
2511
  const handleSignal = (signal)=>{
1343
2512
  rootController.abort(new Error(`Workflow interrupted by ${signal}.`));
1344
2513
  };
@@ -1380,31 +2549,6 @@ async function runPreparedTestProject(plan) {
1380
2549
  const { project } = prepared;
1381
2550
  runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
1382
2551
  };
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
- };
1408
2552
  const buildSkippedProjectResult = (prepared, projectIndex, reason)=>{
1409
2553
  announceProject(prepared, projectIndex);
1410
2554
  return buildProjectResult(prepared, notRunSuite(prepared, reason), []);
@@ -1505,7 +2649,7 @@ async function runPreparedTestProject(plan) {
1505
2649
  };
1506
2650
  const projectResults = new Array(preparedProjects.length);
1507
2651
  try {
1508
- await runConcurrentJobs(preparedProjects, {
2652
+ if ('case' !== definition.test.executionUnit || 'run' === plan.preflightScope && totalErrors > 0) await runConcurrentJobs(preparedProjects, {
1509
2653
  concurrency: effectiveConcurrency,
1510
2654
  shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
1511
2655
  }, async (prepared, index)=>{
@@ -1516,6 +2660,20 @@ async function runPreparedTestProject(plan) {
1516
2660
  throw error;
1517
2661
  }
1518
2662
  });
2663
+ else {
2664
+ const results = await runCaseExecution({
2665
+ projects: preparedProjects,
2666
+ test: definition.test,
2667
+ executor: definition.executor,
2668
+ runDir,
2669
+ signal: rootController.signal,
2670
+ progress,
2671
+ onInfrastructureError: recordInfrastructureError
2672
+ });
2673
+ results.forEach((result, index)=>{
2674
+ projectResults[index] = result;
2675
+ });
2676
+ }
1519
2677
  } catch (error) {
1520
2678
  recordInfrastructureError(error);
1521
2679
  } finally{
@@ -1621,6 +2779,6 @@ async function runPreparedTestProject(plan) {
1621
2779
  async function runTestProject(options = {}) {
1622
2780
  return runPreparedTestProject(await prepareTestRun(options));
1623
2781
  }
1624
- export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
2782
+ export { DEFAULT_TEST_FILE_SELECTION, TestExecutorError, assertTestCaseTaskRunResult, createTestRunId, defineProjectSetup, defineTestExecutor, defineTestProject, discoverSelectedTestFiles, discoverTestConfig, discoverTestFiles, loadTestProject, localTestExecutor, runTestProject, validateTestFileSelection };
1625
2783
 
1626
2784
  //# sourceMappingURL=index.mjs.map