donobu 5.60.1 → 5.60.3

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 (43) hide show
  1. package/dist/cli/donobu-cli.js +158 -52
  2. package/dist/envVars.d.ts +4 -0
  3. package/dist/envVars.js +12 -0
  4. package/dist/esm/cli/donobu-cli.js +158 -52
  5. package/dist/esm/envVars.d.ts +4 -0
  6. package/dist/esm/envVars.js +12 -0
  7. package/dist/esm/lib/page/extendPage.d.ts +6 -0
  8. package/dist/esm/lib/page/extendPage.js +24 -1
  9. package/dist/esm/lib/test/healRerunGate.d.ts +102 -0
  10. package/dist/esm/lib/test/healRerunGate.js +228 -0
  11. package/dist/esm/lib/test/testExtension.d.ts +1 -0
  12. package/dist/esm/lib/test/testExtension.js +20 -10
  13. package/dist/esm/managers/DonobuStack.d.ts +19 -19
  14. package/dist/esm/reporter/buildReport.js +54 -1
  15. package/dist/esm/reporter/merge.d.ts +1 -6
  16. package/dist/esm/reporter/merge.js +57 -35
  17. package/dist/esm/reporter/model.d.ts +16 -0
  18. package/dist/esm/reporter/model.js +10 -1
  19. package/dist/esm/reporter/render.js +34 -12
  20. package/dist/esm/reporter/renderMarkdown.js +148 -93
  21. package/dist/esm/reporter/renderSlack.js +39 -28
  22. package/dist/esm/reporter/reportWalk.d.ts +16 -6
  23. package/dist/esm/reporter/reportWalk.js +63 -13
  24. package/dist/esm/utils/BrowserUtils.d.ts +4 -4
  25. package/dist/lib/page/extendPage.d.ts +6 -0
  26. package/dist/lib/page/extendPage.js +24 -1
  27. package/dist/lib/test/healRerunGate.d.ts +102 -0
  28. package/dist/lib/test/healRerunGate.js +228 -0
  29. package/dist/lib/test/testExtension.d.ts +1 -0
  30. package/dist/lib/test/testExtension.js +20 -10
  31. package/dist/managers/DonobuStack.d.ts +19 -19
  32. package/dist/reporter/buildReport.js +54 -1
  33. package/dist/reporter/merge.d.ts +1 -6
  34. package/dist/reporter/merge.js +57 -35
  35. package/dist/reporter/model.d.ts +16 -0
  36. package/dist/reporter/model.js +10 -1
  37. package/dist/reporter/render.js +34 -12
  38. package/dist/reporter/renderMarkdown.js +148 -93
  39. package/dist/reporter/renderSlack.js +39 -28
  40. package/dist/reporter/reportWalk.d.ts +16 -6
  41. package/dist/reporter/reportWalk.js +63 -13
  42. package/dist/utils/BrowserUtils.d.ts +4 -4
  43. package/package.json +4 -4
@@ -15,6 +15,8 @@
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.mergeReports = mergeReports;
17
17
  exports.buildTestKey = buildTestKey;
18
+ const model_1 = require("./model");
19
+ const reportWalk_1 = require("./reportWalk");
18
20
  function mergeReports(params) {
19
21
  const { initialReport, healReport } = params;
20
22
  if (!initialReport && !healReport) {
@@ -29,7 +31,17 @@ function mergeReports(params) {
29
31
  if (healReport) {
30
32
  const processedHealEntries = new Set();
31
33
  const processHealEntry = (healEntry) => {
32
- const key = buildTestKey(healEntry.suite.file, healEntry.test.projectName, healEntry.test.title);
34
+ // Tests the rerun's collection gate statically skipped carry zero
35
+ // information — the initial run's result for them stands untouched, so
36
+ // their heal entries are dropped wholesale (no appended attempts, no
37
+ // status churn, no skip noise in the merged report).
38
+ if (healEntry.test.annotations?.some((annotation) => annotation.type === model_1.HEAL_SKIP_REPLAY_ANNOTATION_TYPE)) {
39
+ return;
40
+ }
41
+ // Titles live on the spec in both report shapes (the Donobu state file
42
+ // and Playwright's native JSON); `test.title` is never set. Without the
43
+ // spec fallback all tests in a file/project collapse to one key.
44
+ const key = buildTestKey(healEntry.suite.file, healEntry.test.projectName, healEntry.test.title ?? healEntry.spec.title);
33
45
  let combinedEntry = (healEntry.test.testId
34
46
  ? combinedIndex.byId.get(healEntry.test.testId)
35
47
  : undefined) ??
@@ -54,16 +66,47 @@ function mergeReports(params) {
54
66
  ...healEntry.test.results,
55
67
  ];
56
68
  }
57
- if (healEntry.test.status !== undefined) {
58
- combinedTest.status = healEntry.test.status;
59
- }
60
- if (healEntry.test.outcome !== undefined) {
61
- combinedTest.outcome = healEntry.test.outcome;
62
- }
63
69
  const originalStatus = originalEntry
64
70
  ? getFinalResultStatus(originalEntry.test)
65
71
  : undefined;
66
72
  const healStatus = getFinalResultStatus(healEntry.test);
73
+ const originalFailed = originalStatus !== undefined &&
74
+ originalStatus !== 'passed' &&
75
+ originalStatus !== 'skipped';
76
+ // The heal rerun may only improve a test's outcome:
77
+ // - It must not relabel a genuine failure as "skipped" (a rerun in
78
+ // which the test skipped itself — e.g. a precondition guard fired —
79
+ // used to turn red runs green).
80
+ // - It must not flip an initially-passing test red: rerun failures of
81
+ // tests that tagged along (serial siblings, dependency projects) are
82
+ // advisory, recorded in the attempt history and an annotation.
83
+ const adoptHealStatus = healStatus === 'passed' ||
84
+ (originalStatus !== 'passed' && healStatus !== 'skipped');
85
+ if (adoptHealStatus) {
86
+ if (healEntry.test.status !== undefined) {
87
+ combinedTest.status = healEntry.test.status;
88
+ }
89
+ if (healEntry.test.outcome !== undefined) {
90
+ combinedTest.outcome = healEntry.test.outcome;
91
+ }
92
+ }
93
+ else if (originalFailed && healStatus === 'skipped') {
94
+ combinedTest.annotations = combinedTest.annotations ?? [];
95
+ combinedTest.annotations.push({
96
+ type: 'auto-heal-not-reattempted',
97
+ description: 'The auto-heal rerun could not re-attempt this test: it skipped itself during the rerun because a precondition set up by another test was missing. The original failure stands. To make this test heal-eligible, declare the ordering explicitly — via Playwright project `dependencies` or `test.describe.serial` — so the rerun can execute the prerequisites.',
98
+ });
99
+ }
100
+ else if (originalStatus === 'passed' &&
101
+ healStatus !== undefined &&
102
+ healStatus !== 'passed' &&
103
+ healStatus !== 'skipped') {
104
+ combinedTest.annotations = combinedTest.annotations ?? [];
105
+ combinedTest.annotations.push({
106
+ type: 'auto-heal-rerun-failed',
107
+ description: 'This test passed in the initial run but failed when re-run during the auto-heal pass. The initial result stands; the failed rerun attempt is preserved in the attempt history.',
108
+ });
109
+ }
67
110
  if (healStatus === 'passed' &&
68
111
  originalStatus &&
69
112
  originalStatus !== 'passed') {
@@ -90,26 +133,6 @@ function mergeReports(params) {
90
133
  iterateEntries(healIndex.byId);
91
134
  iterateEntries(healIndex.byKey);
92
135
  }
93
- if (params.healSucceeded && healedKeys.size === 0) {
94
- params.healedTests.forEach((descriptor) => {
95
- // Descriptors must arrive with `testCase.file` already normalized to the
96
- // same form that suite.file has in the reports — the caller owns path
97
- // normalization because it has access to the CWD the run was launched in.
98
- const key = buildTestKey(descriptor.testCase.file, descriptor.testCase.projectName, descriptor.testCase.title);
99
- const entry = combinedIndex.byKey.get(key);
100
- if (entry) {
101
- entry.test.annotations = entry.test.annotations ?? [];
102
- if (!entry.test.annotations.some((annotation) => annotation.type === 'self-healed')) {
103
- entry.test.annotations.push({
104
- type: 'self-healed',
105
- description: 'Automatically healed by Donobu auto-heal rerun after applying treatment plan.',
106
- });
107
- }
108
- entry.test.donobuStatus = 'healed';
109
- healedKeys.add(key);
110
- }
111
- });
112
- }
113
136
  combined.stats = computeReportStats(combined);
114
137
  const mergedMetadata = {
115
138
  ...(combined.metadata ?? {}),
@@ -152,7 +175,7 @@ function indexReport(report) {
152
175
  if (test.testId) {
153
176
  byId.set(test.testId, entry);
154
177
  }
155
- const key = buildTestKey(suite.file, test.projectName, test.title);
178
+ const key = buildTestKey(suite.file, test.projectName, test.title ?? spec.title);
156
179
  byKey.set(key, entry);
157
180
  });
158
181
  });
@@ -195,9 +218,13 @@ function computeReportStats(report) {
195
218
  if (finalResult?.duration) {
196
219
  duration += finalResult.duration;
197
220
  }
198
- const status = finalResult?.status ?? test.status;
199
- switch (status) {
221
+ // Classify from the full attempt history so a heal-rerun skip can't
222
+ // hide an earlier genuine failure, retry recoveries count as flaky,
223
+ // and expected failures (`test.fail()`) count as expected.
224
+ switch ((0, reportWalk_1.statusOf)(test)) {
200
225
  case 'passed':
226
+ case 'expectedFailure':
227
+ case 'healed':
201
228
  expected += 1;
202
229
  break;
203
230
  case 'skipped':
@@ -206,11 +233,6 @@ function computeReportStats(report) {
206
233
  case 'flaky':
207
234
  flaky += 1;
208
235
  break;
209
- case 'failed':
210
- case 'timedOut':
211
- case 'interrupted':
212
- unexpected += 1;
213
- break;
214
236
  default:
215
237
  unexpected += 1;
216
238
  }
@@ -21,6 +21,15 @@
21
21
  * stable across Donobu versions.
22
22
  */
23
23
  export declare const DONOBU_REPORT_STATE_FILENAME = ".donobu-report-state.json";
24
+ /**
25
+ * Annotation type stamped on tests the auto-heal rerun statically skipped
26
+ * because they were not part of the rerun plan (they passed the initial run
27
+ * and were not prerequisites of any heal target). The merge step drops these
28
+ * heal-run entries entirely: they carry no information, and the initial run's
29
+ * result for those tests stands untouched. Defined here (rather than in the
30
+ * test-side gate) so the pure reporter modules don't import test runtime code.
31
+ */
32
+ export declare const HEAL_SKIP_REPLAY_ANNOTATION_TYPE = "donobu-heal-skip-replay";
24
33
  /**
25
34
  * Per-format output record written by each Donobu reporter into the shared
26
35
  * state file. The auto-heal orchestrator reads this map after merging two
@@ -48,6 +57,13 @@ export interface DonobuReportMetadata {
48
57
  /** True on reports that are the result of merging an initial + heal run. */
49
58
  donobuMergedReport?: boolean;
50
59
  mergedAtIso?: string;
60
+ /**
61
+ * Declared Playwright project dependency graph (`FullProject.dependencies`)
62
+ * keyed by project name, recorded by the reporter. The auto-heal
63
+ * orchestrator uses it to exclude declared dependencies of heal targets
64
+ * from the rerun gate — they must run in full.
65
+ */
66
+ projectDependencies?: Record<string, string[]>;
51
67
  sources?: {
52
68
  initial?: string | null;
53
69
  autoHeal?: string | null;
@@ -14,7 +14,7 @@
14
14
  * retype the whole Playwright reporter surface.
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.DONOBU_REPORT_STATE_FILENAME = void 0;
17
+ exports.HEAL_SKIP_REPLAY_ANNOTATION_TYPE = exports.DONOBU_REPORT_STATE_FILENAME = void 0;
18
18
  /**
19
19
  * Filename the reporter writes into each run's Playwright output directory.
20
20
  * The auto-heal orchestrator looks for this file (in both the initial run's
@@ -24,4 +24,13 @@ exports.DONOBU_REPORT_STATE_FILENAME = void 0;
24
24
  * stable across Donobu versions.
25
25
  */
26
26
  exports.DONOBU_REPORT_STATE_FILENAME = '.donobu-report-state.json';
27
+ /**
28
+ * Annotation type stamped on tests the auto-heal rerun statically skipped
29
+ * because they were not part of the rerun plan (they passed the initial run
30
+ * and were not prerequisites of any heal target). The merge step drops these
31
+ * heal-run entries entirely: they carry no information, and the initial run's
32
+ * result for those tests stands untouched. Defined here (rather than in the
33
+ * test-side gate) so the pure reporter modules don't import test runtime code.
34
+ */
35
+ exports.HEAL_SKIP_REPLAY_ANNOTATION_TYPE = 'donobu-heal-skip-replay';
27
36
  //# sourceMappingURL=model.js.map
@@ -253,18 +253,10 @@ function extractTests(jsonData) {
253
253
  for (const test of spec.tests ?? []) {
254
254
  const annotations = test.annotations ?? [];
255
255
  const isSelfHealed = (0, reportWalk_1.isSelfHealed)(test);
256
- let status;
257
- const lastResult = test.results?.at(-1);
258
- if (test.status === 'skipped' ||
259
- (!lastResult && test.status === undefined)) {
260
- status = 'skipped';
261
- }
262
- else if (isSelfHealed) {
263
- status = 'healed';
264
- }
265
- else {
266
- status = lastResult?.status ?? 'unknown';
267
- }
256
+ // Derived from the full attempt history (see reportWalk.statusOf):
257
+ // surviving failures stay failed even when a heal-rerun skip is the
258
+ // final attempt, and retry recoveries surface as 'flaky'.
259
+ const status = (0, reportWalk_1.statusOf)(test);
268
260
  const objectiveAnnotation = annotations.find((a) => a.type === 'objective');
269
261
  const results = (test.results ?? []).map((r, i) => {
270
262
  const attachments = r.attachments ?? [];
@@ -394,6 +386,18 @@ const STATUS_CFG = {
394
386
  bg: 'rgba(16,185,129,0.08)',
395
387
  icon: '&#10003;',
396
388
  },
389
+ flaky: {
390
+ label: 'Flaky',
391
+ color: '#eab308',
392
+ bg: 'rgba(234,179,8,0.08)',
393
+ icon: '&#8635;',
394
+ },
395
+ expectedFailure: {
396
+ label: 'Expected Failure',
397
+ color: '#14b8a6',
398
+ bg: 'rgba(20,184,166,0.08)',
399
+ icon: '&#9745;',
400
+ },
397
401
  failed: {
398
402
  label: 'Failed',
399
403
  color: '#ef4444',
@@ -1524,6 +1528,8 @@ function renderHtml(report, triage, outputDir) {
1524
1528
  // Counts
1525
1529
  const counts = {
1526
1530
  passed: 0,
1531
+ flaky: 0,
1532
+ expectedFailure: 0,
1527
1533
  failed: 0,
1528
1534
  healed: 0,
1529
1535
  timedOut: 0,
@@ -1566,6 +1572,8 @@ function renderHtml(report, triage, outputDir) {
1566
1572
  // Build test bar blocks (one square per test, ordered by status, clickable)
1567
1573
  const statusOrder = [
1568
1574
  'passed',
1575
+ 'flaky',
1576
+ 'expectedFailure',
1569
1577
  'healed',
1570
1578
  'failed',
1571
1579
  'timedOut',
@@ -1594,6 +1602,18 @@ function renderHtml(report, triage, outputDir) {
1594
1602
  icon: '&#10003;',
1595
1603
  iconClass: 'icon-pass',
1596
1604
  },
1605
+ {
1606
+ key: 'flaky',
1607
+ label: 'Flaky',
1608
+ icon: '&#8635;',
1609
+ iconClass: 'icon-flaky',
1610
+ },
1611
+ {
1612
+ key: 'expectedFailure',
1613
+ label: 'Expected Failures',
1614
+ icon: '&#9745;',
1615
+ iconClass: 'icon-expected-failure',
1616
+ },
1597
1617
  { key: 'healed', label: 'Healed', icon: '&#9829;', iconClass: 'icon-heal' },
1598
1618
  { key: 'failed', label: 'Failed', icon: '&#9888;', iconClass: 'icon-fail' },
1599
1619
  {
@@ -1793,6 +1813,8 @@ body::before{content:'';position:fixed;top:-750px;left:50%;transform:translateX(
1793
1813
  .test-bar-block[data-tooltip]:hover::after{content:attr(data-tooltip);position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:11px;font-family:var(--font);white-space:nowrap;z-index:10;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.2)}
1794
1814
  .test-bar-block[data-tooltip]:hover::before{content:'';position:absolute;top:calc(100% + 2px);left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--border);z-index:10;pointer-events:none}
1795
1815
  .test-bar-block.bar-passed{background:var(--bar-pass)}
1816
+ .test-bar-block.bar-flaky{background:var(--bar-warn)}
1817
+ .test-bar-block.bar-expectedfailure{background:var(--bar-pass);opacity:.55}
1796
1818
  .test-bar-block.bar-healed{background:var(--bar-heal)}
1797
1819
  .test-bar-block.bar-failed{background:var(--bar-fail)}
1798
1820
  .test-bar-block.bar-timedout,.test-bar-block.bar-interrupted{background:var(--bar-warn)}
@@ -11,6 +11,34 @@ Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.renderMarkdown = renderMarkdown;
12
12
  const ansi_1 = require("../utils/ansi");
13
13
  const reportWalk_1 = require("./reportWalk");
14
+ const STATUS_LABELS = {
15
+ passed: '✅ Passed',
16
+ flaky: '🔁 Flaky',
17
+ expectedFailure: '☑️ Expected Failure',
18
+ healed: '❤️‍🩹 Healed',
19
+ failed: '❌ Failed',
20
+ timedOut: '⏰ Timed Out',
21
+ skipped: '⏭️ Skipped',
22
+ interrupted: '⚡ Interrupted',
23
+ unknown: '⚠️ Unknown',
24
+ };
25
+ /**
26
+ * The attempt whose details (duration, errors) the per-test section shows.
27
+ * For surviving failures this is the last failing attempt — the final entry
28
+ * in `results` may be a heal-rerun skip that carries no error context.
29
+ */
30
+ function displayResultOf(test, status) {
31
+ const results = test.results ?? [];
32
+ if (status === 'failed' ||
33
+ status === 'timedOut' ||
34
+ status === 'interrupted') {
35
+ const failures = results.filter((r) => ['failed', 'timedOut', 'interrupted'].includes(r?.status));
36
+ if (failures.length > 0) {
37
+ return failures.at(-1);
38
+ }
39
+ }
40
+ return results.at(-1);
41
+ }
14
42
  function formatDuration(ms) {
15
43
  if (ms < 1000) {
16
44
  return `${ms}ms`;
@@ -29,69 +57,111 @@ function renderMarkdown(report) {
29
57
  if (report.metadata?.donobuMergedReport) {
30
58
  markdown += `> ⚙️ Auto-heal summary generated by Donobu (merged initial and retry runs).\n\n`;
31
59
  }
32
- // Per-file summary table
60
+ // Per-file summary table. Counts are computed for all suites first so the
61
+ // rarely-used Expected Failures column can be omitted when it would be
62
+ // entirely empty.
33
63
  markdown += `## Summary\n\n`;
34
- markdown += `| File | Passed | Self-Healed | Failed | Timed Out | Skipped | Interrupted | Duration |\n`;
35
- markdown += `| - | - | - | - | - | - | - | - |\n`;
36
- let totalPassed = 0;
37
- let totalFailed = 0;
38
- let totalTimedOut = 0;
39
- let totalSkipped = 0;
40
- let totalInterrupted = 0;
41
- let totalSelfHealed = 0;
42
- let totalDuration = 0;
43
- suites.forEach((suite) => {
44
- let passed = 0;
45
- let failed = 0;
46
- let timedOut = 0;
47
- let skipped = 0;
48
- let interrupted = 0;
49
- let selfHealed = 0;
50
- const allSpecs = (0, reportWalk_1.collectSpecs)(suite);
51
- const fileDuration = allSpecs.reduce((total, spec) => total +
52
- (spec.tests ?? []).reduce((testTotal, test) => {
53
- const result = test.results?.at(-1);
54
- const healed = (0, reportWalk_1.isSelfHealed)(test);
55
- if (test.status === 'skipped' ||
56
- (!result && test.status === undefined)) {
57
- skipped++;
58
- }
59
- else if (result) {
60
- if (healed) {
61
- selfHealed++;
62
- }
63
- else {
64
- switch (result.status) {
65
- case 'passed':
66
- passed++;
67
- break;
68
- case 'failed':
69
- failed++;
70
- break;
71
- case 'timedOut':
72
- timedOut++;
73
- break;
74
- case 'skipped':
75
- skipped++;
76
- break;
77
- case 'interrupted':
78
- interrupted++;
79
- break;
80
- }
81
- }
64
+ const suiteRows = suites.map((suite) => {
65
+ const row = {
66
+ file: suite.file,
67
+ passed: 0,
68
+ flaky: 0,
69
+ expectedFailures: 0,
70
+ selfHealed: 0,
71
+ failed: 0,
72
+ timedOut: 0,
73
+ skipped: 0,
74
+ interrupted: 0,
75
+ duration: 0,
76
+ };
77
+ for (const spec of (0, reportWalk_1.collectSpecs)(suite)) {
78
+ for (const test of spec.tests ?? []) {
79
+ row.duration += test.results?.at(-1)?.duration || 0;
80
+ switch ((0, reportWalk_1.statusOf)(test)) {
81
+ case 'passed':
82
+ row.passed++;
83
+ break;
84
+ case 'flaky':
85
+ row.flaky++;
86
+ break;
87
+ case 'expectedFailure':
88
+ row.expectedFailures++;
89
+ break;
90
+ case 'healed':
91
+ row.selfHealed++;
92
+ break;
93
+ case 'timedOut':
94
+ row.timedOut++;
95
+ break;
96
+ case 'skipped':
97
+ row.skipped++;
98
+ break;
99
+ case 'interrupted':
100
+ row.interrupted++;
101
+ break;
102
+ default:
103
+ // 'failed' and 'unknown' both belong in the Failed column —
104
+ // dropping unknowns from the table would hide real problems.
105
+ row.failed++;
106
+ break;
82
107
  }
83
- return testTotal + (result?.duration || 0);
84
- }, 0), 0);
85
- totalPassed += passed;
86
- totalFailed += failed;
87
- totalTimedOut += timedOut;
88
- totalSkipped += skipped;
89
- totalInterrupted += interrupted;
90
- totalSelfHealed += selfHealed;
91
- totalDuration += fileDuration;
92
- markdown += `| ${suite.file} | ${passed ? passed + ' ✅' : ''} | ${selfHealed ? selfHealed + ' ❤️‍🩹' : ''} | ${failed ? failed + ' ❌' : ''} | ${timedOut ? timedOut + ' ⏰' : ''} | ${skipped ? skipped + ' ⏭️' : ''} | ${interrupted ? interrupted + ' ⚡' : ''} | ${formatDuration(fileDuration)} |\n`;
108
+ }
109
+ }
110
+ return row;
93
111
  });
94
- markdown += `| **TOTAL** | **${totalPassed + ' ✅'}** | **${totalSelfHealed + ' ❤️‍🩹'}** | **${totalFailed + ' ❌'}** | **${totalTimedOut + ' ⏰'}** | **${totalSkipped + ' ⏭️'}** | **${totalInterrupted + ' ⚡'}** | **${formatDuration(totalDuration)}** |\n`;
112
+ const totals = suiteRows.reduce((acc, row) => ({
113
+ file: '**TOTAL**',
114
+ passed: acc.passed + row.passed,
115
+ flaky: acc.flaky + row.flaky,
116
+ expectedFailures: acc.expectedFailures + row.expectedFailures,
117
+ selfHealed: acc.selfHealed + row.selfHealed,
118
+ failed: acc.failed + row.failed,
119
+ timedOut: acc.timedOut + row.timedOut,
120
+ skipped: acc.skipped + row.skipped,
121
+ interrupted: acc.interrupted + row.interrupted,
122
+ duration: acc.duration + row.duration,
123
+ }), {
124
+ file: '**TOTAL**',
125
+ passed: 0,
126
+ flaky: 0,
127
+ expectedFailures: 0,
128
+ selfHealed: 0,
129
+ failed: 0,
130
+ timedOut: 0,
131
+ skipped: 0,
132
+ interrupted: 0,
133
+ duration: 0,
134
+ });
135
+ const includeExpectedFailures = totals.expectedFailures > 0;
136
+ const expectedFailuresHeader = includeExpectedFailures
137
+ ? ` Expected Failures |`
138
+ : '';
139
+ markdown += `| File | Passed | Flaky |${expectedFailuresHeader} Self-Healed | Failed | Timed Out | Skipped | Interrupted | Duration |\n`;
140
+ markdown += `| - | - | - |${includeExpectedFailures ? ' - |' : ''} - | - | - | - | - | - |\n`;
141
+ const cell = (count, emoji, bold) => {
142
+ const text = bold
143
+ ? `**${count} ${emoji}**`
144
+ : count
145
+ ? `${count} ${emoji}`
146
+ : '';
147
+ return ` ${text} |`;
148
+ };
149
+ for (const row of [...suiteRows, totals]) {
150
+ const bold = row === totals;
151
+ markdown += `| ${row.file} |`;
152
+ markdown += cell(row.passed, '✅', bold);
153
+ markdown += cell(row.flaky, '🔁', bold);
154
+ if (includeExpectedFailures) {
155
+ markdown += cell(row.expectedFailures, '☑️', bold);
156
+ }
157
+ markdown += cell(row.selfHealed, '❤️‍🩹', bold);
158
+ markdown += cell(row.failed, '❌', bold);
159
+ markdown += cell(row.timedOut, '⏰', bold);
160
+ markdown += cell(row.skipped, '⏭️', bold);
161
+ markdown += cell(row.interrupted, '⚡', bold);
162
+ const duration = formatDuration(row.duration);
163
+ markdown += ` ${bold ? `**${duration}**` : duration} |\n`;
164
+ }
95
165
  markdown += `\n`;
96
166
  // Per-test details
97
167
  suites.forEach((suite) => {
@@ -100,9 +170,9 @@ function renderMarkdown(report) {
100
170
  (0, reportWalk_1.collectSpecs)(suite).forEach((spec) => {
101
171
  markdown += `### ${spec.title}\n\n`;
102
172
  (spec.tests ?? []).forEach((test) => {
103
- const result = test.results?.at(-1);
104
- if (test.status === 'skipped' ||
105
- (!result && test.status === undefined)) {
173
+ const status = (0, reportWalk_1.statusOf)(test);
174
+ const result = displayResultOf(test, status);
175
+ if (status === 'skipped') {
106
176
  markdown += `**Status**: ⏭️ Skipped \n`;
107
177
  markdown += `**Duration**: N/A \n`;
108
178
  if (Array.isArray(test.tags) && test.tags.length > 0) {
@@ -116,47 +186,32 @@ function renderMarkdown(report) {
116
186
  markdown += `\n---\n\n`;
117
187
  return;
118
188
  }
119
- const healed = (0, reportWalk_1.isSelfHealed)(test);
120
- let status;
121
- if (healed) {
122
- status = '❤️‍🩹 Healed';
123
- }
124
- else {
125
- switch (result.status) {
126
- case 'passed':
127
- status = '✅ Passed';
128
- break;
129
- case 'failed':
130
- status = '❌ Failed';
131
- break;
132
- case 'timedOut':
133
- status = '⏰ Timed Out';
134
- break;
135
- case 'skipped':
136
- status = '⏭️ Skipped';
137
- break;
138
- case 'interrupted':
139
- status = '⚡ Interrupted';
140
- break;
141
- default:
142
- status = `⚠️ ${result.status || 'Unknown'}`;
143
- }
144
- }
145
- const duration = formatDuration(result.duration || 0);
146
- markdown += `**Status**: ${status} \n`;
189
+ const duration = formatDuration(result?.duration || 0);
190
+ markdown += `**Status**: ${STATUS_LABELS[status]} \n`;
147
191
  markdown += `**Duration**: ${duration} \n`;
148
192
  if (Array.isArray(test.tags) && test.tags.length > 0) {
149
193
  markdown += `**Tags**: ${test.tags.join(' ')} \n`;
150
194
  }
151
- if (healed) {
195
+ if (status === 'healed') {
152
196
  markdown += `> ❤️‍🩹 This test was automatically healed by re-running with Donobu treatment plan directives.\n\n`;
153
197
  }
198
+ if (status === 'flaky') {
199
+ markdown += `> 🔁 This test failed and then passed on a retry.\n\n`;
200
+ }
201
+ const notReattempted = test.annotations?.find((a) => a.type === 'auto-heal-not-reattempted');
202
+ if (notReattempted) {
203
+ markdown += `> ⚠️ ${notReattempted.description}\n\n`;
204
+ }
205
+ const rerunFailed = test.annotations?.find((a) => a.type === 'auto-heal-rerun-failed');
206
+ if (rerunFailed) {
207
+ markdown += `> ⚠️ ${rerunFailed.description}\n\n`;
208
+ }
154
209
  const objectiveAnnotation = test.annotations?.find((a) => a.type === 'objective');
155
210
  if (objectiveAnnotation) {
156
211
  const objective = (objectiveAnnotation.description || 'No objective provided').replace(/```/g, '\\`\\`\\`');
157
212
  markdown += `**Objective**:\n\`\`\`\n${objective}\n\`\`\`\n`;
158
213
  }
159
- if (result.status === 'failed' && result.error) {
214
+ if (result?.status === 'failed' && result.error) {
160
215
  markdown += `\n<details>\n<summary>⚠️ Error Details</summary>\n\n`;
161
216
  markdown += `\`\`\`\n${(0, ansi_1.stripAnsi)(result.error.message || '') || 'No error message available'}\n\`\`\`\n\n`;
162
217
  if (result.error.snippet) {
@@ -17,6 +17,8 @@ function renderSlack(report, options = {}) {
17
17
  text: { type: 'plain_text', text: '🐵 Donobu Test Summary' },
18
18
  });
19
19
  let totalPassed = 0;
20
+ let totalFlaky = 0;
21
+ let totalExpectedFailures = 0;
20
22
  let totalFailed = 0;
21
23
  let totalTimedOut = 0;
22
24
  let totalSkipped = 0;
@@ -25,41 +27,50 @@ function renderSlack(report, options = {}) {
25
27
  suites.forEach((suite) => {
26
28
  (0, reportWalk_1.collectSpecs)(suite).forEach((spec) => {
27
29
  (spec.tests ?? []).forEach((test) => {
28
- const result = test.results?.at(-1);
29
- const healed = (0, reportWalk_1.isSelfHealed)(test);
30
- if (test.status === 'skipped' ||
31
- (!result && test.status === undefined)) {
32
- totalSkipped++;
33
- }
34
- else if (result) {
35
- if (healed) {
30
+ switch ((0, reportWalk_1.statusOf)(test)) {
31
+ case 'passed':
32
+ totalPassed++;
33
+ break;
34
+ case 'flaky':
35
+ totalFlaky++;
36
+ break;
37
+ case 'expectedFailure':
38
+ totalExpectedFailures++;
39
+ break;
40
+ case 'healed':
36
41
  totalSelfHealed++;
37
- }
38
- else {
39
- switch (result.status) {
40
- case 'passed':
41
- totalPassed++;
42
- break;
43
- case 'failed':
44
- totalFailed++;
45
- break;
46
- case 'timedOut':
47
- totalTimedOut++;
48
- break;
49
- case 'skipped':
50
- totalSkipped++;
51
- break;
52
- case 'interrupted':
53
- totalInterrupted++;
54
- break;
55
- }
56
- }
42
+ break;
43
+ case 'timedOut':
44
+ totalTimedOut++;
45
+ break;
46
+ case 'skipped':
47
+ totalSkipped++;
48
+ break;
49
+ case 'interrupted':
50
+ totalInterrupted++;
51
+ break;
52
+ default:
53
+ // 'failed' and 'unknown' both count as failures.
54
+ totalFailed++;
55
+ break;
57
56
  }
58
57
  });
59
58
  });
60
59
  });
61
60
  const statusRows = [
62
61
  { name: 'Passed', emoji: '✅', count: totalPassed },
62
+ { name: 'Flaky', emoji: '🔁', count: totalFlaky },
63
+ // Expected failures are rare enough that a permanent zero row would be
64
+ // clutter; anyone using test.fail() will see the row when it matters.
65
+ ...(totalExpectedFailures > 0
66
+ ? [
67
+ {
68
+ name: 'Expected Failures',
69
+ emoji: '☑️',
70
+ count: totalExpectedFailures,
71
+ },
72
+ ]
73
+ : []),
63
74
  { name: 'Self-Healed', emoji: '❤️‍🩹', count: totalSelfHealed },
64
75
  { name: 'Failed', emoji: '❌', count: totalFailed },
65
76
  { name: 'Timed Out', emoji: '⏰', count: totalTimedOut },