@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.
- package/dist/es/cli/index.mjs +235 -129
- package/dist/es/cli/index.mjs.map +1 -1
- package/dist/es/index.mjs +72 -106
- 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 -130
- package/dist/es/test-cli.mjs.map +1 -1
- package/dist/lib/cli/index.js +233 -127
- package/dist/lib/cli/index.js.map +1 -1
- package/dist/lib/index.js +70 -110
- 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 -129
- 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,103 +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)=>{
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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;
|