donobu 5.26.0 → 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.
Files changed (87) hide show
  1. package/dist/cli/donobu-cli.js +203 -251
  2. package/dist/codegen/CodeGenerator.js +12 -16
  3. package/dist/esm/cli/donobu-cli.js +203 -251
  4. package/dist/esm/codegen/CodeGenerator.js +12 -16
  5. package/dist/esm/managers/DonobuFlowsManager.js +2 -1
  6. package/dist/esm/managers/TestsManager.js +2 -2
  7. package/dist/esm/models/CreateTest.d.ts +1 -1
  8. package/dist/esm/models/CreateTest.js +6 -0
  9. package/dist/esm/persistence/DonobuSqliteDb.js +102 -0
  10. package/dist/esm/persistence/TestConfigHash.d.ts +11 -0
  11. package/dist/esm/persistence/TestConfigHash.js +31 -0
  12. package/dist/esm/persistence/flows/FlowsPersistenceSqlite.d.ts +0 -9
  13. package/dist/esm/persistence/flows/FlowsPersistenceSqlite.js +4 -33
  14. package/dist/esm/persistence/normalizeFlowMetadata.d.ts +16 -0
  15. package/dist/esm/persistence/normalizeFlowMetadata.js +34 -0
  16. package/dist/esm/reporter/buildReport.d.ts +22 -0
  17. package/dist/esm/reporter/buildReport.js +106 -0
  18. package/dist/esm/reporter/html.d.ts +5 -9
  19. package/dist/esm/reporter/html.js +25 -101
  20. package/dist/esm/reporter/markdown.d.ts +33 -0
  21. package/dist/esm/reporter/markdown.js +62 -0
  22. package/dist/esm/reporter/merge.d.ts +33 -0
  23. package/dist/esm/reporter/merge.js +229 -0
  24. package/dist/esm/reporter/model.d.ts +101 -0
  25. package/dist/esm/reporter/model.js +27 -0
  26. package/dist/{cli/playwright-json-to-html.d.ts → esm/reporter/render.d.ts} +9 -14
  27. package/dist/esm/{cli/playwright-json-to-html.js → reporter/render.js} +52 -152
  28. package/dist/esm/reporter/renderMarkdown.d.ts +11 -0
  29. package/dist/esm/{cli/playwright-json-to-markdown.js → reporter/renderMarkdown.js} +31 -110
  30. package/dist/esm/reporter/renderSlack.d.ts +17 -0
  31. package/dist/esm/reporter/renderSlack.js +100 -0
  32. package/dist/esm/reporter/reportWalk.d.ts +28 -0
  33. package/dist/esm/reporter/reportWalk.js +61 -0
  34. package/dist/esm/reporter/slack.d.ts +93 -0
  35. package/dist/esm/reporter/slack.js +150 -0
  36. package/dist/esm/reporter/stateFile.d.ts +31 -0
  37. package/dist/esm/reporter/stateFile.js +70 -0
  38. package/dist/esm/tools/AssertPageTool.d.ts +2 -2
  39. package/dist/esm/utils/MiscUtils.d.ts +0 -13
  40. package/dist/esm/utils/MiscUtils.js +0 -21
  41. package/dist/esm/utils/displayName.d.ts +16 -0
  42. package/dist/esm/utils/displayName.js +28 -0
  43. package/dist/managers/DonobuFlowsManager.js +2 -1
  44. package/dist/managers/TestsManager.js +2 -2
  45. package/dist/models/CreateTest.d.ts +1 -1
  46. package/dist/models/CreateTest.js +6 -0
  47. package/dist/persistence/DonobuSqliteDb.js +102 -0
  48. package/dist/persistence/TestConfigHash.d.ts +11 -0
  49. package/dist/persistence/TestConfigHash.js +31 -0
  50. package/dist/persistence/flows/FlowsPersistenceSqlite.d.ts +0 -9
  51. package/dist/persistence/flows/FlowsPersistenceSqlite.js +4 -33
  52. package/dist/persistence/normalizeFlowMetadata.d.ts +16 -0
  53. package/dist/persistence/normalizeFlowMetadata.js +34 -0
  54. package/dist/reporter/buildReport.d.ts +22 -0
  55. package/dist/reporter/buildReport.js +106 -0
  56. package/dist/reporter/html.d.ts +5 -9
  57. package/dist/reporter/html.js +25 -101
  58. package/dist/reporter/markdown.d.ts +33 -0
  59. package/dist/reporter/markdown.js +62 -0
  60. package/dist/reporter/merge.d.ts +33 -0
  61. package/dist/reporter/merge.js +229 -0
  62. package/dist/reporter/model.d.ts +101 -0
  63. package/dist/reporter/model.js +27 -0
  64. package/dist/{esm/cli/playwright-json-to-html.d.ts → reporter/render.d.ts} +9 -14
  65. package/dist/{cli/playwright-json-to-html.js → reporter/render.js} +52 -152
  66. package/dist/reporter/renderMarkdown.d.ts +11 -0
  67. package/dist/{cli/playwright-json-to-markdown.js → reporter/renderMarkdown.js} +31 -110
  68. package/dist/reporter/renderSlack.d.ts +17 -0
  69. package/dist/reporter/renderSlack.js +100 -0
  70. package/dist/reporter/reportWalk.d.ts +28 -0
  71. package/dist/reporter/reportWalk.js +61 -0
  72. package/dist/reporter/slack.d.ts +93 -0
  73. package/dist/reporter/slack.js +150 -0
  74. package/dist/reporter/stateFile.d.ts +31 -0
  75. package/dist/reporter/stateFile.js +70 -0
  76. package/dist/tools/AssertPageTool.d.ts +2 -2
  77. package/dist/utils/MiscUtils.d.ts +0 -13
  78. package/dist/utils/MiscUtils.js +0 -21
  79. package/dist/utils/displayName.d.ts +16 -0
  80. package/dist/utils/displayName.js +28 -0
  81. package/package.json +11 -5
  82. package/dist/cli/playwright-json-to-markdown.d.ts +0 -43
  83. package/dist/cli/playwright-json-to-slack-json.d.ts +0 -3
  84. package/dist/cli/playwright-json-to-slack-json.js +0 -214
  85. package/dist/esm/cli/playwright-json-to-markdown.d.ts +0 -43
  86. package/dist/esm/cli/playwright-json-to-slack-json.d.ts +0 -3
  87. package/dist/esm/cli/playwright-json-to-slack-json.js +0 -214
@@ -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
- if (params.initialReport || healReportCopy) {
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
- healReportPath: healReportCopy?.destinationPath ?? undefined,
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
- await regenerateDonobuHtmlReport(mergedReportPath, params.playwrightOutputDir);
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 regenerateDonobuHtmlReport(mergedReportPath, playwrightOutputDir) {
1377
- const sidecarPath = path.join(playwrightOutputDir, '.donobu-html-reporter.json');
1378
- let outputFile;
1379
- try {
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
- catch {
1387
- return; // Sidecar absent — reporter not configured, nothing to do.
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
- const mergedJson = JSON.parse(await fs_1.promises.readFile(mergedReportPath, 'utf-8'));
1391
- let triage = { plans: [], evidence: [] };
1392
- if (mergedJson.metadata?.triageRunDir) {
1404
+ let triage = {
1405
+ plans: [],
1406
+ evidence: [],
1407
+ };
1408
+ if (mergedReport.metadata?.triageRunDir) {
1393
1409
  try {
1394
- triage = (0, playwright_json_to_html_1.loadTriageData)(mergedJson.metadata.triageRunDir);
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, playwright_json_to_html_1.generateHtml)(mergedJson, triage, outputDir);
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
- * Combine the JSON reports from the original failed run and any auto-heal rerun.
1412
- * The merged output is useful for dashboards that expect a single report file.
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 initialReport = params.initialReport ?? null;
1416
- const healReport = await readJsonIfExists(params.healReportPath);
1417
- if (!initialReport && !healReport) {
1418
- return;
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
- // Clone the reports so we never mutate the on-disk originals.
1421
- const combined = initialReport
1422
- ? JSON.parse(JSON.stringify(initialReport))
1423
- : JSON.parse(JSON.stringify(healReport));
1424
- const initialIndex = indexReport(initialReport);
1425
- const combinedIndex = indexReport(combined);
1426
- const healIndex = indexReport(healReport);
1427
- const healedKeys = new Set();
1428
- if (healReport) {
1429
- const processedHealEntries = new Set();
1430
- const processHealEntry = (healEntry) => {
1431
- const key = buildTestKey(healEntry.suite.file, healEntry.test.projectName, healEntry.test.title);
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(combined, params.outputDir);
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(combined, null, 2), 'utf8');
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 initialReport = null;
1676
+ let loadedPlaywrightJson = null;
1677
+ let initialDonobuReport = null;
1772
1678
  if (triageEnabled) {
1773
- initialReport = await loadJsonReport(playwrightOutputDir, {
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 (initialReport) {
1780
- reportTargets.add(initialReport.sourcePath);
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, initialReport?.sourcePath);
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: initialReport?.data,
1793
- initialReportSourcePath: initialReport?.sourcePath,
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
- const persistedInitialReport = await readJsonIfExists(persisted.reportPath);
1888
- if (persistedInitialReport || healReportCopy) {
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
- healReportPath: healReportCopy?.destinationPath ?? undefined,
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
- await regenerateDonobuHtmlReport(mergedReportPath, playwrightOutputDir);
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: Generate GitHub Workflow Summary
465
+ - name: Append Markdown report to GitHub Actions summary
463
466
  if: always()
464
467
  run: |
465
- npm exec playwright-json-to-markdown test-results/playwright-report.json >> $GITHUB_STEP_SUMMARY
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
  });`;