@midscene/test 1.12.10-beta-20260920031616.0 → 1.13.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.
@@ -2,283 +2,16 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { require as api_require } from "tsx/cjs/api";
4
4
  import { tsImport } from "tsx/esm/api";
5
- import { z } from "zod/v4";
5
+ import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, isFatalDeviceError, normalizeSteps, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument } from "@midscene/core/internal/test-runner";
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
7
+ import { globSync } from "tinyglobby";
8
+ import { JSON_SCHEMA, load } from "js-yaml";
6
9
  import { createHash, randomUUID } from "node:crypto";
10
+ import { mkdir, writeFile } from "node:fs/promises";
7
11
  import { setMaxListeners } from "node:events";
8
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
9
12
  import { TestRunReportAssembler } from "@midscene/core/report";
10
- import { globSync } from "tinyglobby";
11
- import { JSON_SCHEMA, load } from "js-yaml";
12
13
  import { getDebug } from "@midscene/shared/logger";
13
- function _define_property(obj, key, value) {
14
- if (key in obj) Object.defineProperty(obj, key, {
15
- value: value,
16
- enumerable: true,
17
- configurable: true,
18
- writable: true
19
- });
20
- else obj[key] = value;
21
- return obj;
22
- }
23
- class WorkflowError extends Error {
24
- toJSON() {
25
- return {
26
- name: this.name,
27
- message: this.message,
28
- code: this.code,
29
- ...void 0 === this.details ? {} : {
30
- details: this.details
31
- }
32
- };
33
- }
34
- constructor(message, options = {}){
35
- super(message, {
36
- cause: options.cause
37
- }), _define_property(this, "code", void 0), _define_property(this, "details", void 0);
38
- this.name = new.target.name;
39
- this.code = options.code ?? 'WORKFLOW_ERROR';
40
- this.details = options.details;
41
- }
42
- }
43
- class WorkflowParseError extends WorkflowError {
44
- constructor(message, details, cause){
45
- super(message, {
46
- code: 'WORKFLOW_PARSE_ERROR',
47
- details,
48
- cause
49
- });
50
- }
51
- }
52
- class NodeDefinitionError extends WorkflowError {
53
- constructor(message, details){
54
- super(message, {
55
- code: 'NODE_DEFINITION_ERROR',
56
- details
57
- });
58
- }
59
- }
60
- class DuplicateNodeError extends WorkflowError {
61
- constructor(node){
62
- super(`Node "${node}" is already registered.`, {
63
- code: 'DUPLICATE_NODE',
64
- details: {
65
- node
66
- }
67
- }), _define_property(this, "node", void 0);
68
- this.node = node;
69
- }
70
- }
71
- class NodeNotFoundError extends WorkflowError {
72
- constructor(node){
73
- super(`Node "${node}" is not registered.`, {
74
- code: 'NODE_NOT_FOUND',
75
- details: {
76
- node
77
- }
78
- }), _define_property(this, "node", void 0);
79
- this.node = node;
80
- }
81
- }
82
- class NodeInputValidationError extends WorkflowError {
83
- static fromZod(node, error) {
84
- const issues = error.issues.map((issue)=>({
85
- code: issue.code,
86
- path: issue.path.map(String).join('.'),
87
- message: issue.message
88
- }));
89
- const firstIssue = issues[0];
90
- const path = firstIssue?.path || '<root>';
91
- const message = firstIssue?.message ?? 'invalid input';
92
- return new NodeInputValidationError(`Node "${node}" input validation failed at "${path}": ${message}`, {
93
- node,
94
- issues
95
- });
96
- }
97
- constructor(message, details){
98
- super(message, {
99
- code: 'NODE_INPUT_VALIDATION_ERROR',
100
- details
101
- });
102
- }
103
- }
104
- class StepTimeoutError extends WorkflowError {
105
- constructor(timeoutMs, node){
106
- super(node ? `Node "${node}" timed out after ${timeoutMs}ms.` : `Step timed out after ${timeoutMs}ms.`, {
107
- code: 'STEP_TIMEOUT',
108
- details: {
109
- timeoutMs,
110
- ...void 0 === node ? {} : {
111
- node
112
- }
113
- }
114
- }), _define_property(this, "timeoutMs", void 0), _define_property(this, "node", void 0);
115
- this.timeoutMs = timeoutMs;
116
- this.node = node;
117
- }
118
- }
119
- class NodeExecutionError extends WorkflowError {
120
- constructor(node, cause){
121
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
122
- super(`Node "${node}" failed: ${causeMessage}`, {
123
- code: 'NODE_EXECUTION_ERROR',
124
- details: {
125
- node
126
- },
127
- cause
128
- }), _define_property(this, "node", void 0);
129
- this.node = node;
130
- }
131
- }
132
- class WorkflowLifecycleError extends WorkflowError {
133
- constructor(message, details){
134
- super(message, {
135
- code: 'WORKFLOW_LIFECYCLE_ERROR',
136
- details
137
- });
138
- }
139
- }
140
- class ProjectSetupError extends WorkflowError {
141
- constructor(cause, details){
142
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
143
- super(`Project "${details.projectName}" setup failed: ${causeMessage}`, {
144
- code: 'PROJECT_SETUP_ERROR',
145
- details,
146
- cause
147
- });
148
- }
149
- }
150
- class ProjectTeardownError extends WorkflowError {
151
- constructor(cause, details){
152
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
153
- super(`Project "${details.projectName}" teardown failed: ${causeMessage}`, {
154
- code: 'PROJECT_TEARDOWN_ERROR',
155
- details,
156
- cause
157
- });
158
- }
159
- }
160
- class NodeScopeTeardownError extends WorkflowError {
161
- constructor(cause, details){
162
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
163
- super(`${'case' === details.scope ? 'Case attempt' : 'Workflow document'} node teardown failed for "${details.node}": ${causeMessage}`, {
164
- code: 'NODE_SCOPE_TEARDOWN_ERROR',
165
- details,
166
- cause
167
- });
168
- }
169
- }
170
- class FatalDeviceError extends WorkflowError {
171
- constructor(message, cause){
172
- super(message, {
173
- code: 'FATAL_DEVICE_ERROR',
174
- cause
175
- });
176
- }
177
- }
178
- const isFatalDeviceError = (error)=>{
179
- if (error instanceof FatalDeviceError) return true;
180
- if (error instanceof WorkflowError && 'FATAL_DEVICE_ERROR' === error.code) return true;
181
- if (error instanceof Error && /device offline|device not found|(?:adb|bdc|device) connection (?:was )?closed/i.test(error.message)) return true;
182
- return error instanceof Error && void 0 !== error.cause ? isFatalDeviceError(error.cause) : false;
183
- };
184
- function normalizeNodeExecutionError(error, node) {
185
- return error instanceof WorkflowError ? error : new NodeExecutionError(node, error);
186
- }
187
- const validateOptionalText = (value, field, node)=>{
188
- if (void 0 !== value && ('string' != typeof value || 0 === value.trim().length)) throw new NodeDefinitionError(`Node "${node}" ${field} must be a non-empty string.`, {
189
- node,
190
- field
191
- });
192
- };
193
- const validateInputSchema = (schema, node)=>{
194
- if (void 0 === schema) return;
195
- if (!(schema instanceof z.ZodObject)) throw new NodeDefinitionError(`Node "${node}" inputSchema must be a Zod object schema.`, {
196
- node,
197
- field: 'inputSchema'
198
- });
199
- if ('$' in schema.shape) throw new NodeDefinitionError(`Node "${node}" inputSchema must not declare "$" as an input property.`, {
200
- node,
201
- field: 'inputSchema.$'
202
- });
203
- };
204
- const validateDefinition = (options)=>{
205
- if (!options || 'object' != typeof options) throw new NodeDefinitionError('Node definition must be an object.');
206
- if ('string' != typeof options.name || 0 === options.name.trim().length) throw new NodeDefinitionError('Node name must be a non-empty string.');
207
- validateOptionalText(options.title, 'title', options.name);
208
- validateOptionalText(options.description, "description", options.name);
209
- if (void 0 !== options.stringInputKey && false !== options.stringInputKey && ('string' != typeof options.stringInputKey || 0 === options.stringInputKey.trim().length)) throw new NodeDefinitionError(`Node "${options.name}" stringInputKey must be a non-empty string or false.`, {
210
- node: options.name,
211
- field: 'stringInputKey'
212
- });
213
- validateInputSchema(options.inputSchema, options.name);
214
- if ('string' == typeof options.stringInputKey && options.inputSchema instanceof z.ZodObject && !(options.stringInputKey in options.inputSchema.shape)) throw new NodeDefinitionError(`Node "${options.name}" stringInputKey must reference a field in inputSchema.`, {
215
- node: options.name,
216
- field: 'stringInputKey'
217
- });
218
- if ('function' != typeof options.execute) throw new NodeDefinitionError(`Node "${options.name}" must provide an execute function.`, {
219
- node: options.name
220
- });
221
- };
222
- function defineNode(options) {
223
- validateDefinition(options);
224
- return options;
225
- }
226
- function _check_private_redeclaration(obj, privateCollection) {
227
- if (privateCollection.has(obj)) throw new TypeError("Cannot initialize the same private elements twice on an object");
228
- }
229
- function _class_apply_descriptor_get(receiver, descriptor) {
230
- if (descriptor.get) return descriptor.get.call(receiver);
231
- return descriptor.value;
232
- }
233
- function _class_extract_field_descriptor(receiver, privateMap, action) {
234
- if (!privateMap.has(receiver)) throw new TypeError("attempted to " + action + " private field on non-instance");
235
- return privateMap.get(receiver);
236
- }
237
- function _class_private_field_get(receiver, privateMap) {
238
- var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get");
239
- return _class_apply_descriptor_get(receiver, descriptor);
240
- }
241
- function _class_private_field_init(obj, privateMap, value) {
242
- _check_private_redeclaration(obj, privateMap);
243
- privateMap.set(obj, value);
244
- }
245
- var _nodes = /*#__PURE__*/ new WeakMap();
246
- class NodeRegistry {
247
- register(node) {
248
- defineNode(node);
249
- if (_class_private_field_get(this, _nodes).has(node.name)) throw new DuplicateNodeError(node.name);
250
- _class_private_field_get(this, _nodes).set(node.name, node);
251
- return this;
252
- }
253
- get(name) {
254
- return _class_private_field_get(this, _nodes).get(name);
255
- }
256
- require(name) {
257
- const node = this.get(name);
258
- if (!node) throw new NodeNotFoundError(name);
259
- return node;
260
- }
261
- has(name) {
262
- return _class_private_field_get(this, _nodes).has(name);
263
- }
264
- names() {
265
- return [
266
- ..._class_private_field_get(this, _nodes).keys()
267
- ];
268
- }
269
- definitions() {
270
- return [
271
- ..._class_private_field_get(this, _nodes).values()
272
- ];
273
- }
274
- constructor(nodes = []){
275
- _class_private_field_init(this, _nodes, {
276
- writable: true,
277
- value: new Map()
278
- });
279
- for (const node of nodes)this.register(node);
280
- }
281
- }
14
+ const registry_NodeRegistry = NodeRegistry;
282
15
  const projectIdFromIndex = (index)=>`project-${index}`;
283
16
  const defineTestProject = (definition)=>definition;
284
17
  const defineProjectSetup = (definition)=>definition;
@@ -318,6 +51,10 @@ const validatePatterns = (value, field, label = 'files', allowEmptyInclude = fal
318
51
  const validateTestFileSelection = (value, label = 'files')=>{
319
52
  if (void 0 === value) return;
320
53
  if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
54
+ rejectUnknownKeys(value, [
55
+ 'include',
56
+ 'exclude'
57
+ ], label);
321
58
  const include = validatePatterns(value.include, 'include', label);
322
59
  const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
323
60
  return Object.freeze({
@@ -373,7 +110,7 @@ const validateVariables = (value, label)=>{
373
110
  assertJsonValue(value, label, new Set());
374
111
  return deepFreezeJson(value);
375
112
  };
376
- const validateProjectSetup = (value, label)=>{
113
+ const validateSetup = (value, label)=>{
377
114
  if (void 0 === value) return;
378
115
  if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
379
116
  rejectUnknownKeys(value, [
@@ -396,7 +133,7 @@ const validateNonNegativeInteger = (value, fallback, label)=>{
396
133
  };
397
134
  const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
398
135
  if (void 0 === value) {
399
- const setup = validateProjectSetup(defaultSetup, 'setup');
136
+ const setup = validateSetup(defaultSetup, 'setup');
400
137
  return {
401
138
  hasExplicitProjects: false,
402
139
  projects: Object.freeze([
@@ -412,7 +149,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
412
149
  }),
413
150
  retry: 0,
414
151
  variables: Object.freeze({}),
415
- nodes: new NodeRegistry(globalNodes.definitions())
152
+ nodes: new registry_NodeRegistry(globalNodes.definitions())
416
153
  })
417
154
  ])
418
155
  };
@@ -437,8 +174,8 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
437
174
  names.add(candidate.name);
438
175
  const files = validateTestFileSelection(candidate.files, `${label}.files`);
439
176
  if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
440
- const localNodes = new NodeRegistry(candidate.nodes);
441
- const nodes = new NodeRegistry([
177
+ const localNodes = new registry_NodeRegistry(candidate.nodes);
178
+ const nodes = new registry_NodeRegistry([
442
179
  ...globalNodes.definitions().filter((node)=>!localNodes.has(node.name)),
443
180
  ...localNodes.definitions()
444
181
  ]);
@@ -453,7 +190,7 @@ const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
453
190
  retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
454
191
  variables: validateVariables(candidate.variables, `${label}.variables`),
455
192
  ...void 0 === candidate.setup ? {} : {
456
- setup: validateProjectSetup(candidate.setup, `${label}.setup`)
193
+ setup: validateSetup(candidate.setup, `${label}.setup`)
457
194
  }
458
195
  });
459
196
  });
@@ -501,7 +238,7 @@ const validateTestProjectDefinition = (definition)=>{
501
238
  'output',
502
239
  'nodes'
503
240
  ], 'root');
504
- const nodes = new NodeRegistry(definition.nodes);
241
+ const nodes = new registry_NodeRegistry(definition.nodes);
505
242
  const resolvedProjects = validateExecutionProjects(definition.projects, definition.setup, nodes);
506
243
  return {
507
244
  ...resolvedProjects,
@@ -548,761 +285,22 @@ async function loadTestProject(configPath) {
548
285
  }
549
286
  return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
550
287
  }
551
- const createProjectRuntime = (options)=>{
552
- const startedAt = new Date();
553
- const controller = new AbortController();
554
- const parentSignal = options.signal;
555
- const abortFromParent = ()=>controller.abort(parentSignal?.reason);
556
- if (parentSignal?.aborted) abortFromParent();
557
- else parentSignal?.addEventListener('abort', abortFromParent, {
558
- once: true
559
- });
560
- const teardownStack = [];
561
- let context;
562
- let setupError;
563
- let started = false;
564
- let acceptingTeardowns = void 0 !== options.setup;
565
- let finishedResult;
566
- const createResult = (requestedStatus, teardownErrors = [])=>{
567
- const endedAt = new Date();
568
- return {
569
- projectName: options.project.name,
570
- status: 'failed' === requestedStatus || setupError || teardownErrors.length > 0 ? 'failed' : 'success',
571
- ...setupError ? {
572
- setupError
573
- } : {},
574
- ...teardownErrors.length > 0 ? {
575
- teardownErrors
576
- } : {},
577
- startedAt: startedAt.toISOString(),
578
- endedAt: endedAt.toISOString(),
579
- durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
580
- };
581
- };
582
- const onTeardown = (teardown)=>{
583
- if (!acceptingTeardowns) throw new WorkflowLifecycleError('onTeardown() can only be called while Project setup is running.', {
584
- projectName: options.project.name
585
- });
586
- if ('function' != typeof teardown) throw new WorkflowLifecycleError('Project onTeardown() requires a teardown function.', {
587
- projectName: options.project.name
588
- });
589
- teardownStack.push({
590
- registrationIndex: teardownStack.length,
591
- teardown
592
- });
593
- };
594
- return {
595
- get context () {
596
- return context;
597
- },
598
- get signal () {
599
- return controller.signal;
600
- },
601
- get canRun () {
602
- return started && !setupError && !controller.signal.aborted;
603
- },
604
- async start () {
605
- if (started) throw new WorkflowLifecycleError('Project runtime has already started.', {
606
- projectName: options.project.name
607
- });
608
- started = true;
609
- if (controller.signal.aborted) {
610
- acceptingTeardowns = false;
611
- return createResult('failed');
612
- }
613
- if (options.setup) try {
614
- context = await options.setup.setup({
615
- project: options.project,
616
- env: Object.freeze({
617
- ...process.env
618
- }),
619
- signal: controller.signal,
620
- onTeardown
621
- });
622
- } catch (error) {
623
- setupError = new ProjectSetupError(error, {
624
- projectName: options.project.name
625
- });
626
- } finally{
627
- acceptingTeardowns = false;
628
- }
629
- return createResult(setupError ? 'failed' : 'success');
630
- },
631
- async finish (requestedStatus = 'success') {
632
- if (finishedResult) return finishedResult;
633
- if (!started) throw new WorkflowLifecycleError('Project runtime must start before it can finish.', {
634
- projectName: options.project.name
635
- });
636
- const statusBeforeTeardown = createResult(requestedStatus).status;
637
- const teardownErrors = [];
638
- for (const { registrationIndex, teardown } of teardownStack.reverse())try {
639
- await teardown({
640
- project: options.project,
641
- context,
642
- status: statusBeforeTeardown,
643
- ...setupError ? {
644
- setupError
645
- } : {}
646
- });
647
- } catch (error) {
648
- teardownErrors.push(new ProjectTeardownError(error, {
649
- projectName: options.project.name,
650
- registrationIndex
651
- }));
652
- }
653
- parentSignal?.removeEventListener('abort', abortFromParent);
654
- finishedResult = createResult(requestedStatus, teardownErrors);
655
- return finishedResult;
656
- },
657
- abort (reason) {
658
- controller.abort(reason);
659
- }
660
- };
661
- };
662
- const assertJsonSerializable = (value, label, node)=>{
663
- const visit = (candidate, path, seen)=>{
664
- if (null === candidate || 'string' == typeof candidate || 'boolean' == typeof candidate) return;
665
- if ('number' == typeof candidate) {
666
- if (Number.isFinite(candidate)) return;
667
- throw new TypeError(`${path} contains a non-finite number.`);
668
- }
669
- if (void 0 === candidate) throw new TypeError(`${path} contains undefined.`);
670
- if ('object' != typeof candidate) throw new TypeError(`${path} is not JSON-serializable.`);
671
- if (!Array.isArray(candidate) && Object.getPrototypeOf(candidate) !== Object.prototype && null !== Object.getPrototypeOf(candidate)) throw new TypeError(`${path} must contain only plain objects.`);
672
- if (seen.has(candidate)) throw new TypeError(`${path} contains a cycle.`);
673
- seen.add(candidate);
674
- if (Array.isArray(candidate)) candidate.forEach((child, index)=>visit(child, `${path}[${index}]`, seen));
675
- else for (const [key, child] of Object.entries(candidate))visit(child, `${path}.${key}`, seen);
676
- seen.delete(candidate);
677
- };
678
- try {
679
- visit(value, label, new Set());
680
- } catch (error) {
681
- throw new NodeExecutionError(node, error);
682
- }
683
- };
684
- function validateNodeOutput(output, node) {
685
- if (void 0 === output) return;
686
- if ('object' != typeof output || null === output || Array.isArray(output)) throw new NodeExecutionError(node, new TypeError('Node output must be an object or undefined.'));
687
- if ('summary' in output && void 0 !== output.summary && 'string' != typeof output.summary) throw new NodeExecutionError(node, new TypeError('Node output summary must be a string.'));
688
- if ('data' in output && void 0 !== output.data) assertJsonSerializable(output.data, 'output.data', node);
689
- return output;
690
- }
691
- function createStepResultBase(step, startedAt, phase, stepIndex) {
692
- const endedAt = new Date();
693
- return {
694
- node: step.node,
695
- phase,
696
- stepIndex,
697
- input: step.input,
698
- meta: step.meta,
699
- startedAt: startedAt.toISOString(),
700
- endedAt: endedAt.toISOString(),
701
- durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
702
- };
703
- }
704
- async function executeNode(step, execute, phase, stepIndex, options) {
705
- const startedAt = new Date();
706
- const abortController = new AbortController();
707
- const parentSignal = options.parentSignal;
708
- const abortFromParent = ()=>abortController.abort(parentSignal?.reason);
709
- if (parentSignal?.aborted) abortFromParent();
710
- else parentSignal?.addEventListener('abort', abortFromParent, {
711
- once: true
712
- });
713
- const timeoutMs = step.meta.timeoutMs ?? options.defaultTimeoutMs;
714
- let timeout;
715
- let abortGraceTimeout;
716
- const execution = Promise.resolve().then(()=>{
717
- abortController.signal.throwIfAborted();
718
- return execute(abortController.signal);
719
- });
720
- let settleOnAbort;
721
- const abortedExecution = new Promise((_, reject)=>{
722
- settleOnAbort = ()=>{
723
- abortGraceTimeout = setTimeout(()=>{
724
- reject(abortController.signal.reason);
725
- }, 0);
726
- };
727
- if (abortController.signal.aborted) settleOnAbort();
728
- else abortController.signal.addEventListener('abort', settleOnAbort, {
729
- once: true
730
- });
731
- });
732
- const timeoutExecution = void 0 === timeoutMs ? void 0 : new Promise((_, reject)=>{
733
- timeout = setTimeout(()=>{
734
- if (abortController.signal.aborted) return void reject(abortController.signal.reason);
735
- const timeoutError = new StepTimeoutError(timeoutMs, step.node);
736
- abortController.abort(timeoutError);
737
- reject(timeoutError);
738
- }, timeoutMs);
739
- });
740
- const settledExecution = Promise.race([
741
- execution,
742
- abortedExecution,
743
- ...void 0 === timeoutExecution ? [] : [
744
- timeoutExecution
745
- ]
746
- ]);
747
- try {
748
- const output = validateNodeOutput(await settledExecution, step.node);
749
- return {
750
- ...createStepResultBase(step, startedAt, phase, stepIndex),
751
- status: 'success',
752
- continuedAfterError: false,
753
- ...void 0 === output ? {} : {
754
- output
755
- }
756
- };
757
- } catch (error) {
758
- return {
759
- ...createStepResultBase(step, startedAt, phase, stepIndex),
760
- status: 'failed',
761
- continuedAfterError: step.meta.continueOnError,
762
- error: normalizeNodeExecutionError(error, step.node)
763
- };
764
- } finally{
765
- if (void 0 !== timeout) clearTimeout(timeout);
766
- if (void 0 !== abortGraceTimeout) clearTimeout(abortGraceTimeout);
767
- if (void 0 !== settleOnAbort) abortController.signal.removeEventListener('abort', settleOnAbort);
768
- parentSignal?.removeEventListener('abort', abortFromParent);
769
- }
770
- }
771
- function createNodeReportCollector(node) {
772
- const traces = new Map();
773
- let accepting = true;
774
- return {
775
- collector: Object.freeze({
776
- addTrace (trace) {
777
- if (!accepting) throw new NodeExecutionError(node, new Error('Report traces can only be added while the current Step is running.'));
778
- if (!trace || 'object' != typeof trace || 'midscene-execution' !== trace.type) throw new NodeExecutionError(node, new TypeError('Report trace type must be "midscene-execution".'));
779
- if ('string' != typeof trace.executionId || 0 === trace.executionId.trim().length) throw new NodeExecutionError(node, new TypeError('Report trace executionId must be non-empty.'));
780
- const normalized = Object.freeze({
781
- type: 'midscene-execution',
782
- executionId: trace.executionId
783
- });
784
- traces.set(`${normalized.type}:${normalized.executionId}`, normalized);
785
- }
786
- }),
787
- close () {
788
- accepting = false;
789
- return Object.freeze([
790
- ...traces.values()
791
- ]);
792
- }
793
- };
794
- }
795
- async function parseNodeInput(node, input) {
796
- if (!node.inputSchema) return input;
797
- const parsed = await node.inputSchema.safeParseAsync(input);
798
- if (!parsed.success) throw NodeInputValidationError.fromZod(node.name, parsed.error);
799
- return parsed.data;
800
- }
801
- async function executeStep(step, node, target, context, execution = {}) {
802
- const phase = 'case' === target.scope ? target.case.phase : target.document.phase;
803
- const stepIndex = 'case' === target.scope ? target.case.stepIndex : target.document.stepIndex;
804
- const reportCollector = createNodeReportCollector(step.node);
805
- let result;
806
- try {
807
- result = await executeNode(step, async (signal)=>{
808
- const input = await parseNodeInput(node, step.input);
809
- signal.throwIfAborted();
810
- const common = {
811
- input,
812
- $: step.meta,
813
- signal,
814
- context,
815
- report: reportCollector.collector,
816
- onTeardown: (teardown)=>{
817
- if (!execution.onTeardown) throw new NodeExecutionError(step.node, new Error('The current execution scope cannot register teardown.'));
818
- execution.onTeardown(step.node, teardown);
819
- }
820
- };
821
- return 'case' === target.scope ? node.execute({
822
- ...common,
823
- scope: 'case',
824
- case: target.case
825
- }) : node.execute({
826
- ...common,
827
- scope: 'document',
828
- document: target.document
829
- });
830
- }, phase, stepIndex, {
831
- parentSignal: execution.signal,
832
- defaultTimeoutMs: execution.defaultTimeoutMs
833
- });
834
- } finally{
835
- const traces = reportCollector.close();
836
- if (traces.length > 0 && result) result = {
837
- ...result,
838
- report: {
839
- traces
840
- }
841
- };
842
- }
843
- if (!result) throw new NodeExecutionError(step.node, new Error('Step execution completed without a result.'));
844
- return result;
845
- }
846
- const reportPathsFromTeardown = (result)=>{
847
- if (void 0 === result) return [];
848
- if ('object' != typeof result || null === result || Array.isArray(result)) throw new TypeError('Node teardown result must be an object or undefined.');
849
- const paths = result.reportPaths;
850
- if (void 0 === paths) return [];
851
- if (!Array.isArray(paths)) throw new TypeError('Node teardown reportPaths must be an array.');
852
- return paths.map((path, index)=>{
853
- if ('string' != typeof path || 0 === path.trim().length) throw new TypeError(`Node teardown reportPaths[${index}] must be a non-empty string.`);
854
- if (!isAbsolute(path)) throw new TypeError(`Node teardown reportPaths[${index}] must be an absolute path.`);
855
- return path;
856
- });
857
- };
858
- function createDocumentRuntime(document, options) {
859
- const documentRunId = options.createDocumentRunId?.() ?? randomUUID();
860
- const startedAt = new Date();
861
- const beforeAll = [];
862
- const afterAll = [];
863
- const nodes = {
864
- beforeAll: document.lifecycle.beforeAll.map((step)=>options.resolveNode(step.node)),
865
- afterAll: document.lifecycle.afterAll.map((step)=>options.resolveNode(step.node))
866
- };
867
- const project = options.project ?? Object.freeze({
868
- projectId: document.projectId,
869
- name: document.projectId,
870
- tags: Object.freeze({
871
- include: [],
872
- exclude: []
873
- }),
874
- retry: 0,
875
- variables: Object.freeze({})
876
- });
877
- const context = options.projectContext;
878
- const signal = options.signal ?? new AbortController().signal;
879
- const nodeTeardownStack = [];
880
- let acceptingNodeTeardowns = true;
881
- let started = false;
882
- let finishedResult;
883
- const reportPaths = new Set();
884
- const createResult = (teardownErrors = [])=>{
885
- const endedAt = new Date();
886
- return {
887
- documentId: document.documentId,
888
- documentRunId,
889
- projectId: document.projectId,
890
- projectName: project.name,
891
- sourcePath: document.sourcePath,
892
- status: beforeAll.some((step)=>'failed' === step.status) || afterAll.some((step)=>'failed' === step.status) || teardownErrors.length > 0 ? 'failed' : 'success',
893
- startedAt: startedAt.toISOString(),
894
- endedAt: endedAt.toISOString(),
895
- durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
896
- beforeAll: [
897
- ...beforeAll
898
- ],
899
- afterAll: [
900
- ...afterAll
901
- ],
902
- ...teardownErrors.length > 0 ? {
903
- teardownErrors
904
- } : {},
905
- ...reportPaths.size > 0 ? {
906
- reportPaths: [
907
- ...reportPaths
908
- ]
909
- } : {}
910
- };
911
- };
912
- const runPhase = async (phase, results, executionSignal = signal)=>{
913
- for (const [stepIndex, step] of document.lifecycle[phase].entries()){
914
- const documentContext = {
915
- documentId: document.documentId,
916
- documentRunId,
917
- projectId: document.projectId,
918
- projectName: project.name,
919
- sourcePath: document.sourcePath,
920
- phase,
921
- stepIndex
922
- };
923
- const stepInfo = {
924
- scope: 'document',
925
- node: step.node,
926
- stepCount: document.lifecycle[phase].length,
927
- document: documentContext
928
- };
929
- await options.onStepStart?.(stepInfo);
930
- const result = await executeStep(step, nodes[phase][stepIndex], {
931
- scope: 'document',
932
- document: documentContext
933
- }, context, {
934
- signal: executionSignal,
935
- defaultTimeoutMs: options.defaultTimeoutMs,
936
- onTeardown (node, teardown) {
937
- if (!acceptingNodeTeardowns) throw new WorkflowLifecycleError('Node teardown can only be registered while a workflow document is running.', {
938
- documentId: document.documentId,
939
- documentRunId,
940
- node
941
- });
942
- if ('function' != typeof teardown) throw new WorkflowLifecycleError('Node onTeardown() requires a teardown function.', {
943
- documentId: document.documentId,
944
- documentRunId,
945
- node
946
- });
947
- nodeTeardownStack.push({
948
- registrationIndex: nodeTeardownStack.length,
949
- node,
950
- teardown
951
- });
952
- }
953
- });
954
- await options.onStepResult?.(stepInfo, result);
955
- results.push(result);
956
- if ('failed' === result.status && !result.continuedAfterError) break;
957
- }
958
- };
959
- return {
960
- get context () {
961
- return context;
962
- },
963
- get canRunCases () {
964
- return started && !signal.aborted && !beforeAll.some((step)=>'failed' === step.status);
965
- },
966
- async start () {
967
- if (started) throw new WorkflowLifecycleError('Workflow document runtime has already started.', {
968
- documentId: document.documentId,
969
- documentRunId
970
- });
971
- started = true;
972
- if (!signal.aborted) await runPhase('beforeAll', beforeAll);
973
- return createResult();
974
- },
975
- async finish () {
976
- if (finishedResult) return finishedResult;
977
- if (!started) throw new WorkflowLifecycleError('Workflow document runtime must start before it can finish.', {
978
- documentId: document.documentId,
979
- documentRunId
980
- });
981
- let finishError;
982
- try {
983
- await runPhase('afterAll', afterAll, signal.aborted ? new AbortController().signal : signal);
984
- } catch (error) {
985
- finishError = error;
986
- }
987
- acceptingNodeTeardowns = false;
988
- const teardownErrors = [];
989
- for (const { registrationIndex, node, teardown } of nodeTeardownStack.reverse())try {
990
- for (const path of reportPathsFromTeardown(await teardown()))reportPaths.add(path);
991
- } catch (error) {
992
- teardownErrors.push(new NodeScopeTeardownError(error, {
993
- scope: 'document',
994
- scopeId: documentRunId,
995
- node,
996
- registrationIndex
997
- }));
998
- }
999
- finishedResult = createResult(teardownErrors);
1000
- if (finishError) throw finishError;
1001
- await options.onResult?.(finishedResult);
1002
- return finishedResult;
1003
- }
1004
- };
1005
- }
1006
- async function runCollectedCase(collectedCase, options) {
1007
- const phases = {
1008
- beforeEach: options.beforeEach ?? [],
1009
- steps: collectedCase.definition.steps,
1010
- afterEach: options.afterEach ?? []
1011
- };
1012
- const nodes = {
1013
- beforeEach: phases.beforeEach.map((step)=>options.resolveNode(step.node)),
1014
- steps: phases.steps.map((step)=>options.resolveNode(step.node)),
1015
- afterEach: phases.afterEach.map((step)=>options.resolveNode(step.node))
1016
- };
1017
- const runId = options.createRunId?.() ?? randomUUID();
1018
- const startedAt = new Date();
1019
- const beforeEach = [];
1020
- const steps = [];
1021
- const afterEach = [];
1022
- const teardownStack = [];
1023
- let acceptingTeardowns = true;
1024
- const onTeardown = (node, teardown)=>{
1025
- if (!acceptingTeardowns) throw new WorkflowLifecycleError('Node teardown can only be registered while a case attempt is running.', {
1026
- runId,
1027
- node
1028
- });
1029
- if ('function' != typeof teardown) throw new WorkflowLifecycleError('Node onTeardown() requires a teardown function.', {
1030
- runId,
1031
- node
1032
- });
1033
- teardownStack.push({
1034
- registrationIndex: teardownStack.length,
1035
- node,
1036
- teardown
1037
- });
1038
- };
1039
- const runPhase = async (phase, results, signal = options.signal)=>{
1040
- for (const [stepIndex, step] of phases[phase].entries()){
1041
- const caseContext = {
1042
- caseId: collectedCase.caseId,
1043
- runId,
1044
- projectName: options.projectName ?? collectedCase.projectId,
1045
- attemptIndex: options.attemptIndex ?? 0,
1046
- name: collectedCase.definition.name,
1047
- sourcePath: collectedCase.sourcePath,
1048
- caseIndex: collectedCase.caseIndex,
1049
- phase,
1050
- stepIndex
1051
- };
1052
- const stepInfo = {
1053
- scope: 'case',
1054
- node: step.node,
1055
- stepCount: phases[phase].length,
1056
- case: caseContext
1057
- };
1058
- await options.onStepStart?.(stepInfo);
1059
- const result = await executeStep(step, nodes[phase][stepIndex], {
1060
- scope: 'case',
1061
- case: caseContext
1062
- }, options.context, {
1063
- signal,
1064
- defaultTimeoutMs: options.defaultTimeoutMs,
1065
- onTeardown
1066
- });
1067
- await options.onStepResult?.(stepInfo, result);
1068
- results.push(result);
1069
- if ('failed' === result.status && !result.continuedAfterError) break;
1070
- }
1071
- };
1072
- let executionError;
1073
- try {
1074
- await runPhase('beforeEach', beforeEach);
1075
- if (!beforeEach.some((step)=>'failed' === step.status)) await runPhase('steps', steps);
1076
- } catch (error) {
1077
- executionError = error;
1078
- }
1079
- try {
1080
- const cleanupSignal = options.signal?.aborted ? new AbortController().signal : options.signal;
1081
- await runPhase('afterEach', afterEach, cleanupSignal);
1082
- } catch (error) {
1083
- executionError ??= error;
1084
- }
1085
- acceptingTeardowns = false;
1086
- const teardownErrors = [];
1087
- const reportPaths = new Set();
1088
- for (const { registrationIndex, node, teardown } of teardownStack.reverse())try {
1089
- for (const path of reportPathsFromTeardown(await teardown()))reportPaths.add(path);
1090
- } catch (error) {
1091
- teardownErrors.push(new NodeScopeTeardownError(error, {
1092
- scope: 'case',
1093
- scopeId: runId,
1094
- node,
1095
- registrationIndex
1096
- }));
1097
- }
1098
- if (executionError) throw executionError;
1099
- const endedAt = new Date();
1100
- const result = {
1101
- caseId: collectedCase.caseId,
1102
- runId,
1103
- projectName: options.projectName ?? collectedCase.projectId,
1104
- attemptIndex: options.attemptIndex ?? 0,
1105
- name: collectedCase.definition.name,
1106
- sourcePath: collectedCase.sourcePath,
1107
- caseIndex: collectedCase.caseIndex,
1108
- status: beforeEach.some((step)=>'failed' === step.status) || steps.some((step)=>'failed' === step.status) || afterEach.some((step)=>'failed' === step.status) || teardownErrors.length > 0 ? 'failed' : 'success',
1109
- beforeEach,
1110
- steps,
1111
- afterEach,
1112
- ...teardownErrors.length > 0 ? {
1113
- teardownErrors
1114
- } : {},
1115
- ...reportPaths.size > 0 ? {
1116
- reportPaths: [
1117
- ...reportPaths
1118
- ]
1119
- } : {},
1120
- startedAt: startedAt.toISOString(),
1121
- endedAt: endedAt.toISOString(),
1122
- durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
1123
- };
1124
- await options.onResult?.(result);
1125
- return result;
1126
- }
1127
- const asNotRun = (collectedCase, projectName, reason)=>({
1128
- caseId: collectedCase.caseId,
1129
- projectName,
1130
- name: collectedCase.definition.name,
1131
- sourcePath: collectedCase.sourcePath,
1132
- caseIndex: collectedCase.caseIndex,
1133
- status: 'not-run',
1134
- notRunReason: reason
1135
- });
1136
- async function runWorkflowDocument(document, options) {
1137
- const createDocumentRunId = options.createDocumentRunId;
1138
- const createCaseRunId = options.createCaseRunId;
1139
- const projectName = options.project?.name ?? document.projectId;
1140
- const retry = options.retry ?? options.project?.retry ?? 0;
1141
- if (!Number.isInteger(retry) || retry < 0) throw new TypeError('Workflow document retry must be a non-negative integer.');
1142
- const runtime = createDocumentRuntime(document, {
1143
- resolveNode: options.resolveNode,
1144
- project: options.project,
1145
- projectContext: options.projectContext,
1146
- signal: options.signal,
1147
- defaultTimeoutMs: options.defaultTimeoutMs,
1148
- onStepStart: options.onStepStart,
1149
- onStepResult: options.onStepResult,
1150
- onResult: options.onDocumentResult,
1151
- createDocumentRunId: createDocumentRunId ? ()=>createDocumentRunId(document) : void 0
1152
- });
1153
- const cases = [];
1154
- let runtimeStarted = false;
1155
- let documentResult;
1156
- let executionError;
1157
- let fatalError = false;
1158
- try {
1159
- runtimeStarted = true;
1160
- await runtime.start();
1161
- if (runtime.canRunCases) for (const collectedCase of document.cases){
1162
- if (fatalError || options.shouldStop?.()) {
1163
- const outcome = asNotRun(collectedCase, projectName, fatalError ? 'fatal-error' : options.stopReason?.() ?? 'interrupted');
1164
- cases.push(outcome);
1165
- await options.onCaseOutcome?.(outcome);
1166
- continue;
1167
- }
1168
- await options.onCaseStart?.(collectedCase);
1169
- const attempts = [];
1170
- for(let attemptIndex = 0; attemptIndex <= retry; attemptIndex += 1){
1171
- const run = await runCollectedCase(collectedCase, {
1172
- resolveNode: options.resolveNode,
1173
- beforeEach: document.lifecycle.beforeEach,
1174
- afterEach: document.lifecycle.afterEach,
1175
- context: runtime.context,
1176
- projectName,
1177
- attemptIndex,
1178
- signal: options.signal,
1179
- defaultTimeoutMs: options.defaultTimeoutMs,
1180
- onStepStart: options.onStepStart,
1181
- onStepResult: options.onStepResult,
1182
- onResult: options.onCaseResult,
1183
- createRunId: createCaseRunId ? ()=>createCaseRunId(collectedCase, attemptIndex) : void 0
1184
- });
1185
- attempts.push(run);
1186
- fatalError = options.isFatalError?.(run) ?? false;
1187
- if ('success' === run.status || fatalError) break;
1188
- }
1189
- const run = attempts.at(-1);
1190
- const outcome = {
1191
- caseId: collectedCase.caseId,
1192
- projectName,
1193
- name: collectedCase.definition.name,
1194
- sourcePath: collectedCase.sourcePath,
1195
- caseIndex: collectedCase.caseIndex,
1196
- status: run.status,
1197
- run,
1198
- attempts
1199
- };
1200
- cases.push(outcome);
1201
- await options.onCaseOutcome?.(outcome);
1202
- }
1203
- else {
1204
- const reason = options.signal?.aborted ? options.stopReason?.() ?? 'interrupted' : 'document-start-failed';
1205
- cases.push(...document.cases.map((collectedCase)=>asNotRun(collectedCase, projectName, reason)));
1206
- }
1207
- } catch (error) {
1208
- executionError = error;
1209
- } finally{
1210
- if (runtimeStarted) try {
1211
- documentResult = await runtime.finish();
1212
- } catch (error) {
1213
- executionError ??= error;
1214
- }
1215
- }
1216
- if (executionError) throw executionError;
1217
- if (!documentResult) throw new Error(`Workflow document "${document.sourcePath}" did not produce a result.`);
1218
- return {
1219
- document: documentResult,
1220
- cases
1221
- };
1222
- }
1223
- const supportedMetaKeys = new Set([
1224
- 'timeout',
1225
- 'continue-on-error'
1226
- ]);
1227
- function isMapping(value) {
1228
- return 'object' == typeof value && null !== value && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || null === Object.getPrototypeOf(value));
1229
- }
1230
- function formatStep(index) {
1231
- return `step ${index + 1}`;
1232
- }
1233
- function normalizeMeta(value, index) {
1234
- if (void 0 === value) return {
1235
- continueOnError: false
1236
- };
1237
- if (!isMapping(value)) throw new WorkflowParseError(`${formatStep(index)} "$" must be a mapping.`, {
1238
- index
1239
- });
1240
- for (const key of Object.keys(value))if (!supportedMetaKeys.has(key)) throw new WorkflowParseError(`${formatStep(index)} has unsupported engine metadata "${key}".`, {
1241
- index,
1242
- key
1243
- });
1244
- const timeout = value.timeout;
1245
- if (void 0 !== timeout && ('number' != typeof timeout || !Number.isFinite(timeout) || timeout <= 0)) throw new WorkflowParseError(`${formatStep(index)} "$.timeout" must be a positive number of milliseconds.`, {
1246
- index,
1247
- timeout
1248
- });
1249
- const continueOnError = value['continue-on-error'];
1250
- if (void 0 !== continueOnError && 'boolean' != typeof continueOnError) throw new WorkflowParseError(`${formatStep(index)} "$.continue-on-error" must be a boolean.`, {
1251
- index,
1252
- continueOnError
1253
- });
1254
- return {
1255
- ...void 0 === timeout ? {} : {
1256
- timeoutMs: timeout
1257
- },
1258
- continueOnError: continueOnError ?? false
1259
- };
1260
- }
1261
- function normalizeStep(value, index = 0, resolveNode) {
1262
- if (!isMapping(value)) throw new WorkflowParseError(`${formatStep(index)} must be a mapping.`, {
1263
- index
1264
- });
1265
- const entries = Object.entries(value);
1266
- if (1 !== entries.length) throw new WorkflowParseError(`${formatStep(index)} must contain exactly one node.`, {
1267
- index,
1268
- nodes: Object.keys(value)
1269
- });
1270
- const [node, rawValue] = entries[0];
1271
- if (0 === node.trim().length) throw new WorkflowParseError(`${formatStep(index)} node name must not be empty.`, {
1272
- index
1273
- });
1274
- if ('string' == typeof rawValue) {
1275
- const resolvedNode = resolveNode?.(node);
1276
- const stringInputKey = resolvedNode ? resolvedNode.stringInputKey : 'prompt';
1277
- if (false === stringInputKey || void 0 === stringInputKey) throw new WorkflowParseError(`${formatStep(index)} node "${node}" does not accept string shorthand.`, {
1278
- index,
1279
- node
1280
- });
1281
- return {
1282
- node,
1283
- input: {
1284
- [stringInputKey]: rawValue
1285
- },
1286
- meta: {
1287
- continueOnError: false
1288
- }
1289
- };
1290
- }
1291
- if (!isMapping(rawValue)) throw new WorkflowParseError(`${formatStep(index)} value must be a string shorthand or a mapping.`, {
1292
- index,
1293
- node
1294
- });
1295
- const { $: rawMeta, ...input } = rawValue;
1296
- return {
1297
- node,
1298
- input,
1299
- meta: normalizeMeta(rawMeta, index)
1300
- };
1301
- }
1302
- function normalizeSteps(steps, resolveNode) {
1303
- if (!Array.isArray(steps)) throw new WorkflowParseError('Steps must be an array.');
1304
- return steps.map((step, index)=>normalizeStep(step, index, resolveNode));
1305
- }
288
+ const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
289
+ projectId,
290
+ sourcePath,
291
+ caseIndex,
292
+ ...invocationIndex ? [
293
+ invocationIndex
294
+ ] : []
295
+ ])).digest('hex');
296
+ const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=>createHash('sha256').update(JSON.stringify([
297
+ projectId,
298
+ sourcePath,
299
+ ...invocationIndex ? [
300
+ invocationIndex
301
+ ] : []
302
+ ])).digest('hex');
303
+ const normalize_normalizeSteps = normalizeSteps;
1306
304
  const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
1307
305
  const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
1308
306
  const exactProjectExpression = /^\$\{([^{}]+)\}$/;
@@ -1346,7 +344,27 @@ const resolveWorkflowVariables = (value, options)=>{
1346
344
  ]));
1347
345
  return value;
1348
346
  };
1349
- const collect_isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
347
+ function _define_property(obj, key, value) {
348
+ if (key in obj) Object.defineProperty(obj, key, {
349
+ value: value,
350
+ enumerable: true,
351
+ configurable: true,
352
+ writable: true
353
+ });
354
+ else obj[key] = value;
355
+ return obj;
356
+ }
357
+ const isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
358
+ class NativeWorkflowFormatError extends WorkflowParseError {
359
+ constructor(actualFormat, sourcePath){
360
+ super('legacy' === actualFormat ? `Legacy tasks/flow YAML is not supported by midscene-test: ${sourcePath}. Run it with \`midscene ${sourcePath}\`, or migrate the file to cases/steps.` : 'legacy-batch' === actualFormat ? `Legacy YAML batch configuration is not a midscene-test workflow document: ${sourcePath}. Run it with \`midscene --config ${sourcePath}\`, or migrate it to a native project config.` : `A workflow file cannot mix legacy tasks/flow with native cases/steps or lifecycle hooks: ${sourcePath}. Keep the formats in separate files and run each file with its matching command.`, {
361
+ sourcePath,
362
+ actualFormat,
363
+ expectedFormat: 'native'
364
+ }), _define_property(this, "actualFormat", void 0);
365
+ this.actualFormat = actualFormat;
366
+ }
367
+ }
1350
368
  const collect_rejectUnknownKeys = (value, allowed, label)=>{
1351
369
  const allowedSet = new Set(allowed);
1352
370
  const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
@@ -1354,15 +372,8 @@ const collect_rejectUnknownKeys = (value, allowed, label)=>{
1354
372
  field: unknown[0]
1355
373
  });
1356
374
  };
1357
- const createCaseId = (projectId, sourcePath, caseIndex)=>createHash('sha256').update(JSON.stringify([
1358
- projectId,
1359
- sourcePath,
1360
- caseIndex
1361
- ])).digest('hex');
1362
- const createWorkflowDocumentId = (projectId, sourcePath)=>createHash('sha256').update(JSON.stringify([
1363
- projectId,
1364
- sourcePath
1365
- ])).digest('hex');
375
+ const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
376
+ const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
1366
377
  function collectWorkflowDocument(source, options) {
1367
378
  let parsed;
1368
379
  try {
@@ -1374,7 +385,9 @@ function collectWorkflowDocument(source, options) {
1374
385
  sourcePath: source.sourcePath
1375
386
  }, error);
1376
387
  }
1377
- if (!collect_isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
388
+ if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
389
+ const format = classifyWorkflowDocumentFormat(parsed);
390
+ if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
1378
391
  collect_rejectUnknownKeys(parsed, [
1379
392
  'beforeAll',
1380
393
  'beforeEach',
@@ -1406,7 +419,7 @@ function collectWorkflowDocument(source, options) {
1406
419
  if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
1407
420
  phase
1408
421
  });
1409
- return normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
422
+ return normalize_normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
1410
423
  const resolved = options.resolveNode(normalized.node);
1411
424
  if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
1412
425
  phase,
@@ -1424,7 +437,7 @@ function collectWorkflowDocument(source, options) {
1424
437
  };
1425
438
  const ids = new Set();
1426
439
  const cases = parsed.cases.map((definition, caseIndex)=>{
1427
- if (!collect_isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
440
+ if (!isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
1428
441
  caseIndex
1429
442
  });
1430
443
  collect_rejectUnknownKeys(definition, [
@@ -1442,7 +455,7 @@ function collectWorkflowDocument(source, options) {
1442
455
  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.`, {
1443
456
  caseIndex
1444
457
  });
1445
- const steps = normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
458
+ const steps = normalize_normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
1446
459
  if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
1447
460
  caseIndex,
1448
461
  stepIndex,
@@ -1450,452 +463,44 @@ function collectWorkflowDocument(source, options) {
1450
463
  });
1451
464
  return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
1452
465
  });
1453
- const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
1454
- if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
1455
- caseId
1456
- });
1457
- ids.add(caseId);
1458
- return {
1459
- caseId,
1460
- projectId: source.projectId,
1461
- sourcePath,
1462
- caseIndex,
1463
- definition: {
1464
- name: definition.name,
1465
- tags: tags,
1466
- steps
1467
- }
1468
- };
1469
- });
1470
- return {
1471
- documentId: createWorkflowDocumentId(source.projectId, sourcePath),
1472
- projectId: source.projectId,
1473
- sourcePath,
1474
- lifecycle,
1475
- cases
1476
- };
1477
- }
1478
- const MAX_DEPTH = 6;
1479
- const MAX_STRING_LENGTH = 2000;
1480
- const MAX_ARRAY_ITEMS = 50;
1481
- const MAX_OBJECT_KEYS = 50;
1482
- const MAX_SERIALIZED_BYTES = 65536;
1483
- const TRUNCATED_VALUE = '[Truncated: display value exceeded 64 KiB]';
1484
- const normalizedSecretKeys = [
1485
- 'password',
1486
- 'passwd',
1487
- 'secret',
1488
- 'token',
1489
- 'authorization',
1490
- 'cookie',
1491
- 'apikey'
1492
- ];
1493
- const normalizedKey = (key)=>key.toLowerCase().replace(/[^a-z0-9]/g, '');
1494
- const isSecretKey = (key)=>{
1495
- const normalized = normalizedKey(key);
1496
- return normalizedSecretKeys.some((secret)=>normalized.includes(secret));
1497
- };
1498
- const objectPath = (parent, key)=>/^[A-Za-z_$][\w$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
1499
- const stringByteLength = (value)=>Buffer.byteLength(value, 'utf8');
1500
- function sanitizeReportText(value, maximumLength = MAX_STRING_LENGTH) {
1501
- return value.length <= maximumLength ? value : `${value.slice(0, maximumLength)}… [truncated]`;
1502
- }
1503
- function sanitizeReportValue(value) {
1504
- const truncatedPaths = new Set();
1505
- const redactedPaths = new Set();
1506
- const ancestors = new WeakSet();
1507
- let remainingBytes = MAX_SERIALIZED_BYTES;
1508
- const consume = (path, text)=>{
1509
- remainingBytes -= stringByteLength(text);
1510
- if (remainingBytes >= 0) return true;
1511
- truncatedPaths.add(path);
1512
- return false;
1513
- };
1514
- const visit = (current, path, depth)=>{
1515
- if (remainingBytes <= 0) {
1516
- truncatedPaths.add(path);
1517
- return '[Truncated: size limit]';
1518
- }
1519
- if (null === current || 'boolean' == typeof current) {
1520
- consume(path, String(current));
1521
- return current;
1522
- }
1523
- if ('string' == typeof current) {
1524
- if (current.length > MAX_STRING_LENGTH) {
1525
- truncatedPaths.add(path);
1526
- const shortened = `${current.slice(0, MAX_STRING_LENGTH)}… [truncated]`;
1527
- consume(path, shortened);
1528
- return shortened;
1529
- }
1530
- consume(path, current);
1531
- return current;
1532
- }
1533
- if ('number' == typeof current) {
1534
- if (Number.isFinite(current)) {
1535
- consume(path, String(current));
1536
- return current;
1537
- }
1538
- truncatedPaths.add(path);
1539
- return `[Unsupported number: ${String(current)}]`;
1540
- }
1541
- if ('bigint' == typeof current) {
1542
- truncatedPaths.add(path);
1543
- return `[Unsupported bigint: ${String(current)}]`;
1544
- }
1545
- if (void 0 === current || 'function' == typeof current || 'symbol' == typeof current) {
1546
- truncatedPaths.add(path);
1547
- return `[Unsupported ${typeof current}]`;
1548
- }
1549
- if (depth >= MAX_DEPTH) {
1550
- truncatedPaths.add(path);
1551
- return '[Truncated: maximum depth]';
1552
- }
1553
- if (current instanceof Date) {
1554
- const date = Number.isNaN(current.getTime()) ? '[Invalid Date]' : current.toISOString();
1555
- consume(path, date);
1556
- return date;
1557
- }
1558
- if (ancestors.has(current)) {
1559
- truncatedPaths.add(path);
1560
- return '[Unsupported circular value]';
1561
- }
1562
- if (Array.isArray(current)) {
1563
- ancestors.add(current);
1564
- const output = [];
1565
- const itemCount = Math.min(current.length, MAX_ARRAY_ITEMS);
1566
- for(let index = 0; index < itemCount; index += 1){
1567
- if (remainingBytes <= 0) break;
1568
- output.push(visit(current[index], `${path}[${index}]`, depth + 1));
1569
- }
1570
- if (current.length > output.length) truncatedPaths.add(path);
1571
- ancestors.delete(current);
1572
- return output;
1573
- }
1574
- const prototype = Object.getPrototypeOf(current);
1575
- if (prototype !== Object.prototype && null !== prototype) {
1576
- truncatedPaths.add(path);
1577
- const constructorName = current.constructor?.name ?? 'object';
1578
- return `[Unsupported object: ${constructorName}]`;
1579
- }
1580
- ancestors.add(current);
1581
- const output = {};
1582
- const keys = Object.keys(current);
1583
- for (const key of keys.slice(0, MAX_OBJECT_KEYS)){
1584
- const childPath = objectPath(path, key);
1585
- if (!consume(childPath, key)) break;
1586
- if (isSecretKey(key)) {
1587
- output[key] = '[REDACTED]';
1588
- redactedPaths.add(childPath);
1589
- } else output[key] = visit(current[key], childPath, depth + 1);
1590
- if (remainingBytes <= 0) break;
1591
- }
1592
- if (keys.length > Object.keys(output).length) truncatedPaths.add(path);
1593
- ancestors.delete(current);
1594
- return output;
1595
- };
1596
- const sanitized = visit(value, '$', 0);
1597
- const serialized = JSON.stringify(sanitized);
1598
- if (void 0 === serialized || stringByteLength(serialized) > MAX_SERIALIZED_BYTES) return {
1599
- value: TRUNCATED_VALUE,
1600
- truncatedPaths: [
1601
- '$'
1602
- ]
1603
- };
1604
- return {
1605
- value: sanitized,
1606
- ...truncatedPaths.size > 0 ? {
1607
- truncatedPaths: [
1608
- ...truncatedPaths
1609
- ]
1610
- } : {},
1611
- ...redactedPaths.size > 0 ? {
1612
- redactedPaths: [
1613
- ...redactedPaths
1614
- ]
1615
- } : {}
1616
- };
1617
- }
1618
- function sanitizeReportError(error) {
1619
- if (error instanceof Error) {
1620
- const workflowLike = error;
1621
- return {
1622
- name: sanitizeReportText(error.name || 'Error', 200),
1623
- message: sanitizeReportText(error.message || String(error)),
1624
- ...'string' == typeof workflowLike.code ? {
1625
- code: sanitizeReportText(workflowLike.code, 200)
1626
- } : {},
1627
- ...void 0 === workflowLike.details ? {} : {
1628
- details: sanitizeReportValue(workflowLike.details)
1629
- }
1630
- };
1631
- }
1632
- return {
1633
- name: 'Error',
1634
- message: sanitizeReportText(String(error))
1635
- };
1636
- }
1637
- const warnReport = getDebug('test-runner:report-assembler', {
1638
- console: true
1639
- });
1640
- const exactMidsceneDetailNodes = new Set([
1641
- 'aiAct',
1642
- 'aiAssert',
1643
- 'recordToReport'
1644
- ]);
1645
- const scopedExecutionKey = (scopeId, executionId)=>`${scopeId}\u0000${executionId}`;
1646
- const reportSourcesForScope = (context, scopeId)=>context.sourcesByScope.get(scopeId) ?? [];
1647
- const addDiagnostic = (context, diagnostic)=>{
1648
- context.diagnostics.push(diagnostic);
1649
- const key = JSON.stringify([
1650
- diagnostic.code,
1651
- diagnostic.scopeId,
1652
- diagnostic.executionId
1653
- ]);
1654
- if (!context.warnedDiagnostics.has(key)) {
1655
- context.warnedDiagnostics.add(key);
1656
- warnReport(diagnostic.message);
1657
- }
1658
- };
1659
- const buildStep = (step, scopeId, context)=>{
1660
- const details = [];
1661
- const unresolvedExecutionIds = [];
1662
- for (const trace of step.report?.traces ?? []){
1663
- const previousScope = context.tracedScopeByExecution.get(trace.executionId);
1664
- if (previousScope && previousScope !== scopeId) throw new Error(`Agent execution ${trace.executionId} was traced by multiple test scopes (${previousScope}, ${scopeId}).`);
1665
- context.tracedScopeByExecution.set(trace.executionId, scopeId);
1666
- const detail = context.detailsByScopeAndExecution.get(scopedExecutionKey(scopeId, trace.executionId));
1667
- if (detail) details.push(detail);
1668
- else unresolvedExecutionIds.push(trace.executionId);
1669
- }
1670
- let agentDetailDiagnostic;
1671
- if (unresolvedExecutionIds.length > 0) {
1672
- agentDetailDiagnostic = `Agent detail could not be resolved for ${unresolvedExecutionIds.join(', ')}.`;
1673
- const sources = reportSourcesForScope(context, scopeId);
1674
- for (const executionId of unresolvedExecutionIds)addDiagnostic(context, {
1675
- level: 'warning',
1676
- code: 'agent-detail-unresolved',
1677
- message: `Agent detail could not be resolved for execution ${executionId} in test scope ${scopeId}.`,
1678
- scopeId,
1679
- executionId,
1680
- ...sources.length > 0 ? {
1681
- sourcePaths: sources.map((source)=>source.sourcePath)
1682
- } : {}
1683
- });
1684
- } else if (0 === details.length && exactMidsceneDetailNodes.has(step.node)) {
1685
- agentDetailDiagnostic = 'This Agent did not expose a stable execution reference for the Step.';
1686
- addDiagnostic(context, {
1687
- level: 'warning',
1688
- code: 'agent-detail-unavailable',
1689
- message: `No stable Agent execution reference was captured for ${step.node} in test scope ${scopeId}.`,
1690
- scopeId,
1691
- ...reportSourcesForScope(context, scopeId).length > 0 ? {
1692
- sourcePaths: reportSourcesForScope(context, scopeId).map((source)=>source.sourcePath)
1693
- } : {}
1694
- });
1695
- }
1696
- const prompt = 'string' == typeof step.input?.prompt ? step.input.prompt : void 0;
1697
- return {
1698
- id: `${scopeId}:${step.phase}:${step.stepIndex}`,
1699
- phase: step.phase,
1700
- stepIndex: step.stepIndex,
1701
- node: step.node,
1702
- ...prompt ? {
1703
- title: sanitizeReportText(prompt, 240)
1704
- } : {},
1705
- status: step.status,
1706
- continuedAfterError: step.continuedAfterError,
1707
- startedAt: step.startedAt,
1708
- endedAt: step.endedAt,
1709
- durationMs: step.durationMs,
1710
- input: sanitizeReportValue(step.input),
1711
- ...step.output ? {
1712
- output: {
1713
- ...void 0 === step.output.summary ? {} : {
1714
- summary: sanitizeReportText(step.output.summary)
1715
- },
1716
- ...void 0 === step.output.data ? {} : {
1717
- data: sanitizeReportValue(step.output.data)
1718
- }
1719
- }
1720
- } : {},
1721
- ...step.error ? {
1722
- error: sanitizeReportError(step.error)
1723
- } : {},
1724
- ...details.length > 0 ? {
1725
- agentDetails: details
1726
- } : {},
1727
- ...agentDetailDiagnostic ? {
1728
- agentDetailDiagnostic
1729
- } : {}
1730
- };
1731
- };
1732
- const buildAttempt = (attempt, context)=>({
1733
- attemptId: attempt.runId,
1734
- attemptIndex: attempt.attemptIndex,
1735
- status: attempt.status,
1736
- startedAt: attempt.startedAt,
1737
- endedAt: attempt.endedAt,
1738
- durationMs: attempt.durationMs,
1739
- beforeEach: attempt.beforeEach.map((step)=>buildStep(step, attempt.runId, context)),
1740
- steps: attempt.steps.map((step)=>buildStep(step, attempt.runId, context)),
1741
- afterEach: attempt.afterEach.map((step)=>buildStep(step, attempt.runId, context)),
1742
- ...attempt.teardownErrors?.length ? {
1743
- teardownErrors: attempt.teardownErrors.map(sanitizeReportError)
1744
- } : {},
1745
- ...reportSourcesForScope(context, attempt.runId).length > 0 ? {
1746
- scopeReportIds: reportSourcesForScope(context, attempt.runId).map((source)=>source.reportId)
1747
- } : {}
1748
- });
1749
- const buildCase = (outcome, context)=>({
1750
- caseId: outcome.caseId,
1751
- name: outcome.name,
1752
- caseIndex: outcome.caseIndex,
1753
- status: outcome.status,
1754
- ...outcome.notRunReason ? {
1755
- notRunReason: outcome.notRunReason
1756
- } : {},
1757
- attempts: (outcome.attempts ?? (outcome.run ? [
1758
- outcome.run
1759
- ] : [])).map((attempt)=>buildAttempt(attempt, context))
1760
- });
1761
- const buildDocument = (documentId, document, cases, context)=>{
1762
- const sourcePath = document?.sourcePath ?? cases[0]?.sourcePath ?? '<unknown>';
1763
- const status = document?.status ?? (cases.some((item)=>'success' !== item.status) ? 'failed' : 'success');
1764
- return {
1765
- documentId,
1766
- sourcePath,
1767
- status,
1768
- ...document ? {
1769
- startedAt: document.startedAt,
1770
- endedAt: document.endedAt,
1771
- durationMs: document.durationMs
1772
- } : {},
1773
- beforeAll: (document?.beforeAll ?? []).map((step)=>buildStep(step, document.documentRunId, context)),
1774
- cases: cases.map((outcome)=>buildCase(outcome, context)),
1775
- afterAll: (document?.afterAll ?? []).map((step)=>buildStep(step, document.documentRunId, context)),
1776
- ...document?.teardownErrors?.length ? {
1777
- teardownErrors: document.teardownErrors.map(sanitizeReportError)
1778
- } : {},
1779
- ...document && reportSourcesForScope(context, document.documentRunId).length > 0 ? {
1780
- scopeReportIds: reportSourcesForScope(context, document.documentRunId).map((source)=>source.reportId)
1781
- } : {}
1782
- };
1783
- };
1784
- const buildProject = (project, context)=>{
1785
- const documentById = new Map(project.documents.map((document)=>[
1786
- document.documentId,
1787
- document
1788
- ]));
1789
- const casesByDocumentId = new Map();
1790
- for (const outcome of project.cases){
1791
- const current = casesByDocumentId.get(outcome.documentId) ?? [];
1792
- current.push(outcome);
1793
- casesByDocumentId.set(outcome.documentId, current);
1794
- }
1795
- const documentIds = new Set([
1796
- ...project.documents.map((document)=>document.documentId),
1797
- ...project.cases.map((outcome)=>outcome.documentId)
1798
- ]);
1799
- return {
1800
- projectId: project.projectId,
1801
- name: project.name,
1802
- status: project.status,
1803
- retry: project.retry,
1804
- ...project.lifecycle ? {
1805
- lifecycle: {
1806
- status: project.lifecycle.status,
1807
- startedAt: project.lifecycle.startedAt,
1808
- endedAt: project.lifecycle.endedAt,
1809
- durationMs: project.lifecycle.durationMs,
1810
- ...project.lifecycle.setupError ? {
1811
- setupError: sanitizeReportError(project.lifecycle.setupError)
1812
- } : {},
1813
- ...project.lifecycle.teardownErrors?.length ? {
1814
- teardownErrors: project.lifecycle.teardownErrors.map(sanitizeReportError)
1815
- } : {}
1816
- }
1817
- } : {},
1818
- documents: [
1819
- ...documentIds
1820
- ].map((documentId)=>buildDocument(documentId, documentById.get(documentId), casesByDocumentId.get(documentId) ?? [], context)),
1821
- collectionErrors: project.collectionErrors.map((collectionError)=>({
1822
- sourcePath: collectionError.sourcePath,
1823
- error: sanitizeReportError(collectionError.error)
1824
- }))
1825
- };
1826
- };
1827
- function collectTestRunReportSources(result) {
1828
- const sources = [];
1829
- for (const project of result.projects){
1830
- for (const document of project.documents)for (const sourcePath of document.reportPaths ?? [])sources.push({
1831
- scopeId: document.documentRunId,
1832
- sourcePath
1833
- });
1834
- for (const outcome of project.cases)for (const attempt of outcome.attempts ?? (outcome.run ? [
1835
- outcome.run
1836
- ] : []))for (const sourcePath of attempt.reportPaths ?? [])sources.push({
1837
- scopeId: attempt.runId,
1838
- sourcePath
1839
- });
1840
- }
1841
- return sources;
1842
- }
1843
- function buildTestRunReportDump(result, index) {
1844
- const sourcesByScope = new Map();
1845
- const detailsByScopeAndExecution = new Map();
1846
- for (const source of index.sources){
1847
- const scopeSources = sourcesByScope.get(source.scopeId) ?? [];
1848
- scopeSources.push(source);
1849
- sourcesByScope.set(source.scopeId, scopeSources);
1850
- for (const executionId of source.executionIds)detailsByScopeAndExecution.set(scopedExecutionKey(source.scopeId, executionId), {
1851
- reportId: source.reportId,
1852
- executionId
466
+ const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
467
+ if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
468
+ caseId
1853
469
  });
1854
- }
1855
- const context = {
1856
- diagnostics: [],
1857
- sourcesByScope,
1858
- detailsByScopeAndExecution,
1859
- tracedScopeByExecution: new Map(),
1860
- warnedDiagnostics: new Set()
1861
- };
1862
- for (const source of index.sources)if (0 === source.executionIds.length) addDiagnostic(context, {
1863
- level: 'warning',
1864
- code: 'source-without-executions',
1865
- message: `Agent report contains no executions: ${source.sourcePath}`,
1866
- scopeId: source.scopeId,
1867
- sourcePaths: [
1868
- source.sourcePath
1869
- ]
470
+ ids.add(caseId);
471
+ return {
472
+ caseId,
473
+ projectId: source.projectId,
474
+ sourcePath,
475
+ caseIndex,
476
+ definition: {
477
+ name: definition.name,
478
+ tags: tags,
479
+ steps
480
+ }
481
+ };
1870
482
  });
1871
- const projects = result.projects.map((project)=>buildProject(project, context));
1872
483
  return {
1873
- schemaVersion: 1,
1874
- kind: 'test-runner',
1875
- runId: result.runId,
1876
- status: result.status,
1877
- startedAt: result.startedAt,
1878
- endedAt: result.endedAt,
1879
- durationMs: result.durationMs,
1880
- summary: {
1881
- ...result.summary
1882
- },
1883
- metrics: index.metrics,
1884
- projects,
1885
- ...context.diagnostics.length > 0 ? {
1886
- diagnostics: context.diagnostics
1887
- } : {}
484
+ documentId: createWorkflowDocumentId(source.projectId, sourcePath),
485
+ projectId: source.projectId,
486
+ sourcePath,
487
+ lifecycle,
488
+ cases
1888
489
  };
1889
490
  }
1890
- const writeJson = (path, value)=>{
1891
- mkdirSync(dirname(path), {
1892
- recursive: true
1893
- });
1894
- writeFileSync(path, JSON.stringify(value, null, 2));
491
+ const writeJson = async (path, value)=>{
492
+ try {
493
+ await mkdir(dirname(path), {
494
+ recursive: true
495
+ });
496
+ await writeFile(path, JSON.stringify(value, null, 2));
497
+ } catch (error) {
498
+ throw new WorkflowPublicationError('write-result', path, error);
499
+ }
1895
500
  };
1896
501
  const toPosix = (value)=>value.split(sep).join('/');
1897
502
  const caseAttemptResultPath = (projectId, documentId, result)=>toPosix(join(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
1898
- const workflowDocumentResultPath = (result)=>toPosix(join(result.projectId, 'documents', result.documentId, 'document.json'));
503
+ const workflowDocumentResultPath = (result)=>toPosix(join(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
1899
504
  const collectionErrorPath = (projectId, sourcePath)=>{
1900
505
  const id = createHash('sha256').update(JSON.stringify([
1901
506
  projectId,
@@ -1905,21 +510,19 @@ const collectionErrorPath = (projectId, sourcePath)=>{
1905
510
  };
1906
511
  const writeWorkflowDocumentResult = (runDir, result)=>{
1907
512
  const { documentRunId: _documentRunId, ...persistedResult } = result;
1908
- writeJson(join(runDir, workflowDocumentResultPath(result)), persistedResult);
513
+ return writeJson(join(runDir, workflowDocumentResultPath(result)), persistedResult);
1909
514
  };
1910
515
  const writeCaseAttemptResult = (runDir, projectId, documentId, result)=>{
1911
516
  const { runId: attemptId, ...persistedResult } = result;
1912
- writeJson(join(runDir, caseAttemptResultPath(projectId, documentId, result)), {
517
+ return writeJson(join(runDir, caseAttemptResultPath(projectId, documentId, result)), {
1913
518
  ...persistedResult,
1914
519
  attemptId
1915
520
  });
1916
521
  };
1917
- const writeCollectionError = (runDir, collectionError)=>{
1918
- writeJson(join(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
522
+ const writeCollectionError = (runDir, collectionError)=>writeJson(join(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
1919
523
  kind: 'collection-error',
1920
524
  ...collectionError
1921
525
  });
1922
- };
1923
526
  const relativeToSummary = (summaryPath, target)=>{
1924
527
  const value = relative(dirname(summaryPath), target);
1925
528
  return toPosix(value || '.');
@@ -1928,7 +531,7 @@ const errorJson = (error)=>error instanceof Error && 'toJSON' in error ? error.t
1928
531
  const writeTestProjectRunResult = (options)=>{
1929
532
  const { result } = options;
1930
533
  const fact = (path)=>toPosix(path);
1931
- writeJson(result.summaryPath, {
534
+ return writeJson(result.summaryPath, {
1932
535
  schemaVersion: result.schemaVersion,
1933
536
  runId: result.runId,
1934
537
  startedAt: result.startedAt,
@@ -1936,6 +539,9 @@ const writeTestProjectRunResult = (options)=>{
1936
539
  durationMs: result.durationMs,
1937
540
  status: result.status,
1938
541
  exitCode: result.exitCode,
542
+ ...result.errors?.length ? {
543
+ errors: result.errors.map(errorJson)
544
+ } : {},
1939
545
  projectRoot: options.projectRoot,
1940
546
  ...options.configPath ? {
1941
547
  configPath: options.configPath
@@ -1971,6 +577,9 @@ const writeTestProjectRunResult = (options)=>{
1971
577
  }
1972
578
  } : {},
1973
579
  cases: project.cases.map((outcome)=>({
580
+ ...outcome.documentRunId ? {
581
+ documentRunId: outcome.documentRunId
582
+ } : {},
1974
583
  documentId: outcome.documentId,
1975
584
  caseId: outcome.caseId,
1976
585
  sourcePath: outcome.sourcePath,
@@ -1994,6 +603,10 @@ const writeTestProjectRunResult = (options)=>{
1994
603
  })),
1995
604
  documents: project.documents.map((document)=>({
1996
605
  documentId: document.documentId,
606
+ ...void 0 === document.attemptIndex ? {} : {
607
+ attemptIndex: document.attemptIndex,
608
+ documentRunId: document.documentRunId
609
+ },
1997
610
  sourcePath: document.sourcePath,
1998
611
  status: document.status,
1999
612
  resultFile: fact(workflowDocumentResultPath(document)),
@@ -2028,32 +641,36 @@ const DEFAULT_TEST_FILE_SELECTION = {
2028
641
  '**/*.{yaml,yml}'
2029
642
  ]
2030
643
  };
2031
- const test_project_runner_toPosix = (value)=>value.split(sep).join('/');
2032
- const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)=>{
644
+ const project_preparation_toPosix = (value)=>value.split(sep).join('/');
645
+ const discoverResolvedTestFiles = (projectRoot, selection)=>{
2033
646
  const root = resolve(projectRoot);
2034
- const normalized = validateTestFileSelection(selection);
2035
- if (!normalized) throw new TypeError('Test file selection is required.');
2036
- const files = globSync(normalized.include, {
2037
- absolute: true,
2038
- caseSensitiveMatch: false,
2039
- cwd: root,
2040
- dot: true,
2041
- expandDirectories: false,
2042
- followSymbolicLinks: false,
2043
- ignore: [
2044
- ...ALWAYS_IGNORED_PATTERNS,
2045
- ...normalized.exclude ?? []
2046
- ],
2047
- onlyFiles: true
2048
- }).filter((file)=>/\.ya?ml$/i.test(file));
647
+ const match = (patterns)=>globSync(patterns, {
648
+ absolute: true,
649
+ caseSensitiveMatch: false,
650
+ cwd: root,
651
+ dot: true,
652
+ expandDirectories: false,
653
+ followSymbolicLinks: false,
654
+ ignore: [
655
+ ...ALWAYS_IGNORED_PATTERNS,
656
+ ...selection.exclude ?? []
657
+ ],
658
+ onlyFiles: true
659
+ }).filter((file)=>/\.ya?ml$/i.test(file));
660
+ const files = match(selection.include);
2049
661
  return [
2050
662
  ...new Set(files.map((file)=>resolve(file)))
2051
663
  ].sort((a, b)=>{
2052
- const relativeA = test_project_runner_toPosix(relative(root, a));
2053
- const relativeB = test_project_runner_toPosix(relative(root, b));
664
+ const relativeA = project_preparation_toPosix(relative(root, a));
665
+ const relativeB = project_preparation_toPosix(relative(root, b));
2054
666
  return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
2055
667
  });
2056
668
  };
669
+ const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)=>{
670
+ const normalized = validateTestFileSelection(selection);
671
+ if (!normalized) throw new TypeError('Test file selection is required.');
672
+ return discoverResolvedTestFiles(projectRoot, normalized);
673
+ };
2057
674
  const discoverTestConfig = (projectRoot)=>{
2058
675
  const root = resolve(projectRoot);
2059
676
  const candidates = readdirSync(root).filter((name)=>name.startsWith(CONFIG_PREFIX)).sort();
@@ -2071,22 +688,6 @@ const discoverTestConfig = (projectRoot)=>{
2071
688
  ].join('\n'));
2072
689
  return supported[0] ? join(root, supported[0]) : void 0;
2073
690
  };
2074
- const defaultResultDir = (projectRoot)=>join(projectRoot, '.midscene', 'test-results');
2075
- const padDatePart = (value)=>String(value).padStart(2, '0');
2076
- const createTestRunId = (date = new Date(), uuid = randomUUID())=>{
2077
- const timestamp = [
2078
- date.getFullYear(),
2079
- padDatePart(date.getMonth() + 1),
2080
- padDatePart(date.getDate()),
2081
- padDatePart(date.getHours()),
2082
- padDatePart(date.getMinutes()),
2083
- padDatePart(date.getSeconds())
2084
- ].join('');
2085
- return `${timestamp}-${uuid.slice(0, 8)}`;
2086
- };
2087
- const assertDirectory = (path, label)=>{
2088
- if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
2089
- };
2090
691
  const asCollectionError = (projectId, projectName, sourcePath, error)=>({
2091
692
  projectId,
2092
693
  projectName,
@@ -2113,52 +714,6 @@ const filterDocumentCases = (document, project)=>{
2113
714
  filtered
2114
715
  };
2115
716
  };
2116
- const test_project_runner_asNotRun = (documentId, collectedCase, projectName, reason)=>({
2117
- documentId,
2118
- caseId: collectedCase.caseId,
2119
- projectName,
2120
- name: collectedCase.definition.name,
2121
- sourcePath: collectedCase.sourcePath,
2122
- caseIndex: collectedCase.caseIndex,
2123
- status: 'not-run',
2124
- notRunReason: reason
2125
- });
2126
- const summarize = (projects)=>{
2127
- const cases = projects.flatMap((project)=>project.cases);
2128
- const documents = projects.flatMap((project)=>project.documents);
2129
- return {
2130
- total: cases.length,
2131
- passed: cases.filter((item)=>'success' === item.status).length,
2132
- failed: cases.filter((item)=>'failed' === item.status).length,
2133
- notRun: cases.filter((item)=>'not-run' === item.status).length,
2134
- filtered: projects.reduce((total, project)=>total + project.filteredCaseCount, 0),
2135
- collectionErrors: projects.reduce((total, project)=>total + project.collectionErrors.length, 0),
2136
- documentFailures: documents.filter((document)=>'failed' === document.status).length,
2137
- projectFailures: projects.filter((project)=>'failed' === project.status).length
2138
- };
2139
- };
2140
- const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
2141
- const test_project_runner_formatStep = (info)=>{
2142
- const position = stepPosition(info);
2143
- const phase = 'steps' === position.phase ? 'step' : position.phase;
2144
- return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
2145
- };
2146
- const formatStepResult = (info, result)=>{
2147
- const indent = 'case' === info.scope ? ' ' : ' ';
2148
- const symbol = 'success' === result.status ? '✓' : '✗';
2149
- const error = result.error ? ` — ${result.error.message}` : '';
2150
- const continuation = result.continuedAfterError ? '; continuing' : '';
2151
- return `${indent}${symbol} ${test_project_runner_formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
2152
- };
2153
- const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
2154
- ...attempt.beforeEach,
2155
- ...attempt.steps,
2156
- ...attempt.afterEach
2157
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError));
2158
- const documentHasFatalError = (result)=>[
2159
- ...result.beforeAll,
2160
- ...result.afterAll
2161
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError);
2162
717
  const selectProjects = (projects, names)=>{
2163
718
  if (!names || 0 === names.length) return projects;
2164
719
  const requested = new Set(names);
@@ -2168,17 +723,30 @@ const selectProjects = (projects, names)=>{
2168
723
  if (unknown) throw new Error(`Unknown Midscene project: ${unknown}`);
2169
724
  return projects.filter((project)=>requested.has(project.name));
2170
725
  };
2171
- const prepareProject = (project, projectRoot, runDir)=>{
726
+ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
2172
727
  const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
2173
- const files = discoverTestFiles(projectRoot, fileSelection);
2174
- const sources = files.map((absolutePath)=>({
728
+ const prerequisiteFile = options.prerequisiteFile ? resolve(options.prerequisiteFile) : void 0;
729
+ const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
730
+ const files = [
731
+ ...prerequisiteFile ? [
732
+ prerequisiteFile
733
+ ] : [],
734
+ ...mainFiles.filter((file)=>file !== prerequisiteFile)
735
+ ];
736
+ const occurrences = new Map();
737
+ const sources = files.map((absolutePath)=>{
738
+ const invocationIndex = occurrences.get(absolutePath) ?? 0;
739
+ occurrences.set(absolutePath, invocationIndex + 1);
740
+ return {
2175
741
  projectId: project.projectId,
2176
742
  projectName: project.name,
2177
- sourcePath: test_project_runner_toPosix(relative(projectRoot, absolutePath)),
2178
- absolutePath
2179
- }));
743
+ sourcePath: project_preparation_toPosix(relative(projectRoot, absolutePath)),
744
+ absolutePath,
745
+ invocationIndex
746
+ };
747
+ });
2180
748
  const collectionErrors = [];
2181
- const documents = [];
749
+ const invocations = [];
2182
750
  let filteredCaseCount = 0;
2183
751
  if (0 === sources.length) {
2184
752
  const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
@@ -2186,56 +754,282 @@ const prepareProject = (project, projectRoot, runDir)=>{
2186
754
  projectRoot
2187
755
  }));
2188
756
  collectionErrors.push(error);
2189
- writeCollectionError(runDir, error);
757
+ await writeCollectionError(runDir, error);
2190
758
  }
2191
759
  for (const source of sources)try {
2192
- const collected = collectWorkflowDocument(source, {
760
+ const collected = await options.collectDocument?.(source, project);
761
+ if (collected) {
762
+ filteredCaseCount += collected.filteredCaseCount;
763
+ if (collected.invocation) invocations.push(collected.invocation);
764
+ continue;
765
+ }
766
+ const document = collectWorkflowDocument(source, {
2193
767
  resolveNode: project.nodes.get.bind(project.nodes),
2194
768
  variables: project.variables,
2195
769
  env: process.env
2196
770
  });
2197
- const filtered = filterDocumentCases(collected, project);
771
+ const filtered = source.absolutePath === prerequisiteFile ? {
772
+ document,
773
+ filtered: 0
774
+ } : filterDocumentCases(document, project);
2198
775
  filteredCaseCount += filtered.filtered;
2199
- if (filtered.document) documents.push(filtered.document);
776
+ if (filtered.document) invocations.push({
777
+ document: filtered.document,
778
+ retry: {
779
+ scope: 'case',
780
+ count: project.retry
781
+ },
782
+ defaultTimeoutMs,
783
+ reportEnabled: true,
784
+ bindings: {
785
+ resolveNode: project.nodes.require.bind(project.nodes)
786
+ }
787
+ });
2200
788
  } catch (error) {
2201
789
  const collectionError = asCollectionError(project.projectId, project.name, source.sourcePath, error);
2202
790
  collectionErrors.push(collectionError);
2203
- writeCollectionError(runDir, collectionError);
791
+ await writeCollectionError(runDir, collectionError);
2204
792
  }
793
+ const documents = invocations.map((invocation)=>invocation.document);
794
+ const prerequisiteSource = sources.find((source)=>source.absolutePath === prerequisiteFile);
795
+ const prerequisiteDocument = documents.find((document)=>document.sourcePath === prerequisiteSource?.sourcePath);
2205
796
  return {
2206
797
  project,
798
+ platform: options.platform ?? 'test',
799
+ documentConcurrency: options.documentConcurrency ?? 1,
800
+ ...prerequisiteDocument ? {
801
+ prerequisiteDocumentId: prerequisiteDocument.documentId
802
+ } : {},
2207
803
  fileSelection,
2208
804
  sources,
2209
805
  documents,
806
+ invocations,
2210
807
  collectionErrors,
2211
808
  selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
2212
809
  filteredCaseCount
2213
810
  };
811
+ }
812
+ const padDatePart = (value)=>String(value).padStart(2, '0');
813
+ const createTestRunId = (date = new Date(), uuid = randomUUID())=>{
814
+ const timestamp = [
815
+ date.getFullYear(),
816
+ padDatePart(date.getMonth() + 1),
817
+ padDatePart(date.getDate()),
818
+ padDatePart(date.getHours()),
819
+ padDatePart(date.getMinutes()),
820
+ padDatePart(date.getSeconds())
821
+ ].join('');
822
+ return `${timestamp}-${uuid.slice(0, 8)}`;
2214
823
  };
2215
- const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>test_project_runner_asNotRun(document.documentId, item, prepared.project.name, reason)));
2216
- async function runTestProject(options = {}) {
2217
- const startedAt = new Date();
2218
- const runId = createTestRunId(startedAt);
824
+ const assertDirectory = (path, label)=>{
825
+ if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
826
+ };
827
+ const resolveRunInput = (options)=>{
2219
828
  const cwd = resolve(options.cwd ?? process.cwd());
2220
829
  assertDirectory(cwd, 'Test working directory');
2221
- const cliProjectRoot = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
830
+ const inputPath = options.projectRoot ? resolve(cwd, options.projectRoot) : void 0;
831
+ const singleFile = inputPath && existsSync(inputPath) && statSync(inputPath).isFile() ? inputPath : void 0;
832
+ if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
833
+ const cliProjectRoot = singleFile ? dirname(singleFile) : inputPath;
2222
834
  if (cliProjectRoot) assertDirectory(cliProjectRoot, 'Test project directory');
2223
- const configSearchRoot = cliProjectRoot ?? cwd;
2224
- const configPath = options.configPath ? resolve(configSearchRoot, options.configPath) : discoverTestConfig(configSearchRoot);
2225
- if (options.configPath && (!configPath || !existsSync(configPath))) throw new Error(`Midscene config does not exist: ${configPath}`);
2226
- const definition = await loadTestProject(configPath);
2227
835
  const projectRoot = cliProjectRoot ?? cwd;
2228
- const resultDir = options.resultDir ? resolve(cwd, options.resultDir) : defaultResultDir(projectRoot);
836
+ return {
837
+ cwd,
838
+ projectRoot,
839
+ configSearchRoot: projectRoot,
840
+ ...singleFile ? {
841
+ singleFile
842
+ } : {}
843
+ };
844
+ };
845
+ async function prepareTestRun(options) {
846
+ const startedAt = new Date();
847
+ const runId = createTestRunId(startedAt);
848
+ const input = resolveRunInput(options);
849
+ const configPath = options.configPath ? resolve(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
850
+ 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}\`.`);
851
+ if (options.configPath && !existsSync(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
852
+ const definition = await loadTestProject(configPath);
853
+ const resultDir = options.resultDir ? resolve(input.cwd, options.resultDir) : join(input.projectRoot, '.midscene', 'test-results');
2229
854
  const runDir = join(resultDir, runId);
2230
855
  const summaryPath = join(runDir, 'summary.json');
2231
- const reportDir = resolve(projectRoot, definition.output.reportDir);
856
+ const reportDir = resolve(input.projectRoot, definition.output.reportDir);
2232
857
  mkdirSync(resultDir, {
2233
858
  recursive: true
2234
859
  });
2235
860
  mkdirSync(runDir);
2236
- const selectedProjects = selectProjects(definition.projects, options.projectNames);
2237
- const preparedProjects = selectedProjects.map((project)=>prepareProject(project, projectRoot, runDir));
2238
- const progress = options.onProgress ?? (()=>{});
861
+ const projects = [];
862
+ for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, input.singleFile ? {
863
+ files: [
864
+ input.singleFile
865
+ ]
866
+ } : void 0));
867
+ const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
868
+ return {
869
+ startedAt,
870
+ runId,
871
+ projectRoot: input.projectRoot,
872
+ ...configPath ? {
873
+ configPath
874
+ } : {},
875
+ resultDir,
876
+ runDir,
877
+ summaryPath,
878
+ reportDir,
879
+ definition,
880
+ projects,
881
+ preflightScope: hasFormatMismatch ? 'run' : 'project',
882
+ reportEnabled: true,
883
+ publications: [],
884
+ onProgress: options.onProgress ?? (()=>{})
885
+ };
886
+ }
887
+ const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
888
+ const formatStep = (info)=>{
889
+ const position = stepPosition(info);
890
+ const phase = 'steps' === position.phase ? 'step' : position.phase;
891
+ return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
892
+ };
893
+ const formatStepResult = (info, result)=>{
894
+ const indent = 'case' === info.scope ? ' ' : ' ';
895
+ const symbol = 'success' === result.status ? '✓' : '✗';
896
+ const error = result.error ? ` — ${result.error.message}` : '';
897
+ const continuation = result.continuedAfterError ? '; continuing' : '';
898
+ return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
899
+ };
900
+ const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
901
+ ...attempt.beforeEach,
902
+ ...attempt.steps,
903
+ ...attempt.afterEach
904
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError));
905
+ const documentHasFatalError = (result)=>[
906
+ ...result.beforeAll,
907
+ ...result.afterAll
908
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError);
909
+ async function executeDocumentInvocation(options) {
910
+ const { invocation, project, sinks } = options;
911
+ const { document } = invocation;
912
+ const documentRetry = 'document' === invocation.retry.scope;
913
+ const attempts = [];
914
+ await runDocumentAttempts({
915
+ retry: documentRetry ? invocation.retry.count : 0,
916
+ signal: options.signal,
917
+ shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
918
+ }, async (documentAttemptIndex)=>{
919
+ if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
920
+ const execution = await runWorkflowDocument(document, {
921
+ documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
922
+ ...invocation.bindings,
923
+ project,
924
+ projectContext: options.projectContext,
925
+ retry: documentRetry ? 0 : invocation.retry.count,
926
+ signal: options.signal,
927
+ defaultTimeoutMs: invocation.defaultTimeoutMs,
928
+ shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
929
+ stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
930
+ isFatalError: (run)=>[
931
+ ...run.beforeEach,
932
+ ...run.steps,
933
+ ...run.afterEach
934
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
935
+ onCaseStart: (collectedCase)=>{
936
+ options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
937
+ },
938
+ onStepStart: (info)=>{
939
+ const indent = 'case' === info.scope ? ' ' : ' ';
940
+ options.onProgress(`${indent}→ ${formatStep(info)}`);
941
+ },
942
+ onStepResult: async (info, result)=>{
943
+ await invocation.bindings.onStepResult?.(info, result);
944
+ options.onProgress(formatStepResult(info, result));
945
+ },
946
+ onCaseResult: async (attempt)=>{
947
+ await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
948
+ options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
949
+ },
950
+ onCaseOutcome: (outcome)=>{
951
+ if (caseHasFatalError(outcome)) options.markProjectFatal();
952
+ if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
953
+ },
954
+ onDocumentResult: options.onDocumentResult
955
+ }).catch(async (error)=>{
956
+ if (error instanceof WorkflowExecutionFailure) {
957
+ const partial = error.result;
958
+ attempts.push(partial);
959
+ sinks.documents.push(partial.document);
960
+ sinks.cases.push(...partial.cases.map((outcome)=>({
961
+ ...outcome,
962
+ documentId: document.documentId,
963
+ documentRunId: partial.document.documentRunId
964
+ })));
965
+ try {
966
+ await writeWorkflowDocumentResult(options.runDir, partial.document);
967
+ } catch (publicationError) {
968
+ throw new WorkflowExecutionFailure(partial, [
969
+ ...error.errors,
970
+ publicationError
971
+ ]);
972
+ }
973
+ }
974
+ throw error;
975
+ });
976
+ attempts.push(execution);
977
+ sinks.cases.push(...execution.cases.map((outcome)=>({
978
+ ...outcome,
979
+ documentId: document.documentId,
980
+ documentRunId: execution.document.documentRunId
981
+ })));
982
+ sinks.documents.push(execution.document);
983
+ await writeWorkflowDocumentResult(options.runDir, execution.document);
984
+ if (documentHasFatalError(execution.document)) options.markProjectFatal();
985
+ const cleanupErrors = [
986
+ ...execution.document.teardownErrors ?? [],
987
+ ...execution.cases.flatMap((outcome)=>(outcome.attempts ?? []).flatMap((attempt)=>attempt.teardownErrors ?? []))
988
+ ];
989
+ if (cleanupErrors.length) throw new WorkflowExecutionFailure(execution, cleanupErrors);
990
+ const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
991
+ return failed ? 'failed' : 'success';
992
+ });
993
+ const final = attempts.at(-1);
994
+ if (!final) return;
995
+ const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
996
+ if (documentRetry && failedCases) options.addFailedCases(failedCases);
997
+ else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
998
+ }
999
+ const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1000
+ documentId,
1001
+ caseId: collectedCase.caseId,
1002
+ projectName,
1003
+ name: collectedCase.definition.name,
1004
+ sourcePath: collectedCase.sourcePath,
1005
+ caseIndex: collectedCase.caseIndex,
1006
+ status: 'not-run',
1007
+ notRunReason: reason
1008
+ });
1009
+ const summarize = (projects)=>{
1010
+ const cases = projects.flatMap((project)=>latestById(project.cases, (item)=>item.caseId));
1011
+ const documents = projects.flatMap((project)=>latestById(project.documents, (item)=>item.documentId));
1012
+ return {
1013
+ total: cases.length,
1014
+ passed: cases.filter((item)=>'success' === item.status).length,
1015
+ failed: cases.filter((item)=>'failed' === item.status).length,
1016
+ notRun: cases.filter((item)=>'not-run' === item.status).length,
1017
+ filtered: projects.reduce((total, project)=>total + project.filteredCaseCount, 0),
1018
+ collectionErrors: projects.reduce((total, project)=>total + project.collectionErrors.length, 0),
1019
+ documentFailures: documents.filter((document)=>'failed' === document.status).length,
1020
+ projectFailures: projects.filter((project)=>'failed' === project.status).length
1021
+ };
1022
+ };
1023
+ const latestById = (items, id)=>[
1024
+ ...new Map(items.map((item)=>[
1025
+ id(item),
1026
+ item
1027
+ ])).values()
1028
+ ];
1029
+ const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
1030
+ async function runPreparedTestProject(plan) {
1031
+ const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
1032
+ const progress = plan.onProgress;
2239
1033
  const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
2240
1034
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
2241
1035
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
@@ -2253,11 +1047,13 @@ async function runTestProject(options = {}) {
2253
1047
  let failedCaseCount = 0;
2254
1048
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
2255
1049
  let hasInfrastructureError = false;
2256
- let firstInfrastructureError;
1050
+ const infrastructureErrors = [];
2257
1051
  const recordInfrastructureError = (error)=>{
2258
- if (hasInfrastructureError) return;
1052
+ const errors = error instanceof WorkflowExecutionFailure ? error.errors : [
1053
+ error
1054
+ ];
1055
+ for (const item of errors)if (!infrastructureErrors.includes(item)) infrastructureErrors.push(item);
2259
1056
  hasInfrastructureError = true;
2260
- firstInfrastructureError = error;
2261
1057
  rootController.abort(error);
2262
1058
  };
2263
1059
  const runInfrastructureCallback = (callback)=>{
@@ -2274,10 +1070,11 @@ async function runTestProject(options = {}) {
2274
1070
  };
2275
1071
  const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
2276
1072
  const { project } = prepared;
2277
- const projectFailed = prepared.collectionErrors.length > 0 || cases.some((item)=>'success' !== item.status) || documents.some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
1073
+ 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';
2278
1074
  return {
2279
1075
  projectId: project.projectId,
2280
1076
  name: project.name,
1077
+ platform: prepared.platform,
2281
1078
  status: projectFailed ? 'failed' : 'success',
2282
1079
  retry: project.retry,
2283
1080
  fileSelection: prepared.fileSelection,
@@ -2305,7 +1102,7 @@ async function runTestProject(options = {}) {
2305
1102
  const documents = [];
2306
1103
  let lifecycle;
2307
1104
  let projectFatal = false;
2308
- if (prepared.collectionErrors.length > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
1105
+ if (prepared.collectionErrors.length > 0 || 'run' === plan.preflightScope && totalErrors > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
2309
1106
  else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
2310
1107
  else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
2311
1108
  else {
@@ -2315,96 +1112,101 @@ async function runTestProject(options = {}) {
2315
1112
  signal: rootController.signal
2316
1113
  });
2317
1114
  let hasProjectExecutionError = false;
2318
- let projectExecutionError;
2319
1115
  try {
2320
1116
  await runtime.start();
2321
- if (runtime.canRun) for (const [documentIndex, document] of prepared.documents.entries()){
2322
- if (rootController.signal.aborted || bailReached() || projectFatal) {
2323
- const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
2324
- cases.push(...document.cases.map((item)=>test_project_runner_asNotRun(document.documentId, item, project.name, reason)));
2325
- continue;
1117
+ if (runtime.canRun) {
1118
+ const runDocument = async (invocation, documentIndex)=>{
1119
+ const { document } = invocation;
1120
+ if (rootController.signal.aborted || bailReached() || projectFatal) {
1121
+ const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1122
+ cases.push(...document.cases.map((item)=>asNotRun(document.documentId, item, project.name, reason)));
1123
+ return;
1124
+ }
1125
+ projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
1126
+ await executeDocumentInvocation({
1127
+ invocation,
1128
+ project,
1129
+ projectContext: runtime.context,
1130
+ runDir,
1131
+ signal: runtime.signal,
1132
+ rootSignal: rootController.signal,
1133
+ shouldBail: bailReached,
1134
+ isProjectFatal: ()=>projectFatal,
1135
+ markProjectFatal: ()=>{
1136
+ projectFatal = true;
1137
+ },
1138
+ addFailedCases: (count)=>{
1139
+ failedCaseCount += count;
1140
+ },
1141
+ onProgress: projectProgress,
1142
+ sinks: {
1143
+ cases,
1144
+ documents
1145
+ }
1146
+ });
1147
+ };
1148
+ let prerequisiteFailed = false;
1149
+ const prerequisite = prepared.invocations.find((invocation)=>invocation.document.documentId === prepared.prerequisiteDocumentId);
1150
+ const prerequisiteCount = prerequisite ? 1 : 0;
1151
+ if (prerequisite) {
1152
+ await runDocument(prerequisite, 0);
1153
+ prerequisiteFailed = documents.at(-1)?.status === 'failed' || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status);
2326
1154
  }
2327
- projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
2328
- const execution = await runWorkflowDocument(document, {
2329
- resolveNode: project.nodes.require.bind(project.nodes),
2330
- project,
2331
- projectContext: runtime.context,
2332
- retry: project.retry,
2333
- signal: runtime.signal,
2334
- defaultTimeoutMs: definition.test.testTimeout,
2335
- shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal,
2336
- stopReason: ()=>rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail',
2337
- isFatalError: (run)=>[
2338
- ...run.beforeEach,
2339
- ...run.steps,
2340
- ...run.afterEach
2341
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
2342
- onCaseStart: (collectedCase)=>{
2343
- projectProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
2344
- },
2345
- onStepStart: (info)=>{
2346
- const indent = 'case' === info.scope ? ' ' : ' ';
2347
- projectProgress(`${indent}→ ${test_project_runner_formatStep(info)}`);
2348
- },
2349
- onStepResult: (info, result)=>projectProgress(formatStepResult(info, result)),
2350
- onCaseResult: (attempt)=>{
2351
- runInfrastructureCallback(()=>writeCaseAttemptResult(runDir, project.projectId, document.documentId, attempt));
2352
- projectProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
2353
- },
2354
- onCaseOutcome: (outcome)=>{
2355
- if ('failed' === outcome.status) failedCaseCount += 1;
2356
- if (caseHasFatalError(outcome)) projectFatal = true;
2357
- },
2358
- onDocumentResult: (documentResult)=>runInfrastructureCallback(()=>writeWorkflowDocumentResult(runDir, documentResult))
1155
+ if (!prerequisiteFailed) await runConcurrentJobs(prepared.invocations.filter((invocation)=>invocation !== prerequisite), {
1156
+ concurrency: prepared.documentConcurrency,
1157
+ shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal
1158
+ }, async (invocation, index)=>{
1159
+ try {
1160
+ await runDocument(invocation, index + prerequisiteCount);
1161
+ } catch (error) {
1162
+ recordInfrastructureError(error);
1163
+ throw error;
1164
+ }
2359
1165
  });
2360
- cases.push(...execution.cases.map((outcome)=>({
2361
- ...outcome,
2362
- documentId: document.documentId
2363
- })));
2364
- if (documentHasFatalError(execution.document)) projectFatal = true;
2365
- documents.push(execution.document);
2366
- }
2367
- else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
1166
+ const completed = new Set(cases.map((item)=>item.caseId));
1167
+ const reason = prerequisiteFailed ? 'project-setup-failed' : rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1168
+ cases.push(...notRunSuite(prepared, reason).filter((item)=>!completed.has(item.caseId)));
1169
+ } else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
2368
1170
  } catch (error) {
2369
1171
  hasProjectExecutionError = true;
2370
- projectExecutionError = error;
2371
1172
  recordInfrastructureError(error);
2372
1173
  } finally{
2373
- const hasFailure = hasProjectExecutionError || cases.some((item)=>'success' !== item.status) || documents.some((item)=>'failed' === item.status) || projectFatal || rootController.signal.aborted;
1174
+ const hasFailure = hasProjectExecutionError || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status) || latestById(documents, (item)=>item.documentId).some((item)=>'failed' === item.status) || projectFatal || rootController.signal.aborted;
2374
1175
  lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
2375
1176
  }
2376
- if (hasProjectExecutionError) throw projectExecutionError;
1177
+ if (hasProjectExecutionError) {
1178
+ const completed = new Set(cases.map((item)=>item.caseId));
1179
+ cases.push(...notRunSuite(prepared, 'interrupted').filter((item)=>!completed.has(item.caseId)));
1180
+ }
2377
1181
  }
1182
+ const order = new Map(prepared.documents.map((document, index)=>[
1183
+ document.documentId,
1184
+ index
1185
+ ]));
1186
+ const documentOrder = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1187
+ cases.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || a.caseIndex - b.caseIndex);
1188
+ documents.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || (a.attemptIndex ?? 0) - (b.attemptIndex ?? 0));
2378
1189
  return buildProjectResult(prepared, cases, documents, lifecycle);
2379
1190
  };
2380
1191
  const projectResults = new Array(preparedProjects.length);
2381
- let nextProjectIndex = 0;
2382
- const claimNextProject = ()=>{
2383
- if (hasInfrastructureError || rootController.signal.aborted || bailReached() || nextProjectIndex >= preparedProjects.length) return;
2384
- const projectIndex = nextProjectIndex;
2385
- nextProjectIndex += 1;
2386
- return projectIndex;
2387
- };
2388
- const worker = async ()=>{
2389
- try {
2390
- while(true){
2391
- const projectIndex = claimNextProject();
2392
- if (void 0 === projectIndex) return;
2393
- projectResults[projectIndex] = await runPreparedProject(preparedProjects[projectIndex], projectIndex);
2394
- }
2395
- } catch (error) {
2396
- recordInfrastructureError(error);
2397
- }
2398
- };
2399
1192
  try {
2400
- await Promise.allSettled(Array.from({
2401
- length: effectiveConcurrency
2402
- }, ()=>worker()));
1193
+ await runConcurrentJobs(preparedProjects, {
1194
+ concurrency: effectiveConcurrency,
1195
+ shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
1196
+ }, async (prepared, index)=>{
1197
+ try {
1198
+ projectResults[index] = await runPreparedProject(prepared, index);
1199
+ } catch (error) {
1200
+ recordInfrastructureError(error);
1201
+ throw error;
1202
+ }
1203
+ });
1204
+ } catch (error) {
1205
+ recordInfrastructureError(error);
2403
1206
  } finally{
2404
1207
  process.off('SIGINT', sigint);
2405
1208
  process.off('SIGTERM', sigterm);
2406
1209
  }
2407
- if (hasInfrastructureError) throw firstInfrastructureError;
2408
1210
  const completedProjectResults = preparedProjects.map((prepared, projectIndex)=>{
2409
1211
  const result = projectResults[projectIndex];
2410
1212
  if (result) return result;
@@ -2413,7 +1215,7 @@ async function runTestProject(options = {}) {
2413
1215
  return buildSkippedProjectResult(prepared, projectIndex, reason);
2414
1216
  });
2415
1217
  const summary = summarize(completedProjectResults);
2416
- const failed = rootController.signal.aborted || summary.failed > 0 || summary.notRun > 0 || summary.collectionErrors > 0 || summary.documentFailures > 0 || summary.projectFailures > 0;
1218
+ const failed = hasInfrastructureError || rootController.signal.aborted || summary.failed > 0 || summary.notRun > 0 || summary.collectionErrors > 0 || summary.documentFailures > 0 || summary.projectFailures > 0;
2417
1219
  const endedAt = new Date();
2418
1220
  const result = {
2419
1221
  schemaVersion: 3,
@@ -2430,34 +1232,66 @@ async function runTestProject(options = {}) {
2430
1232
  projects: completedProjectResults,
2431
1233
  cases: completedProjectResults.flatMap((project)=>project.cases),
2432
1234
  documents: completedProjectResults.flatMap((project)=>project.documents),
2433
- collectionErrors: completedProjectResults.flatMap((project)=>project.collectionErrors)
1235
+ collectionErrors: completedProjectResults.flatMap((project)=>project.collectionErrors),
1236
+ ...hasInfrastructureError ? {
1237
+ errors: infrastructureErrors.map(asExecutionError)
1238
+ } : {}
2434
1239
  };
2435
- writeTestProjectRunResult({
2436
- projectRoot,
2437
- ...configPath ? {
2438
- configPath
2439
- } : {},
2440
- result
2441
- });
2442
- const reportPath = new TestRunReportAssembler().assemble({
2443
- outputDir: reportDir,
2444
- reportFileName: `test-run-${runId}`,
2445
- sources: collectTestRunReportSources(result),
2446
- buildRunnerDump: (index)=>buildTestRunReportDump(result, index)
2447
- });
2448
- const completedResult = {
2449
- ...result,
2450
- reportPath
1240
+ let completedResult = result;
1241
+ const publish = async (operation, path, callback)=>{
1242
+ try {
1243
+ await callback();
1244
+ return true;
1245
+ } catch (error) {
1246
+ recordInfrastructureError(error instanceof WorkflowPublicationError || error instanceof WorkflowExecutionFailure ? error : new WorkflowPublicationError(operation, path, error));
1247
+ completedResult = {
1248
+ ...completedResult,
1249
+ status: 'failed',
1250
+ exitCode: 1,
1251
+ errors: infrastructureErrors.map(asExecutionError)
1252
+ };
1253
+ return false;
1254
+ }
2451
1255
  };
2452
- writeTestProjectRunResult({
2453
- projectRoot,
2454
- ...configPath ? {
2455
- configPath
2456
- } : {},
2457
- result: completedResult
1256
+ const writeSummary = ()=>publish('write-result', summaryPath, ()=>writeTestProjectRunResult({
1257
+ projectRoot,
1258
+ ...configPath ? {
1259
+ configPath
1260
+ } : {},
1261
+ result: completedResult
1262
+ }));
1263
+ const writeReport = ()=>publish('write-report', reportDir, async ()=>{
1264
+ if (!plan.reportEnabled) return;
1265
+ const reportPath = await new TestRunReportAssembler().assembleAsync({
1266
+ outputDir: reportDir,
1267
+ reportFileName: `midscene-e2e-${runId}`,
1268
+ overwrite: false,
1269
+ sources: collectTestRunReportSources(completedResult),
1270
+ buildRunnerDump: (index)=>{
1271
+ const dump = buildTestRunReportDump(completedResult, index);
1272
+ const warn = getDebug('test-runner:report-assembler', {
1273
+ console: true
1274
+ });
1275
+ for (const diagnostic of dump.diagnostics ?? [])warn(diagnostic.message);
1276
+ return dump;
1277
+ }
1278
+ });
1279
+ completedResult = {
1280
+ ...completedResult,
1281
+ reportPath
1282
+ };
1283
+ });
1284
+ await writeReport();
1285
+ for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
1286
+ await publication.publish(completedResult);
2458
1287
  });
1288
+ await writeSummary();
1289
+ if (hasInfrastructureError) throw new WorkflowExecutionFailure(completedResult, infrastructureErrors);
2459
1290
  return completedResult;
2460
1291
  }
1292
+ async function runTestProject(options = {}) {
1293
+ return runPreparedTestProject(await prepareTestRun(options));
1294
+ }
2461
1295
  export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
2462
1296
 
2463
1297
  //# sourceMappingURL=index.mjs.map