@reportforge/playwright-pdf 0.1.0 → 0.2.1
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 +215 -8
- package/dist/index.mjs +211 -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(
|
|
@@ -10962,6 +10971,18 @@ function detectCiEnv() {
|
|
|
10962
10971
|
ciProvider: "CircleCI"
|
|
10963
10972
|
};
|
|
10964
10973
|
}
|
|
10974
|
+
if (env.BITBUCKET_BUILD_NUMBER) {
|
|
10975
|
+
const workspace = env.BITBUCKET_WORKSPACE;
|
|
10976
|
+
const repoSlug = env.BITBUCKET_REPO_SLUG;
|
|
10977
|
+
const buildNumber = env.BITBUCKET_BUILD_NUMBER;
|
|
10978
|
+
const buildUrl = workspace && repoSlug && buildNumber ? `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}` : "";
|
|
10979
|
+
return {
|
|
10980
|
+
branch: env.BITBUCKET_BRANCH || "unknown",
|
|
10981
|
+
commit: env.BITBUCKET_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
|
|
10982
|
+
buildUrl,
|
|
10983
|
+
ciProvider: "Bitbucket Pipelines"
|
|
10984
|
+
};
|
|
10985
|
+
}
|
|
10965
10986
|
return {
|
|
10966
10987
|
branch: gitBranch(),
|
|
10967
10988
|
commit: gitCommit(),
|
|
@@ -12233,7 +12254,7 @@ function fmtMb(bytes) {
|
|
|
12233
12254
|
}
|
|
12234
12255
|
|
|
12235
12256
|
// src/reporter.ts
|
|
12236
|
-
var
|
|
12257
|
+
var import_path12 = __toESM(require("path"));
|
|
12237
12258
|
var import_crypto7 = __toESM(require("crypto"));
|
|
12238
12259
|
var import_os6 = __toESM(require("os"));
|
|
12239
12260
|
|
|
@@ -12275,6 +12296,42 @@ var HistoryManager = class {
|
|
|
12275
12296
|
}
|
|
12276
12297
|
};
|
|
12277
12298
|
|
|
12299
|
+
// src/history/FlakinessAnalyser.ts
|
|
12300
|
+
init_cjs_shims();
|
|
12301
|
+
function computeTopN(entries, topN) {
|
|
12302
|
+
if (topN === 0) return [];
|
|
12303
|
+
const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
|
|
12304
|
+
if (qualifying.length === 0) return [];
|
|
12305
|
+
const map = /* @__PURE__ */ new Map();
|
|
12306
|
+
qualifying.forEach((entry, idx) => {
|
|
12307
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12308
|
+
for (const t of entry.flakyTests) {
|
|
12309
|
+
if (seen.has(t.id)) continue;
|
|
12310
|
+
seen.add(t.id);
|
|
12311
|
+
if (!map.has(t.id)) map.set(t.id, { title: t.title, hits: /* @__PURE__ */ new Set() });
|
|
12312
|
+
map.get(t.id).hits.add(idx);
|
|
12313
|
+
}
|
|
12314
|
+
});
|
|
12315
|
+
const totalRuns = qualifying.length;
|
|
12316
|
+
const rows = [...map.entries()].map(([, { title, hits }]) => {
|
|
12317
|
+
const flakeCount = hits.size;
|
|
12318
|
+
const sparkline = qualifying.slice().reverse().map((_, reversedIdx) => {
|
|
12319
|
+
const originalIdx = qualifying.length - 1 - reversedIdx;
|
|
12320
|
+
return hits.has(originalIdx);
|
|
12321
|
+
});
|
|
12322
|
+
return {
|
|
12323
|
+
title,
|
|
12324
|
+
flakeRate: Math.round(flakeCount / totalRuns * 100),
|
|
12325
|
+
flakeCount,
|
|
12326
|
+
totalRuns,
|
|
12327
|
+
sparkline
|
|
12328
|
+
};
|
|
12329
|
+
});
|
|
12330
|
+
return rows.sort(
|
|
12331
|
+
(a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
|
|
12332
|
+
).slice(0, topN);
|
|
12333
|
+
}
|
|
12334
|
+
|
|
12278
12335
|
// src/notify/index.ts
|
|
12279
12336
|
init_cjs_shims();
|
|
12280
12337
|
|
|
@@ -12391,6 +12448,115 @@ ${name}`,
|
|
|
12391
12448
|
};
|
|
12392
12449
|
}
|
|
12393
12450
|
|
|
12451
|
+
// src/notify/EmailSender.ts
|
|
12452
|
+
init_cjs_shims();
|
|
12453
|
+
var import_promises = require("fs/promises");
|
|
12454
|
+
var import_path11 = require("path");
|
|
12455
|
+
|
|
12456
|
+
// src/notify/formatters/email.ts
|
|
12457
|
+
init_cjs_shims();
|
|
12458
|
+
function buildEmailContent(stats, pdfPaths, reportTitle) {
|
|
12459
|
+
const label = statusLabel(stats.verdict);
|
|
12460
|
+
const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
|
|
12461
|
+
const name = reportName(pdfPaths, reportTitle);
|
|
12462
|
+
const html = `<!DOCTYPE html>
|
|
12463
|
+
<html lang="en">
|
|
12464
|
+
<head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
|
|
12465
|
+
<body style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;color:#1a1a1a">
|
|
12466
|
+
<h2 style="margin:0 0 8px">${escHtml(label)}</h2>
|
|
12467
|
+
<p style="margin:0 0 16px;color:#555">${escHtml(name)}</p>
|
|
12468
|
+
<table style="border-collapse:collapse;width:100%">
|
|
12469
|
+
<tr style="background:#f5f5f5">
|
|
12470
|
+
<th style="text-align:left;padding:8px 12px;font-weight:600">Metric</th>
|
|
12471
|
+
<th style="text-align:right;padding:8px 12px;font-weight:600">Value</th>
|
|
12472
|
+
</tr>
|
|
12473
|
+
<tr>
|
|
12474
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Total</td>
|
|
12475
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.total}</td>
|
|
12476
|
+
</tr>
|
|
12477
|
+
<tr style="background:#f9f9f9">
|
|
12478
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Passed</td>
|
|
12479
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:#1a7f3c">${stats.passed}</td>
|
|
12480
|
+
</tr>
|
|
12481
|
+
<tr>
|
|
12482
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Failed</td>
|
|
12483
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:${stats.failed > 0 ? "#b91c1c" : "#1a1a1a"}">${stats.failed}</td>
|
|
12484
|
+
</tr>
|
|
12485
|
+
<tr style="background:#f9f9f9">
|
|
12486
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Flaky</td>
|
|
12487
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.flaky}</td>
|
|
12488
|
+
</tr>
|
|
12489
|
+
<tr>
|
|
12490
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Pass Rate</td>
|
|
12491
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.passRate}%</td>
|
|
12492
|
+
</tr>
|
|
12493
|
+
<tr style="background:#f9f9f9">
|
|
12494
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
|
|
12495
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
|
|
12496
|
+
</tr>
|
|
12497
|
+
</table>
|
|
12498
|
+
<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>
|
|
12499
|
+
</body>
|
|
12500
|
+
</html>`;
|
|
12501
|
+
return { subject, html };
|
|
12502
|
+
}
|
|
12503
|
+
function escHtml(str) {
|
|
12504
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12505
|
+
}
|
|
12506
|
+
|
|
12507
|
+
// src/notify/EmailSender.ts
|
|
12508
|
+
var RESEND_ENDPOINT = "https://api.resend.com/emails";
|
|
12509
|
+
var DEFAULT_FROM = "ReportForge <noreply@reportforge.org>";
|
|
12510
|
+
var EmailSender = class {
|
|
12511
|
+
async send(reportData, config, pdfPaths) {
|
|
12512
|
+
const apiKey = process.env["RESEND_API_KEY"];
|
|
12513
|
+
if (!apiKey) {
|
|
12514
|
+
logger.warn("[notify] email: RESEND_API_KEY not set \u2014 skipping");
|
|
12515
|
+
return "skipped";
|
|
12516
|
+
}
|
|
12517
|
+
const from = process.env["RESEND_FROM"] ?? DEFAULT_FROM;
|
|
12518
|
+
const { subject, html } = buildEmailContent(
|
|
12519
|
+
reportData.stats,
|
|
12520
|
+
pdfPaths,
|
|
12521
|
+
reportData.meta.title ?? ""
|
|
12522
|
+
);
|
|
12523
|
+
const body = { from, to: config.to, subject, html };
|
|
12524
|
+
if (config.attachPdf && pdfPaths.length > 0) {
|
|
12525
|
+
const pdfPath = pdfPaths[0];
|
|
12526
|
+
try {
|
|
12527
|
+
const content = await (0, import_promises.readFile)(pdfPath);
|
|
12528
|
+
body["attachments"] = [{ filename: (0, import_path11.basename)(pdfPath), content: content.toString("base64") }];
|
|
12529
|
+
} catch (err) {
|
|
12530
|
+
logger.warn(`[notify] email: could not read PDF for attachment: ${err instanceof Error ? err.message : String(err)}`);
|
|
12531
|
+
}
|
|
12532
|
+
}
|
|
12533
|
+
const controller = new AbortController();
|
|
12534
|
+
const timeoutId = setTimeout(() => controller.abort(), 15e3);
|
|
12535
|
+
try {
|
|
12536
|
+
const res = await fetch(RESEND_ENDPOINT, {
|
|
12537
|
+
method: "POST",
|
|
12538
|
+
headers: {
|
|
12539
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
12540
|
+
"Content-Type": "application/json"
|
|
12541
|
+
},
|
|
12542
|
+
body: JSON.stringify(body),
|
|
12543
|
+
signal: controller.signal
|
|
12544
|
+
});
|
|
12545
|
+
if (!res.ok) {
|
|
12546
|
+
const detail = await res.json().catch(() => ({}));
|
|
12547
|
+
throw new Error(`HTTP ${res.status}: ${detail.message ?? "unknown"}`);
|
|
12548
|
+
}
|
|
12549
|
+
logger.info("[notify] email: sent");
|
|
12550
|
+
return "sent";
|
|
12551
|
+
} catch (err) {
|
|
12552
|
+
logger.warn(`[notify] email: ${err instanceof Error ? err.message : String(err)}`);
|
|
12553
|
+
return "failed";
|
|
12554
|
+
} finally {
|
|
12555
|
+
clearTimeout(timeoutId);
|
|
12556
|
+
}
|
|
12557
|
+
}
|
|
12558
|
+
};
|
|
12559
|
+
|
|
12394
12560
|
// src/notify/NotificationSender.ts
|
|
12395
12561
|
function shouldNotify(channel, stats) {
|
|
12396
12562
|
if (!channel.enabled) return false;
|
|
@@ -12419,6 +12585,18 @@ var NotificationSender = class {
|
|
|
12419
12585
|
return this.post(name, channel.url, payload, result);
|
|
12420
12586
|
})
|
|
12421
12587
|
);
|
|
12588
|
+
if (notifyConfig.email) {
|
|
12589
|
+
if (!shouldNotify(notifyConfig.email, reportData.stats)) {
|
|
12590
|
+
result.skipped.push("email");
|
|
12591
|
+
} else {
|
|
12592
|
+
const outcome = await new EmailSender().send(reportData, notifyConfig.email, pdfPaths);
|
|
12593
|
+
if (outcome === "sent") result.sent.push("email");
|
|
12594
|
+
else if (outcome === "failed") result.failed.push("email");
|
|
12595
|
+
else result.skipped.push("email");
|
|
12596
|
+
}
|
|
12597
|
+
} else {
|
|
12598
|
+
result.skipped.push("email");
|
|
12599
|
+
}
|
|
12422
12600
|
return result;
|
|
12423
12601
|
}
|
|
12424
12602
|
async post(name, url, payload, result) {
|
|
@@ -12450,7 +12628,7 @@ var PdfReporter = class {
|
|
|
12450
12628
|
this.dataCollector = new DataCollector();
|
|
12451
12629
|
this.pdfGenerator = new PdfGenerator();
|
|
12452
12630
|
this.options = parseOptions(rawOptions);
|
|
12453
|
-
const version = true ? "0.1
|
|
12631
|
+
const version = true ? "0.2.1" : "0.x";
|
|
12454
12632
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
12455
12633
|
this.licenseClient = new LicenseClient({
|
|
12456
12634
|
licenseKey: this.options.licenseKey,
|
|
@@ -12506,6 +12684,7 @@ var PdfReporter = class {
|
|
|
12506
12684
|
const historyPath = resolveHistoryPath(this.options, this.cwd);
|
|
12507
12685
|
const hm = new HistoryManager(historyPath);
|
|
12508
12686
|
const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
|
|
12687
|
+
const flakyTests = collectFlakyTests(collected.projects);
|
|
12509
12688
|
const entry = {
|
|
12510
12689
|
runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
|
|
12511
12690
|
timestamp: Date.now(),
|
|
@@ -12515,9 +12694,13 @@ var PdfReporter = class {
|
|
|
12515
12694
|
failed: collected.stats.failed,
|
|
12516
12695
|
flaky: collected.stats.flaky,
|
|
12517
12696
|
duration: collected.stats.duration,
|
|
12518
|
-
verdict: deriveVerdict(collected.stats)
|
|
12697
|
+
verdict: deriveVerdict(collected.stats),
|
|
12698
|
+
flakyTests
|
|
12519
12699
|
};
|
|
12520
12700
|
const entries = hm.append(entry, this.options.historySize);
|
|
12701
|
+
this._populateHistoryCharts(entries, collected);
|
|
12702
|
+
}
|
|
12703
|
+
_populateHistoryCharts(entries, collected) {
|
|
12521
12704
|
if (entries.length >= 2) {
|
|
12522
12705
|
collected.charts.trend = {
|
|
12523
12706
|
entries: entries.map((e) => ({
|
|
@@ -12531,6 +12714,12 @@ var PdfReporter = class {
|
|
|
12531
12714
|
delta: entries[0].passRate - entries[1].passRate
|
|
12532
12715
|
};
|
|
12533
12716
|
}
|
|
12717
|
+
if (this.options.flakinessTopN > 0) {
|
|
12718
|
+
const table = computeTopN(entries, this.options.flakinessTopN);
|
|
12719
|
+
if (table.length > 0) {
|
|
12720
|
+
collected.charts.flakinessTable = table;
|
|
12721
|
+
}
|
|
12722
|
+
}
|
|
12534
12723
|
}
|
|
12535
12724
|
_buildReportData(collected, licenseInfo) {
|
|
12536
12725
|
const { projects, stats, failures, environment, charts } = collected;
|
|
@@ -12571,7 +12760,7 @@ var PdfReporter = class {
|
|
|
12571
12760
|
}
|
|
12572
12761
|
resolveProjectName() {
|
|
12573
12762
|
try {
|
|
12574
|
-
const pkgPath =
|
|
12763
|
+
const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
|
|
12575
12764
|
const pkg = require(pkgPath);
|
|
12576
12765
|
return pkg.name ?? "Playwright Tests";
|
|
12577
12766
|
} catch {
|
|
@@ -12621,10 +12810,28 @@ function deriveVerdict(stats) {
|
|
|
12621
12810
|
}
|
|
12622
12811
|
function resolveHistoryPath(options, cwd) {
|
|
12623
12812
|
if (options.historyFile) {
|
|
12624
|
-
return
|
|
12813
|
+
return import_path12.default.isAbsolute(options.historyFile) ? options.historyFile : import_path12.default.resolve(cwd, options.historyFile);
|
|
12625
12814
|
}
|
|
12626
12815
|
const projectKey = import_crypto7.default.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
|
|
12627
|
-
return
|
|
12816
|
+
return import_path12.default.join(import_os6.default.homedir(), ".reportforge", projectKey, "history.json");
|
|
12817
|
+
}
|
|
12818
|
+
function flattenSuiteNodes(suites) {
|
|
12819
|
+
return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
|
|
12820
|
+
}
|
|
12821
|
+
function collectFlakyTests(projects) {
|
|
12822
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12823
|
+
const result = [];
|
|
12824
|
+
for (const project of projects) {
|
|
12825
|
+
for (const suite of flattenSuiteNodes(project.suites)) {
|
|
12826
|
+
for (const test of suite.tests) {
|
|
12827
|
+
if (test.status === "flaky" && !seen.has(test.id)) {
|
|
12828
|
+
seen.add(test.id);
|
|
12829
|
+
result.push({ id: test.id, title: test.fullTitle });
|
|
12830
|
+
}
|
|
12831
|
+
}
|
|
12832
|
+
}
|
|
12833
|
+
}
|
|
12834
|
+
return result;
|
|
12628
12835
|
}
|
|
12629
12836
|
|
|
12630
12837
|
// 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(
|
|
@@ -10963,6 +10972,18 @@ function detectCiEnv() {
|
|
|
10963
10972
|
ciProvider: "CircleCI"
|
|
10964
10973
|
};
|
|
10965
10974
|
}
|
|
10975
|
+
if (env.BITBUCKET_BUILD_NUMBER) {
|
|
10976
|
+
const workspace = env.BITBUCKET_WORKSPACE;
|
|
10977
|
+
const repoSlug = env.BITBUCKET_REPO_SLUG;
|
|
10978
|
+
const buildNumber = env.BITBUCKET_BUILD_NUMBER;
|
|
10979
|
+
const buildUrl = workspace && repoSlug && buildNumber ? `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}` : "";
|
|
10980
|
+
return {
|
|
10981
|
+
branch: env.BITBUCKET_BRANCH || "unknown",
|
|
10982
|
+
commit: env.BITBUCKET_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
|
|
10983
|
+
buildUrl,
|
|
10984
|
+
ciProvider: "Bitbucket Pipelines"
|
|
10985
|
+
};
|
|
10986
|
+
}
|
|
10966
10987
|
return {
|
|
10967
10988
|
branch: gitBranch(),
|
|
10968
10989
|
commit: gitCommit(),
|
|
@@ -12276,6 +12297,42 @@ var HistoryManager = class {
|
|
|
12276
12297
|
}
|
|
12277
12298
|
};
|
|
12278
12299
|
|
|
12300
|
+
// src/history/FlakinessAnalyser.ts
|
|
12301
|
+
init_esm_shims();
|
|
12302
|
+
function computeTopN(entries, topN) {
|
|
12303
|
+
if (topN === 0) return [];
|
|
12304
|
+
const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
|
|
12305
|
+
if (qualifying.length === 0) return [];
|
|
12306
|
+
const map = /* @__PURE__ */ new Map();
|
|
12307
|
+
qualifying.forEach((entry, idx) => {
|
|
12308
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12309
|
+
for (const t of entry.flakyTests) {
|
|
12310
|
+
if (seen.has(t.id)) continue;
|
|
12311
|
+
seen.add(t.id);
|
|
12312
|
+
if (!map.has(t.id)) map.set(t.id, { title: t.title, hits: /* @__PURE__ */ new Set() });
|
|
12313
|
+
map.get(t.id).hits.add(idx);
|
|
12314
|
+
}
|
|
12315
|
+
});
|
|
12316
|
+
const totalRuns = qualifying.length;
|
|
12317
|
+
const rows = [...map.entries()].map(([, { title, hits }]) => {
|
|
12318
|
+
const flakeCount = hits.size;
|
|
12319
|
+
const sparkline = qualifying.slice().reverse().map((_, reversedIdx) => {
|
|
12320
|
+
const originalIdx = qualifying.length - 1 - reversedIdx;
|
|
12321
|
+
return hits.has(originalIdx);
|
|
12322
|
+
});
|
|
12323
|
+
return {
|
|
12324
|
+
title,
|
|
12325
|
+
flakeRate: Math.round(flakeCount / totalRuns * 100),
|
|
12326
|
+
flakeCount,
|
|
12327
|
+
totalRuns,
|
|
12328
|
+
sparkline
|
|
12329
|
+
};
|
|
12330
|
+
});
|
|
12331
|
+
return rows.sort(
|
|
12332
|
+
(a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
|
|
12333
|
+
).slice(0, topN);
|
|
12334
|
+
}
|
|
12335
|
+
|
|
12279
12336
|
// src/notify/index.ts
|
|
12280
12337
|
init_esm_shims();
|
|
12281
12338
|
|
|
@@ -12392,6 +12449,115 @@ ${name}`,
|
|
|
12392
12449
|
};
|
|
12393
12450
|
}
|
|
12394
12451
|
|
|
12452
|
+
// src/notify/EmailSender.ts
|
|
12453
|
+
init_esm_shims();
|
|
12454
|
+
import { readFile } from "fs/promises";
|
|
12455
|
+
import { basename as basename2 } from "path";
|
|
12456
|
+
|
|
12457
|
+
// src/notify/formatters/email.ts
|
|
12458
|
+
init_esm_shims();
|
|
12459
|
+
function buildEmailContent(stats, pdfPaths, reportTitle) {
|
|
12460
|
+
const label = statusLabel(stats.verdict);
|
|
12461
|
+
const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
|
|
12462
|
+
const name = reportName(pdfPaths, reportTitle);
|
|
12463
|
+
const html = `<!DOCTYPE html>
|
|
12464
|
+
<html lang="en">
|
|
12465
|
+
<head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
|
|
12466
|
+
<body style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;color:#1a1a1a">
|
|
12467
|
+
<h2 style="margin:0 0 8px">${escHtml(label)}</h2>
|
|
12468
|
+
<p style="margin:0 0 16px;color:#555">${escHtml(name)}</p>
|
|
12469
|
+
<table style="border-collapse:collapse;width:100%">
|
|
12470
|
+
<tr style="background:#f5f5f5">
|
|
12471
|
+
<th style="text-align:left;padding:8px 12px;font-weight:600">Metric</th>
|
|
12472
|
+
<th style="text-align:right;padding:8px 12px;font-weight:600">Value</th>
|
|
12473
|
+
</tr>
|
|
12474
|
+
<tr>
|
|
12475
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Total</td>
|
|
12476
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.total}</td>
|
|
12477
|
+
</tr>
|
|
12478
|
+
<tr style="background:#f9f9f9">
|
|
12479
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Passed</td>
|
|
12480
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:#1a7f3c">${stats.passed}</td>
|
|
12481
|
+
</tr>
|
|
12482
|
+
<tr>
|
|
12483
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Failed</td>
|
|
12484
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:${stats.failed > 0 ? "#b91c1c" : "#1a1a1a"}">${stats.failed}</td>
|
|
12485
|
+
</tr>
|
|
12486
|
+
<tr style="background:#f9f9f9">
|
|
12487
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Flaky</td>
|
|
12488
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.flaky}</td>
|
|
12489
|
+
</tr>
|
|
12490
|
+
<tr>
|
|
12491
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Pass Rate</td>
|
|
12492
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.passRate}%</td>
|
|
12493
|
+
</tr>
|
|
12494
|
+
<tr style="background:#f9f9f9">
|
|
12495
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
|
|
12496
|
+
<td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
|
|
12497
|
+
</tr>
|
|
12498
|
+
</table>
|
|
12499
|
+
<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>
|
|
12500
|
+
</body>
|
|
12501
|
+
</html>`;
|
|
12502
|
+
return { subject, html };
|
|
12503
|
+
}
|
|
12504
|
+
function escHtml(str) {
|
|
12505
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12506
|
+
}
|
|
12507
|
+
|
|
12508
|
+
// src/notify/EmailSender.ts
|
|
12509
|
+
var RESEND_ENDPOINT = "https://api.resend.com/emails";
|
|
12510
|
+
var DEFAULT_FROM = "ReportForge <noreply@reportforge.org>";
|
|
12511
|
+
var EmailSender = class {
|
|
12512
|
+
async send(reportData, config, pdfPaths) {
|
|
12513
|
+
const apiKey = process.env["RESEND_API_KEY"];
|
|
12514
|
+
if (!apiKey) {
|
|
12515
|
+
logger.warn("[notify] email: RESEND_API_KEY not set \u2014 skipping");
|
|
12516
|
+
return "skipped";
|
|
12517
|
+
}
|
|
12518
|
+
const from = process.env["RESEND_FROM"] ?? DEFAULT_FROM;
|
|
12519
|
+
const { subject, html } = buildEmailContent(
|
|
12520
|
+
reportData.stats,
|
|
12521
|
+
pdfPaths,
|
|
12522
|
+
reportData.meta.title ?? ""
|
|
12523
|
+
);
|
|
12524
|
+
const body = { from, to: config.to, subject, html };
|
|
12525
|
+
if (config.attachPdf && pdfPaths.length > 0) {
|
|
12526
|
+
const pdfPath = pdfPaths[0];
|
|
12527
|
+
try {
|
|
12528
|
+
const content = await readFile(pdfPath);
|
|
12529
|
+
body["attachments"] = [{ filename: basename2(pdfPath), content: content.toString("base64") }];
|
|
12530
|
+
} catch (err) {
|
|
12531
|
+
logger.warn(`[notify] email: could not read PDF for attachment: ${err instanceof Error ? err.message : String(err)}`);
|
|
12532
|
+
}
|
|
12533
|
+
}
|
|
12534
|
+
const controller = new AbortController();
|
|
12535
|
+
const timeoutId = setTimeout(() => controller.abort(), 15e3);
|
|
12536
|
+
try {
|
|
12537
|
+
const res = await fetch(RESEND_ENDPOINT, {
|
|
12538
|
+
method: "POST",
|
|
12539
|
+
headers: {
|
|
12540
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
12541
|
+
"Content-Type": "application/json"
|
|
12542
|
+
},
|
|
12543
|
+
body: JSON.stringify(body),
|
|
12544
|
+
signal: controller.signal
|
|
12545
|
+
});
|
|
12546
|
+
if (!res.ok) {
|
|
12547
|
+
const detail = await res.json().catch(() => ({}));
|
|
12548
|
+
throw new Error(`HTTP ${res.status}: ${detail.message ?? "unknown"}`);
|
|
12549
|
+
}
|
|
12550
|
+
logger.info("[notify] email: sent");
|
|
12551
|
+
return "sent";
|
|
12552
|
+
} catch (err) {
|
|
12553
|
+
logger.warn(`[notify] email: ${err instanceof Error ? err.message : String(err)}`);
|
|
12554
|
+
return "failed";
|
|
12555
|
+
} finally {
|
|
12556
|
+
clearTimeout(timeoutId);
|
|
12557
|
+
}
|
|
12558
|
+
}
|
|
12559
|
+
};
|
|
12560
|
+
|
|
12395
12561
|
// src/notify/NotificationSender.ts
|
|
12396
12562
|
function shouldNotify(channel, stats) {
|
|
12397
12563
|
if (!channel.enabled) return false;
|
|
@@ -12420,6 +12586,18 @@ var NotificationSender = class {
|
|
|
12420
12586
|
return this.post(name, channel.url, payload, result);
|
|
12421
12587
|
})
|
|
12422
12588
|
);
|
|
12589
|
+
if (notifyConfig.email) {
|
|
12590
|
+
if (!shouldNotify(notifyConfig.email, reportData.stats)) {
|
|
12591
|
+
result.skipped.push("email");
|
|
12592
|
+
} else {
|
|
12593
|
+
const outcome = await new EmailSender().send(reportData, notifyConfig.email, pdfPaths);
|
|
12594
|
+
if (outcome === "sent") result.sent.push("email");
|
|
12595
|
+
else if (outcome === "failed") result.failed.push("email");
|
|
12596
|
+
else result.skipped.push("email");
|
|
12597
|
+
}
|
|
12598
|
+
} else {
|
|
12599
|
+
result.skipped.push("email");
|
|
12600
|
+
}
|
|
12423
12601
|
return result;
|
|
12424
12602
|
}
|
|
12425
12603
|
async post(name, url, payload, result) {
|
|
@@ -12451,7 +12629,7 @@ var PdfReporter = class {
|
|
|
12451
12629
|
this.dataCollector = new DataCollector();
|
|
12452
12630
|
this.pdfGenerator = new PdfGenerator();
|
|
12453
12631
|
this.options = parseOptions(rawOptions);
|
|
12454
|
-
const version = true ? "0.1
|
|
12632
|
+
const version = true ? "0.2.1" : "0.x";
|
|
12455
12633
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
12456
12634
|
this.licenseClient = new LicenseClient({
|
|
12457
12635
|
licenseKey: this.options.licenseKey,
|
|
@@ -12507,6 +12685,7 @@ var PdfReporter = class {
|
|
|
12507
12685
|
const historyPath = resolveHistoryPath(this.options, this.cwd);
|
|
12508
12686
|
const hm = new HistoryManager(historyPath);
|
|
12509
12687
|
const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
|
|
12688
|
+
const flakyTests = collectFlakyTests(collected.projects);
|
|
12510
12689
|
const entry = {
|
|
12511
12690
|
runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
|
|
12512
12691
|
timestamp: Date.now(),
|
|
@@ -12516,9 +12695,13 @@ var PdfReporter = class {
|
|
|
12516
12695
|
failed: collected.stats.failed,
|
|
12517
12696
|
flaky: collected.stats.flaky,
|
|
12518
12697
|
duration: collected.stats.duration,
|
|
12519
|
-
verdict: deriveVerdict(collected.stats)
|
|
12698
|
+
verdict: deriveVerdict(collected.stats),
|
|
12699
|
+
flakyTests
|
|
12520
12700
|
};
|
|
12521
12701
|
const entries = hm.append(entry, this.options.historySize);
|
|
12702
|
+
this._populateHistoryCharts(entries, collected);
|
|
12703
|
+
}
|
|
12704
|
+
_populateHistoryCharts(entries, collected) {
|
|
12522
12705
|
if (entries.length >= 2) {
|
|
12523
12706
|
collected.charts.trend = {
|
|
12524
12707
|
entries: entries.map((e) => ({
|
|
@@ -12532,6 +12715,12 @@ var PdfReporter = class {
|
|
|
12532
12715
|
delta: entries[0].passRate - entries[1].passRate
|
|
12533
12716
|
};
|
|
12534
12717
|
}
|
|
12718
|
+
if (this.options.flakinessTopN > 0) {
|
|
12719
|
+
const table = computeTopN(entries, this.options.flakinessTopN);
|
|
12720
|
+
if (table.length > 0) {
|
|
12721
|
+
collected.charts.flakinessTable = table;
|
|
12722
|
+
}
|
|
12723
|
+
}
|
|
12535
12724
|
}
|
|
12536
12725
|
_buildReportData(collected, licenseInfo) {
|
|
12537
12726
|
const { projects, stats, failures, environment, charts } = collected;
|
|
@@ -12627,6 +12816,24 @@ function resolveHistoryPath(options, cwd) {
|
|
|
12627
12816
|
const projectKey = crypto3.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
|
|
12628
12817
|
return path11.join(os6.homedir(), ".reportforge", projectKey, "history.json");
|
|
12629
12818
|
}
|
|
12819
|
+
function flattenSuiteNodes(suites) {
|
|
12820
|
+
return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
|
|
12821
|
+
}
|
|
12822
|
+
function collectFlakyTests(projects) {
|
|
12823
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12824
|
+
const result = [];
|
|
12825
|
+
for (const project of projects) {
|
|
12826
|
+
for (const suite of flattenSuiteNodes(project.suites)) {
|
|
12827
|
+
for (const test of suite.tests) {
|
|
12828
|
+
if (test.status === "flaky" && !seen.has(test.id)) {
|
|
12829
|
+
seen.add(test.id);
|
|
12830
|
+
result.push({ id: test.id, title: test.fullTitle });
|
|
12831
|
+
}
|
|
12832
|
+
}
|
|
12833
|
+
}
|
|
12834
|
+
}
|
|
12835
|
+
return result;
|
|
12836
|
+
}
|
|
12630
12837
|
|
|
12631
12838
|
// src/index.ts
|
|
12632
12839
|
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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|