@midscene/test 1.15.2-beta-20261012100626.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-20261012100626.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,99 +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)=>`${(ms / 1000).toFixed(1)}s`;
1113
- const href = (from, target)=>(0, external_node_path_.relative)((0, external_node_path_.dirname)(from), target).split(external_node_path_.sep).map(encodeURIComponent).join('/');
1114
- function renderTestResultMarkdown(dump, options = {}) {
1115
- const { summary } = dump;
1116
- const lines = [
1117
- `## Midscene Test · ${'success' === dump.status ? 'passed' : 'failed'}`,
1118
- '',
1119
- `**Cases:** ${summary.total} total · ${summary.passed} passed · ${summary.failed} failed · ${summary.notRun} not run`,
1120
- '',
1121
- `**Duration:** ${duration(dump.durationMs)} · **Filtered:** ${summary.filtered}`,
1122
- '',
1123
- `**Infrastructure:** ${summary.collectionErrors} collection errors · ${summary.documentFailures} document failures · ${summary.projectFailures} project failures`,
1124
- '',
1125
- ...options.reportHref ? [
1126
- `[Open the Midscene Test report](<${options.reportHref}>)`,
1127
- ''
1128
- ] : []
1129
- ];
1130
- const rows = [];
1131
- const errors = [];
1132
- const recordSteps = (label, steps)=>{
1133
- for (const step of steps)if ('failed' === step.status) errors.push(`${label} / ${step.node}: ${step.error?.message ?? 'Step failed'}`);
1134
- };
1135
- for (const project of dump.projects){
1136
- if (project.lifecycle?.setupError) errors.push(`${project.name}: ${project.lifecycle.setupError.message}`);
1137
- for (const error of project.lifecycle?.teardownErrors ?? [])errors.push(`${project.name}: ${error.message}`);
1138
- for (const error of project.collectionErrors)errors.push(`${project.name} / ${error.sourcePath}: ${error.error.message}`);
1139
- for (const document of project.documents){
1140
- recordSteps(`${project.name} / ${document.sourcePath}`, [
1141
- ...document.beforeAll,
1142
- ...document.afterAll
1143
- ]);
1144
- for (const error of document.teardownErrors ?? [])errors.push(`${document.sourcePath}: ${error.message}`);
1145
- for (const failure of document.hostErrors ?? [])errors.push(`${document.sourcePath} / ${failure.phase}: ${failure.error.message}`);
1146
- for (const testCase of document.cases){
1147
- const attempt = testCase.attempts.at(-1);
1148
- const steps = attempt ? [
1149
- ...attempt.beforeEach,
1150
- ...attempt.steps,
1151
- ...attempt.afterEach
1152
- ] : [];
1153
- const failedSteps = steps.filter((step)=>'failed' === step.status);
1154
- const step = failedSteps.at(-1) ?? [
1155
- ...steps
1156
- ].reverse().find((item)=>item.agentDetails?.length) ?? steps.at(-1);
1157
- const route = new URLSearchParams({
1158
- 'runner-page': 'case',
1159
- 'runner-project': project.projectId,
1160
- 'runner-case': testCase.caseId,
1161
- ...step ? {
1162
- 'runner-step': step.id
1163
- } : {}
1164
- });
1165
- const target = options.reportHref ? `${options.reportHref}#${route}` : void 0;
1166
- const name = target ? `[${result_markdown_text(testCase.name)}](<${target}>)` : result_markdown_text(testCase.name);
1167
- rows.push({
1168
- failed: 'success' !== testCase.status,
1169
- row: `- ${name} — ${testCase.status}`
1170
- });
1171
- }
1172
- }
1173
- }
1174
- for (const diagnostic of dump.diagnostics ?? [])if ('error' === diagnostic.level) errors.push(diagnostic.message);
1175
- if (errors.length) lines.push('### Execution errors', '', ...errors.map((error)=>`- ${result_markdown_text(error)}`), '');
1176
- lines.push('### Cases', '');
1177
- lines.push(...rows.filter((row)=>row.failed).map((row)=>row.row));
1178
- const passed = rows.filter((row)=>!row.failed);
1179
- if (passed.length) lines.push(...passed.map((row)=>row.row));
1180
- if (!rows.length) lines.push('', 'No cases were reported.');
1181
- return `${lines.join('\n')}\n`;
1182
- }
1183
- const emptyIndex = ()=>({
1184
- sources: [],
1185
- metrics: {
1186
- modelCallCount: 0,
1187
- modelTimeMs: 0,
1188
- promptTokens: 0,
1189
- cachedInputTokens: 0,
1190
- completionTokens: 0,
1191
- totalTokens: 0
1192
- }
1193
- });
1194
- async function writeTestResultMarkdown(result, index = emptyIndex()) {
1195
- const output = (0, external_node_path_.join)((0, external_node_path_.dirname)(result.summaryPath), 'result.md');
1196
- const dump = buildTestRunReportDump(result, index);
1197
- await writeFile(output, renderTestResultMarkdown(dump, {
1198
- ...result.reportPath ? {
1199
- reportHref: href(output, result.reportPath)
1200
- } : {}
1201
- }));
1202
- return output;
1203
- }
1204
1242
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
1205
1243
  const formatStep = (info)=>{
1206
1244
  const position = stepPosition(info);
@@ -1209,6 +1247,7 @@ const formatStep = (info)=>{
1209
1247
  };
1210
1248
  const formatStepResult = (info, result)=>{
1211
1249
  const indent = 'case' === info.scope ? ' ' : ' ';
1250
+ if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
1212
1251
  const symbol = 'success' === result.status ? '✓' : '✗';
1213
1252
  const error = result.error ? ` — ${result.error.message}` : '';
1214
1253
  const continuation = result.continuedAfterError ? '; continuing' : '';
@@ -1218,11 +1257,11 @@ const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
1218
1257
  ...attempt.beforeEach,
1219
1258
  ...attempt.steps,
1220
1259
  ...attempt.afterEach
1221
- ].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));
1222
1261
  const documentHasFatalError = (result)=>[
1223
1262
  ...result.beforeAll,
1224
1263
  ...result.afterAll
1225
- ].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);
1226
1265
  async function executeDocumentInvocation(options) {
1227
1266
  const { invocation, project, sinks } = options;
1228
1267
  const { document } = invocation;
@@ -1235,6 +1274,7 @@ async function executeDocumentInvocation(options) {
1235
1274
  }, async (documentAttemptIndex)=>{
1236
1275
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1237
1276
  const execution = await runWorkflowDocument(document, {
1277
+ plan: invocation.plan,
1238
1278
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1239
1279
  ...invocation.bindings,
1240
1280
  project,
@@ -1272,6 +1312,18 @@ async function executeDocumentInvocation(options) {
1272
1312
  }).catch(async (error)=>{
1273
1313
  if (error instanceof WorkflowExecutionFailure) {
1274
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
+ });
1275
1327
  attempts.push(partial);
1276
1328
  sinks.documents.push(partial.document);
1277
1329
  sinks.cases.push(...partial.cases.map((outcome)=>({
@@ -1280,6 +1332,7 @@ async function executeDocumentInvocation(options) {
1280
1332
  documentRunId: partial.document.documentRunId
1281
1333
  })));
1282
1334
  try {
1335
+ for (const outcome of partial.cases)for (const attempt of outcome.attempts ?? [])await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1283
1336
  await writeWorkflowDocumentResult(options.runDir, partial.document);
1284
1337
  } catch (publicationError) {
1285
1338
  throw new WorkflowExecutionFailure(partial, [
@@ -1290,6 +1343,29 @@ async function executeDocumentInvocation(options) {
1290
1343
  }
1291
1344
  throw error;
1292
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;
1293
1369
  attempts.push(execution);
1294
1370
  sinks.cases.push(...execution.cases.map((outcome)=>({
1295
1371
  ...outcome,
@@ -1297,15 +1373,30 @@ async function executeDocumentInvocation(options) {
1297
1373
  documentRunId: execution.document.documentRunId
1298
1374
  })));
1299
1375
  sinks.documents.push(execution.document);
1300
- await writeWorkflowDocumentResult(options.runDir, execution.document);
1301
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1302
- const cleanupErrors = [
1303
- ...execution.document.teardownErrors ?? [],
1304
- ...execution.cases.flatMap((outcome)=>(outcome.attempts ?? []).flatMap((attempt)=>attempt.teardownErrors ?? []))
1305
- ];
1306
- if (cleanupErrors.length) throw new WorkflowExecutionFailure(execution, cleanupErrors);
1307
- const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1308
- 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;
1309
1400
  });
1310
1401
  const final = attempts.at(-1);
1311
1402
  if (!final) return;
@@ -1313,6 +1404,18 @@ async function executeDocumentInvocation(options) {
1313
1404
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1314
1405
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1315
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
+ }
1316
1419
  const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1317
1420
  documentId,
1318
1421
  caseId: collectedCase.caseId,
@@ -1371,6 +1474,8 @@ async function runPreparedTestProject(plan) {
1371
1474
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1372
1475
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1373
1476
  const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1477
+ await writeTestRunPlan(runDir, preparedProjects);
1478
+ const journal = createOperationJournal(runDir);
1374
1479
  const rootController = new AbortController();
1375
1480
  setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
1376
1481
  const handleSignal = (signal)=>{
@@ -1400,6 +1505,16 @@ async function runPreparedTestProject(plan) {
1400
1505
  throw error;
1401
1506
  }
1402
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
+ });
1403
1518
  const announceProject = (prepared, projectIndex)=>{
1404
1519
  const { project } = prepared;
1405
1520
  runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
@@ -1424,6 +1539,7 @@ async function runPreparedTestProject(plan) {
1424
1539
  lifecycle
1425
1540
  } : {},
1426
1541
  cases,
1542
+ plans: prepared.plans,
1427
1543
  documents,
1428
1544
  collectionErrors: prepared.collectionErrors
1429
1545
  };
@@ -1542,6 +1658,7 @@ async function runPreparedTestProject(plan) {
1542
1658
  } catch (error) {
1543
1659
  recordInfrastructureError(error);
1544
1660
  } finally{
1661
+ stopObservingOperations();
1545
1662
  process.off('SIGINT', sigint);
1546
1663
  process.off('SIGTERM', sigterm);
1547
1664
  }
@@ -1557,6 +1674,10 @@ async function runPreparedTestProject(plan) {
1557
1674
  const endedAt = new Date();
1558
1675
  const result = {
1559
1676
  schemaVersion: 3,
1677
+ operationLog: {
1678
+ path: journal.path,
1679
+ snapshotAt: endedAt.toISOString()
1680
+ },
1560
1681
  runId,
1561
1682
  startedAt: startedAt.toISOString(),
1562
1683
  endedAt: endedAt.toISOString(),
@@ -1576,7 +1697,6 @@ async function runPreparedTestProject(plan) {
1576
1697
  } : {}
1577
1698
  };
1578
1699
  let completedResult = result;
1579
- let reportIndex;
1580
1700
  const publish = async (operation, path, callback)=>{
1581
1701
  try {
1582
1702
  await callback();
@@ -1607,7 +1727,6 @@ async function runPreparedTestProject(plan) {
1607
1727
  overwrite: false,
1608
1728
  sources: collectTestRunReportSources(completedResult),
1609
1729
  buildRunnerDump: (index)=>{
1610
- reportIndex = index;
1611
1730
  const dump = buildTestRunReportDump(completedResult, index);
1612
1731
  const warn = getDebug('test-runner:report-assembler', {
1613
1732
  console: true
@@ -1625,13 +1744,6 @@ async function runPreparedTestProject(plan) {
1625
1744
  for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
1626
1745
  await publication.publish(completedResult);
1627
1746
  });
1628
- await publish('write-result', (0, external_node_path_.join)((0, external_node_path_.dirname)(summaryPath), 'result.md'), async ()=>{
1629
- const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1630
- completedResult = {
1631
- ...completedResult,
1632
- markdownPath
1633
- };
1634
- });
1635
1747
  await writeSummary();
1636
1748
  if (hasInfrastructureError) throw new WorkflowExecutionFailure(completedResult, infrastructureErrors);
1637
1749
  return completedResult;
@@ -1818,7 +1930,6 @@ const reportTestProjectResult = (result, io, infrastructureErrors = result.error
1818
1930
  io.log(`midscene-test: ${result.summary.passed}/${result.summary.total} cases passed, ${result.summary.failed} failed, ${result.summary.notRun} not run`);
1819
1931
  io.log(`Results: ${result.resultDir}`);
1820
1932
  io.log(`Summary: ${result.summaryPath}`);
1821
- if (result.markdownPath) io.log(`Markdown: ${result.markdownPath}`);
1822
1933
  if (result.reportPath) io.log(`Report: ${result.reportPath}`);
1823
1934
  return result.exitCode;
1824
1935
  };