@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.
@@ -14,6 +14,11 @@ var __webpack_modules__ = {
14
14
  },
15
15
  "yargs/yargs" (module) {
16
16
  module.exports = require("yargs/yargs");
17
+ },
18
+ "./package.json" (module) {
19
+ module.exports = {
20
+ rE: "1.13.0"
21
+ };
17
22
  }
18
23
  };
19
24
  var __webpack_module_cache__ = {};
@@ -75,6 +80,7 @@ var __webpack_exports__ = {};
75
80
  (()=>{
76
81
  var external_node_fs_ = __webpack_require__("node:fs");
77
82
  var external_node_path_ = __webpack_require__("node:path");
83
+ var package_0 = __webpack_require__("./package.json");
78
84
  const v4_namespaceObject = require("zod/v4");
79
85
  const GENERATED_COMMENT = '<!-- Generated by `midscene-test nodes`. Do not edit directly. -->';
80
86
  const sortNodesForReference = (nodes)=>{
@@ -127,7 +133,9 @@ cases:
127
133
  continue-on-error: false
128
134
  \`\`\`
129
135
 
130
- A Node may map a string step value to one declared input field. Each Node section states whether it supports this shorthand and names the target field.`;
136
+ A Node may map a string step value to one declared input field. Each Node section states whether it supports this shorthand and names the target field.
137
+
138
+ Each Node's returned \`data\` is saved on that Step's result. Results are not named or automatically passed to later Steps. A failed Case does not stop later Cases by default; \`test.bail\` controls early stopping across the run.`;
131
139
  const sortJsonValue = (value)=>{
132
140
  if (Array.isArray(value)) return value.map(sortJsonValue);
133
141
  if ('object' != typeof value || null === value) return value;
@@ -208,275 +216,8 @@ A Node may map a string step value to one declared input field. Each Node sectio
208
216
  const external_node_url_namespaceObject = require("node:url");
209
217
  const api_namespaceObject = require("tsx/cjs/api");
210
218
  const esm_api_namespaceObject = require("tsx/esm/api");
211
- function _define_property(obj, key, value) {
212
- if (key in obj) Object.defineProperty(obj, key, {
213
- value: value,
214
- enumerable: true,
215
- configurable: true,
216
- writable: true
217
- });
218
- else obj[key] = value;
219
- return obj;
220
- }
221
- class WorkflowError extends Error {
222
- toJSON() {
223
- return {
224
- name: this.name,
225
- message: this.message,
226
- code: this.code,
227
- ...void 0 === this.details ? {} : {
228
- details: this.details
229
- }
230
- };
231
- }
232
- constructor(message, options = {}){
233
- super(message, {
234
- cause: options.cause
235
- }), _define_property(this, "code", void 0), _define_property(this, "details", void 0);
236
- this.name = new.target.name;
237
- this.code = options.code ?? 'WORKFLOW_ERROR';
238
- this.details = options.details;
239
- }
240
- }
241
- class WorkflowParseError extends WorkflowError {
242
- constructor(message, details, cause){
243
- super(message, {
244
- code: 'WORKFLOW_PARSE_ERROR',
245
- details,
246
- cause
247
- });
248
- }
249
- }
250
- class NodeDefinitionError extends WorkflowError {
251
- constructor(message, details){
252
- super(message, {
253
- code: 'NODE_DEFINITION_ERROR',
254
- details
255
- });
256
- }
257
- }
258
- class DuplicateNodeError extends WorkflowError {
259
- constructor(node){
260
- super(`Node "${node}" is already registered.`, {
261
- code: 'DUPLICATE_NODE',
262
- details: {
263
- node
264
- }
265
- }), _define_property(this, "node", void 0);
266
- this.node = node;
267
- }
268
- }
269
- class NodeNotFoundError extends WorkflowError {
270
- constructor(node){
271
- super(`Node "${node}" is not registered.`, {
272
- code: 'NODE_NOT_FOUND',
273
- details: {
274
- node
275
- }
276
- }), _define_property(this, "node", void 0);
277
- this.node = node;
278
- }
279
- }
280
- class NodeInputValidationError extends WorkflowError {
281
- static fromZod(node, error) {
282
- const issues = error.issues.map((issue)=>({
283
- code: issue.code,
284
- path: issue.path.map(String).join('.'),
285
- message: issue.message
286
- }));
287
- const firstIssue = issues[0];
288
- const path = firstIssue?.path || '<root>';
289
- const message = firstIssue?.message ?? 'invalid input';
290
- return new NodeInputValidationError(`Node "${node}" input validation failed at "${path}": ${message}`, {
291
- node,
292
- issues
293
- });
294
- }
295
- constructor(message, details){
296
- super(message, {
297
- code: 'NODE_INPUT_VALIDATION_ERROR',
298
- details
299
- });
300
- }
301
- }
302
- class StepTimeoutError extends WorkflowError {
303
- constructor(timeoutMs, node){
304
- super(node ? `Node "${node}" timed out after ${timeoutMs}ms.` : `Step timed out after ${timeoutMs}ms.`, {
305
- code: 'STEP_TIMEOUT',
306
- details: {
307
- timeoutMs,
308
- ...void 0 === node ? {} : {
309
- node
310
- }
311
- }
312
- }), _define_property(this, "timeoutMs", void 0), _define_property(this, "node", void 0);
313
- this.timeoutMs = timeoutMs;
314
- this.node = node;
315
- }
316
- }
317
- class NodeExecutionError extends WorkflowError {
318
- constructor(node, cause){
319
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
320
- super(`Node "${node}" failed: ${causeMessage}`, {
321
- code: 'NODE_EXECUTION_ERROR',
322
- details: {
323
- node
324
- },
325
- cause
326
- }), _define_property(this, "node", void 0);
327
- this.node = node;
328
- }
329
- }
330
- class WorkflowLifecycleError extends WorkflowError {
331
- constructor(message, details){
332
- super(message, {
333
- code: 'WORKFLOW_LIFECYCLE_ERROR',
334
- details
335
- });
336
- }
337
- }
338
- class ProjectSetupError extends WorkflowError {
339
- constructor(cause, details){
340
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
341
- super(`Project "${details.projectName}" setup failed: ${causeMessage}`, {
342
- code: 'PROJECT_SETUP_ERROR',
343
- details,
344
- cause
345
- });
346
- }
347
- }
348
- class ProjectTeardownError extends WorkflowError {
349
- constructor(cause, details){
350
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
351
- super(`Project "${details.projectName}" teardown failed: ${causeMessage}`, {
352
- code: 'PROJECT_TEARDOWN_ERROR',
353
- details,
354
- cause
355
- });
356
- }
357
- }
358
- class NodeScopeTeardownError extends WorkflowError {
359
- constructor(cause, details){
360
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? 'Unknown error');
361
- super(`${'case' === details.scope ? 'Case attempt' : 'Workflow document'} node teardown failed for "${details.node}": ${causeMessage}`, {
362
- code: 'NODE_SCOPE_TEARDOWN_ERROR',
363
- details,
364
- cause
365
- });
366
- }
367
- }
368
- class FatalDeviceError extends WorkflowError {
369
- constructor(message, cause){
370
- super(message, {
371
- code: 'FATAL_DEVICE_ERROR',
372
- cause
373
- });
374
- }
375
- }
376
- const isFatalDeviceError = (error)=>{
377
- if (error instanceof FatalDeviceError) return true;
378
- if (error instanceof WorkflowError && 'FATAL_DEVICE_ERROR' === error.code) return true;
379
- if (error instanceof Error && /device offline|device not found|(?:adb|bdc|device) connection (?:was )?closed/i.test(error.message)) return true;
380
- return error instanceof Error && void 0 !== error.cause ? isFatalDeviceError(error.cause) : false;
381
- };
382
- function normalizeNodeExecutionError(error, node) {
383
- return error instanceof WorkflowError ? error : new NodeExecutionError(node, error);
384
- }
385
- const validateOptionalText = (value, field, node)=>{
386
- if (void 0 !== value && ('string' != typeof value || 0 === value.trim().length)) throw new NodeDefinitionError(`Node "${node}" ${field} must be a non-empty string.`, {
387
- node,
388
- field
389
- });
390
- };
391
- const validateInputSchema = (schema, node)=>{
392
- if (void 0 === schema) return;
393
- if (!(schema instanceof v4_namespaceObject.z.ZodObject)) throw new NodeDefinitionError(`Node "${node}" inputSchema must be a Zod object schema.`, {
394
- node,
395
- field: 'inputSchema'
396
- });
397
- if ('$' in schema.shape) throw new NodeDefinitionError(`Node "${node}" inputSchema must not declare "$" as an input property.`, {
398
- node,
399
- field: 'inputSchema.$'
400
- });
401
- };
402
- const validateDefinition = (options)=>{
403
- if (!options || 'object' != typeof options) throw new NodeDefinitionError('Node definition must be an object.');
404
- if ('string' != typeof options.name || 0 === options.name.trim().length) throw new NodeDefinitionError('Node name must be a non-empty string.');
405
- validateOptionalText(options.title, 'title', options.name);
406
- validateOptionalText(options.description, "description", options.name);
407
- 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.`, {
408
- node: options.name,
409
- field: 'stringInputKey'
410
- });
411
- validateInputSchema(options.inputSchema, options.name);
412
- if ('string' == typeof options.stringInputKey && options.inputSchema instanceof v4_namespaceObject.z.ZodObject && !(options.stringInputKey in options.inputSchema.shape)) throw new NodeDefinitionError(`Node "${options.name}" stringInputKey must reference a field in inputSchema.`, {
413
- node: options.name,
414
- field: 'stringInputKey'
415
- });
416
- if ('function' != typeof options.execute) throw new NodeDefinitionError(`Node "${options.name}" must provide an execute function.`, {
417
- node: options.name
418
- });
419
- };
420
- function defineNode(options) {
421
- validateDefinition(options);
422
- return options;
423
- }
424
- function _check_private_redeclaration(obj, privateCollection) {
425
- if (privateCollection.has(obj)) throw new TypeError("Cannot initialize the same private elements twice on an object");
426
- }
427
- function _class_apply_descriptor_get(receiver, descriptor) {
428
- if (descriptor.get) return descriptor.get.call(receiver);
429
- return descriptor.value;
430
- }
431
- function _class_extract_field_descriptor(receiver, privateMap, action) {
432
- if (!privateMap.has(receiver)) throw new TypeError("attempted to " + action + " private field on non-instance");
433
- return privateMap.get(receiver);
434
- }
435
- function _class_private_field_get(receiver, privateMap) {
436
- var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get");
437
- return _class_apply_descriptor_get(receiver, descriptor);
438
- }
439
- function _class_private_field_init(obj, privateMap, value) {
440
- _check_private_redeclaration(obj, privateMap);
441
- privateMap.set(obj, value);
442
- }
443
- var _nodes = /*#__PURE__*/ new WeakMap();
444
- class NodeRegistry {
445
- register(node) {
446
- defineNode(node);
447
- if (_class_private_field_get(this, _nodes).has(node.name)) throw new DuplicateNodeError(node.name);
448
- _class_private_field_get(this, _nodes).set(node.name, node);
449
- return this;
450
- }
451
- get(name) {
452
- return _class_private_field_get(this, _nodes).get(name);
453
- }
454
- require(name) {
455
- const node = this.get(name);
456
- if (!node) throw new NodeNotFoundError(name);
457
- return node;
458
- }
459
- has(name) {
460
- return _class_private_field_get(this, _nodes).has(name);
461
- }
462
- names() {
463
- return [
464
- ..._class_private_field_get(this, _nodes).keys()
465
- ];
466
- }
467
- definitions() {
468
- return [
469
- ..._class_private_field_get(this, _nodes).values()
470
- ];
471
- }
472
- constructor(nodes = []){
473
- _class_private_field_init(this, _nodes, {
474
- writable: true,
475
- value: new Map()
476
- });
477
- for (const node of nodes)this.register(node);
478
- }
479
- }
219
+ const test_runner_namespaceObject = require("@midscene/core/internal/test-runner");
220
+ const NodeRegistry = test_runner_namespaceObject.NodeRegistry;
480
221
  const projectIdFromIndex = (index)=>`project-${index}`;
481
222
  const getDefaultExport = (loaded, absolutePath)=>{
482
223
  if ('object' == typeof loaded && null !== loaded && 'default' in loaded) {
@@ -511,9 +252,13 @@ A Node may map a string step value to one declared input field. Each Node sectio
511
252
  return pattern;
512
253
  });
513
254
  };
514
- const validateTestFileSelection = (value, label = 'files')=>{
255
+ const test_project_validateTestFileSelection = (value, label = 'files')=>{
515
256
  if (void 0 === value) return;
516
257
  if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
258
+ rejectUnknownKeys(value, [
259
+ 'include',
260
+ 'exclude'
261
+ ], label);
517
262
  const include = validatePatterns(value.include, 'include', label);
518
263
  const exclude = void 0 === value.exclude ? void 0 : validatePatterns(value.exclude, 'exclude', label);
519
264
  return Object.freeze({
@@ -569,7 +314,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
569
314
  assertJsonValue(value, label, new Set());
570
315
  return deepFreezeJson(value);
571
316
  };
572
- const validateProjectSetup = (value, label)=>{
317
+ const validateSetup = (value, label)=>{
573
318
  if (void 0 === value) return;
574
319
  if (!isRecord(value)) throw new TypeError(`Midscene config ${label} must be an object.`);
575
320
  rejectUnknownKeys(value, [
@@ -592,7 +337,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
592
337
  };
593
338
  const validateExecutionProjects = (value, defaultSetup, globalNodes)=>{
594
339
  if (void 0 === value) {
595
- const setup = validateProjectSetup(defaultSetup, 'setup');
340
+ const setup = validateSetup(defaultSetup, 'setup');
596
341
  return {
597
342
  hasExplicitProjects: false,
598
343
  projects: Object.freeze([
@@ -631,7 +376,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
631
376
  if ('string' != typeof candidate.name || 0 === candidate.name.trim().length) throw new TypeError(`Midscene config ${label}.name must be non-empty.`);
632
377
  if (names.has(candidate.name)) throw new TypeError(`Midscene config project name "${candidate.name}" must be unique.`);
633
378
  names.add(candidate.name);
634
- const files = validateTestFileSelection(candidate.files, `${label}.files`);
379
+ const files = test_project_validateTestFileSelection(candidate.files, `${label}.files`);
635
380
  if (void 0 !== candidate.nodes && !Array.isArray(candidate.nodes)) throw new TypeError(`Midscene config ${label}.nodes must be an array.`);
636
381
  const localNodes = new NodeRegistry(candidate.nodes);
637
382
  const nodes = new NodeRegistry([
@@ -649,7 +394,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
649
394
  retry: validateNonNegativeInteger(candidate.retry, 0, `${label}.retry`),
650
395
  variables: validateVariables(candidate.variables, `${label}.variables`),
651
396
  ...void 0 === candidate.setup ? {} : {
652
- setup: validateProjectSetup(candidate.setup, `${label}.setup`)
397
+ setup: validateSetup(candidate.setup, `${label}.setup`)
653
398
  }
654
399
  });
655
400
  });
@@ -744,877 +489,151 @@ A Node may map a string step value to one declared input field. Each Node sectio
744
489
  }
745
490
  return validateTestProjectDefinition(getDefaultExport(loaded, absolutePath));
746
491
  }
747
- const external_node_crypto_namespaceObject = require("node:crypto");
748
- const external_node_events_namespaceObject = require("node:events");
749
- const report_namespaceObject = require("@midscene/core/report");
750
492
  const external_tinyglobby_namespaceObject = require("tinyglobby");
751
- const createProjectRuntime = (options)=>{
752
- const startedAt = new Date();
753
- const controller = new AbortController();
754
- const parentSignal = options.signal;
755
- const abortFromParent = ()=>controller.abort(parentSignal?.reason);
756
- if (parentSignal?.aborted) abortFromParent();
757
- else parentSignal?.addEventListener('abort', abortFromParent, {
758
- once: true
493
+ const external_js_yaml_namespaceObject = require("js-yaml");
494
+ const external_node_crypto_namespaceObject = require("node:crypto");
495
+ const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>(0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
496
+ projectId,
497
+ sourcePath,
498
+ caseIndex,
499
+ ...invocationIndex ? [
500
+ invocationIndex
501
+ ] : []
502
+ ])).digest('hex');
503
+ const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=>(0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
504
+ projectId,
505
+ sourcePath,
506
+ ...invocationIndex ? [
507
+ invocationIndex
508
+ ] : []
509
+ ])).digest('hex');
510
+ const normalizeSteps = test_runner_namespaceObject.normalizeSteps;
511
+ const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
512
+ const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
513
+ const exactProjectExpression = /^\$\{([^{}]+)\}$/;
514
+ const missingVariable = (kind, name, options)=>{
515
+ throw new test_runner_namespaceObject.WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
516
+ ...options.location,
517
+ variable: name,
518
+ variableKind: kind
759
519
  });
760
- const teardownStack = [];
761
- let context;
762
- let setupError;
763
- let started = false;
764
- let acceptingTeardowns = void 0 !== options.setup;
765
- let finishedResult;
766
- const createResult = (requestedStatus, teardownErrors = [])=>{
767
- const endedAt = new Date();
768
- return {
769
- projectName: options.project.name,
770
- status: 'failed' === requestedStatus || setupError || teardownErrors.length > 0 ? 'failed' : 'success',
771
- ...setupError ? {
772
- setupError
773
- } : {},
774
- ...teardownErrors.length > 0 ? {
775
- teardownErrors
776
- } : {},
777
- startedAt: startedAt.toISOString(),
778
- endedAt: endedAt.toISOString(),
779
- durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
780
- };
781
- };
782
- const onTeardown = (teardown)=>{
783
- if (!acceptingTeardowns) throw new WorkflowLifecycleError('onTeardown() can only be called while Project setup is running.', {
784
- projectName: options.project.name
785
- });
786
- if ('function' != typeof teardown) throw new WorkflowLifecycleError('Project onTeardown() requires a teardown function.', {
787
- projectName: options.project.name
788
- });
789
- teardownStack.push({
790
- registrationIndex: teardownStack.length,
791
- teardown
792
- });
793
- };
794
- return {
795
- get context () {
796
- return context;
797
- },
798
- get signal () {
799
- return controller.signal;
800
- },
801
- get canRun () {
802
- return started && !setupError && !controller.signal.aborted;
803
- },
804
- async start () {
805
- if (started) throw new WorkflowLifecycleError('Project runtime has already started.', {
806
- projectName: options.project.name
807
- });
808
- started = true;
809
- if (controller.signal.aborted) {
810
- acceptingTeardowns = false;
811
- return createResult('failed');
812
- }
813
- if (options.setup) try {
814
- context = await options.setup.setup({
815
- project: options.project,
816
- env: Object.freeze({
817
- ...process.env
818
- }),
819
- signal: controller.signal,
820
- onTeardown
821
- });
822
- } catch (error) {
823
- setupError = new ProjectSetupError(error, {
824
- projectName: options.project.name
825
- });
826
- } finally{
827
- acceptingTeardowns = false;
828
- }
829
- return createResult(setupError ? 'failed' : 'success');
830
- },
831
- async finish (requestedStatus = 'success') {
832
- if (finishedResult) return finishedResult;
833
- if (!started) throw new WorkflowLifecycleError('Project runtime must start before it can finish.', {
834
- projectName: options.project.name
835
- });
836
- const statusBeforeTeardown = createResult(requestedStatus).status;
837
- const teardownErrors = [];
838
- for (const { registrationIndex, teardown } of teardownStack.reverse())try {
839
- await teardown({
840
- project: options.project,
841
- context,
842
- status: statusBeforeTeardown,
843
- ...setupError ? {
844
- setupError
845
- } : {}
846
- });
847
- } catch (error) {
848
- teardownErrors.push(new ProjectTeardownError(error, {
849
- projectName: options.project.name,
850
- registrationIndex
851
- }));
852
- }
853
- parentSignal?.removeEventListener('abort', abortFromParent);
854
- finishedResult = createResult(requestedStatus, teardownErrors);
855
- return finishedResult;
856
- },
857
- abort (reason) {
858
- controller.abort(reason);
859
- }
860
- };
861
520
  };
862
- const assertJsonSerializable = (value, label, node)=>{
863
- const visit = (candidate, path, seen)=>{
864
- if (null === candidate || 'string' == typeof candidate || 'boolean' == typeof candidate) return;
865
- if ('number' == typeof candidate) {
866
- if (Number.isFinite(candidate)) return;
867
- throw new TypeError(`${path} contains a non-finite number.`);
868
- }
869
- if (void 0 === candidate) throw new TypeError(`${path} contains undefined.`);
870
- if ('object' != typeof candidate) throw new TypeError(`${path} is not JSON-serializable.`);
871
- if (!Array.isArray(candidate) && Object.getPrototypeOf(candidate) !== Object.prototype && null !== Object.getPrototypeOf(candidate)) throw new TypeError(`${path} must contain only plain objects.`);
872
- if (seen.has(candidate)) throw new TypeError(`${path} contains a cycle.`);
873
- seen.add(candidate);
874
- if (Array.isArray(candidate)) candidate.forEach((child, index)=>visit(child, `${path}[${index}]`, seen));
875
- else for (const [key, child] of Object.entries(candidate))visit(child, `${path}.${key}`, seen);
876
- seen.delete(candidate);
877
- };
878
- try {
879
- visit(value, label, new Set());
880
- } catch (error) {
881
- throw new NodeExecutionError(node, error);
882
- }
521
+ const projectVariable = (name, options)=>{
522
+ if (!Object.hasOwn(options.variables ?? {}, name)) return missingVariable('project', name, options);
523
+ return options.variables[name];
883
524
  };
884
- function validateNodeOutput(output, node) {
885
- if (void 0 === output) return;
886
- if ('object' != typeof output || null === output || Array.isArray(output)) throw new NodeExecutionError(node, new TypeError('Node output must be an object or undefined.'));
887
- 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.'));
888
- if ('data' in output && void 0 !== output.data) assertJsonSerializable(output.data, 'output.data', node);
889
- return output;
890
- }
891
- function createStepResultBase(step, startedAt, phase, stepIndex) {
892
- const endedAt = new Date();
893
- return {
894
- node: step.node,
895
- phase,
896
- stepIndex,
897
- input: step.input,
898
- meta: step.meta,
899
- startedAt: startedAt.toISOString(),
900
- endedAt: endedAt.toISOString(),
901
- durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
902
- };
903
- }
904
- async function executeNode(step, execute, phase, stepIndex, options) {
905
- const startedAt = new Date();
906
- const abortController = new AbortController();
907
- const parentSignal = options.parentSignal;
908
- const abortFromParent = ()=>abortController.abort(parentSignal?.reason);
909
- if (parentSignal?.aborted) abortFromParent();
910
- else parentSignal?.addEventListener('abort', abortFromParent, {
911
- once: true
912
- });
913
- const timeoutMs = step.meta.timeoutMs ?? options.defaultTimeoutMs;
914
- let timeout;
915
- let abortGraceTimeout;
916
- const execution = Promise.resolve().then(()=>{
917
- abortController.signal.throwIfAborted();
918
- return execute(abortController.signal);
525
+ const environmentVariable = (name, options)=>{
526
+ const value = options.env?.[name];
527
+ if (void 0 === value) return missingVariable('environment', name, options);
528
+ return value;
529
+ };
530
+ const interpolationValue = (name, value, options)=>{
531
+ if ('object' == typeof value && null !== value) throw new test_runner_namespaceObject.WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
532
+ ...options.location,
533
+ variable: name,
534
+ variableKind: 'project'
919
535
  });
920
- let settleOnAbort;
921
- const abortedExecution = new Promise((_, reject)=>{
922
- settleOnAbort = ()=>{
923
- abortGraceTimeout = setTimeout(()=>{
924
- reject(abortController.signal.reason);
925
- }, 0);
926
- };
927
- if (abortController.signal.aborted) settleOnAbort();
928
- else abortController.signal.addEventListener('abort', settleOnAbort, {
929
- once: true
930
- });
536
+ return String(value);
537
+ };
538
+ const resolveString = (value, options)=>{
539
+ const exactEnv = exactEnvExpression.exec(value);
540
+ if (exactEnv) return environmentVariable(exactEnv[1], options);
541
+ const exactProject = exactProjectExpression.exec(value);
542
+ if (exactProject) return projectVariable(exactProject[1], options);
543
+ return value.replace(interpolationExpression, (_match, environmentName, projectName)=>environmentName ? environmentVariable(environmentName, options) : interpolationValue(projectName, projectVariable(projectName, options), options));
544
+ };
545
+ const resolveWorkflowVariables = (value, options)=>{
546
+ if ('string' == typeof value) return resolveString(value, options);
547
+ if (Array.isArray(value)) return value.map((child)=>resolveWorkflowVariables(child, options));
548
+ if ('object' == typeof value && null !== value) return Object.fromEntries(Object.entries(value).map(([key, child])=>[
549
+ key,
550
+ resolveWorkflowVariables(child, options)
551
+ ]));
552
+ return value;
553
+ };
554
+ function _define_property(obj, key, value) {
555
+ if (key in obj) Object.defineProperty(obj, key, {
556
+ value: value,
557
+ enumerable: true,
558
+ configurable: true,
559
+ writable: true
931
560
  });
932
- const timeoutExecution = void 0 === timeoutMs ? void 0 : new Promise((_, reject)=>{
933
- timeout = setTimeout(()=>{
934
- if (abortController.signal.aborted) return void reject(abortController.signal.reason);
935
- const timeoutError = new StepTimeoutError(timeoutMs, step.node);
936
- abortController.abort(timeoutError);
937
- reject(timeoutError);
938
- }, timeoutMs);
561
+ else obj[key] = value;
562
+ return obj;
563
+ }
564
+ const isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
565
+ class NativeWorkflowFormatError extends test_runner_namespaceObject.WorkflowParseError {
566
+ constructor(actualFormat, sourcePath){
567
+ 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.`, {
568
+ sourcePath,
569
+ actualFormat,
570
+ expectedFormat: 'native'
571
+ }), _define_property(this, "actualFormat", void 0);
572
+ this.actualFormat = actualFormat;
573
+ }
574
+ }
575
+ const collect_rejectUnknownKeys = (value, allowed, label)=>{
576
+ const allowedSet = new Set(allowed);
577
+ const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
578
+ if (unknown.length) throw new test_runner_namespaceObject.WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
579
+ field: unknown[0]
939
580
  });
940
- const settledExecution = Promise.race([
941
- execution,
942
- abortedExecution,
943
- ...void 0 === timeoutExecution ? [] : [
944
- timeoutExecution
945
- ]
946
- ]);
581
+ };
582
+ const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
583
+ const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
584
+ function collectWorkflowDocument(source, options) {
585
+ let parsed;
947
586
  try {
948
- const output = validateNodeOutput(await settledExecution, step.node);
949
- return {
950
- ...createStepResultBase(step, startedAt, phase, stepIndex),
951
- status: 'success',
952
- continuedAfterError: false,
953
- ...void 0 === output ? {} : {
954
- output
955
- }
956
- };
587
+ parsed = (0, external_js_yaml_namespaceObject.load)((0, external_node_fs_.readFileSync)(source.absolutePath, 'utf8'), {
588
+ schema: external_js_yaml_namespaceObject.JSON_SCHEMA
589
+ });
957
590
  } catch (error) {
958
- return {
959
- ...createStepResultBase(step, startedAt, phase, stepIndex),
960
- status: 'failed',
961
- continuedAfterError: step.meta.continueOnError,
962
- error: normalizeNodeExecutionError(error, step.node)
963
- };
964
- } finally{
965
- if (void 0 !== timeout) clearTimeout(timeout);
966
- if (void 0 !== abortGraceTimeout) clearTimeout(abortGraceTimeout);
967
- if (void 0 !== settleOnAbort) abortController.signal.removeEventListener('abort', settleOnAbort);
968
- parentSignal?.removeEventListener('abort', abortFromParent);
591
+ throw new test_runner_namespaceObject.WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
592
+ sourcePath: source.sourcePath
593
+ }, error);
969
594
  }
970
- }
971
- function createNodeReportCollector(node) {
972
- const traces = new Map();
973
- let accepting = true;
974
- return {
975
- collector: Object.freeze({
976
- addTrace (trace) {
977
- if (!accepting) throw new NodeExecutionError(node, new Error('Report traces can only be added while the current Step is running.'));
978
- if (!trace || 'object' != typeof trace || 'midscene-execution' !== trace.type) throw new NodeExecutionError(node, new TypeError('Report trace type must be "midscene-execution".'));
979
- if ('string' != typeof trace.executionId || 0 === trace.executionId.trim().length) throw new NodeExecutionError(node, new TypeError('Report trace executionId must be non-empty.'));
980
- const normalized = Object.freeze({
981
- type: 'midscene-execution',
982
- executionId: trace.executionId
983
- });
984
- traces.set(`${normalized.type}:${normalized.executionId}`, normalized);
985
- }
986
- }),
987
- close () {
988
- accepting = false;
989
- return Object.freeze([
990
- ...traces.values()
991
- ]);
992
- }
993
- };
994
- }
995
- async function parseNodeInput(node, input) {
996
- if (!node.inputSchema) return input;
997
- const parsed = await node.inputSchema.safeParseAsync(input);
998
- if (!parsed.success) throw NodeInputValidationError.fromZod(node.name, parsed.error);
999
- return parsed.data;
1000
- }
1001
- async function executeStep(step, node, target, context, execution = {}) {
1002
- const phase = 'case' === target.scope ? target.case.phase : target.document.phase;
1003
- const stepIndex = 'case' === target.scope ? target.case.stepIndex : target.document.stepIndex;
1004
- const reportCollector = createNodeReportCollector(step.node);
1005
- let result;
1006
- try {
1007
- result = await executeNode(step, async (signal)=>{
1008
- const input = await parseNodeInput(node, step.input);
1009
- signal.throwIfAborted();
1010
- const common = {
1011
- input,
1012
- $: step.meta,
1013
- signal,
1014
- context,
1015
- report: reportCollector.collector,
1016
- onTeardown: (teardown)=>{
1017
- if (!execution.onTeardown) throw new NodeExecutionError(step.node, new Error('The current execution scope cannot register teardown.'));
1018
- execution.onTeardown(step.node, teardown);
595
+ if (!isMapping(parsed)) throw new test_runner_namespaceObject.WorkflowParseError('Workflow document must be a mapping.');
596
+ const format = (0, test_runner_namespaceObject.classifyWorkflowDocumentFormat)(parsed);
597
+ if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
598
+ collect_rejectUnknownKeys(parsed, [
599
+ 'beforeAll',
600
+ 'beforeEach',
601
+ 'cases',
602
+ 'afterEach',
603
+ 'afterAll'
604
+ ], 'Workflow document');
605
+ if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new test_runner_namespaceObject.WorkflowParseError('Workflow document must contain a non-empty cases array.');
606
+ const sourcePath = source.sourcePath.split(external_node_path_.sep).join('/');
607
+ const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
608
+ ...step,
609
+ input: resolveWorkflowVariables(step.input, {
610
+ variables: options.variables,
611
+ env: options.env ?? process.env,
612
+ location: {
613
+ projectName: source.projectName ?? source.projectId,
614
+ sourcePath,
615
+ phase,
616
+ stepIndex,
617
+ ...void 0 === caseIndex ? {} : {
618
+ caseIndex
619
+ }
1019
620
  }
1020
- };
1021
- return 'case' === target.scope ? node.execute({
1022
- ...common,
1023
- scope: 'case',
1024
- case: target.case
1025
- }) : node.execute({
1026
- ...common,
1027
- scope: 'document',
1028
- document: target.document
621
+ })
622
+ });
623
+ const normalizeLifecycle = (phase)=>{
624
+ const value = parsed[phase];
625
+ if (void 0 === value) return [];
626
+ if (!Array.isArray(value)) throw new test_runner_namespaceObject.WorkflowParseError(`Workflow document ${phase} must be an array.`, {
627
+ phase
628
+ });
629
+ return normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
630
+ const resolved = options.resolveNode(normalized.node);
631
+ if (!resolved) throw new test_runner_namespaceObject.WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
632
+ phase,
633
+ stepIndex,
634
+ node: normalized.node
1029
635
  });
1030
- }, phase, stepIndex, {
1031
- parentSignal: execution.signal,
1032
- defaultTimeoutMs: execution.defaultTimeoutMs
1033
- });
1034
- } finally{
1035
- const traces = reportCollector.close();
1036
- if (traces.length > 0 && result) result = {
1037
- ...result,
1038
- report: {
1039
- traces
1040
- }
1041
- };
1042
- }
1043
- if (!result) throw new NodeExecutionError(step.node, new Error('Step execution completed without a result.'));
1044
- return result;
1045
- }
1046
- const reportPathsFromTeardown = (result)=>{
1047
- if (void 0 === result) return [];
1048
- if ('object' != typeof result || null === result || Array.isArray(result)) throw new TypeError('Node teardown result must be an object or undefined.');
1049
- const paths = result.reportPaths;
1050
- if (void 0 === paths) return [];
1051
- if (!Array.isArray(paths)) throw new TypeError('Node teardown reportPaths must be an array.');
1052
- return paths.map((path, index)=>{
1053
- if ('string' != typeof path || 0 === path.trim().length) throw new TypeError(`Node teardown reportPaths[${index}] must be a non-empty string.`);
1054
- if (!(0, external_node_path_.isAbsolute)(path)) throw new TypeError(`Node teardown reportPaths[${index}] must be an absolute path.`);
1055
- return path;
1056
- });
1057
- };
1058
- function createDocumentRuntime(document, options) {
1059
- const documentRunId = options.createDocumentRunId?.() ?? (0, external_node_crypto_namespaceObject.randomUUID)();
1060
- const startedAt = new Date();
1061
- const beforeAll = [];
1062
- const afterAll = [];
1063
- const nodes = {
1064
- beforeAll: document.lifecycle.beforeAll.map((step)=>options.resolveNode(step.node)),
1065
- afterAll: document.lifecycle.afterAll.map((step)=>options.resolveNode(step.node))
1066
- };
1067
- const project = options.project ?? Object.freeze({
1068
- projectId: document.projectId,
1069
- name: document.projectId,
1070
- tags: Object.freeze({
1071
- include: [],
1072
- exclude: []
1073
- }),
1074
- retry: 0,
1075
- variables: Object.freeze({})
1076
- });
1077
- const context = options.projectContext;
1078
- const signal = options.signal ?? new AbortController().signal;
1079
- const nodeTeardownStack = [];
1080
- let acceptingNodeTeardowns = true;
1081
- let started = false;
1082
- let finishedResult;
1083
- const reportPaths = new Set();
1084
- const createResult = (teardownErrors = [])=>{
1085
- const endedAt = new Date();
1086
- return {
1087
- documentId: document.documentId,
1088
- documentRunId,
1089
- projectId: document.projectId,
1090
- projectName: project.name,
1091
- sourcePath: document.sourcePath,
1092
- status: beforeAll.some((step)=>'failed' === step.status) || afterAll.some((step)=>'failed' === step.status) || teardownErrors.length > 0 ? 'failed' : 'success',
1093
- startedAt: startedAt.toISOString(),
1094
- endedAt: endedAt.toISOString(),
1095
- durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
1096
- beforeAll: [
1097
- ...beforeAll
1098
- ],
1099
- afterAll: [
1100
- ...afterAll
1101
- ],
1102
- ...teardownErrors.length > 0 ? {
1103
- teardownErrors
1104
- } : {},
1105
- ...reportPaths.size > 0 ? {
1106
- reportPaths: [
1107
- ...reportPaths
1108
- ]
1109
- } : {}
1110
- };
1111
- };
1112
- const runPhase = async (phase, results, executionSignal = signal)=>{
1113
- for (const [stepIndex, step] of document.lifecycle[phase].entries()){
1114
- const documentContext = {
1115
- documentId: document.documentId,
1116
- documentRunId,
1117
- projectId: document.projectId,
1118
- projectName: project.name,
1119
- sourcePath: document.sourcePath,
1120
- phase,
1121
- stepIndex
1122
- };
1123
- const stepInfo = {
1124
- scope: 'document',
1125
- node: step.node,
1126
- stepCount: document.lifecycle[phase].length,
1127
- document: documentContext
1128
- };
1129
- await options.onStepStart?.(stepInfo);
1130
- const result = await executeStep(step, nodes[phase][stepIndex], {
1131
- scope: 'document',
1132
- document: documentContext
1133
- }, context, {
1134
- signal: executionSignal,
1135
- defaultTimeoutMs: options.defaultTimeoutMs,
1136
- onTeardown (node, teardown) {
1137
- if (!acceptingNodeTeardowns) throw new WorkflowLifecycleError('Node teardown can only be registered while a workflow document is running.', {
1138
- documentId: document.documentId,
1139
- documentRunId,
1140
- node
1141
- });
1142
- if ('function' != typeof teardown) throw new WorkflowLifecycleError('Node onTeardown() requires a teardown function.', {
1143
- documentId: document.documentId,
1144
- documentRunId,
1145
- node
1146
- });
1147
- nodeTeardownStack.push({
1148
- registrationIndex: nodeTeardownStack.length,
1149
- node,
1150
- teardown
1151
- });
1152
- }
1153
- });
1154
- await options.onStepResult?.(stepInfo, result);
1155
- results.push(result);
1156
- if ('failed' === result.status && !result.continuedAfterError) break;
1157
- }
1158
- };
1159
- return {
1160
- get context () {
1161
- return context;
1162
- },
1163
- get canRunCases () {
1164
- return started && !signal.aborted && !beforeAll.some((step)=>'failed' === step.status);
1165
- },
1166
- async start () {
1167
- if (started) throw new WorkflowLifecycleError('Workflow document runtime has already started.', {
1168
- documentId: document.documentId,
1169
- documentRunId
1170
- });
1171
- started = true;
1172
- if (!signal.aborted) await runPhase('beforeAll', beforeAll);
1173
- return createResult();
1174
- },
1175
- async finish () {
1176
- if (finishedResult) return finishedResult;
1177
- if (!started) throw new WorkflowLifecycleError('Workflow document runtime must start before it can finish.', {
1178
- documentId: document.documentId,
1179
- documentRunId
1180
- });
1181
- let finishError;
1182
- try {
1183
- await runPhase('afterAll', afterAll, signal.aborted ? new AbortController().signal : signal);
1184
- } catch (error) {
1185
- finishError = error;
1186
- }
1187
- acceptingNodeTeardowns = false;
1188
- const teardownErrors = [];
1189
- for (const { registrationIndex, node, teardown } of nodeTeardownStack.reverse())try {
1190
- for (const path of reportPathsFromTeardown(await teardown()))reportPaths.add(path);
1191
- } catch (error) {
1192
- teardownErrors.push(new NodeScopeTeardownError(error, {
1193
- scope: 'document',
1194
- scopeId: documentRunId,
1195
- node,
1196
- registrationIndex
1197
- }));
1198
- }
1199
- finishedResult = createResult(teardownErrors);
1200
- if (finishError) throw finishError;
1201
- await options.onResult?.(finishedResult);
1202
- return finishedResult;
1203
- }
1204
- };
1205
- }
1206
- async function runCollectedCase(collectedCase, options) {
1207
- const phases = {
1208
- beforeEach: options.beforeEach ?? [],
1209
- steps: collectedCase.definition.steps,
1210
- afterEach: options.afterEach ?? []
1211
- };
1212
- const nodes = {
1213
- beforeEach: phases.beforeEach.map((step)=>options.resolveNode(step.node)),
1214
- steps: phases.steps.map((step)=>options.resolveNode(step.node)),
1215
- afterEach: phases.afterEach.map((step)=>options.resolveNode(step.node))
1216
- };
1217
- const runId = options.createRunId?.() ?? (0, external_node_crypto_namespaceObject.randomUUID)();
1218
- const startedAt = new Date();
1219
- const beforeEach = [];
1220
- const steps = [];
1221
- const afterEach = [];
1222
- const teardownStack = [];
1223
- let acceptingTeardowns = true;
1224
- const onTeardown = (node, teardown)=>{
1225
- if (!acceptingTeardowns) throw new WorkflowLifecycleError('Node teardown can only be registered while a case attempt is running.', {
1226
- runId,
1227
- node
1228
- });
1229
- if ('function' != typeof teardown) throw new WorkflowLifecycleError('Node onTeardown() requires a teardown function.', {
1230
- runId,
1231
- node
1232
- });
1233
- teardownStack.push({
1234
- registrationIndex: teardownStack.length,
1235
- node,
1236
- teardown
1237
- });
1238
- };
1239
- const runPhase = async (phase, results, signal = options.signal)=>{
1240
- for (const [stepIndex, step] of phases[phase].entries()){
1241
- const caseContext = {
1242
- caseId: collectedCase.caseId,
1243
- runId,
1244
- projectName: options.projectName ?? collectedCase.projectId,
1245
- attemptIndex: options.attemptIndex ?? 0,
1246
- name: collectedCase.definition.name,
1247
- sourcePath: collectedCase.sourcePath,
1248
- caseIndex: collectedCase.caseIndex,
1249
- phase,
1250
- stepIndex
1251
- };
1252
- const stepInfo = {
1253
- scope: 'case',
1254
- node: step.node,
1255
- stepCount: phases[phase].length,
1256
- case: caseContext
1257
- };
1258
- await options.onStepStart?.(stepInfo);
1259
- const result = await executeStep(step, nodes[phase][stepIndex], {
1260
- scope: 'case',
1261
- case: caseContext
1262
- }, options.context, {
1263
- signal,
1264
- defaultTimeoutMs: options.defaultTimeoutMs,
1265
- onTeardown
1266
- });
1267
- await options.onStepResult?.(stepInfo, result);
1268
- results.push(result);
1269
- if ('failed' === result.status && !result.continuedAfterError) break;
1270
- }
1271
- };
1272
- let executionError;
1273
- try {
1274
- await runPhase('beforeEach', beforeEach);
1275
- if (!beforeEach.some((step)=>'failed' === step.status)) await runPhase('steps', steps);
1276
- } catch (error) {
1277
- executionError = error;
1278
- }
1279
- try {
1280
- const cleanupSignal = options.signal?.aborted ? new AbortController().signal : options.signal;
1281
- await runPhase('afterEach', afterEach, cleanupSignal);
1282
- } catch (error) {
1283
- executionError ??= error;
1284
- }
1285
- acceptingTeardowns = false;
1286
- const teardownErrors = [];
1287
- const reportPaths = new Set();
1288
- for (const { registrationIndex, node, teardown } of teardownStack.reverse())try {
1289
- for (const path of reportPathsFromTeardown(await teardown()))reportPaths.add(path);
1290
- } catch (error) {
1291
- teardownErrors.push(new NodeScopeTeardownError(error, {
1292
- scope: 'case',
1293
- scopeId: runId,
1294
- node,
1295
- registrationIndex
1296
- }));
1297
- }
1298
- if (executionError) throw executionError;
1299
- const endedAt = new Date();
1300
- const result = {
1301
- caseId: collectedCase.caseId,
1302
- runId,
1303
- projectName: options.projectName ?? collectedCase.projectId,
1304
- attemptIndex: options.attemptIndex ?? 0,
1305
- name: collectedCase.definition.name,
1306
- sourcePath: collectedCase.sourcePath,
1307
- caseIndex: collectedCase.caseIndex,
1308
- status: beforeEach.some((step)=>'failed' === step.status) || steps.some((step)=>'failed' === step.status) || afterEach.some((step)=>'failed' === step.status) || teardownErrors.length > 0 ? 'failed' : 'success',
1309
- beforeEach,
1310
- steps,
1311
- afterEach,
1312
- ...teardownErrors.length > 0 ? {
1313
- teardownErrors
1314
- } : {},
1315
- ...reportPaths.size > 0 ? {
1316
- reportPaths: [
1317
- ...reportPaths
1318
- ]
1319
- } : {},
1320
- startedAt: startedAt.toISOString(),
1321
- endedAt: endedAt.toISOString(),
1322
- durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime())
1323
- };
1324
- await options.onResult?.(result);
1325
- return result;
1326
- }
1327
- const asNotRun = (collectedCase, projectName, reason)=>({
1328
- caseId: collectedCase.caseId,
1329
- projectName,
1330
- name: collectedCase.definition.name,
1331
- sourcePath: collectedCase.sourcePath,
1332
- caseIndex: collectedCase.caseIndex,
1333
- status: 'not-run',
1334
- notRunReason: reason
1335
- });
1336
- async function runWorkflowDocument(document, options) {
1337
- const createDocumentRunId = options.createDocumentRunId;
1338
- const createCaseRunId = options.createCaseRunId;
1339
- const projectName = options.project?.name ?? document.projectId;
1340
- const retry = options.retry ?? options.project?.retry ?? 0;
1341
- if (!Number.isInteger(retry) || retry < 0) throw new TypeError('Workflow document retry must be a non-negative integer.');
1342
- const runtime = createDocumentRuntime(document, {
1343
- resolveNode: options.resolveNode,
1344
- project: options.project,
1345
- projectContext: options.projectContext,
1346
- signal: options.signal,
1347
- defaultTimeoutMs: options.defaultTimeoutMs,
1348
- onStepStart: options.onStepStart,
1349
- onStepResult: options.onStepResult,
1350
- onResult: options.onDocumentResult,
1351
- createDocumentRunId: createDocumentRunId ? ()=>createDocumentRunId(document) : void 0
1352
- });
1353
- const cases = [];
1354
- let runtimeStarted = false;
1355
- let documentResult;
1356
- let executionError;
1357
- let fatalError = false;
1358
- try {
1359
- runtimeStarted = true;
1360
- await runtime.start();
1361
- if (runtime.canRunCases) for (const collectedCase of document.cases){
1362
- if (fatalError || options.shouldStop?.()) {
1363
- const outcome = asNotRun(collectedCase, projectName, fatalError ? 'fatal-error' : options.stopReason?.() ?? 'interrupted');
1364
- cases.push(outcome);
1365
- await options.onCaseOutcome?.(outcome);
1366
- continue;
1367
- }
1368
- await options.onCaseStart?.(collectedCase);
1369
- const attempts = [];
1370
- for(let attemptIndex = 0; attemptIndex <= retry; attemptIndex += 1){
1371
- const run = await runCollectedCase(collectedCase, {
1372
- resolveNode: options.resolveNode,
1373
- beforeEach: document.lifecycle.beforeEach,
1374
- afterEach: document.lifecycle.afterEach,
1375
- context: runtime.context,
1376
- projectName,
1377
- attemptIndex,
1378
- signal: options.signal,
1379
- defaultTimeoutMs: options.defaultTimeoutMs,
1380
- onStepStart: options.onStepStart,
1381
- onStepResult: options.onStepResult,
1382
- onResult: options.onCaseResult,
1383
- createRunId: createCaseRunId ? ()=>createCaseRunId(collectedCase, attemptIndex) : void 0
1384
- });
1385
- attempts.push(run);
1386
- fatalError = options.isFatalError?.(run) ?? false;
1387
- if ('success' === run.status || fatalError) break;
1388
- }
1389
- const run = attempts.at(-1);
1390
- const outcome = {
1391
- caseId: collectedCase.caseId,
1392
- projectName,
1393
- name: collectedCase.definition.name,
1394
- sourcePath: collectedCase.sourcePath,
1395
- caseIndex: collectedCase.caseIndex,
1396
- status: run.status,
1397
- run,
1398
- attempts
1399
- };
1400
- cases.push(outcome);
1401
- await options.onCaseOutcome?.(outcome);
1402
- }
1403
- else {
1404
- const reason = options.signal?.aborted ? options.stopReason?.() ?? 'interrupted' : 'document-start-failed';
1405
- cases.push(...document.cases.map((collectedCase)=>asNotRun(collectedCase, projectName, reason)));
1406
- }
1407
- } catch (error) {
1408
- executionError = error;
1409
- } finally{
1410
- if (runtimeStarted) try {
1411
- documentResult = await runtime.finish();
1412
- } catch (error) {
1413
- executionError ??= error;
1414
- }
1415
- }
1416
- if (executionError) throw executionError;
1417
- if (!documentResult) throw new Error(`Workflow document "${document.sourcePath}" did not produce a result.`);
1418
- return {
1419
- document: documentResult,
1420
- cases
1421
- };
1422
- }
1423
- const external_js_yaml_namespaceObject = require("js-yaml");
1424
- const supportedMetaKeys = new Set([
1425
- 'timeout',
1426
- 'continue-on-error'
1427
- ]);
1428
- function isMapping(value) {
1429
- return 'object' == typeof value && null !== value && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || null === Object.getPrototypeOf(value));
1430
- }
1431
- function formatStep(index) {
1432
- return `step ${index + 1}`;
1433
- }
1434
- function normalizeMeta(value, index) {
1435
- if (void 0 === value) return {
1436
- continueOnError: false
1437
- };
1438
- if (!isMapping(value)) throw new WorkflowParseError(`${formatStep(index)} "$" must be a mapping.`, {
1439
- index
1440
- });
1441
- for (const key of Object.keys(value))if (!supportedMetaKeys.has(key)) throw new WorkflowParseError(`${formatStep(index)} has unsupported engine metadata "${key}".`, {
1442
- index,
1443
- key
1444
- });
1445
- const timeout = value.timeout;
1446
- 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.`, {
1447
- index,
1448
- timeout
1449
- });
1450
- const continueOnError = value['continue-on-error'];
1451
- if (void 0 !== continueOnError && 'boolean' != typeof continueOnError) throw new WorkflowParseError(`${formatStep(index)} "$.continue-on-error" must be a boolean.`, {
1452
- index,
1453
- continueOnError
1454
- });
1455
- return {
1456
- ...void 0 === timeout ? {} : {
1457
- timeoutMs: timeout
1458
- },
1459
- continueOnError: continueOnError ?? false
1460
- };
1461
- }
1462
- function normalizeStep(value, index = 0, resolveNode) {
1463
- if (!isMapping(value)) throw new WorkflowParseError(`${formatStep(index)} must be a mapping.`, {
1464
- index
1465
- });
1466
- const entries = Object.entries(value);
1467
- if (1 !== entries.length) throw new WorkflowParseError(`${formatStep(index)} must contain exactly one node.`, {
1468
- index,
1469
- nodes: Object.keys(value)
1470
- });
1471
- const [node, rawValue] = entries[0];
1472
- if (0 === node.trim().length) throw new WorkflowParseError(`${formatStep(index)} node name must not be empty.`, {
1473
- index
1474
- });
1475
- if ('string' == typeof rawValue) {
1476
- const resolvedNode = resolveNode?.(node);
1477
- const stringInputKey = resolvedNode ? resolvedNode.stringInputKey : 'prompt';
1478
- if (false === stringInputKey || void 0 === stringInputKey) throw new WorkflowParseError(`${formatStep(index)} node "${node}" does not accept string shorthand.`, {
1479
- index,
1480
- node
1481
- });
1482
- return {
1483
- node,
1484
- input: {
1485
- [stringInputKey]: rawValue
1486
- },
1487
- meta: {
1488
- continueOnError: false
1489
- }
1490
- };
1491
- }
1492
- if (!isMapping(rawValue)) throw new WorkflowParseError(`${formatStep(index)} value must be a string shorthand or a mapping.`, {
1493
- index,
1494
- node
1495
- });
1496
- const { $: rawMeta, ...input } = rawValue;
1497
- return {
1498
- node,
1499
- input,
1500
- meta: normalizeMeta(rawMeta, index)
1501
- };
1502
- }
1503
- function normalizeSteps(steps, resolveNode) {
1504
- if (!Array.isArray(steps)) throw new WorkflowParseError('Steps must be an array.');
1505
- return steps.map((step, index)=>normalizeStep(step, index, resolveNode));
1506
- }
1507
- const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
1508
- const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
1509
- const exactProjectExpression = /^\$\{([^{}]+)\}$/;
1510
- const missingVariable = (kind, name, options)=>{
1511
- throw new WorkflowParseError(`Undefined ${kind} variable "${name}" in ${options.location.sourcePath}.`, {
1512
- ...options.location,
1513
- variable: name,
1514
- variableKind: kind
1515
- });
1516
- };
1517
- const projectVariable = (name, options)=>{
1518
- if (!Object.hasOwn(options.variables ?? {}, name)) return missingVariable('project', name, options);
1519
- return options.variables[name];
1520
- };
1521
- const environmentVariable = (name, options)=>{
1522
- const value = options.env?.[name];
1523
- if (void 0 === value) return missingVariable('environment', name, options);
1524
- return value;
1525
- };
1526
- const interpolationValue = (name, value, options)=>{
1527
- if ('object' == typeof value && null !== value) throw new WorkflowParseError(`Project variable "${name}" must be a primitive when embedded in a string.`, {
1528
- ...options.location,
1529
- variable: name,
1530
- variableKind: 'project'
1531
- });
1532
- return String(value);
1533
- };
1534
- const resolveString = (value, options)=>{
1535
- const exactEnv = exactEnvExpression.exec(value);
1536
- if (exactEnv) return environmentVariable(exactEnv[1], options);
1537
- const exactProject = exactProjectExpression.exec(value);
1538
- if (exactProject) return projectVariable(exactProject[1], options);
1539
- return value.replace(interpolationExpression, (_match, environmentName, projectName)=>environmentName ? environmentVariable(environmentName, options) : interpolationValue(projectName, projectVariable(projectName, options), options));
1540
- };
1541
- const resolveWorkflowVariables = (value, options)=>{
1542
- if ('string' == typeof value) return resolveString(value, options);
1543
- if (Array.isArray(value)) return value.map((child)=>resolveWorkflowVariables(child, options));
1544
- if ('object' == typeof value && null !== value) return Object.fromEntries(Object.entries(value).map(([key, child])=>[
1545
- key,
1546
- resolveWorkflowVariables(child, options)
1547
- ]));
1548
- return value;
1549
- };
1550
- const collect_isMapping = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
1551
- const collect_rejectUnknownKeys = (value, allowed, label)=>{
1552
- const allowedSet = new Set(allowed);
1553
- const unknown = Object.keys(value).filter((key)=>!allowedSet.has(key));
1554
- if (unknown.length) throw new WorkflowParseError(`${label} has unsupported field "${unknown[0]}".`, {
1555
- field: unknown[0]
1556
- });
1557
- };
1558
- const createCaseId = (projectId, sourcePath, caseIndex)=>(0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
1559
- projectId,
1560
- sourcePath,
1561
- caseIndex
1562
- ])).digest('hex');
1563
- const createWorkflowDocumentId = (projectId, sourcePath)=>(0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
1564
- projectId,
1565
- sourcePath
1566
- ])).digest('hex');
1567
- function collectWorkflowDocument(source, options) {
1568
- let parsed;
1569
- try {
1570
- parsed = (0, external_js_yaml_namespaceObject.load)((0, external_node_fs_.readFileSync)(source.absolutePath, 'utf8'), {
1571
- schema: external_js_yaml_namespaceObject.JSON_SCHEMA
1572
- });
1573
- } catch (error) {
1574
- throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
1575
- sourcePath: source.sourcePath
1576
- }, error);
1577
- }
1578
- if (!collect_isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
1579
- collect_rejectUnknownKeys(parsed, [
1580
- 'beforeAll',
1581
- 'beforeEach',
1582
- 'cases',
1583
- 'afterEach',
1584
- 'afterAll'
1585
- ], 'Workflow document');
1586
- if (!Array.isArray(parsed.cases) || 0 === parsed.cases.length) throw new WorkflowParseError('Workflow document must contain a non-empty cases array.');
1587
- const sourcePath = source.sourcePath.split(external_node_path_.sep).join('/');
1588
- const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
1589
- ...step,
1590
- input: resolveWorkflowVariables(step.input, {
1591
- variables: options.variables,
1592
- env: options.env ?? process.env,
1593
- location: {
1594
- projectName: source.projectName ?? source.projectId,
1595
- sourcePath,
1596
- phase,
1597
- stepIndex,
1598
- ...void 0 === caseIndex ? {} : {
1599
- caseIndex
1600
- }
1601
- }
1602
- })
1603
- });
1604
- const normalizeLifecycle = (phase)=>{
1605
- const value = parsed[phase];
1606
- if (void 0 === value) return [];
1607
- if (!Array.isArray(value)) throw new WorkflowParseError(`Workflow document ${phase} must be an array.`, {
1608
- phase
1609
- });
1610
- return normalizeSteps(value, options.resolveNode).map((normalized, stepIndex)=>{
1611
- const resolved = options.resolveNode(normalized.node);
1612
- if (!resolved) throw new WorkflowParseError(`Workflow document ${phase} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
1613
- phase,
1614
- stepIndex,
1615
- node: normalized.node
1616
- });
1617
- return resolveStepVariables(normalized, phase, stepIndex);
636
+ return resolveStepVariables(normalized, phase, stepIndex);
1618
637
  });
1619
638
  };
1620
639
  const lifecycle = {
@@ -1625,7 +644,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
1625
644
  };
1626
645
  const ids = new Set();
1627
646
  const cases = parsed.cases.map((definition, caseIndex)=>{
1628
- if (!collect_isMapping(definition)) throw new WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
647
+ if (!isMapping(definition)) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} must be a mapping.`, {
1629
648
  caseIndex
1630
649
  });
1631
650
  collect_rejectUnknownKeys(definition, [
@@ -1633,18 +652,18 @@ A Node may map a string step value to one declared input field. Each Node sectio
1633
652
  'tags',
1634
653
  'steps'
1635
654
  ], `Case ${caseIndex + 1}`);
1636
- if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
655
+ if ('string' != typeof definition.name || 0 === definition.name.trim().length) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} name must be a non-empty string.`, {
1637
656
  caseIndex
1638
657
  });
1639
- if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
658
+ if (!Array.isArray(definition.steps) || 0 === definition.steps.length) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} steps must be a non-empty array.`, {
1640
659
  caseIndex
1641
660
  });
1642
661
  const tags = definition.tags ?? [];
1643
- 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.`, {
662
+ if (!Array.isArray(tags) || tags.some((tag)=>'string' != typeof tag || 0 === tag.trim().length)) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} tags must be an array of non-empty strings.`, {
1644
663
  caseIndex
1645
664
  });
1646
665
  const steps = normalizeSteps(definition.steps, options.resolveNode).map((normalized, stepIndex)=>{
1647
- if (!options.resolveNode(normalized.node)) throw new WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
666
+ if (!options.resolveNode(normalized.node)) throw new test_runner_namespaceObject.WorkflowParseError(`Case ${caseIndex + 1} step ${stepIndex + 1} references unknown node "${normalized.node}".`, {
1648
667
  caseIndex,
1649
668
  stepIndex,
1650
669
  node: normalized.node
@@ -1652,7 +671,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
1652
671
  return resolveStepVariables(normalized, 'steps', stepIndex, caseIndex);
1653
672
  });
1654
673
  const caseId = createCaseId(source.projectId, sourcePath, caseIndex);
1655
- if (ids.has(caseId)) throw new WorkflowParseError(`Case id collision: ${caseId}.`, {
674
+ if (ids.has(caseId)) throw new test_runner_namespaceObject.WorkflowParseError(`Case id collision: ${caseId}.`, {
1656
675
  caseId
1657
676
  });
1658
677
  ids.add(caseId);
@@ -1669,435 +688,27 @@ A Node may map a string step value to one declared input field. Each Node sectio
1669
688
  };
1670
689
  });
1671
690
  return {
1672
- documentId: createWorkflowDocumentId(source.projectId, sourcePath),
1673
- projectId: source.projectId,
1674
- sourcePath,
1675
- lifecycle,
1676
- cases
1677
- };
1678
- }
1679
- const logger_namespaceObject = require("@midscene/shared/logger");
1680
- const MAX_DEPTH = 6;
1681
- const MAX_STRING_LENGTH = 2000;
1682
- const MAX_ARRAY_ITEMS = 50;
1683
- const MAX_OBJECT_KEYS = 50;
1684
- const MAX_SERIALIZED_BYTES = 65536;
1685
- const TRUNCATED_VALUE = '[Truncated: display value exceeded 64 KiB]';
1686
- const normalizedSecretKeys = [
1687
- 'password',
1688
- 'passwd',
1689
- 'secret',
1690
- 'token',
1691
- 'authorization',
1692
- 'cookie',
1693
- 'apikey'
1694
- ];
1695
- const normalizedKey = (key)=>key.toLowerCase().replace(/[^a-z0-9]/g, '');
1696
- const isSecretKey = (key)=>{
1697
- const normalized = normalizedKey(key);
1698
- return normalizedSecretKeys.some((secret)=>normalized.includes(secret));
1699
- };
1700
- const objectPath = (parent, key)=>/^[A-Za-z_$][\w$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
1701
- const stringByteLength = (value)=>Buffer.byteLength(value, 'utf8');
1702
- function sanitizeReportText(value, maximumLength = MAX_STRING_LENGTH) {
1703
- return value.length <= maximumLength ? value : `${value.slice(0, maximumLength)}… [truncated]`;
1704
- }
1705
- function sanitizeReportValue(value) {
1706
- const truncatedPaths = new Set();
1707
- const redactedPaths = new Set();
1708
- const ancestors = new WeakSet();
1709
- let remainingBytes = MAX_SERIALIZED_BYTES;
1710
- const consume = (path, text)=>{
1711
- remainingBytes -= stringByteLength(text);
1712
- if (remainingBytes >= 0) return true;
1713
- truncatedPaths.add(path);
1714
- return false;
1715
- };
1716
- const visit = (current, path, depth)=>{
1717
- if (remainingBytes <= 0) {
1718
- truncatedPaths.add(path);
1719
- return '[Truncated: size limit]';
1720
- }
1721
- if (null === current || 'boolean' == typeof current) {
1722
- consume(path, String(current));
1723
- return current;
1724
- }
1725
- if ('string' == typeof current) {
1726
- if (current.length > MAX_STRING_LENGTH) {
1727
- truncatedPaths.add(path);
1728
- const shortened = `${current.slice(0, MAX_STRING_LENGTH)}… [truncated]`;
1729
- consume(path, shortened);
1730
- return shortened;
1731
- }
1732
- consume(path, current);
1733
- return current;
1734
- }
1735
- if ('number' == typeof current) {
1736
- if (Number.isFinite(current)) {
1737
- consume(path, String(current));
1738
- return current;
1739
- }
1740
- truncatedPaths.add(path);
1741
- return `[Unsupported number: ${String(current)}]`;
1742
- }
1743
- if ('bigint' == typeof current) {
1744
- truncatedPaths.add(path);
1745
- return `[Unsupported bigint: ${String(current)}]`;
1746
- }
1747
- if (void 0 === current || 'function' == typeof current || 'symbol' == typeof current) {
1748
- truncatedPaths.add(path);
1749
- return `[Unsupported ${typeof current}]`;
1750
- }
1751
- if (depth >= MAX_DEPTH) {
1752
- truncatedPaths.add(path);
1753
- return '[Truncated: maximum depth]';
1754
- }
1755
- if (current instanceof Date) {
1756
- const date = Number.isNaN(current.getTime()) ? '[Invalid Date]' : current.toISOString();
1757
- consume(path, date);
1758
- return date;
1759
- }
1760
- if (ancestors.has(current)) {
1761
- truncatedPaths.add(path);
1762
- return '[Unsupported circular value]';
1763
- }
1764
- if (Array.isArray(current)) {
1765
- ancestors.add(current);
1766
- const output = [];
1767
- const itemCount = Math.min(current.length, MAX_ARRAY_ITEMS);
1768
- for(let index = 0; index < itemCount; index += 1){
1769
- if (remainingBytes <= 0) break;
1770
- output.push(visit(current[index], `${path}[${index}]`, depth + 1));
1771
- }
1772
- if (current.length > output.length) truncatedPaths.add(path);
1773
- ancestors.delete(current);
1774
- return output;
1775
- }
1776
- const prototype = Object.getPrototypeOf(current);
1777
- if (prototype !== Object.prototype && null !== prototype) {
1778
- truncatedPaths.add(path);
1779
- const constructorName = current.constructor?.name ?? 'object';
1780
- return `[Unsupported object: ${constructorName}]`;
1781
- }
1782
- ancestors.add(current);
1783
- const output = {};
1784
- const keys = Object.keys(current);
1785
- for (const key of keys.slice(0, MAX_OBJECT_KEYS)){
1786
- const childPath = objectPath(path, key);
1787
- if (!consume(childPath, key)) break;
1788
- if (isSecretKey(key)) {
1789
- output[key] = '[REDACTED]';
1790
- redactedPaths.add(childPath);
1791
- } else output[key] = visit(current[key], childPath, depth + 1);
1792
- if (remainingBytes <= 0) break;
1793
- }
1794
- if (keys.length > Object.keys(output).length) truncatedPaths.add(path);
1795
- ancestors.delete(current);
1796
- return output;
1797
- };
1798
- const sanitized = visit(value, '$', 0);
1799
- const serialized = JSON.stringify(sanitized);
1800
- if (void 0 === serialized || stringByteLength(serialized) > MAX_SERIALIZED_BYTES) return {
1801
- value: TRUNCATED_VALUE,
1802
- truncatedPaths: [
1803
- '$'
1804
- ]
1805
- };
1806
- return {
1807
- value: sanitized,
1808
- ...truncatedPaths.size > 0 ? {
1809
- truncatedPaths: [
1810
- ...truncatedPaths
1811
- ]
1812
- } : {},
1813
- ...redactedPaths.size > 0 ? {
1814
- redactedPaths: [
1815
- ...redactedPaths
1816
- ]
1817
- } : {}
1818
- };
1819
- }
1820
- function sanitizeReportError(error) {
1821
- if (error instanceof Error) {
1822
- const workflowLike = error;
1823
- return {
1824
- name: sanitizeReportText(error.name || 'Error', 200),
1825
- message: sanitizeReportText(error.message || String(error)),
1826
- ...'string' == typeof workflowLike.code ? {
1827
- code: sanitizeReportText(workflowLike.code, 200)
1828
- } : {},
1829
- ...void 0 === workflowLike.details ? {} : {
1830
- details: sanitizeReportValue(workflowLike.details)
1831
- }
1832
- };
1833
- }
1834
- return {
1835
- name: 'Error',
1836
- message: sanitizeReportText(String(error))
1837
- };
1838
- }
1839
- const warnReport = (0, logger_namespaceObject.getDebug)('test-runner:report-assembler', {
1840
- console: true
1841
- });
1842
- const exactMidsceneDetailNodes = new Set([
1843
- 'aiAct',
1844
- 'aiAssert',
1845
- 'recordToReport'
1846
- ]);
1847
- const scopedExecutionKey = (scopeId, executionId)=>`${scopeId}\u0000${executionId}`;
1848
- const reportSourcesForScope = (context, scopeId)=>context.sourcesByScope.get(scopeId) ?? [];
1849
- const addDiagnostic = (context, diagnostic)=>{
1850
- context.diagnostics.push(diagnostic);
1851
- const key = JSON.stringify([
1852
- diagnostic.code,
1853
- diagnostic.scopeId,
1854
- diagnostic.executionId
1855
- ]);
1856
- if (!context.warnedDiagnostics.has(key)) {
1857
- context.warnedDiagnostics.add(key);
1858
- warnReport(diagnostic.message);
1859
- }
1860
- };
1861
- const buildStep = (step, scopeId, context)=>{
1862
- const details = [];
1863
- const unresolvedExecutionIds = [];
1864
- for (const trace of step.report?.traces ?? []){
1865
- const previousScope = context.tracedScopeByExecution.get(trace.executionId);
1866
- if (previousScope && previousScope !== scopeId) throw new Error(`Agent execution ${trace.executionId} was traced by multiple test scopes (${previousScope}, ${scopeId}).`);
1867
- context.tracedScopeByExecution.set(trace.executionId, scopeId);
1868
- const detail = context.detailsByScopeAndExecution.get(scopedExecutionKey(scopeId, trace.executionId));
1869
- if (detail) details.push(detail);
1870
- else unresolvedExecutionIds.push(trace.executionId);
1871
- }
1872
- let agentDetailDiagnostic;
1873
- if (unresolvedExecutionIds.length > 0) {
1874
- agentDetailDiagnostic = `Agent detail could not be resolved for ${unresolvedExecutionIds.join(', ')}.`;
1875
- const sources = reportSourcesForScope(context, scopeId);
1876
- for (const executionId of unresolvedExecutionIds)addDiagnostic(context, {
1877
- level: 'warning',
1878
- code: 'agent-detail-unresolved',
1879
- message: `Agent detail could not be resolved for execution ${executionId} in test scope ${scopeId}.`,
1880
- scopeId,
1881
- executionId,
1882
- ...sources.length > 0 ? {
1883
- sourcePaths: sources.map((source)=>source.sourcePath)
1884
- } : {}
1885
- });
1886
- } else if (0 === details.length && exactMidsceneDetailNodes.has(step.node)) {
1887
- agentDetailDiagnostic = 'This Agent did not expose a stable execution reference for the Step.';
1888
- addDiagnostic(context, {
1889
- level: 'warning',
1890
- code: 'agent-detail-unavailable',
1891
- message: `No stable Agent execution reference was captured for ${step.node} in test scope ${scopeId}.`,
1892
- scopeId,
1893
- ...reportSourcesForScope(context, scopeId).length > 0 ? {
1894
- sourcePaths: reportSourcesForScope(context, scopeId).map((source)=>source.sourcePath)
1895
- } : {}
1896
- });
1897
- }
1898
- const prompt = 'string' == typeof step.input?.prompt ? step.input.prompt : void 0;
1899
- return {
1900
- id: `${scopeId}:${step.phase}:${step.stepIndex}`,
1901
- phase: step.phase,
1902
- stepIndex: step.stepIndex,
1903
- node: step.node,
1904
- ...prompt ? {
1905
- title: sanitizeReportText(prompt, 240)
1906
- } : {},
1907
- status: step.status,
1908
- continuedAfterError: step.continuedAfterError,
1909
- startedAt: step.startedAt,
1910
- endedAt: step.endedAt,
1911
- durationMs: step.durationMs,
1912
- input: sanitizeReportValue(step.input),
1913
- ...step.output ? {
1914
- output: {
1915
- ...void 0 === step.output.summary ? {} : {
1916
- summary: sanitizeReportText(step.output.summary)
1917
- },
1918
- ...void 0 === step.output.data ? {} : {
1919
- data: sanitizeReportValue(step.output.data)
1920
- }
1921
- }
1922
- } : {},
1923
- ...step.error ? {
1924
- error: sanitizeReportError(step.error)
1925
- } : {},
1926
- ...details.length > 0 ? {
1927
- agentDetails: details
1928
- } : {},
1929
- ...agentDetailDiagnostic ? {
1930
- agentDetailDiagnostic
1931
- } : {}
1932
- };
1933
- };
1934
- const buildAttempt = (attempt, context)=>({
1935
- attemptId: attempt.runId,
1936
- attemptIndex: attempt.attemptIndex,
1937
- status: attempt.status,
1938
- startedAt: attempt.startedAt,
1939
- endedAt: attempt.endedAt,
1940
- durationMs: attempt.durationMs,
1941
- beforeEach: attempt.beforeEach.map((step)=>buildStep(step, attempt.runId, context)),
1942
- steps: attempt.steps.map((step)=>buildStep(step, attempt.runId, context)),
1943
- afterEach: attempt.afterEach.map((step)=>buildStep(step, attempt.runId, context)),
1944
- ...attempt.teardownErrors?.length ? {
1945
- teardownErrors: attempt.teardownErrors.map(sanitizeReportError)
1946
- } : {},
1947
- ...reportSourcesForScope(context, attempt.runId).length > 0 ? {
1948
- scopeReportIds: reportSourcesForScope(context, attempt.runId).map((source)=>source.reportId)
1949
- } : {}
1950
- });
1951
- const buildCase = (outcome, context)=>({
1952
- caseId: outcome.caseId,
1953
- name: outcome.name,
1954
- caseIndex: outcome.caseIndex,
1955
- status: outcome.status,
1956
- ...outcome.notRunReason ? {
1957
- notRunReason: outcome.notRunReason
1958
- } : {},
1959
- attempts: (outcome.attempts ?? (outcome.run ? [
1960
- outcome.run
1961
- ] : [])).map((attempt)=>buildAttempt(attempt, context))
1962
- });
1963
- const buildDocument = (documentId, document, cases, context)=>{
1964
- const sourcePath = document?.sourcePath ?? cases[0]?.sourcePath ?? '<unknown>';
1965
- const status = document?.status ?? (cases.some((item)=>'success' !== item.status) ? 'failed' : 'success');
1966
- return {
1967
- documentId,
1968
- sourcePath,
1969
- status,
1970
- ...document ? {
1971
- startedAt: document.startedAt,
1972
- endedAt: document.endedAt,
1973
- durationMs: document.durationMs
1974
- } : {},
1975
- beforeAll: (document?.beforeAll ?? []).map((step)=>buildStep(step, document.documentRunId, context)),
1976
- cases: cases.map((outcome)=>buildCase(outcome, context)),
1977
- afterAll: (document?.afterAll ?? []).map((step)=>buildStep(step, document.documentRunId, context)),
1978
- ...document?.teardownErrors?.length ? {
1979
- teardownErrors: document.teardownErrors.map(sanitizeReportError)
1980
- } : {},
1981
- ...document && reportSourcesForScope(context, document.documentRunId).length > 0 ? {
1982
- scopeReportIds: reportSourcesForScope(context, document.documentRunId).map((source)=>source.reportId)
1983
- } : {}
1984
- };
1985
- };
1986
- const buildProject = (project, context)=>{
1987
- const documentById = new Map(project.documents.map((document)=>[
1988
- document.documentId,
1989
- document
1990
- ]));
1991
- const casesByDocumentId = new Map();
1992
- for (const outcome of project.cases){
1993
- const current = casesByDocumentId.get(outcome.documentId) ?? [];
1994
- current.push(outcome);
1995
- casesByDocumentId.set(outcome.documentId, current);
1996
- }
1997
- const documentIds = new Set([
1998
- ...project.documents.map((document)=>document.documentId),
1999
- ...project.cases.map((outcome)=>outcome.documentId)
2000
- ]);
2001
- return {
2002
- projectId: project.projectId,
2003
- name: project.name,
2004
- status: project.status,
2005
- retry: project.retry,
2006
- ...project.lifecycle ? {
2007
- lifecycle: {
2008
- status: project.lifecycle.status,
2009
- startedAt: project.lifecycle.startedAt,
2010
- endedAt: project.lifecycle.endedAt,
2011
- durationMs: project.lifecycle.durationMs,
2012
- ...project.lifecycle.setupError ? {
2013
- setupError: sanitizeReportError(project.lifecycle.setupError)
2014
- } : {},
2015
- ...project.lifecycle.teardownErrors?.length ? {
2016
- teardownErrors: project.lifecycle.teardownErrors.map(sanitizeReportError)
2017
- } : {}
2018
- }
2019
- } : {},
2020
- documents: [
2021
- ...documentIds
2022
- ].map((documentId)=>buildDocument(documentId, documentById.get(documentId), casesByDocumentId.get(documentId) ?? [], context)),
2023
- collectionErrors: project.collectionErrors.map((collectionError)=>({
2024
- sourcePath: collectionError.sourcePath,
2025
- error: sanitizeReportError(collectionError.error)
2026
- }))
691
+ documentId: createWorkflowDocumentId(source.projectId, sourcePath),
692
+ projectId: source.projectId,
693
+ sourcePath,
694
+ lifecycle,
695
+ cases
2027
696
  };
2028
- };
2029
- function collectTestRunReportSources(result) {
2030
- const sources = [];
2031
- for (const project of result.projects){
2032
- for (const document of project.documents)for (const sourcePath of document.reportPaths ?? [])sources.push({
2033
- scopeId: document.documentRunId,
2034
- sourcePath
2035
- });
2036
- for (const outcome of project.cases)for (const attempt of outcome.attempts ?? (outcome.run ? [
2037
- outcome.run
2038
- ] : []))for (const sourcePath of attempt.reportPaths ?? [])sources.push({
2039
- scopeId: attempt.runId,
2040
- sourcePath
2041
- });
2042
- }
2043
- return sources;
2044
697
  }
2045
- function buildTestRunReportDump(result, index) {
2046
- const sourcesByScope = new Map();
2047
- const detailsByScopeAndExecution = new Map();
2048
- for (const source of index.sources){
2049
- const scopeSources = sourcesByScope.get(source.scopeId) ?? [];
2050
- scopeSources.push(source);
2051
- sourcesByScope.set(source.scopeId, scopeSources);
2052
- for (const executionId of source.executionIds)detailsByScopeAndExecution.set(scopedExecutionKey(source.scopeId, executionId), {
2053
- reportId: source.reportId,
2054
- executionId
698
+ const promises_namespaceObject = require("node:fs/promises");
699
+ const writeJson = async (path, value)=>{
700
+ try {
701
+ await (0, promises_namespaceObject.mkdir)((0, external_node_path_.dirname)(path), {
702
+ recursive: true
2055
703
  });
704
+ await (0, promises_namespaceObject.writeFile)(path, JSON.stringify(value, null, 2));
705
+ } catch (error) {
706
+ throw new test_runner_namespaceObject.WorkflowPublicationError('write-result', path, error);
2056
707
  }
2057
- const context = {
2058
- diagnostics: [],
2059
- sourcesByScope,
2060
- detailsByScopeAndExecution,
2061
- tracedScopeByExecution: new Map(),
2062
- warnedDiagnostics: new Set()
2063
- };
2064
- for (const source of index.sources)if (0 === source.executionIds.length) addDiagnostic(context, {
2065
- level: 'warning',
2066
- code: 'source-without-executions',
2067
- message: `Agent report contains no executions: ${source.sourcePath}`,
2068
- scopeId: source.scopeId,
2069
- sourcePaths: [
2070
- source.sourcePath
2071
- ]
2072
- });
2073
- const projects = result.projects.map((project)=>buildProject(project, context));
2074
- return {
2075
- schemaVersion: 1,
2076
- kind: 'test-runner',
2077
- runId: result.runId,
2078
- status: result.status,
2079
- startedAt: result.startedAt,
2080
- endedAt: result.endedAt,
2081
- durationMs: result.durationMs,
2082
- summary: {
2083
- ...result.summary
2084
- },
2085
- metrics: index.metrics,
2086
- projects,
2087
- ...context.diagnostics.length > 0 ? {
2088
- diagnostics: context.diagnostics
2089
- } : {}
2090
- };
2091
- }
2092
- const writeJson = (path, value)=>{
2093
- (0, external_node_fs_.mkdirSync)((0, external_node_path_.dirname)(path), {
2094
- recursive: true
2095
- });
2096
- (0, external_node_fs_.writeFileSync)(path, JSON.stringify(value, null, 2));
2097
708
  };
2098
709
  const toPosix = (value)=>value.split(external_node_path_.sep).join('/');
2099
710
  const caseAttemptResultPath = (projectId, documentId, result)=>toPosix((0, external_node_path_.join)(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
2100
- const workflowDocumentResultPath = (result)=>toPosix((0, external_node_path_.join)(result.projectId, 'documents', result.documentId, 'document.json'));
711
+ const workflowDocumentResultPath = (result)=>toPosix((0, external_node_path_.join)(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
2101
712
  const collectionErrorPath = (projectId, sourcePath)=>{
2102
713
  const id = (0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
2103
714
  projectId,
@@ -2107,21 +718,19 @@ A Node may map a string step value to one declared input field. Each Node sectio
2107
718
  };
2108
719
  const writeWorkflowDocumentResult = (runDir, result)=>{
2109
720
  const { documentRunId: _documentRunId, ...persistedResult } = result;
2110
- writeJson((0, external_node_path_.join)(runDir, workflowDocumentResultPath(result)), persistedResult);
721
+ return writeJson((0, external_node_path_.join)(runDir, workflowDocumentResultPath(result)), persistedResult);
2111
722
  };
2112
723
  const writeCaseAttemptResult = (runDir, projectId, documentId, result)=>{
2113
724
  const { runId: attemptId, ...persistedResult } = result;
2114
- writeJson((0, external_node_path_.join)(runDir, caseAttemptResultPath(projectId, documentId, result)), {
725
+ return writeJson((0, external_node_path_.join)(runDir, caseAttemptResultPath(projectId, documentId, result)), {
2115
726
  ...persistedResult,
2116
727
  attemptId
2117
728
  });
2118
729
  };
2119
- const writeCollectionError = (runDir, collectionError)=>{
2120
- writeJson((0, external_node_path_.join)(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
730
+ const writeCollectionError = (runDir, collectionError)=>writeJson((0, external_node_path_.join)(runDir, collectionErrorPath(collectionError.projectId, collectionError.sourcePath)), {
2121
731
  kind: 'collection-error',
2122
732
  ...collectionError
2123
733
  });
2124
- };
2125
734
  const relativeToSummary = (summaryPath, target)=>{
2126
735
  const value = (0, external_node_path_.relative)((0, external_node_path_.dirname)(summaryPath), target);
2127
736
  return toPosix(value || '.');
@@ -2130,7 +739,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
2130
739
  const writeTestProjectRunResult = (options)=>{
2131
740
  const { result } = options;
2132
741
  const fact = (path)=>toPosix(path);
2133
- writeJson(result.summaryPath, {
742
+ return writeJson(result.summaryPath, {
2134
743
  schemaVersion: result.schemaVersion,
2135
744
  runId: result.runId,
2136
745
  startedAt: result.startedAt,
@@ -2138,6 +747,9 @@ A Node may map a string step value to one declared input field. Each Node sectio
2138
747
  durationMs: result.durationMs,
2139
748
  status: result.status,
2140
749
  exitCode: result.exitCode,
750
+ ...result.errors?.length ? {
751
+ errors: result.errors.map(errorJson)
752
+ } : {},
2141
753
  projectRoot: options.projectRoot,
2142
754
  ...options.configPath ? {
2143
755
  configPath: options.configPath
@@ -2173,6 +785,9 @@ A Node may map a string step value to one declared input field. Each Node sectio
2173
785
  }
2174
786
  } : {},
2175
787
  cases: project.cases.map((outcome)=>({
788
+ ...outcome.documentRunId ? {
789
+ documentRunId: outcome.documentRunId
790
+ } : {},
2176
791
  documentId: outcome.documentId,
2177
792
  caseId: outcome.caseId,
2178
793
  sourcePath: outcome.sourcePath,
@@ -2196,6 +811,10 @@ A Node may map a string step value to one declared input field. Each Node sectio
2196
811
  })),
2197
812
  documents: project.documents.map((document)=>({
2198
813
  documentId: document.documentId,
814
+ ...void 0 === document.attemptIndex ? {} : {
815
+ attemptIndex: document.attemptIndex,
816
+ documentRunId: document.documentRunId
817
+ },
2199
818
  sourcePath: document.sourcePath,
2200
819
  status: document.status,
2201
820
  resultFile: fact(workflowDocumentResultPath(document)),
@@ -2230,29 +849,28 @@ A Node may map a string step value to one declared input field. Each Node sectio
2230
849
  '**/*.{yaml,yml}'
2231
850
  ]
2232
851
  };
2233
- const test_project_runner_toPosix = (value)=>value.split(external_node_path_.sep).join('/');
2234
- const discoverTestFiles = (projectRoot, selection = DEFAULT_TEST_FILE_SELECTION)=>{
852
+ const project_preparation_toPosix = (value)=>value.split(external_node_path_.sep).join('/');
853
+ const discoverResolvedTestFiles = (projectRoot, selection)=>{
2235
854
  const root = (0, external_node_path_.resolve)(projectRoot);
2236
- const normalized = validateTestFileSelection(selection);
2237
- if (!normalized) throw new TypeError('Test file selection is required.');
2238
- const files = (0, external_tinyglobby_namespaceObject.globSync)(normalized.include, {
2239
- absolute: true,
2240
- caseSensitiveMatch: false,
2241
- cwd: root,
2242
- dot: true,
2243
- expandDirectories: false,
2244
- followSymbolicLinks: false,
2245
- ignore: [
2246
- ...ALWAYS_IGNORED_PATTERNS,
2247
- ...normalized.exclude ?? []
2248
- ],
2249
- onlyFiles: true
2250
- }).filter((file)=>/\.ya?ml$/i.test(file));
855
+ const match = (patterns)=>(0, external_tinyglobby_namespaceObject.globSync)(patterns, {
856
+ absolute: true,
857
+ caseSensitiveMatch: false,
858
+ cwd: root,
859
+ dot: true,
860
+ expandDirectories: false,
861
+ followSymbolicLinks: false,
862
+ ignore: [
863
+ ...ALWAYS_IGNORED_PATTERNS,
864
+ ...selection.exclude ?? []
865
+ ],
866
+ onlyFiles: true
867
+ }).filter((file)=>/\.ya?ml$/i.test(file));
868
+ const files = match(selection.include);
2251
869
  return [
2252
870
  ...new Set(files.map((file)=>(0, external_node_path_.resolve)(file)))
2253
871
  ].sort((a, b)=>{
2254
- const relativeA = test_project_runner_toPosix((0, external_node_path_.relative)(root, a));
2255
- const relativeB = test_project_runner_toPosix((0, external_node_path_.relative)(root, b));
872
+ const relativeA = project_preparation_toPosix((0, external_node_path_.relative)(root, a));
873
+ const relativeB = project_preparation_toPosix((0, external_node_path_.relative)(root, b));
2256
874
  return relativeA < relativeB ? -1 : relativeA > relativeB ? 1 : 0;
2257
875
  });
2258
876
  };
@@ -2273,27 +891,11 @@ A Node may map a string step value to one declared input field. Each Node sectio
2273
891
  ].join('\n'));
2274
892
  return supported[0] ? (0, external_node_path_.join)(root, supported[0]) : void 0;
2275
893
  };
2276
- const defaultResultDir = (projectRoot)=>(0, external_node_path_.join)(projectRoot, '.midscene', 'test-results');
2277
- const padDatePart = (value)=>String(value).padStart(2, '0');
2278
- const createTestRunId = (date = new Date(), uuid = (0, external_node_crypto_namespaceObject.randomUUID)())=>{
2279
- const timestamp = [
2280
- date.getFullYear(),
2281
- padDatePart(date.getMonth() + 1),
2282
- padDatePart(date.getDate()),
2283
- padDatePart(date.getHours()),
2284
- padDatePart(date.getMinutes()),
2285
- padDatePart(date.getSeconds())
2286
- ].join('');
2287
- return `${timestamp}-${uuid.slice(0, 8)}`;
2288
- };
2289
- const assertDirectory = (path, label)=>{
2290
- if (!(0, external_node_fs_.existsSync)(path) || !(0, external_node_fs_.statSync)(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
2291
- };
2292
894
  const asCollectionError = (projectId, projectName, sourcePath, error)=>({
2293
895
  projectId,
2294
896
  projectName,
2295
897
  sourcePath,
2296
- error: error instanceof WorkflowError ? error : new WorkflowParseError(`Failed to collect workflow document "${sourcePath}": ${error instanceof Error ? error.message : String(error)}`, {
898
+ error: error instanceof test_runner_namespaceObject.WorkflowError ? error : new test_runner_namespaceObject.WorkflowParseError(`Failed to collect workflow document "${sourcePath}": ${error instanceof Error ? error.message : String(error)}`, {
2297
899
  projectName,
2298
900
  sourcePath
2299
901
  }, error)
@@ -2315,52 +917,6 @@ A Node may map a string step value to one declared input field. Each Node sectio
2315
917
  filtered
2316
918
  };
2317
919
  };
2318
- const test_project_runner_asNotRun = (documentId, collectedCase, projectName, reason)=>({
2319
- documentId,
2320
- caseId: collectedCase.caseId,
2321
- projectName,
2322
- name: collectedCase.definition.name,
2323
- sourcePath: collectedCase.sourcePath,
2324
- caseIndex: collectedCase.caseIndex,
2325
- status: 'not-run',
2326
- notRunReason: reason
2327
- });
2328
- const summarize = (projects)=>{
2329
- const cases = projects.flatMap((project)=>project.cases);
2330
- const documents = projects.flatMap((project)=>project.documents);
2331
- return {
2332
- total: cases.length,
2333
- passed: cases.filter((item)=>'success' === item.status).length,
2334
- failed: cases.filter((item)=>'failed' === item.status).length,
2335
- notRun: cases.filter((item)=>'not-run' === item.status).length,
2336
- filtered: projects.reduce((total, project)=>total + project.filteredCaseCount, 0),
2337
- collectionErrors: projects.reduce((total, project)=>total + project.collectionErrors.length, 0),
2338
- documentFailures: documents.filter((document)=>'failed' === document.status).length,
2339
- projectFailures: projects.filter((project)=>'failed' === project.status).length
2340
- };
2341
- };
2342
- const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
2343
- const test_project_runner_formatStep = (info)=>{
2344
- const position = stepPosition(info);
2345
- const phase = 'steps' === position.phase ? 'step' : position.phase;
2346
- return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
2347
- };
2348
- const formatStepResult = (info, result)=>{
2349
- const indent = 'case' === info.scope ? ' ' : ' ';
2350
- const symbol = 'success' === result.status ? '✓' : '✗';
2351
- const error = result.error ? ` — ${result.error.message}` : '';
2352
- const continuation = result.continuedAfterError ? '; continuing' : '';
2353
- return `${indent}${symbol} ${test_project_runner_formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
2354
- };
2355
- const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
2356
- ...attempt.beforeEach,
2357
- ...attempt.steps,
2358
- ...attempt.afterEach
2359
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError));
2360
- const documentHasFatalError = (result)=>[
2361
- ...result.beforeAll,
2362
- ...result.afterAll
2363
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError);
2364
920
  const selectProjects = (projects, names)=>{
2365
921
  if (!names || 0 === names.length) return projects;
2366
922
  const requested = new Set(names);
@@ -2370,74 +926,316 @@ A Node may map a string step value to one declared input field. Each Node sectio
2370
926
  if (unknown) throw new Error(`Unknown Midscene project: ${unknown}`);
2371
927
  return projects.filter((project)=>requested.has(project.name));
2372
928
  };
2373
- const prepareProject = (project, projectRoot, runDir)=>{
929
+ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, options = {}) {
2374
930
  const fileSelection = project.files ?? DEFAULT_TEST_FILE_SELECTION;
2375
- const files = discoverTestFiles(projectRoot, fileSelection);
2376
- const sources = files.map((absolutePath)=>({
931
+ const prerequisiteFile = options.prerequisiteFile ? (0, external_node_path_.resolve)(options.prerequisiteFile) : void 0;
932
+ const mainFiles = options.files ?? discoverResolvedTestFiles(projectRoot, fileSelection);
933
+ const files = [
934
+ ...prerequisiteFile ? [
935
+ prerequisiteFile
936
+ ] : [],
937
+ ...mainFiles.filter((file)=>file !== prerequisiteFile)
938
+ ];
939
+ const occurrences = new Map();
940
+ const sources = files.map((absolutePath)=>{
941
+ const invocationIndex = occurrences.get(absolutePath) ?? 0;
942
+ occurrences.set(absolutePath, invocationIndex + 1);
943
+ return {
2377
944
  projectId: project.projectId,
2378
945
  projectName: project.name,
2379
- sourcePath: test_project_runner_toPosix((0, external_node_path_.relative)(projectRoot, absolutePath)),
2380
- absolutePath
2381
- }));
946
+ sourcePath: project_preparation_toPosix((0, external_node_path_.relative)(projectRoot, absolutePath)),
947
+ absolutePath,
948
+ invocationIndex
949
+ };
950
+ });
2382
951
  const collectionErrors = [];
2383
- const documents = [];
952
+ const invocations = [];
2384
953
  let filteredCaseCount = 0;
2385
954
  if (0 === sources.length) {
2386
- const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
955
+ const error = asCollectionError(project.projectId, project.name, '<project>', new test_runner_namespaceObject.WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
2387
956
  projectName: project.name,
2388
957
  projectRoot
2389
958
  }));
2390
959
  collectionErrors.push(error);
2391
- writeCollectionError(runDir, error);
960
+ await writeCollectionError(runDir, error);
2392
961
  }
2393
962
  for (const source of sources)try {
2394
- const collected = collectWorkflowDocument(source, {
963
+ const collected = await options.collectDocument?.(source, project);
964
+ if (collected) {
965
+ filteredCaseCount += collected.filteredCaseCount;
966
+ if (collected.invocation) invocations.push(collected.invocation);
967
+ continue;
968
+ }
969
+ const document = collectWorkflowDocument(source, {
2395
970
  resolveNode: project.nodes.get.bind(project.nodes),
2396
971
  variables: project.variables,
2397
972
  env: process.env
2398
973
  });
2399
- const filtered = filterDocumentCases(collected, project);
974
+ const filtered = source.absolutePath === prerequisiteFile ? {
975
+ document,
976
+ filtered: 0
977
+ } : filterDocumentCases(document, project);
2400
978
  filteredCaseCount += filtered.filtered;
2401
- if (filtered.document) documents.push(filtered.document);
979
+ if (filtered.document) invocations.push({
980
+ document: filtered.document,
981
+ retry: {
982
+ scope: 'case',
983
+ count: project.retry
984
+ },
985
+ defaultTimeoutMs,
986
+ reportEnabled: true,
987
+ bindings: {
988
+ resolveNode: project.nodes.require.bind(project.nodes)
989
+ }
990
+ });
2402
991
  } catch (error) {
2403
992
  const collectionError = asCollectionError(project.projectId, project.name, source.sourcePath, error);
2404
993
  collectionErrors.push(collectionError);
2405
- writeCollectionError(runDir, collectionError);
994
+ await writeCollectionError(runDir, collectionError);
2406
995
  }
996
+ const documents = invocations.map((invocation)=>invocation.document);
997
+ const prerequisiteSource = sources.find((source)=>source.absolutePath === prerequisiteFile);
998
+ const prerequisiteDocument = documents.find((document)=>document.sourcePath === prerequisiteSource?.sourcePath);
2407
999
  return {
2408
1000
  project,
1001
+ platform: options.platform ?? 'test',
1002
+ documentConcurrency: options.documentConcurrency ?? 1,
1003
+ ...prerequisiteDocument ? {
1004
+ prerequisiteDocumentId: prerequisiteDocument.documentId
1005
+ } : {},
2409
1006
  fileSelection,
2410
1007
  sources,
2411
1008
  documents,
1009
+ invocations,
2412
1010
  collectionErrors,
2413
1011
  selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
2414
1012
  filteredCaseCount
2415
1013
  };
1014
+ }
1015
+ const padDatePart = (value)=>String(value).padStart(2, '0');
1016
+ const createTestRunId = (date = new Date(), uuid = (0, external_node_crypto_namespaceObject.randomUUID)())=>{
1017
+ const timestamp = [
1018
+ date.getFullYear(),
1019
+ padDatePart(date.getMonth() + 1),
1020
+ padDatePart(date.getDate()),
1021
+ padDatePart(date.getHours()),
1022
+ padDatePart(date.getMinutes()),
1023
+ padDatePart(date.getSeconds())
1024
+ ].join('');
1025
+ return `${timestamp}-${uuid.slice(0, 8)}`;
2416
1026
  };
2417
- const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>test_project_runner_asNotRun(document.documentId, item, prepared.project.name, reason)));
2418
- async function runTestProject(options = {}) {
2419
- const startedAt = new Date();
2420
- const runId = createTestRunId(startedAt);
1027
+ const assertDirectory = (path, label)=>{
1028
+ if (!(0, external_node_fs_.existsSync)(path) || !(0, external_node_fs_.statSync)(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
1029
+ };
1030
+ const resolveRunInput = (options)=>{
2421
1031
  const cwd = (0, external_node_path_.resolve)(options.cwd ?? process.cwd());
2422
1032
  assertDirectory(cwd, 'Test working directory');
2423
- const cliProjectRoot = options.projectRoot ? (0, external_node_path_.resolve)(cwd, options.projectRoot) : void 0;
1033
+ const inputPath = options.projectRoot ? (0, external_node_path_.resolve)(cwd, options.projectRoot) : void 0;
1034
+ const singleFile = inputPath && (0, external_node_fs_.existsSync)(inputPath) && (0, external_node_fs_.statSync)(inputPath).isFile() ? inputPath : void 0;
1035
+ if (singleFile && !/\.ya?ml$/i.test(singleFile)) throw new Error(`Test input must be a YAML file or project directory: ${singleFile}`);
1036
+ const cliProjectRoot = singleFile ? (0, external_node_path_.dirname)(singleFile) : inputPath;
2424
1037
  if (cliProjectRoot) assertDirectory(cliProjectRoot, 'Test project directory');
2425
- const configSearchRoot = cliProjectRoot ?? cwd;
2426
- const configPath = options.configPath ? (0, external_node_path_.resolve)(configSearchRoot, options.configPath) : discoverTestConfig(configSearchRoot);
2427
- if (options.configPath && (!configPath || !(0, external_node_fs_.existsSync)(configPath))) throw new Error(`Midscene config does not exist: ${configPath}`);
2428
- const definition = await loadTestProject(configPath);
2429
1038
  const projectRoot = cliProjectRoot ?? cwd;
2430
- const resultDir = options.resultDir ? (0, external_node_path_.resolve)(cwd, options.resultDir) : defaultResultDir(projectRoot);
1039
+ return {
1040
+ cwd,
1041
+ projectRoot,
1042
+ configSearchRoot: projectRoot,
1043
+ ...singleFile ? {
1044
+ singleFile
1045
+ } : {}
1046
+ };
1047
+ };
1048
+ async function prepareTestRun(options) {
1049
+ const startedAt = new Date();
1050
+ const runId = createTestRunId(startedAt);
1051
+ const input = resolveRunInput(options);
1052
+ const configPath = options.configPath ? (0, external_node_path_.resolve)(input.configSearchRoot, options.configPath) : discoverTestConfig(input.configSearchRoot);
1053
+ 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}\`.`);
1054
+ if (options.configPath && !(0, external_node_fs_.existsSync)(configPath)) throw new Error(`Midscene config does not exist: ${configPath}`);
1055
+ const definition = await loadTestProject(configPath);
1056
+ const resultDir = options.resultDir ? (0, external_node_path_.resolve)(input.cwd, options.resultDir) : (0, external_node_path_.join)(input.projectRoot, '.midscene', 'test-results');
2431
1057
  const runDir = (0, external_node_path_.join)(resultDir, runId);
2432
1058
  const summaryPath = (0, external_node_path_.join)(runDir, 'summary.json');
2433
- const reportDir = (0, external_node_path_.resolve)(projectRoot, definition.output.reportDir);
1059
+ const reportDir = (0, external_node_path_.resolve)(input.projectRoot, definition.output.reportDir);
2434
1060
  (0, external_node_fs_.mkdirSync)(resultDir, {
2435
1061
  recursive: true
2436
1062
  });
2437
1063
  (0, external_node_fs_.mkdirSync)(runDir);
2438
- const selectedProjects = selectProjects(definition.projects, options.projectNames);
2439
- const preparedProjects = selectedProjects.map((project)=>prepareProject(project, projectRoot, runDir));
2440
- const progress = options.onProgress ?? (()=>{});
1064
+ const projects = [];
1065
+ for (const project of selectProjects(definition.projects, options.projectNames))projects.push(await prepareProject(project, input.projectRoot, runDir, definition.test.testTimeout, input.singleFile ? {
1066
+ files: [
1067
+ input.singleFile
1068
+ ]
1069
+ } : void 0));
1070
+ const hasFormatMismatch = projects.some((project)=>project.collectionErrors.some(({ error })=>error instanceof NativeWorkflowFormatError));
1071
+ return {
1072
+ startedAt,
1073
+ runId,
1074
+ projectRoot: input.projectRoot,
1075
+ ...configPath ? {
1076
+ configPath
1077
+ } : {},
1078
+ resultDir,
1079
+ runDir,
1080
+ summaryPath,
1081
+ reportDir,
1082
+ definition,
1083
+ projects,
1084
+ preflightScope: hasFormatMismatch ? 'run' : 'project',
1085
+ reportEnabled: true,
1086
+ publications: [],
1087
+ onProgress: options.onProgress ?? (()=>{})
1088
+ };
1089
+ }
1090
+ const external_node_events_namespaceObject = require("node:events");
1091
+ const report_namespaceObject = require("@midscene/core/report");
1092
+ const logger_namespaceObject = require("@midscene/shared/logger");
1093
+ const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1094
+ const formatStep = (info)=>{
1095
+ const position = stepPosition(info);
1096
+ const phase = 'steps' === position.phase ? 'step' : position.phase;
1097
+ return `${phase} ${position.stepIndex + 1}/${info.stepCount}: ${info.node}`;
1098
+ };
1099
+ const formatStepResult = (info, result)=>{
1100
+ const indent = 'case' === info.scope ? ' ' : ' ';
1101
+ const symbol = 'success' === result.status ? '✓' : '✗';
1102
+ const error = result.error ? ` — ${result.error.message}` : '';
1103
+ const continuation = result.continuedAfterError ? '; continuing' : '';
1104
+ return `${indent}${symbol} ${formatStep(info)} (${result.durationMs} ms)${error}${continuation}`;
1105
+ };
1106
+ const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
1107
+ ...attempt.beforeEach,
1108
+ ...attempt.steps,
1109
+ ...attempt.afterEach
1110
+ ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (attempt.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError));
1111
+ const documentHasFatalError = (result)=>[
1112
+ ...result.beforeAll,
1113
+ ...result.afterAll
1114
+ ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (result.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError);
1115
+ async function executeDocumentInvocation(options) {
1116
+ const { invocation, project, sinks } = options;
1117
+ const { document } = invocation;
1118
+ const documentRetry = 'document' === invocation.retry.scope;
1119
+ const attempts = [];
1120
+ await (0, test_runner_namespaceObject.runDocumentAttempts)({
1121
+ retry: documentRetry ? invocation.retry.count : 0,
1122
+ signal: options.signal,
1123
+ shouldStop: ()=>options.shouldBail() || options.isProjectFatal()
1124
+ }, async (documentAttemptIndex)=>{
1125
+ if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1126
+ const execution = await (0, test_runner_namespaceObject.runWorkflowDocument)(document, {
1127
+ documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1128
+ ...invocation.bindings,
1129
+ project,
1130
+ projectContext: options.projectContext,
1131
+ retry: documentRetry ? 0 : invocation.retry.count,
1132
+ signal: options.signal,
1133
+ defaultTimeoutMs: invocation.defaultTimeoutMs,
1134
+ shouldStop: ()=>options.rootSignal.aborted || options.shouldBail() || options.isProjectFatal(),
1135
+ stopReason: ()=>options.rootSignal.aborted ? 'interrupted' : options.isProjectFatal() ? 'fatal-error' : 'bail',
1136
+ isFatalError: (run)=>[
1137
+ ...run.beforeEach,
1138
+ ...run.steps,
1139
+ ...run.afterEach
1140
+ ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (run.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError),
1141
+ onCaseStart: (collectedCase)=>{
1142
+ options.onProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
1143
+ },
1144
+ onStepStart: (info)=>{
1145
+ const indent = 'case' === info.scope ? ' ' : ' ';
1146
+ options.onProgress(`${indent}→ ${formatStep(info)}`);
1147
+ },
1148
+ onStepResult: async (info, result)=>{
1149
+ await invocation.bindings.onStepResult?.(info, result);
1150
+ options.onProgress(formatStepResult(info, result));
1151
+ },
1152
+ onCaseResult: async (attempt)=>{
1153
+ await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1154
+ options.onProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${documentRetry ? 1 : invocation.retry.count + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
1155
+ },
1156
+ onCaseOutcome: (outcome)=>{
1157
+ if (caseHasFatalError(outcome)) options.markProjectFatal();
1158
+ if (!documentRetry && 'failed' === outcome.status) options.addFailedCases(1);
1159
+ },
1160
+ onDocumentResult: options.onDocumentResult
1161
+ }).catch(async (error)=>{
1162
+ if (error instanceof test_runner_namespaceObject.WorkflowExecutionFailure) {
1163
+ const partial = error.result;
1164
+ attempts.push(partial);
1165
+ sinks.documents.push(partial.document);
1166
+ sinks.cases.push(...partial.cases.map((outcome)=>({
1167
+ ...outcome,
1168
+ documentId: document.documentId,
1169
+ documentRunId: partial.document.documentRunId
1170
+ })));
1171
+ try {
1172
+ await writeWorkflowDocumentResult(options.runDir, partial.document);
1173
+ } catch (publicationError) {
1174
+ throw new test_runner_namespaceObject.WorkflowExecutionFailure(partial, [
1175
+ ...error.errors,
1176
+ publicationError
1177
+ ]);
1178
+ }
1179
+ }
1180
+ throw error;
1181
+ });
1182
+ attempts.push(execution);
1183
+ sinks.cases.push(...execution.cases.map((outcome)=>({
1184
+ ...outcome,
1185
+ documentId: document.documentId,
1186
+ documentRunId: execution.document.documentRunId
1187
+ })));
1188
+ sinks.documents.push(execution.document);
1189
+ await writeWorkflowDocumentResult(options.runDir, execution.document);
1190
+ if (documentHasFatalError(execution.document)) options.markProjectFatal();
1191
+ const cleanupErrors = [
1192
+ ...execution.document.teardownErrors ?? [],
1193
+ ...execution.cases.flatMap((outcome)=>(outcome.attempts ?? []).flatMap((attempt)=>attempt.teardownErrors ?? []))
1194
+ ];
1195
+ if (cleanupErrors.length) throw new test_runner_namespaceObject.WorkflowExecutionFailure(execution, cleanupErrors);
1196
+ const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1197
+ return failed ? 'failed' : 'success';
1198
+ });
1199
+ const final = attempts.at(-1);
1200
+ if (!final) return;
1201
+ const failedCases = final.cases.filter((item)=>'failed' === item.status).length;
1202
+ if (documentRetry && failedCases) options.addFailedCases(failedCases);
1203
+ else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1204
+ }
1205
+ const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1206
+ documentId,
1207
+ caseId: collectedCase.caseId,
1208
+ projectName,
1209
+ name: collectedCase.definition.name,
1210
+ sourcePath: collectedCase.sourcePath,
1211
+ caseIndex: collectedCase.caseIndex,
1212
+ status: 'not-run',
1213
+ notRunReason: reason
1214
+ });
1215
+ const summarize = (projects)=>{
1216
+ const cases = projects.flatMap((project)=>latestById(project.cases, (item)=>item.caseId));
1217
+ const documents = projects.flatMap((project)=>latestById(project.documents, (item)=>item.documentId));
1218
+ return {
1219
+ total: cases.length,
1220
+ passed: cases.filter((item)=>'success' === item.status).length,
1221
+ failed: cases.filter((item)=>'failed' === item.status).length,
1222
+ notRun: cases.filter((item)=>'not-run' === item.status).length,
1223
+ filtered: projects.reduce((total, project)=>total + project.filteredCaseCount, 0),
1224
+ collectionErrors: projects.reduce((total, project)=>total + project.collectionErrors.length, 0),
1225
+ documentFailures: documents.filter((document)=>'failed' === document.status).length,
1226
+ projectFailures: projects.filter((project)=>'failed' === project.status).length
1227
+ };
1228
+ };
1229
+ const latestById = (items, id)=>[
1230
+ ...new Map(items.map((item)=>[
1231
+ id(item),
1232
+ item
1233
+ ])).values()
1234
+ ];
1235
+ const notRunSuite = (prepared, reason)=>prepared.documents.flatMap((document)=>document.cases.map((item)=>asNotRun(document.documentId, item, prepared.project.name, reason)));
1236
+ async function runPreparedTestProject(plan) {
1237
+ const { startedAt, runId, projectRoot, configPath, resultDir, runDir, summaryPath, reportDir, definition, projects: preparedProjects } = plan;
1238
+ const progress = plan.onProgress;
2441
1239
  const totalDocuments = preparedProjects.reduce((total, prepared)=>total + prepared.documents.length, 0);
2442
1240
  const totalCases = preparedProjects.reduce((total, prepared)=>total + prepared.selectedCaseCount, 0);
2443
1241
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
@@ -2455,11 +1253,13 @@ A Node may map a string step value to one declared input field. Each Node sectio
2455
1253
  let failedCaseCount = 0;
2456
1254
  const bailReached = ()=>definition.test.bail > 0 && failedCaseCount >= definition.test.bail;
2457
1255
  let hasInfrastructureError = false;
2458
- let firstInfrastructureError;
1256
+ const infrastructureErrors = [];
2459
1257
  const recordInfrastructureError = (error)=>{
2460
- if (hasInfrastructureError) return;
1258
+ const errors = error instanceof test_runner_namespaceObject.WorkflowExecutionFailure ? error.errors : [
1259
+ error
1260
+ ];
1261
+ for (const item of errors)if (!infrastructureErrors.includes(item)) infrastructureErrors.push(item);
2461
1262
  hasInfrastructureError = true;
2462
- firstInfrastructureError = error;
2463
1263
  rootController.abort(error);
2464
1264
  };
2465
1265
  const runInfrastructureCallback = (callback)=>{
@@ -2476,10 +1276,11 @@ A Node may map a string step value to one declared input field. Each Node sectio
2476
1276
  };
2477
1277
  const buildProjectResult = (prepared, cases, documents, lifecycle)=>{
2478
1278
  const { project } = prepared;
2479
- const projectFailed = prepared.collectionErrors.length > 0 || cases.some((item)=>'success' !== item.status) || documents.some((item)=>'failed' === item.status) || lifecycle?.status === 'failed';
1279
+ 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';
2480
1280
  return {
2481
1281
  projectId: project.projectId,
2482
1282
  name: project.name,
1283
+ platform: prepared.platform,
2483
1284
  status: projectFailed ? 'failed' : 'success',
2484
1285
  retry: project.retry,
2485
1286
  fileSelection: prepared.fileSelection,
@@ -2507,106 +1308,111 @@ A Node may map a string step value to one declared input field. Each Node sectio
2507
1308
  const documents = [];
2508
1309
  let lifecycle;
2509
1310
  let projectFatal = false;
2510
- if (prepared.collectionErrors.length > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
1311
+ if (prepared.collectionErrors.length > 0 || 'run' === plan.preflightScope && totalErrors > 0) cases.push(...notRunSuite(prepared, 'project-preflight-failed'));
2511
1312
  else if (rootController.signal.aborted) cases.push(...notRunSuite(prepared, 'interrupted'));
2512
1313
  else if (bailReached()) cases.push(...notRunSuite(prepared, 'bail'));
2513
1314
  else {
2514
- const runtime = createProjectRuntime({
1315
+ const runtime = (0, test_runner_namespaceObject.createProjectRuntime)({
2515
1316
  project,
2516
1317
  setup: project.setup,
2517
1318
  signal: rootController.signal
2518
1319
  });
2519
1320
  let hasProjectExecutionError = false;
2520
- let projectExecutionError;
2521
1321
  try {
2522
1322
  await runtime.start();
2523
- if (runtime.canRun) for (const [documentIndex, document] of prepared.documents.entries()){
2524
- if (rootController.signal.aborted || bailReached() || projectFatal) {
2525
- const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
2526
- cases.push(...document.cases.map((item)=>test_project_runner_asNotRun(document.documentId, item, project.name, reason)));
2527
- continue;
1323
+ if (runtime.canRun) {
1324
+ const runDocument = async (invocation, documentIndex)=>{
1325
+ const { document } = invocation;
1326
+ if (rootController.signal.aborted || bailReached() || projectFatal) {
1327
+ const reason = rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1328
+ cases.push(...document.cases.map((item)=>asNotRun(document.documentId, item, project.name, reason)));
1329
+ return;
1330
+ }
1331
+ projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
1332
+ await executeDocumentInvocation({
1333
+ invocation,
1334
+ project,
1335
+ projectContext: runtime.context,
1336
+ runDir,
1337
+ signal: runtime.signal,
1338
+ rootSignal: rootController.signal,
1339
+ shouldBail: bailReached,
1340
+ isProjectFatal: ()=>projectFatal,
1341
+ markProjectFatal: ()=>{
1342
+ projectFatal = true;
1343
+ },
1344
+ addFailedCases: (count)=>{
1345
+ failedCaseCount += count;
1346
+ },
1347
+ onProgress: projectProgress,
1348
+ sinks: {
1349
+ cases,
1350
+ documents
1351
+ }
1352
+ });
1353
+ };
1354
+ let prerequisiteFailed = false;
1355
+ const prerequisite = prepared.invocations.find((invocation)=>invocation.document.documentId === prepared.prerequisiteDocumentId);
1356
+ const prerequisiteCount = prerequisite ? 1 : 0;
1357
+ if (prerequisite) {
1358
+ await runDocument(prerequisite, 0);
1359
+ prerequisiteFailed = documents.at(-1)?.status === 'failed' || latestById(cases, (item)=>item.caseId).some((item)=>'success' !== item.status);
2528
1360
  }
2529
- projectProgress(` [document ${documentIndex + 1}/${prepared.documents.length}] ${document.sourcePath}`);
2530
- const execution = await runWorkflowDocument(document, {
2531
- resolveNode: project.nodes.require.bind(project.nodes),
2532
- project,
2533
- projectContext: runtime.context,
2534
- retry: project.retry,
2535
- signal: runtime.signal,
2536
- defaultTimeoutMs: definition.test.testTimeout,
2537
- shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal,
2538
- stopReason: ()=>rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail',
2539
- isFatalError: (run)=>[
2540
- ...run.beforeEach,
2541
- ...run.steps,
2542
- ...run.afterEach
2543
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (run.teardownErrors ?? []).some(isFatalDeviceError),
2544
- onCaseStart: (collectedCase)=>{
2545
- projectProgress(` [case ${collectedCase.caseIndex + 1}/${document.cases.length}] ${collectedCase.definition.name}`);
2546
- },
2547
- onStepStart: (info)=>{
2548
- const indent = 'case' === info.scope ? ' ' : ' ';
2549
- projectProgress(`${indent}→ ${test_project_runner_formatStep(info)}`);
2550
- },
2551
- onStepResult: (info, result)=>projectProgress(formatStepResult(info, result)),
2552
- onCaseResult: (attempt)=>{
2553
- runInfrastructureCallback(()=>writeCaseAttemptResult(runDir, project.projectId, document.documentId, attempt));
2554
- projectProgress(` ${'success' === attempt.status ? '✓' : '✗'} attempt ${attempt.attemptIndex + 1}/${project.retry + 1}: ${attempt.name} (${attempt.durationMs} ms)`);
2555
- },
2556
- onCaseOutcome: (outcome)=>{
2557
- if ('failed' === outcome.status) failedCaseCount += 1;
2558
- if (caseHasFatalError(outcome)) projectFatal = true;
2559
- },
2560
- onDocumentResult: (documentResult)=>runInfrastructureCallback(()=>writeWorkflowDocumentResult(runDir, documentResult))
1361
+ if (!prerequisiteFailed) await (0, test_runner_namespaceObject.runConcurrentJobs)(prepared.invocations.filter((invocation)=>invocation !== prerequisite), {
1362
+ concurrency: prepared.documentConcurrency,
1363
+ shouldStop: ()=>rootController.signal.aborted || bailReached() || projectFatal
1364
+ }, async (invocation, index)=>{
1365
+ try {
1366
+ await runDocument(invocation, index + prerequisiteCount);
1367
+ } catch (error) {
1368
+ recordInfrastructureError(error);
1369
+ throw error;
1370
+ }
2561
1371
  });
2562
- cases.push(...execution.cases.map((outcome)=>({
2563
- ...outcome,
2564
- documentId: document.documentId
2565
- })));
2566
- if (documentHasFatalError(execution.document)) projectFatal = true;
2567
- documents.push(execution.document);
2568
- }
2569
- else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
1372
+ const completed = new Set(cases.map((item)=>item.caseId));
1373
+ const reason = prerequisiteFailed ? 'project-setup-failed' : rootController.signal.aborted ? 'interrupted' : projectFatal ? 'fatal-error' : 'bail';
1374
+ cases.push(...notRunSuite(prepared, reason).filter((item)=>!completed.has(item.caseId)));
1375
+ } else cases.push(...notRunSuite(prepared, rootController.signal.aborted ? 'interrupted' : 'project-setup-failed'));
2570
1376
  } catch (error) {
2571
1377
  hasProjectExecutionError = true;
2572
- projectExecutionError = error;
2573
1378
  recordInfrastructureError(error);
2574
1379
  } finally{
2575
- const hasFailure = hasProjectExecutionError || cases.some((item)=>'success' !== item.status) || documents.some((item)=>'failed' === item.status) || projectFatal || rootController.signal.aborted;
1380
+ 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;
2576
1381
  lifecycle = await runtime.finish(hasFailure ? 'failed' : 'success');
2577
1382
  }
2578
- if (hasProjectExecutionError) throw projectExecutionError;
1383
+ if (hasProjectExecutionError) {
1384
+ const completed = new Set(cases.map((item)=>item.caseId));
1385
+ cases.push(...notRunSuite(prepared, 'interrupted').filter((item)=>!completed.has(item.caseId)));
1386
+ }
2579
1387
  }
1388
+ const order = new Map(prepared.documents.map((document, index)=>[
1389
+ document.documentId,
1390
+ index
1391
+ ]));
1392
+ const documentOrder = (id)=>order.get(id) ?? Number.MAX_SAFE_INTEGER;
1393
+ cases.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || a.caseIndex - b.caseIndex);
1394
+ documents.sort((a, b)=>documentOrder(a.documentId) - documentOrder(b.documentId) || (a.attemptIndex ?? 0) - (b.attemptIndex ?? 0));
2580
1395
  return buildProjectResult(prepared, cases, documents, lifecycle);
2581
1396
  };
2582
1397
  const projectResults = new Array(preparedProjects.length);
2583
- let nextProjectIndex = 0;
2584
- const claimNextProject = ()=>{
2585
- if (hasInfrastructureError || rootController.signal.aborted || bailReached() || nextProjectIndex >= preparedProjects.length) return;
2586
- const projectIndex = nextProjectIndex;
2587
- nextProjectIndex += 1;
2588
- return projectIndex;
2589
- };
2590
- const worker = async ()=>{
2591
- try {
2592
- while(true){
2593
- const projectIndex = claimNextProject();
2594
- if (void 0 === projectIndex) return;
2595
- projectResults[projectIndex] = await runPreparedProject(preparedProjects[projectIndex], projectIndex);
2596
- }
2597
- } catch (error) {
2598
- recordInfrastructureError(error);
2599
- }
2600
- };
2601
1398
  try {
2602
- await Promise.allSettled(Array.from({
2603
- length: effectiveConcurrency
2604
- }, ()=>worker()));
1399
+ await (0, test_runner_namespaceObject.runConcurrentJobs)(preparedProjects, {
1400
+ concurrency: effectiveConcurrency,
1401
+ shouldStop: ()=>hasInfrastructureError || rootController.signal.aborted || bailReached()
1402
+ }, async (prepared, index)=>{
1403
+ try {
1404
+ projectResults[index] = await runPreparedProject(prepared, index);
1405
+ } catch (error) {
1406
+ recordInfrastructureError(error);
1407
+ throw error;
1408
+ }
1409
+ });
1410
+ } catch (error) {
1411
+ recordInfrastructureError(error);
2605
1412
  } finally{
2606
1413
  process.off('SIGINT', sigint);
2607
1414
  process.off('SIGTERM', sigterm);
2608
1415
  }
2609
- if (hasInfrastructureError) throw firstInfrastructureError;
2610
1416
  const completedProjectResults = preparedProjects.map((prepared, projectIndex)=>{
2611
1417
  const result = projectResults[projectIndex];
2612
1418
  if (result) return result;
@@ -2615,7 +1421,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
2615
1421
  return buildSkippedProjectResult(prepared, projectIndex, reason);
2616
1422
  });
2617
1423
  const summary = summarize(completedProjectResults);
2618
- const failed = rootController.signal.aborted || summary.failed > 0 || summary.notRun > 0 || summary.collectionErrors > 0 || summary.documentFailures > 0 || summary.projectFailures > 0;
1424
+ const failed = hasInfrastructureError || rootController.signal.aborted || summary.failed > 0 || summary.notRun > 0 || summary.collectionErrors > 0 || summary.documentFailures > 0 || summary.projectFailures > 0;
2619
1425
  const endedAt = new Date();
2620
1426
  const result = {
2621
1427
  schemaVersion: 3,
@@ -2632,34 +1438,66 @@ A Node may map a string step value to one declared input field. Each Node sectio
2632
1438
  projects: completedProjectResults,
2633
1439
  cases: completedProjectResults.flatMap((project)=>project.cases),
2634
1440
  documents: completedProjectResults.flatMap((project)=>project.documents),
2635
- collectionErrors: completedProjectResults.flatMap((project)=>project.collectionErrors)
1441
+ collectionErrors: completedProjectResults.flatMap((project)=>project.collectionErrors),
1442
+ ...hasInfrastructureError ? {
1443
+ errors: infrastructureErrors.map(test_runner_namespaceObject.asExecutionError)
1444
+ } : {}
2636
1445
  };
2637
- writeTestProjectRunResult({
2638
- projectRoot,
2639
- ...configPath ? {
2640
- configPath
2641
- } : {},
2642
- result
2643
- });
2644
- const reportPath = new report_namespaceObject.TestRunReportAssembler().assemble({
2645
- outputDir: reportDir,
2646
- reportFileName: `test-run-${runId}`,
2647
- sources: collectTestRunReportSources(result),
2648
- buildRunnerDump: (index)=>buildTestRunReportDump(result, index)
2649
- });
2650
- const completedResult = {
2651
- ...result,
2652
- reportPath
1446
+ let completedResult = result;
1447
+ const publish = async (operation, path, callback)=>{
1448
+ try {
1449
+ await callback();
1450
+ return true;
1451
+ } catch (error) {
1452
+ recordInfrastructureError(error instanceof test_runner_namespaceObject.WorkflowPublicationError || error instanceof test_runner_namespaceObject.WorkflowExecutionFailure ? error : new test_runner_namespaceObject.WorkflowPublicationError(operation, path, error));
1453
+ completedResult = {
1454
+ ...completedResult,
1455
+ status: 'failed',
1456
+ exitCode: 1,
1457
+ errors: infrastructureErrors.map(test_runner_namespaceObject.asExecutionError)
1458
+ };
1459
+ return false;
1460
+ }
2653
1461
  };
2654
- writeTestProjectRunResult({
2655
- projectRoot,
2656
- ...configPath ? {
2657
- configPath
2658
- } : {},
2659
- result: completedResult
1462
+ const writeSummary = ()=>publish('write-result', summaryPath, ()=>writeTestProjectRunResult({
1463
+ projectRoot,
1464
+ ...configPath ? {
1465
+ configPath
1466
+ } : {},
1467
+ result: completedResult
1468
+ }));
1469
+ const writeReport = ()=>publish('write-report', reportDir, async ()=>{
1470
+ if (!plan.reportEnabled) return;
1471
+ const reportPath = await new report_namespaceObject.TestRunReportAssembler().assembleAsync({
1472
+ outputDir: reportDir,
1473
+ reportFileName: `midscene-e2e-${runId}`,
1474
+ overwrite: false,
1475
+ sources: (0, test_runner_namespaceObject.collectTestRunReportSources)(completedResult),
1476
+ buildRunnerDump: (index)=>{
1477
+ const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(completedResult, index);
1478
+ const warn = (0, logger_namespaceObject.getDebug)('test-runner:report-assembler', {
1479
+ console: true
1480
+ });
1481
+ for (const diagnostic of dump.diagnostics ?? [])warn(diagnostic.message);
1482
+ return dump;
1483
+ }
1484
+ });
1485
+ completedResult = {
1486
+ ...completedResult,
1487
+ reportPath
1488
+ };
1489
+ });
1490
+ await writeReport();
1491
+ for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
1492
+ await publication.publish(completedResult);
2660
1493
  });
1494
+ await writeSummary();
1495
+ if (hasInfrastructureError) throw new test_runner_namespaceObject.WorkflowExecutionFailure(completedResult, infrastructureErrors);
2661
1496
  return completedResult;
2662
1497
  }
1498
+ async function runTestProject(options = {}) {
1499
+ return runPreparedTestProject(await prepareTestRun(options));
1500
+ }
2663
1501
  const parseTestCliArgs = (args, cwd = process.cwd())=>{
2664
1502
  const command = 'nodes' === args[0] ? args[0] : void 0;
2665
1503
  const commandOffset = command ? 1 : 0;
@@ -2668,20 +1506,23 @@ A Node may map a string step value to one declared input field. Each Node sectio
2668
1506
  let resultDir;
2669
1507
  const projectNames = [];
2670
1508
  for(let index = commandOffset; index < args.length; index += 1){
2671
- const arg = args[index];
1509
+ const token = args[index];
1510
+ const equal = token.startsWith('-') ? token.indexOf('=') : -1;
1511
+ const arg = equal < 0 ? token : token.slice(0, equal);
1512
+ const inlineValue = equal < 0 ? void 0 : token.slice(equal + 1);
2672
1513
  if (!arg.startsWith('-')) {
2673
1514
  if (projectRoot) throw new Error('Only one test project directory is allowed.');
2674
1515
  projectRoot = (0, external_node_path_.resolve)(cwd, arg);
2675
1516
  continue;
2676
1517
  }
2677
1518
  if ('--config' === arg || '--result-dir' === arg || '--project' === arg) {
2678
- const value = args[index + 1];
2679
- if (!value) throw new Error(`${arg} requires a value.`);
1519
+ const value = inlineValue ?? args[index + 1];
1520
+ if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
2680
1521
  if ('--config' === arg) configPath = value;
2681
1522
  else if ('--result-dir' === arg) resultDir = (0, external_node_path_.resolve)(cwd, value);
2682
1523
  else projectNames.push(value);
2683
- index += 1;
2684
- } else throw new Error(`Unknown option: ${arg}`);
1524
+ if (void 0 === inlineValue) index += 1;
1525
+ } else throw new Error(`Unknown option: ${token}`);
2685
1526
  }
2686
1527
  if ('nodes' === command && resultDir) throw new Error('--result-dir is not supported by nodes.');
2687
1528
  if ('nodes' === command && projectNames.length > 1) throw new Error('nodes accepts only one --project name.');
@@ -2703,6 +1544,23 @@ A Node may map a string step value to one declared input field. Each Node sectio
2703
1544
  error: console.error,
2704
1545
  write: (message)=>process.stdout.write(message)
2705
1546
  };
1547
+ const testCliHelp = `Midscene Test: run native cases/steps Test projects.
1548
+
1549
+ Usage:
1550
+ midscene-test [file.yaml | directory] [options]
1551
+ midscene-test --config <midscene.config.ts> [options]
1552
+ midscene-test nodes [directory] [--config midscene.config.ts]
1553
+ midscene-test create --help
1554
+
1555
+ Options:
1556
+ --config <path> Native TypeScript or JavaScript Test config
1557
+ --project <name> Select a native execution Project (repeatable)
1558
+ --result-dir <path> Store Test result files in this directory
1559
+ --help, -h Show this help
1560
+ --version Show the package version
1561
+
1562
+ Legacy tasks/flow YAML and its CLI options belong to the midscene command.
1563
+ `;
2706
1564
  const test_command_assertDirectory = (path, label)=>{
2707
1565
  if (!(0, external_node_fs_.existsSync)(path) || !(0, external_node_fs_.statSync)(path).isDirectory()) throw new Error(`${label} does not exist or is not a directory: ${path}`);
2708
1566
  };
@@ -2713,6 +1571,7 @@ A Node may map a string step value to one declared input field. Each Node sectio
2713
1571
  if (projectRoot) test_command_assertDirectory(projectRoot, 'Test project directory');
2714
1572
  const configSearchRoot = projectRoot ?? cwd;
2715
1573
  const configPath = options.configPath ? (0, external_node_path_.resolve)(configSearchRoot, options.configPath) : discoverTestConfig(configSearchRoot);
1574
+ if (options.configPath && /\.ya?ml$/i.test(configPath)) throw new Error(`midscene-test nodes --config only accepts a native TypeScript or JavaScript project config: ${configPath}.`);
2716
1575
  if (options.configPath && (!configPath || !(0, external_node_fs_.existsSync)(configPath))) throw new Error(`Midscene config does not exist: ${configPath}`);
2717
1576
  const project = await loadTestProject(configPath);
2718
1577
  const projectName = options.projectNames?.[0];
@@ -2740,10 +1599,18 @@ A Node may map a string step value to one declared input field. Each Node sectio
2740
1599
  async function runTestCli(args, io = defaultCliIO) {
2741
1600
  try {
2742
1601
  if ('create' === args[0]) {
2743
- const { runCreateCommand } = await __webpack_require__.e("458").then(__webpack_require__.bind(__webpack_require__, "./src/cli/create-command.ts"));
1602
+ const { runCreateCommand } = await __webpack_require__.e("160").then(__webpack_require__.bind(__webpack_require__, "./src/cli/create-command.ts"));
2744
1603
  await runCreateCommand(args.slice(1), io);
2745
1604
  return 0;
2746
1605
  }
1606
+ if (args.includes('--help') || args.includes('-h')) {
1607
+ io.log(testCliHelp);
1608
+ return 0;
1609
+ }
1610
+ if (args.includes('--version')) {
1611
+ io.log(package_0.rE);
1612
+ return 0;
1613
+ }
2747
1614
  const options = parseTestCliArgs(args);
2748
1615
  if ('nodes' === options.command) {
2749
1616
  await runNodesCommand(options, io);
@@ -2753,6 +1620,25 @@ A Node may map a string step value to one declared input field. Each Node sectio
2753
1620
  ...options,
2754
1621
  onProgress: (message)=>io.log(message)
2755
1622
  });
1623
+ for (const failure of result.collectionErrors)io.error(`midscene-test: ${failure.projectName}/${failure.sourcePath}: ${failure.error.message}`);
1624
+ const finalCases = new Map(result.cases.map((outcome)=>[
1625
+ outcome.caseId,
1626
+ outcome
1627
+ ]));
1628
+ for (const outcome of finalCases.values()){
1629
+ if ('failed' !== outcome.status || !outcome.run) continue;
1630
+ const run = outcome.run;
1631
+ const source = `${run.projectName}/${run.sourcePath} / ${run.name}`;
1632
+ for (const step of [
1633
+ ...run.beforeEach,
1634
+ ...run.steps,
1635
+ ...run.afterEach
1636
+ ])if (step.error) io.error(`midscene-test: ${source} / ${step.phase}[${step.stepIndex + 1}] ${step.node}: ${step.error.message}`);
1637
+ for (const error of [
1638
+ ...run.executionErrors ?? [],
1639
+ ...run.teardownErrors ?? []
1640
+ ])io.error(`midscene-test: ${source}: ${error.message}`);
1641
+ }
2756
1642
  io.log(`midscene-test: ${result.summary.passed}/${result.summary.total} cases passed, ${result.summary.failed} failed, ${result.summary.notRun} not run`);
2757
1643
  io.log(`Results: ${result.resultDir}`);
2758
1644
  io.log(`Summary: ${result.summaryPath}`);