donobu 5.25.5 → 5.27.0
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/cli/donobu-cli.js +203 -251
- package/dist/codegen/CodeGenerator.js +12 -16
- package/dist/esm/cli/donobu-cli.js +203 -251
- package/dist/esm/codegen/CodeGenerator.js +12 -16
- package/dist/esm/lib/ai/PageAi.d.ts +1 -0
- package/dist/esm/lib/ai/PageAi.js +2 -1
- package/dist/esm/main.d.ts +1 -1
- package/dist/esm/main.js +2 -1
- package/dist/esm/managers/DonobuFlowsManager.d.ts +11 -23
- package/dist/esm/managers/DonobuFlowsManager.js +31 -68
- package/dist/esm/managers/TestsManager.js +2 -2
- package/dist/esm/models/CreateTest.d.ts +1 -1
- package/dist/esm/models/CreateTest.js +6 -0
- package/dist/esm/persistence/DonobuSqliteDb.js +102 -0
- package/dist/esm/persistence/TestConfigHash.d.ts +11 -0
- package/dist/esm/persistence/TestConfigHash.js +31 -0
- package/dist/esm/persistence/flows/FlowsPersistenceSqlite.d.ts +0 -9
- package/dist/esm/persistence/flows/FlowsPersistenceSqlite.js +4 -33
- package/dist/esm/persistence/normalizeFlowMetadata.d.ts +16 -0
- package/dist/esm/persistence/normalizeFlowMetadata.js +34 -0
- package/dist/esm/reporter/buildReport.d.ts +22 -0
- package/dist/esm/reporter/buildReport.js +106 -0
- package/dist/esm/reporter/html.d.ts +5 -9
- package/dist/esm/reporter/html.js +25 -101
- package/dist/esm/reporter/markdown.d.ts +33 -0
- package/dist/esm/reporter/markdown.js +62 -0
- package/dist/esm/reporter/merge.d.ts +33 -0
- package/dist/esm/reporter/merge.js +229 -0
- package/dist/esm/reporter/model.d.ts +101 -0
- package/dist/esm/reporter/model.js +27 -0
- package/dist/{cli/playwright-json-to-html.d.ts → esm/reporter/render.d.ts} +9 -14
- package/dist/esm/{cli/playwright-json-to-html.js → reporter/render.js} +52 -152
- package/dist/esm/reporter/renderMarkdown.d.ts +11 -0
- package/dist/esm/{cli/playwright-json-to-markdown.js → reporter/renderMarkdown.js} +31 -110
- package/dist/esm/reporter/renderSlack.d.ts +17 -0
- package/dist/esm/reporter/renderSlack.js +100 -0
- package/dist/esm/reporter/reportWalk.d.ts +28 -0
- package/dist/esm/reporter/reportWalk.js +61 -0
- package/dist/esm/reporter/slack.d.ts +93 -0
- package/dist/esm/reporter/slack.js +150 -0
- package/dist/esm/reporter/stateFile.d.ts +31 -0
- package/dist/esm/reporter/stateFile.js +70 -0
- package/dist/esm/targets/TargetRuntimePlugin.d.ts +0 -10
- package/dist/esm/targets/WebTargetRuntime.js +0 -43
- package/dist/esm/tools/AssertPageTool.d.ts +2 -2
- package/dist/esm/tools/ReplayableInteraction.d.ts +1 -1
- package/dist/esm/tools/ReplayableInteraction.js +1 -1
- package/dist/esm/tools/Tool.d.ts +14 -0
- package/dist/esm/tools/Tool.js +16 -0
- package/dist/esm/utils/MiscUtils.d.ts +0 -13
- package/dist/esm/utils/MiscUtils.js +0 -21
- package/dist/esm/utils/displayName.d.ts +16 -0
- package/dist/esm/utils/displayName.js +28 -0
- package/dist/lib/ai/PageAi.d.ts +1 -0
- package/dist/lib/ai/PageAi.js +2 -1
- package/dist/main.d.ts +1 -1
- package/dist/main.js +2 -1
- package/dist/managers/DonobuFlowsManager.d.ts +11 -23
- package/dist/managers/DonobuFlowsManager.js +31 -68
- package/dist/managers/TestsManager.js +2 -2
- package/dist/models/CreateTest.d.ts +1 -1
- package/dist/models/CreateTest.js +6 -0
- package/dist/persistence/DonobuSqliteDb.js +102 -0
- package/dist/persistence/TestConfigHash.d.ts +11 -0
- package/dist/persistence/TestConfigHash.js +31 -0
- package/dist/persistence/flows/FlowsPersistenceSqlite.d.ts +0 -9
- package/dist/persistence/flows/FlowsPersistenceSqlite.js +4 -33
- package/dist/persistence/normalizeFlowMetadata.d.ts +16 -0
- package/dist/persistence/normalizeFlowMetadata.js +34 -0
- package/dist/reporter/buildReport.d.ts +22 -0
- package/dist/reporter/buildReport.js +106 -0
- package/dist/reporter/html.d.ts +5 -9
- package/dist/reporter/html.js +25 -101
- package/dist/reporter/markdown.d.ts +33 -0
- package/dist/reporter/markdown.js +62 -0
- package/dist/reporter/merge.d.ts +33 -0
- package/dist/reporter/merge.js +229 -0
- package/dist/reporter/model.d.ts +101 -0
- package/dist/reporter/model.js +27 -0
- package/dist/{esm/cli/playwright-json-to-html.d.ts → reporter/render.d.ts} +9 -14
- package/dist/{cli/playwright-json-to-html.js → reporter/render.js} +52 -152
- package/dist/reporter/renderMarkdown.d.ts +11 -0
- package/dist/{cli/playwright-json-to-markdown.js → reporter/renderMarkdown.js} +31 -110
- package/dist/reporter/renderSlack.d.ts +17 -0
- package/dist/reporter/renderSlack.js +100 -0
- package/dist/reporter/reportWalk.d.ts +28 -0
- package/dist/reporter/reportWalk.js +61 -0
- package/dist/reporter/slack.d.ts +93 -0
- package/dist/reporter/slack.js +150 -0
- package/dist/reporter/stateFile.d.ts +31 -0
- package/dist/reporter/stateFile.js +70 -0
- package/dist/targets/TargetRuntimePlugin.d.ts +0 -10
- package/dist/targets/WebTargetRuntime.js +0 -43
- package/dist/tools/AssertPageTool.d.ts +2 -2
- package/dist/tools/ReplayableInteraction.d.ts +1 -1
- package/dist/tools/ReplayableInteraction.js +1 -1
- package/dist/tools/Tool.d.ts +14 -0
- package/dist/tools/Tool.js +16 -0
- package/dist/utils/MiscUtils.d.ts +0 -13
- package/dist/utils/MiscUtils.js +0 -21
- package/dist/utils/displayName.d.ts +16 -0
- package/dist/utils/displayName.js +28 -0
- package/package.json +11 -5
- package/dist/cli/playwright-json-to-markdown.d.ts +0 -43
- package/dist/cli/playwright-json-to-slack-json.d.ts +0 -3
- package/dist/cli/playwright-json-to-slack-json.js +0 -214
- package/dist/esm/cli/playwright-json-to-markdown.d.ts +0 -43
- package/dist/esm/cli/playwright-json-to-slack-json.d.ts +0 -3
- package/dist/esm/cli/playwright-json-to-slack-json.js +0 -214
package/dist/cli/donobu-cli.js
CHANGED
|
@@ -63,8 +63,13 @@ const v4_1 = require("zod/v4");
|
|
|
63
63
|
const gptClients_1 = require("../lib/test/fixtures/gptClients");
|
|
64
64
|
const donobuTestStack_1 = require("../lib/test/utils/donobuTestStack");
|
|
65
65
|
const triageTestFailure_1 = require("../lib/test/utils/triageTestFailure");
|
|
66
|
+
const merge_1 = require("../reporter/merge");
|
|
67
|
+
const model_1 = require("../reporter/model");
|
|
68
|
+
const render_1 = require("../reporter/render");
|
|
69
|
+
const renderMarkdown_1 = require("../reporter/renderMarkdown");
|
|
70
|
+
const renderSlack_1 = require("../reporter/renderSlack");
|
|
71
|
+
const slack_1 = require("../reporter/slack");
|
|
66
72
|
const Logger_1 = require("../utils/Logger");
|
|
67
|
-
const playwright_json_to_html_1 = require("./playwright-json-to-html");
|
|
68
73
|
const FAILURE_EVIDENCE_PREFIX = 'failure-evidence-';
|
|
69
74
|
const TREATMENT_PLAN_PREFIX = 'treatment-plan-';
|
|
70
75
|
const PLAYWRIGHT_JSON_REPORT_FILENAME = 'report.json';
|
|
@@ -1287,17 +1292,21 @@ async function attemptAutoHealRun(params) {
|
|
|
1287
1292
|
else {
|
|
1288
1293
|
Logger_1.appLogger.warn(`Auto-heal attempt exited with code ${healExitCode}. Keeping failing status.`);
|
|
1289
1294
|
}
|
|
1290
|
-
|
|
1295
|
+
// Prefer the heal reporter's state file; fall back to Playwright's JSON
|
|
1296
|
+
// reporter output for configs that don't wire up the Donobu reporter.
|
|
1297
|
+
const healReport = await loadDonobuReportForMerge(staging.playwrightOutputDir, healReportCopy?.destinationPath ?? undefined);
|
|
1298
|
+
if (params.initialReport || healReport) {
|
|
1291
1299
|
// Write the merged report directly to the primary report target when
|
|
1292
1300
|
// available, avoiding a redundant intermediate file.
|
|
1293
1301
|
const primaryTarget = params.reportTargets[0];
|
|
1294
1302
|
const mergedReportPath = primaryTarget
|
|
1295
1303
|
? primaryTarget
|
|
1296
1304
|
: path.join(params.playwrightOutputDir, `donobu-merged-report-${Date.now()}.json`);
|
|
1297
|
-
await mergePlaywrightJsonReports({
|
|
1298
|
-
initialReport: params.initialReport,
|
|
1305
|
+
const mergedReport = await mergePlaywrightJsonReports({
|
|
1306
|
+
initialReport: params.initialReport ?? null,
|
|
1299
1307
|
initialReportSourcePath: params.initialReportSourcePath,
|
|
1300
|
-
|
|
1308
|
+
healReport,
|
|
1309
|
+
healReportSourcePath: healReportCopy?.destinationPath ?? undefined,
|
|
1301
1310
|
mergedReportPath,
|
|
1302
1311
|
healedTests: evaluation.eligiblePlans.map((record) => ({
|
|
1303
1312
|
plan: record.plan,
|
|
@@ -1309,10 +1318,12 @@ async function attemptAutoHealRun(params) {
|
|
|
1309
1318
|
});
|
|
1310
1319
|
// Copy to any remaining report targets (skip the primary — already
|
|
1311
1320
|
// written there).
|
|
1312
|
-
if (params.reportTargets.length > 1) {
|
|
1321
|
+
if (mergedReport && params.reportTargets.length > 1) {
|
|
1313
1322
|
await overwriteReportTargets(mergedReportPath, params.reportTargets.slice(1));
|
|
1314
1323
|
}
|
|
1315
|
-
|
|
1324
|
+
if (mergedReport) {
|
|
1325
|
+
await regenerateDonobuReports(mergedReport);
|
|
1326
|
+
}
|
|
1316
1327
|
}
|
|
1317
1328
|
}
|
|
1318
1329
|
finally {
|
|
@@ -1373,32 +1384,37 @@ async function relocateTemporaryAttachments(report, outputDir) {
|
|
|
1373
1384
|
* outcome. Reads the output path from the sidecar written by the reporter
|
|
1374
1385
|
* during the initial run; does nothing if the reporter wasn't configured.
|
|
1375
1386
|
*/
|
|
1376
|
-
async function
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
const sidecar = JSON.parse(await fs_1.promises.readFile(sidecarPath, 'utf-8'));
|
|
1381
|
-
outputFile = sidecar.outputFile;
|
|
1382
|
-
if (!outputFile) {
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1387
|
+
async function regenerateDonobuReports(mergedReport) {
|
|
1388
|
+
const outputs = mergedReport.metadata?.donobuOutputs;
|
|
1389
|
+
if (!outputs) {
|
|
1390
|
+
return;
|
|
1385
1391
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
1392
|
+
if (outputs.html?.outputFile) {
|
|
1393
|
+
await regenerateHtmlOutput(mergedReport, outputs.html.outputFile);
|
|
1388
1394
|
}
|
|
1395
|
+
if (outputs.markdown?.outputFile) {
|
|
1396
|
+
await regenerateMarkdownOutput(mergedReport, outputs.markdown.outputFile);
|
|
1397
|
+
}
|
|
1398
|
+
if (outputs.slack?.outputFile) {
|
|
1399
|
+
await regenerateSlackOutput(mergedReport, outputs.slack.outputFile);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
async function regenerateHtmlOutput(mergedReport, outputFile) {
|
|
1389
1403
|
try {
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1404
|
+
let triage = {
|
|
1405
|
+
plans: [],
|
|
1406
|
+
evidence: [],
|
|
1407
|
+
};
|
|
1408
|
+
if (mergedReport.metadata?.triageRunDir) {
|
|
1393
1409
|
try {
|
|
1394
|
-
triage = (0,
|
|
1410
|
+
triage = (0, render_1.loadTriageData)(mergedReport.metadata.triageRunDir);
|
|
1395
1411
|
}
|
|
1396
1412
|
catch {
|
|
1397
1413
|
// Triage dir may not exist in all environments; report without it.
|
|
1398
1414
|
}
|
|
1399
1415
|
}
|
|
1400
1416
|
const outputDir = path.dirname(outputFile);
|
|
1401
|
-
const html = (0,
|
|
1417
|
+
const html = (0, render_1.renderHtml)(mergedReport, triage, outputDir);
|
|
1402
1418
|
await fs_1.promises.mkdir(outputDir, { recursive: true });
|
|
1403
1419
|
await fs_1.promises.writeFile(outputFile, html, 'utf8');
|
|
1404
1420
|
Logger_1.appLogger.info(`Donobu HTML report regenerated from merged data: ${outputFile}`);
|
|
@@ -1407,234 +1423,110 @@ async function regenerateDonobuHtmlReport(mergedReportPath, playwrightOutputDir)
|
|
|
1407
1423
|
Logger_1.appLogger.warn('Failed to regenerate Donobu HTML report from merged data.', error);
|
|
1408
1424
|
}
|
|
1409
1425
|
}
|
|
1426
|
+
async function regenerateMarkdownOutput(mergedReport, outputFile) {
|
|
1427
|
+
try {
|
|
1428
|
+
const markdown = (0, renderMarkdown_1.renderMarkdown)(mergedReport);
|
|
1429
|
+
await fs_1.promises.mkdir(path.dirname(outputFile), { recursive: true });
|
|
1430
|
+
await fs_1.promises.writeFile(outputFile, markdown, 'utf8');
|
|
1431
|
+
Logger_1.appLogger.info(`Donobu Markdown report regenerated from merged data: ${outputFile}`);
|
|
1432
|
+
}
|
|
1433
|
+
catch (error) {
|
|
1434
|
+
Logger_1.appLogger.warn('Failed to regenerate Donobu Markdown report from merged data.', error);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
async function regenerateSlackOutput(mergedReport, outputFile) {
|
|
1438
|
+
try {
|
|
1439
|
+
// `DONOBU_REPORT_URL` is read here (not threaded via state) for symmetry
|
|
1440
|
+
// with the reporter — both the reporter and the orchestrator pull Slack
|
|
1441
|
+
// configuration from the environment.
|
|
1442
|
+
const payload = (0, renderSlack_1.renderSlack)(mergedReport, {
|
|
1443
|
+
reportUrl: process.env.DONOBU_REPORT_URL,
|
|
1444
|
+
});
|
|
1445
|
+
await fs_1.promises.mkdir(path.dirname(outputFile), { recursive: true });
|
|
1446
|
+
await fs_1.promises.writeFile(outputFile, JSON.stringify(payload, null, 2), 'utf8');
|
|
1447
|
+
Logger_1.appLogger.info(`Donobu Slack payload regenerated from merged data: ${outputFile}`);
|
|
1448
|
+
// Post to Slack from the orchestrator — at this point the payload reflects
|
|
1449
|
+
// the final (merged, possibly healed) outcome. The reporter itself
|
|
1450
|
+
// deferred posting when `DONOBU_AUTO_HEAL_ORCHESTRATED=1` was set on the
|
|
1451
|
+
// initial run, so this is the single authoritative post for the whole run.
|
|
1452
|
+
const webhookUrl = process.env.DONOBU_SLACK_WEBHOOK_URL;
|
|
1453
|
+
if (webhookUrl) {
|
|
1454
|
+
await (0, slack_1.postSlackPayload)(webhookUrl, payload);
|
|
1455
|
+
Logger_1.appLogger.info('Donobu Slack payload posted to webhook.');
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
catch (error) {
|
|
1459
|
+
Logger_1.appLogger.warn('Failed to regenerate Donobu Slack payload from merged data.', error);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1410
1462
|
/**
|
|
1411
|
-
*
|
|
1412
|
-
*
|
|
1463
|
+
* Try to load a run's `DonobuReport` from the reporter state file, falling
|
|
1464
|
+
* back to a Playwright JSON report at `fallbackPath`. The DonobuReport shape
|
|
1465
|
+
* is a superset of Playwright JSON so either works as input to `mergeReports`.
|
|
1466
|
+
*
|
|
1467
|
+
* Returns null when neither source exists (e.g. a config that uses neither
|
|
1468
|
+
* reporter) — the orchestrator treats this as "nothing to merge".
|
|
1469
|
+
*/
|
|
1470
|
+
async function loadDonobuReportForMerge(outputDir, fallbackPath) {
|
|
1471
|
+
if (outputDir) {
|
|
1472
|
+
const stateFile = path.join(outputDir, model_1.DONOBU_REPORT_STATE_FILENAME);
|
|
1473
|
+
const state = await readJsonIfExists(stateFile);
|
|
1474
|
+
if (state) {
|
|
1475
|
+
return state;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (fallbackPath) {
|
|
1479
|
+
return await readJsonIfExists(fallbackPath);
|
|
1480
|
+
}
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Combine the reports from the original failed run and any auto-heal rerun,
|
|
1485
|
+
* relocate any heal-run attachments to persistent storage, and write the
|
|
1486
|
+
* merged JSON to `mergedReportPath` for downstream dashboards. Returns the
|
|
1487
|
+
* in-memory merged report so the caller can drive HTML regeneration without
|
|
1488
|
+
* re-reading from disk.
|
|
1489
|
+
*
|
|
1490
|
+
* Also forwards `metadata.donobuOutputs` from whichever input recorded it
|
|
1491
|
+
* (typically the initial run's state file), so regeneration lands every
|
|
1492
|
+
* format at the same paths the reporters originally chose.
|
|
1413
1493
|
*/
|
|
1414
1494
|
async function mergePlaywrightJsonReports(params) {
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1495
|
+
const merged = (0, merge_1.mergeReports)({
|
|
1496
|
+
initialReport: params.initialReport,
|
|
1497
|
+
healReport: params.healReport,
|
|
1498
|
+
healedTests: params.healedTests,
|
|
1499
|
+
healSucceeded: params.healSucceeded,
|
|
1500
|
+
triageRunDir: params.triageRunDir,
|
|
1501
|
+
initialReportSourcePath: params.initialReportSourcePath,
|
|
1502
|
+
healReportSourcePath: params.healReportSourcePath,
|
|
1503
|
+
});
|
|
1504
|
+
if (!merged) {
|
|
1505
|
+
return null;
|
|
1419
1506
|
}
|
|
1420
|
-
//
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if (
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
let combinedEntry = (healEntry.test.testId
|
|
1433
|
-
? combinedIndex.byId.get(healEntry.test.testId)
|
|
1434
|
-
: undefined) ??
|
|
1435
|
-
combinedIndex.byKey.get(key) ??
|
|
1436
|
-
null;
|
|
1437
|
-
if (!combinedEntry) {
|
|
1438
|
-
combinedEntry = insertTestIntoReport(combined, healEntry);
|
|
1439
|
-
if (healEntry.test.testId) {
|
|
1440
|
-
combinedIndex.byId.set(healEntry.test.testId, combinedEntry);
|
|
1441
|
-
}
|
|
1442
|
-
combinedIndex.byKey.set(key, combinedEntry);
|
|
1443
|
-
}
|
|
1444
|
-
const originalEntry = (healEntry.test.testId
|
|
1445
|
-
? initialIndex.byId.get(healEntry.test.testId)
|
|
1446
|
-
: undefined) ??
|
|
1447
|
-
initialIndex.byKey.get(key) ??
|
|
1448
|
-
null;
|
|
1449
|
-
const combinedTest = combinedEntry.test;
|
|
1450
|
-
if (healEntry.test.results?.length) {
|
|
1451
|
-
combinedTest.results = [
|
|
1452
|
-
...(combinedTest.results ?? []),
|
|
1453
|
-
...healEntry.test.results,
|
|
1454
|
-
];
|
|
1455
|
-
}
|
|
1456
|
-
if (healEntry.test.status !== undefined) {
|
|
1457
|
-
combinedTest.status = healEntry.test.status;
|
|
1458
|
-
}
|
|
1459
|
-
if (healEntry.test.outcome !== undefined) {
|
|
1460
|
-
combinedTest.outcome = healEntry.test.outcome;
|
|
1461
|
-
}
|
|
1462
|
-
const originalStatus = originalEntry
|
|
1463
|
-
? getFinalResultStatus(originalEntry.test)
|
|
1464
|
-
: undefined;
|
|
1465
|
-
const healStatus = getFinalResultStatus(healEntry.test);
|
|
1466
|
-
if (healStatus === 'passed' &&
|
|
1467
|
-
originalStatus &&
|
|
1468
|
-
originalStatus !== 'passed') {
|
|
1469
|
-
combinedTest.annotations = combinedTest.annotations ?? [];
|
|
1470
|
-
if (!combinedTest.annotations.some((annotation) => annotation.type === 'self-healed')) {
|
|
1471
|
-
combinedTest.annotations.push({
|
|
1472
|
-
type: 'self-healed',
|
|
1473
|
-
description: 'Automatically healed by Donobu auto-heal rerun after applying treatment plan.',
|
|
1474
|
-
});
|
|
1475
|
-
}
|
|
1476
|
-
combinedTest.donobuStatus = 'healed';
|
|
1477
|
-
healedKeys.add(key);
|
|
1478
|
-
}
|
|
1479
|
-
};
|
|
1480
|
-
const iterateEntries = (entries) => {
|
|
1481
|
-
for (const [, healEntry] of entries) {
|
|
1482
|
-
if (processedHealEntries.has(healEntry)) {
|
|
1483
|
-
continue;
|
|
1484
|
-
}
|
|
1485
|
-
processedHealEntries.add(healEntry);
|
|
1486
|
-
processHealEntry(healEntry);
|
|
1487
|
-
}
|
|
1507
|
+
// Carry forward each format's output path recorded by the reporters so the
|
|
1508
|
+
// orchestrator can regenerate every output at the same place. Prefer the
|
|
1509
|
+
// initial run's value — the heal-run lands in a temp staging directory the
|
|
1510
|
+
// user never sees.
|
|
1511
|
+
const donobuOutputs = {
|
|
1512
|
+
...(params.healReport?.metadata?.donobuOutputs ?? {}),
|
|
1513
|
+
...(params.initialReport?.metadata?.donobuOutputs ?? {}),
|
|
1514
|
+
};
|
|
1515
|
+
if (Object.keys(donobuOutputs).length > 0) {
|
|
1516
|
+
merged.metadata = {
|
|
1517
|
+
...(merged.metadata ?? {}),
|
|
1518
|
+
donobuOutputs,
|
|
1488
1519
|
};
|
|
1489
|
-
iterateEntries(healIndex.byId);
|
|
1490
|
-
iterateEntries(healIndex.byKey);
|
|
1491
|
-
}
|
|
1492
|
-
if (params.healSucceeded && healedKeys.size === 0) {
|
|
1493
|
-
params.healedTests.forEach((descriptor) => {
|
|
1494
|
-
const key = buildTestKey(normalizeSpecPath(descriptor.testCase.file), descriptor.testCase.projectName, descriptor.testCase.title);
|
|
1495
|
-
const entry = combinedIndex.byKey.get(key);
|
|
1496
|
-
if (entry) {
|
|
1497
|
-
entry.test.annotations = entry.test.annotations ?? [];
|
|
1498
|
-
if (!entry.test.annotations.some((annotation) => annotation.type === 'self-healed')) {
|
|
1499
|
-
entry.test.annotations.push({
|
|
1500
|
-
type: 'self-healed',
|
|
1501
|
-
description: 'Automatically healed by Donobu auto-heal rerun after applying treatment plan.',
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
entry.test.donobuStatus = 'healed';
|
|
1505
|
-
healedKeys.add(key);
|
|
1506
|
-
}
|
|
1507
|
-
});
|
|
1508
1520
|
}
|
|
1509
|
-
combined.stats = computeReportStats(combined);
|
|
1510
|
-
combined.metadata = {
|
|
1511
|
-
...(combined.metadata ?? {}),
|
|
1512
|
-
donobuMergedReport: true,
|
|
1513
|
-
mergedAtIso: new Date().toISOString(),
|
|
1514
|
-
sources: {
|
|
1515
|
-
initial: params.initialReportSourcePath ?? null,
|
|
1516
|
-
autoHeal: params.healReportPath ?? null,
|
|
1517
|
-
},
|
|
1518
|
-
...(params.triageRunDir
|
|
1519
|
-
? { triageRunDir: params.triageRunDir }
|
|
1520
|
-
: undefined),
|
|
1521
|
-
donobuHealedTests: Array.from(healedKeys.values()),
|
|
1522
|
-
};
|
|
1523
1521
|
// Relocate heal-run attachments from temporary staging directory to the
|
|
1524
1522
|
// persistent output directory so they survive after staging cleanup.
|
|
1525
1523
|
if (params.outputDir) {
|
|
1526
|
-
await relocateTemporaryAttachments(
|
|
1524
|
+
await relocateTemporaryAttachments(merged, params.outputDir);
|
|
1527
1525
|
}
|
|
1528
1526
|
await ensureDirectory(path.dirname(params.mergedReportPath));
|
|
1529
|
-
await fs_1.promises.writeFile(params.mergedReportPath, JSON.stringify(
|
|
1527
|
+
await fs_1.promises.writeFile(params.mergedReportPath, JSON.stringify(merged, null, 2), 'utf8');
|
|
1530
1528
|
Logger_1.appLogger.debug(`Saved merged Playwright report to ${params.mergedReportPath}.`);
|
|
1531
|
-
|
|
1532
|
-
// Playwright does not reliably expose stable IDs across reports; fall back to a composite key.
|
|
1533
|
-
function buildTestKey(file, projectName, title) {
|
|
1534
|
-
return [file ?? 'unknown-file', projectName ?? 'default', title ?? '']
|
|
1535
|
-
.map((segment) => segment.toString())
|
|
1536
|
-
.join('::');
|
|
1537
|
-
}
|
|
1538
|
-
function getFinalResultStatus(test) {
|
|
1539
|
-
if (!test) {
|
|
1540
|
-
return undefined;
|
|
1541
|
-
}
|
|
1542
|
-
return test.results?.at?.(-1)?.status ?? test.status;
|
|
1543
|
-
}
|
|
1544
|
-
/**
|
|
1545
|
-
* Build lookup tables for quickly finding test entries inside a Playwright
|
|
1546
|
-
* report. We index by both `testId` (preferred) and the composite key to handle
|
|
1547
|
-
* differences between initial runs and reruns.
|
|
1548
|
-
*/
|
|
1549
|
-
function indexReport(report) {
|
|
1550
|
-
const byId = new Map();
|
|
1551
|
-
const byKey = new Map();
|
|
1552
|
-
if (!report?.suites) {
|
|
1553
|
-
return { byId, byKey };
|
|
1554
|
-
}
|
|
1555
|
-
report.suites.forEach((suite) => {
|
|
1556
|
-
suite.specs?.forEach((spec) => {
|
|
1557
|
-
spec.tests?.forEach((test) => {
|
|
1558
|
-
const entry = { suite, spec, test };
|
|
1559
|
-
if (test.testId) {
|
|
1560
|
-
byId.set(test.testId, entry);
|
|
1561
|
-
}
|
|
1562
|
-
const key = buildTestKey(suite.file, test.projectName, test.title);
|
|
1563
|
-
byKey.set(key, entry);
|
|
1564
|
-
});
|
|
1565
|
-
});
|
|
1566
|
-
});
|
|
1567
|
-
return { byId, byKey };
|
|
1568
|
-
}
|
|
1569
|
-
function insertTestIntoReport(report, entry) {
|
|
1570
|
-
// Look for an existing suite/spec structure to attach the test clone to.
|
|
1571
|
-
let suite = report.suites?.find((candidate) => candidate.file === entry.suite.file);
|
|
1572
|
-
if (!suite) {
|
|
1573
|
-
suite = JSON.parse(JSON.stringify(entry.suite));
|
|
1574
|
-
suite.specs = [];
|
|
1575
|
-
report.suites = report.suites ?? [];
|
|
1576
|
-
report.suites.push(suite);
|
|
1577
|
-
}
|
|
1578
|
-
let spec = suite.specs.find((candidate) => candidate.title === entry.spec.title);
|
|
1579
|
-
if (!spec) {
|
|
1580
|
-
spec = JSON.parse(JSON.stringify(entry.spec));
|
|
1581
|
-
spec.tests = [];
|
|
1582
|
-
suite.specs.push(spec);
|
|
1583
|
-
}
|
|
1584
|
-
const testClone = JSON.parse(JSON.stringify(entry.test));
|
|
1585
|
-
spec.tests.push(testClone);
|
|
1586
|
-
return { suite, spec, test: testClone };
|
|
1587
|
-
}
|
|
1588
|
-
function computeReportStats(report) {
|
|
1589
|
-
let expected = 0;
|
|
1590
|
-
let unexpected = 0;
|
|
1591
|
-
let skipped = 0;
|
|
1592
|
-
let flaky = 0;
|
|
1593
|
-
let total = 0;
|
|
1594
|
-
let duration = 0;
|
|
1595
|
-
// Some reports come from external tools and might not include suites/tests.
|
|
1596
|
-
// Fall back to the existing stats block if we cannot recompute metrics.
|
|
1597
|
-
if (!report?.suites) {
|
|
1598
|
-
return report?.stats ?? {};
|
|
1599
|
-
}
|
|
1600
|
-
report.suites.forEach((suite) => {
|
|
1601
|
-
suite.specs?.forEach((spec) => {
|
|
1602
|
-
spec.tests?.forEach((test) => {
|
|
1603
|
-
total += 1;
|
|
1604
|
-
const finalResult = test.results?.at(-1);
|
|
1605
|
-
if (finalResult?.duration) {
|
|
1606
|
-
duration += finalResult.duration;
|
|
1607
|
-
}
|
|
1608
|
-
const status = finalResult?.status ?? test.status;
|
|
1609
|
-
switch (status) {
|
|
1610
|
-
case 'passed':
|
|
1611
|
-
expected += 1;
|
|
1612
|
-
break;
|
|
1613
|
-
case 'skipped':
|
|
1614
|
-
skipped += 1;
|
|
1615
|
-
break;
|
|
1616
|
-
case 'flaky':
|
|
1617
|
-
flaky += 1;
|
|
1618
|
-
break;
|
|
1619
|
-
case 'failed':
|
|
1620
|
-
case 'timedOut':
|
|
1621
|
-
case 'interrupted':
|
|
1622
|
-
unexpected += 1;
|
|
1623
|
-
break;
|
|
1624
|
-
default:
|
|
1625
|
-
unexpected += 1;
|
|
1626
|
-
}
|
|
1627
|
-
});
|
|
1628
|
-
});
|
|
1629
|
-
});
|
|
1630
|
-
return {
|
|
1631
|
-
expected,
|
|
1632
|
-
unexpected,
|
|
1633
|
-
flaky,
|
|
1634
|
-
skipped,
|
|
1635
|
-
duration,
|
|
1636
|
-
total,
|
|
1637
|
-
};
|
|
1529
|
+
return merged;
|
|
1638
1530
|
}
|
|
1639
1531
|
async function readJsonIfExists(filePath) {
|
|
1640
1532
|
if (!filePath) {
|
|
@@ -1737,6 +1629,13 @@ async function runTestCommand(cliArgs) {
|
|
|
1737
1629
|
triageContext,
|
|
1738
1630
|
});
|
|
1739
1631
|
applyJsonReportEnv(envOverrides, playwrightOutputDir);
|
|
1632
|
+
// When auto-heal is enabled, any Slack reporter defers its POST to us —
|
|
1633
|
+
// whether auto-heal actually triggers or not, we want exactly one Slack
|
|
1634
|
+
// message per run reflecting the final outcome, so the orchestrator owns
|
|
1635
|
+
// the HTTP call.
|
|
1636
|
+
if (effectiveOptions.autoHeal) {
|
|
1637
|
+
envOverrides.DONOBU_AUTO_HEAL_ORCHESTRATED = '1';
|
|
1638
|
+
}
|
|
1740
1639
|
const reporterSetup = await ensureJsonReporter(playwrightArgs);
|
|
1741
1640
|
const runArgs = reporterSetup.args;
|
|
1742
1641
|
Logger_1.appLogger.debug(`Initial Playwright args: ${JSON.stringify(runArgs)} with env overrides ${JSON.stringify(envOverrides)}`);
|
|
@@ -1763,25 +1662,38 @@ async function runTestCommand(cliArgs) {
|
|
|
1763
1662
|
reportTargets.add(discoveredReport);
|
|
1764
1663
|
}
|
|
1765
1664
|
if (exitCode === 0 || !effectiveOptions.autoHeal) {
|
|
1665
|
+
// Auto-heal wasn't attempted (either tests passed or the user disabled
|
|
1666
|
+
// it). If a Slack reporter deferred its POST waiting for us, deliver the
|
|
1667
|
+
// initial run's payload now — nothing further will be re-rendered.
|
|
1668
|
+
if (effectiveOptions.autoHeal) {
|
|
1669
|
+
await postDeferredSlackFromInitialRun(playwrightOutputDir);
|
|
1670
|
+
}
|
|
1766
1671
|
return exitCode;
|
|
1767
1672
|
}
|
|
1768
1673
|
// Tests failed and auto-heal is enabled — load the initial report into
|
|
1769
1674
|
// memory so we can merge it with the heal-run report later without writing
|
|
1770
1675
|
// a redundant copy to disk.
|
|
1771
|
-
let
|
|
1676
|
+
let loadedPlaywrightJson = null;
|
|
1677
|
+
let initialDonobuReport = null;
|
|
1772
1678
|
if (triageEnabled) {
|
|
1773
|
-
|
|
1679
|
+
loadedPlaywrightJson = await loadJsonReport(playwrightOutputDir, {
|
|
1774
1680
|
envOverrides,
|
|
1775
1681
|
additionalCandidates: [
|
|
1776
1682
|
path.join(playwrightOutputDir, PLAYWRIGHT_JSON_REPORT_FILENAME),
|
|
1777
1683
|
],
|
|
1778
1684
|
});
|
|
1779
|
-
if (
|
|
1780
|
-
reportTargets.add(
|
|
1685
|
+
if (loadedPlaywrightJson) {
|
|
1686
|
+
reportTargets.add(loadedPlaywrightJson.sourcePath);
|
|
1781
1687
|
}
|
|
1688
|
+
// Prefer the Donobu reporter's state file (carries the HTML output path
|
|
1689
|
+
// and any Donobu-specific metadata); fall back to the Playwright JSON for
|
|
1690
|
+
// configs that don't wire up the Donobu reporter.
|
|
1691
|
+
const stateReport = await readJsonIfExists(path.join(playwrightOutputDir, model_1.DONOBU_REPORT_STATE_FILENAME));
|
|
1692
|
+
initialDonobuReport =
|
|
1693
|
+
stateReport ?? loadedPlaywrightJson?.data ?? null;
|
|
1782
1694
|
}
|
|
1783
1695
|
if (triageEnabled && triageContext) {
|
|
1784
|
-
generatedPlans = await postProcessTriageRun(triageContext, playwrightArgs,
|
|
1696
|
+
generatedPlans = await postProcessTriageRun(triageContext, playwrightArgs, loadedPlaywrightJson?.sourcePath);
|
|
1785
1697
|
}
|
|
1786
1698
|
const autoHealOutcome = await attemptAutoHealRun({
|
|
1787
1699
|
options: effectiveOptions,
|
|
@@ -1789,13 +1701,47 @@ async function runTestCommand(cliArgs) {
|
|
|
1789
1701
|
playwrightOutputDir,
|
|
1790
1702
|
generatedPlans,
|
|
1791
1703
|
currentExitCode: exitCode,
|
|
1792
|
-
initialReport:
|
|
1793
|
-
initialReportSourcePath:
|
|
1704
|
+
initialReport: initialDonobuReport,
|
|
1705
|
+
initialReportSourcePath: loadedPlaywrightJson?.sourcePath,
|
|
1794
1706
|
triageRunDir: triageContext?.runDir,
|
|
1795
1707
|
reportTargets: Array.from(reportTargets),
|
|
1796
1708
|
});
|
|
1709
|
+
// When auto-heal was eligible-checked but didn't actually run a rerun (no
|
|
1710
|
+
// actionable directives), nothing downstream re-renders the Slack payload —
|
|
1711
|
+
// deliver the pre-heal payload now so we honor the "one post per run"
|
|
1712
|
+
// guarantee the reporter was deferring for.
|
|
1713
|
+
if (!autoHealOutcome.attempted) {
|
|
1714
|
+
await postDeferredSlackFromInitialRun(playwrightOutputDir);
|
|
1715
|
+
}
|
|
1797
1716
|
return autoHealOutcome.exitCode;
|
|
1798
1717
|
}
|
|
1718
|
+
/**
|
|
1719
|
+
* When auto-heal was enabled but no rerun happened (tests passed, or no
|
|
1720
|
+
* treatment plan had actionable directives), the Slack reporter deferred its
|
|
1721
|
+
* POST to the orchestrator. Fire the deferred POST from the initial run's
|
|
1722
|
+
* state file so the user gets exactly one Slack notification per run.
|
|
1723
|
+
*/
|
|
1724
|
+
async function postDeferredSlackFromInitialRun(playwrightOutputDir) {
|
|
1725
|
+
const webhookUrl = process.env.DONOBU_SLACK_WEBHOOK_URL;
|
|
1726
|
+
if (!webhookUrl) {
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const state = await readJsonIfExists(path.join(playwrightOutputDir, model_1.DONOBU_REPORT_STATE_FILENAME));
|
|
1730
|
+
const slack = state?.metadata?.donobuOutputs?.slack;
|
|
1731
|
+
if (!state || !slack) {
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
try {
|
|
1735
|
+
const payload = (0, renderSlack_1.renderSlack)(state, {
|
|
1736
|
+
reportUrl: process.env.DONOBU_REPORT_URL,
|
|
1737
|
+
});
|
|
1738
|
+
await (0, slack_1.postSlackPayload)(webhookUrl, payload);
|
|
1739
|
+
Logger_1.appLogger.info('Donobu Slack payload posted to webhook.');
|
|
1740
|
+
}
|
|
1741
|
+
catch (error) {
|
|
1742
|
+
Logger_1.appLogger.warn('Failed to POST deferred Donobu Slack payload.', error);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1799
1745
|
/**
|
|
1800
1746
|
* Apply a previously generated treatment plan manually. Engineers use this
|
|
1801
1747
|
* entrypoint to re-run a specific plan locally or in CI without waiting for
|
|
@@ -1884,17 +1830,21 @@ async function runHealCommand(cliArgs) {
|
|
|
1884
1830
|
if (triageEnabled && triageContext && exitCode !== 0) {
|
|
1885
1831
|
await postProcessTriageRun(triageContext, healArgsWithDirectives, healReportCopy?.destinationPath ?? undefined);
|
|
1886
1832
|
}
|
|
1887
|
-
|
|
1888
|
-
|
|
1833
|
+
// Prefer a Donobu reporter state file written alongside the persisted
|
|
1834
|
+
// Playwright JSON; fall back to the raw Playwright JSON for older runs.
|
|
1835
|
+
const persistedInitialReport = await loadDonobuReportForMerge(persisted.reportPath ? path.dirname(persisted.reportPath) : undefined, persisted.reportPath);
|
|
1836
|
+
const healReport = await loadDonobuReportForMerge(playwrightOutputDir, healReportCopy?.destinationPath ?? undefined);
|
|
1837
|
+
if (persistedInitialReport || healReport) {
|
|
1889
1838
|
// Write merged report directly to the original report location when
|
|
1890
1839
|
// available, otherwise fall back to a standalone file.
|
|
1891
1840
|
const mergedReportPath = persisted.reportPath
|
|
1892
1841
|
? persisted.reportPath
|
|
1893
1842
|
: path.join(path.dirname(parsed.planPath), 'donobu-heal-merged-report.json');
|
|
1894
|
-
await mergePlaywrightJsonReports({
|
|
1843
|
+
const mergedReport = await mergePlaywrightJsonReports({
|
|
1895
1844
|
initialReport: persistedInitialReport,
|
|
1896
1845
|
initialReportSourcePath: persisted.reportPath,
|
|
1897
|
-
|
|
1846
|
+
healReport,
|
|
1847
|
+
healReportSourcePath: healReportCopy?.destinationPath ?? undefined,
|
|
1898
1848
|
mergedReportPath,
|
|
1899
1849
|
healedTests: [
|
|
1900
1850
|
{
|
|
@@ -1905,7 +1855,9 @@ async function runHealCommand(cliArgs) {
|
|
|
1905
1855
|
healSucceeded: exitCode === 0,
|
|
1906
1856
|
outputDir: path.dirname(mergedReportPath),
|
|
1907
1857
|
});
|
|
1908
|
-
|
|
1858
|
+
if (mergedReport) {
|
|
1859
|
+
await regenerateDonobuReports(mergedReport);
|
|
1860
|
+
}
|
|
1909
1861
|
}
|
|
1910
1862
|
return exitCode;
|
|
1911
1863
|
}
|
|
@@ -402,6 +402,9 @@ async function generateGitHubActionsWorkflow(flowsWithToolCalls, gptConfig, opti
|
|
|
402
402
|
envVarsList.push('DONOBU_API_KEY: ${{ secrets.DONOBU_API_KEY }}');
|
|
403
403
|
}
|
|
404
404
|
envVarsList.push(...allUniqueEnvVars.map((envVarName) => `${envVarName}: \${{ secrets.${envVarName} }}`));
|
|
405
|
+
// Let the Donobu Slack reporter POST directly when a webhook secret is
|
|
406
|
+
// present, and link each message back to the workflow run.
|
|
407
|
+
envVarsList.push('DONOBU_SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}', 'DONOBU_REPORT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}');
|
|
405
408
|
const envVarsSection = envVarsList.length > 0
|
|
406
409
|
? `\n env:\n ${envVarsList.join('\n ')}`
|
|
407
410
|
: '';
|
|
@@ -459,10 +462,12 @@ ${xvfbStep}
|
|
|
459
462
|
continue-on-error: true${envVarsSection}
|
|
460
463
|
run: ${testCommand}
|
|
461
464
|
|
|
462
|
-
- name:
|
|
465
|
+
- name: Append Markdown report to GitHub Actions summary
|
|
463
466
|
if: always()
|
|
464
467
|
run: |
|
|
465
|
-
|
|
468
|
+
if [ -f test-results/report.md ]; then
|
|
469
|
+
cat test-results/report.md >> $GITHUB_STEP_SUMMARY
|
|
470
|
+
fi
|
|
466
471
|
|
|
467
472
|
- name: Save Test Reports as Artifacts
|
|
468
473
|
uses: actions/upload-artifact@v6
|
|
@@ -471,19 +476,6 @@ ${xvfbStep}
|
|
|
471
476
|
name: test-results
|
|
472
477
|
path: test-results/
|
|
473
478
|
retention-days: 3
|
|
474
|
-
|
|
475
|
-
- name: Post to Slack
|
|
476
|
-
if: always()
|
|
477
|
-
env:
|
|
478
|
-
SLACK_WEBHOOK_URL: \${{ secrets.SLACK_WEBHOOK_URL }}
|
|
479
|
-
run: |
|
|
480
|
-
if [ -n "$SLACK_WEBHOOK_URL" ]; then
|
|
481
|
-
WORKFLOW_URL="\${GITHUB_SERVER_URL}/\${GITHUB_REPOSITORY}/actions/runs/\${GITHUB_RUN_ID}"
|
|
482
|
-
SLACK_PAYLOAD=$(npm exec playwright-json-to-slack-json -- --report-url "$WORKFLOW_URL" < "test-results/playwright-report.json")
|
|
483
|
-
curl --header 'Content-type: application/json' --data "$SLACK_PAYLOAD" "$SLACK_WEBHOOK_URL"
|
|
484
|
-
else
|
|
485
|
-
echo "SLACK_WEBHOOK_URL secret not present, skipping Slack notification."
|
|
486
|
-
fi
|
|
487
479
|
${pullRequestCreationSection}`;
|
|
488
480
|
}
|
|
489
481
|
async function buildCacheContents(flowsWithToolCalls, toolRegistry) {
|
|
@@ -769,8 +761,12 @@ export default defineConfig({
|
|
|
769
761
|
projects: [ ${projects.join(',')} ],
|
|
770
762
|
use: ${JSON.stringify(useConfig, null, 2)},
|
|
771
763
|
reporter: [
|
|
772
|
-
['json', { outputFile: 'test-results/playwright-report.json' }],
|
|
773
764
|
['donobu/reporter/html'],
|
|
765
|
+
['donobu/reporter/markdown'],
|
|
766
|
+
// A Slack message will be sent to destination specified by the
|
|
767
|
+
// DONOBU_SLACK_WEBHOOK_URL (if it exists) and optionally contain a link
|
|
768
|
+
// specified by DONOBU_REPORT_URL.
|
|
769
|
+
['donobu/reporter/slack'],
|
|
774
770
|
],
|
|
775
771
|
${metadata}
|
|
776
772
|
});`;
|