@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.
- package/dist/es/cli/index.mjs +235 -125
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/index.mjs +72 -102
- package/dist/es/index.mjs.map +1 -1
- package/dist/es/midscene/index.mjs +54 -30
- package/dist/es/midscene/index.mjs.map +1 -1
- package/dist/es/test-cli.mjs +237 -126
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/cli/index.js +233 -123
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/index.js +70 -106
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/midscene/index.js +53 -29
- package/dist/lib/midscene/index.js.map +1 -1
- package/dist/lib/test-cli.js +236 -125
- package/dist/lib/test-cli.js.map +1 -1
- package/dist/types/cli/index.d.ts +10 -6
- package/dist/types/index.d.ts +1 -85
- package/dist/types/midscene/index.d.ts +3 -1
- package/package.json +13 -13
package/dist/es/cli/index.mjs
CHANGED
|
@@ -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
|
|
444
|
+
let snapshot;
|
|
382
445
|
try {
|
|
383
|
-
|
|
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 =
|
|
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)
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll('|', '|').replaceAll('[', '[').replaceAll(']', ']').replaceAll('*', '*').replaceAll('_', '_').replaceAll('`', '`').replaceAll('\\', '\').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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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;
|