donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
229 lines • 9.05 kB
JavaScript
;
/**
* @fileoverview Pure in-memory merge of two `DonobuReport`s.
*
* Combines the initial Playwright run and an auto-heal rerun into a single
* report that carries both attempts and annotates any test whose outcome
* flipped from failing → passing as `self-healed`. The result is destined for
* the HTML renderer (and, for back-compat, the on-disk Playwright JSON that
* downstream dashboards consume).
*
* This module intentionally owns zero I/O. Callers load the reports off disk,
* call `mergeReports`, then persist / relocate attachments / re-render as
* needed. Keeping the merge pure makes it trivial to test and reason about.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.mergeReports = mergeReports;
exports.buildTestKey = buildTestKey;
function mergeReports(params) {
const { initialReport, healReport } = params;
if (!initialReport && !healReport) {
return null;
}
// Clone so we never mutate either input.
const combined = JSON.parse(JSON.stringify(initialReport ?? healReport));
const initialIndex = indexReport(initialReport);
const combinedIndex = indexReport(combined);
const healIndex = indexReport(healReport);
const healedKeys = new Set();
if (healReport) {
const processedHealEntries = new Set();
const processHealEntry = (healEntry) => {
const key = buildTestKey(healEntry.suite.file, healEntry.test.projectName, healEntry.test.title);
let combinedEntry = (healEntry.test.testId
? combinedIndex.byId.get(healEntry.test.testId)
: undefined) ??
combinedIndex.byKey.get(key) ??
null;
if (!combinedEntry) {
combinedEntry = insertTestIntoReport(combined, healEntry);
if (healEntry.test.testId) {
combinedIndex.byId.set(healEntry.test.testId, combinedEntry);
}
combinedIndex.byKey.set(key, combinedEntry);
}
const originalEntry = (healEntry.test.testId
? initialIndex.byId.get(healEntry.test.testId)
: undefined) ??
initialIndex.byKey.get(key) ??
null;
const combinedTest = combinedEntry.test;
if (healEntry.test.results?.length) {
combinedTest.results = [
...(combinedTest.results ?? []),
...healEntry.test.results,
];
}
if (healEntry.test.status !== undefined) {
combinedTest.status = healEntry.test.status;
}
if (healEntry.test.outcome !== undefined) {
combinedTest.outcome = healEntry.test.outcome;
}
const originalStatus = originalEntry
? getFinalResultStatus(originalEntry.test)
: undefined;
const healStatus = getFinalResultStatus(healEntry.test);
if (healStatus === 'passed' &&
originalStatus &&
originalStatus !== 'passed') {
combinedTest.annotations = combinedTest.annotations ?? [];
if (!combinedTest.annotations.some((annotation) => annotation.type === 'self-healed')) {
combinedTest.annotations.push({
type: 'self-healed',
description: 'Automatically healed by Donobu auto-heal rerun after applying treatment plan.',
});
}
combinedTest.donobuStatus = 'healed';
healedKeys.add(key);
}
};
const iterateEntries = (entries) => {
for (const [, healEntry] of entries) {
if (processedHealEntries.has(healEntry)) {
continue;
}
processedHealEntries.add(healEntry);
processHealEntry(healEntry);
}
};
iterateEntries(healIndex.byId);
iterateEntries(healIndex.byKey);
}
if (params.healSucceeded && healedKeys.size === 0) {
params.healedTests.forEach((descriptor) => {
// Descriptors must arrive with `testCase.file` already normalized to the
// same form that suite.file has in the reports — the caller owns path
// normalization because it has access to the CWD the run was launched in.
const key = buildTestKey(descriptor.testCase.file, descriptor.testCase.projectName, descriptor.testCase.title);
const entry = combinedIndex.byKey.get(key);
if (entry) {
entry.test.annotations = entry.test.annotations ?? [];
if (!entry.test.annotations.some((annotation) => annotation.type === 'self-healed')) {
entry.test.annotations.push({
type: 'self-healed',
description: 'Automatically healed by Donobu auto-heal rerun after applying treatment plan.',
});
}
entry.test.donobuStatus = 'healed';
healedKeys.add(key);
}
});
}
combined.stats = computeReportStats(combined);
const mergedMetadata = {
...(combined.metadata ?? {}),
donobuMergedReport: true,
mergedAtIso: new Date().toISOString(),
sources: {
initial: params.initialReportSourcePath ?? null,
autoHeal: params.healReportSourcePath ?? null,
},
...(params.triageRunDir ? { triageRunDir: params.triageRunDir } : {}),
donobuHealedTests: Array.from(healedKeys.values()),
};
combined.metadata = mergedMetadata;
return combined;
}
// Playwright does not reliably expose stable IDs across reports; fall back to
// a composite key. Exported so the orchestrator can build matching keys for
// heal descriptors when needed.
function buildTestKey(file, projectName, title) {
return [file ?? 'unknown-file', projectName ?? 'default', title ?? '']
.map((segment) => segment.toString())
.join('::');
}
function getFinalResultStatus(test) {
if (!test) {
return undefined;
}
return test.results?.at?.(-1)?.status ?? test.status;
}
function indexReport(report) {
const byId = new Map();
const byKey = new Map();
if (!report?.suites) {
return { byId, byKey };
}
report.suites.forEach((suite) => {
suite.specs?.forEach((spec) => {
spec.tests?.forEach((test) => {
const entry = { suite, spec, test };
if (test.testId) {
byId.set(test.testId, entry);
}
const key = buildTestKey(suite.file, test.projectName, test.title);
byKey.set(key, entry);
});
});
});
return { byId, byKey };
}
function insertTestIntoReport(report, entry) {
const suites = report.suites ?? [];
let suite = suites.find((candidate) => candidate.file === entry.suite.file);
if (!suite) {
suite = JSON.parse(JSON.stringify(entry.suite));
suite.specs = [];
report.suites = [...suites, suite];
}
let spec = suite.specs.find((candidate) => candidate.title === entry.spec.title);
if (!spec) {
spec = JSON.parse(JSON.stringify(entry.spec));
spec.tests = [];
suite.specs.push(spec);
}
const testClone = JSON.parse(JSON.stringify(entry.test));
spec.tests.push(testClone);
return { suite, spec, test: testClone };
}
function computeReportStats(report) {
let expected = 0;
let unexpected = 0;
let skipped = 0;
let flaky = 0;
let total = 0;
let duration = 0;
if (!report?.suites) {
return report?.stats ?? {};
}
report.suites.forEach((suite) => {
suite.specs?.forEach((spec) => {
spec.tests?.forEach((test) => {
total += 1;
const finalResult = test.results?.at(-1);
if (finalResult?.duration) {
duration += finalResult.duration;
}
const status = finalResult?.status ?? test.status;
switch (status) {
case 'passed':
expected += 1;
break;
case 'skipped':
skipped += 1;
break;
case 'flaky':
flaky += 1;
break;
case 'failed':
case 'timedOut':
case 'interrupted':
unexpected += 1;
break;
default:
unexpected += 1;
}
});
});
});
return {
expected,
unexpected,
flaky,
skipped,
duration,
total,
};
}
//# sourceMappingURL=merge.js.map