@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,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,103 +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)=>{
898
- const seconds = Math.round(ms / 1000);
899
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
900
- };
901
- const href = (from, target)=>relative(dirname(from), target).split(sep).map(encodeURIComponent).join('/');
902
- function renderTestResultMarkdown(dump, options = {}) {
903
- const { summary } = dump;
904
- const outcome = 'success' === dump.status ? `✅ **${summary.passed}/${summary.total} passed**` : `❌ **${summary.failed} failed** · ${summary.passed} passed · ${summary.notRun} not run`;
905
- const lines = [
906
- '## Midscene Test',
907
- '',
908
- `${outcome} · ${duration(dump.durationMs)}`,
909
- ''
910
- ];
911
- if (summary.filtered) lines.push(`**Filtered:** ${summary.filtered}`, '');
912
- const infrastructure = [
913
- summary.collectionErrors ? `${summary.collectionErrors} collection errors` : '',
914
- summary.documentFailures ? `${summary.documentFailures} document failures` : '',
915
- summary.projectFailures ? `${summary.projectFailures} project failures` : ''
916
- ].filter(Boolean);
917
- if (infrastructure.length) lines.push(`**Infrastructure:** ${infrastructure.join(' · ')}`, '');
918
- const groups = [];
919
- const errors = [];
920
- const recordSteps = (label, steps)=>{
921
- for (const step of steps)if ('failed' === step.status) errors.push(`${label} / ${step.node}: ${step.error?.message ?? 'Step failed'}`);
922
- };
923
- for (const project of dump.projects){
924
- if (project.lifecycle?.setupError) errors.push(`${project.name}: ${project.lifecycle.setupError.message}`);
925
- for (const error of project.lifecycle?.teardownErrors ?? [])errors.push(`${project.name}: ${error.message}`);
926
- for (const error of project.collectionErrors)errors.push(`${project.name} / ${error.sourcePath}: ${error.error.message}`);
927
- for (const document of project.documents){
928
- const name = document.sourcePath.replace(/\\/g, '/').replace(/\.(?:yaml|yml)$/, '');
929
- const group = {
930
- name: dump.projects.length > 1 ? `${project.name} / ${name}` : name,
931
- rows: []
932
- };
933
- groups.push(group);
934
- recordSteps(`${project.name} / ${document.sourcePath}`, [
935
- ...document.beforeAll,
936
- ...document.afterAll
937
- ]);
938
- for (const error of document.teardownErrors ?? [])errors.push(`${document.sourcePath}: ${error.message}`);
939
- for (const failure of document.hostErrors ?? [])errors.push(`${document.sourcePath} / ${failure.phase}: ${failure.error.message}`);
940
- for (const testCase of document.cases){
941
- const attempt = testCase.attempts.at(-1);
942
- const steps = attempt ? [
943
- ...attempt.beforeEach,
944
- ...attempt.steps,
945
- ...attempt.afterEach
946
- ] : [];
947
- const failedSteps = steps.filter((step)=>'failed' === step.status);
948
- const step = failedSteps.at(-1) ?? [
949
- ...steps
950
- ].reverse().find((item)=>item.agentDetails?.length) ?? steps.at(-1);
951
- const route = new URLSearchParams({
952
- 'runner-page': 'case',
953
- 'runner-project': project.projectId,
954
- 'runner-case': testCase.caseId,
955
- ...step ? {
956
- 'runner-step': step.id
957
- } : {}
958
- });
959
- const target = options.reportHref ? `${options.reportHref}#${route}` : void 0;
960
- const name = target ? `[${result_markdown_text(testCase.name)}](<${target}>)` : result_markdown_text(testCase.name);
961
- group.rows.push(` - ${'success' === testCase.status ? '✅' : 'not-run' === testCase.status ? '⏭️' : '❌'} ${name}`);
962
- }
963
- }
964
- }
965
- for (const diagnostic of dump.diagnostics ?? [])if ('error' === diagnostic.level) errors.push(diagnostic.message);
966
- if (errors.length) lines.push('### Execution errors', '', ...errors.map((error)=>`- ${result_markdown_text(error)}`), '');
967
- for (const group of groups)if (group.rows.length) lines.push(`- ${result_markdown_text(group.name)}`, ...group.rows);
968
- if (!groups.some((group)=>group.rows.length)) lines.push('No cases were reported.');
969
- if (options.reportHref) lines.push('', `[Open the Midscene Test report ↗](<${options.reportHref}>)`);
970
- return `${lines.join('\n')}\n`;
971
- }
972
- const emptyIndex = ()=>({
973
- sources: [],
974
- metrics: {
975
- modelCallCount: 0,
976
- modelTimeMs: 0,
977
- promptTokens: 0,
978
- cachedInputTokens: 0,
979
- completionTokens: 0,
980
- totalTokens: 0
981
- }
982
- });
983
- async function writeTestResultMarkdown(result, index = emptyIndex()) {
984
- const output = join(dirname(result.summaryPath), 'result.md');
985
- const dump = buildTestRunReportDump(result, index);
986
- await writeFile(output, renderTestResultMarkdown(dump, {
987
- ...result.reportPath ? {
988
- reportHref: href(output, result.reportPath)
989
- } : {}
990
- }));
991
- return output;
992
- }
993
1025
  const stepPosition = (info)=>'case' === info.scope ? info.case : info.document;
994
1026
  const formatStep = (info)=>{
995
1027
  const position = stepPosition(info);
@@ -998,6 +1030,7 @@ const formatStep = (info)=>{
998
1030
  };
999
1031
  const formatStepResult = (info, result)=>{
1000
1032
  const indent = 'case' === info.scope ? ' ' : ' ';
1033
+ if ('not-run' === result.status) return `${indent}– ${formatStep(info)} (not run: ${result.notRunReason})`;
1001
1034
  const symbol = 'success' === result.status ? '✓' : '✗';
1002
1035
  const error = result.error ? ` — ${result.error.message}` : '';
1003
1036
  const continuation = result.continuedAfterError ? '; continuing' : '';
@@ -1007,11 +1040,11 @@ const caseHasFatalError = (outcome)=>(outcome.attempts ?? []).some((attempt)=>[
1007
1040
  ...attempt.beforeEach,
1008
1041
  ...attempt.steps,
1009
1042
  ...attempt.afterEach
1010
- ].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));
1011
1044
  const documentHasFatalError = (result)=>[
1012
1045
  ...result.beforeAll,
1013
1046
  ...result.afterAll
1014
- ].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);
1015
1048
  async function executeDocumentInvocation(options) {
1016
1049
  const { invocation, project, sinks } = options;
1017
1050
  const { document } = invocation;
@@ -1024,6 +1057,7 @@ async function executeDocumentInvocation(options) {
1024
1057
  }, async (documentAttemptIndex)=>{
1025
1058
  if (documentRetry) options.onProgress(` file attempt ${documentAttemptIndex + 1}/${invocation.retry.count + 1}: ${document.sourcePath}`);
1026
1059
  const execution = await runWorkflowDocument(document, {
1060
+ plan: invocation.plan,
1027
1061
  documentAttemptIndex: documentRetry ? documentAttemptIndex : void 0,
1028
1062
  ...invocation.bindings,
1029
1063
  project,
@@ -1061,6 +1095,18 @@ async function executeDocumentInvocation(options) {
1061
1095
  }).catch(async (error)=>{
1062
1096
  if (error instanceof WorkflowExecutionFailure) {
1063
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
+ });
1064
1110
  attempts.push(partial);
1065
1111
  sinks.documents.push(partial.document);
1066
1112
  sinks.cases.push(...partial.cases.map((outcome)=>({
@@ -1069,6 +1115,7 @@ async function executeDocumentInvocation(options) {
1069
1115
  documentRunId: partial.document.documentRunId
1070
1116
  })));
1071
1117
  try {
1118
+ for (const outcome of partial.cases)for (const attempt of outcome.attempts ?? [])await writeCaseAttemptResult(options.runDir, project.projectId, document.documentId, attempt);
1072
1119
  await writeWorkflowDocumentResult(options.runDir, partial.document);
1073
1120
  } catch (publicationError) {
1074
1121
  throw new WorkflowExecutionFailure(partial, [
@@ -1079,6 +1126,29 @@ async function executeDocumentInvocation(options) {
1079
1126
  }
1080
1127
  throw error;
1081
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;
1082
1152
  attempts.push(execution);
1083
1153
  sinks.cases.push(...execution.cases.map((outcome)=>({
1084
1154
  ...outcome,
@@ -1086,15 +1156,30 @@ async function executeDocumentInvocation(options) {
1086
1156
  documentRunId: execution.document.documentRunId
1087
1157
  })));
1088
1158
  sinks.documents.push(execution.document);
1089
- await writeWorkflowDocumentResult(options.runDir, execution.document);
1090
- if (documentHasFatalError(execution.document)) options.markProjectFatal();
1091
- const cleanupErrors = [
1092
- ...execution.document.teardownErrors ?? [],
1093
- ...execution.cases.flatMap((outcome)=>(outcome.attempts ?? []).flatMap((attempt)=>attempt.teardownErrors ?? []))
1094
- ];
1095
- if (cleanupErrors.length) throw new WorkflowExecutionFailure(execution, cleanupErrors);
1096
- const failed = 'failed' === execution.document.status || execution.cases.some((item)=>'success' !== item.status);
1097
- 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;
1098
1183
  });
1099
1184
  const final = attempts.at(-1);
1100
1185
  if (!final) return;
@@ -1102,6 +1187,18 @@ async function executeDocumentInvocation(options) {
1102
1187
  if (documentRetry && failedCases) options.addFailedCases(failedCases);
1103
1188
  else if ('failed' === final.document.status && 0 === failedCases) options.addFailedCases(Math.max(1, failedCases));
1104
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
+ }
1105
1202
  const asNotRun = (documentId, collectedCase, projectName, reason)=>({
1106
1203
  documentId,
1107
1204
  caseId: collectedCase.caseId,
@@ -1160,6 +1257,8 @@ async function runPreparedTestProject(plan) {
1160
1257
  const totalErrors = preparedProjects.reduce((total, prepared)=>total + prepared.collectionErrors.length, 0);
1161
1258
  progress(`midscene-test: preflighted ${preparedProjects.length} projects, ${totalDocuments} documents, ${totalCases} cases, ${totalErrors} collection errors`);
1162
1259
  const effectiveConcurrency = Math.min(definition.test.maxConcurrency, preparedProjects.length);
1260
+ await writeTestRunPlan(runDir, preparedProjects);
1261
+ const journal = createOperationJournal(runDir);
1163
1262
  const rootController = new AbortController();
1164
1263
  setMaxListeners(Math.max(10, effectiveConcurrency + 1), rootController.signal);
1165
1264
  const handleSignal = (signal)=>{
@@ -1189,6 +1288,16 @@ async function runPreparedTestProject(plan) {
1189
1288
  throw error;
1190
1289
  }
1191
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
+ });
1192
1301
  const announceProject = (prepared, projectIndex)=>{
1193
1302
  const { project } = prepared;
1194
1303
  runInfrastructureCallback(()=>progress(`[project ${projectIndex + 1}/${preparedProjects.length}] ${project.name}`));
@@ -1213,6 +1322,7 @@ async function runPreparedTestProject(plan) {
1213
1322
  lifecycle
1214
1323
  } : {},
1215
1324
  cases,
1325
+ plans: prepared.plans,
1216
1326
  documents,
1217
1327
  collectionErrors: prepared.collectionErrors
1218
1328
  };
@@ -1331,6 +1441,7 @@ async function runPreparedTestProject(plan) {
1331
1441
  } catch (error) {
1332
1442
  recordInfrastructureError(error);
1333
1443
  } finally{
1444
+ stopObservingOperations();
1334
1445
  process.off('SIGINT', sigint);
1335
1446
  process.off('SIGTERM', sigterm);
1336
1447
  }
@@ -1346,6 +1457,10 @@ async function runPreparedTestProject(plan) {
1346
1457
  const endedAt = new Date();
1347
1458
  const result = {
1348
1459
  schemaVersion: 3,
1460
+ operationLog: {
1461
+ path: journal.path,
1462
+ snapshotAt: endedAt.toISOString()
1463
+ },
1349
1464
  runId,
1350
1465
  startedAt: startedAt.toISOString(),
1351
1466
  endedAt: endedAt.toISOString(),
@@ -1365,7 +1480,6 @@ async function runPreparedTestProject(plan) {
1365
1480
  } : {}
1366
1481
  };
1367
1482
  let completedResult = result;
1368
- let reportIndex;
1369
1483
  const publish = async (operation, path, callback)=>{
1370
1484
  try {
1371
1485
  await callback();
@@ -1396,7 +1510,6 @@ async function runPreparedTestProject(plan) {
1396
1510
  overwrite: false,
1397
1511
  sources: collectTestRunReportSources(completedResult),
1398
1512
  buildRunnerDump: (index)=>{
1399
- reportIndex = index;
1400
1513
  const dump = buildTestRunReportDump(completedResult, index);
1401
1514
  const warn = getDebug('test-runner:report-assembler', {
1402
1515
  console: true
@@ -1414,13 +1527,6 @@ async function runPreparedTestProject(plan) {
1414
1527
  for (const publication of plan.publications)await publish(publication.operation, publication.path, async ()=>{
1415
1528
  await publication.publish(completedResult);
1416
1529
  });
1417
- await publish('write-result', join(dirname(summaryPath), 'result.md'), async ()=>{
1418
- const markdownPath = await writeTestResultMarkdown(completedResult, reportIndex);
1419
- completedResult = {
1420
- ...completedResult,
1421
- markdownPath
1422
- };
1423
- });
1424
1530
  await writeSummary();
1425
1531
  if (hasInfrastructureError) throw new WorkflowExecutionFailure(completedResult, infrastructureErrors);
1426
1532
  return completedResult;