@reportforge/playwright-pdf 0.23.0 → 0.24.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
@@ -245,7 +245,7 @@ Sharded runs need no extra config: every shard of one CI run converges on the sa
245
245
 
246
246
  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.
247
247
 
248
- > **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).
248
+ > **Security:** the watch link exposes test titles + statuses (and step titles when `steps` is on) to anyone holding it — no login needed, which is the point: share it with whoever should watch. The token is scoped to that one run, and rotating your license key never breaks an already-shared link. 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).
249
249
 
250
250
  ### Shard Merging
251
251
 
package/dist/index.d.mts CHANGED
@@ -552,9 +552,12 @@ declare class PdfReporter implements Reporter {
552
552
  * Announces the watch link once the run is confirmed live-entitled. Gating on
553
553
  * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
554
554
  * feature) guarantees the link is never printed or posted for a run that will
555
- * never stream — no dead links. The chat ping fires from the primary shard only
556
- * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
557
- * not N. The banner still prints from every shard's own log.
555
+ * never stream — no dead links. The link's token is the server-ISSUED one from
556
+ * the announce handshake (random, key-independent a key rotation can never
557
+ * kill the link); the key-derived HMAC is minted only as a fallback when the
558
+ * handshake fails (network blip, older server). The chat ping fires from the
559
+ * primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
560
+ * posts one message, not N. The banner still prints from every shard's own log.
558
561
  */
559
562
  private announceLive;
560
563
  private resolveProjectName;
package/dist/index.d.ts CHANGED
@@ -552,9 +552,12 @@ declare class PdfReporter implements Reporter {
552
552
  * Announces the watch link once the run is confirmed live-entitled. Gating on
553
553
  * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
554
554
  * feature) guarantees the link is never printed or posted for a run that will
555
- * never stream — no dead links. The chat ping fires from the primary shard only
556
- * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
557
- * not N. The banner still prints from every shard's own log.
555
+ * never stream — no dead links. The link's token is the server-ISSUED one from
556
+ * the announce handshake (random, key-independent a key rotation can never
557
+ * kill the link); the key-derived HMAC is minted only as a fallback when the
558
+ * handshake fails (network blip, older server). The chat ping fires from the
559
+ * primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
560
+ * posts one message, not N. The banner still prints from every shard's own log.
558
561
  */
559
562
  private announceLive;
560
563
  private resolveProjectName;
package/dist/index.js CHANGED
@@ -14248,6 +14248,7 @@ var LiveStreamer = class {
14248
14248
  this.enabled = true;
14249
14249
  this.gated = false;
14250
14250
  this.gatePromise = null;
14251
+ this.announcePromise = null;
14251
14252
  this.jwt = null;
14252
14253
  this.envSent = false;
14253
14254
  this.lastWarnMs = 0;
@@ -14280,6 +14281,58 @@ var LiveStreamer = class {
14280
14281
  this.capPreLicenseBuffer();
14281
14282
  this.scheduleFlush();
14282
14283
  }
14284
+ /**
14285
+ * One immediate empty "hello" batch, sent at run start before the banner
14286
+ * prints. It binds the run server-side (so early link-openers and the
14287
+ * dashboard see it instantly instead of a pending window) and returns the
14288
+ * server-ISSUED watch token from the ack — the capability that survives
14289
+ * every license-key lifecycle event, unlike the key-derived HMAC fallback.
14290
+ * Returns null on any failure (not entitled, network error, older server
14291
+ * whose ack carries no token); the caller then falls back to the HMAC link.
14292
+ * Memoized so drain() can await the same in-flight promise (see drain()).
14293
+ */
14294
+ announce() {
14295
+ return this.announcePromise ?? (this.announcePromise = this.announceOnce());
14296
+ }
14297
+ async announceOnce() {
14298
+ const entitled = await this.ensureGate();
14299
+ if (!entitled || !this.jwt) return null;
14300
+ const body = {
14301
+ runId: this.config.runId,
14302
+ shardKey: this.config.shardKey,
14303
+ shardTotal: this.config.shardTotal,
14304
+ status: "running",
14305
+ counts: this.snapshotCounts(),
14306
+ tests: [],
14307
+ events: [],
14308
+ ...this.envSent ? {} : { env: this.config.env }
14309
+ };
14310
+ const controller = new AbortController();
14311
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS3);
14312
+ try {
14313
+ const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
14314
+ method: "POST",
14315
+ headers: {
14316
+ "content-type": "application/json",
14317
+ authorization: `Bearer ${this.jwt}`
14318
+ },
14319
+ body: JSON.stringify(body),
14320
+ signal: controller.signal
14321
+ });
14322
+ if (!res.ok) {
14323
+ await res.body?.cancel().catch(() => {
14324
+ });
14325
+ return null;
14326
+ }
14327
+ const ack = await res.json().catch(() => null);
14328
+ if (body.env) this.envSent = true;
14329
+ return typeof ack?.watchToken === "string" && ack.watchToken ? ack.watchToken : null;
14330
+ } catch {
14331
+ return null;
14332
+ } finally {
14333
+ clearTimeout(timeout);
14334
+ }
14335
+ }
14283
14336
  /**
14284
14337
  * Final flush: cancels the pending timer, emits the terminal `finished` batch,
14285
14338
  * then awaits in-flight requests bounded by LIVE_DRAIN_DEADLINE_MS. Called from
@@ -14291,6 +14344,7 @@ var LiveStreamer = class {
14291
14344
  clearTimeout(this.flushTimer);
14292
14345
  this.flushTimer = null;
14293
14346
  }
14347
+ if (this.announcePromise) await this.announcePromise;
14294
14348
  await this.flush("finished");
14295
14349
  await Promise.race([
14296
14350
  Promise.allSettled([...this.inFlight]),
@@ -14468,7 +14522,7 @@ function computeWatchToken(runId, key) {
14468
14522
  }
14469
14523
  function buildWatchUrl(serverUrl, runId, token) {
14470
14524
  const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
14471
- return `${base}/live/${encodeURIComponent(runId)}?t=${encodeURIComponent(token)}`;
14525
+ return `${base}/live/${encodeURIComponent(runId)}#t=${encodeURIComponent(token)}`;
14472
14526
  }
14473
14527
  function formatLiveBanner(url) {
14474
14528
  const label = "Live Tracker";
@@ -14584,7 +14638,7 @@ var PdfReporter = class {
14584
14638
  if (this.options.redact.enabled) {
14585
14639
  this.redactor = new Redactor(this.options.redact);
14586
14640
  }
14587
- this.version = true ? "0.23.0" : "0.x";
14641
+ this.version = true ? "0.24.0" : "0.x";
14588
14642
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14589
14643
  this.licenseClient = new LicenseClient({
14590
14644
  licenseKey: this.options.licenseKey,
@@ -14849,18 +14903,8 @@ var PdfReporter = class {
14849
14903
  this.liveSteps = live.steps;
14850
14904
  this.liveConsole = live.console;
14851
14905
  void this.license();
14852
- const token = computeWatchToken(runId, this.options.licenseKey);
14853
- const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14854
14906
  const env = detectCiEnv();
14855
14907
  if (this.redactor && env?.buildUrl) env.buildUrl = this.redactor.redactText(env.buildUrl);
14856
- if (watchUrl) {
14857
- void this.announceLive(watchUrl, env, shard).catch(() => {
14858
- });
14859
- } else {
14860
- logger.warn(
14861
- "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)."
14862
- );
14863
- }
14864
14908
  this.liveStreamer = new LiveStreamer({
14865
14909
  serverUrl,
14866
14910
  runId,
@@ -14871,16 +14915,27 @@ var PdfReporter = class {
14871
14915
  env,
14872
14916
  flushMs: live.flushMs
14873
14917
  });
14918
+ if (!this.options.licenseKey?.trim() && !process.env.RF_LICENSE_KEY?.trim()) {
14919
+ logger.warn(
14920
+ "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)."
14921
+ );
14922
+ return;
14923
+ }
14924
+ void this.announceLive(serverUrl, runId, env, shard).catch(() => {
14925
+ });
14874
14926
  }
14875
14927
  /**
14876
14928
  * Announces the watch link once the run is confirmed live-entitled. Gating on
14877
14929
  * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
14878
14930
  * feature) guarantees the link is never printed or posted for a run that will
14879
- * never stream — no dead links. The chat ping fires from the primary shard only
14880
- * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
14881
- * not N. The banner still prints from every shard's own log.
14931
+ * never stream — no dead links. The link's token is the server-ISSUED one from
14932
+ * the announce handshake (random, key-independent a key rotation can never
14933
+ * kill the link); the key-derived HMAC is minted only as a fallback when the
14934
+ * handshake fails (network blip, older server). The chat ping fires from the
14935
+ * primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
14936
+ * posts one message, not N. The banner still prints from every shard's own log.
14882
14937
  */
14883
- async announceLive(watchUrl, env, shard) {
14938
+ async announceLive(serverUrl, runId, env, shard) {
14884
14939
  const info = await this.license();
14885
14940
  if (!info) {
14886
14941
  logger.warn("Live is enabled but no active license resolved \u2014 no watch link or streaming.");
@@ -14898,6 +14953,13 @@ var PdfReporter = class {
14898
14953
  );
14899
14954
  return;
14900
14955
  }
14956
+ const issued = await this.liveStreamer?.announce();
14957
+ const token = issued ?? computeWatchToken(runId, this.options.licenseKey);
14958
+ if (!token) {
14959
+ logger.warn("Live is enabled but no watch link could be established \u2014 streaming continues without one.");
14960
+ return;
14961
+ }
14962
+ const watchUrl = buildWatchUrl(serverUrl, runId, token);
14901
14963
  if (isLevelEnabled("info")) {
14902
14964
  console.log(formatLiveBanner(watchUrl));
14903
14965
  }
package/dist/index.mjs CHANGED
@@ -14249,6 +14249,7 @@ var LiveStreamer = class {
14249
14249
  this.enabled = true;
14250
14250
  this.gated = false;
14251
14251
  this.gatePromise = null;
14252
+ this.announcePromise = null;
14252
14253
  this.jwt = null;
14253
14254
  this.envSent = false;
14254
14255
  this.lastWarnMs = 0;
@@ -14281,6 +14282,58 @@ var LiveStreamer = class {
14281
14282
  this.capPreLicenseBuffer();
14282
14283
  this.scheduleFlush();
14283
14284
  }
14285
+ /**
14286
+ * One immediate empty "hello" batch, sent at run start before the banner
14287
+ * prints. It binds the run server-side (so early link-openers and the
14288
+ * dashboard see it instantly instead of a pending window) and returns the
14289
+ * server-ISSUED watch token from the ack — the capability that survives
14290
+ * every license-key lifecycle event, unlike the key-derived HMAC fallback.
14291
+ * Returns null on any failure (not entitled, network error, older server
14292
+ * whose ack carries no token); the caller then falls back to the HMAC link.
14293
+ * Memoized so drain() can await the same in-flight promise (see drain()).
14294
+ */
14295
+ announce() {
14296
+ return this.announcePromise ?? (this.announcePromise = this.announceOnce());
14297
+ }
14298
+ async announceOnce() {
14299
+ const entitled = await this.ensureGate();
14300
+ if (!entitled || !this.jwt) return null;
14301
+ const body = {
14302
+ runId: this.config.runId,
14303
+ shardKey: this.config.shardKey,
14304
+ shardTotal: this.config.shardTotal,
14305
+ status: "running",
14306
+ counts: this.snapshotCounts(),
14307
+ tests: [],
14308
+ events: [],
14309
+ ...this.envSent ? {} : { env: this.config.env }
14310
+ };
14311
+ const controller = new AbortController();
14312
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS3);
14313
+ try {
14314
+ const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
14315
+ method: "POST",
14316
+ headers: {
14317
+ "content-type": "application/json",
14318
+ authorization: `Bearer ${this.jwt}`
14319
+ },
14320
+ body: JSON.stringify(body),
14321
+ signal: controller.signal
14322
+ });
14323
+ if (!res.ok) {
14324
+ await res.body?.cancel().catch(() => {
14325
+ });
14326
+ return null;
14327
+ }
14328
+ const ack = await res.json().catch(() => null);
14329
+ if (body.env) this.envSent = true;
14330
+ return typeof ack?.watchToken === "string" && ack.watchToken ? ack.watchToken : null;
14331
+ } catch {
14332
+ return null;
14333
+ } finally {
14334
+ clearTimeout(timeout);
14335
+ }
14336
+ }
14284
14337
  /**
14285
14338
  * Final flush: cancels the pending timer, emits the terminal `finished` batch,
14286
14339
  * then awaits in-flight requests bounded by LIVE_DRAIN_DEADLINE_MS. Called from
@@ -14292,6 +14345,7 @@ var LiveStreamer = class {
14292
14345
  clearTimeout(this.flushTimer);
14293
14346
  this.flushTimer = null;
14294
14347
  }
14348
+ if (this.announcePromise) await this.announcePromise;
14295
14349
  await this.flush("finished");
14296
14350
  await Promise.race([
14297
14351
  Promise.allSettled([...this.inFlight]),
@@ -14469,7 +14523,7 @@ function computeWatchToken(runId, key) {
14469
14523
  }
14470
14524
  function buildWatchUrl(serverUrl, runId, token) {
14471
14525
  const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
14472
- return `${base}/live/${encodeURIComponent(runId)}?t=${encodeURIComponent(token)}`;
14526
+ return `${base}/live/${encodeURIComponent(runId)}#t=${encodeURIComponent(token)}`;
14473
14527
  }
14474
14528
  function formatLiveBanner(url) {
14475
14529
  const label = "Live Tracker";
@@ -14585,7 +14639,7 @@ var PdfReporter = class {
14585
14639
  if (this.options.redact.enabled) {
14586
14640
  this.redactor = new Redactor(this.options.redact);
14587
14641
  }
14588
- this.version = true ? "0.23.0" : "0.x";
14642
+ this.version = true ? "0.24.0" : "0.x";
14589
14643
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14590
14644
  this.licenseClient = new LicenseClient({
14591
14645
  licenseKey: this.options.licenseKey,
@@ -14850,18 +14904,8 @@ var PdfReporter = class {
14850
14904
  this.liveSteps = live.steps;
14851
14905
  this.liveConsole = live.console;
14852
14906
  void this.license();
14853
- const token = computeWatchToken(runId, this.options.licenseKey);
14854
- const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14855
14907
  const env = detectCiEnv();
14856
14908
  if (this.redactor && env?.buildUrl) env.buildUrl = this.redactor.redactText(env.buildUrl);
14857
- if (watchUrl) {
14858
- void this.announceLive(watchUrl, env, shard).catch(() => {
14859
- });
14860
- } else {
14861
- logger.warn(
14862
- "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)."
14863
- );
14864
- }
14865
14909
  this.liveStreamer = new LiveStreamer({
14866
14910
  serverUrl,
14867
14911
  runId,
@@ -14872,16 +14916,27 @@ var PdfReporter = class {
14872
14916
  env,
14873
14917
  flushMs: live.flushMs
14874
14918
  });
14919
+ if (!this.options.licenseKey?.trim() && !process.env.RF_LICENSE_KEY?.trim()) {
14920
+ logger.warn(
14921
+ "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)."
14922
+ );
14923
+ return;
14924
+ }
14925
+ void this.announceLive(serverUrl, runId, env, shard).catch(() => {
14926
+ });
14875
14927
  }
14876
14928
  /**
14877
14929
  * Announces the watch link once the run is confirmed live-entitled. Gating on
14878
14930
  * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
14879
14931
  * feature) guarantees the link is never printed or posted for a run that will
14880
- * never stream — no dead links. The chat ping fires from the primary shard only
14881
- * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
14882
- * not N. The banner still prints from every shard's own log.
14932
+ * never stream — no dead links. The link's token is the server-ISSUED one from
14933
+ * the announce handshake (random, key-independent a key rotation can never
14934
+ * kill the link); the key-derived HMAC is minted only as a fallback when the
14935
+ * handshake fails (network blip, older server). The chat ping fires from the
14936
+ * primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
14937
+ * posts one message, not N. The banner still prints from every shard's own log.
14883
14938
  */
14884
- async announceLive(watchUrl, env, shard) {
14939
+ async announceLive(serverUrl, runId, env, shard) {
14885
14940
  const info = await this.license();
14886
14941
  if (!info) {
14887
14942
  logger.warn("Live is enabled but no active license resolved \u2014 no watch link or streaming.");
@@ -14899,6 +14954,13 @@ var PdfReporter = class {
14899
14954
  );
14900
14955
  return;
14901
14956
  }
14957
+ const issued = await this.liveStreamer?.announce();
14958
+ const token = issued ?? computeWatchToken(runId, this.options.licenseKey);
14959
+ if (!token) {
14960
+ logger.warn("Live is enabled but no watch link could be established \u2014 streaming continues without one.");
14961
+ return;
14962
+ }
14963
+ const watchUrl = buildWatchUrl(serverUrl, runId, token);
14902
14964
  if (isLevelEnabled("info")) {
14903
14965
  console.log(formatLiveBanner(watchUrl));
14904
14966
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Playwright Test reporter that generates designed PDF reports: minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "Elastic-2.0",
6
6
  "author": "ReportForge",