@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.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),
|
|
@@ -10373,7 +10381,16 @@ var ReporterOptionsSchema = external_exports.object({
|
|
|
10373
10381
|
notify: notifyConfigSchema,
|
|
10374
10382
|
historyFile: external_exports.string().optional(),
|
|
10375
10383
|
historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
|
|
10376
|
-
showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend)
|
|
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),
|
|
10386
|
+
templatePath: external_exports.union([
|
|
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"),
|
|
10388
|
+
external_exports.array(
|
|
10389
|
+
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")
|
|
10390
|
+
).min(1, "templatePath array must not be empty")
|
|
10391
|
+
]).optional().transform(
|
|
10392
|
+
(v) => v === void 0 ? void 0 : Array.isArray(v) ? v : [v]
|
|
10393
|
+
)
|
|
10377
10394
|
});
|
|
10378
10395
|
function parseOptions(raw) {
|
|
10379
10396
|
const result = ReporterOptionsSchema.safeParse(raw ?? {});
|
|
@@ -11461,6 +11478,7 @@ var TemplateEngine = class {
|
|
|
11461
11478
|
constructor() {
|
|
11462
11479
|
this.chartjsInline = "";
|
|
11463
11480
|
this.fontsBundleCss = "";
|
|
11481
|
+
this.customTemplateCache = /* @__PURE__ */ new Map();
|
|
11464
11482
|
this.handlebars = import_handlebars.default.create();
|
|
11465
11483
|
this.templatesDir = import_path3.default.resolve(__dirname, "templates");
|
|
11466
11484
|
if (!import_fs4.default.existsSync(this.templatesDir)) {
|
|
@@ -11490,12 +11508,43 @@ var TemplateEngine = class {
|
|
|
11490
11508
|
const ctx = this.buildContext(data, template);
|
|
11491
11509
|
return compiledLayout(ctx);
|
|
11492
11510
|
}
|
|
11511
|
+
renderFromPath(data, filePath) {
|
|
11512
|
+
if (!this.customTemplateCache.has(filePath)) {
|
|
11513
|
+
if (!import_fs4.default.existsSync(filePath)) {
|
|
11514
|
+
throw new Error(`[reportforge] Custom template not found: ${filePath}`);
|
|
11515
|
+
}
|
|
11516
|
+
const source = import_fs4.default.readFileSync(filePath, "utf8");
|
|
11517
|
+
this.customTemplateCache.set(filePath, this.handlebars.compile(source));
|
|
11518
|
+
}
|
|
11519
|
+
const compiledFn = this.customTemplateCache.get(filePath);
|
|
11520
|
+
return compiledFn(this.buildCustomContext(data));
|
|
11521
|
+
}
|
|
11522
|
+
buildCustomContext(data) {
|
|
11523
|
+
return {
|
|
11524
|
+
...data,
|
|
11525
|
+
options: {
|
|
11526
|
+
showCharts: true,
|
|
11527
|
+
showTrend: true,
|
|
11528
|
+
showStackTraces: true,
|
|
11529
|
+
showFullEnvironment: true,
|
|
11530
|
+
showPassRate: true,
|
|
11531
|
+
showRetries: true,
|
|
11532
|
+
showFullFailures: true
|
|
11533
|
+
},
|
|
11534
|
+
tagGroups: this.buildTagGroups(data),
|
|
11535
|
+
slowTests: this.buildSlowTests(data),
|
|
11536
|
+
baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
|
|
11537
|
+
templateCSS: "",
|
|
11538
|
+
chartjsScript: this.buildChartjsScript()
|
|
11539
|
+
};
|
|
11540
|
+
}
|
|
11493
11541
|
buildContext(data, template) {
|
|
11494
11542
|
const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
|
|
11495
11543
|
const templateCSS = this.readStyle(`${template}.css`);
|
|
11496
11544
|
const optionsMap = {
|
|
11497
11545
|
minimal: {
|
|
11498
11546
|
showCharts: false,
|
|
11547
|
+
showTrend: false,
|
|
11499
11548
|
showStackTraces: true,
|
|
11500
11549
|
showFullEnvironment: false,
|
|
11501
11550
|
showPassRate: true,
|
|
@@ -11504,6 +11553,7 @@ var TemplateEngine = class {
|
|
|
11504
11553
|
},
|
|
11505
11554
|
detailed: {
|
|
11506
11555
|
showCharts: true,
|
|
11556
|
+
showTrend: true,
|
|
11507
11557
|
showStackTraces: true,
|
|
11508
11558
|
showFullEnvironment: true,
|
|
11509
11559
|
showPassRate: true,
|
|
@@ -11512,6 +11562,7 @@ var TemplateEngine = class {
|
|
|
11512
11562
|
},
|
|
11513
11563
|
executive: {
|
|
11514
11564
|
showCharts: true,
|
|
11565
|
+
showTrend: true,
|
|
11515
11566
|
showStackTraces: false,
|
|
11516
11567
|
showFullEnvironment: false,
|
|
11517
11568
|
showPassRate: true,
|
|
@@ -11519,7 +11570,7 @@ var TemplateEngine = class {
|
|
|
11519
11570
|
showFullFailures: false
|
|
11520
11571
|
}
|
|
11521
11572
|
};
|
|
11522
|
-
const chartjsScript = this.
|
|
11573
|
+
const chartjsScript = this.buildChartjsScript();
|
|
11523
11574
|
return {
|
|
11524
11575
|
...data,
|
|
11525
11576
|
options: optionsMap[template],
|
|
@@ -11530,6 +11581,9 @@ var TemplateEngine = class {
|
|
|
11530
11581
|
chartjsScript
|
|
11531
11582
|
};
|
|
11532
11583
|
}
|
|
11584
|
+
buildChartjsScript() {
|
|
11585
|
+
return this.chartjsInline ? `<script>${this.chartjsInline}</script>` : "<!-- Chart.js not bundled; charts disabled --><script>window.__chartsReady=true;</script>";
|
|
11586
|
+
}
|
|
11533
11587
|
buildTagGroups(data) {
|
|
11534
11588
|
const tagMap = /* @__PURE__ */ new Map();
|
|
11535
11589
|
for (const project of data.projects) {
|
|
@@ -11910,10 +11964,10 @@ var FilenameResolver = class {
|
|
|
11910
11964
|
import_fs9.default.mkdirSync(import_path6.default.dirname(absolute), { recursive: true });
|
|
11911
11965
|
return absolute;
|
|
11912
11966
|
}
|
|
11913
|
-
injectTemplateSuffix(outputPath,
|
|
11967
|
+
injectTemplateSuffix(outputPath, suffix) {
|
|
11914
11968
|
const ext = import_path6.default.extname(outputPath);
|
|
11915
11969
|
const base = outputPath.slice(0, outputPath.length - ext.length);
|
|
11916
|
-
return `${base}-${
|
|
11970
|
+
return `${base}-${suffix}${ext}`;
|
|
11917
11971
|
}
|
|
11918
11972
|
formatDate(d) {
|
|
11919
11973
|
const yyyy = d.getFullYear();
|
|
@@ -12033,9 +12087,15 @@ var PdfGenerator = class {
|
|
|
12033
12087
|
}
|
|
12034
12088
|
}
|
|
12035
12089
|
async generateAll(data, options, _licenseInfo) {
|
|
12036
|
-
const
|
|
12037
|
-
const
|
|
12038
|
-
const
|
|
12090
|
+
const sources = this.resolveTemplateSources(options);
|
|
12091
|
+
const isMulti = sources.length > 1;
|
|
12092
|
+
for (const source of sources) {
|
|
12093
|
+
if (source.kind === "custom" && !import_fs11.default.existsSync(source.absolutePath)) {
|
|
12094
|
+
throw new Error(
|
|
12095
|
+
`[reportforge] Custom template not found: ${source.absolutePath}`
|
|
12096
|
+
);
|
|
12097
|
+
}
|
|
12098
|
+
}
|
|
12039
12099
|
if (options.logo) {
|
|
12040
12100
|
const logoBase64 = await this.screenshotEmbedder.embedLogoFile(options.logo);
|
|
12041
12101
|
data.meta.branding.logoBase64 = logoBase64;
|
|
@@ -12052,11 +12112,13 @@ var PdfGenerator = class {
|
|
|
12052
12112
|
const outputPaths = [];
|
|
12053
12113
|
const { page } = await this.browserManager.launch(options.puppeteerExecutablePath);
|
|
12054
12114
|
try {
|
|
12055
|
-
for (const
|
|
12056
|
-
const
|
|
12115
|
+
for (const source of sources) {
|
|
12116
|
+
const suffix = source.kind === "builtin" ? source.id : source.label;
|
|
12117
|
+
const outputPath = isMulti ? this.filenameResolver.injectTemplateSuffix(baseOutputPath, suffix) : baseOutputPath;
|
|
12118
|
+
const templateId = source.kind === "builtin" ? source.id : "detailed";
|
|
12057
12119
|
const templateData = { ...data, meta: { ...data.meta, template: templateId } };
|
|
12058
12120
|
const templateOptions = { ...options, template: templateId };
|
|
12059
|
-
await this.renderPdf(templateData, templateOptions, compression, outputPath, page);
|
|
12121
|
+
await this.renderPdf(templateData, templateOptions, source, compression, outputPath, page);
|
|
12060
12122
|
const capBytes = (options.maxFileSizeMb ?? 0) * 1024 * 1024;
|
|
12061
12123
|
if (capBytes > 0) {
|
|
12062
12124
|
const sizeBytes = (await import_fs11.default.promises.stat(outputPath)).size;
|
|
@@ -12065,7 +12127,7 @@ var PdfGenerator = class {
|
|
|
12065
12127
|
logger.warn(
|
|
12066
12128
|
`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}).`
|
|
12067
12129
|
);
|
|
12068
|
-
await this.renderPdf(templateData, templateOptions, tighter, outputPath, page);
|
|
12130
|
+
await this.renderPdf(templateData, templateOptions, source, tighter, outputPath, page);
|
|
12069
12131
|
const finalSize = (await import_fs11.default.promises.stat(outputPath)).size;
|
|
12070
12132
|
if (finalSize > capBytes) {
|
|
12071
12133
|
logger.warn(
|
|
@@ -12088,6 +12150,17 @@ var PdfGenerator = class {
|
|
|
12088
12150
|
}
|
|
12089
12151
|
return outputPaths;
|
|
12090
12152
|
}
|
|
12153
|
+
resolveTemplateSources(options) {
|
|
12154
|
+
if (options.templatePath && options.templatePath.length > 0) {
|
|
12155
|
+
return [...new Set(options.templatePath)].map((p) => ({
|
|
12156
|
+
kind: "custom",
|
|
12157
|
+
absolutePath: import_path8.default.isAbsolute(p) ? p : import_path8.default.resolve(process.cwd(), p),
|
|
12158
|
+
label: import_path8.default.basename(p, ".hbs")
|
|
12159
|
+
}));
|
|
12160
|
+
}
|
|
12161
|
+
const rawIds = Array.isArray(options.template) ? options.template : [options.template];
|
|
12162
|
+
return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
|
|
12163
|
+
}
|
|
12091
12164
|
resolveSettings(options, data) {
|
|
12092
12165
|
return resolveCompression({
|
|
12093
12166
|
level: options.compressionLevel ?? "auto",
|
|
@@ -12122,7 +12195,7 @@ var PdfGenerator = class {
|
|
|
12122
12195
|
* Separated from generateAll() so the size-cap retune can reuse it with
|
|
12123
12196
|
* stricter settings without reshaping the rest of the flow.
|
|
12124
12197
|
*/
|
|
12125
|
-
async renderPdf(data, options, compression, outputPath, page) {
|
|
12198
|
+
async renderPdf(data, options, source, compression, outputPath, page) {
|
|
12126
12199
|
const paginated = await this.failurePaginator.paginate(
|
|
12127
12200
|
data.failures,
|
|
12128
12201
|
compression.maxInlineFailures,
|
|
@@ -12142,15 +12215,15 @@ var PdfGenerator = class {
|
|
|
12142
12215
|
maxWidth: compression.maxWidth
|
|
12143
12216
|
});
|
|
12144
12217
|
}
|
|
12145
|
-
const
|
|
12146
|
-
logger.info(`Rendering template: ${
|
|
12147
|
-
const html = this.templateEngine.render(renderData,
|
|
12218
|
+
const templateLabel = source.kind === "builtin" ? source.id : source.label;
|
|
12219
|
+
logger.info(`Rendering template: ${templateLabel}`);
|
|
12220
|
+
const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id) : this.templateEngine.renderFromPath(renderData, source.absolutePath);
|
|
12148
12221
|
const tempHtmlPath = await this.htmlWriter.write(html);
|
|
12149
12222
|
const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
|
|
12150
12223
|
try {
|
|
12151
12224
|
logger.info(`Navigating to temp HTML (${Math.round(html.length / 1024)} KB)...`);
|
|
12152
12225
|
await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
|
|
12153
|
-
const showCharts =
|
|
12226
|
+
const showCharts = source.kind === "builtin" ? source.id !== "minimal" : true;
|
|
12154
12227
|
await this.chartRenderer.waitForCharts(page, showCharts);
|
|
12155
12228
|
logger.info(`Generating PDF \u2192 ${import_path8.default.relative(process.cwd(), outputPath)}`);
|
|
12156
12229
|
await page.pdf({
|
|
@@ -12169,7 +12242,7 @@ function fmtMb(bytes) {
|
|
|
12169
12242
|
}
|
|
12170
12243
|
|
|
12171
12244
|
// src/reporter.ts
|
|
12172
|
-
var
|
|
12245
|
+
var import_path12 = __toESM(require("path"));
|
|
12173
12246
|
var import_crypto7 = __toESM(require("crypto"));
|
|
12174
12247
|
var import_os6 = __toESM(require("os"));
|
|
12175
12248
|
|
|
@@ -12211,6 +12284,42 @@ var HistoryManager = class {
|
|
|
12211
12284
|
}
|
|
12212
12285
|
};
|
|
12213
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
|
+
|
|
12214
12323
|
// src/notify/index.ts
|
|
12215
12324
|
init_cjs_shims();
|
|
12216
12325
|
|
|
@@ -12327,6 +12436,115 @@ ${name}`,
|
|
|
12327
12436
|
};
|
|
12328
12437
|
}
|
|
12329
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
|
+
|
|
12330
12548
|
// src/notify/NotificationSender.ts
|
|
12331
12549
|
function shouldNotify(channel, stats) {
|
|
12332
12550
|
if (!channel.enabled) return false;
|
|
@@ -12355,6 +12573,18 @@ var NotificationSender = class {
|
|
|
12355
12573
|
return this.post(name, channel.url, payload, result);
|
|
12356
12574
|
})
|
|
12357
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
|
+
}
|
|
12358
12588
|
return result;
|
|
12359
12589
|
}
|
|
12360
12590
|
async post(name, url, payload, result) {
|
|
@@ -12386,7 +12616,7 @@ var PdfReporter = class {
|
|
|
12386
12616
|
this.dataCollector = new DataCollector();
|
|
12387
12617
|
this.pdfGenerator = new PdfGenerator();
|
|
12388
12618
|
this.options = parseOptions(rawOptions);
|
|
12389
|
-
const version = true ? "0.0
|
|
12619
|
+
const version = true ? "0.2.0" : "0.x";
|
|
12390
12620
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
12391
12621
|
this.licenseClient = new LicenseClient({
|
|
12392
12622
|
licenseKey: this.options.licenseKey,
|
|
@@ -12442,6 +12672,7 @@ var PdfReporter = class {
|
|
|
12442
12672
|
const historyPath = resolveHistoryPath(this.options, this.cwd);
|
|
12443
12673
|
const hm = new HistoryManager(historyPath);
|
|
12444
12674
|
const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
|
|
12675
|
+
const flakyTests = collectFlakyTests(collected.projects);
|
|
12445
12676
|
const entry = {
|
|
12446
12677
|
runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
|
|
12447
12678
|
timestamp: Date.now(),
|
|
@@ -12451,9 +12682,13 @@ var PdfReporter = class {
|
|
|
12451
12682
|
failed: collected.stats.failed,
|
|
12452
12683
|
flaky: collected.stats.flaky,
|
|
12453
12684
|
duration: collected.stats.duration,
|
|
12454
|
-
verdict: deriveVerdict(collected.stats)
|
|
12685
|
+
verdict: deriveVerdict(collected.stats),
|
|
12686
|
+
flakyTests
|
|
12455
12687
|
};
|
|
12456
12688
|
const entries = hm.append(entry, this.options.historySize);
|
|
12689
|
+
this._populateHistoryCharts(entries, collected);
|
|
12690
|
+
}
|
|
12691
|
+
_populateHistoryCharts(entries, collected) {
|
|
12457
12692
|
if (entries.length >= 2) {
|
|
12458
12693
|
collected.charts.trend = {
|
|
12459
12694
|
entries: entries.map((e) => ({
|
|
@@ -12467,11 +12702,17 @@ var PdfReporter = class {
|
|
|
12467
12702
|
delta: entries[0].passRate - entries[1].passRate
|
|
12468
12703
|
};
|
|
12469
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
|
+
}
|
|
12470
12711
|
}
|
|
12471
12712
|
_buildReportData(collected, licenseInfo) {
|
|
12472
12713
|
const { projects, stats, failures, environment, charts } = collected;
|
|
12473
12714
|
const projectName = this.options.projectName ?? this.resolveProjectName();
|
|
12474
|
-
const firstTemplate = Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
|
|
12715
|
+
const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
|
|
12475
12716
|
return {
|
|
12476
12717
|
meta: {
|
|
12477
12718
|
title: this.options.reportTitle,
|
|
@@ -12507,7 +12748,7 @@ var PdfReporter = class {
|
|
|
12507
12748
|
}
|
|
12508
12749
|
resolveProjectName() {
|
|
12509
12750
|
try {
|
|
12510
|
-
const pkgPath =
|
|
12751
|
+
const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
|
|
12511
12752
|
const pkg = require(pkgPath);
|
|
12512
12753
|
return pkg.name ?? "Playwright Tests";
|
|
12513
12754
|
} catch {
|
|
@@ -12529,6 +12770,15 @@ var PdfReporter = class {
|
|
|
12529
12770
|
logger.warn(`open: true \u2014 could not open ${pdfPath}: ${err.message}`);
|
|
12530
12771
|
});
|
|
12531
12772
|
child.unref();
|
|
12773
|
+
if (platform === "win32") {
|
|
12774
|
+
child.on("close", (code) => {
|
|
12775
|
+
if (code !== 0 && code !== null) {
|
|
12776
|
+
logger.warn(
|
|
12777
|
+
`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}`
|
|
12778
|
+
);
|
|
12779
|
+
}
|
|
12780
|
+
});
|
|
12781
|
+
}
|
|
12532
12782
|
} catch (err) {
|
|
12533
12783
|
logger.warn(`open: true \u2014 unexpected error: ${err.message}`);
|
|
12534
12784
|
}
|
|
@@ -12548,10 +12798,28 @@ function deriveVerdict(stats) {
|
|
|
12548
12798
|
}
|
|
12549
12799
|
function resolveHistoryPath(options, cwd) {
|
|
12550
12800
|
if (options.historyFile) {
|
|
12551
|
-
return
|
|
12801
|
+
return import_path12.default.isAbsolute(options.historyFile) ? options.historyFile : import_path12.default.resolve(cwd, options.historyFile);
|
|
12552
12802
|
}
|
|
12553
12803
|
const projectKey = import_crypto7.default.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
|
|
12554
|
-
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;
|
|
12555
12823
|
}
|
|
12556
12824
|
|
|
12557
12825
|
// src/index.ts
|