@reportforge/playwright-pdf 0.23.0 → 0.25.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/dist/index.js CHANGED
@@ -14230,11 +14230,35 @@ var NETWORK_TIMEOUT_MS3 = 8e3;
14230
14230
  var LIVE_DRAIN_DEADLINE_MS = 1e4;
14231
14231
  var MAX_FLUSH_MS = 3e4;
14232
14232
  var WARN_THROTTLE_MS = 3e4;
14233
+ var TREE_UPDATE_WEIGHT = 25;
14234
+ function redactTestUpdate(update, redact) {
14235
+ if (update.title !== void 0) update.title = redact(update.title);
14236
+ if (update.currentStep !== void 0) update.currentStep = redact(update.currentStep);
14237
+ if (update.file !== void 0) update.file = redact(update.file);
14238
+ if (update.suitePath) update.suitePath = update.suitePath.map(redact);
14239
+ if (update.projectName !== void 0) update.projectName = redact(update.projectName);
14240
+ if (update.steps) redactStepNodes(update.steps, redact);
14241
+ if (update.errors) update.errors.forEach((e) => redactError(e, redact));
14242
+ }
14243
+ function redactStepNodes(nodes, redact) {
14244
+ for (const node of nodes) {
14245
+ node.t = redact(node.t);
14246
+ if (node.err) redactError(node.err, redact);
14247
+ if (node.ch) redactStepNodes(node.ch, redact);
14248
+ }
14249
+ }
14250
+ function redactError(err, redact) {
14251
+ err.message = redact(err.message);
14252
+ if (err.stack !== void 0) err.stack = redact(err.stack);
14253
+ if (err.snippet !== void 0) err.snippet = redact(err.snippet);
14254
+ }
14233
14255
  var LiveStreamer = class {
14234
14256
  constructor(config) {
14235
14257
  this.config = config;
14236
14258
  this.testBuffer = [];
14237
14259
  this.eventBuffer = [];
14260
+ /** Weighted size of the buffers (tree-bearing updates count TREE_UPDATE_WEIGHT). */
14261
+ this.bufferWeight = 0;
14238
14262
  /** Monotonic per-shard event counter → stable `{shardKey}:{seq}` event ids. */
14239
14263
  this.eventSeq = 0;
14240
14264
  /**
@@ -14248,6 +14272,7 @@ var LiveStreamer = class {
14248
14272
  this.enabled = true;
14249
14273
  this.gated = false;
14250
14274
  this.gatePromise = null;
14275
+ this.announcePromise = null;
14251
14276
  this.jwt = null;
14252
14277
  this.envSent = false;
14253
14278
  this.lastWarnMs = 0;
@@ -14259,13 +14284,11 @@ var LiveStreamer = class {
14259
14284
  }
14260
14285
  enqueueTest(update) {
14261
14286
  if (!this.enabled) return;
14262
- if (this.config.redact) {
14263
- if (update.title !== void 0) update.title = this.config.redact(update.title);
14264
- if (update.currentStep !== void 0) update.currentStep = this.config.redact(update.currentStep);
14265
- }
14287
+ if (this.config.redact) redactTestUpdate(update, this.config.redact);
14266
14288
  const prevRetry = this.testStates.get(update.id)?.retry;
14267
14289
  this.testStates.set(update.id, { status: update.status, retry: update.retry ?? prevRetry });
14268
14290
  this.testBuffer.push(update);
14291
+ this.bufferWeight += update.steps ? TREE_UPDATE_WEIGHT : 1;
14269
14292
  this.capPreLicenseBuffer();
14270
14293
  this.scheduleFlush();
14271
14294
  }
@@ -14277,9 +14300,62 @@ var LiveStreamer = class {
14277
14300
  }
14278
14301
  event.id = `${this.config.shardKey}:${this.eventSeq++}`;
14279
14302
  this.eventBuffer.push(event);
14303
+ this.bufferWeight += 1;
14280
14304
  this.capPreLicenseBuffer();
14281
14305
  this.scheduleFlush();
14282
14306
  }
14307
+ /**
14308
+ * One immediate empty "hello" batch, sent at run start before the banner
14309
+ * prints. It binds the run server-side (so early link-openers and the
14310
+ * dashboard see it instantly instead of a pending window) and returns the
14311
+ * server-ISSUED watch token from the ack — the capability that survives
14312
+ * every license-key lifecycle event, unlike the key-derived HMAC fallback.
14313
+ * Returns null on any failure (not entitled, network error, older server
14314
+ * whose ack carries no token); the caller then falls back to the HMAC link.
14315
+ * Memoized so drain() can await the same in-flight promise (see drain()).
14316
+ */
14317
+ announce() {
14318
+ return this.announcePromise ?? (this.announcePromise = this.announceOnce());
14319
+ }
14320
+ async announceOnce() {
14321
+ const entitled = await this.ensureGate();
14322
+ if (!entitled || !this.jwt) return null;
14323
+ const body = {
14324
+ runId: this.config.runId,
14325
+ shardKey: this.config.shardKey,
14326
+ shardTotal: this.config.shardTotal,
14327
+ status: "running",
14328
+ counts: this.snapshotCounts(),
14329
+ tests: [],
14330
+ events: [],
14331
+ ...this.envSent ? {} : { env: this.config.env }
14332
+ };
14333
+ const controller = new AbortController();
14334
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS3);
14335
+ try {
14336
+ const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
14337
+ method: "POST",
14338
+ headers: {
14339
+ "content-type": "application/json",
14340
+ authorization: `Bearer ${this.jwt}`
14341
+ },
14342
+ body: JSON.stringify(body),
14343
+ signal: controller.signal
14344
+ });
14345
+ if (!res.ok) {
14346
+ await res.body?.cancel().catch(() => {
14347
+ });
14348
+ return null;
14349
+ }
14350
+ const ack = await res.json().catch(() => null);
14351
+ if (body.env) this.envSent = true;
14352
+ return typeof ack?.watchToken === "string" && ack.watchToken ? ack.watchToken : null;
14353
+ } catch {
14354
+ return null;
14355
+ } finally {
14356
+ clearTimeout(timeout);
14357
+ }
14358
+ }
14283
14359
  /**
14284
14360
  * Final flush: cancels the pending timer, emits the terminal `finished` batch,
14285
14361
  * then awaits in-flight requests bounded by LIVE_DRAIN_DEADLINE_MS. Called from
@@ -14291,6 +14367,7 @@ var LiveStreamer = class {
14291
14367
  clearTimeout(this.flushTimer);
14292
14368
  this.flushTimer = null;
14293
14369
  }
14370
+ if (this.announcePromise) await this.announcePromise;
14294
14371
  await this.flush("finished");
14295
14372
  await Promise.race([
14296
14373
  Promise.allSettled([...this.inFlight]),
@@ -14341,9 +14418,13 @@ var LiveStreamer = class {
14341
14418
  if (dropEvents > 0) this.eventBuffer.splice(0, dropEvents);
14342
14419
  overflow -= dropEvents;
14343
14420
  if (overflow > 0) this.testBuffer.splice(0, overflow);
14421
+ this.bufferWeight = this.currentBufferWeight();
14422
+ }
14423
+ currentBufferWeight() {
14424
+ return this.eventBuffer.length + this.testBuffer.reduce((w, t) => w + (t.steps ? TREE_UPDATE_WEIGHT : 1), 0);
14344
14425
  }
14345
14426
  scheduleFlush() {
14346
- if (this.testBuffer.length + this.eventBuffer.length >= this.flushMaxBatch) {
14427
+ if (this.bufferWeight >= this.flushMaxBatch) {
14347
14428
  void this.flush("running");
14348
14429
  return;
14349
14430
  }
@@ -14364,6 +14445,7 @@ var LiveStreamer = class {
14364
14445
  if (status !== "finished" && tests.length === 0 && events.length === 0) return;
14365
14446
  this.testBuffer = [];
14366
14447
  this.eventBuffer = [];
14448
+ this.bufferWeight = 0;
14367
14449
  const body = {
14368
14450
  runId: this.config.runId,
14369
14451
  shardKey: this.config.shardKey,
@@ -14398,6 +14480,7 @@ var LiveStreamer = class {
14398
14480
  this.enabled = false;
14399
14481
  this.testBuffer = [];
14400
14482
  this.eventBuffer = [];
14483
+ this.bufferWeight = 0;
14401
14484
  logger.debug("Live streaming disabled \u2014 no active license or live entitlement.");
14402
14485
  return false;
14403
14486
  }
@@ -14468,7 +14551,7 @@ function computeWatchToken(runId, key) {
14468
14551
  }
14469
14552
  function buildWatchUrl(serverUrl, runId, token) {
14470
14553
  const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
14471
- return `${base}/live/${encodeURIComponent(runId)}?t=${encodeURIComponent(token)}`;
14554
+ return `${base}/live/${encodeURIComponent(runId)}#t=${encodeURIComponent(token)}`;
14472
14555
  }
14473
14556
  function formatLiveBanner(url) {
14474
14557
  const label = "Live Tracker";
@@ -14484,25 +14567,213 @@ function shardKeyOf(shard) {
14484
14567
 
14485
14568
  // src/live/event-map.ts
14486
14569
  init_cjs_shims();
14570
+
14571
+ // src/live/step-tree.ts
14572
+ init_cjs_shims();
14573
+ var MAX_TREE_DEPTH = 10;
14574
+ var MAX_TREE_NODES = 150;
14575
+ var MAX_TREE_JSON = 24e3;
14576
+ var MAX_NODE_TITLE = 200;
14577
+ var MAX_ERRORS = 3;
14578
+ var MAX_ERR_MESSAGE = 2e3;
14579
+ var MAX_ERR_STACK = 8192;
14580
+ var MAX_ERR_SNIPPET = 4096;
14581
+ var isHook = (c) => c === "hook" || c === "fixture";
14582
+ function flatten2(steps) {
14583
+ const out = [];
14584
+ const walk = (nodes, depth, ancestors) => {
14585
+ for (const s of nodes ?? []) {
14586
+ const index = out.length;
14587
+ out.push({
14588
+ raw: s,
14589
+ category: s.category ?? "",
14590
+ depth,
14591
+ hasError: s.error != null,
14592
+ ancestors
14593
+ });
14594
+ if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
14595
+ }
14596
+ };
14597
+ walk(steps ?? [], 0, []);
14598
+ return out;
14599
+ }
14600
+ var inHookSubtree2 = (flat, f) => isHook(f.category) || f.ancestors.some((a) => isHook(flat[a].category));
14601
+ function pickFailingIdx2(flat) {
14602
+ const pick = (allowTeardown) => {
14603
+ let idx = -1;
14604
+ for (let i = 0; i < flat.length; i++) {
14605
+ const f = flat[i];
14606
+ if (!f.hasError || !allowTeardown && inHookSubtree2(flat, f)) continue;
14607
+ if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
14608
+ }
14609
+ return idx;
14610
+ };
14611
+ const body = pick(false);
14612
+ return body >= 0 ? body : pick(true);
14613
+ }
14614
+ function mapCategory(category) {
14615
+ if (category === "test.step") return "step";
14616
+ if (category === "expect") return "expect";
14617
+ if (isHook(category)) return "hook";
14618
+ return "api";
14619
+ }
14620
+ function buildErrorInfo(err) {
14621
+ if (!err?.message) return void 0;
14622
+ return {
14623
+ message: clampWithEllipsis(stripAnsi(err.message), MAX_ERR_MESSAGE),
14624
+ ...err.stack ? { stack: clampWithEllipsis(stripAnsi(err.stack), MAX_ERR_STACK) } : {},
14625
+ ...err.snippet ? { snippet: clampWithEllipsis(stripAnsi(err.snippet), MAX_ERR_SNIPPET) } : {}
14626
+ };
14627
+ }
14628
+ function mapResultErrors(errors) {
14629
+ if (!errors?.length) return void 0;
14630
+ const out = errors.slice(0, MAX_ERRORS).map((e) => buildErrorInfo(e)).filter((e) => e !== void 0);
14631
+ return out.length > 0 ? out : void 0;
14632
+ }
14633
+ function buildLiveStepTree(steps) {
14634
+ if (!steps?.length) return void 0;
14635
+ const flat = flatten2(steps);
14636
+ if (flat.length === 0) return void 0;
14637
+ const failingIdx = pickFailingIdx2(flat);
14638
+ const failingPath = /* @__PURE__ */ new Set();
14639
+ if (failingIdx >= 0) {
14640
+ failingPath.add(flat[failingIdx].raw);
14641
+ for (const a of flat[failingIdx].ancestors) failingPath.add(flat[a].raw);
14642
+ }
14643
+ const failingLeaf = failingIdx >= 0 ? flat[failingIdx].raw : void 0;
14644
+ for (const level of [0, 1, 2, 3]) {
14645
+ const budget = { left: MAX_TREE_NODES };
14646
+ const tree = buildChildren(steps, {
14647
+ level,
14648
+ depth: 0,
14649
+ underHook: false,
14650
+ underApiOrExpect: false,
14651
+ failingPath,
14652
+ failingLeaf,
14653
+ budget
14654
+ });
14655
+ if (tree.length === 0) return void 0;
14656
+ if (budget.left >= 0 && JSON.stringify(tree).length <= MAX_TREE_JSON) return tree;
14657
+ }
14658
+ return void 0;
14659
+ }
14660
+ function keepNode(s, ctx) {
14661
+ if (ctx.failingPath.has(s)) return true;
14662
+ if (!s.title) return false;
14663
+ const c = s.category ?? "";
14664
+ if (isHook(c) || ctx.underHook) return false;
14665
+ if (c === "test.step") return true;
14666
+ if (ctx.underApiOrExpect) return false;
14667
+ if (c === "expect") return ctx.level < 2;
14668
+ if (c === "pw:api") return ctx.level < 1;
14669
+ return false;
14670
+ }
14671
+ function containsFailing(s, leaf) {
14672
+ if (!leaf) return false;
14673
+ if (s === leaf) return true;
14674
+ return (s.steps ?? []).some((child) => containsFailing(child, leaf));
14675
+ }
14676
+ function buildChildren(nodes, ctx) {
14677
+ const out = [];
14678
+ for (const s of nodes ?? []) {
14679
+ if (ctx.budget.left <= 0) {
14680
+ ctx.budget.left = -1;
14681
+ break;
14682
+ }
14683
+ const c = s.category ?? "";
14684
+ if (!keepNode(s, ctx)) {
14685
+ if (containsFailing(s, ctx.failingLeaf)) {
14686
+ out.push(
14687
+ ...buildChildren(s.steps, {
14688
+ ...ctx,
14689
+ underHook: ctx.underHook || isHook(c),
14690
+ underApiOrExpect: ctx.underApiOrExpect || c === "pw:api" || c === "expect"
14691
+ })
14692
+ );
14693
+ }
14694
+ continue;
14695
+ }
14696
+ ctx.budget.left -= 1;
14697
+ const node = {
14698
+ t: clampWithEllipsis(stripAnsi(s.title ?? ""), MAX_NODE_TITLE),
14699
+ c: mapCategory(c),
14700
+ ...Number.isFinite(s.duration) && s.duration >= 0 ? { d: Math.round(s.duration) } : {},
14701
+ ...s.error != null ? { s: "failed" } : {},
14702
+ ...s === ctx.failingLeaf ? errField(s) : {}
14703
+ };
14704
+ if (s.steps?.length) {
14705
+ if (ctx.depth + 1 >= MAX_TREE_DEPTH) {
14706
+ if (!node.err && containsFailing(s, ctx.failingLeaf) && s !== ctx.failingLeaf) {
14707
+ const hoisted = errField(ctx.failingLeaf);
14708
+ if (hoisted.err) {
14709
+ node.err = hoisted.err;
14710
+ node.s = "failed";
14711
+ }
14712
+ }
14713
+ } else {
14714
+ const ch = buildChildren(s.steps, {
14715
+ ...ctx,
14716
+ depth: ctx.depth + 1,
14717
+ underHook: ctx.underHook || isHook(c),
14718
+ underApiOrExpect: ctx.underApiOrExpect || c === "pw:api" || c === "expect"
14719
+ });
14720
+ if (ch.length > 0) node.ch = ch;
14721
+ }
14722
+ }
14723
+ out.push(node);
14724
+ }
14725
+ return out;
14726
+ }
14727
+ function errField(s) {
14728
+ const err = buildErrorInfo(s.error);
14729
+ return err ? { err } : {};
14730
+ }
14731
+
14732
+ // src/live/event-map.ts
14487
14733
  var MAX_TITLE_CHARS2 = 200;
14488
14734
  var MAX_STEP_TITLE_CHARS = 200;
14489
14735
  var MAX_CONSOLE_CHARS = 2048;
14736
+ var MAX_FILE_CHARS = 300;
14737
+ var MAX_PROJECT_CHARS = 100;
14738
+ var MAX_SUITE_SEGMENTS = 10;
14739
+ function suiteIdentity(test) {
14740
+ const path15 = typeof test.titlePath === "function" ? test.titlePath() : [];
14741
+ if (!Array.isArray(path15) || path15.length < 4) return {};
14742
+ const [, project, file, ...rest] = path15;
14743
+ const describes = rest.slice(0, -1);
14744
+ const out = {};
14745
+ if (typeof file === "string" && file) {
14746
+ out.file = clampWithEllipsis(file.replace(/\\/g, "/"), MAX_FILE_CHARS);
14747
+ }
14748
+ const segs = describes.filter((s) => typeof s === "string" && s.length > 0).slice(0, MAX_SUITE_SEGMENTS).map((s) => clampWithEllipsis(s, MAX_STEP_TITLE_CHARS));
14749
+ if (segs.length > 0) out.suitePath = segs;
14750
+ if (typeof project === "string" && project) {
14751
+ out.projectName = clampWithEllipsis(project, MAX_PROJECT_CHARS);
14752
+ }
14753
+ return out;
14754
+ }
14490
14755
  function mapTestBegin(test, shardKey) {
14491
14756
  return {
14492
14757
  id: test.id,
14493
14758
  title: clampWithEllipsis(test.title ?? "", MAX_TITLE_CHARS2),
14494
14759
  status: "running",
14495
- shardKey
14760
+ shardKey,
14761
+ ...suiteIdentity(test)
14496
14762
  };
14497
14763
  }
14498
14764
  function mapTestEnd(test, result, shardKey) {
14765
+ const steps = buildLiveStepTree(result.steps);
14766
+ const errors = mapResultErrors(result.errors);
14499
14767
  return {
14500
14768
  id: test.id,
14501
14769
  title: clampWithEllipsis(test.title ?? "", MAX_TITLE_CHARS2),
14502
14770
  status: result.status,
14503
14771
  durationMs: result.duration,
14504
14772
  retry: result.retry,
14505
- shardKey
14773
+ shardKey,
14774
+ ...suiteIdentity(test),
14775
+ ...steps ? { steps } : {},
14776
+ ...errors ? { errors } : {}
14506
14777
  };
14507
14778
  }
14508
14779
  function isLifecycleStep(step) {
@@ -14552,7 +14823,11 @@ function mapStep(testId, step, phase, shardKey) {
14552
14823
  startedAt: step.startTime?.getTime(),
14553
14824
  // Nesting depth (test.step ancestors) so the page indents children under their
14554
14825
  // parent step. 0 → flat, so a test without test.steps renders as a flat trail.
14555
- depth: testStepDepth2(step)
14826
+ depth: testStepDepth2(step),
14827
+ // Category so the trail styles pw:api actions and expects distinctly. Hooks
14828
+ // and fixtures never reach here non-errored (shouldEmitStep drops them); an
14829
+ // errored one maps to 'api' — the trail has no dedicated hook style.
14830
+ category: step.category === "test.step" ? "step" : step.category === "expect" ? "expect" : "api"
14556
14831
  };
14557
14832
  }
14558
14833
  function mapConsole(kind, chunk, testId, shardKey) {
@@ -14584,7 +14859,7 @@ var PdfReporter = class {
14584
14859
  if (this.options.redact.enabled) {
14585
14860
  this.redactor = new Redactor(this.options.redact);
14586
14861
  }
14587
- this.version = true ? "0.23.0" : "0.x";
14862
+ this.version = true ? "0.25.0" : "0.x";
14588
14863
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14589
14864
  this.licenseClient = new LicenseClient({
14590
14865
  licenseKey: this.options.licenseKey,
@@ -14849,18 +15124,8 @@ var PdfReporter = class {
14849
15124
  this.liveSteps = live.steps;
14850
15125
  this.liveConsole = live.console;
14851
15126
  void this.license();
14852
- const token = computeWatchToken(runId, this.options.licenseKey);
14853
- const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14854
15127
  const env = detectCiEnv();
14855
15128
  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
15129
  this.liveStreamer = new LiveStreamer({
14865
15130
  serverUrl,
14866
15131
  runId,
@@ -14871,16 +15136,27 @@ var PdfReporter = class {
14871
15136
  env,
14872
15137
  flushMs: live.flushMs
14873
15138
  });
15139
+ if (!this.options.licenseKey?.trim() && !process.env.RF_LICENSE_KEY?.trim()) {
15140
+ logger.warn(
15141
+ "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)."
15142
+ );
15143
+ return;
15144
+ }
15145
+ void this.announceLive(serverUrl, runId, env, shard).catch(() => {
15146
+ });
14874
15147
  }
14875
15148
  /**
14876
15149
  * Announces the watch link once the run is confirmed live-entitled. Gating on
14877
15150
  * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
14878
15151
  * 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.
15152
+ * never stream — no dead links. The link's token is the server-ISSUED one from
15153
+ * the announce handshake (random, key-independent a key rotation can never
15154
+ * kill the link); the key-derived HMAC is minted only as a fallback when the
15155
+ * handshake fails (network blip, older server). The chat ping fires from the
15156
+ * primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
15157
+ * posts one message, not N. The banner still prints from every shard's own log.
14882
15158
  */
14883
- async announceLive(watchUrl, env, shard) {
15159
+ async announceLive(serverUrl, runId, env, shard) {
14884
15160
  const info = await this.license();
14885
15161
  if (!info) {
14886
15162
  logger.warn("Live is enabled but no active license resolved \u2014 no watch link or streaming.");
@@ -14898,6 +15174,13 @@ var PdfReporter = class {
14898
15174
  );
14899
15175
  return;
14900
15176
  }
15177
+ const issued = await this.liveStreamer?.announce();
15178
+ const token = issued ?? computeWatchToken(runId, this.options.licenseKey);
15179
+ if (!token) {
15180
+ logger.warn("Live is enabled but no watch link could be established \u2014 streaming continues without one.");
15181
+ return;
15182
+ }
15183
+ const watchUrl = buildWatchUrl(serverUrl, runId, token);
14901
15184
  if (isLevelEnabled("info")) {
14902
15185
  console.log(formatLiveBanner(watchUrl));
14903
15186
  }