@reportforge/playwright-pdf 0.9.0 → 0.10.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 CHANGED
@@ -211,7 +211,7 @@ Full reference: [reportforge.org/docs/configuration](https://reportforge.org/doc
211
211
 
212
212
  ### Live Runs
213
213
 
214
- Watch a run as it happens. With `live` enabled, the reporter prints an unguessable watch link to your CI logs at run start and streams per-test progress to a hosted dashboard while tests execute all shards of one CI run converge on a single live view. The PDF is still generated at the end, unchanged.
214
+ Watch a run as it happens. With `live` enabled, the reporter prints an unguessable watch link — boxed under a `Live Tracker` heading — to your CI logs at run start and streams per-test progress to a hosted dashboard while tests execute. All shards of one CI run converge on a single live view, each running test shows the step it's currently executing, and the PDF is still generated at the end, unchanged.
215
215
 
216
216
  ```typescript
217
217
  // playwright.config.ts
@@ -230,7 +230,7 @@ reporter: [
230
230
 
231
231
  Sharded runs need no extra config — the run id is derived from the run-shared CI identifier (e.g. `GITHUB_RUN_ID`), identical across every matrix shard. Set `RF_LIVE_RUN_ID` to override when your CI is not auto-detected.
232
232
 
233
- Live streaming is best-effort and entitlement-gated: if the server is unreachable the run is unaffected, and an active subscription with the `live` feature is required.
233
+ Live streaming is best-effort and entitlement-gated: if the server is unreachable the run is unaffected, and an active subscription with the `live` feature is required. If you've configured Slack/Teams/Discord notifications, the watch link is also posted to those channels when the run starts.
234
234
 
235
235
  > **Security:** the watch link exposes test titles + statuses (and step titles when `steps` is on). Leaving `console: false` is recommended — enabling it streams your tests' stdout/stderr to anyone holding the link. Full details at [reportforge.org/docs/advanced/live](https://reportforge.org/docs/advanced/live).
236
236
 
package/dist/index.d.mts CHANGED
@@ -442,6 +442,15 @@ declare class PdfReporter implements Reporter {
442
442
  * link to the CI logs. Throws are caught by the onBegin try/catch.
443
443
  */
444
444
  private setupLive;
445
+ /**
446
+ * Announces the watch link once the run is confirmed live-entitled. Gating on
447
+ * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
448
+ * feature) guarantees the link is never printed or posted for a run that will
449
+ * never stream — no dead links. The chat ping fires from the primary shard only
450
+ * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
451
+ * not N. The banner still prints from every shard's own log.
452
+ */
453
+ private announceLive;
445
454
  private resolveProjectName;
446
455
  private openPdf;
447
456
  }
package/dist/index.d.ts CHANGED
@@ -442,6 +442,15 @@ declare class PdfReporter implements Reporter {
442
442
  * link to the CI logs. Throws are caught by the onBegin try/catch.
443
443
  */
444
444
  private setupLive;
445
+ /**
446
+ * Announces the watch link once the run is confirmed live-entitled. Gating on
447
+ * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
448
+ * feature) guarantees the link is never printed or posted for a run that will
449
+ * never stream — no dead links. The chat ping fires from the primary shard only
450
+ * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
451
+ * not N. The banner still prints from every shard's own log.
452
+ */
453
+ private announceLive;
445
454
  private resolveProjectName;
446
455
  private openPdf;
447
456
  }
package/dist/index.js CHANGED
@@ -13319,6 +13319,22 @@ _${name}_`
13319
13319
 
13320
13320
  // src/notify/formatters/teams.ts
13321
13321
  init_cjs_shims();
13322
+ function buildTeamsSimpleMessage(text) {
13323
+ return {
13324
+ type: "message",
13325
+ attachments: [
13326
+ {
13327
+ contentType: "application/vnd.microsoft.card.adaptive",
13328
+ content: {
13329
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
13330
+ type: "AdaptiveCard",
13331
+ version: "1.4",
13332
+ body: [{ type: "TextBlock", text, wrap: true }]
13333
+ }
13334
+ }
13335
+ ]
13336
+ };
13337
+ }
13322
13338
  function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13323
13339
  const label = statusLabel(stats.verdict);
13324
13340
  const name = reportName(pdfPaths, reportTitle);
@@ -13525,6 +13541,30 @@ var NotificationSender = class {
13525
13541
  }
13526
13542
  return result;
13527
13543
  }
13544
+ /**
13545
+ * Fire-and-forget "live run started" ping to the enabled chat channels, carrying
13546
+ * the watch URL so teammates can open the live view without hunting through CI
13547
+ * logs. Only channels with `on: 'always'` receive it — a start ping has no
13548
+ * pass/fail outcome, so a `failure`/`success`-only channel is left alone. Never
13549
+ * touches the email channel (too heavy for a start signal). Resolves even if
13550
+ * every post fails; live is best-effort.
13551
+ */
13552
+ async sendLiveStarted(notifyConfig, watchUrl, branch) {
13553
+ if (!notifyConfig) return;
13554
+ const line = `ReportForge: live test run started${branch ? ` on ${branch}` : ""}. Watch: ${watchUrl}`;
13555
+ const payloads = {
13556
+ slack: { text: line },
13557
+ teams: buildTeamsSimpleMessage(line),
13558
+ discord: { content: line }
13559
+ };
13560
+ await Promise.allSettled(
13561
+ Object.keys(payloads).map((name) => {
13562
+ const channel = notifyConfig[name];
13563
+ if (!channel || !channel.enabled || channel.on !== "always") return Promise.resolve();
13564
+ return this.post(`${name} (live-start)`, channel.url, payloads[name]);
13565
+ })
13566
+ );
13567
+ }
13528
13568
  async post(name, url, payload, result) {
13529
13569
  const controller = new AbortController();
13530
13570
  const timeoutId = setTimeout(() => controller.abort(), 1e4);
@@ -13537,10 +13577,10 @@ var NotificationSender = class {
13537
13577
  });
13538
13578
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
13539
13579
  logger.info(`[notify] ${name}: sent`);
13540
- result.sent.push(name);
13580
+ result?.sent.push(name);
13541
13581
  } catch (err) {
13542
13582
  logger.warn(`[notify] ${name}: ${err instanceof Error ? err.message : String(err)}`);
13543
- result.failed.push(name);
13583
+ result?.failed.push(name);
13544
13584
  } finally {
13545
13585
  clearTimeout(timeoutId);
13546
13586
  }
@@ -13582,7 +13622,8 @@ var LiveStreamer = class {
13582
13622
  }
13583
13623
  enqueueTest(update) {
13584
13624
  if (!this.enabled) return;
13585
- this.testStates.set(update.id, { status: update.status, retry: update.retry });
13625
+ const prevRetry = this.testStates.get(update.id)?.retry;
13626
+ this.testStates.set(update.id, { status: update.status, retry: update.retry ?? prevRetry });
13586
13627
  this.testBuffer.push(update);
13587
13628
  this.capPreLicenseBuffer();
13588
13629
  this.scheduleFlush();
@@ -13716,7 +13757,6 @@ var LiveStreamer = class {
13716
13757
  return false;
13717
13758
  }
13718
13759
  this.jwt = info.jwt;
13719
- if (this.config.watchUrl) logger.info(`Live results: ${this.config.watchUrl}`);
13720
13760
  return true;
13721
13761
  }
13722
13762
  async send(body) {
@@ -13785,6 +13825,13 @@ function buildWatchUrl(serverUrl, runId, token) {
13785
13825
  const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
13786
13826
  return `${base}/live/${encodeURIComponent(runId)}?t=${encodeURIComponent(token)}`;
13787
13827
  }
13828
+ function formatLiveBanner(url) {
13829
+ const label = "Live Tracker";
13830
+ const link = `Watch live: ${url}`;
13831
+ const width = Math.max(label.length, link.length);
13832
+ const rule = "\u2500".repeat(width + 2);
13833
+ return [rule, ` ${label.padEnd(width)} `, ` ${link.padEnd(width)} `, rule].join("\n");
13834
+ }
13788
13835
  function shardKeyOf(shard) {
13789
13836
  if (!shard || !shard.total) return "1/1";
13790
13837
  return `${shard.current}/${shard.total}`;
@@ -13819,6 +13866,17 @@ function shouldEmitStep(step, mode) {
13819
13866
  if (step.error) return true;
13820
13867
  return step.category === "test.step" || step.category === "expect";
13821
13868
  }
13869
+ function isCurrentStep(step) {
13870
+ return step.category === "test.step" && !!step.title;
13871
+ }
13872
+ function mapCurrentStep(testId, step, shardKey) {
13873
+ return {
13874
+ id: testId,
13875
+ status: "running",
13876
+ shardKey,
13877
+ currentStep: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS)
13878
+ };
13879
+ }
13822
13880
  function mapStep(testId, step, phase, shardKey) {
13823
13881
  return {
13824
13882
  kind: "step",
@@ -13853,7 +13911,7 @@ var PdfReporter = class {
13853
13911
  this.liveConsole = false;
13854
13912
  this.options = parseOptions(rawOptions);
13855
13913
  this.dataCollector = new DataCollector(this.options.capture);
13856
- const version = true ? "0.9.0" : "0.x";
13914
+ const version = true ? "0.10.0" : "0.x";
13857
13915
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13858
13916
  this.licenseClient = new LicenseClient({
13859
13917
  licenseKey: this.options.licenseKey,
@@ -13887,9 +13945,14 @@ var PdfReporter = class {
13887
13945
  }
13888
13946
  }
13889
13947
  onStepBegin(test, _result, step) {
13890
- if (!this.liveStreamer || !shouldEmitStep(step, this.liveSteps)) return;
13948
+ if (!this.liveStreamer) return;
13891
13949
  try {
13892
- this.liveStreamer.enqueueEvent(mapStep(test.id, step, "begin", this.liveShardKey));
13950
+ if (this.liveSteps !== "none" && isCurrentStep(step)) {
13951
+ this.liveStreamer.enqueueTest(mapCurrentStep(test.id, step, this.liveShardKey));
13952
+ }
13953
+ if (shouldEmitStep(step, this.liveSteps)) {
13954
+ this.liveStreamer.enqueueEvent(mapStep(test.id, step, "begin", this.liveShardKey));
13955
+ }
13893
13956
  } catch {
13894
13957
  }
13895
13958
  }
@@ -14064,17 +14127,38 @@ var PdfReporter = class {
14064
14127
  void this.license();
14065
14128
  const token = computeWatchToken(runId);
14066
14129
  const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14130
+ const env = detectCiEnv();
14131
+ if (watchUrl) void this.announceLive(watchUrl, env, shard).catch(() => {
14132
+ });
14067
14133
  this.liveStreamer = new LiveStreamer({
14068
14134
  serverUrl,
14069
14135
  runId,
14070
14136
  shardKey: this.liveShardKey,
14071
14137
  shardTotal: shard?.total ?? null,
14072
14138
  license: () => this.license(),
14073
- env: detectCiEnv(),
14074
- watchUrl,
14139
+ env,
14075
14140
  flushMs: live.flushMs
14076
14141
  });
14077
14142
  }
14143
+ /**
14144
+ * Announces the watch link once the run is confirmed live-entitled. Gating on
14145
+ * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
14146
+ * feature) guarantees the link is never printed or posted for a run that will
14147
+ * never stream — no dead links. The chat ping fires from the primary shard only
14148
+ * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
14149
+ * not N. The banner still prints from every shard's own log.
14150
+ */
14151
+ async announceLive(watchUrl, env, shard) {
14152
+ const info = await this.license();
14153
+ if (!info?.jwt || !info.features?.includes("live")) return;
14154
+ logger.info(`Live Tracker: ${watchUrl}`);
14155
+ console.log(formatLiveBanner(watchUrl));
14156
+ const isPrimaryShard = !shard || shard.current === 1;
14157
+ const notify = this.options.notify;
14158
+ if (isPrimaryShard && notify) {
14159
+ await new NotificationSender().sendLiveStarted(notify, watchUrl, env?.branch);
14160
+ }
14161
+ }
14078
14162
  resolveProjectName() {
14079
14163
  try {
14080
14164
  const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
package/dist/index.mjs CHANGED
@@ -13320,6 +13320,22 @@ _${name}_`
13320
13320
 
13321
13321
  // src/notify/formatters/teams.ts
13322
13322
  init_esm_shims();
13323
+ function buildTeamsSimpleMessage(text) {
13324
+ return {
13325
+ type: "message",
13326
+ attachments: [
13327
+ {
13328
+ contentType: "application/vnd.microsoft.card.adaptive",
13329
+ content: {
13330
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
13331
+ type: "AdaptiveCard",
13332
+ version: "1.4",
13333
+ body: [{ type: "TextBlock", text, wrap: true }]
13334
+ }
13335
+ }
13336
+ ]
13337
+ };
13338
+ }
13323
13339
  function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13324
13340
  const label = statusLabel(stats.verdict);
13325
13341
  const name = reportName(pdfPaths, reportTitle);
@@ -13526,6 +13542,30 @@ var NotificationSender = class {
13526
13542
  }
13527
13543
  return result;
13528
13544
  }
13545
+ /**
13546
+ * Fire-and-forget "live run started" ping to the enabled chat channels, carrying
13547
+ * the watch URL so teammates can open the live view without hunting through CI
13548
+ * logs. Only channels with `on: 'always'` receive it — a start ping has no
13549
+ * pass/fail outcome, so a `failure`/`success`-only channel is left alone. Never
13550
+ * touches the email channel (too heavy for a start signal). Resolves even if
13551
+ * every post fails; live is best-effort.
13552
+ */
13553
+ async sendLiveStarted(notifyConfig, watchUrl, branch) {
13554
+ if (!notifyConfig) return;
13555
+ const line = `ReportForge: live test run started${branch ? ` on ${branch}` : ""}. Watch: ${watchUrl}`;
13556
+ const payloads = {
13557
+ slack: { text: line },
13558
+ teams: buildTeamsSimpleMessage(line),
13559
+ discord: { content: line }
13560
+ };
13561
+ await Promise.allSettled(
13562
+ Object.keys(payloads).map((name) => {
13563
+ const channel = notifyConfig[name];
13564
+ if (!channel || !channel.enabled || channel.on !== "always") return Promise.resolve();
13565
+ return this.post(`${name} (live-start)`, channel.url, payloads[name]);
13566
+ })
13567
+ );
13568
+ }
13529
13569
  async post(name, url, payload, result) {
13530
13570
  const controller = new AbortController();
13531
13571
  const timeoutId = setTimeout(() => controller.abort(), 1e4);
@@ -13538,10 +13578,10 @@ var NotificationSender = class {
13538
13578
  });
13539
13579
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
13540
13580
  logger.info(`[notify] ${name}: sent`);
13541
- result.sent.push(name);
13581
+ result?.sent.push(name);
13542
13582
  } catch (err) {
13543
13583
  logger.warn(`[notify] ${name}: ${err instanceof Error ? err.message : String(err)}`);
13544
- result.failed.push(name);
13584
+ result?.failed.push(name);
13545
13585
  } finally {
13546
13586
  clearTimeout(timeoutId);
13547
13587
  }
@@ -13583,7 +13623,8 @@ var LiveStreamer = class {
13583
13623
  }
13584
13624
  enqueueTest(update) {
13585
13625
  if (!this.enabled) return;
13586
- this.testStates.set(update.id, { status: update.status, retry: update.retry });
13626
+ const prevRetry = this.testStates.get(update.id)?.retry;
13627
+ this.testStates.set(update.id, { status: update.status, retry: update.retry ?? prevRetry });
13587
13628
  this.testBuffer.push(update);
13588
13629
  this.capPreLicenseBuffer();
13589
13630
  this.scheduleFlush();
@@ -13717,7 +13758,6 @@ var LiveStreamer = class {
13717
13758
  return false;
13718
13759
  }
13719
13760
  this.jwt = info.jwt;
13720
- if (this.config.watchUrl) logger.info(`Live results: ${this.config.watchUrl}`);
13721
13761
  return true;
13722
13762
  }
13723
13763
  async send(body) {
@@ -13786,6 +13826,13 @@ function buildWatchUrl(serverUrl, runId, token) {
13786
13826
  const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
13787
13827
  return `${base}/live/${encodeURIComponent(runId)}?t=${encodeURIComponent(token)}`;
13788
13828
  }
13829
+ function formatLiveBanner(url) {
13830
+ const label = "Live Tracker";
13831
+ const link = `Watch live: ${url}`;
13832
+ const width = Math.max(label.length, link.length);
13833
+ const rule = "\u2500".repeat(width + 2);
13834
+ return [rule, ` ${label.padEnd(width)} `, ` ${link.padEnd(width)} `, rule].join("\n");
13835
+ }
13789
13836
  function shardKeyOf(shard) {
13790
13837
  if (!shard || !shard.total) return "1/1";
13791
13838
  return `${shard.current}/${shard.total}`;
@@ -13820,6 +13867,17 @@ function shouldEmitStep(step, mode) {
13820
13867
  if (step.error) return true;
13821
13868
  return step.category === "test.step" || step.category === "expect";
13822
13869
  }
13870
+ function isCurrentStep(step) {
13871
+ return step.category === "test.step" && !!step.title;
13872
+ }
13873
+ function mapCurrentStep(testId, step, shardKey) {
13874
+ return {
13875
+ id: testId,
13876
+ status: "running",
13877
+ shardKey,
13878
+ currentStep: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS)
13879
+ };
13880
+ }
13823
13881
  function mapStep(testId, step, phase, shardKey) {
13824
13882
  return {
13825
13883
  kind: "step",
@@ -13854,7 +13912,7 @@ var PdfReporter = class {
13854
13912
  this.liveConsole = false;
13855
13913
  this.options = parseOptions(rawOptions);
13856
13914
  this.dataCollector = new DataCollector(this.options.capture);
13857
- const version = true ? "0.9.0" : "0.x";
13915
+ const version = true ? "0.10.0" : "0.x";
13858
13916
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13859
13917
  this.licenseClient = new LicenseClient({
13860
13918
  licenseKey: this.options.licenseKey,
@@ -13888,9 +13946,14 @@ var PdfReporter = class {
13888
13946
  }
13889
13947
  }
13890
13948
  onStepBegin(test, _result, step) {
13891
- if (!this.liveStreamer || !shouldEmitStep(step, this.liveSteps)) return;
13949
+ if (!this.liveStreamer) return;
13892
13950
  try {
13893
- this.liveStreamer.enqueueEvent(mapStep(test.id, step, "begin", this.liveShardKey));
13951
+ if (this.liveSteps !== "none" && isCurrentStep(step)) {
13952
+ this.liveStreamer.enqueueTest(mapCurrentStep(test.id, step, this.liveShardKey));
13953
+ }
13954
+ if (shouldEmitStep(step, this.liveSteps)) {
13955
+ this.liveStreamer.enqueueEvent(mapStep(test.id, step, "begin", this.liveShardKey));
13956
+ }
13894
13957
  } catch {
13895
13958
  }
13896
13959
  }
@@ -14065,17 +14128,38 @@ var PdfReporter = class {
14065
14128
  void this.license();
14066
14129
  const token = computeWatchToken(runId);
14067
14130
  const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14131
+ const env = detectCiEnv();
14132
+ if (watchUrl) void this.announceLive(watchUrl, env, shard).catch(() => {
14133
+ });
14068
14134
  this.liveStreamer = new LiveStreamer({
14069
14135
  serverUrl,
14070
14136
  runId,
14071
14137
  shardKey: this.liveShardKey,
14072
14138
  shardTotal: shard?.total ?? null,
14073
14139
  license: () => this.license(),
14074
- env: detectCiEnv(),
14075
- watchUrl,
14140
+ env,
14076
14141
  flushMs: live.flushMs
14077
14142
  });
14078
14143
  }
14144
+ /**
14145
+ * Announces the watch link once the run is confirmed live-entitled. Gating on
14146
+ * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
14147
+ * feature) guarantees the link is never printed or posted for a run that will
14148
+ * never stream — no dead links. The chat ping fires from the primary shard only
14149
+ * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
14150
+ * not N. The banner still prints from every shard's own log.
14151
+ */
14152
+ async announceLive(watchUrl, env, shard) {
14153
+ const info = await this.license();
14154
+ if (!info?.jwt || !info.features?.includes("live")) return;
14155
+ logger.info(`Live Tracker: ${watchUrl}`);
14156
+ console.log(formatLiveBanner(watchUrl));
14157
+ const isPrimaryShard = !shard || shard.current === 1;
14158
+ const notify = this.options.notify;
14159
+ if (isPrimaryShard && notify) {
14160
+ await new NotificationSender().sendLiveStarted(notify, watchUrl, env?.branch);
14161
+ }
14162
+ }
14079
14163
  resolveProjectName() {
14080
14164
  try {
14081
14165
  const pkgPath = path14.resolve(process.cwd(), "package.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",