@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.
@@ -1,13 +1,13 @@
1
1
  import * as __rspack_external_node_fs_5ea92f0c from "node:fs";
2
2
  import * as __rspack_external_node_path_c5b9b54f from "node:path";
3
- import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, isFatalDeviceError, normalizeSteps, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument } from "@midscene/core/internal/test-runner";
3
+ import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, buildTestRunReportPlan, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, decideRetry, isFatalDeviceError, isReportSecretKey, isResourceRecoveryError, normalizeSteps, observeResourceOperations, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument, sanitizeReportError, sanitizeReportText } from "@midscene/core/internal/test-runner";
4
4
  import { inspect } from "node:util";
5
5
  import { z } from "zod/v4";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { require as api_require } from "tsx/cjs/api";
8
8
  import { globSync } from "tinyglobby";
9
- import { JSON_SCHEMA, load } from "js-yaml";
10
9
  import { createHash, randomUUID } from "node:crypto";
10
+ import { JSON_SCHEMA, load } from "js-yaml";
11
11
  import { mkdir, writeFile } from "node:fs/promises";
12
12
  import { setMaxListeners } from "node:events";
13
13
  import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
@@ -20,7 +20,9 @@ var __webpack_modules__ = {
20
20
  module.exports = __rspack_external_node_path_c5b9b54f;
21
21
  },
22
22
  "./package.json" (module) {
23
- module.exports = JSON.parse('{"rE":"1.15.2-beta-20261012123219.0"}');
23
+ module.exports = {
24
+ rE: "1.15.2"
25
+ };
24
26
  }
25
27
  };
26
28
  var __webpack_module_cache__ = {};
@@ -524,6 +526,69 @@ const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=
524
526
  ] : []
525
527
  ])).digest('hex');
526
528
  const normalize_normalizeSteps = normalizeSteps;
529
+ function parseWorkflowSource(source) {
530
+ const stack = [];
531
+ const nodes = [];
532
+ const sensitiveNodes = new Set();
533
+ const sensitiveValues = new Set();
534
+ const redactedPaths = new Set();
535
+ const sourceOffset = 0xfeff === source.charCodeAt(0) ? 1 : 0;
536
+ const parsed = load(source, {
537
+ schema: JSON_SCHEMA,
538
+ listener (event, state) {
539
+ if ('open' === event) return void stack.push({
540
+ start: state.position + sourceOffset,
541
+ end: 0,
542
+ value: null,
543
+ children: []
544
+ });
545
+ const node = stack.pop();
546
+ node.end = state.position + sourceOffset;
547
+ node.value = state.result;
548
+ stack.at(-1)?.children.push(node);
549
+ nodes.push(node);
550
+ if ('mapping' !== state.kind) return;
551
+ for(let index = 0; index < node.children.length; index += 2){
552
+ const key = node.children[index].value;
553
+ const value = node.children[index + 1];
554
+ if ('string' == typeof key && isReportSecretKey(key) && value) {
555
+ sensitiveNodes.add(value);
556
+ if (null !== value.value) sensitiveValues.add(value.value);
557
+ redactedPaths.add(`$..[${JSON.stringify(key)}]`);
558
+ }
559
+ }
560
+ }
561
+ });
562
+ const ranges = nodes.filter((node)=>sensitiveNodes.has(node) || sensitiveValues.has(node.value)).sort((left, right)=>left.start - right.start || right.end - left.end);
563
+ let offset = 0;
564
+ let redacted = '';
565
+ for (const range of ranges){
566
+ if (range.start < offset) continue;
567
+ const text = source.slice(range.start, range.end);
568
+ const leading = text.match(/^\s*/)?.[0] ?? '';
569
+ const trailing = text.slice(leading.length).match(/\s*$/)?.[0] ?? '';
570
+ redacted += `${source.slice(offset, range.start)}${leading}"[REDACTED]"${trailing}`;
571
+ offset = range.end;
572
+ }
573
+ redacted += source.slice(offset);
574
+ const value = sanitizeReportText(redacted, 65536);
575
+ return {
576
+ parsed,
577
+ sourceYaml: {
578
+ value,
579
+ ...redactedPaths.size ? {
580
+ redactedPaths: [
581
+ ...redactedPaths
582
+ ]
583
+ } : {},
584
+ ...value !== redacted ? {
585
+ truncatedPaths: [
586
+ '$'
587
+ ]
588
+ } : {}
589
+ }
590
+ };
591
+ }
527
592
  const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
528
593
  const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
529
594
  const exactProjectExpression = /^\$\{([^{}]+)\}$/;
@@ -598,16 +663,15 @@ const collect_rejectUnknownKeys = (value, allowed, label)=>{
598
663
  const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
599
664
  const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
600
665
  function collectWorkflowDocument(source, options) {
601
- let parsed;
666
+ let snapshot;
602
667
  try {
603
- parsed = load((0, external_node_fs_.readFileSync)(source.absolutePath, 'utf8'), {
604
- schema: JSON_SCHEMA
605
- });
668
+ snapshot = parseWorkflowSource((0, external_node_fs_.readFileSync)(source.absolutePath, 'utf8'));
606
669
  } catch (error) {
607
670
  throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
608
671
  sourcePath: source.sourcePath
609
672
  }, error);
610
673
  }
674
+ const { parsed, sourceYaml } = snapshot;
611
675
  if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
612
676
  const format = classifyWorkflowDocumentFormat(parsed);
613
677
  if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
@@ -622,6 +686,7 @@ function collectWorkflowDocument(source, options) {
622
686
  const sourcePath = source.sourcePath.split(external_node_path_.sep).join('/');
623
687
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
624
688
  ...step,
689
+ templateInput: step.input,
625
690
  input: resolveWorkflowVariables(step.input, {
626
691
  variables: options.variables,
627
692
  env: options.env ?? process.env,
@@ -707,6 +772,7 @@ function collectWorkflowDocument(source, options) {
707
772
  documentId: createWorkflowDocumentId(source.projectId, sourcePath),
708
773
  projectId: source.projectId,
709
774
  sourcePath,
775
+ sourceYaml,
710
776
  lifecycle,
711
777
  cases
712
778
  };
@@ -721,6 +787,15 @@ const writeJson = async (path, value)=>{
721
787
  throw new WorkflowPublicationError('write-result', path, error);
722
788
  }
723
789
  };
790
+ const writeTestRunPlan = (runDir, projects)=>writeJson((0, external_node_path_.join)(runDir, 'plan.json'), {
791
+ schemaVersion: 1,
792
+ createdAt: new Date().toISOString(),
793
+ projects: projects.map((item)=>({
794
+ projectId: item.project.projectId,
795
+ name: item.project.name,
796
+ documents: item.plans ?? []
797
+ }))
798
+ });
724
799
  const toPosix = (value)=>value.split(external_node_path_.sep).join('/');
725
800
  const caseAttemptResultPath = (projectId, documentId, result)=>toPosix((0, external_node_path_.join)(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
726
801
  const workflowDocumentResultPath = (result)=>toPosix((0, external_node_path_.join)(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
@@ -750,7 +825,7 @@ const relativeToSummary = (summaryPath, target)=>{
750
825
  const value = (0, external_node_path_.relative)((0, external_node_path_.dirname)(summaryPath), target);
751
826
  return toPosix(value || '.');
752
827
  };
753
- const errorJson = (error)=>error instanceof Error && 'toJSON' in error ? error.toJSON() : error;
828
+ const errorJson = sanitizeReportError;
754
829
  const writeTestProjectRunResult = (options)=>{
755
830
  const { result } = options;
756
831
  const fact = (path)=>toPosix(path);
@@ -770,13 +845,17 @@ const writeTestProjectRunResult = (options)=>{
770
845
  configPath: options.configPath
771
846
  } : {},
772
847
  factsRoot: '.',
848
+ plan: 'plan.json',
849
+ ...result.operationLog ? {
850
+ operationLog: {
851
+ ...result.operationLog,
852
+ path: relativeToSummary(result.summaryPath, result.operationLog.path)
853
+ }
854
+ } : {},
773
855
  reportDir: relativeToSummary(result.summaryPath, result.reportDir),
774
856
  ...result.reportPath ? {
775
857
  report: relativeToSummary(result.summaryPath, result.reportPath)
776
858
  } : {},
777
- ...result.markdownPath ? {
778
- markdown: relativeToSummary(result.summaryPath, result.markdownPath)
779
- } : {},
780
859
  summary: result.summary,
781
860
  projects: result.projects.map((project)=>({
782
861
  projectId: project.projectId,
@@ -794,6 +873,12 @@ const writeTestProjectRunResult = (options)=>{
794
873
  startedAt: project.lifecycle.startedAt,
795
874
  endedAt: project.lifecycle.endedAt,
796
875
  durationMs: project.lifecycle.durationMs,
876
+ ...project.lifecycle.cleanup ? {
877
+ cleanup: project.lifecycle.cleanup
878
+ } : {},
879
+ ...project.lifecycle.pendingOperations ? {
880
+ pendingOperations: project.lifecycle.pendingOperations
881
+ } : {},
797
882
  ...project.lifecycle.setupError ? {
798
883
  setupError: errorJson(project.lifecycle.setupError)
799
884
  } : {},
@@ -820,6 +905,18 @@ const writeTestProjectRunResult = (options)=>{
820
905
  attemptId: attempt.runId,
821
906
  attemptIndex: attempt.attemptIndex,
822
907
  status: attempt.status,
908
+ ...attempt.cleanup ? {
909
+ cleanup: attempt.cleanup
910
+ } : {},
911
+ ...attempt.pendingOperations ? {
912
+ pendingOperations: attempt.pendingOperations
913
+ } : {},
914
+ ...attempt.executionStatus ? {
915
+ executionStatus: attempt.executionStatus
916
+ } : {},
917
+ ...attempt.retryDecision ? {
918
+ retryDecision: attempt.retryDecision
919
+ } : {},
823
920
  resultFile: fact(caseAttemptResultPath(project.projectId, outcome.documentId, attempt)),
824
921
  ...attempt.reportPaths?.length ? {
825
922
  reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
@@ -835,6 +932,18 @@ const writeTestProjectRunResult = (options)=>{
835
932
  },
836
933
  sourcePath: document.sourcePath,
837
934
  status: document.status,
935
+ ...document.cleanup ? {
936
+ cleanup: document.cleanup
937
+ } : {},
938
+ ...document.pendingOperations ? {
939
+ pendingOperations: document.pendingOperations
940
+ } : {},
941
+ ...document.executionStatus ? {
942
+ executionStatus: document.executionStatus
943
+ } : {},
944
+ ...document.retryDecision ? {
945
+ retryDecision: document.retryDecision
946
+ } : {},
838
947
  resultFile: fact(workflowDocumentResultPath(document)),
839
948
  ...document.reportPaths?.length ? {
840
949
  reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
@@ -969,6 +1078,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
969
1078
  });
970
1079
  const collectionErrors = [];
971
1080
  const invocations = [];
1081
+ const plans = [];
972
1082
  let filteredCaseCount = 0;
973
1083
  if (0 === sources.length) {
974
1084
  const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
@@ -982,7 +1092,17 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
982
1092
  const collected = await options.collectDocument?.(source, project);
983
1093
  if (collected) {
984
1094
  filteredCaseCount += collected.filteredCaseCount;
985
- if (collected.invocation) invocations.push(collected.invocation);
1095
+ if (collected.invocation) {
1096
+ const plan = buildTestRunReportPlan(collected.invocation.document, {
1097
+ defaultTimeoutMs: collected.invocation.defaultTimeoutMs,
1098
+ retry: collected.invocation.retry
1099
+ });
1100
+ invocations.push({
1101
+ ...collected.invocation,
1102
+ plan
1103
+ });
1104
+ plans.push(plan);
1105
+ }
986
1106
  continue;
987
1107
  }
988
1108
  const document = collectWorkflowDocument(source, {
@@ -994,8 +1114,18 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
994
1114
  document,
995
1115
  filtered: 0
996
1116
  } : filterDocumentCases(document, project);
1117
+ const plan = buildTestRunReportPlan(document, {
1118
+ selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
1119
+ defaultTimeoutMs,
1120
+ retry: {
1121
+ scope: 'case',
1122
+ count: project.retry
1123
+ }
1124
+ });
1125
+ plans.push(plan);
997
1126
  filteredCaseCount += filtered.filtered;
998
1127
  if (filtered.document) invocations.push({
1128
+ plan,
999
1129
  document: filtered.document,
1000
1130
  retry: {
1001
1131
  scope: 'case',
@@ -1028,6 +1158,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
1028
1158
  sources,
1029
1159
  documents,
1030
1160
  invocations,
1161
+ plans,
1031
1162
  collectionErrors,
1032
1163
  selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
1033
1164
  filteredCaseCount
@@ -1108,103 +1239,6 @@ async function prepareTestRun(options) {
1108
1239
  onProgress: options.onProgress ?? (()=>{})
1109
1240
  };
1110
1241
  }
1111
- 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, ' ');
1112
- const duration = (ms)=>{
1113
- const seconds = Math.round(ms / 1000);
1114
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
1115
- };
1116
- const href = (from, target)=>(0, external_node_path_.relative)((0, external_node_path_.dirname)(from), target).split(external_node_path_.sep).map(encodeURIComponent).join('/');
1117
- function renderTestResultMarkdown(dump, options = {}) {
1118
- const { summary } = dump;
1119
- const outcome = 'success' === dump.status ? `✅ **${summary.passed}/${summary.total} passed**` : `❌ **${summary.failed} failed** · ${summary.passed} passed · ${summary.notRun} not run`;
1120
- const lines = [
1121
- '## Midscene Test',
1122
- '',
1123
- `${outcome} · ${duration(dump.durationMs)}`,
1124
- ''
1125
- ];
1126
- if (summary.filtered) lines.push(`**Filtered:** ${summary.filtered}`, '');
1127
- const infrastructure = [
1128
- summary.collectionErrors ? `${summary.collectionErrors} collection errors` : '',
1129
- summary.documentFailures ? `${summary.documentFailures} document failures` : '',
1130
- summary.projectFailures ? `${summary.projectFailures} project failures` : ''
1131
- ].filter(Boolean);
1132
- if (infrastructure.length) lines.push(`**Infrastructure:** ${infrastructure.join(' · ')}`, '');
1133
- const groups = [];
1134
- const errors = [];
1135
- const recordSteps = (label, steps)=>{
1136
- for (const step of steps)if ('failed' === step.status) errors.push(`${label} / ${step.node}: ${step.error?.message ?? 'Step failed'}`);
1137
- };
1138
- for (const project of dump.projects){
1139
- if (project.lifecycle?.setupError) errors.push(`${project.name}: ${project.lifecycle.setupError.message}`);
1140
- for (const error of project.lifecycle?.teardownErrors ?? [])errors.push(`${project.name}: ${error.message}`);
1141
- for (const error of project.collectionErrors)errors.push(`${project.name} / ${error.sourcePath}: ${error.error.message}`);
1142
- for (const document of project.documents){
1143
- const name = document.sourcePath.replace(/\\/g, '/').replace(/\.(?:yaml|yml)$/, '');
1144
- const group = {
1145
- name: dump.projects.length > 1 ? `${project.name} / ${name}` : name,
1146
- rows: []
1147
- };
1148
- groups.push(group);
1149
- recordSteps(`${project.name} / ${document.sourcePath}`, [
1150
- ...document.beforeAll,
1151
- ...document.afterAll
1152
- ]);
1153
- for (const error of document.teardownErrors ?? [])errors.push(`${document.sourcePath}: ${error.message}`);
1154
- for (const failure of document.hostErrors ?? [])errors.push(`${document.sourcePath} / ${failure.phase}: ${failure.error.message}`);
1155
- for (const testCase of document.cases){
1156
- const attempt = testCase.attempts.at(-1);
1157
- const steps = attempt ? [
1158
- ...attempt.beforeEach,
1159
- ...attempt.steps,
1160
- ...attempt.afterEach
1161
- ] : [];
1162
- const failedSteps = steps.filter((step)=>'failed' === step.status);
1163
- const step = failedSteps.at(-1) ?? [
1164
- ...steps
1165
- ].reverse().find((item)=>item.agentDetails?.length) ?? steps.at(-1);
1166
- const route = new URLSearchParams({
1167
- 'runner-page': 'case',
1168
- 'runner-project': project.projectId,
1169
- 'runner-case': testCase.caseId,
1170
- ...step ? {
1171
- 'runner-step': step.id
1172
- } : {}
1173
- });
1174
- const target = options.reportHref ? `${options.reportHref}#${route}` : void 0;
1175
- const name = target ? `[${result_markdown_text(testCase.name)}](<${target}>)` : result_markdown_text(testCase.name);
1176
- group.rows.push(` - ${'success' === testCase.status ? '✅' : 'not-run' === testCase.status ? '⏭️' : '❌'} ${name}`);
1177
- }
1178
- }
1179
- }
1180
- for (const diagnostic of dump.diagnostics ?? [])if ('error' === diagnostic.level) errors.push(diagnostic.message);
1181
- if (errors.length) lines.push('### Execution errors', '', ...errors.map((error)=>`- ${result_markdown_text(error)}`), '');
1182
- for (const group of groups)if (group.rows.length) lines.push(`- ${result_markdown_text(group.name)}`, ...group.rows);
1183
- if (!groups.some((group)=>group.rows.length)) lines.push('No cases were reported.');
1184
- if (options.reportHref) lines.push('', `[Open the Midscene Test report ↗](<${options.reportHref}>)`);
1185
- return `${lines.join('\n')}\n`;
1186
- }
1187
- const emptyIndex = ()=>({
1188
- sources: [],
1189
- metrics: {
1190
- modelCallCount: 0,
1191
- modelTimeMs: 0,
1192
- promptTokens: 0,
1193
- cachedInputTokens: 0,
1194
- completionTokens: 0,
1195
- totalTokens: 0
1196
- }
1197
- });
1198
- async function writeTestResultMarkdown(result, index = emptyIndex()) {
1199
- const output = (0, external_node_path_.join)((0, external_node_path_.dirname)(result.summaryPath), 'result.md');
1200
- const dump = buildTestRunReportDump(result, index);
1201
- await writeFile(output, renderTestResultMarkdown(dump, {
1202
- ...result.reportPath ? {
1203
- reportHref: href(output, result.reportPath)
1204
- } : {}
1205
- }));
1206
- return output;
1207
- }
1208
1242
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1209
1243
  const formatStep = (info)=>{
1210
1244
  const position = stepPosition(info);
@@ -1213,6 +1247,7 @@ const formatStep = (info)=>{
1213
1247
  };
1214
1248
  const formatStepResult = (info, result)=>{
1215
1249
  const indent = 'case' === info.scope ? ' ' : ' ';
1250
+ if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
1216
1251
  const symbol = 'success' === result.status ? '✓' : '✗';
1217
1252
  const error = result.error ? ` — ${result.error.message}` : '';
1218
1253
  const continuation = result.continuedAfterError ? '; continuing' : '';
@@ -1222,11 +1257,11 @@ const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
1222
1257
  ...attempt.beforeEach,
1223
1258
  ...attempt.steps,
1224
1259
  ...attempt.afterEach
1225
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError));
1260
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError) || !!attempt.executionErrors?.some(isResourceRecoveryError));
1226
1261
  const documentHasFatalError = (result)=>[
1227
1262
  ...result.beforeAll,
1228
1263
  ...result.afterAll
1229
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError);
1264
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError) || !!result.executionErrors?.some(isResourceRecoveryError);
1230
1265
  async function executeDocumentInvocation(options) {
1231
1266
  const { invocation, project, sinks } = options;
1232
1267
  const { document } = invocation;
@@ -1239,6 +1274,7 @@ async function executeDocumentInvocation(options) {
1239
1274
  }, async (documentAttemptIndex)=>{
1240
1275
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1241
1276
  const execution = await runWorkflowDocument(document, {
1277
+ plan: invocation.plan,
1242
1278
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1243
1279
  ...invocation.bindings,
1244
1280
  project,
@@ -1276,6 +1312,18 @@ async function executeDocumentInvocation(options) {
1276
1312
  }).catch(async (error)=>{
1277
1313
  if (error instanceof WorkflowExecutionFailure) {
1278
1314
  const partial = error.result;
1315
+ const resourceOnly = error.errors.length > 0 && error.errors.every((failure)=>isResourceRecoveryError(failure) || partial.document.hostErrors?.some((host)=>'cleanup' === host.phase && (host.error === failure || host.error instanceof Error && host.error.cause === failure)));
1316
+ if (resourceOnly) {
1317
+ if (error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1318
+ return partial;
1319
+ }
1320
+ if (documentRetry) partial.document.retryDecision = decideRetry({
1321
+ scope: 'document',
1322
+ retryCount: invocation.retry.count,
1323
+ attemptIndex: documentAttemptIndex,
1324
+ status: 'failed',
1325
+ infrastructureError: true
1326
+ });
1279
1327
  attempts.push(partial);
1280
1328
  sinks.documents.push(partial.document);
1281
1329
  sinks.cases.push(...partial.cases.map((outcome)=>({
@@ -1284,6 +1332,7 @@ async function executeDocumentInvocation(options) {
1284
1332
  documentRunId: partial.document.documentRunId
1285
1333
  })));
1286
1334
  try {
1335
+ for (const outcome of partial.cases)for (const attempt of outcome.attempts ?? [])await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1287
1336
  await writeWorkflowDocumentResult(options.runDir, partial.document);
1288
1337
  } catch (publicationError) {
1289
1338
  throw new WorkflowExecutionFailure(partial, [
@@ -1294,6 +1343,29 @@ async function executeDocumentInvocation(options) {
1294
1343
  }
1295
1344
  throw error;
1296
1345
  });
1346
+ if (documentHasFatalError(execution.document)) options.markProjectFatal();
1347
+ const owners = [
1348
+ execution.document,
1349
+ ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
1350
+ ];
1351
+ const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1352
+ const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1353
+ const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
1354
+ if (cleanupPending || cleanupFailed || operationPending) options.markProjectFatal();
1355
+ const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1356
+ const decision = decideRetry({
1357
+ scope: 'document',
1358
+ retryCount: invocation.retry.count,
1359
+ attemptIndex: documentAttemptIndex,
1360
+ status: failed ? 'failed' : 'success',
1361
+ cleanupPending,
1362
+ cleanupFailed,
1363
+ operationPending,
1364
+ fatalError: options.isProjectFatal(),
1365
+ interrupted: options.signal.aborted || options.rootSignal.aborted,
1366
+ shouldStop: options.shouldBail()
1367
+ });
1368
+ if (documentRetry) execution.document.retryDecision = decision;
1297
1369
  attempts.push(execution);
1298
1370
  sinks.cases.push(...execution.cases.map((outcome)=>({
1299
1371
  ...outcome,
@@ -1301,15 +1373,30 @@ async function executeDocumentInvocation(options) {
1301
1373
  documentRunId: execution.document.documentRunId
1302
1374
  })));
1303
1375
  sinks.documents.push(execution.document);
1304
- await writeWorkflowDocumentResult(options.runDir, execution.document);
1305
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1306
- const cleanupErrors = [
1307
- ...execution.document.teardownErrors ?? [],
1308
- ...execution.cases.flatMap((outcome)=>(outcome.attempts ?? []).flatMap((attempt)=>attempt.teardownErrors ?? []))
1309
- ];
1310
- if (cleanupErrors.length) throw new WorkflowExecutionFailure(execution, cleanupErrors);
1311
- const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1312
- return failed ? 'failed' : 'success';
1376
+ try {
1377
+ for (const outcome of execution.cases)for (const attempt of outcome.attempts ?? [])await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1378
+ await writeWorkflowDocumentResult(options.runDir, execution.document);
1379
+ } catch (error) {
1380
+ execution.document.status = 'failed';
1381
+ execution.document.hostErrors = [
1382
+ ...execution.document.hostErrors ?? [],
1383
+ {
1384
+ phase: 'publication',
1385
+ error
1386
+ }
1387
+ ];
1388
+ if (documentRetry) execution.document.retryDecision = decideRetry({
1389
+ scope: 'document',
1390
+ retryCount: invocation.retry.count,
1391
+ attemptIndex: documentAttemptIndex,
1392
+ status: 'failed',
1393
+ infrastructureError: true
1394
+ });
1395
+ throw new WorkflowExecutionFailure(execution, [
1396
+ error
1397
+ ]);
1398
+ }
1399
+ return 'retry' === decision.decision ? 'failed' : void 0;
1313
1400
  });
1314
1401
  const final = attempts.at(-1);
1315
1402
  if (!final) return;
@@ -1317,6 +1404,18 @@ async function executeDocumentInvocation(options) {
1317
1404
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1318
1405
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1319
1406
  }
1407
+ function createOperationJournal(runDir) {
1408
+ const path = (0, external_node_path_.join)(runDir, 'operations.jsonl');
1409
+ (0, external_node_fs_.writeFileSync)(path, '', {
1410
+ flag: 'wx'
1411
+ });
1412
+ return {
1413
+ path,
1414
+ append (event) {
1415
+ (0, external_node_fs_.appendFileSync)(path, `${JSON.stringify(event)}\n`);
1416
+ }
1417
+ };
1418
+ }
1320
1419
  const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1321
1420
  documentId,
1322
1421
  caseId: collectedCase.caseId,
@@ -1375,6 +1474,8 @@ async function runPreparedTestProject(plan) {
1375
1474
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1376
1475
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1377
1476
  const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1477
+ await writeTestRunPlan(runDir, preparedProjects);
1478
+ const journal = createOperationJournal(runDir);
1378
1479
  const rootController = new AbortController();
1379
1480
  setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
1380
1481
  const handleSignal = (signal)=>{
@@ -1404,6 +1505,16 @@ async function runPreparedTestProject(plan) {
1404
1505
  throw error;
1405
1506
  }
1406
1507
  };
1508
+ const stopObservingOperations = observeResourceOperations(rootController.signal, (event)=>{
1509
+ try {
1510
+ journal.append(event);
1511
+ } catch (error) {
1512
+ recordInfrastructureError(new WorkflowPublicationError('write-result', journal.path, error));
1513
+ getDebug('test-runner:operations', {
1514
+ console: true
1515
+ })(`Could not persist operation event: ${String(error)}`);
1516
+ }
1517
+ });
1407
1518
  const announceProject = (prepared, projectIndex)=>{
1408
1519
  const { project } = prepared;
1409
1520
  runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
@@ -1428,6 +1539,7 @@ async function runPreparedTestProject(plan) {
1428
1539
  lifecycle
1429
1540
  } : {},
1430
1541
  cases,
1542
+ plans: prepared.plans,
1431
1543
  documents,
1432
1544
  collectionErrors: prepared.collectionErrors
1433
1545
  };
@@ -1546,6 +1658,7 @@ async function runPreparedTestProject(plan) {
1546
1658
  } catch (error) {
1547
1659
  recordInfrastructureError(error);
1548
1660
  } finally{
1661
+ stopObservingOperations();
1549
1662
  process.off('SIGINT', sigint);
1550
1663
  process.off('SIGTERM', sigterm);
1551
1664
  }
@@ -1561,6 +1674,10 @@ async function runPreparedTestProject(plan) {
1561
1674
  const endedAt = new Date();
1562
1675
  const result = {
1563
1676
  schemaVersion: 3,
1677
+ operationLog: {
1678
+ path: journal.path,
1679
+ snapshotAt: endedAt.toISOString()
1680
+ },
1564
1681
  runId,
1565
1682
  startedAt: startedAt.toISOString(),
1566
1683
  endedAt: endedAt.toISOString(),
@@ -1580,7 +1697,6 @@ async function runPreparedTestProject(plan) {
1580
1697
  } : {}
1581
1698
  };
1582
1699
  let completedResult = result;
1583
- let reportIndex;
1584
1700
  const publish = async (operation, path, callback)=>{
1585
1701
  try {
1586
1702
  await callback();
@@ -1611,7 +1727,6 @@ async function runPreparedTestProject(plan) {
1611
1727
  overwrite: false,
1612
1728
  sources: collectTestRunReportSources(completedResult),
1613
1729
  buildRunnerDump: (index)=>{
1614
- reportIndex = index;
1615
1730
  const dump = buildTestRunReportDump(completedResult, index);
1616
1731
  const warn = getDebug('test-runner:report-assembler', {
1617
1732
  console: true
@@ -1629,13 +1744,6 @@ async function runPreparedTestProject(plan) {
1629
1744
  for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
1630
1745
  await publication.publish(completedResult);
1631
1746
  });
1632
- await publish('write-result', (0, external_node_path_.join)((0, external_node_path_.dirname)(summaryPath), 'result.md'), async ()=>{
1633
- const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1634
- completedResult = {
1635
- ...completedResult,
1636
- markdownPath
1637
- };
1638
- });
1639
1747
  await writeSummary();
1640
1748
  if (hasInfrastructureError) throw new WorkflowExecutionFailure(completedResult, infrastructureErrors);
1641
1749
  return completedResult;
@@ -1822,7 +1930,6 @@ const reportTestProjectResult = (result, io, infrastructureErrors = result.error
1822
1930
  io.log(`midscene-test: ${result.summary.passed}/${result.summary.total} cases passed, ${result.summary.failed} failed, ${result.summary.notRun} not run`);
1823
1931
  io.log(`Results: ${result.resultDir}`);
1824
1932
  io.log(`Summary: ${result.summaryPath}`);
1825
- if (result.markdownPath) io.log(`Markdown: ${result.markdownPath}`);
1826
1933
  if (result.reportPath) io.log(`Report: ${result.reportPath}`);
1827
1934
  return result.exitCode;
1828
1935
  };