@reportforge/playwright-pdf 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,7 +211,9 @@ 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 test shows its steps as sub-lines beneath its own row, and the PDF is still generated at the end, unchanged.
215
+
216
+ > The watch link is entitlement-gated. If you enable `live` but see no `Live Tracker` line, the reporter logs why (missing `RF_LICENSE_KEY`, or a cached token without the `live` entitlement — delete `~/.reportforge/license.json` to force re-activation).
215
217
 
216
218
  ```typescript
217
219
  // playwright.config.ts
@@ -230,7 +232,7 @@ reporter: [
230
232
 
231
233
  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
234
 
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.
235
+ 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
236
 
235
237
  > **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
238
 
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();
@@ -13712,11 +13753,10 @@ var LiveStreamer = class {
13712
13753
  this.enabled = false;
13713
13754
  this.testBuffer = [];
13714
13755
  this.eventBuffer = [];
13715
- this.warn("Live streaming disabled \u2014 no active license or live entitlement.");
13756
+ logger.debug("Live streaming disabled \u2014 no active license or live entitlement.");
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) {
@@ -13776,8 +13816,8 @@ function deriveRunId(opts) {
13776
13816
  if (fromEnv) return fromEnv;
13777
13817
  return detectCiRunId() ?? (0, import_crypto9.randomUUID)();
13778
13818
  }
13779
- function computeWatchToken(runId) {
13780
- const secret = process.env.RF_LICENSE_KEY?.trim();
13819
+ function computeWatchToken(runId, key) {
13820
+ const secret = key?.trim() || process.env.RF_LICENSE_KEY?.trim();
13781
13821
  if (!secret) return null;
13782
13822
  return (0, import_crypto8.createHmac)("sha256", secret).update(`live:${runId}`).digest("hex").slice(0, WATCH_TOKEN_HEX_LEN);
13783
13823
  }
@@ -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.1" : "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
  }
@@ -14062,19 +14125,61 @@ var PdfReporter = class {
14062
14125
  this.liveSteps = live.steps;
14063
14126
  this.liveConsole = live.console;
14064
14127
  void this.license();
14065
- const token = computeWatchToken(runId);
14128
+ const token = computeWatchToken(runId, this.options.licenseKey);
14066
14129
  const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14130
+ const env = detectCiEnv();
14131
+ if (watchUrl) {
14132
+ void this.announceLive(watchUrl, env, shard).catch(() => {
14133
+ });
14134
+ } else {
14135
+ logger.warn(
14136
+ "Live is enabled but no RF_LICENSE_KEY was found \u2014 cannot mint a watch link or stream. Set RF_LICENSE_KEY (or the `licenseKey` option)."
14137
+ );
14138
+ }
14067
14139
  this.liveStreamer = new LiveStreamer({
14068
14140
  serverUrl,
14069
14141
  runId,
14070
14142
  shardKey: this.liveShardKey,
14071
14143
  shardTotal: shard?.total ?? null,
14072
14144
  license: () => this.license(),
14073
- env: detectCiEnv(),
14074
- watchUrl,
14145
+ env,
14075
14146
  flushMs: live.flushMs
14076
14147
  });
14077
14148
  }
14149
+ /**
14150
+ * Announces the watch link once the run is confirmed live-entitled. Gating on
14151
+ * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
14152
+ * feature) guarantees the link is never printed or posted for a run that will
14153
+ * never stream — no dead links. The chat ping fires from the primary shard only
14154
+ * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
14155
+ * not N. The banner still prints from every shard's own log.
14156
+ */
14157
+ async announceLive(watchUrl, env, shard) {
14158
+ const info = await this.license();
14159
+ if (!info) {
14160
+ logger.warn("Live is enabled but no active license resolved \u2014 no watch link or streaming.");
14161
+ return;
14162
+ }
14163
+ if (!info.jwt) {
14164
+ logger.warn(
14165
+ "Live is enabled but the license has no signed token (offline with no cached token?) \u2014 no streaming."
14166
+ );
14167
+ return;
14168
+ }
14169
+ if (!info.features?.includes("live")) {
14170
+ logger.warn(
14171
+ "Live is enabled but your license token does not include the 'live' entitlement \u2014 no watch link or streaming. This usually means a cached token predates live streaming and could not be refreshed. Ensure network access on the next run, or delete ~/.reportforge/license.json to force re-activation. If it persists on an active subscription, contact support."
14172
+ );
14173
+ return;
14174
+ }
14175
+ logger.info(`Live Tracker: ${watchUrl}`);
14176
+ console.log(formatLiveBanner(watchUrl));
14177
+ const isPrimaryShard = !shard || shard.current === 1;
14178
+ const notify = this.options.notify;
14179
+ if (isPrimaryShard && notify) {
14180
+ await new NotificationSender().sendLiveStarted(notify, watchUrl, env?.branch);
14181
+ }
14182
+ }
14078
14183
  resolveProjectName() {
14079
14184
  try {
14080
14185
  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();
@@ -13713,11 +13754,10 @@ var LiveStreamer = class {
13713
13754
  this.enabled = false;
13714
13755
  this.testBuffer = [];
13715
13756
  this.eventBuffer = [];
13716
- this.warn("Live streaming disabled \u2014 no active license or live entitlement.");
13757
+ logger.debug("Live streaming disabled \u2014 no active license or live entitlement.");
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) {
@@ -13777,8 +13817,8 @@ function deriveRunId(opts) {
13777
13817
  if (fromEnv) return fromEnv;
13778
13818
  return detectCiRunId() ?? randomUUID2();
13779
13819
  }
13780
- function computeWatchToken(runId) {
13781
- const secret = process.env.RF_LICENSE_KEY?.trim();
13820
+ function computeWatchToken(runId, key) {
13821
+ const secret = key?.trim() || process.env.RF_LICENSE_KEY?.trim();
13782
13822
  if (!secret) return null;
13783
13823
  return createHmac3("sha256", secret).update(`live:${runId}`).digest("hex").slice(0, WATCH_TOKEN_HEX_LEN);
13784
13824
  }
@@ -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.1" : "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
  }
@@ -14063,19 +14126,61 @@ var PdfReporter = class {
14063
14126
  this.liveSteps = live.steps;
14064
14127
  this.liveConsole = live.console;
14065
14128
  void this.license();
14066
- const token = computeWatchToken(runId);
14129
+ const token = computeWatchToken(runId, this.options.licenseKey);
14067
14130
  const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14131
+ const env = detectCiEnv();
14132
+ if (watchUrl) {
14133
+ void this.announceLive(watchUrl, env, shard).catch(() => {
14134
+ });
14135
+ } else {
14136
+ logger.warn(
14137
+ "Live is enabled but no RF_LICENSE_KEY was found \u2014 cannot mint a watch link or stream. Set RF_LICENSE_KEY (or the `licenseKey` option)."
14138
+ );
14139
+ }
14068
14140
  this.liveStreamer = new LiveStreamer({
14069
14141
  serverUrl,
14070
14142
  runId,
14071
14143
  shardKey: this.liveShardKey,
14072
14144
  shardTotal: shard?.total ?? null,
14073
14145
  license: () => this.license(),
14074
- env: detectCiEnv(),
14075
- watchUrl,
14146
+ env,
14076
14147
  flushMs: live.flushMs
14077
14148
  });
14078
14149
  }
14150
+ /**
14151
+ * Announces the watch link once the run is confirmed live-entitled. Gating on
14152
+ * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
14153
+ * feature) guarantees the link is never printed or posted for a run that will
14154
+ * never stream — no dead links. The chat ping fires from the primary shard only
14155
+ * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
14156
+ * not N. The banner still prints from every shard's own log.
14157
+ */
14158
+ async announceLive(watchUrl, env, shard) {
14159
+ const info = await this.license();
14160
+ if (!info) {
14161
+ logger.warn("Live is enabled but no active license resolved \u2014 no watch link or streaming.");
14162
+ return;
14163
+ }
14164
+ if (!info.jwt) {
14165
+ logger.warn(
14166
+ "Live is enabled but the license has no signed token (offline with no cached token?) \u2014 no streaming."
14167
+ );
14168
+ return;
14169
+ }
14170
+ if (!info.features?.includes("live")) {
14171
+ logger.warn(
14172
+ "Live is enabled but your license token does not include the 'live' entitlement \u2014 no watch link or streaming. This usually means a cached token predates live streaming and could not be refreshed. Ensure network access on the next run, or delete ~/.reportforge/license.json to force re-activation. If it persists on an active subscription, contact support."
14173
+ );
14174
+ return;
14175
+ }
14176
+ logger.info(`Live Tracker: ${watchUrl}`);
14177
+ console.log(formatLiveBanner(watchUrl));
14178
+ const isPrimaryShard = !shard || shard.current === 1;
14179
+ const notify = this.options.notify;
14180
+ if (isPrimaryShard && notify) {
14181
+ await new NotificationSender().sendLiveStarted(notify, watchUrl, env?.branch);
14182
+ }
14183
+ }
14079
14184
  resolveProjectName() {
14080
14185
  try {
14081
14186
  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.1",
4
4
  "description": "Enterprise-ready PDF reports for Playwright Test — minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "MIT",
6
6
  "author": "ReportForge",