@reportforge/playwright-pdf 0.0.1 → 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 +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +293 -25
- package/dist/index.mjs +289 -21
- package/dist/templates/layouts/detailed.hbs +1 -1
- package/dist/templates/layouts/executive.hbs +1 -1
- package/dist/templates/partials/charts.hbs +41 -6
- package/dist/templates/styles/detailed.css +101 -0
- package/package.json +1 -1
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),
|
|
@@ -10374,7 +10382,16 @@ var ReporterOptionsSchema = external_exports.object({
|
|
|
10374
10382
|
notify: notifyConfigSchema,
|
|
10375
10383
|
historyFile: external_exports.string().optional(),
|
|
10376
10384
|
historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
|
|
10377
|
-
showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend)
|
|
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),
|
|
10387
|
+
templatePath: external_exports.union([
|
|
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"),
|
|
10389
|
+
external_exports.array(
|
|
10390
|
+
external_exports.string().regex(/\.hbs$/, "Each templatePath entry must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "Each templatePath basename must not be empty")
|
|
10391
|
+
).min(1, "templatePath array must not be empty")
|
|
10392
|
+
]).optional().transform(
|
|
10393
|
+
(v) => v === void 0 ? void 0 : Array.isArray(v) ? v : [v]
|
|
10394
|
+
)
|
|
10378
10395
|
});
|
|
10379
10396
|
function parseOptions(raw) {
|
|
10380
10397
|
const result = ReporterOptionsSchema.safeParse(raw ?? {});
|
|
@@ -11462,6 +11479,7 @@ var TemplateEngine = class {
|
|
|
11462
11479
|
constructor() {
|
|
11463
11480
|
this.chartjsInline = "";
|
|
11464
11481
|
this.fontsBundleCss = "";
|
|
11482
|
+
this.customTemplateCache = /* @__PURE__ */ new Map();
|
|
11465
11483
|
this.handlebars = import_handlebars.default.create();
|
|
11466
11484
|
this.templatesDir = path4.resolve(__dirname, "templates");
|
|
11467
11485
|
if (!fs3.existsSync(this.templatesDir)) {
|
|
@@ -11491,12 +11509,43 @@ var TemplateEngine = class {
|
|
|
11491
11509
|
const ctx = this.buildContext(data, template);
|
|
11492
11510
|
return compiledLayout(ctx);
|
|
11493
11511
|
}
|
|
11512
|
+
renderFromPath(data, filePath) {
|
|
11513
|
+
if (!this.customTemplateCache.has(filePath)) {
|
|
11514
|
+
if (!fs3.existsSync(filePath)) {
|
|
11515
|
+
throw new Error(`[reportforge] Custom template not found: ${filePath}`);
|
|
11516
|
+
}
|
|
11517
|
+
const source = fs3.readFileSync(filePath, "utf8");
|
|
11518
|
+
this.customTemplateCache.set(filePath, this.handlebars.compile(source));
|
|
11519
|
+
}
|
|
11520
|
+
const compiledFn = this.customTemplateCache.get(filePath);
|
|
11521
|
+
return compiledFn(this.buildCustomContext(data));
|
|
11522
|
+
}
|
|
11523
|
+
buildCustomContext(data) {
|
|
11524
|
+
return {
|
|
11525
|
+
...data,
|
|
11526
|
+
options: {
|
|
11527
|
+
showCharts: true,
|
|
11528
|
+
showTrend: true,
|
|
11529
|
+
showStackTraces: true,
|
|
11530
|
+
showFullEnvironment: true,
|
|
11531
|
+
showPassRate: true,
|
|
11532
|
+
showRetries: true,
|
|
11533
|
+
showFullFailures: true
|
|
11534
|
+
},
|
|
11535
|
+
tagGroups: this.buildTagGroups(data),
|
|
11536
|
+
slowTests: this.buildSlowTests(data),
|
|
11537
|
+
baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
|
|
11538
|
+
templateCSS: "",
|
|
11539
|
+
chartjsScript: this.buildChartjsScript()
|
|
11540
|
+
};
|
|
11541
|
+
}
|
|
11494
11542
|
buildContext(data, template) {
|
|
11495
11543
|
const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
|
|
11496
11544
|
const templateCSS = this.readStyle(`${template}.css`);
|
|
11497
11545
|
const optionsMap = {
|
|
11498
11546
|
minimal: {
|
|
11499
11547
|
showCharts: false,
|
|
11548
|
+
showTrend: false,
|
|
11500
11549
|
showStackTraces: true,
|
|
11501
11550
|
showFullEnvironment: false,
|
|
11502
11551
|
showPassRate: true,
|
|
@@ -11505,6 +11554,7 @@ var TemplateEngine = class {
|
|
|
11505
11554
|
},
|
|
11506
11555
|
detailed: {
|
|
11507
11556
|
showCharts: true,
|
|
11557
|
+
showTrend: true,
|
|
11508
11558
|
showStackTraces: true,
|
|
11509
11559
|
showFullEnvironment: true,
|
|
11510
11560
|
showPassRate: true,
|
|
@@ -11513,6 +11563,7 @@ var TemplateEngine = class {
|
|
|
11513
11563
|
},
|
|
11514
11564
|
executive: {
|
|
11515
11565
|
showCharts: true,
|
|
11566
|
+
showTrend: true,
|
|
11516
11567
|
showStackTraces: false,
|
|
11517
11568
|
showFullEnvironment: false,
|
|
11518
11569
|
showPassRate: true,
|
|
@@ -11520,7 +11571,7 @@ var TemplateEngine = class {
|
|
|
11520
11571
|
showFullFailures: false
|
|
11521
11572
|
}
|
|
11522
11573
|
};
|
|
11523
|
-
const chartjsScript = this.
|
|
11574
|
+
const chartjsScript = this.buildChartjsScript();
|
|
11524
11575
|
return {
|
|
11525
11576
|
...data,
|
|
11526
11577
|
options: optionsMap[template],
|
|
@@ -11531,6 +11582,9 @@ var TemplateEngine = class {
|
|
|
11531
11582
|
chartjsScript
|
|
11532
11583
|
};
|
|
11533
11584
|
}
|
|
11585
|
+
buildChartjsScript() {
|
|
11586
|
+
return this.chartjsInline ? `<script>${this.chartjsInline}</script>` : "<!-- Chart.js not bundled; charts disabled --><script>window.__chartsReady=true;</script>";
|
|
11587
|
+
}
|
|
11534
11588
|
buildTagGroups(data) {
|
|
11535
11589
|
const tagMap = /* @__PURE__ */ new Map();
|
|
11536
11590
|
for (const project of data.projects) {
|
|
@@ -11911,10 +11965,10 @@ var FilenameResolver = class {
|
|
|
11911
11965
|
fs8.mkdirSync(path7.dirname(absolute), { recursive: true });
|
|
11912
11966
|
return absolute;
|
|
11913
11967
|
}
|
|
11914
|
-
injectTemplateSuffix(outputPath,
|
|
11968
|
+
injectTemplateSuffix(outputPath, suffix) {
|
|
11915
11969
|
const ext = path7.extname(outputPath);
|
|
11916
11970
|
const base = outputPath.slice(0, outputPath.length - ext.length);
|
|
11917
|
-
return `${base}-${
|
|
11971
|
+
return `${base}-${suffix}${ext}`;
|
|
11918
11972
|
}
|
|
11919
11973
|
formatDate(d) {
|
|
11920
11974
|
const yyyy = d.getFullYear();
|
|
@@ -12034,9 +12088,15 @@ var PdfGenerator = class {
|
|
|
12034
12088
|
}
|
|
12035
12089
|
}
|
|
12036
12090
|
async generateAll(data, options, _licenseInfo) {
|
|
12037
|
-
const
|
|
12038
|
-
const
|
|
12039
|
-
const
|
|
12091
|
+
const sources = this.resolveTemplateSources(options);
|
|
12092
|
+
const isMulti = sources.length > 1;
|
|
12093
|
+
for (const source of sources) {
|
|
12094
|
+
if (source.kind === "custom" && !fs10.existsSync(source.absolutePath)) {
|
|
12095
|
+
throw new Error(
|
|
12096
|
+
`[reportforge] Custom template not found: ${source.absolutePath}`
|
|
12097
|
+
);
|
|
12098
|
+
}
|
|
12099
|
+
}
|
|
12040
12100
|
if (options.logo) {
|
|
12041
12101
|
const logoBase64 = await this.screenshotEmbedder.embedLogoFile(options.logo);
|
|
12042
12102
|
data.meta.branding.logoBase64 = logoBase64;
|
|
@@ -12053,11 +12113,13 @@ var PdfGenerator = class {
|
|
|
12053
12113
|
const outputPaths = [];
|
|
12054
12114
|
const { page } = await this.browserManager.launch(options.puppeteerExecutablePath);
|
|
12055
12115
|
try {
|
|
12056
|
-
for (const
|
|
12057
|
-
const
|
|
12116
|
+
for (const source of sources) {
|
|
12117
|
+
const suffix = source.kind === "builtin" ? source.id : source.label;
|
|
12118
|
+
const outputPath = isMulti ? this.filenameResolver.injectTemplateSuffix(baseOutputPath, suffix) : baseOutputPath;
|
|
12119
|
+
const templateId = source.kind === "builtin" ? source.id : "detailed";
|
|
12058
12120
|
const templateData = { ...data, meta: { ...data.meta, template: templateId } };
|
|
12059
12121
|
const templateOptions = { ...options, template: templateId };
|
|
12060
|
-
await this.renderPdf(templateData, templateOptions, compression, outputPath, page);
|
|
12122
|
+
await this.renderPdf(templateData, templateOptions, source, compression, outputPath, page);
|
|
12061
12123
|
const capBytes = (options.maxFileSizeMb ?? 0) * 1024 * 1024;
|
|
12062
12124
|
if (capBytes > 0) {
|
|
12063
12125
|
const sizeBytes = (await fs10.promises.stat(outputPath)).size;
|
|
@@ -12066,7 +12128,7 @@ var PdfGenerator = class {
|
|
|
12066
12128
|
logger.warn(
|
|
12067
12129
|
`PDF size ${fmtMb(sizeBytes)} exceeds cap ${fmtMb(capBytes)} \u2014 re-rendering (jpeg-q=${tighter.quality}, max-w=${tighter.maxWidth}px, inline-failures\u2264${tighter.maxInlineFailures}).`
|
|
12068
12130
|
);
|
|
12069
|
-
await this.renderPdf(templateData, templateOptions, tighter, outputPath, page);
|
|
12131
|
+
await this.renderPdf(templateData, templateOptions, source, tighter, outputPath, page);
|
|
12070
12132
|
const finalSize = (await fs10.promises.stat(outputPath)).size;
|
|
12071
12133
|
if (finalSize > capBytes) {
|
|
12072
12134
|
logger.warn(
|
|
@@ -12089,6 +12151,17 @@ var PdfGenerator = class {
|
|
|
12089
12151
|
}
|
|
12090
12152
|
return outputPaths;
|
|
12091
12153
|
}
|
|
12154
|
+
resolveTemplateSources(options) {
|
|
12155
|
+
if (options.templatePath && options.templatePath.length > 0) {
|
|
12156
|
+
return [...new Set(options.templatePath)].map((p) => ({
|
|
12157
|
+
kind: "custom",
|
|
12158
|
+
absolutePath: path9.isAbsolute(p) ? p : path9.resolve(process.cwd(), p),
|
|
12159
|
+
label: path9.basename(p, ".hbs")
|
|
12160
|
+
}));
|
|
12161
|
+
}
|
|
12162
|
+
const rawIds = Array.isArray(options.template) ? options.template : [options.template];
|
|
12163
|
+
return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
|
|
12164
|
+
}
|
|
12092
12165
|
resolveSettings(options, data) {
|
|
12093
12166
|
return resolveCompression({
|
|
12094
12167
|
level: options.compressionLevel ?? "auto",
|
|
@@ -12123,7 +12196,7 @@ var PdfGenerator = class {
|
|
|
12123
12196
|
* Separated from generateAll() so the size-cap retune can reuse it with
|
|
12124
12197
|
* stricter settings without reshaping the rest of the flow.
|
|
12125
12198
|
*/
|
|
12126
|
-
async renderPdf(data, options, compression, outputPath, page) {
|
|
12199
|
+
async renderPdf(data, options, source, compression, outputPath, page) {
|
|
12127
12200
|
const paginated = await this.failurePaginator.paginate(
|
|
12128
12201
|
data.failures,
|
|
12129
12202
|
compression.maxInlineFailures,
|
|
@@ -12143,15 +12216,15 @@ var PdfGenerator = class {
|
|
|
12143
12216
|
maxWidth: compression.maxWidth
|
|
12144
12217
|
});
|
|
12145
12218
|
}
|
|
12146
|
-
const
|
|
12147
|
-
logger.info(`Rendering template: ${
|
|
12148
|
-
const html = this.templateEngine.render(renderData,
|
|
12219
|
+
const templateLabel = source.kind === "builtin" ? source.id : source.label;
|
|
12220
|
+
logger.info(`Rendering template: ${templateLabel}`);
|
|
12221
|
+
const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id) : this.templateEngine.renderFromPath(renderData, source.absolutePath);
|
|
12149
12222
|
const tempHtmlPath = await this.htmlWriter.write(html);
|
|
12150
12223
|
const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
|
|
12151
12224
|
try {
|
|
12152
12225
|
logger.info(`Navigating to temp HTML (${Math.round(html.length / 1024)} KB)...`);
|
|
12153
12226
|
await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
|
|
12154
|
-
const showCharts =
|
|
12227
|
+
const showCharts = source.kind === "builtin" ? source.id !== "minimal" : true;
|
|
12155
12228
|
await this.chartRenderer.waitForCharts(page, showCharts);
|
|
12156
12229
|
logger.info(`Generating PDF \u2192 ${path9.relative(process.cwd(), outputPath)}`);
|
|
12157
12230
|
await page.pdf({
|
|
@@ -12212,6 +12285,42 @@ var HistoryManager = class {
|
|
|
12212
12285
|
}
|
|
12213
12286
|
};
|
|
12214
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
|
+
|
|
12215
12324
|
// src/notify/index.ts
|
|
12216
12325
|
init_esm_shims();
|
|
12217
12326
|
|
|
@@ -12328,6 +12437,115 @@ ${name}`,
|
|
|
12328
12437
|
};
|
|
12329
12438
|
}
|
|
12330
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
|
+
|
|
12331
12549
|
// src/notify/NotificationSender.ts
|
|
12332
12550
|
function shouldNotify(channel, stats) {
|
|
12333
12551
|
if (!channel.enabled) return false;
|
|
@@ -12356,6 +12574,18 @@ var NotificationSender = class {
|
|
|
12356
12574
|
return this.post(name, channel.url, payload, result);
|
|
12357
12575
|
})
|
|
12358
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
|
+
}
|
|
12359
12589
|
return result;
|
|
12360
12590
|
}
|
|
12361
12591
|
async post(name, url, payload, result) {
|
|
@@ -12387,7 +12617,7 @@ var PdfReporter = class {
|
|
|
12387
12617
|
this.dataCollector = new DataCollector();
|
|
12388
12618
|
this.pdfGenerator = new PdfGenerator();
|
|
12389
12619
|
this.options = parseOptions(rawOptions);
|
|
12390
|
-
const version = true ? "0.0
|
|
12620
|
+
const version = true ? "0.2.0" : "0.x";
|
|
12391
12621
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
12392
12622
|
this.licenseClient = new LicenseClient({
|
|
12393
12623
|
licenseKey: this.options.licenseKey,
|
|
@@ -12443,6 +12673,7 @@ var PdfReporter = class {
|
|
|
12443
12673
|
const historyPath = resolveHistoryPath(this.options, this.cwd);
|
|
12444
12674
|
const hm = new HistoryManager(historyPath);
|
|
12445
12675
|
const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
|
|
12676
|
+
const flakyTests = collectFlakyTests(collected.projects);
|
|
12446
12677
|
const entry = {
|
|
12447
12678
|
runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
|
|
12448
12679
|
timestamp: Date.now(),
|
|
@@ -12452,9 +12683,13 @@ var PdfReporter = class {
|
|
|
12452
12683
|
failed: collected.stats.failed,
|
|
12453
12684
|
flaky: collected.stats.flaky,
|
|
12454
12685
|
duration: collected.stats.duration,
|
|
12455
|
-
verdict: deriveVerdict(collected.stats)
|
|
12686
|
+
verdict: deriveVerdict(collected.stats),
|
|
12687
|
+
flakyTests
|
|
12456
12688
|
};
|
|
12457
12689
|
const entries = hm.append(entry, this.options.historySize);
|
|
12690
|
+
this._populateHistoryCharts(entries, collected);
|
|
12691
|
+
}
|
|
12692
|
+
_populateHistoryCharts(entries, collected) {
|
|
12458
12693
|
if (entries.length >= 2) {
|
|
12459
12694
|
collected.charts.trend = {
|
|
12460
12695
|
entries: entries.map((e) => ({
|
|
@@ -12468,11 +12703,17 @@ var PdfReporter = class {
|
|
|
12468
12703
|
delta: entries[0].passRate - entries[1].passRate
|
|
12469
12704
|
};
|
|
12470
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
|
+
}
|
|
12471
12712
|
}
|
|
12472
12713
|
_buildReportData(collected, licenseInfo) {
|
|
12473
12714
|
const { projects, stats, failures, environment, charts } = collected;
|
|
12474
12715
|
const projectName = this.options.projectName ?? this.resolveProjectName();
|
|
12475
|
-
const firstTemplate = Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
|
|
12716
|
+
const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
|
|
12476
12717
|
return {
|
|
12477
12718
|
meta: {
|
|
12478
12719
|
title: this.options.reportTitle,
|
|
@@ -12530,6 +12771,15 @@ var PdfReporter = class {
|
|
|
12530
12771
|
logger.warn(`open: true \u2014 could not open ${pdfPath}: ${err.message}`);
|
|
12531
12772
|
});
|
|
12532
12773
|
child.unref();
|
|
12774
|
+
if (platform === "win32") {
|
|
12775
|
+
child.on("close", (code) => {
|
|
12776
|
+
if (code !== 0 && code !== null) {
|
|
12777
|
+
logger.warn(
|
|
12778
|
+
`open: true \u2014 shell-open exited with code ${code}. No PDF viewer may be registered for .pdf files on this machine. File is at: ${pdfPath}`
|
|
12779
|
+
);
|
|
12780
|
+
}
|
|
12781
|
+
});
|
|
12782
|
+
}
|
|
12533
12783
|
} catch (err) {
|
|
12534
12784
|
logger.warn(`open: true \u2014 unexpected error: ${err.message}`);
|
|
12535
12785
|
}
|
|
@@ -12554,6 +12804,24 @@ function resolveHistoryPath(options, cwd) {
|
|
|
12554
12804
|
const projectKey = crypto3.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
|
|
12555
12805
|
return path11.join(os6.homedir(), ".reportforge", projectKey, "history.json");
|
|
12556
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
|
+
}
|
|
12557
12825
|
|
|
12558
12826
|
// src/index.ts
|
|
12559
12827
|
function defineReporterConfig(options) {
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
</div>
|
|
11
11
|
</div>
|
|
12
12
|
|
|
13
|
-
{{!-- Trend card: detailed + executive
|
|
14
|
-
|
|
15
|
-
{{#unless (eq meta.template 'minimal')}}
|
|
13
|
+
{{!-- Trend card: detailed + executive + custom templates. Minimal has showTrend=false. --}}
|
|
14
|
+
{{#if options.showTrend}}
|
|
16
15
|
{{#if charts.trend}}
|
|
17
16
|
<div class="trend-card">
|
|
18
17
|
<div class="trend-card__header">
|
|
@@ -29,7 +28,43 @@
|
|
|
29
28
|
<div class="trend-run-history" id="run-history-table"></div>
|
|
30
29
|
</div>
|
|
31
30
|
{{/if}}
|
|
32
|
-
{{/
|
|
31
|
+
{{/if}}
|
|
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}}
|
|
33
68
|
|
|
34
69
|
<script>
|
|
35
70
|
(function() {
|
|
@@ -144,7 +179,7 @@
|
|
|
144
179
|
}
|
|
145
180
|
});
|
|
146
181
|
|
|
147
|
-
{{#
|
|
182
|
+
{{#if options.showTrend}}{{#if charts.trend}}
|
|
148
183
|
// entries are newest-first in storage; reverse for oldest-left (chronological) display
|
|
149
184
|
var trendRaw = {{{safeJsonData charts.trend.entries}}};
|
|
150
185
|
var trendData = trendRaw.slice().reverse();
|
|
@@ -298,7 +333,7 @@
|
|
|
298
333
|
histEl.appendChild(row);
|
|
299
334
|
});
|
|
300
335
|
}
|
|
301
|
-
{{/if}}{{/
|
|
336
|
+
{{/if}}{{/if}}
|
|
302
337
|
})();
|
|
303
338
|
</script>
|
|
304
339
|
{{else}}
|