@reportforge/playwright-pdf 0.1.0 → 0.2.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/README.md +37 -13
- package/dist/index.d.mts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +203 -8
- package/dist/index.mjs +199 -4
- package/dist/templates/layouts/detailed.hbs +1 -1
- package/dist/templates/layouts/executive.hbs +1 -1
- package/dist/templates/partials/charts.hbs +36 -0
- package/dist/templates/styles/detailed.css +101 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,7 +53,8 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
|
|
|
53
53
|
- **Dynamic filenames** — `{date}`, `{branch}`, `{status}` tokens in the output path
|
|
54
54
|
- **CI/CD snippets** — GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps
|
|
55
55
|
- **Shard merging** — combine N Playwright JSON shard reports into one PDF via `shardResults`; accepts glob patterns or explicit paths
|
|
56
|
-
- **
|
|
56
|
+
- **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email supports optional PDF attachment via `attachPdf`
|
|
57
|
+
- **Flakiness trend** — top-N flakiest tests table with per-test dot sparkline across stored history runs in the `detailed` template; `flakinessTopN` option (default 5, `0` disables)
|
|
57
58
|
- **Hybrid licensing** — one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
|
|
58
59
|
|
|
59
60
|
---
|
|
@@ -183,10 +184,11 @@ All options are the second element of the reporter tuple.
|
|
|
183
184
|
| `serverUrl` | `string` | `'https://reportforge.org'` | Licensing server base URL (override only for self-hosting) |
|
|
184
185
|
| `open` | `boolean` | `false` | Open PDF after generation (local only) |
|
|
185
186
|
| `shardResults` | `string \| string[]` | — | Glob or path array of Playwright JSON shard files to merge into one PDF. |
|
|
186
|
-
| `notify` | `object` | — |
|
|
187
|
+
| `notify` | `object` | — | Notification channels: `slack`, `teams`, `discord` (each `{ url, enabled, on }`), `email` (`{ to, enabled, on, attachPdf }`). See [Notifications](#notifications). |
|
|
187
188
|
| `historyFile` | `string` | `~/.reportforge/{key}/history.json` | Path to history JSON. Relative paths resolved from cwd at startup. |
|
|
188
189
|
| `historySize` | `number` | `10` | Max run entries to keep (integer ≥ 2). |
|
|
189
|
-
| `showTrend` | `boolean` | `true` | Set to `false` to disable history write and
|
|
190
|
+
| `showTrend` | `boolean` | `true` | Set to `false` to disable history write and trend charts entirely. |
|
|
191
|
+
| `flakinessTopN` | `number` | `5` | Max flaky tests to show in the `detailed` template flakiness table. `0` disables the table. |
|
|
190
192
|
| `puppeteerExecutablePath` | `string` | auto-detect | Path to Chrome / Chromium |
|
|
191
193
|
|
|
192
194
|
Full reference: [reportforge.org/docs#configuration](https://reportforge.org/docs#configuration).
|
|
@@ -215,30 +217,37 @@ The merge step can run against a dummy test file with zero tests — `shardResul
|
|
|
215
217
|
|
|
216
218
|
> **Note:** Timed-out tests are counted as failures in shard mode (Playwright JSON stats do not distinguish `timedOut` from `failed`).
|
|
217
219
|
|
|
218
|
-
###
|
|
220
|
+
### Notifications
|
|
219
221
|
|
|
220
|
-
Post a summary
|
|
222
|
+
Post a summary to Slack, Teams, Discord, or email after each run.
|
|
221
223
|
|
|
222
224
|
```typescript
|
|
223
225
|
notify: {
|
|
224
226
|
slack: {
|
|
225
|
-
url:
|
|
226
|
-
enabled: true,
|
|
227
|
-
on: '
|
|
227
|
+
url: process.env.SLACK_WEBHOOK_URL,
|
|
228
|
+
enabled: true,
|
|
229
|
+
on: 'failure', // 'always' | 'failure' | 'success' (default: 'always')
|
|
228
230
|
},
|
|
229
231
|
teams: {
|
|
230
|
-
url:
|
|
232
|
+
url: process.env.TEAMS_WEBHOOK_URL,
|
|
231
233
|
enabled: true,
|
|
232
|
-
on: '
|
|
234
|
+
on: 'always',
|
|
233
235
|
},
|
|
234
236
|
discord: {
|
|
235
|
-
url:
|
|
237
|
+
url: process.env.DISCORD_WEBHOOK_URL,
|
|
238
|
+
enabled: true,
|
|
239
|
+
on: 'failure',
|
|
240
|
+
},
|
|
241
|
+
email: {
|
|
242
|
+
to: ['team@company.com', 'qa@company.com'],
|
|
236
243
|
enabled: true,
|
|
244
|
+
on: 'failure',
|
|
245
|
+
attachPdf: false, // set true to attach the generated PDF
|
|
237
246
|
},
|
|
238
247
|
},
|
|
239
248
|
```
|
|
240
249
|
|
|
241
|
-
Each channel is independent. `enabled: false` (the default) lets you store a URL without activating it — useful for staging configs.
|
|
250
|
+
Each channel is independent. `enabled: false` (the default) lets you store a URL without activating it — useful for staging configs.
|
|
242
251
|
|
|
243
252
|
The `on` trigger controls when the message fires:
|
|
244
253
|
|
|
@@ -248,7 +257,9 @@ The `on` trigger controls when the message fires:
|
|
|
248
257
|
| `'failure'` | `stats.failed > 0` or run timed out / interrupted |
|
|
249
258
|
| `'success'` | All tests passed |
|
|
250
259
|
|
|
251
|
-
|
|
260
|
+
**Email** requires `RESEND_API_KEY` in the environment (get one at [resend.com](https://resend.com)). Sender defaults to `noreply@reportforge.org`; override with `RESEND_FROM`. Store webhook URLs and API keys as CI secrets — never commit them.
|
|
261
|
+
|
|
262
|
+
The summary includes pass rate, test counts, duration, and the report filename. Notifications require a valid license and fire after PDF generation (or after a PDF failure — you still get the ping).
|
|
252
263
|
|
|
253
264
|
### Test History Trending
|
|
254
265
|
|
|
@@ -277,6 +288,19 @@ reporter: [['@reportforge/playwright-pdf', {
|
|
|
277
288
|
|
|
278
289
|
**Monorepo** — each package needs a distinct `historyFile` (e.g. `.reportforge/api-history.json`, `.reportforge/web-history.json`) so runs do not overwrite each other.
|
|
279
290
|
|
|
291
|
+
### Flakiness Trend Table
|
|
292
|
+
|
|
293
|
+
The `detailed` template includes a **Top flaky tests** table showing which tests flake most often across your stored history. Each row shows the test name, flake rate (%), run count, and a dot sparkline (amber dot = flaky in that run).
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
reporter: [['@reportforge/playwright-pdf', {
|
|
297
|
+
template: 'detailed',
|
|
298
|
+
flakinessTopN: 5, // default — show top 5; set 0 to disable
|
|
299
|
+
}]]
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The table is gated behind `showTrend: true` (the default) and appears automatically once history entries are present. Runs written before the flakiness feature was added are excluded from the denominator — the table fills in correctly as newer runs accumulate. Set `flakinessTopN: 0` to hide the table entirely.
|
|
303
|
+
|
|
280
304
|
### Filename tokens
|
|
281
305
|
|
|
282
306
|
| Token | Replaced with | Example |
|
package/dist/index.d.mts
CHANGED
|
@@ -176,6 +176,12 @@ interface ReporterOptions {
|
|
|
176
176
|
* @default true
|
|
177
177
|
*/
|
|
178
178
|
showTrend?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Number of flaky tests to display in the flakiness table (detailed template).
|
|
181
|
+
* Set to 0 to disable. Must be a non-negative integer.
|
|
182
|
+
* @default 5
|
|
183
|
+
*/
|
|
184
|
+
flakinessTopN?: number;
|
|
179
185
|
/**
|
|
180
186
|
* Path to a custom Handlebars (.hbs) template file. When set, takes
|
|
181
187
|
* precedence over `template`. Relative paths resolve from `process.cwd()`.
|
|
@@ -256,6 +262,7 @@ declare class PdfReporter implements Reporter {
|
|
|
256
262
|
onEnd(result: FullResult): Promise<void>;
|
|
257
263
|
private _collectData;
|
|
258
264
|
private _appendTrend;
|
|
265
|
+
private _populateHistoryCharts;
|
|
259
266
|
private _buildReportData;
|
|
260
267
|
printsToStdio(): boolean;
|
|
261
268
|
private resolveLicense;
|
package/dist/index.d.ts
CHANGED
|
@@ -176,6 +176,12 @@ interface ReporterOptions {
|
|
|
176
176
|
* @default true
|
|
177
177
|
*/
|
|
178
178
|
showTrend?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Number of flaky tests to display in the flakiness table (detailed template).
|
|
181
|
+
* Set to 0 to disable. Must be a non-negative integer.
|
|
182
|
+
* @default 5
|
|
183
|
+
*/
|
|
184
|
+
flakinessTopN?: number;
|
|
179
185
|
/**
|
|
180
186
|
* Path to a custom Handlebars (.hbs) template file. When set, takes
|
|
181
187
|
* precedence over `template`. Relative paths resolve from `process.cwd()`.
|
|
@@ -256,6 +262,7 @@ declare class PdfReporter implements Reporter {
|
|
|
256
262
|
onEnd(result: FullResult): Promise<void>;
|
|
257
263
|
private _collectData;
|
|
258
264
|
private _appendTrend;
|
|
265
|
+
private _populateHistoryCharts;
|
|
259
266
|
private _buildReportData;
|
|
260
267
|
printsToStdio(): boolean;
|
|
261
268
|
private resolveLicense;
|
package/dist/index.js
CHANGED
|
@@ -10328,7 +10328,8 @@ var DEFAULT_OPTIONS = {
|
|
|
10328
10328
|
compressionLevel: "auto",
|
|
10329
10329
|
maxFileSizeMb: 8,
|
|
10330
10330
|
historySize: 10,
|
|
10331
|
-
showTrend: true
|
|
10331
|
+
showTrend: true,
|
|
10332
|
+
flakinessTopN: 5
|
|
10332
10333
|
};
|
|
10333
10334
|
|
|
10334
10335
|
// src/config/schema.ts
|
|
@@ -10341,10 +10342,17 @@ var channelConfigSchema = external_exports.object({
|
|
|
10341
10342
|
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
10342
10343
|
enabled: external_exports.boolean().default(false)
|
|
10343
10344
|
});
|
|
10345
|
+
var emailChannelConfigSchema = external_exports.object({
|
|
10346
|
+
to: external_exports.array(external_exports.string().email('Each "to" entry must be a valid email')).min(1, '"to" must have at least one address'),
|
|
10347
|
+
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
10348
|
+
enabled: external_exports.boolean().default(false),
|
|
10349
|
+
attachPdf: external_exports.boolean().default(false)
|
|
10350
|
+
});
|
|
10344
10351
|
var notifyConfigSchema = external_exports.object({
|
|
10345
10352
|
slack: channelConfigSchema.optional(),
|
|
10346
10353
|
teams: channelConfigSchema.optional(),
|
|
10347
|
-
discord: channelConfigSchema.optional()
|
|
10354
|
+
discord: channelConfigSchema.optional(),
|
|
10355
|
+
email: emailChannelConfigSchema.optional()
|
|
10348
10356
|
}).optional();
|
|
10349
10357
|
var ReporterOptionsSchema = external_exports.object({
|
|
10350
10358
|
outputFile: external_exports.string().optional().default(DEFAULT_OPTIONS.outputFile),
|
|
@@ -10374,6 +10382,7 @@ var ReporterOptionsSchema = external_exports.object({
|
|
|
10374
10382
|
historyFile: external_exports.string().optional(),
|
|
10375
10383
|
historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
|
|
10376
10384
|
showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
|
|
10385
|
+
flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
|
|
10377
10386
|
templatePath: external_exports.union([
|
|
10378
10387
|
external_exports.string().regex(/\.hbs$/, "templatePath must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "templatePath basename must not be empty"),
|
|
10379
10388
|
external_exports.array(
|
|
@@ -12233,7 +12242,7 @@ function fmtMb(bytes) {
|
|
|
12233
12242
|
}
|
|
12234
12243
|
|
|
12235
12244
|
// src/reporter.ts
|
|
12236
|
-
var
|
|
12245
|
+
var import_path12 = __toESM(require("path"));
|
|
12237
12246
|
var import_crypto7 = __toESM(require("crypto"));
|
|
12238
12247
|
var import_os6 = __toESM(require("os"));
|
|
12239
12248
|
|
|
@@ -12275,6 +12284,42 @@ var HistoryManager = class {
|
|
|
12275
12284
|
}
|
|
12276
12285
|
};
|
|
12277
12286
|
|
|
12287
|
+
// src/history/FlakinessAnalyser.ts
|
|
12288
|
+
init_cjs_shims();
|
|
12289
|
+
function computeTopN(entries, topN) {
|
|
12290
|
+
if (topN === 0) return [];
|
|
12291
|
+
const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
|
|
12292
|
+
if (qualifying.length === 0) return [];
|
|
12293
|
+
const map = /* @__PURE__ */ new Map();
|
|
12294
|
+
qualifying.forEach((entry, idx) => {
|
|
12295
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12296
|
+
for (const t of entry.flakyTests) {
|
|
12297
|
+
if (seen.has(t.id)) continue;
|
|
12298
|
+
seen.add(t.id);
|
|
12299
|
+
if (!map.has(t.id)) map.set(t.id, { title: t.title, hits: /* @__PURE__ */ new Set() });
|
|
12300
|
+
map.get(t.id).hits.add(idx);
|
|
12301
|
+
}
|
|
12302
|
+
});
|
|
12303
|
+
const totalRuns = qualifying.length;
|
|
12304
|
+
const rows = [...map.entries()].map(([, { title, hits }]) => {
|
|
12305
|
+
const flakeCount = hits.size;
|
|
12306
|
+
const sparkline = qualifying.slice().reverse().map((_, reversedIdx) => {
|
|
12307
|
+
const originalIdx = qualifying.length - 1 - reversedIdx;
|
|
12308
|
+
return hits.has(originalIdx);
|
|
12309
|
+
});
|
|
12310
|
+
return {
|
|
12311
|
+
title,
|
|
12312
|
+
flakeRate: Math.round(flakeCount / totalRuns * 100),
|
|
12313
|
+
flakeCount,
|
|
12314
|
+
totalRuns,
|
|
12315
|
+
sparkline
|
|
12316
|
+
};
|
|
12317
|
+
});
|
|
12318
|
+
return rows.sort(
|
|
12319
|
+
(a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
|
|
12320
|
+
).slice(0, topN);
|
|
12321
|
+
}
|
|
12322
|
+
|
|
12278
12323
|
// src/notify/index.ts
|
|
12279
12324
|
init_cjs_shims();
|
|
12280
12325
|
|
|
@@ -12391,6 +12436,115 @@ ${name}`,
|
|
|
12391
12436
|
};
|
|
12392
12437
|
}
|
|
12393
12438
|
|
|
12439
|
+
// src/notify/EmailSender.ts
|
|
12440
|
+
init_cjs_shims();
|
|
12441
|
+
var import_promises = require("fs/promises");
|
|
12442
|
+
var import_path11 = require("path");
|
|
12443
|
+
|
|
12444
|
+
// src/notify/formatters/email.ts
|
|
12445
|
+
init_cjs_shims();
|
|
12446
|
+
function buildEmailContent(stats, pdfPaths, reportTitle) {
|
|
12447
|
+
const label = statusLabel(stats.verdict);
|
|
12448
|
+
const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
|
|
12449
|
+
const name = reportName(pdfPaths, reportTitle);
|
|
12450
|
+
const html = `<!DOCTYPE html>
|
|
12451
|
+
<html lang="en">
|
|
12452
|
+
<head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
|
|
12453
|
+
<body style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;color:#1a1a1a">
|
|
12454
|
+
<h2 style="margin:0 0 8px">${escHtml(label)}</h2>
|
|
12455
|
+
<p style="margin:0 0 16px;color:#555">${escHtml(name)}</p>
|
|
12456
|
+
<table style="border-collapse:collapse;width:100%">
|
|
12457
|
+
<tr style="background:#f5f5f5">
|
|
12458
|
+
<th style="text-align:left;padding:8px 12px;font-weight:600">Metric</th>
|
|
12459
|
+
<th style="text-align:right;padding:8px 12px;font-weight:600">Value</th>
|
|
12460
|
+
</tr>
|
|
12461
|
+
<tr>
|
|
12462
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Total</td>
|
|
12463
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.total}</td>
|
|
12464
|
+
</tr>
|
|
12465
|
+
<tr style="background:#f9f9f9">
|
|
12466
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Passed</td>
|
|
12467
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:#1a7f3c">${stats.passed}</td>
|
|
12468
|
+
</tr>
|
|
12469
|
+
<tr>
|
|
12470
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Failed</td>
|
|
12471
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:${stats.failed > 0 ? "#b91c1c" : "#1a1a1a"}">${stats.failed}</td>
|
|
12472
|
+
</tr>
|
|
12473
|
+
<tr style="background:#f9f9f9">
|
|
12474
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Flaky</td>
|
|
12475
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.flaky}</td>
|
|
12476
|
+
</tr>
|
|
12477
|
+
<tr>
|
|
12478
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Pass Rate</td>
|
|
12479
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.passRate}%</td>
|
|
12480
|
+
</tr>
|
|
12481
|
+
<tr style="background:#f9f9f9">
|
|
12482
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
|
|
12483
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
|
|
12484
|
+
</tr>
|
|
12485
|
+
</table>
|
|
12486
|
+
<p style="margin:24px 0 0;color:#888;font-size:12px">Sent by ReportForge \xB7 <a href="https://reportforge.org" style="color:#888">reportforge.org</a></p>
|
|
12487
|
+
</body>
|
|
12488
|
+
</html>`;
|
|
12489
|
+
return { subject, html };
|
|
12490
|
+
}
|
|
12491
|
+
function escHtml(str) {
|
|
12492
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12493
|
+
}
|
|
12494
|
+
|
|
12495
|
+
// src/notify/EmailSender.ts
|
|
12496
|
+
var RESEND_ENDPOINT = "https://api.resend.com/emails";
|
|
12497
|
+
var DEFAULT_FROM = "ReportForge <noreply@reportforge.org>";
|
|
12498
|
+
var EmailSender = class {
|
|
12499
|
+
async send(reportData, config, pdfPaths) {
|
|
12500
|
+
const apiKey = process.env["RESEND_API_KEY"];
|
|
12501
|
+
if (!apiKey) {
|
|
12502
|
+
logger.warn("[notify] email: RESEND_API_KEY not set \u2014 skipping");
|
|
12503
|
+
return "skipped";
|
|
12504
|
+
}
|
|
12505
|
+
const from = process.env["RESEND_FROM"] ?? DEFAULT_FROM;
|
|
12506
|
+
const { subject, html } = buildEmailContent(
|
|
12507
|
+
reportData.stats,
|
|
12508
|
+
pdfPaths,
|
|
12509
|
+
reportData.meta.title ?? ""
|
|
12510
|
+
);
|
|
12511
|
+
const body = { from, to: config.to, subject, html };
|
|
12512
|
+
if (config.attachPdf && pdfPaths.length > 0) {
|
|
12513
|
+
const pdfPath = pdfPaths[0];
|
|
12514
|
+
try {
|
|
12515
|
+
const content = await (0, import_promises.readFile)(pdfPath);
|
|
12516
|
+
body["attachments"] = [{ filename: (0, import_path11.basename)(pdfPath), content: content.toString("base64") }];
|
|
12517
|
+
} catch (err) {
|
|
12518
|
+
logger.warn(`[notify] email: could not read PDF for attachment: ${err instanceof Error ? err.message : String(err)}`);
|
|
12519
|
+
}
|
|
12520
|
+
}
|
|
12521
|
+
const controller = new AbortController();
|
|
12522
|
+
const timeoutId = setTimeout(() => controller.abort(), 15e3);
|
|
12523
|
+
try {
|
|
12524
|
+
const res = await fetch(RESEND_ENDPOINT, {
|
|
12525
|
+
method: "POST",
|
|
12526
|
+
headers: {
|
|
12527
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
12528
|
+
"Content-Type": "application/json"
|
|
12529
|
+
},
|
|
12530
|
+
body: JSON.stringify(body),
|
|
12531
|
+
signal: controller.signal
|
|
12532
|
+
});
|
|
12533
|
+
if (!res.ok) {
|
|
12534
|
+
const detail = await res.json().catch(() => ({}));
|
|
12535
|
+
throw new Error(`HTTP ${res.status}: ${detail.message ?? "unknown"}`);
|
|
12536
|
+
}
|
|
12537
|
+
logger.info("[notify] email: sent");
|
|
12538
|
+
return "sent";
|
|
12539
|
+
} catch (err) {
|
|
12540
|
+
logger.warn(`[notify] email: ${err instanceof Error ? err.message : String(err)}`);
|
|
12541
|
+
return "failed";
|
|
12542
|
+
} finally {
|
|
12543
|
+
clearTimeout(timeoutId);
|
|
12544
|
+
}
|
|
12545
|
+
}
|
|
12546
|
+
};
|
|
12547
|
+
|
|
12394
12548
|
// src/notify/NotificationSender.ts
|
|
12395
12549
|
function shouldNotify(channel, stats) {
|
|
12396
12550
|
if (!channel.enabled) return false;
|
|
@@ -12419,6 +12573,18 @@ var NotificationSender = class {
|
|
|
12419
12573
|
return this.post(name, channel.url, payload, result);
|
|
12420
12574
|
})
|
|
12421
12575
|
);
|
|
12576
|
+
if (notifyConfig.email) {
|
|
12577
|
+
if (!shouldNotify(notifyConfig.email, reportData.stats)) {
|
|
12578
|
+
result.skipped.push("email");
|
|
12579
|
+
} else {
|
|
12580
|
+
const outcome = await new EmailSender().send(reportData, notifyConfig.email, pdfPaths);
|
|
12581
|
+
if (outcome === "sent") result.sent.push("email");
|
|
12582
|
+
else if (outcome === "failed") result.failed.push("email");
|
|
12583
|
+
else result.skipped.push("email");
|
|
12584
|
+
}
|
|
12585
|
+
} else {
|
|
12586
|
+
result.skipped.push("email");
|
|
12587
|
+
}
|
|
12422
12588
|
return result;
|
|
12423
12589
|
}
|
|
12424
12590
|
async post(name, url, payload, result) {
|
|
@@ -12450,7 +12616,7 @@ var PdfReporter = class {
|
|
|
12450
12616
|
this.dataCollector = new DataCollector();
|
|
12451
12617
|
this.pdfGenerator = new PdfGenerator();
|
|
12452
12618
|
this.options = parseOptions(rawOptions);
|
|
12453
|
-
const version = true ? "0.
|
|
12619
|
+
const version = true ? "0.2.0" : "0.x";
|
|
12454
12620
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
12455
12621
|
this.licenseClient = new LicenseClient({
|
|
12456
12622
|
licenseKey: this.options.licenseKey,
|
|
@@ -12506,6 +12672,7 @@ var PdfReporter = class {
|
|
|
12506
12672
|
const historyPath = resolveHistoryPath(this.options, this.cwd);
|
|
12507
12673
|
const hm = new HistoryManager(historyPath);
|
|
12508
12674
|
const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
|
|
12675
|
+
const flakyTests = collectFlakyTests(collected.projects);
|
|
12509
12676
|
const entry = {
|
|
12510
12677
|
runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
|
|
12511
12678
|
timestamp: Date.now(),
|
|
@@ -12515,9 +12682,13 @@ var PdfReporter = class {
|
|
|
12515
12682
|
failed: collected.stats.failed,
|
|
12516
12683
|
flaky: collected.stats.flaky,
|
|
12517
12684
|
duration: collected.stats.duration,
|
|
12518
|
-
verdict: deriveVerdict(collected.stats)
|
|
12685
|
+
verdict: deriveVerdict(collected.stats),
|
|
12686
|
+
flakyTests
|
|
12519
12687
|
};
|
|
12520
12688
|
const entries = hm.append(entry, this.options.historySize);
|
|
12689
|
+
this._populateHistoryCharts(entries, collected);
|
|
12690
|
+
}
|
|
12691
|
+
_populateHistoryCharts(entries, collected) {
|
|
12521
12692
|
if (entries.length >= 2) {
|
|
12522
12693
|
collected.charts.trend = {
|
|
12523
12694
|
entries: entries.map((e) => ({
|
|
@@ -12531,6 +12702,12 @@ var PdfReporter = class {
|
|
|
12531
12702
|
delta: entries[0].passRate - entries[1].passRate
|
|
12532
12703
|
};
|
|
12533
12704
|
}
|
|
12705
|
+
if (this.options.flakinessTopN > 0) {
|
|
12706
|
+
const table = computeTopN(entries, this.options.flakinessTopN);
|
|
12707
|
+
if (table.length > 0) {
|
|
12708
|
+
collected.charts.flakinessTable = table;
|
|
12709
|
+
}
|
|
12710
|
+
}
|
|
12534
12711
|
}
|
|
12535
12712
|
_buildReportData(collected, licenseInfo) {
|
|
12536
12713
|
const { projects, stats, failures, environment, charts } = collected;
|
|
@@ -12571,7 +12748,7 @@ var PdfReporter = class {
|
|
|
12571
12748
|
}
|
|
12572
12749
|
resolveProjectName() {
|
|
12573
12750
|
try {
|
|
12574
|
-
const pkgPath =
|
|
12751
|
+
const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
|
|
12575
12752
|
const pkg = require(pkgPath);
|
|
12576
12753
|
return pkg.name ?? "Playwright Tests";
|
|
12577
12754
|
} catch {
|
|
@@ -12621,10 +12798,28 @@ function deriveVerdict(stats) {
|
|
|
12621
12798
|
}
|
|
12622
12799
|
function resolveHistoryPath(options, cwd) {
|
|
12623
12800
|
if (options.historyFile) {
|
|
12624
|
-
return
|
|
12801
|
+
return import_path12.default.isAbsolute(options.historyFile) ? options.historyFile : import_path12.default.resolve(cwd, options.historyFile);
|
|
12625
12802
|
}
|
|
12626
12803
|
const projectKey = import_crypto7.default.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
|
|
12627
|
-
return
|
|
12804
|
+
return import_path12.default.join(import_os6.default.homedir(), ".reportforge", projectKey, "history.json");
|
|
12805
|
+
}
|
|
12806
|
+
function flattenSuiteNodes(suites) {
|
|
12807
|
+
return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
|
|
12808
|
+
}
|
|
12809
|
+
function collectFlakyTests(projects) {
|
|
12810
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12811
|
+
const result = [];
|
|
12812
|
+
for (const project of projects) {
|
|
12813
|
+
for (const suite of flattenSuiteNodes(project.suites)) {
|
|
12814
|
+
for (const test of suite.tests) {
|
|
12815
|
+
if (test.status === "flaky" && !seen.has(test.id)) {
|
|
12816
|
+
seen.add(test.id);
|
|
12817
|
+
result.push({ id: test.id, title: test.fullTitle });
|
|
12818
|
+
}
|
|
12819
|
+
}
|
|
12820
|
+
}
|
|
12821
|
+
}
|
|
12822
|
+
return result;
|
|
12628
12823
|
}
|
|
12629
12824
|
|
|
12630
12825
|
// src/index.ts
|
package/dist/index.mjs
CHANGED
|
@@ -10329,7 +10329,8 @@ var DEFAULT_OPTIONS = {
|
|
|
10329
10329
|
compressionLevel: "auto",
|
|
10330
10330
|
maxFileSizeMb: 8,
|
|
10331
10331
|
historySize: 10,
|
|
10332
|
-
showTrend: true
|
|
10332
|
+
showTrend: true,
|
|
10333
|
+
flakinessTopN: 5
|
|
10333
10334
|
};
|
|
10334
10335
|
|
|
10335
10336
|
// src/config/schema.ts
|
|
@@ -10342,10 +10343,17 @@ var channelConfigSchema = external_exports.object({
|
|
|
10342
10343
|
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
10343
10344
|
enabled: external_exports.boolean().default(false)
|
|
10344
10345
|
});
|
|
10346
|
+
var emailChannelConfigSchema = external_exports.object({
|
|
10347
|
+
to: external_exports.array(external_exports.string().email('Each "to" entry must be a valid email')).min(1, '"to" must have at least one address'),
|
|
10348
|
+
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
10349
|
+
enabled: external_exports.boolean().default(false),
|
|
10350
|
+
attachPdf: external_exports.boolean().default(false)
|
|
10351
|
+
});
|
|
10345
10352
|
var notifyConfigSchema = external_exports.object({
|
|
10346
10353
|
slack: channelConfigSchema.optional(),
|
|
10347
10354
|
teams: channelConfigSchema.optional(),
|
|
10348
|
-
discord: channelConfigSchema.optional()
|
|
10355
|
+
discord: channelConfigSchema.optional(),
|
|
10356
|
+
email: emailChannelConfigSchema.optional()
|
|
10349
10357
|
}).optional();
|
|
10350
10358
|
var ReporterOptionsSchema = external_exports.object({
|
|
10351
10359
|
outputFile: external_exports.string().optional().default(DEFAULT_OPTIONS.outputFile),
|
|
@@ -10375,6 +10383,7 @@ var ReporterOptionsSchema = external_exports.object({
|
|
|
10375
10383
|
historyFile: external_exports.string().optional(),
|
|
10376
10384
|
historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
|
|
10377
10385
|
showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
|
|
10386
|
+
flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
|
|
10378
10387
|
templatePath: external_exports.union([
|
|
10379
10388
|
external_exports.string().regex(/\.hbs$/, "templatePath must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "templatePath basename must not be empty"),
|
|
10380
10389
|
external_exports.array(
|
|
@@ -12276,6 +12285,42 @@ var HistoryManager = class {
|
|
|
12276
12285
|
}
|
|
12277
12286
|
};
|
|
12278
12287
|
|
|
12288
|
+
// src/history/FlakinessAnalyser.ts
|
|
12289
|
+
init_esm_shims();
|
|
12290
|
+
function computeTopN(entries, topN) {
|
|
12291
|
+
if (topN === 0) return [];
|
|
12292
|
+
const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
|
|
12293
|
+
if (qualifying.length === 0) return [];
|
|
12294
|
+
const map = /* @__PURE__ */ new Map();
|
|
12295
|
+
qualifying.forEach((entry, idx) => {
|
|
12296
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12297
|
+
for (const t of entry.flakyTests) {
|
|
12298
|
+
if (seen.has(t.id)) continue;
|
|
12299
|
+
seen.add(t.id);
|
|
12300
|
+
if (!map.has(t.id)) map.set(t.id, { title: t.title, hits: /* @__PURE__ */ new Set() });
|
|
12301
|
+
map.get(t.id).hits.add(idx);
|
|
12302
|
+
}
|
|
12303
|
+
});
|
|
12304
|
+
const totalRuns = qualifying.length;
|
|
12305
|
+
const rows = [...map.entries()].map(([, { title, hits }]) => {
|
|
12306
|
+
const flakeCount = hits.size;
|
|
12307
|
+
const sparkline = qualifying.slice().reverse().map((_, reversedIdx) => {
|
|
12308
|
+
const originalIdx = qualifying.length - 1 - reversedIdx;
|
|
12309
|
+
return hits.has(originalIdx);
|
|
12310
|
+
});
|
|
12311
|
+
return {
|
|
12312
|
+
title,
|
|
12313
|
+
flakeRate: Math.round(flakeCount / totalRuns * 100),
|
|
12314
|
+
flakeCount,
|
|
12315
|
+
totalRuns,
|
|
12316
|
+
sparkline
|
|
12317
|
+
};
|
|
12318
|
+
});
|
|
12319
|
+
return rows.sort(
|
|
12320
|
+
(a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
|
|
12321
|
+
).slice(0, topN);
|
|
12322
|
+
}
|
|
12323
|
+
|
|
12279
12324
|
// src/notify/index.ts
|
|
12280
12325
|
init_esm_shims();
|
|
12281
12326
|
|
|
@@ -12392,6 +12437,115 @@ ${name}`,
|
|
|
12392
12437
|
};
|
|
12393
12438
|
}
|
|
12394
12439
|
|
|
12440
|
+
// src/notify/EmailSender.ts
|
|
12441
|
+
init_esm_shims();
|
|
12442
|
+
import { readFile } from "fs/promises";
|
|
12443
|
+
import { basename as basename2 } from "path";
|
|
12444
|
+
|
|
12445
|
+
// src/notify/formatters/email.ts
|
|
12446
|
+
init_esm_shims();
|
|
12447
|
+
function buildEmailContent(stats, pdfPaths, reportTitle) {
|
|
12448
|
+
const label = statusLabel(stats.verdict);
|
|
12449
|
+
const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
|
|
12450
|
+
const name = reportName(pdfPaths, reportTitle);
|
|
12451
|
+
const html = `<!DOCTYPE html>
|
|
12452
|
+
<html lang="en">
|
|
12453
|
+
<head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
|
|
12454
|
+
<body style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;color:#1a1a1a">
|
|
12455
|
+
<h2 style="margin:0 0 8px">${escHtml(label)}</h2>
|
|
12456
|
+
<p style="margin:0 0 16px;color:#555">${escHtml(name)}</p>
|
|
12457
|
+
<table style="border-collapse:collapse;width:100%">
|
|
12458
|
+
<tr style="background:#f5f5f5">
|
|
12459
|
+
<th style="text-align:left;padding:8px 12px;font-weight:600">Metric</th>
|
|
12460
|
+
<th style="text-align:right;padding:8px 12px;font-weight:600">Value</th>
|
|
12461
|
+
</tr>
|
|
12462
|
+
<tr>
|
|
12463
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Total</td>
|
|
12464
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.total}</td>
|
|
12465
|
+
</tr>
|
|
12466
|
+
<tr style="background:#f9f9f9">
|
|
12467
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Passed</td>
|
|
12468
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:#1a7f3c">${stats.passed}</td>
|
|
12469
|
+
</tr>
|
|
12470
|
+
<tr>
|
|
12471
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Failed</td>
|
|
12472
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:${stats.failed > 0 ? "#b91c1c" : "#1a1a1a"}">${stats.failed}</td>
|
|
12473
|
+
</tr>
|
|
12474
|
+
<tr style="background:#f9f9f9">
|
|
12475
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Flaky</td>
|
|
12476
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.flaky}</td>
|
|
12477
|
+
</tr>
|
|
12478
|
+
<tr>
|
|
12479
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Pass Rate</td>
|
|
12480
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.passRate}%</td>
|
|
12481
|
+
</tr>
|
|
12482
|
+
<tr style="background:#f9f9f9">
|
|
12483
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
|
|
12484
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
|
|
12485
|
+
</tr>
|
|
12486
|
+
</table>
|
|
12487
|
+
<p style="margin:24px 0 0;color:#888;font-size:12px">Sent by ReportForge \xB7 <a href="https://reportforge.org" style="color:#888">reportforge.org</a></p>
|
|
12488
|
+
</body>
|
|
12489
|
+
</html>`;
|
|
12490
|
+
return { subject, html };
|
|
12491
|
+
}
|
|
12492
|
+
function escHtml(str) {
|
|
12493
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12494
|
+
}
|
|
12495
|
+
|
|
12496
|
+
// src/notify/EmailSender.ts
|
|
12497
|
+
var RESEND_ENDPOINT = "https://api.resend.com/emails";
|
|
12498
|
+
var DEFAULT_FROM = "ReportForge <noreply@reportforge.org>";
|
|
12499
|
+
var EmailSender = class {
|
|
12500
|
+
async send(reportData, config, pdfPaths) {
|
|
12501
|
+
const apiKey = process.env["RESEND_API_KEY"];
|
|
12502
|
+
if (!apiKey) {
|
|
12503
|
+
logger.warn("[notify] email: RESEND_API_KEY not set \u2014 skipping");
|
|
12504
|
+
return "skipped";
|
|
12505
|
+
}
|
|
12506
|
+
const from = process.env["RESEND_FROM"] ?? DEFAULT_FROM;
|
|
12507
|
+
const { subject, html } = buildEmailContent(
|
|
12508
|
+
reportData.stats,
|
|
12509
|
+
pdfPaths,
|
|
12510
|
+
reportData.meta.title ?? ""
|
|
12511
|
+
);
|
|
12512
|
+
const body = { from, to: config.to, subject, html };
|
|
12513
|
+
if (config.attachPdf && pdfPaths.length > 0) {
|
|
12514
|
+
const pdfPath = pdfPaths[0];
|
|
12515
|
+
try {
|
|
12516
|
+
const content = await readFile(pdfPath);
|
|
12517
|
+
body["attachments"] = [{ filename: basename2(pdfPath), content: content.toString("base64") }];
|
|
12518
|
+
} catch (err) {
|
|
12519
|
+
logger.warn(`[notify] email: could not read PDF for attachment: ${err instanceof Error ? err.message : String(err)}`);
|
|
12520
|
+
}
|
|
12521
|
+
}
|
|
12522
|
+
const controller = new AbortController();
|
|
12523
|
+
const timeoutId = setTimeout(() => controller.abort(), 15e3);
|
|
12524
|
+
try {
|
|
12525
|
+
const res = await fetch(RESEND_ENDPOINT, {
|
|
12526
|
+
method: "POST",
|
|
12527
|
+
headers: {
|
|
12528
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
12529
|
+
"Content-Type": "application/json"
|
|
12530
|
+
},
|
|
12531
|
+
body: JSON.stringify(body),
|
|
12532
|
+
signal: controller.signal
|
|
12533
|
+
});
|
|
12534
|
+
if (!res.ok) {
|
|
12535
|
+
const detail = await res.json().catch(() => ({}));
|
|
12536
|
+
throw new Error(`HTTP ${res.status}: ${detail.message ?? "unknown"}`);
|
|
12537
|
+
}
|
|
12538
|
+
logger.info("[notify] email: sent");
|
|
12539
|
+
return "sent";
|
|
12540
|
+
} catch (err) {
|
|
12541
|
+
logger.warn(`[notify] email: ${err instanceof Error ? err.message : String(err)}`);
|
|
12542
|
+
return "failed";
|
|
12543
|
+
} finally {
|
|
12544
|
+
clearTimeout(timeoutId);
|
|
12545
|
+
}
|
|
12546
|
+
}
|
|
12547
|
+
};
|
|
12548
|
+
|
|
12395
12549
|
// src/notify/NotificationSender.ts
|
|
12396
12550
|
function shouldNotify(channel, stats) {
|
|
12397
12551
|
if (!channel.enabled) return false;
|
|
@@ -12420,6 +12574,18 @@ var NotificationSender = class {
|
|
|
12420
12574
|
return this.post(name, channel.url, payload, result);
|
|
12421
12575
|
})
|
|
12422
12576
|
);
|
|
12577
|
+
if (notifyConfig.email) {
|
|
12578
|
+
if (!shouldNotify(notifyConfig.email, reportData.stats)) {
|
|
12579
|
+
result.skipped.push("email");
|
|
12580
|
+
} else {
|
|
12581
|
+
const outcome = await new EmailSender().send(reportData, notifyConfig.email, pdfPaths);
|
|
12582
|
+
if (outcome === "sent") result.sent.push("email");
|
|
12583
|
+
else if (outcome === "failed") result.failed.push("email");
|
|
12584
|
+
else result.skipped.push("email");
|
|
12585
|
+
}
|
|
12586
|
+
} else {
|
|
12587
|
+
result.skipped.push("email");
|
|
12588
|
+
}
|
|
12423
12589
|
return result;
|
|
12424
12590
|
}
|
|
12425
12591
|
async post(name, url, payload, result) {
|
|
@@ -12451,7 +12617,7 @@ var PdfReporter = class {
|
|
|
12451
12617
|
this.dataCollector = new DataCollector();
|
|
12452
12618
|
this.pdfGenerator = new PdfGenerator();
|
|
12453
12619
|
this.options = parseOptions(rawOptions);
|
|
12454
|
-
const version = true ? "0.
|
|
12620
|
+
const version = true ? "0.2.0" : "0.x";
|
|
12455
12621
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
12456
12622
|
this.licenseClient = new LicenseClient({
|
|
12457
12623
|
licenseKey: this.options.licenseKey,
|
|
@@ -12507,6 +12673,7 @@ var PdfReporter = class {
|
|
|
12507
12673
|
const historyPath = resolveHistoryPath(this.options, this.cwd);
|
|
12508
12674
|
const hm = new HistoryManager(historyPath);
|
|
12509
12675
|
const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
|
|
12676
|
+
const flakyTests = collectFlakyTests(collected.projects);
|
|
12510
12677
|
const entry = {
|
|
12511
12678
|
runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
|
|
12512
12679
|
timestamp: Date.now(),
|
|
@@ -12516,9 +12683,13 @@ var PdfReporter = class {
|
|
|
12516
12683
|
failed: collected.stats.failed,
|
|
12517
12684
|
flaky: collected.stats.flaky,
|
|
12518
12685
|
duration: collected.stats.duration,
|
|
12519
|
-
verdict: deriveVerdict(collected.stats)
|
|
12686
|
+
verdict: deriveVerdict(collected.stats),
|
|
12687
|
+
flakyTests
|
|
12520
12688
|
};
|
|
12521
12689
|
const entries = hm.append(entry, this.options.historySize);
|
|
12690
|
+
this._populateHistoryCharts(entries, collected);
|
|
12691
|
+
}
|
|
12692
|
+
_populateHistoryCharts(entries, collected) {
|
|
12522
12693
|
if (entries.length >= 2) {
|
|
12523
12694
|
collected.charts.trend = {
|
|
12524
12695
|
entries: entries.map((e) => ({
|
|
@@ -12532,6 +12703,12 @@ var PdfReporter = class {
|
|
|
12532
12703
|
delta: entries[0].passRate - entries[1].passRate
|
|
12533
12704
|
};
|
|
12534
12705
|
}
|
|
12706
|
+
if (this.options.flakinessTopN > 0) {
|
|
12707
|
+
const table = computeTopN(entries, this.options.flakinessTopN);
|
|
12708
|
+
if (table.length > 0) {
|
|
12709
|
+
collected.charts.flakinessTable = table;
|
|
12710
|
+
}
|
|
12711
|
+
}
|
|
12535
12712
|
}
|
|
12536
12713
|
_buildReportData(collected, licenseInfo) {
|
|
12537
12714
|
const { projects, stats, failures, environment, charts } = collected;
|
|
@@ -12627,6 +12804,24 @@ function resolveHistoryPath(options, cwd) {
|
|
|
12627
12804
|
const projectKey = crypto3.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
|
|
12628
12805
|
return path11.join(os6.homedir(), ".reportforge", projectKey, "history.json");
|
|
12629
12806
|
}
|
|
12807
|
+
function flattenSuiteNodes(suites) {
|
|
12808
|
+
return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
|
|
12809
|
+
}
|
|
12810
|
+
function collectFlakyTests(projects) {
|
|
12811
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12812
|
+
const result = [];
|
|
12813
|
+
for (const project of projects) {
|
|
12814
|
+
for (const suite of flattenSuiteNodes(project.suites)) {
|
|
12815
|
+
for (const test of suite.tests) {
|
|
12816
|
+
if (test.status === "flaky" && !seen.has(test.id)) {
|
|
12817
|
+
seen.add(test.id);
|
|
12818
|
+
result.push({ id: test.id, title: test.fullTitle });
|
|
12819
|
+
}
|
|
12820
|
+
}
|
|
12821
|
+
}
|
|
12822
|
+
}
|
|
12823
|
+
return result;
|
|
12824
|
+
}
|
|
12630
12825
|
|
|
12631
12826
|
// src/index.ts
|
|
12632
12827
|
function defineReporterConfig(options) {
|
|
@@ -30,6 +30,42 @@
|
|
|
30
30
|
{{/if}}
|
|
31
31
|
{{/if}}
|
|
32
32
|
|
|
33
|
+
{{#if options.showTrend}}
|
|
34
|
+
{{#if charts.flakinessTable}}
|
|
35
|
+
<div class="flakiness-card">
|
|
36
|
+
<div class="flakiness-card__header">
|
|
37
|
+
<span class="flakiness-card__title">Top flaky tests — last {{charts.flakinessTable.[0].totalRuns}} runs</span>
|
|
38
|
+
</div>
|
|
39
|
+
<table class="flakiness-table">
|
|
40
|
+
<thead>
|
|
41
|
+
<tr>
|
|
42
|
+
<th class="flakiness-table__test">Test</th>
|
|
43
|
+
<th class="flakiness-table__rate">Flake rate</th>
|
|
44
|
+
<th class="flakiness-table__count">Runs flaky</th>
|
|
45
|
+
<th class="flakiness-table__spark">History</th>
|
|
46
|
+
</tr>
|
|
47
|
+
</thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
{{#each charts.flakinessTable}}
|
|
50
|
+
<tr>
|
|
51
|
+
<td class="flakiness-table__test" title="{{this.title}}">{{this.title}}</td>
|
|
52
|
+
<td class="flakiness-table__rate">{{this.flakeRate}}%</td>
|
|
53
|
+
<td class="flakiness-table__count">{{this.flakeCount}} / {{this.totalRuns}}</td>
|
|
54
|
+
<td class="flakiness-table__spark">
|
|
55
|
+
<span class="flaky-spark">
|
|
56
|
+
{{#each this.sparkline}}
|
|
57
|
+
<span class="flaky-dot{{#if this}} flaky-dot--hit{{/if}}"></span>
|
|
58
|
+
{{/each}}
|
|
59
|
+
</span>
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
{{/each}}
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
{{/if}}
|
|
67
|
+
{{/if}}
|
|
68
|
+
|
|
33
69
|
<script>
|
|
34
70
|
(function() {
|
|
35
71
|
var remaining = 0;
|
|
@@ -307,5 +307,106 @@
|
|
|
307
307
|
.verdict-partial { background: var(--flaky); -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
308
308
|
.verdict-failed { background: var(--fail); -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
309
309
|
.verdict-unknown { background: var(--skip); -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
310
|
+
|
|
311
|
+
/* ── Flakiness trend table (R8) ─────────────────────────────────────────── */
|
|
312
|
+
.flakiness-card {
|
|
313
|
+
margin: 24px 0 0;
|
|
314
|
+
border: 1px solid var(--border);
|
|
315
|
+
border-radius: 8px;
|
|
316
|
+
overflow: hidden;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.flakiness-card__header {
|
|
320
|
+
display: flex;
|
|
321
|
+
align-items: center;
|
|
322
|
+
padding: 10px 16px;
|
|
323
|
+
background: var(--surface-2);
|
|
324
|
+
border-bottom: 1px solid var(--border);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.flakiness-card__title {
|
|
328
|
+
font-size: 11px;
|
|
329
|
+
font-weight: 600;
|
|
330
|
+
text-transform: uppercase;
|
|
331
|
+
letter-spacing: 0.04em;
|
|
332
|
+
color: var(--text-3);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.flakiness-table {
|
|
336
|
+
width: 100%;
|
|
337
|
+
border-collapse: collapse;
|
|
338
|
+
font-size: 11px;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.flakiness-table thead th {
|
|
342
|
+
padding: 8px 12px;
|
|
343
|
+
text-align: left;
|
|
344
|
+
font-weight: 600;
|
|
345
|
+
font-size: 10px;
|
|
346
|
+
text-transform: uppercase;
|
|
347
|
+
letter-spacing: 0.04em;
|
|
348
|
+
color: var(--text-3);
|
|
349
|
+
border-bottom: 1px solid var(--border);
|
|
350
|
+
background: var(--surface-2);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.flakiness-table tbody tr {
|
|
354
|
+
border-bottom: 1px solid var(--border);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.flakiness-table tbody tr:last-child {
|
|
358
|
+
border-bottom: none;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.flakiness-table tbody td {
|
|
362
|
+
padding: 8px 12px;
|
|
363
|
+
vertical-align: middle;
|
|
364
|
+
color: var(--text-1);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.flakiness-table__test {
|
|
368
|
+
max-width: 300px;
|
|
369
|
+
overflow: hidden;
|
|
370
|
+
text-overflow: ellipsis;
|
|
371
|
+
white-space: nowrap;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.flakiness-table__rate {
|
|
375
|
+
width: 72px;
|
|
376
|
+
font-weight: 600;
|
|
377
|
+
color: var(--flaky);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.flakiness-table__count {
|
|
381
|
+
width: 72px;
|
|
382
|
+
color: var(--text-3);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.flakiness-table__spark {
|
|
386
|
+
width: 140px;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.flaky-spark {
|
|
390
|
+
display: flex;
|
|
391
|
+
gap: 3px;
|
|
392
|
+
align-items: center;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.flaky-dot {
|
|
396
|
+
flex-shrink: 0;
|
|
397
|
+
width: 7px;
|
|
398
|
+
height: 7px;
|
|
399
|
+
border-radius: 50%;
|
|
400
|
+
background: var(--border, #e5e3d8);
|
|
401
|
+
print-color-adjust: exact;
|
|
402
|
+
-webkit-print-color-adjust: exact;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.flaky-dot--hit {
|
|
406
|
+
background: #f59e0b;
|
|
407
|
+
print-color-adjust: exact;
|
|
408
|
+
-webkit-print-color-adjust: exact;
|
|
409
|
+
}
|
|
410
|
+
|
|
310
411
|
</content>
|
|
311
412
|
</invoke>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reportforge/playwright-pdf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Enterprise-ready PDF reports for Playwright Test — minimal, detailed, and executive templates with CI/CD integrations",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ReportForge",
|