@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,11 +1,11 @@
1
1
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { require as api_require } from "tsx/cjs/api";
4
- import { NodeRegistry, WorkflowError, WorkflowExecutionFailure, WorkflowParseError, WorkflowPublicationError, asExecutionError, buildTestRunReportDump, classifyWorkflowDocumentFormat, collectTestRunReportSources, createProjectRuntime, isFatalDeviceError, normalizeSteps, runConcurrentJobs, runDocumentAttempts, runWorkflowDocument } from "@midscene/core/internal/test-runner";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
4
+ 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";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
6
  import { globSync } from "tinyglobby";
7
- import { JSON_SCHEMA, load } from "js-yaml";
8
7
  import { createHash, randomUUID } from "node:crypto";
8
+ import { JSON_SCHEMA, load } from "js-yaml";
9
9
  import { mkdir, writeFile } from "node:fs/promises";
10
10
  import { setMaxListeners } from "node:events";
11
11
  import { TestRunReportAssembler, calculateTestRunHealth } from "@midscene/core/report";
@@ -304,6 +304,69 @@ const createDocumentInvocationId = (projectId, sourcePath, invocationIndex = 0)=
304
304
  ] : []
305
305
  ])).digest('hex');
306
306
  const normalize_normalizeSteps = normalizeSteps;
307
+ function parseWorkflowSource(source) {
308
+ const stack = [];
309
+ const nodes = [];
310
+ const sensitiveNodes = new Set();
311
+ const sensitiveValues = new Set();
312
+ const redactedPaths = new Set();
313
+ const sourceOffset = 0xfeff === source.charCodeAt(0) ? 1 : 0;
314
+ const parsed = load(source, {
315
+ schema: JSON_SCHEMA,
316
+ listener (event, state) {
317
+ if ('open' === event) return void stack.push({
318
+ start: state.position + sourceOffset,
319
+ end: 0,
320
+ value: null,
321
+ children: []
322
+ });
323
+ const node = stack.pop();
324
+ node.end = state.position + sourceOffset;
325
+ node.value = state.result;
326
+ stack.at(-1)?.children.push(node);
327
+ nodes.push(node);
328
+ if ('mapping' !== state.kind) return;
329
+ for(let index = 0; index < node.children.length; index += 2){
330
+ const key = node.children[index].value;
331
+ const value = node.children[index + 1];
332
+ if ('string' == typeof key && isReportSecretKey(key) && value) {
333
+ sensitiveNodes.add(value);
334
+ if (null !== value.value) sensitiveValues.add(value.value);
335
+ redactedPaths.add(`$..[${JSON.stringify(key)}]`);
336
+ }
337
+ }
338
+ }
339
+ });
340
+ const ranges = nodes.filter((node)=>sensitiveNodes.has(node) || sensitiveValues.has(node.value)).sort((left, right)=>left.start - right.start || right.end - left.end);
341
+ let offset = 0;
342
+ let redacted = '';
343
+ for (const range of ranges){
344
+ if (range.start < offset) continue;
345
+ const text = source.slice(range.start, range.end);
346
+ const leading = text.match(/^\s*/)?.[0] ?? '';
347
+ const trailing = text.slice(leading.length).match(/\s*$/)?.[0] ?? '';
348
+ redacted += `${source.slice(offset, range.start)}${leading}"[REDACTED]"${trailing}`;
349
+ offset = range.end;
350
+ }
351
+ redacted += source.slice(offset);
352
+ const value = sanitizeReportText(redacted, 65536);
353
+ return {
354
+ parsed,
355
+ sourceYaml: {
356
+ value,
357
+ ...redactedPaths.size ? {
358
+ redactedPaths: [
359
+ ...redactedPaths
360
+ ]
361
+ } : {},
362
+ ...value !== redacted ? {
363
+ truncatedPaths: [
364
+ '$'
365
+ ]
366
+ } : {}
367
+ }
368
+ };
369
+ }
307
370
  const interpolationExpression = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}|\$\{([^{}]+)\}/g;
308
371
  const exactEnvExpression = /^\$\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/;
309
372
  const exactProjectExpression = /^\$\{([^{}]+)\}$/;
@@ -378,16 +441,15 @@ const collect_rejectUnknownKeys = (value, allowed, label)=>{
378
441
  const createCaseId = (projectId, sourcePath, caseIndex)=>createCaseInvocationId(projectId, sourcePath, caseIndex);
379
442
  const createWorkflowDocumentId = (projectId, sourcePath)=>createDocumentInvocationId(projectId, sourcePath);
380
443
  function collectWorkflowDocument(source, options) {
381
- let parsed;
444
+ let snapshot;
382
445
  try {
383
- parsed = load(readFileSync(source.absolutePath, 'utf8'), {
384
- schema: JSON_SCHEMA
385
- });
446
+ snapshot = parseWorkflowSource(readFileSync(source.absolutePath, 'utf8'));
386
447
  } catch (error) {
387
448
  throw new WorkflowParseError(`Failed to parse workflow document "${source.sourcePath}".`, {
388
449
  sourcePath: source.sourcePath
389
450
  }, error);
390
451
  }
452
+ const { parsed, sourceYaml } = snapshot;
391
453
  if (!isMapping(parsed)) throw new WorkflowParseError('Workflow document must be a mapping.');
392
454
  const format = classifyWorkflowDocumentFormat(parsed);
393
455
  if ('legacy' === format || 'legacy-batch' === format || 'mixed' === format) throw new NativeWorkflowFormatError(format, source.sourcePath);
@@ -402,6 +464,7 @@ function collectWorkflowDocument(source, options) {
402
464
  const sourcePath = source.sourcePath.split(sep).join('/');
403
465
  const resolveStepVariables = (step, phase, stepIndex, caseIndex)=>({
404
466
  ...step,
467
+ templateInput: step.input,
405
468
  input: resolveWorkflowVariables(step.input, {
406
469
  variables: options.variables,
407
470
  env: options.env ?? process.env,
@@ -487,6 +550,7 @@ function collectWorkflowDocument(source, options) {
487
550
  documentId: createWorkflowDocumentId(source.projectId, sourcePath),
488
551
  projectId: source.projectId,
489
552
  sourcePath,
553
+ sourceYaml,
490
554
  lifecycle,
491
555
  cases
492
556
  };
@@ -501,6 +565,15 @@ const writeJson = async (path, value)=>{
501
565
  throw new WorkflowPublicationError('write-result', path, error);
502
566
  }
503
567
  };
568
+ const writeTestRunPlan = (runDir, projects)=>writeJson(join(runDir, 'plan.json'), {
569
+ schemaVersion: 1,
570
+ createdAt: new Date().toISOString(),
571
+ projects: projects.map((item)=>({
572
+ projectId: item.project.projectId,
573
+ name: item.project.name,
574
+ documents: item.plans ?? []
575
+ }))
576
+ });
504
577
  const toPosix = (value)=>value.split(sep).join('/');
505
578
  const caseAttemptResultPath = (projectId, documentId, result)=>toPosix(join(projectId, 'documents', documentId, 'cases', result.caseId, `${result.runId}.json`));
506
579
  const workflowDocumentResultPath = (result)=>toPosix(join(result.projectId, 'documents', result.documentId, void 0 === result.attemptIndex ? 'document.json' : `${result.documentRunId}.json`));
@@ -530,7 +603,7 @@ const relativeToSummary = (summaryPath, target)=>{
530
603
  const value = relative(dirname(summaryPath), target);
531
604
  return toPosix(value || '.');
532
605
  };
533
- const errorJson = (error)=>error instanceof Error && 'toJSON' in error ? error.toJSON() : error;
606
+ const errorJson = sanitizeReportError;
534
607
  const writeTestProjectRunResult = (options)=>{
535
608
  const { result } = options;
536
609
  const fact = (path)=>toPosix(path);
@@ -550,13 +623,17 @@ const writeTestProjectRunResult = (options)=>{
550
623
  configPath: options.configPath
551
624
  } : {},
552
625
  factsRoot: '.',
626
+ plan: 'plan.json',
627
+ ...result.operationLog ? {
628
+ operationLog: {
629
+ ...result.operationLog,
630
+ path: relativeToSummary(result.summaryPath, result.operationLog.path)
631
+ }
632
+ } : {},
553
633
  reportDir: relativeToSummary(result.summaryPath, result.reportDir),
554
634
  ...result.reportPath ? {
555
635
  report: relativeToSummary(result.summaryPath, result.reportPath)
556
636
  } : {},
557
- ...result.markdownPath ? {
558
- markdown: relativeToSummary(result.summaryPath, result.markdownPath)
559
- } : {},
560
637
  summary: result.summary,
561
638
  projects: result.projects.map((project)=>({
562
639
  projectId: project.projectId,
@@ -574,6 +651,12 @@ const writeTestProjectRunResult = (options)=>{
574
651
  startedAt: project.lifecycle.startedAt,
575
652
  endedAt: project.lifecycle.endedAt,
576
653
  durationMs: project.lifecycle.durationMs,
654
+ ...project.lifecycle.cleanup ? {
655
+ cleanup: project.lifecycle.cleanup
656
+ } : {},
657
+ ...project.lifecycle.pendingOperations ? {
658
+ pendingOperations: project.lifecycle.pendingOperations
659
+ } : {},
577
660
  ...project.lifecycle.setupError ? {
578
661
  setupError: errorJson(project.lifecycle.setupError)
579
662
  } : {},
@@ -600,6 +683,18 @@ const writeTestProjectRunResult = (options)=>{
600
683
  attemptId: attempt.runId,
601
684
  attemptIndex: attempt.attemptIndex,
602
685
  status: attempt.status,
686
+ ...attempt.cleanup ? {
687
+ cleanup: attempt.cleanup
688
+ } : {},
689
+ ...attempt.pendingOperations ? {
690
+ pendingOperations: attempt.pendingOperations
691
+ } : {},
692
+ ...attempt.executionStatus ? {
693
+ executionStatus: attempt.executionStatus
694
+ } : {},
695
+ ...attempt.retryDecision ? {
696
+ retryDecision: attempt.retryDecision
697
+ } : {},
603
698
  resultFile: fact(caseAttemptResultPath(project.projectId, outcome.documentId, attempt)),
604
699
  ...attempt.reportPaths?.length ? {
605
700
  reports: attempt.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
@@ -615,6 +710,18 @@ const writeTestProjectRunResult = (options)=>{
615
710
  },
616
711
  sourcePath: document.sourcePath,
617
712
  status: document.status,
713
+ ...document.cleanup ? {
714
+ cleanup: document.cleanup
715
+ } : {},
716
+ ...document.pendingOperations ? {
717
+ pendingOperations: document.pendingOperations
718
+ } : {},
719
+ ...document.executionStatus ? {
720
+ executionStatus: document.executionStatus
721
+ } : {},
722
+ ...document.retryDecision ? {
723
+ retryDecision: document.retryDecision
724
+ } : {},
618
725
  resultFile: fact(workflowDocumentResultPath(document)),
619
726
  ...document.reportPaths?.length ? {
620
727
  reports: document.reportPaths.map((path)=>relativeToSummary(result.summaryPath, path))
@@ -754,6 +861,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
754
861
  });
755
862
  const collectionErrors = [];
756
863
  const invocations = [];
864
+ const plans = [];
757
865
  let filteredCaseCount = 0;
758
866
  if (0 === sources.length) {
759
867
  const error = asCollectionError(project.projectId, project.name, '<project>', new WorkflowParseError(`No workflow YAML files found for project "${project.name}" in ${projectRoot}.`, {
@@ -767,7 +875,17 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
767
875
  const collected = await options.collectDocument?.(source, project);
768
876
  if (collected) {
769
877
  filteredCaseCount += collected.filteredCaseCount;
770
- if (collected.invocation) invocations.push(collected.invocation);
878
+ if (collected.invocation) {
879
+ const plan = buildTestRunReportPlan(collected.invocation.document, {
880
+ defaultTimeoutMs: collected.invocation.defaultTimeoutMs,
881
+ retry: collected.invocation.retry
882
+ });
883
+ invocations.push({
884
+ ...collected.invocation,
885
+ plan
886
+ });
887
+ plans.push(plan);
888
+ }
771
889
  continue;
772
890
  }
773
891
  const document = collectWorkflowDocument(source, {
@@ -779,8 +897,18 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
779
897
  document,
780
898
  filtered: 0
781
899
  } : filterDocumentCases(document, project);
900
+ const plan = buildTestRunReportPlan(document, {
901
+ selectedCaseIds: filtered.document?.cases.map((item)=>item.caseId) ?? [],
902
+ defaultTimeoutMs,
903
+ retry: {
904
+ scope: 'case',
905
+ count: project.retry
906
+ }
907
+ });
908
+ plans.push(plan);
782
909
  filteredCaseCount += filtered.filtered;
783
910
  if (filtered.document) invocations.push({
911
+ plan,
784
912
  document: filtered.document,
785
913
  retry: {
786
914
  scope: 'case',
@@ -813,6 +941,7 @@ async function prepareProject(project, projectRoot, runDir, defaultTimeoutMs, op
813
941
  sources,
814
942
  documents,
815
943
  invocations,
944
+ plans,
816
945
  collectionErrors,
817
946
  selectedCaseCount: documents.reduce((total, document)=>total + document.cases.length, 0),
818
947
  filteredCaseCount
@@ -893,99 +1022,6 @@ async function prepareTestRun(options) {
893
1022
  onProgress: options.onProgress ?? (()=>{})
894
1023
  };
895
1024
  }
896
- 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, ' ');
897
- const duration = (ms)=>`${(ms / 1000).toFixed(1)}s`;
898
- const href = (from, target)=>relative(dirname(from), target).split(sep).map(encodeURIComponent).join('/');
899
- function renderTestResultMarkdown(dump, options = {}) {
900
- const { summary } = dump;
901
- const lines = [
902
- `## Midscene Test · ${'success' === dump.status ? 'passed' : 'failed'}`,
903
- '',
904
- `**Cases:** ${summary.total} total · ${summary.passed} passed · ${summary.failed} failed · ${summary.notRun} not run`,
905
- '',
906
- `**Duration:** ${duration(dump.durationMs)} · **Filtered:** ${summary.filtered}`,
907
- '',
908
- `**Infrastructure:** ${summary.collectionErrors} collection errors · ${summary.documentFailures} document failures · ${summary.projectFailures} project failures`,
909
- '',
910
- ...options.reportHref ? [
911
- `[Open the Midscene Test report](<${options.reportHref}>)`,
912
- ''
913
- ] : []
914
- ];
915
- const rows = [];
916
- const errors = [];
917
- const recordSteps = (label, steps)=>{
918
- for (const step of steps)if ('failed' === step.status) errors.push(`${label} / ${step.node}: ${step.error?.message ?? 'Step failed'}`);
919
- };
920
- for (const project of dump.projects){
921
- if (project.lifecycle?.setupError) errors.push(`${project.name}: ${project.lifecycle.setupError.message}`);
922
- for (const error of project.lifecycle?.teardownErrors ?? [])errors.push(`${project.name}: ${error.message}`);
923
- for (const error of project.collectionErrors)errors.push(`${project.name} / ${error.sourcePath}: ${error.error.message}`);
924
- for (const document of project.documents){
925
- recordSteps(`${project.name} / ${document.sourcePath}`, [
926
- ...document.beforeAll,
927
- ...document.afterAll
928
- ]);
929
- for (const error of document.teardownErrors ?? [])errors.push(`${document.sourcePath}: ${error.message}`);
930
- for (const failure of document.hostErrors ?? [])errors.push(`${document.sourcePath} / ${failure.phase}: ${failure.error.message}`);
931
- for (const testCase of document.cases){
932
- const attempt = testCase.attempts.at(-1);
933
- const steps = attempt ? [
934
- ...attempt.beforeEach,
935
- ...attempt.steps,
936
- ...attempt.afterEach
937
- ] : [];
938
- const failedSteps = steps.filter((step)=>'failed' === step.status);
939
- const step = failedSteps.at(-1) ?? [
940
- ...steps
941
- ].reverse().find((item)=>item.agentDetails?.length) ?? steps.at(-1);
942
- const route = new URLSearchParams({
943
- 'runner-page': 'case',
944
- 'runner-project': project.projectId,
945
- 'runner-case': testCase.caseId,
946
- ...step ? {
947
- 'runner-step': step.id
948
- } : {}
949
- });
950
- const target = options.reportHref ? `${options.reportHref}#${route}` : void 0;
951
- const name = target ? `[${result_markdown_text(testCase.name)}](<${target}>)` : result_markdown_text(testCase.name);
952
- rows.push({
953
- failed: 'success' !== testCase.status,
954
- row: `- ${name} — ${testCase.status}`
955
- });
956
- }
957
- }
958
- }
959
- for (const diagnostic of dump.diagnostics ?? [])if ('error' === diagnostic.level) errors.push(diagnostic.message);
960
- if (errors.length) lines.push('### Execution errors', '', ...errors.map((error)=>`- ${result_markdown_text(error)}`), '');
961
- lines.push('### Cases', '');
962
- lines.push(...rows.filter((row)=>row.failed).map((row)=>row.row));
963
- const passed = rows.filter((row)=>!row.failed);
964
- if (passed.length) lines.push(...passed.map((row)=>row.row));
965
- if (!rows.length) lines.push('', 'No cases were reported.');
966
- return `${lines.join('\n')}\n`;
967
- }
968
- const emptyIndex = ()=>({
969
- sources: [],
970
- metrics: {
971
- modelCallCount: 0,
972
- modelTimeMs: 0,
973
- promptTokens: 0,
974
- cachedInputTokens: 0,
975
- completionTokens: 0,
976
- totalTokens: 0
977
- }
978
- });
979
- async function writeTestResultMarkdown(result, index = emptyIndex()) {
980
- const output = join(dirname(result.summaryPath), 'result.md');
981
- const dump = buildTestRunReportDump(result, index);
982
- await writeFile(output, renderTestResultMarkdown(dump, {
983
- ...result.reportPath ? {
984
- reportHref: href(output, result.reportPath)
985
- } : {}
986
- }));
987
- return output;
988
- }
989
1025
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
990
1026
  const formatStep = (info)=>{
991
1027
  const position = stepPosition(info);
@@ -994,6 +1030,7 @@ const formatStep = (info)=>{
994
1030
  };
995
1031
  const formatStepResult = (info, result)=>{
996
1032
  const indent = 'case' === info.scope ? ' ' : ' ';
1033
+ if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
997
1034
  const symbol = 'success' === result.status ? '✓' : '✗';
998
1035
  const error = result.error ? ` — ${result.error.message}` : '';
999
1036
  const continuation = result.continuedAfterError ? '; continuing' : '';
@@ -1003,11 +1040,11 @@ const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
1003
1040
  ...attempt.beforeEach,
1004
1041
  ...attempt.steps,
1005
1042
  ...attempt.afterEach
1006
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError));
1043
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (attempt.teardownErrors ?? []).some(isFatalDeviceError) || !!attempt.executionErrors?.some(isResourceRecoveryError));
1007
1044
  const documentHasFatalError = (result)=>[
1008
1045
  ...result.beforeAll,
1009
1046
  ...result.afterAll
1010
- ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError);
1047
+ ].some((step)=>step.error && isFatalDeviceError(step.error)) || (result.teardownErrors ?? []).some(isFatalDeviceError) || !!result.executionErrors?.some(isResourceRecoveryError);
1011
1048
  async function executeDocumentInvocation(options) {
1012
1049
  const { invocation, project, sinks } = options;
1013
1050
  const { document } = invocation;
@@ -1020,6 +1057,7 @@ async function executeDocumentInvocation(options) {
1020
1057
  }, async (documentAttemptIndex)=>{
1021
1058
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1022
1059
  const execution = await runWorkflowDocument(document, {
1060
+ plan: invocation.plan,
1023
1061
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1024
1062
  ...invocation.bindings,
1025
1063
  project,
@@ -1057,6 +1095,18 @@ async function executeDocumentInvocation(options) {
1057
1095
  }).catch(async (error)=>{
1058
1096
  if (error instanceof WorkflowExecutionFailure) {
1059
1097
  const partial = error.result;
1098
+ 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)));
1099
+ if (resourceOnly) {
1100
+ if (error.errors.some(isResourceRecoveryError)) options.markProjectFatal();
1101
+ return partial;
1102
+ }
1103
+ if (documentRetry) partial.document.retryDecision = decideRetry({
1104
+ scope: 'document',
1105
+ retryCount: invocation.retry.count,
1106
+ attemptIndex: documentAttemptIndex,
1107
+ status: 'failed',
1108
+ infrastructureError: true
1109
+ });
1060
1110
  attempts.push(partial);
1061
1111
  sinks.documents.push(partial.document);
1062
1112
  sinks.cases.push(...partial.cases.map((outcome)=>({
@@ -1065,6 +1115,7 @@ async function executeDocumentInvocation(options) {
1065
1115
  documentRunId: partial.document.documentRunId
1066
1116
  })));
1067
1117
  try {
1118
+ for (const outcome of partial.cases)for (const attempt of outcome.attempts ?? [])await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1068
1119
  await writeWorkflowDocumentResult(options.runDir, partial.document);
1069
1120
  } catch (publicationError) {
1070
1121
  throw new WorkflowExecutionFailure(partial, [
@@ -1075,6 +1126,29 @@ async function executeDocumentInvocation(options) {
1075
1126
  }
1076
1127
  throw error;
1077
1128
  });
1129
+ if (documentHasFatalError(execution.document)) options.markProjectFatal();
1130
+ const owners = [
1131
+ execution.document,
1132
+ ...execution.cases.flatMap((outcome)=>outcome.attempts ?? [])
1133
+ ];
1134
+ const cleanupPending = owners.some((owner)=>owner.cleanup?.status === 'pending');
1135
+ const operationPending = owners.some((owner)=>!!owner.pendingOperations?.length);
1136
+ const cleanupFailed = owners.some((owner)=>owner.cleanup?.status === 'failed' || !!owner.teardownErrors?.length || 'hostErrors' in owner && owner.hostErrors?.some(({ phase })=>'cleanup' === phase));
1137
+ if (cleanupPending || cleanupFailed || operationPending) options.markProjectFatal();
1138
+ const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1139
+ const decision = decideRetry({
1140
+ scope: 'document',
1141
+ retryCount: invocation.retry.count,
1142
+ attemptIndex: documentAttemptIndex,
1143
+ status: failed ? 'failed' : 'success',
1144
+ cleanupPending,
1145
+ cleanupFailed,
1146
+ operationPending,
1147
+ fatalError: options.isProjectFatal(),
1148
+ interrupted: options.signal.aborted || options.rootSignal.aborted,
1149
+ shouldStop: options.shouldBail()
1150
+ });
1151
+ if (documentRetry) execution.document.retryDecision = decision;
1078
1152
  attempts.push(execution);
1079
1153
  sinks.cases.push(...execution.cases.map((outcome)=>({
1080
1154
  ...outcome,
@@ -1082,15 +1156,30 @@ async function executeDocumentInvocation(options) {
1082
1156
  documentRunId: execution.document.documentRunId
1083
1157
  })));
1084
1158
  sinks.documents.push(execution.document);
1085
- await writeWorkflowDocumentResult(options.runDir, execution.document);
1086
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1087
- const cleanupErrors = [
1088
- ...execution.document.teardownErrors ?? [],
1089
- ...execution.cases.flatMap((outcome)=>(outcome.attempts ?? []).flatMap((attempt)=>attempt.teardownErrors ?? []))
1090
- ];
1091
- if (cleanupErrors.length) throw new WorkflowExecutionFailure(execution, cleanupErrors);
1092
- const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1093
- return failed ? 'failed' : 'success';
1159
+ try {
1160
+ for (const outcome of execution.cases)for (const attempt of outcome.attempts ?? [])await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1161
+ await writeWorkflowDocumentResult(options.runDir, execution.document);
1162
+ } catch (error) {
1163
+ execution.document.status = 'failed';
1164
+ execution.document.hostErrors = [
1165
+ ...execution.document.hostErrors ?? [],
1166
+ {
1167
+ phase: 'publication',
1168
+ error
1169
+ }
1170
+ ];
1171
+ if (documentRetry) execution.document.retryDecision = decideRetry({
1172
+ scope: 'document',
1173
+ retryCount: invocation.retry.count,
1174
+ attemptIndex: documentAttemptIndex,
1175
+ status: 'failed',
1176
+ infrastructureError: true
1177
+ });
1178
+ throw new WorkflowExecutionFailure(execution, [
1179
+ error
1180
+ ]);
1181
+ }
1182
+ return 'retry' === decision.decision ? 'failed' : void 0;
1094
1183
  });
1095
1184
  const final = attempts.at(-1);
1096
1185
  if (!final) return;
@@ -1098,6 +1187,18 @@ async function executeDocumentInvocation(options) {
1098
1187
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1099
1188
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1100
1189
  }
1190
+ function createOperationJournal(runDir) {
1191
+ const path = join(runDir, 'operations.jsonl');
1192
+ writeFileSync(path, '', {
1193
+ flag: 'wx'
1194
+ });
1195
+ return {
1196
+ path,
1197
+ append (event) {
1198
+ appendFileSync(path, `${JSON.stringify(event)}\n`);
1199
+ }
1200
+ };
1201
+ }
1101
1202
  const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1102
1203
  documentId,
1103
1204
  caseId: collectedCase.caseId,
@@ -1156,6 +1257,8 @@ async function runPreparedTestProject(plan) {
1156
1257
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1157
1258
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1158
1259
  const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1260
+ await writeTestRunPlan(runDir, preparedProjects);
1261
+ const journal = createOperationJournal(runDir);
1159
1262
  const rootController = new AbortController();
1160
1263
  setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
1161
1264
  const handleSignal = (signal)=>{
@@ -1185,6 +1288,16 @@ async function runPreparedTestProject(plan) {
1185
1288
  throw error;
1186
1289
  }
1187
1290
  };
1291
+ const stopObservingOperations = observeResourceOperations(rootController.signal, (event)=>{
1292
+ try {
1293
+ journal.append(event);
1294
+ } catch (error) {
1295
+ recordInfrastructureError(new WorkflowPublicationError('write-result', journal.path, error));
1296
+ getDebug('test-runner:operations', {
1297
+ console: true
1298
+ })(`Could not persist operation event: ${String(error)}`);
1299
+ }
1300
+ });
1188
1301
  const announceProject = (prepared, projectIndex)=>{
1189
1302
  const { project } = prepared;
1190
1303
  runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
@@ -1209,6 +1322,7 @@ async function runPreparedTestProject(plan) {
1209
1322
  lifecycle
1210
1323
  } : {},
1211
1324
  cases,
1325
+ plans: prepared.plans,
1212
1326
  documents,
1213
1327
  collectionErrors: prepared.collectionErrors
1214
1328
  };
@@ -1327,6 +1441,7 @@ async function runPreparedTestProject(plan) {
1327
1441
  } catch (error) {
1328
1442
  recordInfrastructureError(error);
1329
1443
  } finally{
1444
+ stopObservingOperations();
1330
1445
  process.off('SIGINT', sigint);
1331
1446
  process.off('SIGTERM', sigterm);
1332
1447
  }
@@ -1342,6 +1457,10 @@ async function runPreparedTestProject(plan) {
1342
1457
  const endedAt = new Date();
1343
1458
  const result = {
1344
1459
  schemaVersion: 3,
1460
+ operationLog: {
1461
+ path: journal.path,
1462
+ snapshotAt: endedAt.toISOString()
1463
+ },
1345
1464
  runId,
1346
1465
  startedAt: startedAt.toISOString(),
1347
1466
  endedAt: endedAt.toISOString(),
@@ -1361,7 +1480,6 @@ async function runPreparedTestProject(plan) {
1361
1480
  } : {}
1362
1481
  };
1363
1482
  let completedResult = result;
1364
- let reportIndex;
1365
1483
  const publish = async (operation, path, callback)=>{
1366
1484
  try {
1367
1485
  await callback();
@@ -1392,7 +1510,6 @@ async function runPreparedTestProject(plan) {
1392
1510
  overwrite: false,
1393
1511
  sources: collectTestRunReportSources(completedResult),
1394
1512
  buildRunnerDump: (index)=>{
1395
- reportIndex = index;
1396
1513
  const dump = buildTestRunReportDump(completedResult, index);
1397
1514
  const warn = getDebug('test-runner:report-assembler', {
1398
1515
  console: true
@@ -1410,13 +1527,6 @@ async function runPreparedTestProject(plan) {
1410
1527
  for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
1411
1528
  await publication.publish(completedResult);
1412
1529
  });
1413
- await publish('write-result', join(dirname(summaryPath), 'result.md'), async ()=>{
1414
- const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1415
- completedResult = {
1416
- ...completedResult,
1417
- markdownPath
1418
- };
1419
- });
1420
1530
  await writeSummary();
1421
1531
  if (hasInfrastructureError) throw new WorkflowExecutionFailure(completedResult, infrastructureErrors);
1422
1532
  return completedResult;