@reportforge/playwright-pdf 0.8.0 → 0.9.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
@@ -10484,6 +10484,21 @@ var captureConfigSchema = external_exports.object({
10484
10484
  maxSteps: external_exports.number().int().min(1).max(500).default(50),
10485
10485
  maxConsoleLines: external_exports.number().int().min(1).max(1e3).default(50)
10486
10486
  }).default({});
10487
+ var liveConfigSchema = external_exports.object({
10488
+ enabled: external_exports.boolean().default(false),
10489
+ // Override the auto-derived run id. All shards of one run must share it.
10490
+ runId: external_exports.string().optional(),
10491
+ // Override the streaming/server base URL (defaults to the license serverUrl).
10492
+ serverUrl: external_exports.string().url().optional(),
10493
+ // Step verbosity: 'none' skips steps, 'failed' streams intent + assertions +
10494
+ // errored steps, 'all' streams every step.
10495
+ steps: external_exports.enum(["none", "failed", "all"]).default("failed"),
10496
+ // Stream test stdout/stderr tails. Off by default — may leak secrets printed
10497
+ // by tests to a token-holder.
10498
+ console: external_exports.boolean().default(false),
10499
+ // Debounce window for batched flushes (ms).
10500
+ flushMs: external_exports.number().int().min(500).max(1e4).default(2e3)
10501
+ }).default({});
10487
10502
  var sectionFlagsShape = Object.fromEntries(
10488
10503
  SECTION_KEYS.map((k) => [k, external_exports.boolean().optional()])
10489
10504
  );
@@ -10525,6 +10540,7 @@ var ReporterOptionsSchema = external_exports.object({
10525
10540
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10526
10541
  failureAnalysis: failureAnalysisConfigSchema,
10527
10542
  capture: captureConfigSchema,
10543
+ live: liveConfigSchema,
10528
10544
  templatePath: external_exports.union([
10529
10545
  external_exports.string().regex(/\.hbs$/, "templatePath must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "templatePath basename must not be empty"),
10530
10546
  external_exports.array(
@@ -10890,13 +10906,21 @@ var LicenseClient = class {
10890
10906
  this.autoUpdateModel = opts.autoUpdateModel ?? true;
10891
10907
  this.modelVersionForServer = this.autoUpdateModel ? opts.currentModelVersion ?? 0 : Number.MAX_SAFE_INTEGER;
10892
10908
  }
10893
- async resolve() {
10909
+ /**
10910
+ * @param opts.requireFeature When set, a fresh cached JWT that does NOT carry
10911
+ * this feature is bypassed in favour of a network refresh — so a token issued
10912
+ * before the feature (e.g. `'live'`) was added to entitlements doesn't gate it
10913
+ * off until natural TTL expiry. If the refresh also lacks it, the caller gets
10914
+ * a LicenseInfo without the feature (genuinely not entitled).
10915
+ */
10916
+ async resolve(opts = {}) {
10894
10917
  const key = this.rawKey || process.env.RF_LICENSE_KEY?.trim();
10895
10918
  const init = this._validateKey(key);
10896
10919
  if (!init) return null;
10897
10920
  const { normalized, verifier } = init;
10898
10921
  const { cacheValid, cached, cacheFresh, nowMs } = this._readCache(verifier);
10899
- if (cacheFresh && cacheValid && cached) {
10922
+ const cacheHasFeature = !opts.requireFeature || (cacheValid?.features?.includes(opts.requireFeature) ?? false);
10923
+ if (cacheFresh && cacheValid && cached && cacheHasFeature) {
10900
10924
  logger.debug("license path=jwt-cache", {
10901
10925
  exp: new Date(cacheValid.exp * 1e3).toISOString(),
10902
10926
  machinesUsed: cached.machinesUsed,
@@ -10961,7 +10985,8 @@ var LicenseClient = class {
10961
10985
  lastRefresh: Math.floor(Date.now() / 1e3),
10962
10986
  fingerprintHash: this.fingerprint.hash,
10963
10987
  machinesUsed: result.machinesUsed,
10964
- machineLimit: result.machineLimit
10988
+ machineLimit: result.machineLimit,
10989
+ features: payload.features
10965
10990
  };
10966
10991
  writeCache(entry);
10967
10992
  logger.debug(`license path=${cached ? "refresh" : "activate"} response`, {
@@ -10979,7 +11004,8 @@ var LicenseClient = class {
10979
11004
  jwt: result.jwt,
10980
11005
  expiry: new Date(payload.exp * 1e3),
10981
11006
  machinesUsed: result.machinesUsed,
10982
- machineLimit: result.machineLimit
11007
+ machineLimit: result.machineLimit,
11008
+ features: payload.features
10983
11009
  };
10984
11010
  } catch (err) {
10985
11011
  const msg = err.message;
@@ -11067,7 +11093,11 @@ function toLicenseInfo(payload, cached, normalizedKey, source) {
11067
11093
  // caches written by older reporter versions before these fields were
11068
11094
  // persisted (the next refresh will populate them).
11069
11095
  machinesUsed: cached.machinesUsed,
11070
- machineLimit: cached.machineLimit
11096
+ machineLimit: cached.machineLimit,
11097
+ // Prefer the signed `features` claim (authenticated) over the unauthenticated
11098
+ // sidecar — falls back to the sidecar only for caches written before the
11099
+ // payload was threaded through here.
11100
+ features: payload.features ?? cached.features
11071
11101
  };
11072
11102
  }
11073
11103
  function normalizeUrl(url) {
@@ -11407,6 +11437,7 @@ var DEFAULT_MAX_STEPS = 50;
11407
11437
  var DEFAULT_MAX_CONSOLE_LINES = 50;
11408
11438
  var PW_API_WINDOW = 10;
11409
11439
  var isTeardown = (category) => category === "hook" || category === "fixture";
11440
+ var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11410
11441
  function flatten(steps) {
11411
11442
  const out = [];
11412
11443
  const walk = (nodes, depth, ancestors) => {
@@ -11425,7 +11456,7 @@ function flatten(steps) {
11425
11456
  walk(steps ?? [], 0, []);
11426
11457
  return out;
11427
11458
  }
11428
- function materialize(n) {
11459
+ function materialize(n, isFailing) {
11429
11460
  const loc = n.raw.location;
11430
11461
  return {
11431
11462
  title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
@@ -11433,17 +11464,16 @@ function materialize(n) {
11433
11464
  durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11434
11465
  depth: n.depth,
11435
11466
  failed: n.hasError,
11436
- errorMessage: n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
11467
+ errorMessage: isFailing && n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
11437
11468
  location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11438
11469
  };
11439
11470
  }
11440
11471
  function pickFailingIdx(flat) {
11441
- const inTeardown = (f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11442
11472
  const pick = (allowTeardown) => {
11443
11473
  let idx = -1;
11444
11474
  for (let i = 0; i < flat.length; i++) {
11445
11475
  const f = flat[i];
11446
- if (!f.hasError || !allowTeardown && inTeardown(f)) continue;
11476
+ if (!f.hasError || !allowTeardown && inHookSubtree(flat, f)) continue;
11447
11477
  if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
11448
11478
  }
11449
11479
  return idx;
@@ -11466,7 +11496,7 @@ function compactSteps(rawSteps, opts = {}) {
11466
11496
  if (opts.apiSteps) {
11467
11497
  const pwApiBefore = [];
11468
11498
  for (let i = 0; i < failingIdx; i++) {
11469
- if (flat[i].category === "pw:api") pwApiBefore.push(i);
11499
+ if (flat[i].category === "pw:api" && !inHookSubtree(flat, flat[i])) pwApiBefore.push(i);
11470
11500
  }
11471
11501
  for (const i of pwApiBefore.slice(-PW_API_WINDOW)) keep.add(i);
11472
11502
  }
@@ -11482,7 +11512,7 @@ function compactSteps(rawSteps, opts = {}) {
11482
11512
  const mat = (i) => {
11483
11513
  let es = cache.get(i);
11484
11514
  if (!es) {
11485
- es = materialize(flat[i]);
11515
+ es = materialize(flat[i], i === failingIdx);
11486
11516
  cache.set(i, es);
11487
11517
  }
11488
11518
  return es;
@@ -11686,6 +11716,7 @@ function sortBySeverity(failures) {
11686
11716
  // src/utils/env.ts
11687
11717
  init_cjs_shims();
11688
11718
  var import_child_process = require("child_process");
11719
+ var import_crypto5 = require("crypto");
11689
11720
  var COMMIT_HASH_LENGTH = 8;
11690
11721
  function detectCiEnv() {
11691
11722
  const env = process.env;
@@ -11756,6 +11787,34 @@ function detectCiEnv() {
11756
11787
  ciProvider: "local"
11757
11788
  };
11758
11789
  }
11790
+ var CI_RUN_SOURCES = [
11791
+ { provider: "github.com", repoKeys: ["GITHUB_REPOSITORY"], runKeys: ["GITHUB_RUN_ID"], attemptKeys: ["GITHUB_RUN_ATTEMPT"] },
11792
+ { provider: "gitlab.com", repoKeys: ["CI_PROJECT_PATH"], runKeys: ["CI_PIPELINE_ID"] },
11793
+ { provider: "circleci.com", repoKeys: ["CIRCLE_PROJECT_REPONAME"], runKeys: ["CIRCLE_WORKFLOW_ID"] },
11794
+ { provider: "buildkite.com", repoKeys: ["BUILDKITE_REPO"], runKeys: ["BUILDKITE_BUILD_ID"] },
11795
+ { provider: "dev.azure.com", repoKeys: ["BUILD_REPOSITORY_NAME"], runKeys: ["BUILD_BUILDID"] },
11796
+ { provider: "bitbucket.org", repoKeys: ["BITBUCKET_REPO_FULL_NAME"], runKeys: ["BITBUCKET_PIPELINE_UUID"] },
11797
+ { provider: "jenkins", repoKeys: ["JOB_NAME"], runKeys: ["BUILD_NUMBER"] }
11798
+ ];
11799
+ var RUN_ID_FALLBACK_SECRET = "rf-live-runid";
11800
+ function detectCiRunId() {
11801
+ for (const src of CI_RUN_SOURCES) {
11802
+ const runId = firstEnv(src.runKeys);
11803
+ if (!runId) continue;
11804
+ const repo = firstEnv(src.repoKeys) ?? "unknown";
11805
+ const attempt = src.attemptKeys ? firstEnv(src.attemptKeys) ?? "1" : "1";
11806
+ const secret = process.env.RF_LICENSE_KEY?.trim() || RUN_ID_FALLBACK_SECRET;
11807
+ return (0, import_crypto5.createHmac)("sha256", secret).update(`${src.provider}:${repo}:${runId}:${attempt}`).digest("hex").slice(0, 32);
11808
+ }
11809
+ return null;
11810
+ }
11811
+ function firstEnv(keys) {
11812
+ for (const k of keys) {
11813
+ const v = process.env[k]?.trim();
11814
+ if (v) return v;
11815
+ }
11816
+ return void 0;
11817
+ }
11759
11818
  function gitBranch() {
11760
11819
  try {
11761
11820
  return (0, import_child_process.execSync)("git rev-parse --abbrev-ref HEAD", { stdio: "pipe" }).toString().trim();
@@ -12678,11 +12737,11 @@ init_cjs_shims();
12678
12737
  var import_fs7 = __toESM(require("fs"));
12679
12738
  var import_os4 = __toESM(require("os"));
12680
12739
  var import_path4 = __toESM(require("path"));
12681
- var import_crypto5 = __toESM(require("crypto"));
12740
+ var import_crypto6 = __toESM(require("crypto"));
12682
12741
  var HtmlWriter = class {
12683
12742
  async write(html) {
12684
12743
  const tmpDir = import_os4.default.tmpdir();
12685
- const filename = `rf-report-${import_crypto5.default.randomBytes(8).toString("hex")}.html`;
12744
+ const filename = `rf-report-${import_crypto6.default.randomBytes(8).toString("hex")}.html`;
12686
12745
  this.tempPath = import_path4.default.join(tmpDir, filename);
12687
12746
  await import_fs7.default.promises.writeFile(this.tempPath, html, "utf8");
12688
12747
  return this.tempPath;
@@ -12726,7 +12785,7 @@ var import_child_process3 = require("child_process");
12726
12785
  var import_fs8 = __toESM(require("fs"));
12727
12786
  var import_path5 = __toESM(require("path"));
12728
12787
  var import_os5 = __toESM(require("os"));
12729
- var import_crypto6 = __toESM(require("crypto"));
12788
+ var import_crypto7 = __toESM(require("crypto"));
12730
12789
  var PdfEncryptor = class {
12731
12790
  async encrypt(pdfPath, password) {
12732
12791
  if (!password) return;
@@ -12737,7 +12796,7 @@ var PdfEncryptor = class {
12737
12796
  return;
12738
12797
  }
12739
12798
  const tmpPath = import_path5.default.join(import_os5.default.tmpdir(), `rf-encrypted-${Date.now()}.pdf`);
12740
- const pwFile = import_path5.default.join(import_os5.default.tmpdir(), `rf-pw-${import_crypto6.default.randomBytes(8).toString("hex")}`);
12799
+ const pwFile = import_path5.default.join(import_os5.default.tmpdir(), `rf-pw-${import_crypto7.default.randomBytes(8).toString("hex")}`);
12741
12800
  try {
12742
12801
  import_fs8.default.writeFileSync(pwFile, `${password}
12743
12802
  ${password}`, { mode: 384 });
@@ -13488,14 +13547,313 @@ var NotificationSender = class {
13488
13547
  }
13489
13548
  };
13490
13549
 
13550
+ // src/live/LiveStreamer.ts
13551
+ init_cjs_shims();
13552
+ var NETWORK_TIMEOUT_MS2 = 8e3;
13553
+ var LIVE_DRAIN_DEADLINE_MS = 1e4;
13554
+ var MAX_FLUSH_MS = 3e4;
13555
+ var WARN_THROTTLE_MS = 3e4;
13556
+ var LiveStreamer = class {
13557
+ constructor(config) {
13558
+ this.config = config;
13559
+ this.testBuffer = [];
13560
+ this.eventBuffer = [];
13561
+ /** Monotonic per-shard event counter → stable `{shardKey}:{seq}` event ids. */
13562
+ this.eventSeq = 0;
13563
+ /**
13564
+ * Latest status per test id (last write wins), so retries don't inflate the
13565
+ * tallies: a flaky test that fails then passes is one entry, counted once.
13566
+ * Counts are derived from this map, matching the collector/PDF semantics.
13567
+ */
13568
+ this.testStates = /* @__PURE__ */ new Map();
13569
+ this.flushTimer = null;
13570
+ this.inFlight = /* @__PURE__ */ new Set();
13571
+ this.enabled = true;
13572
+ this.gated = false;
13573
+ this.gatePromise = null;
13574
+ this.jwt = null;
13575
+ this.envSent = false;
13576
+ this.lastWarnMs = 0;
13577
+ this.serverUrl = config.serverUrl.endsWith("/") ? config.serverUrl.slice(0, -1) : config.serverUrl;
13578
+ this.flushMs = config.flushMs ?? 2e3;
13579
+ this.flushMaxBatch = config.maxBatch ?? 100;
13580
+ this.maxInFlight = config.maxInFlight ?? 4;
13581
+ this.maxPreLicenseBuffer = config.maxPreLicenseBuffer ?? 2e3;
13582
+ }
13583
+ enqueueTest(update) {
13584
+ if (!this.enabled) return;
13585
+ this.testStates.set(update.id, { status: update.status, retry: update.retry });
13586
+ this.testBuffer.push(update);
13587
+ this.capPreLicenseBuffer();
13588
+ this.scheduleFlush();
13589
+ }
13590
+ enqueueEvent(event) {
13591
+ if (!this.enabled) return;
13592
+ event.id = `${this.config.shardKey}:${this.eventSeq++}`;
13593
+ this.eventBuffer.push(event);
13594
+ this.capPreLicenseBuffer();
13595
+ this.scheduleFlush();
13596
+ }
13597
+ /**
13598
+ * Final flush: cancels the pending timer, emits the terminal `finished` batch,
13599
+ * then awaits in-flight requests bounded by LIVE_DRAIN_DEADLINE_MS. Called from
13600
+ * the reporter's `onExit`. The bound is the #16644 fix — a hung server cannot
13601
+ * hold the process open past the deadline.
13602
+ */
13603
+ async drain() {
13604
+ if (this.flushTimer) {
13605
+ clearTimeout(this.flushTimer);
13606
+ this.flushTimer = null;
13607
+ }
13608
+ await this.flush("finished");
13609
+ await Promise.race([
13610
+ Promise.allSettled([...this.inFlight]),
13611
+ sleep(LIVE_DRAIN_DEADLINE_MS)
13612
+ ]);
13613
+ }
13614
+ // ─── internals ────────────────────────────────────────────────────────────
13615
+ snapshotCounts() {
13616
+ const c = {
13617
+ total: this.testStates.size,
13618
+ running: 0,
13619
+ passed: 0,
13620
+ failed: 0,
13621
+ timedOut: 0,
13622
+ skipped: 0,
13623
+ flaky: 0
13624
+ };
13625
+ for (const { status, retry } of this.testStates.values()) {
13626
+ switch (status) {
13627
+ case "running":
13628
+ c.running++;
13629
+ break;
13630
+ case "passed":
13631
+ retry && retry > 0 ? c.flaky++ : c.passed++;
13632
+ break;
13633
+ case "failed":
13634
+ c.failed++;
13635
+ break;
13636
+ case "timedOut":
13637
+ c.timedOut++;
13638
+ break;
13639
+ case "skipped":
13640
+ c.skipped++;
13641
+ break;
13642
+ case "interrupted":
13643
+ c.failed++;
13644
+ break;
13645
+ }
13646
+ }
13647
+ return c;
13648
+ }
13649
+ /** Drop-oldest cap on buffered items while the license gate is still pending. */
13650
+ capPreLicenseBuffer() {
13651
+ if (this.gated) return;
13652
+ let overflow = this.testBuffer.length + this.eventBuffer.length - this.maxPreLicenseBuffer;
13653
+ if (overflow <= 0) return;
13654
+ const dropEvents = Math.min(overflow, this.eventBuffer.length);
13655
+ if (dropEvents > 0) this.eventBuffer.splice(0, dropEvents);
13656
+ overflow -= dropEvents;
13657
+ if (overflow > 0) this.testBuffer.splice(0, overflow);
13658
+ }
13659
+ scheduleFlush() {
13660
+ if (this.testBuffer.length + this.eventBuffer.length >= this.flushMaxBatch) {
13661
+ void this.flush("running");
13662
+ return;
13663
+ }
13664
+ if (this.flushTimer) return;
13665
+ this.flushTimer = setTimeout(() => {
13666
+ this.flushTimer = null;
13667
+ void this.flush("running");
13668
+ }, this.flushMs);
13669
+ if (typeof this.flushTimer.unref === "function") this.flushTimer.unref();
13670
+ }
13671
+ async flush(status) {
13672
+ if (!this.enabled) return;
13673
+ const entitled = await this.ensureGate();
13674
+ if (!entitled) return;
13675
+ if (status !== "finished" && this.inFlight.size >= this.maxInFlight) return;
13676
+ const tests = this.testBuffer;
13677
+ const events = this.eventBuffer;
13678
+ if (status !== "finished" && tests.length === 0 && events.length === 0) return;
13679
+ this.testBuffer = [];
13680
+ this.eventBuffer = [];
13681
+ const body = {
13682
+ runId: this.config.runId,
13683
+ shardKey: this.config.shardKey,
13684
+ shardTotal: this.config.shardTotal,
13685
+ status,
13686
+ counts: this.snapshotCounts(),
13687
+ tests,
13688
+ events,
13689
+ // Keep attaching env until a send actually succeeds (envSent flips in
13690
+ // send()), so a failed first request doesn't lose the run's CI context.
13691
+ ...this.envSent ? {} : { env: this.config.env }
13692
+ };
13693
+ const p = this.send(body).finally(() => {
13694
+ this.inFlight.delete(p);
13695
+ });
13696
+ this.inFlight.add(p);
13697
+ }
13698
+ /** Runs the license entitlement check exactly once, memoized. */
13699
+ ensureGate() {
13700
+ return this.gatePromise ?? (this.gatePromise = this.runGate());
13701
+ }
13702
+ async runGate() {
13703
+ let info = null;
13704
+ try {
13705
+ info = await this.config.license();
13706
+ } catch {
13707
+ info = null;
13708
+ }
13709
+ this.gated = true;
13710
+ const features = info?.features ?? [];
13711
+ if (!info || !info.jwt || !features.includes("live")) {
13712
+ this.enabled = false;
13713
+ this.testBuffer = [];
13714
+ this.eventBuffer = [];
13715
+ this.warn("Live streaming disabled \u2014 no active license or live entitlement.");
13716
+ return false;
13717
+ }
13718
+ this.jwt = info.jwt;
13719
+ if (this.config.watchUrl) logger.info(`Live results: ${this.config.watchUrl}`);
13720
+ return true;
13721
+ }
13722
+ async send(body) {
13723
+ if (!this.jwt) return;
13724
+ const controller = new AbortController();
13725
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
13726
+ try {
13727
+ const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
13728
+ method: "POST",
13729
+ headers: {
13730
+ "content-type": "application/json",
13731
+ authorization: `Bearer ${this.jwt}`
13732
+ },
13733
+ body: JSON.stringify(body),
13734
+ signal: controller.signal
13735
+ });
13736
+ await res.body?.cancel().catch(() => {
13737
+ });
13738
+ if (res.status === 429) {
13739
+ this.flushMs = Math.min(this.flushMs * 2, MAX_FLUSH_MS);
13740
+ return;
13741
+ }
13742
+ if (!res.ok) {
13743
+ this.warn(`Live ingest returned HTTP ${res.status}.`);
13744
+ return;
13745
+ }
13746
+ if (body.env) this.envSent = true;
13747
+ } catch (err) {
13748
+ this.warn(`Live ingest request failed: ${err.message}`);
13749
+ } finally {
13750
+ clearTimeout(timeout);
13751
+ }
13752
+ }
13753
+ warn(msg) {
13754
+ const now = Date.now();
13755
+ if (now - this.lastWarnMs < WARN_THROTTLE_MS) return;
13756
+ this.lastWarnMs = now;
13757
+ logger.warn(msg);
13758
+ }
13759
+ };
13760
+ function sleep(ms) {
13761
+ return new Promise((resolve) => {
13762
+ const t = setTimeout(resolve, ms);
13763
+ if (typeof t.unref === "function") t.unref();
13764
+ });
13765
+ }
13766
+
13767
+ // src/live/runId.ts
13768
+ init_cjs_shims();
13769
+ var import_crypto8 = require("crypto");
13770
+ var import_crypto9 = require("crypto");
13771
+ var WATCH_TOKEN_HEX_LEN = 32;
13772
+ function deriveRunId(opts) {
13773
+ const explicit = opts.runId?.trim();
13774
+ if (explicit) return explicit;
13775
+ const fromEnv = process.env.RF_LIVE_RUN_ID?.trim();
13776
+ if (fromEnv) return fromEnv;
13777
+ return detectCiRunId() ?? (0, import_crypto9.randomUUID)();
13778
+ }
13779
+ function computeWatchToken(runId) {
13780
+ const secret = process.env.RF_LICENSE_KEY?.trim();
13781
+ if (!secret) return null;
13782
+ return (0, import_crypto8.createHmac)("sha256", secret).update(`live:${runId}`).digest("hex").slice(0, WATCH_TOKEN_HEX_LEN);
13783
+ }
13784
+ function buildWatchUrl(serverUrl, runId, token) {
13785
+ const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
13786
+ return `${base}/live/${encodeURIComponent(runId)}?t=${encodeURIComponent(token)}`;
13787
+ }
13788
+ function shardKeyOf(shard) {
13789
+ if (!shard || !shard.total) return "1/1";
13790
+ return `${shard.current}/${shard.total}`;
13791
+ }
13792
+
13793
+ // src/live/event-map.ts
13794
+ init_cjs_shims();
13795
+ var MAX_TITLE_CHARS2 = 200;
13796
+ var MAX_STEP_TITLE_CHARS = 200;
13797
+ var MAX_CONSOLE_CHARS = 2048;
13798
+ function mapTestBegin(test, shardKey) {
13799
+ return {
13800
+ id: test.id,
13801
+ title: clampWithEllipsis(test.title ?? "", MAX_TITLE_CHARS2),
13802
+ status: "running",
13803
+ shardKey
13804
+ };
13805
+ }
13806
+ function mapTestEnd(test, result, shardKey) {
13807
+ return {
13808
+ id: test.id,
13809
+ status: result.status,
13810
+ durationMs: result.duration,
13811
+ retry: result.retry,
13812
+ shardKey
13813
+ };
13814
+ }
13815
+ function shouldEmitStep(step, mode) {
13816
+ if (mode === "none") return false;
13817
+ if (!step.title) return false;
13818
+ if (mode === "all") return true;
13819
+ if (step.error) return true;
13820
+ return step.category === "test.step" || step.category === "expect";
13821
+ }
13822
+ function mapStep(testId, step, phase, shardKey) {
13823
+ return {
13824
+ kind: "step",
13825
+ testId,
13826
+ title: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS),
13827
+ status: phase === "begin" ? "running" : step.error ? "failed" : "passed",
13828
+ shardKey,
13829
+ at: Date.now()
13830
+ };
13831
+ }
13832
+ function mapConsole(kind, chunk, testId, shardKey) {
13833
+ return {
13834
+ kind,
13835
+ testId,
13836
+ text: clampWithEllipsis(chunkToString2(chunk), MAX_CONSOLE_CHARS),
13837
+ shardKey,
13838
+ at: Date.now()
13839
+ };
13840
+ }
13841
+ function chunkToString2(chunk) {
13842
+ return typeof chunk === "string" ? chunk : chunk.toString("utf8");
13843
+ }
13844
+
13491
13845
  // src/reporter.ts
13846
+ var DEFAULT_SERVER_URL2 = "https://reportforge.org";
13492
13847
  var PdfReporter = class {
13493
13848
  constructor(rawOptions = {}) {
13494
13849
  this.cwd = process.cwd();
13495
13850
  this.pdfGenerator = new PdfGenerator();
13851
+ this.liveShardKey = "1/1";
13852
+ this.liveSteps = "failed";
13853
+ this.liveConsole = false;
13496
13854
  this.options = parseOptions(rawOptions);
13497
13855
  this.dataCollector = new DataCollector(this.options.capture);
13498
- const version = true ? "0.8.0" : "0.x";
13856
+ const version = true ? "0.9.0" : "0.x";
13499
13857
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13500
13858
  this.licenseClient = new LicenseClient({
13501
13859
  licenseKey: this.options.licenseKey,
@@ -13506,12 +13864,65 @@ var PdfReporter = class {
13506
13864
  }
13507
13865
  onBegin(config, suite) {
13508
13866
  this.dataCollector.onBegin(config, suite);
13867
+ if (!this.options.live.enabled) return;
13868
+ try {
13869
+ this.setupLive(config);
13870
+ } catch (err) {
13871
+ logger.warn(`Live setup failed \u2014 streaming disabled: ${err.message}`);
13872
+ }
13873
+ }
13874
+ onTestBegin(test) {
13875
+ if (!this.liveStreamer) return;
13876
+ try {
13877
+ this.liveStreamer.enqueueTest(mapTestBegin(test, this.liveShardKey));
13878
+ } catch {
13879
+ }
13509
13880
  }
13510
13881
  onTestEnd(test, result) {
13511
13882
  this.dataCollector.onTestEnd(test, result);
13883
+ if (!this.liveStreamer) return;
13884
+ try {
13885
+ this.liveStreamer.enqueueTest(mapTestEnd(test, result, this.liveShardKey));
13886
+ } catch {
13887
+ }
13888
+ }
13889
+ onStepBegin(test, _result, step) {
13890
+ if (!this.liveStreamer || !shouldEmitStep(step, this.liveSteps)) return;
13891
+ try {
13892
+ this.liveStreamer.enqueueEvent(mapStep(test.id, step, "begin", this.liveShardKey));
13893
+ } catch {
13894
+ }
13895
+ }
13896
+ onStepEnd(test, _result, step) {
13897
+ if (!this.liveStreamer || !shouldEmitStep(step, this.liveSteps)) return;
13898
+ try {
13899
+ this.liveStreamer.enqueueEvent(mapStep(test.id, step, "end", this.liveShardKey));
13900
+ } catch {
13901
+ }
13902
+ }
13903
+ onStdOut(chunk, test) {
13904
+ if (!this.liveStreamer || !this.liveConsole) return;
13905
+ try {
13906
+ this.liveStreamer.enqueueEvent(mapConsole("stdout", chunk, test?.id, this.liveShardKey));
13907
+ } catch {
13908
+ }
13909
+ }
13910
+ onStdErr(chunk, test) {
13911
+ if (!this.liveStreamer || !this.liveConsole) return;
13912
+ try {
13913
+ this.liveStreamer.enqueueEvent(mapConsole("stderr", chunk, test?.id, this.liveShardKey));
13914
+ } catch {
13915
+ }
13916
+ }
13917
+ async onExit() {
13918
+ if (!this.liveStreamer) return;
13919
+ try {
13920
+ await this.liveStreamer.drain();
13921
+ } catch {
13922
+ }
13512
13923
  }
13513
13924
  async onEnd(result) {
13514
- const licenseInfo = await this.resolveLicense();
13925
+ const licenseInfo = await this.license();
13515
13926
  if (!licenseInfo) return;
13516
13927
  const collected = this._collectData(result);
13517
13928
  if (!collected) return;
@@ -13618,9 +14029,18 @@ var PdfReporter = class {
13618
14029
  printsToStdio() {
13619
14030
  return false;
13620
14031
  }
14032
+ /**
14033
+ * Memoized license resolution shared by the live stream (kicked early in
14034
+ * onBegin) and PDF generation (awaited in onEnd) — at most one network trip.
14035
+ */
14036
+ license() {
14037
+ return this.licensePromise ?? (this.licensePromise = this.resolveLicense());
14038
+ }
13621
14039
  async resolveLicense() {
13622
14040
  try {
13623
- return await this.licenseClient.resolve();
14041
+ return await this.licenseClient.resolve(
14042
+ this.options.live.enabled ? { requireFeature: "live" } : {}
14043
+ );
13624
14044
  } catch (err) {
13625
14045
  logger.warn(
13626
14046
  `License resolution errored: ${err.message}. PDF generation skipped.`
@@ -13628,6 +14048,33 @@ var PdfReporter = class {
13628
14048
  return null;
13629
14049
  }
13630
14050
  }
14051
+ /**
14052
+ * Wires up live streaming: derives the run id (shared by all shards), kicks
14053
+ * the license resolution early, builds the streamer, and prints the watch
14054
+ * link to the CI logs. Throws are caught by the onBegin try/catch.
14055
+ */
14056
+ setupLive(config) {
14057
+ const live = this.options.live;
14058
+ const runId = deriveRunId({ runId: live.runId });
14059
+ const serverUrl = live.serverUrl ?? this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
14060
+ const shard = config.shard ?? null;
14061
+ this.liveShardKey = shardKeyOf(shard);
14062
+ this.liveSteps = live.steps;
14063
+ this.liveConsole = live.console;
14064
+ void this.license();
14065
+ const token = computeWatchToken(runId);
14066
+ const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14067
+ this.liveStreamer = new LiveStreamer({
14068
+ serverUrl,
14069
+ runId,
14070
+ shardKey: this.liveShardKey,
14071
+ shardTotal: shard?.total ?? null,
14072
+ license: () => this.license(),
14073
+ env: detectCiEnv(),
14074
+ watchUrl,
14075
+ flushMs: live.flushMs
14076
+ });
14077
+ }
13631
14078
  resolveProjectName() {
13632
14079
  try {
13633
14080
  const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");