@reportforge/playwright-pdf 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) {
@@ -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 });
@@ -13260,6 +13319,22 @@ _${name}_`
13260
13319
 
13261
13320
  // src/notify/formatters/teams.ts
13262
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
+ }
13263
13338
  function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13264
13339
  const label = statusLabel(stats.verdict);
13265
13340
  const name = reportName(pdfPaths, reportTitle);
@@ -13466,6 +13541,30 @@ var NotificationSender = class {
13466
13541
  }
13467
13542
  return result;
13468
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
+ }
13469
13568
  async post(name, url, payload, result) {
13470
13569
  const controller = new AbortController();
13471
13570
  const timeoutId = setTimeout(() => controller.abort(), 1e4);
@@ -13478,24 +13577,341 @@ var NotificationSender = class {
13478
13577
  });
13479
13578
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
13480
13579
  logger.info(`[notify] ${name}: sent`);
13481
- result.sent.push(name);
13580
+ result?.sent.push(name);
13482
13581
  } catch (err) {
13483
13582
  logger.warn(`[notify] ${name}: ${err instanceof Error ? err.message : String(err)}`);
13484
- result.failed.push(name);
13583
+ result?.failed.push(name);
13485
13584
  } finally {
13486
13585
  clearTimeout(timeoutId);
13487
13586
  }
13488
13587
  }
13489
13588
  };
13490
13589
 
13590
+ // src/live/LiveStreamer.ts
13591
+ init_cjs_shims();
13592
+ var NETWORK_TIMEOUT_MS2 = 8e3;
13593
+ var LIVE_DRAIN_DEADLINE_MS = 1e4;
13594
+ var MAX_FLUSH_MS = 3e4;
13595
+ var WARN_THROTTLE_MS = 3e4;
13596
+ var LiveStreamer = class {
13597
+ constructor(config) {
13598
+ this.config = config;
13599
+ this.testBuffer = [];
13600
+ this.eventBuffer = [];
13601
+ /** Monotonic per-shard event counter → stable `{shardKey}:{seq}` event ids. */
13602
+ this.eventSeq = 0;
13603
+ /**
13604
+ * Latest status per test id (last write wins), so retries don't inflate the
13605
+ * tallies: a flaky test that fails then passes is one entry, counted once.
13606
+ * Counts are derived from this map, matching the collector/PDF semantics.
13607
+ */
13608
+ this.testStates = /* @__PURE__ */ new Map();
13609
+ this.flushTimer = null;
13610
+ this.inFlight = /* @__PURE__ */ new Set();
13611
+ this.enabled = true;
13612
+ this.gated = false;
13613
+ this.gatePromise = null;
13614
+ this.jwt = null;
13615
+ this.envSent = false;
13616
+ this.lastWarnMs = 0;
13617
+ this.serverUrl = config.serverUrl.endsWith("/") ? config.serverUrl.slice(0, -1) : config.serverUrl;
13618
+ this.flushMs = config.flushMs ?? 2e3;
13619
+ this.flushMaxBatch = config.maxBatch ?? 100;
13620
+ this.maxInFlight = config.maxInFlight ?? 4;
13621
+ this.maxPreLicenseBuffer = config.maxPreLicenseBuffer ?? 2e3;
13622
+ }
13623
+ enqueueTest(update) {
13624
+ if (!this.enabled) return;
13625
+ const prevRetry = this.testStates.get(update.id)?.retry;
13626
+ this.testStates.set(update.id, { status: update.status, retry: update.retry ?? prevRetry });
13627
+ this.testBuffer.push(update);
13628
+ this.capPreLicenseBuffer();
13629
+ this.scheduleFlush();
13630
+ }
13631
+ enqueueEvent(event) {
13632
+ if (!this.enabled) return;
13633
+ event.id = `${this.config.shardKey}:${this.eventSeq++}`;
13634
+ this.eventBuffer.push(event);
13635
+ this.capPreLicenseBuffer();
13636
+ this.scheduleFlush();
13637
+ }
13638
+ /**
13639
+ * Final flush: cancels the pending timer, emits the terminal `finished` batch,
13640
+ * then awaits in-flight requests bounded by LIVE_DRAIN_DEADLINE_MS. Called from
13641
+ * the reporter's `onExit`. The bound is the #16644 fix — a hung server cannot
13642
+ * hold the process open past the deadline.
13643
+ */
13644
+ async drain() {
13645
+ if (this.flushTimer) {
13646
+ clearTimeout(this.flushTimer);
13647
+ this.flushTimer = null;
13648
+ }
13649
+ await this.flush("finished");
13650
+ await Promise.race([
13651
+ Promise.allSettled([...this.inFlight]),
13652
+ sleep(LIVE_DRAIN_DEADLINE_MS)
13653
+ ]);
13654
+ }
13655
+ // ─── internals ────────────────────────────────────────────────────────────
13656
+ snapshotCounts() {
13657
+ const c = {
13658
+ total: this.testStates.size,
13659
+ running: 0,
13660
+ passed: 0,
13661
+ failed: 0,
13662
+ timedOut: 0,
13663
+ skipped: 0,
13664
+ flaky: 0
13665
+ };
13666
+ for (const { status, retry } of this.testStates.values()) {
13667
+ switch (status) {
13668
+ case "running":
13669
+ c.running++;
13670
+ break;
13671
+ case "passed":
13672
+ retry && retry > 0 ? c.flaky++ : c.passed++;
13673
+ break;
13674
+ case "failed":
13675
+ c.failed++;
13676
+ break;
13677
+ case "timedOut":
13678
+ c.timedOut++;
13679
+ break;
13680
+ case "skipped":
13681
+ c.skipped++;
13682
+ break;
13683
+ case "interrupted":
13684
+ c.failed++;
13685
+ break;
13686
+ }
13687
+ }
13688
+ return c;
13689
+ }
13690
+ /** Drop-oldest cap on buffered items while the license gate is still pending. */
13691
+ capPreLicenseBuffer() {
13692
+ if (this.gated) return;
13693
+ let overflow = this.testBuffer.length + this.eventBuffer.length - this.maxPreLicenseBuffer;
13694
+ if (overflow <= 0) return;
13695
+ const dropEvents = Math.min(overflow, this.eventBuffer.length);
13696
+ if (dropEvents > 0) this.eventBuffer.splice(0, dropEvents);
13697
+ overflow -= dropEvents;
13698
+ if (overflow > 0) this.testBuffer.splice(0, overflow);
13699
+ }
13700
+ scheduleFlush() {
13701
+ if (this.testBuffer.length + this.eventBuffer.length >= this.flushMaxBatch) {
13702
+ void this.flush("running");
13703
+ return;
13704
+ }
13705
+ if (this.flushTimer) return;
13706
+ this.flushTimer = setTimeout(() => {
13707
+ this.flushTimer = null;
13708
+ void this.flush("running");
13709
+ }, this.flushMs);
13710
+ if (typeof this.flushTimer.unref === "function") this.flushTimer.unref();
13711
+ }
13712
+ async flush(status) {
13713
+ if (!this.enabled) return;
13714
+ const entitled = await this.ensureGate();
13715
+ if (!entitled) return;
13716
+ if (status !== "finished" && this.inFlight.size >= this.maxInFlight) return;
13717
+ const tests = this.testBuffer;
13718
+ const events = this.eventBuffer;
13719
+ if (status !== "finished" && tests.length === 0 && events.length === 0) return;
13720
+ this.testBuffer = [];
13721
+ this.eventBuffer = [];
13722
+ const body = {
13723
+ runId: this.config.runId,
13724
+ shardKey: this.config.shardKey,
13725
+ shardTotal: this.config.shardTotal,
13726
+ status,
13727
+ counts: this.snapshotCounts(),
13728
+ tests,
13729
+ events,
13730
+ // Keep attaching env until a send actually succeeds (envSent flips in
13731
+ // send()), so a failed first request doesn't lose the run's CI context.
13732
+ ...this.envSent ? {} : { env: this.config.env }
13733
+ };
13734
+ const p = this.send(body).finally(() => {
13735
+ this.inFlight.delete(p);
13736
+ });
13737
+ this.inFlight.add(p);
13738
+ }
13739
+ /** Runs the license entitlement check exactly once, memoized. */
13740
+ ensureGate() {
13741
+ return this.gatePromise ?? (this.gatePromise = this.runGate());
13742
+ }
13743
+ async runGate() {
13744
+ let info = null;
13745
+ try {
13746
+ info = await this.config.license();
13747
+ } catch {
13748
+ info = null;
13749
+ }
13750
+ this.gated = true;
13751
+ const features = info?.features ?? [];
13752
+ if (!info || !info.jwt || !features.includes("live")) {
13753
+ this.enabled = false;
13754
+ this.testBuffer = [];
13755
+ this.eventBuffer = [];
13756
+ this.warn("Live streaming disabled \u2014 no active license or live entitlement.");
13757
+ return false;
13758
+ }
13759
+ this.jwt = info.jwt;
13760
+ return true;
13761
+ }
13762
+ async send(body) {
13763
+ if (!this.jwt) return;
13764
+ const controller = new AbortController();
13765
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
13766
+ try {
13767
+ const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
13768
+ method: "POST",
13769
+ headers: {
13770
+ "content-type": "application/json",
13771
+ authorization: `Bearer ${this.jwt}`
13772
+ },
13773
+ body: JSON.stringify(body),
13774
+ signal: controller.signal
13775
+ });
13776
+ await res.body?.cancel().catch(() => {
13777
+ });
13778
+ if (res.status === 429) {
13779
+ this.flushMs = Math.min(this.flushMs * 2, MAX_FLUSH_MS);
13780
+ return;
13781
+ }
13782
+ if (!res.ok) {
13783
+ this.warn(`Live ingest returned HTTP ${res.status}.`);
13784
+ return;
13785
+ }
13786
+ if (body.env) this.envSent = true;
13787
+ } catch (err) {
13788
+ this.warn(`Live ingest request failed: ${err.message}`);
13789
+ } finally {
13790
+ clearTimeout(timeout);
13791
+ }
13792
+ }
13793
+ warn(msg) {
13794
+ const now = Date.now();
13795
+ if (now - this.lastWarnMs < WARN_THROTTLE_MS) return;
13796
+ this.lastWarnMs = now;
13797
+ logger.warn(msg);
13798
+ }
13799
+ };
13800
+ function sleep(ms) {
13801
+ return new Promise((resolve) => {
13802
+ const t = setTimeout(resolve, ms);
13803
+ if (typeof t.unref === "function") t.unref();
13804
+ });
13805
+ }
13806
+
13807
+ // src/live/runId.ts
13808
+ init_cjs_shims();
13809
+ var import_crypto8 = require("crypto");
13810
+ var import_crypto9 = require("crypto");
13811
+ var WATCH_TOKEN_HEX_LEN = 32;
13812
+ function deriveRunId(opts) {
13813
+ const explicit = opts.runId?.trim();
13814
+ if (explicit) return explicit;
13815
+ const fromEnv = process.env.RF_LIVE_RUN_ID?.trim();
13816
+ if (fromEnv) return fromEnv;
13817
+ return detectCiRunId() ?? (0, import_crypto9.randomUUID)();
13818
+ }
13819
+ function computeWatchToken(runId) {
13820
+ const secret = process.env.RF_LICENSE_KEY?.trim();
13821
+ if (!secret) return null;
13822
+ return (0, import_crypto8.createHmac)("sha256", secret).update(`live:${runId}`).digest("hex").slice(0, WATCH_TOKEN_HEX_LEN);
13823
+ }
13824
+ function buildWatchUrl(serverUrl, runId, token) {
13825
+ const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
13826
+ return `${base}/live/${encodeURIComponent(runId)}?t=${encodeURIComponent(token)}`;
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
+ }
13835
+ function shardKeyOf(shard) {
13836
+ if (!shard || !shard.total) return "1/1";
13837
+ return `${shard.current}/${shard.total}`;
13838
+ }
13839
+
13840
+ // src/live/event-map.ts
13841
+ init_cjs_shims();
13842
+ var MAX_TITLE_CHARS2 = 200;
13843
+ var MAX_STEP_TITLE_CHARS = 200;
13844
+ var MAX_CONSOLE_CHARS = 2048;
13845
+ function mapTestBegin(test, shardKey) {
13846
+ return {
13847
+ id: test.id,
13848
+ title: clampWithEllipsis(test.title ?? "", MAX_TITLE_CHARS2),
13849
+ status: "running",
13850
+ shardKey
13851
+ };
13852
+ }
13853
+ function mapTestEnd(test, result, shardKey) {
13854
+ return {
13855
+ id: test.id,
13856
+ status: result.status,
13857
+ durationMs: result.duration,
13858
+ retry: result.retry,
13859
+ shardKey
13860
+ };
13861
+ }
13862
+ function shouldEmitStep(step, mode) {
13863
+ if (mode === "none") return false;
13864
+ if (!step.title) return false;
13865
+ if (mode === "all") return true;
13866
+ if (step.error) return true;
13867
+ return step.category === "test.step" || step.category === "expect";
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
+ }
13880
+ function mapStep(testId, step, phase, shardKey) {
13881
+ return {
13882
+ kind: "step",
13883
+ testId,
13884
+ title: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS),
13885
+ status: phase === "begin" ? "running" : step.error ? "failed" : "passed",
13886
+ shardKey,
13887
+ at: Date.now()
13888
+ };
13889
+ }
13890
+ function mapConsole(kind, chunk, testId, shardKey) {
13891
+ return {
13892
+ kind,
13893
+ testId,
13894
+ text: clampWithEllipsis(chunkToString2(chunk), MAX_CONSOLE_CHARS),
13895
+ shardKey,
13896
+ at: Date.now()
13897
+ };
13898
+ }
13899
+ function chunkToString2(chunk) {
13900
+ return typeof chunk === "string" ? chunk : chunk.toString("utf8");
13901
+ }
13902
+
13491
13903
  // src/reporter.ts
13904
+ var DEFAULT_SERVER_URL2 = "https://reportforge.org";
13492
13905
  var PdfReporter = class {
13493
13906
  constructor(rawOptions = {}) {
13494
13907
  this.cwd = process.cwd();
13495
13908
  this.pdfGenerator = new PdfGenerator();
13909
+ this.liveShardKey = "1/1";
13910
+ this.liveSteps = "failed";
13911
+ this.liveConsole = false;
13496
13912
  this.options = parseOptions(rawOptions);
13497
13913
  this.dataCollector = new DataCollector(this.options.capture);
13498
- const version = true ? "0.8.1" : "0.x";
13914
+ const version = true ? "0.10.0" : "0.x";
13499
13915
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13500
13916
  this.licenseClient = new LicenseClient({
13501
13917
  licenseKey: this.options.licenseKey,
@@ -13506,12 +13922,70 @@ var PdfReporter = class {
13506
13922
  }
13507
13923
  onBegin(config, suite) {
13508
13924
  this.dataCollector.onBegin(config, suite);
13925
+ if (!this.options.live.enabled) return;
13926
+ try {
13927
+ this.setupLive(config);
13928
+ } catch (err) {
13929
+ logger.warn(`Live setup failed \u2014 streaming disabled: ${err.message}`);
13930
+ }
13931
+ }
13932
+ onTestBegin(test) {
13933
+ if (!this.liveStreamer) return;
13934
+ try {
13935
+ this.liveStreamer.enqueueTest(mapTestBegin(test, this.liveShardKey));
13936
+ } catch {
13937
+ }
13509
13938
  }
13510
13939
  onTestEnd(test, result) {
13511
13940
  this.dataCollector.onTestEnd(test, result);
13941
+ if (!this.liveStreamer) return;
13942
+ try {
13943
+ this.liveStreamer.enqueueTest(mapTestEnd(test, result, this.liveShardKey));
13944
+ } catch {
13945
+ }
13946
+ }
13947
+ onStepBegin(test, _result, step) {
13948
+ if (!this.liveStreamer) return;
13949
+ try {
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
+ }
13956
+ } catch {
13957
+ }
13958
+ }
13959
+ onStepEnd(test, _result, step) {
13960
+ if (!this.liveStreamer || !shouldEmitStep(step, this.liveSteps)) return;
13961
+ try {
13962
+ this.liveStreamer.enqueueEvent(mapStep(test.id, step, "end", this.liveShardKey));
13963
+ } catch {
13964
+ }
13965
+ }
13966
+ onStdOut(chunk, test) {
13967
+ if (!this.liveStreamer || !this.liveConsole) return;
13968
+ try {
13969
+ this.liveStreamer.enqueueEvent(mapConsole("stdout", chunk, test?.id, this.liveShardKey));
13970
+ } catch {
13971
+ }
13972
+ }
13973
+ onStdErr(chunk, test) {
13974
+ if (!this.liveStreamer || !this.liveConsole) return;
13975
+ try {
13976
+ this.liveStreamer.enqueueEvent(mapConsole("stderr", chunk, test?.id, this.liveShardKey));
13977
+ } catch {
13978
+ }
13979
+ }
13980
+ async onExit() {
13981
+ if (!this.liveStreamer) return;
13982
+ try {
13983
+ await this.liveStreamer.drain();
13984
+ } catch {
13985
+ }
13512
13986
  }
13513
13987
  async onEnd(result) {
13514
- const licenseInfo = await this.resolveLicense();
13988
+ const licenseInfo = await this.license();
13515
13989
  if (!licenseInfo) return;
13516
13990
  const collected = this._collectData(result);
13517
13991
  if (!collected) return;
@@ -13618,9 +14092,18 @@ var PdfReporter = class {
13618
14092
  printsToStdio() {
13619
14093
  return false;
13620
14094
  }
14095
+ /**
14096
+ * Memoized license resolution shared by the live stream (kicked early in
14097
+ * onBegin) and PDF generation (awaited in onEnd) — at most one network trip.
14098
+ */
14099
+ license() {
14100
+ return this.licensePromise ?? (this.licensePromise = this.resolveLicense());
14101
+ }
13621
14102
  async resolveLicense() {
13622
14103
  try {
13623
- return await this.licenseClient.resolve();
14104
+ return await this.licenseClient.resolve(
14105
+ this.options.live.enabled ? { requireFeature: "live" } : {}
14106
+ );
13624
14107
  } catch (err) {
13625
14108
  logger.warn(
13626
14109
  `License resolution errored: ${err.message}. PDF generation skipped.`
@@ -13628,6 +14111,54 @@ var PdfReporter = class {
13628
14111
  return null;
13629
14112
  }
13630
14113
  }
14114
+ /**
14115
+ * Wires up live streaming: derives the run id (shared by all shards), kicks
14116
+ * the license resolution early, builds the streamer, and prints the watch
14117
+ * link to the CI logs. Throws are caught by the onBegin try/catch.
14118
+ */
14119
+ setupLive(config) {
14120
+ const live = this.options.live;
14121
+ const runId = deriveRunId({ runId: live.runId });
14122
+ const serverUrl = live.serverUrl ?? this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
14123
+ const shard = config.shard ?? null;
14124
+ this.liveShardKey = shardKeyOf(shard);
14125
+ this.liveSteps = live.steps;
14126
+ this.liveConsole = live.console;
14127
+ void this.license();
14128
+ const token = computeWatchToken(runId);
14129
+ const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14130
+ const env = detectCiEnv();
14131
+ if (watchUrl) void this.announceLive(watchUrl, env, shard).catch(() => {
14132
+ });
14133
+ this.liveStreamer = new LiveStreamer({
14134
+ serverUrl,
14135
+ runId,
14136
+ shardKey: this.liveShardKey,
14137
+ shardTotal: shard?.total ?? null,
14138
+ license: () => this.license(),
14139
+ env,
14140
+ flushMs: live.flushMs
14141
+ });
14142
+ }
14143
+ /**
14144
+ * Announces the watch link once the run is confirmed live-entitled. Gating on
14145
+ * the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
14146
+ * feature) guarantees the link is never printed or posted for a run that will
14147
+ * never stream — no dead links. The chat ping fires from the primary shard only
14148
+ * (unsharded, or `shard.current === 1`) so an N-way matrix posts one message,
14149
+ * not N. The banner still prints from every shard's own log.
14150
+ */
14151
+ async announceLive(watchUrl, env, shard) {
14152
+ const info = await this.license();
14153
+ if (!info?.jwt || !info.features?.includes("live")) return;
14154
+ logger.info(`Live Tracker: ${watchUrl}`);
14155
+ console.log(formatLiveBanner(watchUrl));
14156
+ const isPrimaryShard = !shard || shard.current === 1;
14157
+ const notify = this.options.notify;
14158
+ if (isPrimaryShard && notify) {
14159
+ await new NotificationSender().sendLiveStarted(notify, watchUrl, env?.branch);
14160
+ }
14161
+ }
13631
14162
  resolveProjectName() {
13632
14163
  try {
13633
14164
  const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");