@midscene/test 1.15.2-beta-20261012123219.0 → 1.15.2

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.
@@ -318,7 +318,6 @@ async function loadTestProject(configPath) {
318
318
  }
319
319
  const external_node_fs_namespaceObject = require("node:fs");
320
320
  const external_tinyglobby_namespaceObject = require("tinyglobby");
321
- const external_js_yaml_namespaceObject = require("js-yaml");
322
321
  const external_node_crypto_namespaceObject = require("node:crypto");
323
322
  const createCaseInvocationId = (projectId, sourcePath, caseIndex, invocationIndex = 0)=>(0, external_node_crypto_namespaceObject.createHash)('sha256').update(JSON.stringify([
324
323
  projectId,
@@ -336,6 +335,70 @@ const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=
336
335
  ] : []
337
336
  ])).digest('hex');
338
337
  const normalizeSteps = test_runner_namespaceObject.normalizeSteps;
338
+ const external_js_yaml_namespaceObject = require("js-yaml");
339
+ function parseWorkflowSource(source) {
340
+ const stack = [];
341
+ const nodes = [];
342
+ const sensitiveNodes = new Set();
343
+ const sensitiveValues = new Set();
344
+ const redactedPaths = new Set();
345
+ const sourceOffset = 0xfeff === source.charCodeAt(0) ? 1 : 0;
346
+ const parsed = (0, external_js_yaml_namespaceObject.load)(source, {
347
+ schema: external_js_yaml_namespaceObject.JSON_SCHEMA,
348
+ listener (event, state) {
349
+ if ('open' === event) return void stack.push({
350
+ start: state.position + sourceOffset,
351
+ end: 0,
352
+ value: null,
353
+ children: []
354
+ });
355
+ const node = stack.pop();
356
+ node.end = state.position + sourceOffset;
357
+ node.value = state.result;
358
+ stack.at(-1)?.children.push(node);
359
+ nodes.push(node);
360
+ if ('mapping' !== state.kind) return;
361
+ for(let index = 0; index < node.children.length; index += 2){
362
+ const key = node.children[index].value;
363
+ const value = node.children[index + 1];
364
+ if ('string' == typeof key && (0, test_runner_namespaceObject.isReportSecretKey)(key) && value) {
365
+ sensitiveNodes.add(value);
366
+ if (null !== value.value) sensitiveValues.add(value.value);
367
+ redactedPaths.add(`$..[${JSON.stringify(key)}]`);
368
+ }
369
+ }
370
+ }
371
+ });
372
+ const ranges = nodes.filter((node)=>sensitiveNodes.has(node) || sensitiveValues.has(node.value)).sort((left, right)=>left.start - right.start || right.end - left.end);
373
+ let offset = 0;
374
+ let redacted = '';
375
+ for (const range of ranges){
376
+ if (range.start < offset) continue;
377
+ const text = source.slice(range.start, range.end);
378
+ const leading = text.match(/^\s*/)?.[0] ?? '';
379
+ const trailing = text.slice(leading.length).match(/\s*$/)?.[0] ?? '';
380
+ redacted += `${source.slice(offset, range.start)}${leading}"[REDACTED]"${trailing}`;
381
+ offset = range.end;
382
+ }
383
+ redacted += source.slice(offset);
384
+ const value = (0, test_runner_namespaceObject.sanitizeReportText)(redacted, 65536);
385
+ return {
386
+ parsed,
387
+ sourceYaml: {
388
+ value,
389
+ ...redactedPaths.size ? {
390
+ redactedPaths: [
391
+ ...redactedPaths
392
+ ]
393
+ } : {},
394
+ ...value !== redacted ? {
395
+ truncatedPaths: [
396
+ '$'
397
+ ]
398
+ } : {}
399
+ }
400
+ };
401
+ }
339
402
  const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
340
403
  const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
341
404
  const exactProjectExpression = /^\$\{([^{}]+)\}$/;
@@ -410,16 +473,15 @@ const collect_rejectUnknownKeys = (value, allowed, label)=>{
410
473
  const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
411
474
  const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
412
475
  function collectWorkflowDocument(source, options) {
413
- let parsed;
476
+ let snapshot;
414
477
  try {
415
- parsed = (0, external_js_yaml_namespaceObject.load)((0, external_node_fs_namespaceObject.readFileSync)(source.absolutePath, 'utf8'), {
416
- schema: external_js_yaml_namespaceObject.JSON_SCHEMA
417
- });
478
+ snapshot = parseWorkflowSource((0, external_node_fs_namespaceObject.readFileSync)(source.absolutePath, 'utf8'));
418
479
  } catch (error) {
419
480
  throw new test_runner_namespaceObject.WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
420
481
  sourcePath: source.sourcePath
421
482
  }, error);
422
483
  }
484
+ const { parsed, sourceYaml } = snapshot;
423
485
  if (!isMapping(parsed)) throw new test_runner_namespaceObject.WorkflowParseError('Workflow document must be a mapping.');
424
486
  const format = (0, test_runner_namespaceObject.classifyWorkflowDocumentFormat)(parsed);
425
487
  if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
@@ -434,6 +496,7 @@ function collectWorkflowDocument(source, options) {
434
496
  const sourcePath = source.sourcePath.split(external_node_path_namespaceObject.sep).join('/');
435
497
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
436
498
  ...step,
499
+ templateInput: step.input,
437
500
  input: resolveWorkflowVariables(step.input, {
438
501
  variables: options.variables,
439
502
  env: options.env ?? process.env,
@@ -519,6 +582,7 @@ function collectWorkflowDocument(source, options) {
519
582
  documentId: createWorkflowDocumentId(source.projectId, sourcePath),
520
583
  projectId: source.projectId,
521
584
  sourcePath,
585
+ sourceYaml,
522
586
  lifecycle,
523
587
  cases
524
588
  };
@@ -534,6 +598,15 @@ const writeJson = async (path, value)=>{
534
598
  throw new test_runner_namespaceObject.WorkflowPublicationError('write-result', path, error);
535
599
  }
536
600
  };
601
+ const writeTestRunPlan = (runDir, projects)=>writeJson((0, external_node_path_namespaceObject.join)(runDir, 'plan.json'), {
602
+ schemaVersion: 1,
603
+ createdAt: new Date().toISOString(),
604
+ projects: projects.map((item)=>({
605
+ projectId: item.project.projectId,
606
+ name: item.project.name,
607
+ documents: item.plans ?? []
608
+ }))
609
+ });
537
610
  const toPosix = (value)=>value.split(external_node_path_namespaceObject.sep).join('/');
538
611
  const caseAttemptResultPath = (projectId, documentId, result)=>toPosix((0, external_node_path_namespaceObject.join)(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
539
612
  const workflowDocumentResultPath = (result)=>toPosix((0, external_node_path_namespaceObject.join)(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
@@ -563,7 +636,7 @@ const relativeToSummary = (summaryPath, target)=>{
563
636
  const value = (0, external_node_path_namespaceObject.relative)((0, external_node_path_namespaceObject.dirname)(summaryPath), target);
564
637
  return toPosix(value || '.');
565
638
  };
566
- const errorJson = (error)=>error instanceof Error && 'toJSON' in error ? error.toJSON() : error;
639
+ const errorJson = test_runner_namespaceObject.sanitizeReportError;
567
640
  const writeTestProjectRunResult = (options)=>{
568
641
  const { result } = options;
569
642
  const fact = (path)=>toPosix(path);
@@ -583,13 +656,17 @@ const writeTestProjectRunResult = (options)=>{
583
656
  configPath: options.configPath
584
657
  } : {},
585
658
  factsRoot: '.',
659
+ plan: 'plan.json',
660
+ ...result.operationLog ? {
661
+ operationLog: {
662
+ ...result.operationLog,
663
+ path: relativeToSummary(result.summaryPath, result.operationLog.path)
664
+ }
665
+ } : {},
586
666
  reportDir: relativeToSummary(result.summaryPath, result.reportDir),
587
667
  ...result.reportPath ? {
588
668
  report: relativeToSummary(result.summaryPath, result.reportPath)
589
669
  } : {},
590
- ...result.markdownPath ? {
591
- markdown: relativeToSummary(result.summaryPath, result.markdownPath)
592
- } : {},
593
670
  summary: result.summary,
594
671
  projects: result.projects.map((project)=>({
595
672
  projectId: project.projectId,
@@ -607,6 +684,12 @@ const writeTestProjectRunResult = (options)=>{
607
684
  startedAt: project.lifecycle.startedAt,
608
685
  endedAt: project.lifecycle.endedAt,
609
686
  durationMs: project.lifecycle.durationMs,
687
+ ...project.lifecycle.cleanup ? {
688
+ cleanup: project.lifecycle.cleanup
689
+ } : {},
690
+ ...project.lifecycle.pendingOperations ? {
691
+ pendingOperations: project.lifecycle.pendingOperations
692
+ } : {},
610
693
  ...project.lifecycle.setupError ? {
611
694
  setupError: errorJson(project.lifecycle.setupError)
612
695
  } : {},
@@ -633,6 +716,18 @@ const writeTestProjectRunResult = (options)=>{
633
716
  attemptId: attempt.runId,
634
717
  attemptIndex: attempt.attemptIndex,
635
718
  status: attempt.status,
719
+ ...attempt.cleanup ? {
720
+ cleanup: attempt.cleanup
721
+ } : {},
722
+ ...attempt.pendingOperations ? {
723
+ pendingOperations: attempt.pendingOperations
724
+ } : {},
725
+ ...attempt.executionStatus ? {
726
+ executionStatus: attempt.executionStatus
727
+ } : {},
728
+ ...attempt.retryDecision ? {
729
+ retryDecision: attempt.retryDecision
730
+ } : {},
636
731
  resultFile: fact(caseAttemptResultPath(project.projectId, outcome.documentId, attempt)),
637
732
  ...attempt.reportPaths?.length ? {
638
733
  reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
@@ -648,6 +743,18 @@ const writeTestProjectRunResult = (options)=>{
648
743
  },
649
744
  sourcePath: document.sourcePath,
650
745
  status: document.status,
746
+ ...document.cleanup ? {
747
+ cleanup: document.cleanup
748
+ } : {},
749
+ ...document.pendingOperations ? {
750
+ pendingOperations: document.pendingOperations
751
+ } : {},
752
+ ...document.executionStatus ? {
753
+ executionStatus: document.executionStatus
754
+ } : {},
755
+ ...document.retryDecision ? {
756
+ retryDecision: document.retryDecision
757
+ } : {},
651
758
  resultFile: fact(workflowDocumentResultPath(document)),
652
759
  ...document.reportPaths?.length ? {
653
760
  reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
@@ -787,6 +894,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
787
894
  });
788
895
  const collectionErrors = [];
789
896
  const invocations = [];
897
+ const plans = [];
790
898
  let filteredCaseCount = 0;
791
899
  if (0 === sources.length) {
792
900
  const error = asCollectionError(project.projectId, project.name, '<project>', new test_runner_namespaceObject.WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
@@ -800,7 +908,17 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
800
908
  const collected = await options.collectDocument?.(source, project);
801
909
  if (collected) {
802
910
  filteredCaseCount += collected.filteredCaseCount;
803
- if (collected.invocation) invocations.push(collected.invocation);
911
+ if (collected.invocation) {
912
+ const plan = (0, test_runner_namespaceObject.buildTestRunReportPlan)(collected.invocation.document, {
913
+ defaultTimeoutMs: collected.invocation.defaultTimeoutMs,
914
+ retry: collected.invocation.retry
915
+ });
916
+ invocations.push({
917
+ ...collected.invocation,
918
+ plan
919
+ });
920
+ plans.push(plan);
921
+ }
804
922
  continue;
805
923
  }
806
924
  const document = collectWorkflowDocument(source, {
@@ -812,8 +930,18 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
812
930
  document,
813
931
  filtered: 0
814
932
  } : filterDocumentCases(document, project);
933
+ const plan = (0, test_runner_namespaceObject.buildTestRunReportPlan)(document, {
934
+ selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
935
+ defaultTimeoutMs,
936
+ retry: {
937
+ scope: 'case',
938
+ count: project.retry
939
+ }
940
+ });
941
+ plans.push(plan);
815
942
  filteredCaseCount += filtered.filtered;
816
943
  if (filtered.document) invocations.push({
944
+ plan,
817
945
  document: filtered.document,
818
946
  retry: {
819
947
  scope: 'case',
@@ -846,6 +974,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
846
974
  sources,
847
975
  documents,
848
976
  invocations,
977
+ plans,
849
978
  collectionErrors,
850
979
  selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
851
980
  filteredCaseCount
@@ -929,103 +1058,6 @@ async function prepareTestRun(options) {
929
1058
  const external_node_events_namespaceObject = require("node:events");
930
1059
  const report_namespaceObject = require("@midscene/core/report");
931
1060
  const logger_namespaceObject = require("@midscene/shared/logger");
932
- const result_markdown_text = (value)=>value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll('|', '&#124;').replaceAll('[', '&#91;').replaceAll(']', '&#93;').replaceAll('*', '&#42;').replaceAll('_', '&#95;').replaceAll('`', '&#96;').replaceAll('\\', '&#92;').replace(/[\r\n]+/g, ' ');
933
- const duration = (ms)=>{
934
- const seconds = Math.round(ms / 1000);
935
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
936
- };
937
- const href = (from, target)=>(0, external_node_path_namespaceObject.relative)((0, external_node_path_namespaceObject.dirname)(from), target).split(external_node_path_namespaceObject.sep).map(encodeURIComponent).join('/');
938
- function renderTestResultMarkdown(dump, options = {}) {
939
- const { summary } = dump;
940
- const outcome = 'success' === dump.status ? `✅ **${summary.passed}/${summary.total} passed**` : `❌ **${summary.failed} failed** · ${summary.passed} passed · ${summary.notRun} not run`;
941
- const lines = [
942
- '## Midscene Test',
943
- '',
944
- `${outcome} · ${duration(dump.durationMs)}`,
945
- ''
946
- ];
947
- if (summary.filtered) lines.push(`**Filtered:** ${summary.filtered}`, '');
948
- const infrastructure = [
949
- summary.collectionErrors ? `${summary.collectionErrors} collection errors` : '',
950
- summary.documentFailures ? `${summary.documentFailures} document failures` : '',
951
- summary.projectFailures ? `${summary.projectFailures} project failures` : ''
952
- ].filter(Boolean);
953
- if (infrastructure.length) lines.push(`**Infrastructure:** ${infrastructure.join(' · ')}`, '');
954
- const groups = [];
955
- const errors = [];
956
- const recordSteps = (label, steps)=>{
957
- for (const step of steps)if ('failed' === step.status) errors.push(`${label} / ${step.node}: ${step.error?.message ?? 'Step failed'}`);
958
- };
959
- for (const project of dump.projects){
960
- if (project.lifecycle?.setupError) errors.push(`${project.name}: ${project.lifecycle.setupError.message}`);
961
- for (const error of project.lifecycle?.teardownErrors ?? [])errors.push(`${project.name}: ${error.message}`);
962
- for (const error of project.collectionErrors)errors.push(`${project.name} / ${error.sourcePath}: ${error.error.message}`);
963
- for (const document of project.documents){
964
- const name = document.sourcePath.replace(/\\/g, '/').replace(/\.(?:yaml|yml)$/, '');
965
- const group = {
966
- name: dump.projects.length > 1 ? `${project.name} / ${name}` : name,
967
- rows: []
968
- };
969
- groups.push(group);
970
- recordSteps(`${project.name} / ${document.sourcePath}`, [
971
- ...document.beforeAll,
972
- ...document.afterAll
973
- ]);
974
- for (const error of document.teardownErrors ?? [])errors.push(`${document.sourcePath}: ${error.message}`);
975
- for (const failure of document.hostErrors ?? [])errors.push(`${document.sourcePath} / ${failure.phase}: ${failure.error.message}`);
976
- for (const testCase of document.cases){
977
- const attempt = testCase.attempts.at(-1);
978
- const steps = attempt ? [
979
- ...attempt.beforeEach,
980
- ...attempt.steps,
981
- ...attempt.afterEach
982
- ] : [];
983
- const failedSteps = steps.filter((step)=>'failed' === step.status);
984
- const step = failedSteps.at(-1) ?? [
985
- ...steps
986
- ].reverse().find((item)=>item.agentDetails?.length) ?? steps.at(-1);
987
- const route = new URLSearchParams({
988
- 'runner-page': 'case',
989
- 'runner-project': project.projectId,
990
- 'runner-case': testCase.caseId,
991
- ...step ? {
992
- 'runner-step': step.id
993
- } : {}
994
- });
995
- const target = options.reportHref ? `${options.reportHref}#${route}` : void 0;
996
- const name = target ? `[${result_markdown_text(testCase.name)}](<${target}>)` : result_markdown_text(testCase.name);
997
- group.rows.push(` - ${'success' === testCase.status ? '✅' : 'not-run' === testCase.status ? '⏭️' : '❌'} ${name}`);
998
- }
999
- }
1000
- }
1001
- for (const diagnostic of dump.diagnostics ?? [])if ('error' === diagnostic.level) errors.push(diagnostic.message);
1002
- if (errors.length) lines.push('### Execution errors', '', ...errors.map((error)=>`- ${result_markdown_text(error)}`), '');
1003
- for (const group of groups)if (group.rows.length) lines.push(`- ${result_markdown_text(group.name)}`, ...group.rows);
1004
- if (!groups.some((group)=>group.rows.length)) lines.push('No cases were reported.');
1005
- if (options.reportHref) lines.push('', `[Open the Midscene Test report ↗](<${options.reportHref}>)`);
1006
- return `${lines.join('\n')}\n`;
1007
- }
1008
- const emptyIndex = ()=>({
1009
- sources: [],
1010
- metrics: {
1011
- modelCallCount: 0,
1012
- modelTimeMs: 0,
1013
- promptTokens: 0,
1014
- cachedInputTokens: 0,
1015
- completionTokens: 0,
1016
- totalTokens: 0
1017
- }
1018
- });
1019
- async function writeTestResultMarkdown(result, index = emptyIndex()) {
1020
- const output = (0, external_node_path_namespaceObject.join)((0, external_node_path_namespaceObject.dirname)(result.summaryPath), 'result.md');
1021
- const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(result, index);
1022
- await (0, promises_namespaceObject.writeFile)(output, renderTestResultMarkdown(dump, {
1023
- ...result.reportPath ? {
1024
- reportHref: href(output, result.reportPath)
1025
- } : {}
1026
- }));
1027
- return output;
1028
- }
1029
1061
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1030
1062
  const formatStep = (info)=>{
1031
1063
  const position = stepPosition(info);
@@ -1034,6 +1066,7 @@ const formatStep = (info)=>{
1034
1066
  };
1035
1067
  const formatStepResult = (info, result)=>{
1036
1068
  const indent = 'case' === info.scope ? ' ' : ' ';
1069
+ if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
1037
1070
  const symbol = 'success' === result.status ? '✓' : '✗';
1038
1071
  const error = result.error ? ` — ${result.error.message}` : '';
1039
1072
  const continuation = result.continuedAfterError ? '; continuing' : '';
@@ -1043,11 +1076,11 @@ const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
1043
1076
  ...attempt.beforeEach,
1044
1077
  ...attempt.steps,
1045
1078
  ...attempt.afterEach
1046
- ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (attempt.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError));
1079
+ ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (attempt.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError) || !!attempt.executionErrors?.some(test_runner_namespaceObject.isResourceRecoveryError));
1047
1080
  const documentHasFatalError = (result)=>[
1048
1081
  ...result.beforeAll,
1049
1082
  ...result.afterAll
1050
- ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (result.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError);
1083
+ ].some((step)=>step.error && (0, test_runner_namespaceObject.isFatalDeviceError)(step.error)) || (result.teardownErrors ?? []).some(test_runner_namespaceObject.isFatalDeviceError) || !!result.executionErrors?.some(test_runner_namespaceObject.isResourceRecoveryError);
1051
1084
  async function executeDocumentInvocation(options) {
1052
1085
  const { invocation, project, sinks } = options;
1053
1086
  const { document } = invocation;
@@ -1060,6 +1093,7 @@ async function executeDocumentInvocation(options) {
1060
1093
  }, async (documentAttemptIndex)=>{
1061
1094
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1062
1095
  const execution = await (0, test_runner_namespaceObject.runWorkflowDocument)(document, {
1096
+ plan: invocation.plan,
1063
1097
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1064
1098
  ...invocation.bindings,
1065
1099
  project,
@@ -1097,6 +1131,18 @@ async function executeDocumentInvocation(options) {
1097
1131
  }).catch(async (error)=>{
1098
1132
  if (error instanceof test_runner_namespaceObject.WorkflowExecutionFailure) {
1099
1133
  const partial = error.result;
1134
+ const resourceOnly = error.errors.length > 0 && error.errors.every((failure)=>(0, test_runner_namespaceObject.isResourceRecoveryError)(failure) || partial.document.hostErrors?.some((host)=>'cleanup' === host.phase && (host.error === failure || host.error instanceof Error && host.error.cause === failure)));
1135
+ if (resourceOnly) {
1136
+ if (error.errors.some(test_runner_namespaceObject.isResourceRecoveryError)) options.markProjectFatal();
1137
+ return partial;
1138
+ }
1139
+ if (documentRetry) partial.document.retryDecision = (0, test_runner_namespaceObject.decideRetry)({
1140
+ scope: 'document',
1141
+ retryCount: invocation.retry.count,
1142
+ attemptIndex: documentAttemptIndex,
1143
+ status: 'failed',
1144
+ infrastructureError: true
1145
+ });
1100
1146
  attempts.push(partial);
1101
1147
  sinks.documents.push(partial.document);
1102
1148
  sinks.cases.push(...partial.cases.map((outcome)=>({
@@ -1105,6 +1151,7 @@ async function executeDocumentInvocation(options) {
1105
1151
  documentRunId: partial.document.documentRunId
1106
1152
  })));
1107
1153
  try {
1154
+ for (const outcome of partial.cases)for (const attempt of outcome.attempts ?? [])await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1108
1155
  await writeWorkflowDocumentResult(options.runDir, partial.document);
1109
1156
  } catch (publicationError) {
1110
1157
  throw new test_runner_namespaceObject.WorkflowExecutionFailure(partial, [
@@ -1115,6 +1162,29 @@ async function executeDocumentInvocation(options) {
1115
1162
  }
1116
1163
  throw error;
1117
1164
  });
1165
+ if (documentHasFatalError(execution.document)) options.markProjectFatal();
1166
+ const owners = [
1167
+ execution.document,
1168
+ ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
1169
+ ];
1170
+ const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1171
+ const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1172
+ const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
1173
+ if (cleanupPending || cleanupFailed || operationPending) options.markProjectFatal();
1174
+ const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1175
+ const decision = (0, test_runner_namespaceObject.decideRetry)({
1176
+ scope: 'document',
1177
+ retryCount: invocation.retry.count,
1178
+ attemptIndex: documentAttemptIndex,
1179
+ status: failed ? 'failed' : 'success',
1180
+ cleanupPending,
1181
+ cleanupFailed,
1182
+ operationPending,
1183
+ fatalError: options.isProjectFatal(),
1184
+ interrupted: options.signal.aborted || options.rootSignal.aborted,
1185
+ shouldStop: options.shouldBail()
1186
+ });
1187
+ if (documentRetry) execution.document.retryDecision = decision;
1118
1188
  attempts.push(execution);
1119
1189
  sinks.cases.push(...execution.cases.map((outcome)=>({
1120
1190
  ...outcome,
@@ -1122,15 +1192,30 @@ async function executeDocumentInvocation(options) {
1122
1192
  documentRunId: execution.document.documentRunId
1123
1193
  })));
1124
1194
  sinks.documents.push(execution.document);
1125
- await writeWorkflowDocumentResult(options.runDir, execution.document);
1126
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1127
- const cleanupErrors = [
1128
- ...execution.document.teardownErrors ?? [],
1129
- ...execution.cases.flatMap((outcome)=>(outcome.attempts ?? []).flatMap((attempt)=>attempt.teardownErrors ?? []))
1130
- ];
1131
- if (cleanupErrors.length) throw new test_runner_namespaceObject.WorkflowExecutionFailure(execution, cleanupErrors);
1132
- const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1133
- return failed ? 'failed' : 'success';
1195
+ try {
1196
+ for (const outcome of execution.cases)for (const attempt of outcome.attempts ?? [])await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1197
+ await writeWorkflowDocumentResult(options.runDir, execution.document);
1198
+ } catch (error) {
1199
+ execution.document.status = 'failed';
1200
+ execution.document.hostErrors = [
1201
+ ...execution.document.hostErrors ?? [],
1202
+ {
1203
+ phase: 'publication',
1204
+ error
1205
+ }
1206
+ ];
1207
+ if (documentRetry) execution.document.retryDecision = (0, test_runner_namespaceObject.decideRetry)({
1208
+ scope: 'document',
1209
+ retryCount: invocation.retry.count,
1210
+ attemptIndex: documentAttemptIndex,
1211
+ status: 'failed',
1212
+ infrastructureError: true
1213
+ });
1214
+ throw new test_runner_namespaceObject.WorkflowExecutionFailure(execution, [
1215
+ error
1216
+ ]);
1217
+ }
1218
+ return 'retry' === decision.decision ? 'failed' : void 0;
1134
1219
  });
1135
1220
  const final = attempts.at(-1);
1136
1221
  if (!final) return;
@@ -1138,6 +1223,18 @@ async function executeDocumentInvocation(options) {
1138
1223
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1139
1224
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1140
1225
  }
1226
+ function createOperationJournal(runDir) {
1227
+ const path = (0, external_node_path_namespaceObject.join)(runDir, 'operations.jsonl');
1228
+ (0, external_node_fs_namespaceObject.writeFileSync)(path, '', {
1229
+ flag: 'wx'
1230
+ });
1231
+ return {
1232
+ path,
1233
+ append (event) {
1234
+ (0, external_node_fs_namespaceObject.appendFileSync)(path, `${JSON.stringify(event)}\n`);
1235
+ }
1236
+ };
1237
+ }
1141
1238
  const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1142
1239
  documentId,
1143
1240
  caseId: collectedCase.caseId,
@@ -1196,6 +1293,8 @@ async function runPreparedTestProject(plan) {
1196
1293
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1197
1294
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1198
1295
  const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1296
+ await writeTestRunPlan(runDir, preparedProjects);
1297
+ const journal = createOperationJournal(runDir);
1199
1298
  const rootController = new AbortController();
1200
1299
  (0, external_node_events_namespaceObject.setMaxListeners)(Math.max(10, effectiveConcurrency + 1), rootController.signal);
1201
1300
  const handleSignal = (signal)=>{
@@ -1225,6 +1324,16 @@ async function runPreparedTestProject(plan) {
1225
1324
  throw error;
1226
1325
  }
1227
1326
  };
1327
+ const stopObservingOperations = (0, test_runner_namespaceObject.observeResourceOperations)(rootController.signal, (event)=>{
1328
+ try {
1329
+ journal.append(event);
1330
+ } catch (error) {
1331
+ recordInfrastructureError(new test_runner_namespaceObject.WorkflowPublicationError('write-result', journal.path, error));
1332
+ (0, logger_namespaceObject.getDebug)('test-runner:operations', {
1333
+ console: true
1334
+ })(`Could not persist operation event: ${String(error)}`);
1335
+ }
1336
+ });
1228
1337
  const announceProject = (prepared, projectIndex)=>{
1229
1338
  const { project } = prepared;
1230
1339
  runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
@@ -1249,6 +1358,7 @@ async function runPreparedTestProject(plan) {
1249
1358
  lifecycle
1250
1359
  } : {},
1251
1360
  cases,
1361
+ plans: prepared.plans,
1252
1362
  documents,
1253
1363
  collectionErrors: prepared.collectionErrors
1254
1364
  };
@@ -1367,6 +1477,7 @@ async function runPreparedTestProject(plan) {
1367
1477
  } catch (error) {
1368
1478
  recordInfrastructureError(error);
1369
1479
  } finally{
1480
+ stopObservingOperations();
1370
1481
  process.off('SIGINT', sigint);
1371
1482
  process.off('SIGTERM', sigterm);
1372
1483
  }
@@ -1382,6 +1493,10 @@ async function runPreparedTestProject(plan) {
1382
1493
  const endedAt = new Date();
1383
1494
  const result = {
1384
1495
  schemaVersion: 3,
1496
+ operationLog: {
1497
+ path: journal.path,
1498
+ snapshotAt: endedAt.toISOString()
1499
+ },
1385
1500
  runId,
1386
1501
  startedAt: startedAt.toISOString(),
1387
1502
  endedAt: endedAt.toISOString(),
@@ -1401,7 +1516,6 @@ async function runPreparedTestProject(plan) {
1401
1516
  } : {}
1402
1517
  };
1403
1518
  let completedResult = result;
1404
- let reportIndex;
1405
1519
  const publish = async (operation, path, callback)=>{
1406
1520
  try {
1407
1521
  await callback();
@@ -1432,7 +1546,6 @@ async function runPreparedTestProject(plan) {
1432
1546
  overwrite: false,
1433
1547
  sources: (0, test_runner_namespaceObject.collectTestRunReportSources)(completedResult),
1434
1548
  buildRunnerDump: (index)=>{
1435
- reportIndex = index;
1436
1549
  const dump = (0, test_runner_namespaceObject.buildTestRunReportDump)(completedResult, index);
1437
1550
  const warn = (0, logger_namespaceObject.getDebug)('test-runner:report-assembler', {
1438
1551
  console: true
@@ -1450,13 +1563,6 @@ async function runPreparedTestProject(plan) {
1450
1563
  for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
1451
1564
  await publication.publish(completedResult);
1452
1565
  });
1453
- await publish('write-result', (0, external_node_path_namespaceObject.join)((0, external_node_path_namespaceObject.dirname)(summaryPath), 'result.md'), async ()=>{
1454
- const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1455
- completedResult = {
1456
- ...completedResult,
1457
- markdownPath
1458
- };
1459
- });
1460
1566
  await writeSummary();
1461
1567
  if (hasInfrastructureError) throw new test_runner_namespaceObject.WorkflowExecutionFailure(completedResult, infrastructureErrors);
1462
1568
  return completedResult;