@reportforge/playwright-pdf 0.11.2 → 0.13.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 +7 -4
- package/dist/cli/index.js +98 -5
- package/dist/index.d.mts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +89 -22
- package/dist/index.mjs +85 -18
- package/dist/templates/partials/defect-log.hbs +11 -9
- package/dist/templates/styles/base.css +13 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,11 +55,11 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
|
|
|
55
55
|
- **CI/CD snippets** — GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps
|
|
56
56
|
- **Live runs** — opt-in `live` streams per-test progress to an unguessable watch link printed at run start; all shards of one CI run converge on a single live dashboard while tests execute. The PDF is still produced at the end, unchanged
|
|
57
57
|
- **Shard merging** — combine N Playwright JSON shard reports into one PDF via `shardResults`; accepts glob patterns or explicit paths
|
|
58
|
-
- **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email
|
|
58
|
+
- **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email and Discord support optional PDF attachment via `attachPdf`
|
|
59
59
|
- **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)
|
|
60
60
|
- **Offline failure analysis** — sorts each failure into one of 7 root-cause buckets and renders a per-test root-cause chip inline under the failed test in the `detailed` PDF (dominant-cause one-liner in `executive`). No network, no AI service — a small embedded classifier that updates itself safely over your existing license refresh; pin it any time. Full details at [reportforge.org/docs/advanced/failure-analysis](https://reportforge.org/docs/advanced/failure-analysis).
|
|
61
61
|
- `npx @reportforge/playwright-pdf export-feedback` — exports locally-collected unrecognized failures (tokenized, local-only) for optional manual sharing.
|
|
62
|
-
- **Rich execution capture** — opt-in
|
|
62
|
+
- **Rich execution capture** — opt-in "Steps to Reproduce" outline (a nested, copy-pasteable Markdown list of your `test.step`s and assertions — add `apiSteps` for the full click/fill/check action trail), Node console tail, and trace/video links under each failed test in the defect log, read straight from the Playwright step tree — no fixtures, no test-code changes. Enable via `capture` (`steps` / `apiSteps` / `console` / `evidence`); off by default.
|
|
63
63
|
- **Configurable sections** — add or remove any report section per template via `sections`.
|
|
64
64
|
- **Hybrid licensing** — one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
|
|
65
65
|
|
|
@@ -194,9 +194,9 @@ All options are the second element of the reporter tuple.
|
|
|
194
194
|
| `maxInlineFailures` | `number` | derived from `compressionLevel` | Cap on failure entries rendered inline in the PDF. Overflow is written to a sibling `{basename}-failures.json` sidecar file. |
|
|
195
195
|
| `maxFileSizeMb` | `number` | `8` | Soft cap on the final PDF size in MB. If exceeded, the report is re-rendered once with the `'max'` compression preset. |
|
|
196
196
|
| `shardResults` | `string \| string[]` | — | Glob or path array of Playwright JSON shard report files to merge into one PDF. See the Shard Merging docs. |
|
|
197
|
-
| `notify` | `object` | — | Notification channels: `slack`, `teams
|
|
197
|
+
| `notify` | `object` | — | Notification channels: `slack`, `teams` (each `{ url, enabled, on }`), `discord` (`{ url, enabled, on, attachPdf }`), `email` (`{ to, enabled, on, attachPdf }`). See the Notifications docs. |
|
|
198
198
|
| `failureAnalysis` | `object` | `{ enabled: true }` | Offline failure root-cause analysis (embedded Naive Bayes; no runtime network). Fields: `enabled` (default `true`), `maxClusters` (default `10`), `minStrength` (`weak`\|`moderate`\|`strong`, default `weak`), `maxFailuresToAnalyse` (default `500`), `collectUnclassified` (default `true`), `autoUpdateModel` (default `true`). See the Failure Analysis docs. |
|
|
199
|
-
| `capture` | `object` | `{}` (off) | Opt-in rich execution capture for the defect log (reporter-side; no fixtures, no test-code changes). Fields: `steps` (
|
|
199
|
+
| `capture` | `object` | `{}` (off) | Opt-in rich execution capture for the defect log (reporter-side; no fixtures, no test-code changes). Fields: `steps` (a copy-pasteable "Steps to Reproduce" outline built from the Playwright step tree — each row a Markdown list line indented under its parent `test.step`, default `false`), `apiSteps` (include every top-level `pw:api` action (click/fill/check/goto) in the outline for a full action trail; off by default the outline shows only `test.step` intent + `expect` assertions, default `false`), `console` (Node stdout/stderr tail, default `false`), `evidence` (trace/video file links, default `false`), `maxSteps` (default `50`), `maxConsoleLines` (default `50`). Renders in the Defect Log section of the `detailed` template. |
|
|
200
200
|
| `live` | `object` | `{}` (off) | Opt-in live test-execution streaming. When `enabled`, the reporter prints an unguessable watch link to the CI logs at run start and streams per-test progress to the hosted dashboard while tests run — all shards of one CI run converge on a single live view. Fields: `enabled` (default `false`), `runId` (override the auto-derived run id), `serverUrl` (override the streaming server), `steps` (`none`\|`failed`\|`intent`\|`all`, default `failed`; `intent` shows only your `test.step` names plus any failing step), `console` (stream stdout/stderr tails, default `false`), `flushMs` (batch debounce 500–10000, default `2000`). The PDF at the end of the run is unaffected. Requires an active subscription with the `live` entitlement. See the Live Runs docs. |
|
|
201
201
|
| `historyFile` | `string` | `~/.reportforge/{key}/history.json` | Path to the history JSON file. Relative paths resolve from `cwd`. Enables pass-rate trending charts in the `detailed` template. |
|
|
202
202
|
| `historySize` | `number` | `10` | Maximum number of test runs to keep in the history file (integer ≥ 2). Older runs are pruned on append. |
|
|
@@ -282,6 +282,7 @@ notify: {
|
|
|
282
282
|
url: process.env.DISCORD_WEBHOOK_URL,
|
|
283
283
|
enabled: true,
|
|
284
284
|
on: 'failure',
|
|
285
|
+
attachPdf: true, // upload the PDF with the message (default: false)
|
|
285
286
|
},
|
|
286
287
|
email: {
|
|
287
288
|
to: ['team@company.com', 'qa@company.com'],
|
|
@@ -304,6 +305,8 @@ The `on` trigger controls when the message fires:
|
|
|
304
305
|
|
|
305
306
|
**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.
|
|
306
307
|
|
|
308
|
+
**PDF attachment** (`attachPdf: true`) is available on `email` and `discord` — Slack and Teams webhooks cannot carry file uploads. On Discord the PDF is uploaded with the message; if the upload fails or the PDF exceeds Discord's file-size cap, the summary still posts without the attachment. With multiple templates, the first PDF is attached.
|
|
309
|
+
|
|
307
310
|
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).
|
|
308
311
|
|
|
309
312
|
### Test History Trending
|
package/dist/cli/index.js
CHANGED
|
@@ -139,7 +139,37 @@ function buildDemoFailures() {
|
|
|
139
139
|
{ attempt: 2, status: "failed", duration: 4900 }
|
|
140
140
|
],
|
|
141
141
|
durationMs: 4900,
|
|
142
|
-
severity: "critical"
|
|
142
|
+
severity: "critical",
|
|
143
|
+
// A test with no test.step → a flat repro outline (every step depth 0).
|
|
144
|
+
execution: {
|
|
145
|
+
source: "reporter",
|
|
146
|
+
steps: [
|
|
147
|
+
{ title: "Navigate to '/login'", category: "pw:api", durationMs: 420, depth: 0, failed: false },
|
|
148
|
+
{ title: "Fill getByLabel('Email')", category: "pw:api", durationMs: 60, depth: 0, failed: false },
|
|
149
|
+
{ title: "Fill getByLabel('Password')", category: "pw:api", durationMs: 55, depth: 0, failed: false },
|
|
150
|
+
{ title: "Click getByRole('button', { name: 'Sign in' }) \u2014 repeated 5\xD7", category: "pw:api", durationMs: 2100, depth: 0, failed: false },
|
|
151
|
+
{
|
|
152
|
+
title: "Expect response status toBe 429",
|
|
153
|
+
category: "expect",
|
|
154
|
+
durationMs: 80,
|
|
155
|
+
depth: 0,
|
|
156
|
+
failed: true,
|
|
157
|
+
failingLeaf: true,
|
|
158
|
+
errorMessage: "expect(received).toBe(expected) \u2014 Expected: 429 Received: 200",
|
|
159
|
+
location: "tests/auth.spec.ts:79"
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
failingStep: {
|
|
163
|
+
title: "Expect response status toBe 429",
|
|
164
|
+
category: "expect",
|
|
165
|
+
durationMs: 80,
|
|
166
|
+
depth: 0,
|
|
167
|
+
failed: true,
|
|
168
|
+
failingLeaf: true,
|
|
169
|
+
errorMessage: "expect(received).toBe(expected) \u2014 Expected: 429 Received: 200",
|
|
170
|
+
location: "tests/auth.spec.ts:79"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
143
173
|
},
|
|
144
174
|
{
|
|
145
175
|
testId: "co-3",
|
|
@@ -155,7 +185,38 @@ function buildDemoFailures() {
|
|
|
155
185
|
{ attempt: 2, status: "failed", duration: 4900 }
|
|
156
186
|
],
|
|
157
187
|
durationMs: 4900,
|
|
158
|
-
severity: "high"
|
|
188
|
+
severity: "high",
|
|
189
|
+
// Nested outline — child actions/assertions indent under their parent test.step.
|
|
190
|
+
execution: {
|
|
191
|
+
source: "reporter",
|
|
192
|
+
steps: [
|
|
193
|
+
{ title: "Add product to cart", category: "test.step", durationMs: 1200, depth: 0, failed: false },
|
|
194
|
+
{ title: "Click getByRole('button', { name: 'Add to cart' })", category: "pw:api", durationMs: 340, depth: 1, failed: false },
|
|
195
|
+
{ title: "Apply expired promo code", category: "test.step", durationMs: 5600, depth: 0, failed: false },
|
|
196
|
+
{ title: "Fill getByLabel('Promo code')", category: "pw:api", durationMs: 120, depth: 1, failed: false },
|
|
197
|
+
{ title: "Click getByRole('button', { name: 'Apply' })", category: "pw:api", durationMs: 90, depth: 1, failed: false },
|
|
198
|
+
{
|
|
199
|
+
title: "Expect getByText('Promo code is invalid') to be visible",
|
|
200
|
+
category: "expect",
|
|
201
|
+
durationMs: 5001,
|
|
202
|
+
depth: 1,
|
|
203
|
+
failed: true,
|
|
204
|
+
failingLeaf: true,
|
|
205
|
+
errorMessage: "Timed out 5000ms waiting for expect(locator).toBeVisible()",
|
|
206
|
+
location: "tests/checkout.spec.ts:58"
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
failingStep: {
|
|
210
|
+
title: "Expect getByText('Promo code is invalid') to be visible",
|
|
211
|
+
category: "expect",
|
|
212
|
+
durationMs: 5001,
|
|
213
|
+
depth: 1,
|
|
214
|
+
failed: true,
|
|
215
|
+
failingLeaf: true,
|
|
216
|
+
errorMessage: "Timed out 5000ms waiting for expect(locator).toBeVisible()",
|
|
217
|
+
location: "tests/checkout.spec.ts:58"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
159
220
|
},
|
|
160
221
|
{
|
|
161
222
|
testId: "cat-7",
|
|
@@ -171,7 +232,35 @@ function buildDemoFailures() {
|
|
|
171
232
|
{ attempt: 2, status: "failed", duration: 3900 }
|
|
172
233
|
],
|
|
173
234
|
durationMs: 3900,
|
|
174
|
-
severity: "medium"
|
|
235
|
+
severity: "medium",
|
|
236
|
+
execution: {
|
|
237
|
+
source: "reporter",
|
|
238
|
+
steps: [
|
|
239
|
+
{ title: "Scroll to lazy-loaded section", category: "test.step", durationMs: 900, depth: 0, failed: false },
|
|
240
|
+
{ title: "Mouse wheel by (0, 2000)", category: "pw:api", durationMs: 300, depth: 1, failed: false },
|
|
241
|
+
{ title: "Verify lazy image renders", category: "test.step", durationMs: 5e3, depth: 0, failed: false },
|
|
242
|
+
{
|
|
243
|
+
title: "Expect getByTestId('lazy-img-12') to be visible",
|
|
244
|
+
category: "expect",
|
|
245
|
+
durationMs: 5e3,
|
|
246
|
+
depth: 1,
|
|
247
|
+
failed: true,
|
|
248
|
+
failingLeaf: true,
|
|
249
|
+
errorMessage: "expect(locator).toBeVisible() \u2014 Expected: visible Received: hidden",
|
|
250
|
+
location: "tests/catalog.spec.ts:112"
|
|
251
|
+
}
|
|
252
|
+
],
|
|
253
|
+
failingStep: {
|
|
254
|
+
title: "Expect getByTestId('lazy-img-12') to be visible",
|
|
255
|
+
category: "expect",
|
|
256
|
+
durationMs: 5e3,
|
|
257
|
+
depth: 1,
|
|
258
|
+
failed: true,
|
|
259
|
+
failingLeaf: true,
|
|
260
|
+
errorMessage: "expect(locator).toBeVisible() \u2014 Expected: visible Received: hidden",
|
|
261
|
+
location: "tests/catalog.spec.ts:112"
|
|
262
|
+
}
|
|
263
|
+
}
|
|
175
264
|
}
|
|
176
265
|
];
|
|
177
266
|
}
|
|
@@ -6670,6 +6759,7 @@ var init_engine = __esm({
|
|
|
6670
6759
|
}
|
|
6671
6760
|
registerHelpers() {
|
|
6672
6761
|
this.handlebars.registerHelper("formatDuration", (ms) => formatDuration(ms ?? 0));
|
|
6762
|
+
this.handlebars.registerHelper("repeat", (s, n) => typeof s === "string" ? s.repeat(Math.max(0, n | 0)) : "");
|
|
6673
6763
|
this.handlebars.registerHelper("formatDate", (d) => {
|
|
6674
6764
|
const date = d instanceof Date ? d : new Date(d);
|
|
6675
6765
|
return date.toLocaleString("en-US", {
|
|
@@ -11831,7 +11921,7 @@ ${issues}`);
|
|
|
11831
11921
|
}
|
|
11832
11922
|
return result.data;
|
|
11833
11923
|
}
|
|
11834
|
-
var hexColor, channelConfigSchema, emailChannelConfigSchema, notifyConfigSchema, failureAnalysisConfigSchema, captureConfigSchema, liveConfigSchema, sectionFlagsShape, sectionFlagsSchema, sectionsSchema, ReporterOptionsSchema;
|
|
11924
|
+
var hexColor, channelConfigSchema, discordChannelConfigSchema, emailChannelConfigSchema, notifyConfigSchema, failureAnalysisConfigSchema, captureConfigSchema, liveConfigSchema, sectionFlagsShape, sectionFlagsSchema, sectionsSchema, ReporterOptionsSchema;
|
|
11835
11925
|
var init_schema = __esm({
|
|
11836
11926
|
"src/config/schema.ts"() {
|
|
11837
11927
|
"use strict";
|
|
@@ -11848,6 +11938,9 @@ var init_schema = __esm({
|
|
|
11848
11938
|
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
11849
11939
|
enabled: external_exports.boolean().default(false)
|
|
11850
11940
|
});
|
|
11941
|
+
discordChannelConfigSchema = channelConfigSchema.extend({
|
|
11942
|
+
attachPdf: external_exports.boolean().default(false)
|
|
11943
|
+
});
|
|
11851
11944
|
emailChannelConfigSchema = external_exports.object({
|
|
11852
11945
|
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'),
|
|
11853
11946
|
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
@@ -11857,7 +11950,7 @@ var init_schema = __esm({
|
|
|
11857
11950
|
notifyConfigSchema = external_exports.object({
|
|
11858
11951
|
slack: channelConfigSchema.optional(),
|
|
11859
11952
|
teams: channelConfigSchema.optional(),
|
|
11860
|
-
discord:
|
|
11953
|
+
discord: discordChannelConfigSchema.optional(),
|
|
11861
11954
|
email: emailChannelConfigSchema.optional()
|
|
11862
11955
|
}).optional();
|
|
11863
11956
|
failureAnalysisConfigSchema = external_exports.object({
|
package/dist/index.d.mts
CHANGED
|
@@ -158,7 +158,12 @@ interface ReporterOptions {
|
|
|
158
158
|
* `on` controls the trigger: `'always'` (default), `'failure'`, or `'success'`.
|
|
159
159
|
*
|
|
160
160
|
* - `slack` / `teams` / `discord`: webhook URL channels
|
|
161
|
+
* - `discord`: `attachPdf: true` uploads the PDF with the message (falls back
|
|
162
|
+
* to the summary alone if the upload fails or the PDF exceeds Discord's cap)
|
|
161
163
|
* - `email`: sends via Resend; `to` is required; `attachPdf: true` attaches the PDF
|
|
164
|
+
*
|
|
165
|
+
* Slack and Teams webhooks cannot carry file uploads, so `attachPdf` does not
|
|
166
|
+
* exist on those channels.
|
|
162
167
|
*/
|
|
163
168
|
notify?: {
|
|
164
169
|
slack?: {
|
|
@@ -175,6 +180,7 @@ interface ReporterOptions {
|
|
|
175
180
|
url: string;
|
|
176
181
|
on?: 'always' | 'failure' | 'success';
|
|
177
182
|
enabled?: boolean;
|
|
183
|
+
attachPdf?: boolean;
|
|
178
184
|
};
|
|
179
185
|
email?: {
|
|
180
186
|
to: string[];
|
|
@@ -223,9 +229,10 @@ interface ReporterOptions {
|
|
|
223
229
|
/** Capture a compacted repro-steps trail from the Playwright step tree. Default: false */
|
|
224
230
|
steps?: boolean;
|
|
225
231
|
/**
|
|
226
|
-
* Include
|
|
227
|
-
*
|
|
228
|
-
*
|
|
232
|
+
* Include every top-level `pw:api` action (click/fill/check/goto) in the repro
|
|
233
|
+
* outline — a full, copy-pasteable action trail across the whole test, nested under
|
|
234
|
+
* its parent `test.step`. Off by default, so the outline shows only `test.step`
|
|
235
|
+
* intent + `expect` assertions. Default: false
|
|
229
236
|
*/
|
|
230
237
|
apiSteps?: boolean;
|
|
231
238
|
/** Capture the tail of the test's Node stdout/stderr. Default: false */
|
package/dist/index.d.ts
CHANGED
|
@@ -158,7 +158,12 @@ interface ReporterOptions {
|
|
|
158
158
|
* `on` controls the trigger: `'always'` (default), `'failure'`, or `'success'`.
|
|
159
159
|
*
|
|
160
160
|
* - `slack` / `teams` / `discord`: webhook URL channels
|
|
161
|
+
* - `discord`: `attachPdf: true` uploads the PDF with the message (falls back
|
|
162
|
+
* to the summary alone if the upload fails or the PDF exceeds Discord's cap)
|
|
161
163
|
* - `email`: sends via Resend; `to` is required; `attachPdf: true` attaches the PDF
|
|
164
|
+
*
|
|
165
|
+
* Slack and Teams webhooks cannot carry file uploads, so `attachPdf` does not
|
|
166
|
+
* exist on those channels.
|
|
162
167
|
*/
|
|
163
168
|
notify?: {
|
|
164
169
|
slack?: {
|
|
@@ -175,6 +180,7 @@ interface ReporterOptions {
|
|
|
175
180
|
url: string;
|
|
176
181
|
on?: 'always' | 'failure' | 'success';
|
|
177
182
|
enabled?: boolean;
|
|
183
|
+
attachPdf?: boolean;
|
|
178
184
|
};
|
|
179
185
|
email?: {
|
|
180
186
|
to: string[];
|
|
@@ -223,9 +229,10 @@ interface ReporterOptions {
|
|
|
223
229
|
/** Capture a compacted repro-steps trail from the Playwright step tree. Default: false */
|
|
224
230
|
steps?: boolean;
|
|
225
231
|
/**
|
|
226
|
-
* Include
|
|
227
|
-
*
|
|
228
|
-
*
|
|
232
|
+
* Include every top-level `pw:api` action (click/fill/check/goto) in the repro
|
|
233
|
+
* outline — a full, copy-pasteable action trail across the whole test, nested under
|
|
234
|
+
* its parent `test.step`. Off by default, so the outline shows only `test.step`
|
|
235
|
+
* intent + `expect` assertions. Default: false
|
|
229
236
|
*/
|
|
230
237
|
apiSteps?: boolean;
|
|
231
238
|
/** Capture the tail of the test's Node stdout/stderr. Default: false */
|
package/dist/index.js
CHANGED
|
@@ -10454,6 +10454,9 @@ var channelConfigSchema = external_exports.object({
|
|
|
10454
10454
|
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
10455
10455
|
enabled: external_exports.boolean().default(false)
|
|
10456
10456
|
});
|
|
10457
|
+
var discordChannelConfigSchema = channelConfigSchema.extend({
|
|
10458
|
+
attachPdf: external_exports.boolean().default(false)
|
|
10459
|
+
});
|
|
10457
10460
|
var emailChannelConfigSchema = external_exports.object({
|
|
10458
10461
|
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'),
|
|
10459
10462
|
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
@@ -10463,7 +10466,7 @@ var emailChannelConfigSchema = external_exports.object({
|
|
|
10463
10466
|
var notifyConfigSchema = external_exports.object({
|
|
10464
10467
|
slack: channelConfigSchema.optional(),
|
|
10465
10468
|
teams: channelConfigSchema.optional(),
|
|
10466
|
-
discord:
|
|
10469
|
+
discord: discordChannelConfigSchema.optional(),
|
|
10467
10470
|
email: emailChannelConfigSchema.optional()
|
|
10468
10471
|
}).optional();
|
|
10469
10472
|
var failureAnalysisConfigSchema = external_exports.object({
|
|
@@ -11435,9 +11438,13 @@ init_cjs_shims();
|
|
|
11435
11438
|
var MAX_TITLE_CHARS = 200;
|
|
11436
11439
|
var DEFAULT_MAX_STEPS = 50;
|
|
11437
11440
|
var DEFAULT_MAX_CONSOLE_LINES = 50;
|
|
11438
|
-
var PW_API_WINDOW = 10;
|
|
11439
11441
|
var isTeardown = (category) => category === "hook" || category === "fixture";
|
|
11440
11442
|
var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
|
|
11443
|
+
var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
|
|
11444
|
+
const c = flat[a].category;
|
|
11445
|
+
return c === "pw:api" || c === "expect";
|
|
11446
|
+
});
|
|
11447
|
+
var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
|
|
11441
11448
|
function flatten(steps) {
|
|
11442
11449
|
const out = [];
|
|
11443
11450
|
const walk = (nodes, depth, ancestors) => {
|
|
@@ -11456,15 +11463,19 @@ function flatten(steps) {
|
|
|
11456
11463
|
walk(steps ?? [], 0, []);
|
|
11457
11464
|
return out;
|
|
11458
11465
|
}
|
|
11459
|
-
function materialize(n, isFailing) {
|
|
11466
|
+
function materialize(n, isFailing, depth) {
|
|
11460
11467
|
const loc = n.raw.location;
|
|
11468
|
+
const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
|
|
11461
11469
|
return {
|
|
11462
11470
|
title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
|
|
11463
11471
|
category: n.category,
|
|
11464
11472
|
durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
|
|
11465
|
-
depth
|
|
11473
|
+
depth,
|
|
11466
11474
|
failed: n.hasError,
|
|
11467
|
-
|
|
11475
|
+
// Marks the single failing leaf so the template renders the error there once —
|
|
11476
|
+
// independent of whether a step-level message survives (falls back to the record error).
|
|
11477
|
+
failingLeaf: isFailing || void 0,
|
|
11478
|
+
errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
|
|
11468
11479
|
location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
|
|
11469
11480
|
};
|
|
11470
11481
|
}
|
|
@@ -11488,21 +11499,19 @@ function compactSteps(rawSteps, opts = {}) {
|
|
|
11488
11499
|
const failingIdx = pickFailingIdx(flat);
|
|
11489
11500
|
const keep = /* @__PURE__ */ new Set();
|
|
11490
11501
|
flat.forEach((f, i) => {
|
|
11491
|
-
if (
|
|
11502
|
+
if (inHookSubtree(flat, f)) return;
|
|
11503
|
+
if (f.category === "test.step") keep.add(i);
|
|
11504
|
+
else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
|
|
11505
|
+
else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
|
|
11492
11506
|
});
|
|
11493
11507
|
if (failingIdx >= 0) {
|
|
11494
11508
|
keep.add(failingIdx);
|
|
11495
11509
|
for (const a of flat[failingIdx].ancestors) keep.add(a);
|
|
11496
|
-
if (opts.apiSteps) {
|
|
11497
|
-
const pwApiBefore = [];
|
|
11498
|
-
for (let i = 0; i < failingIdx; i++) {
|
|
11499
|
-
if (flat[i].category === "pw:api" && !inHookSubtree(flat, flat[i])) pwApiBefore.push(i);
|
|
11500
|
-
}
|
|
11501
|
-
for (const i of pwApiBefore.slice(-PW_API_WINDOW)) keep.add(i);
|
|
11502
|
-
}
|
|
11503
11510
|
}
|
|
11504
11511
|
let keepArr = [...keep].sort((a, b) => a - b);
|
|
11512
|
+
let omitted = 0;
|
|
11505
11513
|
if (keepArr.length > maxSteps) {
|
|
11514
|
+
omitted = keepArr.length - maxSteps;
|
|
11506
11515
|
keepArr = keepArr.slice(-maxSteps);
|
|
11507
11516
|
if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
|
|
11508
11517
|
keepArr = [failingIdx, ...keepArr.slice(1)];
|
|
@@ -11512,13 +11521,23 @@ function compactSteps(rawSteps, opts = {}) {
|
|
|
11512
11521
|
const mat = (i) => {
|
|
11513
11522
|
let es = cache.get(i);
|
|
11514
11523
|
if (!es) {
|
|
11515
|
-
es = materialize(flat[i], i === failingIdx);
|
|
11524
|
+
es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
|
|
11516
11525
|
cache.set(i, es);
|
|
11517
11526
|
}
|
|
11518
11527
|
return es;
|
|
11519
11528
|
};
|
|
11529
|
+
const steps = keepArr.map(mat);
|
|
11530
|
+
if (omitted > 0) {
|
|
11531
|
+
steps.unshift({
|
|
11532
|
+
title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
|
|
11533
|
+
category: "",
|
|
11534
|
+
durationMs: 0,
|
|
11535
|
+
depth: 0,
|
|
11536
|
+
failed: false
|
|
11537
|
+
});
|
|
11538
|
+
}
|
|
11520
11539
|
return {
|
|
11521
|
-
steps
|
|
11540
|
+
steps,
|
|
11522
11541
|
failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
|
|
11523
11542
|
};
|
|
11524
11543
|
}
|
|
@@ -12500,6 +12519,7 @@ var TemplateEngine = class {
|
|
|
12500
12519
|
}
|
|
12501
12520
|
registerHelpers() {
|
|
12502
12521
|
this.handlebars.registerHelper("formatDuration", (ms) => formatDuration(ms ?? 0));
|
|
12522
|
+
this.handlebars.registerHelper("repeat", (s, n) => typeof s === "string" ? s.repeat(Math.max(0, n | 0)) : "");
|
|
12503
12523
|
this.handlebars.registerHelper("formatDate", (d) => {
|
|
12504
12524
|
const date = d instanceof Date ? d : new Date(d);
|
|
12505
12525
|
return date.toLocaleString("en-US", {
|
|
@@ -13182,7 +13202,7 @@ function fmtMb(bytes) {
|
|
|
13182
13202
|
}
|
|
13183
13203
|
|
|
13184
13204
|
// src/reporter.ts
|
|
13185
|
-
var
|
|
13205
|
+
var import_path13 = __toESM(require("path"));
|
|
13186
13206
|
|
|
13187
13207
|
// src/history/HistoryManager.ts
|
|
13188
13208
|
init_cjs_shims();
|
|
@@ -13263,6 +13283,8 @@ init_cjs_shims();
|
|
|
13263
13283
|
|
|
13264
13284
|
// src/notify/NotificationSender.ts
|
|
13265
13285
|
init_cjs_shims();
|
|
13286
|
+
var import_promises2 = require("fs/promises");
|
|
13287
|
+
var import_path12 = require("path");
|
|
13266
13288
|
|
|
13267
13289
|
// src/notify/formatters/slack.ts
|
|
13268
13290
|
init_cjs_shims();
|
|
@@ -13512,6 +13534,7 @@ var FORMATTERS = {
|
|
|
13512
13534
|
teams: buildTeamsPayload,
|
|
13513
13535
|
discord: buildDiscordPayload
|
|
13514
13536
|
};
|
|
13537
|
+
var DISCORD_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
13515
13538
|
var NotificationSender = class {
|
|
13516
13539
|
async sendAll(reportData, notifyConfig, pdfPaths) {
|
|
13517
13540
|
const result = { sent: [], skipped: [], failed: [] };
|
|
@@ -13524,6 +13547,9 @@ var NotificationSender = class {
|
|
|
13524
13547
|
return Promise.resolve();
|
|
13525
13548
|
}
|
|
13526
13549
|
const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
|
|
13550
|
+
if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
|
|
13551
|
+
return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
|
|
13552
|
+
}
|
|
13527
13553
|
return this.post(name, channel.url, payload, result);
|
|
13528
13554
|
})
|
|
13529
13555
|
);
|
|
@@ -13565,6 +13591,47 @@ var NotificationSender = class {
|
|
|
13565
13591
|
})
|
|
13566
13592
|
);
|
|
13567
13593
|
}
|
|
13594
|
+
/**
|
|
13595
|
+
* Posts the summary to a Discord webhook with the PDF as a multipart file
|
|
13596
|
+
* upload (`payload_json` + `files[0]`). Any problem — unreadable file, PDF
|
|
13597
|
+
* over Discord's upload cap, or a failed upload — falls back to the plain
|
|
13598
|
+
* JSON summary post so the notification still lands; only the fallback's
|
|
13599
|
+
* outcome is recorded in the result. Attaches the first PDF only (matches
|
|
13600
|
+
* the email channel's multi-template behaviour).
|
|
13601
|
+
*/
|
|
13602
|
+
async postDiscordWithPdf(url, payload, pdfPath, result) {
|
|
13603
|
+
let file;
|
|
13604
|
+
try {
|
|
13605
|
+
file = await (0, import_promises2.readFile)(pdfPath);
|
|
13606
|
+
if (file.byteLength > DISCORD_MAX_UPLOAD_BYTES) {
|
|
13607
|
+
const mb = (file.byteLength / 1024 / 1024).toFixed(1);
|
|
13608
|
+
throw new Error(`PDF is ${mb}MB \u2014 over Discord's upload limit`);
|
|
13609
|
+
}
|
|
13610
|
+
} catch (err) {
|
|
13611
|
+
logger.warn(
|
|
13612
|
+
`[notify] discord: ${err instanceof Error ? err.message : String(err)} \u2014 sending summary without the PDF`
|
|
13613
|
+
);
|
|
13614
|
+
return this.post("discord", url, payload, result);
|
|
13615
|
+
}
|
|
13616
|
+
const form = new FormData();
|
|
13617
|
+
form.append("payload_json", JSON.stringify(payload));
|
|
13618
|
+
form.append("files[0]", new Blob([new Uint8Array(file)], { type: "application/pdf" }), (0, import_path12.basename)(pdfPath));
|
|
13619
|
+
const controller = new AbortController();
|
|
13620
|
+
const timeoutId = setTimeout(() => controller.abort(), 3e4);
|
|
13621
|
+
try {
|
|
13622
|
+
const res = await fetch(url, { method: "POST", body: form, signal: controller.signal });
|
|
13623
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
13624
|
+
logger.info("[notify] discord: sent (PDF attached)");
|
|
13625
|
+
result.sent.push("discord");
|
|
13626
|
+
} catch (err) {
|
|
13627
|
+
logger.warn(
|
|
13628
|
+
`[notify] discord: upload failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying without the PDF`
|
|
13629
|
+
);
|
|
13630
|
+
return this.post("discord", url, payload, result);
|
|
13631
|
+
} finally {
|
|
13632
|
+
clearTimeout(timeoutId);
|
|
13633
|
+
}
|
|
13634
|
+
}
|
|
13568
13635
|
async post(name, url, payload, result) {
|
|
13569
13636
|
const controller = new AbortController();
|
|
13570
13637
|
const timeoutId = setTimeout(() => controller.abort(), 1e4);
|
|
@@ -13887,7 +13954,7 @@ function mapCurrentStep(testId, step, shardKey) {
|
|
|
13887
13954
|
currentStep: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS)
|
|
13888
13955
|
};
|
|
13889
13956
|
}
|
|
13890
|
-
function
|
|
13957
|
+
function testStepDepth2(step) {
|
|
13891
13958
|
let depth = 0;
|
|
13892
13959
|
for (let p = step.parent; p; p = p.parent) {
|
|
13893
13960
|
if (p.category === "test.step") depth++;
|
|
@@ -13907,7 +13974,7 @@ function mapStep(testId, step, phase, shardKey) {
|
|
|
13907
13974
|
startedAt: step.startTime?.getTime(),
|
|
13908
13975
|
// Nesting depth (test.step ancestors) so the page indents children under their
|
|
13909
13976
|
// parent step. 0 → flat, so a test without test.steps renders as a flat trail.
|
|
13910
|
-
depth:
|
|
13977
|
+
depth: testStepDepth2(step)
|
|
13911
13978
|
};
|
|
13912
13979
|
}
|
|
13913
13980
|
function mapConsole(kind, chunk, testId, shardKey) {
|
|
@@ -13934,7 +14001,7 @@ var PdfReporter = class {
|
|
|
13934
14001
|
this.liveConsole = false;
|
|
13935
14002
|
this.options = parseOptions(rawOptions);
|
|
13936
14003
|
this.dataCollector = new DataCollector(this.options.capture);
|
|
13937
|
-
const version = true ? "0.
|
|
14004
|
+
const version = true ? "0.13.0" : "0.x";
|
|
13938
14005
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
13939
14006
|
this.licenseClient = new LicenseClient({
|
|
13940
14007
|
licenseKey: this.options.licenseKey,
|
|
@@ -14202,7 +14269,7 @@ var PdfReporter = class {
|
|
|
14202
14269
|
}
|
|
14203
14270
|
resolveProjectName() {
|
|
14204
14271
|
try {
|
|
14205
|
-
const pkgPath =
|
|
14272
|
+
const pkgPath = import_path13.default.resolve(process.cwd(), "package.json");
|
|
14206
14273
|
const pkg = require(pkgPath);
|
|
14207
14274
|
return pkg.name ?? "Playwright Tests";
|
|
14208
14275
|
} catch {
|
|
@@ -14252,9 +14319,9 @@ function deriveVerdict(stats) {
|
|
|
14252
14319
|
}
|
|
14253
14320
|
function resolveHistoryPath(options, cwd) {
|
|
14254
14321
|
if (options.historyFile) {
|
|
14255
|
-
return
|
|
14322
|
+
return import_path13.default.isAbsolute(options.historyFile) ? options.historyFile : import_path13.default.resolve(cwd, options.historyFile);
|
|
14256
14323
|
}
|
|
14257
|
-
return
|
|
14324
|
+
return import_path13.default.join(resolveProjectDir(options, cwd), "history.json");
|
|
14258
14325
|
}
|
|
14259
14326
|
function flattenSuiteNodes(suites) {
|
|
14260
14327
|
return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
|
package/dist/index.mjs
CHANGED
|
@@ -10455,6 +10455,9 @@ var channelConfigSchema = external_exports.object({
|
|
|
10455
10455
|
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
10456
10456
|
enabled: external_exports.boolean().default(false)
|
|
10457
10457
|
});
|
|
10458
|
+
var discordChannelConfigSchema = channelConfigSchema.extend({
|
|
10459
|
+
attachPdf: external_exports.boolean().default(false)
|
|
10460
|
+
});
|
|
10458
10461
|
var emailChannelConfigSchema = external_exports.object({
|
|
10459
10462
|
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'),
|
|
10460
10463
|
on: external_exports.enum(["always", "failure", "success"]).default("always"),
|
|
@@ -10464,7 +10467,7 @@ var emailChannelConfigSchema = external_exports.object({
|
|
|
10464
10467
|
var notifyConfigSchema = external_exports.object({
|
|
10465
10468
|
slack: channelConfigSchema.optional(),
|
|
10466
10469
|
teams: channelConfigSchema.optional(),
|
|
10467
|
-
discord:
|
|
10470
|
+
discord: discordChannelConfigSchema.optional(),
|
|
10468
10471
|
email: emailChannelConfigSchema.optional()
|
|
10469
10472
|
}).optional();
|
|
10470
10473
|
var failureAnalysisConfigSchema = external_exports.object({
|
|
@@ -11436,9 +11439,13 @@ init_esm_shims();
|
|
|
11436
11439
|
var MAX_TITLE_CHARS = 200;
|
|
11437
11440
|
var DEFAULT_MAX_STEPS = 50;
|
|
11438
11441
|
var DEFAULT_MAX_CONSOLE_LINES = 50;
|
|
11439
|
-
var PW_API_WINDOW = 10;
|
|
11440
11442
|
var isTeardown = (category) => category === "hook" || category === "fixture";
|
|
11441
11443
|
var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
|
|
11444
|
+
var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
|
|
11445
|
+
const c = flat[a].category;
|
|
11446
|
+
return c === "pw:api" || c === "expect";
|
|
11447
|
+
});
|
|
11448
|
+
var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
|
|
11442
11449
|
function flatten(steps) {
|
|
11443
11450
|
const out = [];
|
|
11444
11451
|
const walk = (nodes, depth, ancestors) => {
|
|
@@ -11457,15 +11464,19 @@ function flatten(steps) {
|
|
|
11457
11464
|
walk(steps ?? [], 0, []);
|
|
11458
11465
|
return out;
|
|
11459
11466
|
}
|
|
11460
|
-
function materialize(n, isFailing) {
|
|
11467
|
+
function materialize(n, isFailing, depth) {
|
|
11461
11468
|
const loc = n.raw.location;
|
|
11469
|
+
const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
|
|
11462
11470
|
return {
|
|
11463
11471
|
title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
|
|
11464
11472
|
category: n.category,
|
|
11465
11473
|
durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
|
|
11466
|
-
depth
|
|
11474
|
+
depth,
|
|
11467
11475
|
failed: n.hasError,
|
|
11468
|
-
|
|
11476
|
+
// Marks the single failing leaf so the template renders the error there once —
|
|
11477
|
+
// independent of whether a step-level message survives (falls back to the record error).
|
|
11478
|
+
failingLeaf: isFailing || void 0,
|
|
11479
|
+
errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
|
|
11469
11480
|
location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
|
|
11470
11481
|
};
|
|
11471
11482
|
}
|
|
@@ -11489,21 +11500,19 @@ function compactSteps(rawSteps, opts = {}) {
|
|
|
11489
11500
|
const failingIdx = pickFailingIdx(flat);
|
|
11490
11501
|
const keep = /* @__PURE__ */ new Set();
|
|
11491
11502
|
flat.forEach((f, i) => {
|
|
11492
|
-
if (
|
|
11503
|
+
if (inHookSubtree(flat, f)) return;
|
|
11504
|
+
if (f.category === "test.step") keep.add(i);
|
|
11505
|
+
else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
|
|
11506
|
+
else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
|
|
11493
11507
|
});
|
|
11494
11508
|
if (failingIdx >= 0) {
|
|
11495
11509
|
keep.add(failingIdx);
|
|
11496
11510
|
for (const a of flat[failingIdx].ancestors) keep.add(a);
|
|
11497
|
-
if (opts.apiSteps) {
|
|
11498
|
-
const pwApiBefore = [];
|
|
11499
|
-
for (let i = 0; i < failingIdx; i++) {
|
|
11500
|
-
if (flat[i].category === "pw:api" && !inHookSubtree(flat, flat[i])) pwApiBefore.push(i);
|
|
11501
|
-
}
|
|
11502
|
-
for (const i of pwApiBefore.slice(-PW_API_WINDOW)) keep.add(i);
|
|
11503
|
-
}
|
|
11504
11511
|
}
|
|
11505
11512
|
let keepArr = [...keep].sort((a, b) => a - b);
|
|
11513
|
+
let omitted = 0;
|
|
11506
11514
|
if (keepArr.length > maxSteps) {
|
|
11515
|
+
omitted = keepArr.length - maxSteps;
|
|
11507
11516
|
keepArr = keepArr.slice(-maxSteps);
|
|
11508
11517
|
if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
|
|
11509
11518
|
keepArr = [failingIdx, ...keepArr.slice(1)];
|
|
@@ -11513,13 +11522,23 @@ function compactSteps(rawSteps, opts = {}) {
|
|
|
11513
11522
|
const mat = (i) => {
|
|
11514
11523
|
let es = cache.get(i);
|
|
11515
11524
|
if (!es) {
|
|
11516
|
-
es = materialize(flat[i], i === failingIdx);
|
|
11525
|
+
es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
|
|
11517
11526
|
cache.set(i, es);
|
|
11518
11527
|
}
|
|
11519
11528
|
return es;
|
|
11520
11529
|
};
|
|
11530
|
+
const steps = keepArr.map(mat);
|
|
11531
|
+
if (omitted > 0) {
|
|
11532
|
+
steps.unshift({
|
|
11533
|
+
title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
|
|
11534
|
+
category: "",
|
|
11535
|
+
durationMs: 0,
|
|
11536
|
+
depth: 0,
|
|
11537
|
+
failed: false
|
|
11538
|
+
});
|
|
11539
|
+
}
|
|
11521
11540
|
return {
|
|
11522
|
-
steps
|
|
11541
|
+
steps,
|
|
11523
11542
|
failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
|
|
11524
11543
|
};
|
|
11525
11544
|
}
|
|
@@ -12501,6 +12520,7 @@ var TemplateEngine = class {
|
|
|
12501
12520
|
}
|
|
12502
12521
|
registerHelpers() {
|
|
12503
12522
|
this.handlebars.registerHelper("formatDuration", (ms) => formatDuration(ms ?? 0));
|
|
12523
|
+
this.handlebars.registerHelper("repeat", (s, n) => typeof s === "string" ? s.repeat(Math.max(0, n | 0)) : "");
|
|
12504
12524
|
this.handlebars.registerHelper("formatDate", (d) => {
|
|
12505
12525
|
const date = d instanceof Date ? d : new Date(d);
|
|
12506
12526
|
return date.toLocaleString("en-US", {
|
|
@@ -13264,6 +13284,8 @@ init_esm_shims();
|
|
|
13264
13284
|
|
|
13265
13285
|
// src/notify/NotificationSender.ts
|
|
13266
13286
|
init_esm_shims();
|
|
13287
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
13288
|
+
import { basename as basename3 } from "path";
|
|
13267
13289
|
|
|
13268
13290
|
// src/notify/formatters/slack.ts
|
|
13269
13291
|
init_esm_shims();
|
|
@@ -13513,6 +13535,7 @@ var FORMATTERS = {
|
|
|
13513
13535
|
teams: buildTeamsPayload,
|
|
13514
13536
|
discord: buildDiscordPayload
|
|
13515
13537
|
};
|
|
13538
|
+
var DISCORD_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
13516
13539
|
var NotificationSender = class {
|
|
13517
13540
|
async sendAll(reportData, notifyConfig, pdfPaths) {
|
|
13518
13541
|
const result = { sent: [], skipped: [], failed: [] };
|
|
@@ -13525,6 +13548,9 @@ var NotificationSender = class {
|
|
|
13525
13548
|
return Promise.resolve();
|
|
13526
13549
|
}
|
|
13527
13550
|
const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
|
|
13551
|
+
if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
|
|
13552
|
+
return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
|
|
13553
|
+
}
|
|
13528
13554
|
return this.post(name, channel.url, payload, result);
|
|
13529
13555
|
})
|
|
13530
13556
|
);
|
|
@@ -13566,6 +13592,47 @@ var NotificationSender = class {
|
|
|
13566
13592
|
})
|
|
13567
13593
|
);
|
|
13568
13594
|
}
|
|
13595
|
+
/**
|
|
13596
|
+
* Posts the summary to a Discord webhook with the PDF as a multipart file
|
|
13597
|
+
* upload (`payload_json` + `files[0]`). Any problem — unreadable file, PDF
|
|
13598
|
+
* over Discord's upload cap, or a failed upload — falls back to the plain
|
|
13599
|
+
* JSON summary post so the notification still lands; only the fallback's
|
|
13600
|
+
* outcome is recorded in the result. Attaches the first PDF only (matches
|
|
13601
|
+
* the email channel's multi-template behaviour).
|
|
13602
|
+
*/
|
|
13603
|
+
async postDiscordWithPdf(url, payload, pdfPath, result) {
|
|
13604
|
+
let file;
|
|
13605
|
+
try {
|
|
13606
|
+
file = await readFile2(pdfPath);
|
|
13607
|
+
if (file.byteLength > DISCORD_MAX_UPLOAD_BYTES) {
|
|
13608
|
+
const mb = (file.byteLength / 1024 / 1024).toFixed(1);
|
|
13609
|
+
throw new Error(`PDF is ${mb}MB \u2014 over Discord's upload limit`);
|
|
13610
|
+
}
|
|
13611
|
+
} catch (err) {
|
|
13612
|
+
logger.warn(
|
|
13613
|
+
`[notify] discord: ${err instanceof Error ? err.message : String(err)} \u2014 sending summary without the PDF`
|
|
13614
|
+
);
|
|
13615
|
+
return this.post("discord", url, payload, result);
|
|
13616
|
+
}
|
|
13617
|
+
const form = new FormData();
|
|
13618
|
+
form.append("payload_json", JSON.stringify(payload));
|
|
13619
|
+
form.append("files[0]", new Blob([new Uint8Array(file)], { type: "application/pdf" }), basename3(pdfPath));
|
|
13620
|
+
const controller = new AbortController();
|
|
13621
|
+
const timeoutId = setTimeout(() => controller.abort(), 3e4);
|
|
13622
|
+
try {
|
|
13623
|
+
const res = await fetch(url, { method: "POST", body: form, signal: controller.signal });
|
|
13624
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
13625
|
+
logger.info("[notify] discord: sent (PDF attached)");
|
|
13626
|
+
result.sent.push("discord");
|
|
13627
|
+
} catch (err) {
|
|
13628
|
+
logger.warn(
|
|
13629
|
+
`[notify] discord: upload failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying without the PDF`
|
|
13630
|
+
);
|
|
13631
|
+
return this.post("discord", url, payload, result);
|
|
13632
|
+
} finally {
|
|
13633
|
+
clearTimeout(timeoutId);
|
|
13634
|
+
}
|
|
13635
|
+
}
|
|
13569
13636
|
async post(name, url, payload, result) {
|
|
13570
13637
|
const controller = new AbortController();
|
|
13571
13638
|
const timeoutId = setTimeout(() => controller.abort(), 1e4);
|
|
@@ -13888,7 +13955,7 @@ function mapCurrentStep(testId, step, shardKey) {
|
|
|
13888
13955
|
currentStep: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS)
|
|
13889
13956
|
};
|
|
13890
13957
|
}
|
|
13891
|
-
function
|
|
13958
|
+
function testStepDepth2(step) {
|
|
13892
13959
|
let depth = 0;
|
|
13893
13960
|
for (let p = step.parent; p; p = p.parent) {
|
|
13894
13961
|
if (p.category === "test.step") depth++;
|
|
@@ -13908,7 +13975,7 @@ function mapStep(testId, step, phase, shardKey) {
|
|
|
13908
13975
|
startedAt: step.startTime?.getTime(),
|
|
13909
13976
|
// Nesting depth (test.step ancestors) so the page indents children under their
|
|
13910
13977
|
// parent step. 0 → flat, so a test without test.steps renders as a flat trail.
|
|
13911
|
-
depth:
|
|
13978
|
+
depth: testStepDepth2(step)
|
|
13912
13979
|
};
|
|
13913
13980
|
}
|
|
13914
13981
|
function mapConsole(kind, chunk, testId, shardKey) {
|
|
@@ -13935,7 +14002,7 @@ var PdfReporter = class {
|
|
|
13935
14002
|
this.liveConsole = false;
|
|
13936
14003
|
this.options = parseOptions(rawOptions);
|
|
13937
14004
|
this.dataCollector = new DataCollector(this.options.capture);
|
|
13938
|
-
const version = true ? "0.
|
|
14005
|
+
const version = true ? "0.13.0" : "0.x";
|
|
13939
14006
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
13940
14007
|
this.licenseClient = new LicenseClient({
|
|
13941
14008
|
licenseKey: this.options.licenseKey,
|
|
@@ -36,17 +36,19 @@
|
|
|
36
36
|
{{#if execution.steps.length}}
|
|
37
37
|
<div class="repro">
|
|
38
38
|
<div class="repro__tag">Steps to Reproduce</div>
|
|
39
|
-
|
|
39
|
+
{{! Each row is a real Markdown list line — `' '×depth + '- ' + title` rendered
|
|
40
|
+
pre-wrap — so the outline both displays indented and survives a copy-paste
|
|
41
|
+
into a bug tracker as a nested list. }}
|
|
42
|
+
<div class="repro-steps">
|
|
40
43
|
{{#each execution.steps}}
|
|
41
|
-
<
|
|
42
|
-
<span class="repro-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
</li>
|
|
44
|
+
<div class="repro-step{{#if failed}} repro-step--failed{{/if}}">
|
|
45
|
+
<span class="repro-step__line">{{repeat " " depth}}- {{title}}{{#if failed}} [FAILED]{{#if location}} ({{location}}){{/if}}{{/if}}</span>
|
|
46
|
+
{{! Error renders once, at the failing leaf: the step's own message, else the
|
|
47
|
+
authoritative record error so an empty step message never hides it. }}
|
|
48
|
+
{{#if failingLeaf}}<div class="repro-step__error">{{#if errorMessage}}{{errorMessage}}{{else}}{{../error.message}}{{/if}}</div>{{/if}}
|
|
49
|
+
</div>
|
|
48
50
|
{{/each}}
|
|
49
|
-
</
|
|
51
|
+
</div>
|
|
50
52
|
</div>
|
|
51
53
|
{{/if}}
|
|
52
54
|
|
|
@@ -436,32 +436,24 @@ code {
|
|
|
436
436
|
font-family: 'JetBrains Mono', monospace; font-size: 8.5px; color: var(--text-3); margin-left: 6px;
|
|
437
437
|
}
|
|
438
438
|
.repro { margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--border); }
|
|
439
|
-
.repro-steps {
|
|
440
|
-
.repro-step {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
439
|
+
.repro-steps { margin: 6px 0 0; padding: 0; }
|
|
440
|
+
.repro-step { page-break-inside: avoid; break-inside: avoid; }
|
|
441
|
+
/* One monospace, pre-wrap line per step. The leading `' '×depth + '- '` (real
|
|
442
|
+
characters, not CSS padding) renders the outline indentation AND survives a PDF
|
|
443
|
+
text-copy — pasting yields a valid nested Markdown list. */
|
|
444
|
+
.repro-step__line {
|
|
445
|
+
display: block;
|
|
446
|
+
font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.55;
|
|
447
|
+
color: var(--text-2); white-space: pre-wrap; word-break: break-word;
|
|
446
448
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
+
.repro-step--failed .repro-step__line { color: #8A2E1E; font-weight: 700; }
|
|
450
|
+
/* The error sits at the failing step — below its line, slightly indented so it reads
|
|
451
|
+
as belonging to that step. */
|
|
449
452
|
.repro-step__error {
|
|
450
|
-
|
|
453
|
+
margin: 2px 0 3px; padding-left: 18px;
|
|
451
454
|
font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.55;
|
|
452
455
|
color: #8A2E1E; white-space: pre-wrap; word-break: break-word;
|
|
453
456
|
}
|
|
454
|
-
.repro-step__cat {
|
|
455
|
-
flex: none; min-width: 52px;
|
|
456
|
-
font-family: 'JetBrains Mono', monospace; font-size: 8px; color: var(--text-3);
|
|
457
|
-
}
|
|
458
|
-
.repro-step__title { flex: 1; color: var(--text-2); word-break: break-word; }
|
|
459
|
-
.repro-step__loc {
|
|
460
|
-
flex: none; font-family: 'JetBrains Mono', monospace; font-size: 8px; color: var(--text-3);
|
|
461
|
-
}
|
|
462
|
-
.repro-step__dur { flex: none; color: var(--text-3); font-size: 8.5px; }
|
|
463
|
-
.repro-step--failed .repro-step__title { color: #8A2E1E; font-weight: 700; }
|
|
464
|
-
.repro-step--failed .repro-step__cat { color: var(--fail); }
|
|
465
457
|
.console-tail { margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--border); }
|
|
466
458
|
.console-tail__line {
|
|
467
459
|
font-family: 'JetBrains Mono', monospace; font-size: 8.5px; line-height: 1.55;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reportforge/playwright-pdf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Enterprise-ready PDF reports for Playwright Test — minimal, detailed, and executive templates with CI/CD integrations",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ReportForge",
|