@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.mjs CHANGED
@@ -10485,6 +10485,21 @@ var captureConfigSchema = external_exports.object({
10485
10485
  maxSteps: external_exports.number().int().min(1).max(500).default(50),
10486
10486
  maxConsoleLines: external_exports.number().int().min(1).max(1e3).default(50)
10487
10487
  }).default({});
10488
+ var liveConfigSchema = external_exports.object({
10489
+ enabled: external_exports.boolean().default(false),
10490
+ // Override the auto-derived run id. All shards of one run must share it.
10491
+ runId: external_exports.string().optional(),
10492
+ // Override the streaming/server base URL (defaults to the license serverUrl).
10493
+ serverUrl: external_exports.string().url().optional(),
10494
+ // Step verbosity: 'none' skips steps, 'failed' streams intent + assertions +
10495
+ // errored steps, 'all' streams every step.
10496
+ steps: external_exports.enum(["none", "failed", "all"]).default("failed"),
10497
+ // Stream test stdout/stderr tails. Off by default — may leak secrets printed
10498
+ // by tests to a token-holder.
10499
+ console: external_exports.boolean().default(false),
10500
+ // Debounce window for batched flushes (ms).
10501
+ flushMs: external_exports.number().int().min(500).max(1e4).default(2e3)
10502
+ }).default({});
10488
10503
  var sectionFlagsShape = Object.fromEntries(
10489
10504
  SECTION_KEYS.map((k) => [k, external_exports.boolean().optional()])
10490
10505
  );
@@ -10526,6 +10541,7 @@ var ReporterOptionsSchema = external_exports.object({
10526
10541
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10527
10542
  failureAnalysis: failureAnalysisConfigSchema,
10528
10543
  capture: captureConfigSchema,
10544
+ live: liveConfigSchema,
10529
10545
  templatePath: external_exports.union([
10530
10546
  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"),
10531
10547
  external_exports.array(
@@ -10891,13 +10907,21 @@ var LicenseClient = class {
10891
10907
  this.autoUpdateModel = opts.autoUpdateModel ?? true;
10892
10908
  this.modelVersionForServer = this.autoUpdateModel ? opts.currentModelVersion ?? 0 : Number.MAX_SAFE_INTEGER;
10893
10909
  }
10894
- async resolve() {
10910
+ /**
10911
+ * @param opts.requireFeature When set, a fresh cached JWT that does NOT carry
10912
+ * this feature is bypassed in favour of a network refresh — so a token issued
10913
+ * before the feature (e.g. `'live'`) was added to entitlements doesn't gate it
10914
+ * off until natural TTL expiry. If the refresh also lacks it, the caller gets
10915
+ * a LicenseInfo without the feature (genuinely not entitled).
10916
+ */
10917
+ async resolve(opts = {}) {
10895
10918
  const key = this.rawKey || process.env.RF_LICENSE_KEY?.trim();
10896
10919
  const init = this._validateKey(key);
10897
10920
  if (!init) return null;
10898
10921
  const { normalized, verifier } = init;
10899
10922
  const { cacheValid, cached, cacheFresh, nowMs } = this._readCache(verifier);
10900
- if (cacheFresh && cacheValid && cached) {
10923
+ const cacheHasFeature = !opts.requireFeature || (cacheValid?.features?.includes(opts.requireFeature) ?? false);
10924
+ if (cacheFresh && cacheValid && cached && cacheHasFeature) {
10901
10925
  logger.debug("license path=jwt-cache", {
10902
10926
  exp: new Date(cacheValid.exp * 1e3).toISOString(),
10903
10927
  machinesUsed: cached.machinesUsed,
@@ -10962,7 +10986,8 @@ var LicenseClient = class {
10962
10986
  lastRefresh: Math.floor(Date.now() / 1e3),
10963
10987
  fingerprintHash: this.fingerprint.hash,
10964
10988
  machinesUsed: result.machinesUsed,
10965
- machineLimit: result.machineLimit
10989
+ machineLimit: result.machineLimit,
10990
+ features: payload.features
10966
10991
  };
10967
10992
  writeCache(entry);
10968
10993
  logger.debug(`license path=${cached ? "refresh" : "activate"} response`, {
@@ -10980,7 +11005,8 @@ var LicenseClient = class {
10980
11005
  jwt: result.jwt,
10981
11006
  expiry: new Date(payload.exp * 1e3),
10982
11007
  machinesUsed: result.machinesUsed,
10983
- machineLimit: result.machineLimit
11008
+ machineLimit: result.machineLimit,
11009
+ features: payload.features
10984
11010
  };
10985
11011
  } catch (err) {
10986
11012
  const msg = err.message;
@@ -11068,7 +11094,11 @@ function toLicenseInfo(payload, cached, normalizedKey, source) {
11068
11094
  // caches written by older reporter versions before these fields were
11069
11095
  // persisted (the next refresh will populate them).
11070
11096
  machinesUsed: cached.machinesUsed,
11071
- machineLimit: cached.machineLimit
11097
+ machineLimit: cached.machineLimit,
11098
+ // Prefer the signed `features` claim (authenticated) over the unauthenticated
11099
+ // sidecar — falls back to the sidecar only for caches written before the
11100
+ // payload was threaded through here.
11101
+ features: payload.features ?? cached.features
11072
11102
  };
11073
11103
  }
11074
11104
  function normalizeUrl(url) {
@@ -11408,6 +11438,7 @@ var DEFAULT_MAX_STEPS = 50;
11408
11438
  var DEFAULT_MAX_CONSOLE_LINES = 50;
11409
11439
  var PW_API_WINDOW = 10;
11410
11440
  var isTeardown = (category) => category === "hook" || category === "fixture";
11441
+ var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11411
11442
  function flatten(steps) {
11412
11443
  const out = [];
11413
11444
  const walk = (nodes, depth, ancestors) => {
@@ -11426,7 +11457,7 @@ function flatten(steps) {
11426
11457
  walk(steps ?? [], 0, []);
11427
11458
  return out;
11428
11459
  }
11429
- function materialize(n) {
11460
+ function materialize(n, isFailing) {
11430
11461
  const loc = n.raw.location;
11431
11462
  return {
11432
11463
  title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
@@ -11434,17 +11465,16 @@ function materialize(n) {
11434
11465
  durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11435
11466
  depth: n.depth,
11436
11467
  failed: n.hasError,
11437
- errorMessage: n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
11468
+ errorMessage: isFailing && n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
11438
11469
  location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11439
11470
  };
11440
11471
  }
11441
11472
  function pickFailingIdx(flat) {
11442
- const inTeardown = (f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11443
11473
  const pick = (allowTeardown) => {
11444
11474
  let idx = -1;
11445
11475
  for (let i = 0; i < flat.length; i++) {
11446
11476
  const f = flat[i];
11447
- if (!f.hasError || !allowTeardown && inTeardown(f)) continue;
11477
+ if (!f.hasError || !allowTeardown && inHookSubtree(flat, f)) continue;
11448
11478
  if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
11449
11479
  }
11450
11480
  return idx;
@@ -11467,7 +11497,7 @@ function compactSteps(rawSteps, opts = {}) {
11467
11497
  if (opts.apiSteps) {
11468
11498
  const pwApiBefore = [];
11469
11499
  for (let i = 0; i < failingIdx; i++) {
11470
- if (flat[i].category === "pw:api") pwApiBefore.push(i);
11500
+ if (flat[i].category === "pw:api" && !inHookSubtree(flat, flat[i])) pwApiBefore.push(i);
11471
11501
  }
11472
11502
  for (const i of pwApiBefore.slice(-PW_API_WINDOW)) keep.add(i);
11473
11503
  }
@@ -11483,7 +11513,7 @@ function compactSteps(rawSteps, opts = {}) {
11483
11513
  const mat = (i) => {
11484
11514
  let es = cache.get(i);
11485
11515
  if (!es) {
11486
- es = materialize(flat[i]);
11516
+ es = materialize(flat[i], i === failingIdx);
11487
11517
  cache.set(i, es);
11488
11518
  }
11489
11519
  return es;
@@ -11687,6 +11717,7 @@ function sortBySeverity(failures) {
11687
11717
  // src/utils/env.ts
11688
11718
  init_esm_shims();
11689
11719
  import { execSync } from "child_process";
11720
+ import { createHmac as createHmac2 } from "crypto";
11690
11721
  var COMMIT_HASH_LENGTH = 8;
11691
11722
  function detectCiEnv() {
11692
11723
  const env = process.env;
@@ -11757,6 +11788,34 @@ function detectCiEnv() {
11757
11788
  ciProvider: "local"
11758
11789
  };
11759
11790
  }
11791
+ var CI_RUN_SOURCES = [
11792
+ { provider: "github.com", repoKeys: ["GITHUB_REPOSITORY"], runKeys: ["GITHUB_RUN_ID"], attemptKeys: ["GITHUB_RUN_ATTEMPT"] },
11793
+ { provider: "gitlab.com", repoKeys: ["CI_PROJECT_PATH"], runKeys: ["CI_PIPELINE_ID"] },
11794
+ { provider: "circleci.com", repoKeys: ["CIRCLE_PROJECT_REPONAME"], runKeys: ["CIRCLE_WORKFLOW_ID"] },
11795
+ { provider: "buildkite.com", repoKeys: ["BUILDKITE_REPO"], runKeys: ["BUILDKITE_BUILD_ID"] },
11796
+ { provider: "dev.azure.com", repoKeys: ["BUILD_REPOSITORY_NAME"], runKeys: ["BUILD_BUILDID"] },
11797
+ { provider: "bitbucket.org", repoKeys: ["BITBUCKET_REPO_FULL_NAME"], runKeys: ["BITBUCKET_PIPELINE_UUID"] },
11798
+ { provider: "jenkins", repoKeys: ["JOB_NAME"], runKeys: ["BUILD_NUMBER"] }
11799
+ ];
11800
+ var RUN_ID_FALLBACK_SECRET = "rf-live-runid";
11801
+ function detectCiRunId() {
11802
+ for (const src of CI_RUN_SOURCES) {
11803
+ const runId = firstEnv(src.runKeys);
11804
+ if (!runId) continue;
11805
+ const repo = firstEnv(src.repoKeys) ?? "unknown";
11806
+ const attempt = src.attemptKeys ? firstEnv(src.attemptKeys) ?? "1" : "1";
11807
+ const secret = process.env.RF_LICENSE_KEY?.trim() || RUN_ID_FALLBACK_SECRET;
11808
+ return createHmac2("sha256", secret).update(`${src.provider}:${repo}:${runId}:${attempt}`).digest("hex").slice(0, 32);
11809
+ }
11810
+ return null;
11811
+ }
11812
+ function firstEnv(keys) {
11813
+ for (const k of keys) {
11814
+ const v = process.env[k]?.trim();
11815
+ if (v) return v;
11816
+ }
11817
+ return void 0;
11818
+ }
11760
11819
  function gitBranch() {
11761
11820
  try {
11762
11821
  return execSync("git rev-parse --abbrev-ref HEAD", { stdio: "pipe" }).toString().trim();
@@ -13489,14 +13548,313 @@ var NotificationSender = class {
13489
13548
  }
13490
13549
  };
13491
13550
 
13551
+ // src/live/LiveStreamer.ts
13552
+ init_esm_shims();
13553
+ var NETWORK_TIMEOUT_MS2 = 8e3;
13554
+ var LIVE_DRAIN_DEADLINE_MS = 1e4;
13555
+ var MAX_FLUSH_MS = 3e4;
13556
+ var WARN_THROTTLE_MS = 3e4;
13557
+ var LiveStreamer = class {
13558
+ constructor(config) {
13559
+ this.config = config;
13560
+ this.testBuffer = [];
13561
+ this.eventBuffer = [];
13562
+ /** Monotonic per-shard event counter → stable `{shardKey}:{seq}` event ids. */
13563
+ this.eventSeq = 0;
13564
+ /**
13565
+ * Latest status per test id (last write wins), so retries don't inflate the
13566
+ * tallies: a flaky test that fails then passes is one entry, counted once.
13567
+ * Counts are derived from this map, matching the collector/PDF semantics.
13568
+ */
13569
+ this.testStates = /* @__PURE__ */ new Map();
13570
+ this.flushTimer = null;
13571
+ this.inFlight = /* @__PURE__ */ new Set();
13572
+ this.enabled = true;
13573
+ this.gated = false;
13574
+ this.gatePromise = null;
13575
+ this.jwt = null;
13576
+ this.envSent = false;
13577
+ this.lastWarnMs = 0;
13578
+ this.serverUrl = config.serverUrl.endsWith("/") ? config.serverUrl.slice(0, -1) : config.serverUrl;
13579
+ this.flushMs = config.flushMs ?? 2e3;
13580
+ this.flushMaxBatch = config.maxBatch ?? 100;
13581
+ this.maxInFlight = config.maxInFlight ?? 4;
13582
+ this.maxPreLicenseBuffer = config.maxPreLicenseBuffer ?? 2e3;
13583
+ }
13584
+ enqueueTest(update) {
13585
+ if (!this.enabled) return;
13586
+ this.testStates.set(update.id, { status: update.status, retry: update.retry });
13587
+ this.testBuffer.push(update);
13588
+ this.capPreLicenseBuffer();
13589
+ this.scheduleFlush();
13590
+ }
13591
+ enqueueEvent(event) {
13592
+ if (!this.enabled) return;
13593
+ event.id = `${this.config.shardKey}:${this.eventSeq++}`;
13594
+ this.eventBuffer.push(event);
13595
+ this.capPreLicenseBuffer();
13596
+ this.scheduleFlush();
13597
+ }
13598
+ /**
13599
+ * Final flush: cancels the pending timer, emits the terminal `finished` batch,
13600
+ * then awaits in-flight requests bounded by LIVE_DRAIN_DEADLINE_MS. Called from
13601
+ * the reporter's `onExit`. The bound is the #16644 fix — a hung server cannot
13602
+ * hold the process open past the deadline.
13603
+ */
13604
+ async drain() {
13605
+ if (this.flushTimer) {
13606
+ clearTimeout(this.flushTimer);
13607
+ this.flushTimer = null;
13608
+ }
13609
+ await this.flush("finished");
13610
+ await Promise.race([
13611
+ Promise.allSettled([...this.inFlight]),
13612
+ sleep(LIVE_DRAIN_DEADLINE_MS)
13613
+ ]);
13614
+ }
13615
+ // ─── internals ────────────────────────────────────────────────────────────
13616
+ snapshotCounts() {
13617
+ const c = {
13618
+ total: this.testStates.size,
13619
+ running: 0,
13620
+ passed: 0,
13621
+ failed: 0,
13622
+ timedOut: 0,
13623
+ skipped: 0,
13624
+ flaky: 0
13625
+ };
13626
+ for (const { status, retry } of this.testStates.values()) {
13627
+ switch (status) {
13628
+ case "running":
13629
+ c.running++;
13630
+ break;
13631
+ case "passed":
13632
+ retry && retry > 0 ? c.flaky++ : c.passed++;
13633
+ break;
13634
+ case "failed":
13635
+ c.failed++;
13636
+ break;
13637
+ case "timedOut":
13638
+ c.timedOut++;
13639
+ break;
13640
+ case "skipped":
13641
+ c.skipped++;
13642
+ break;
13643
+ case "interrupted":
13644
+ c.failed++;
13645
+ break;
13646
+ }
13647
+ }
13648
+ return c;
13649
+ }
13650
+ /** Drop-oldest cap on buffered items while the license gate is still pending. */
13651
+ capPreLicenseBuffer() {
13652
+ if (this.gated) return;
13653
+ let overflow = this.testBuffer.length + this.eventBuffer.length - this.maxPreLicenseBuffer;
13654
+ if (overflow <= 0) return;
13655
+ const dropEvents = Math.min(overflow, this.eventBuffer.length);
13656
+ if (dropEvents > 0) this.eventBuffer.splice(0, dropEvents);
13657
+ overflow -= dropEvents;
13658
+ if (overflow > 0) this.testBuffer.splice(0, overflow);
13659
+ }
13660
+ scheduleFlush() {
13661
+ if (this.testBuffer.length + this.eventBuffer.length >= this.flushMaxBatch) {
13662
+ void this.flush("running");
13663
+ return;
13664
+ }
13665
+ if (this.flushTimer) return;
13666
+ this.flushTimer = setTimeout(() => {
13667
+ this.flushTimer = null;
13668
+ void this.flush("running");
13669
+ }, this.flushMs);
13670
+ if (typeof this.flushTimer.unref === "function") this.flushTimer.unref();
13671
+ }
13672
+ async flush(status) {
13673
+ if (!this.enabled) return;
13674
+ const entitled = await this.ensureGate();
13675
+ if (!entitled) return;
13676
+ if (status !== "finished" && this.inFlight.size >= this.maxInFlight) return;
13677
+ const tests = this.testBuffer;
13678
+ const events = this.eventBuffer;
13679
+ if (status !== "finished" && tests.length === 0 && events.length === 0) return;
13680
+ this.testBuffer = [];
13681
+ this.eventBuffer = [];
13682
+ const body = {
13683
+ runId: this.config.runId,
13684
+ shardKey: this.config.shardKey,
13685
+ shardTotal: this.config.shardTotal,
13686
+ status,
13687
+ counts: this.snapshotCounts(),
13688
+ tests,
13689
+ events,
13690
+ // Keep attaching env until a send actually succeeds (envSent flips in
13691
+ // send()), so a failed first request doesn't lose the run's CI context.
13692
+ ...this.envSent ? {} : { env: this.config.env }
13693
+ };
13694
+ const p = this.send(body).finally(() => {
13695
+ this.inFlight.delete(p);
13696
+ });
13697
+ this.inFlight.add(p);
13698
+ }
13699
+ /** Runs the license entitlement check exactly once, memoized. */
13700
+ ensureGate() {
13701
+ return this.gatePromise ?? (this.gatePromise = this.runGate());
13702
+ }
13703
+ async runGate() {
13704
+ let info = null;
13705
+ try {
13706
+ info = await this.config.license();
13707
+ } catch {
13708
+ info = null;
13709
+ }
13710
+ this.gated = true;
13711
+ const features = info?.features ?? [];
13712
+ if (!info || !info.jwt || !features.includes("live")) {
13713
+ this.enabled = false;
13714
+ this.testBuffer = [];
13715
+ this.eventBuffer = [];
13716
+ this.warn("Live streaming disabled \u2014 no active license or live entitlement.");
13717
+ return false;
13718
+ }
13719
+ this.jwt = info.jwt;
13720
+ if (this.config.watchUrl) logger.info(`Live results: ${this.config.watchUrl}`);
13721
+ return true;
13722
+ }
13723
+ async send(body) {
13724
+ if (!this.jwt) return;
13725
+ const controller = new AbortController();
13726
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
13727
+ try {
13728
+ const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
13729
+ method: "POST",
13730
+ headers: {
13731
+ "content-type": "application/json",
13732
+ authorization: `Bearer ${this.jwt}`
13733
+ },
13734
+ body: JSON.stringify(body),
13735
+ signal: controller.signal
13736
+ });
13737
+ await res.body?.cancel().catch(() => {
13738
+ });
13739
+ if (res.status === 429) {
13740
+ this.flushMs = Math.min(this.flushMs * 2, MAX_FLUSH_MS);
13741
+ return;
13742
+ }
13743
+ if (!res.ok) {
13744
+ this.warn(`Live ingest returned HTTP ${res.status}.`);
13745
+ return;
13746
+ }
13747
+ if (body.env) this.envSent = true;
13748
+ } catch (err) {
13749
+ this.warn(`Live ingest request failed: ${err.message}`);
13750
+ } finally {
13751
+ clearTimeout(timeout);
13752
+ }
13753
+ }
13754
+ warn(msg) {
13755
+ const now = Date.now();
13756
+ if (now - this.lastWarnMs < WARN_THROTTLE_MS) return;
13757
+ this.lastWarnMs = now;
13758
+ logger.warn(msg);
13759
+ }
13760
+ };
13761
+ function sleep(ms) {
13762
+ return new Promise((resolve) => {
13763
+ const t = setTimeout(resolve, ms);
13764
+ if (typeof t.unref === "function") t.unref();
13765
+ });
13766
+ }
13767
+
13768
+ // src/live/runId.ts
13769
+ init_esm_shims();
13770
+ import { createHmac as createHmac3 } from "crypto";
13771
+ import { randomUUID as randomUUID2 } from "crypto";
13772
+ var WATCH_TOKEN_HEX_LEN = 32;
13773
+ function deriveRunId(opts) {
13774
+ const explicit = opts.runId?.trim();
13775
+ if (explicit) return explicit;
13776
+ const fromEnv = process.env.RF_LIVE_RUN_ID?.trim();
13777
+ if (fromEnv) return fromEnv;
13778
+ return detectCiRunId() ?? randomUUID2();
13779
+ }
13780
+ function computeWatchToken(runId) {
13781
+ const secret = process.env.RF_LICENSE_KEY?.trim();
13782
+ if (!secret) return null;
13783
+ return createHmac3("sha256", secret).update(`live:${runId}`).digest("hex").slice(0, WATCH_TOKEN_HEX_LEN);
13784
+ }
13785
+ function buildWatchUrl(serverUrl, runId, token) {
13786
+ const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
13787
+ return `${base}/live/${encodeURIComponent(runId)}?t=${encodeURIComponent(token)}`;
13788
+ }
13789
+ function shardKeyOf(shard) {
13790
+ if (!shard || !shard.total) return "1/1";
13791
+ return `${shard.current}/${shard.total}`;
13792
+ }
13793
+
13794
+ // src/live/event-map.ts
13795
+ init_esm_shims();
13796
+ var MAX_TITLE_CHARS2 = 200;
13797
+ var MAX_STEP_TITLE_CHARS = 200;
13798
+ var MAX_CONSOLE_CHARS = 2048;
13799
+ function mapTestBegin(test, shardKey) {
13800
+ return {
13801
+ id: test.id,
13802
+ title: clampWithEllipsis(test.title ?? "", MAX_TITLE_CHARS2),
13803
+ status: "running",
13804
+ shardKey
13805
+ };
13806
+ }
13807
+ function mapTestEnd(test, result, shardKey) {
13808
+ return {
13809
+ id: test.id,
13810
+ status: result.status,
13811
+ durationMs: result.duration,
13812
+ retry: result.retry,
13813
+ shardKey
13814
+ };
13815
+ }
13816
+ function shouldEmitStep(step, mode) {
13817
+ if (mode === "none") return false;
13818
+ if (!step.title) return false;
13819
+ if (mode === "all") return true;
13820
+ if (step.error) return true;
13821
+ return step.category === "test.step" || step.category === "expect";
13822
+ }
13823
+ function mapStep(testId, step, phase, shardKey) {
13824
+ return {
13825
+ kind: "step",
13826
+ testId,
13827
+ title: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS),
13828
+ status: phase === "begin" ? "running" : step.error ? "failed" : "passed",
13829
+ shardKey,
13830
+ at: Date.now()
13831
+ };
13832
+ }
13833
+ function mapConsole(kind, chunk, testId, shardKey) {
13834
+ return {
13835
+ kind,
13836
+ testId,
13837
+ text: clampWithEllipsis(chunkToString2(chunk), MAX_CONSOLE_CHARS),
13838
+ shardKey,
13839
+ at: Date.now()
13840
+ };
13841
+ }
13842
+ function chunkToString2(chunk) {
13843
+ return typeof chunk === "string" ? chunk : chunk.toString("utf8");
13844
+ }
13845
+
13492
13846
  // src/reporter.ts
13847
+ var DEFAULT_SERVER_URL2 = "https://reportforge.org";
13493
13848
  var PdfReporter = class {
13494
13849
  constructor(rawOptions = {}) {
13495
13850
  this.cwd = process.cwd();
13496
13851
  this.pdfGenerator = new PdfGenerator();
13852
+ this.liveShardKey = "1/1";
13853
+ this.liveSteps = "failed";
13854
+ this.liveConsole = false;
13497
13855
  this.options = parseOptions(rawOptions);
13498
13856
  this.dataCollector = new DataCollector(this.options.capture);
13499
- const version = true ? "0.8.0" : "0.x";
13857
+ const version = true ? "0.9.0" : "0.x";
13500
13858
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13501
13859
  this.licenseClient = new LicenseClient({
13502
13860
  licenseKey: this.options.licenseKey,
@@ -13507,12 +13865,65 @@ var PdfReporter = class {
13507
13865
  }
13508
13866
  onBegin(config, suite) {
13509
13867
  this.dataCollector.onBegin(config, suite);
13868
+ if (!this.options.live.enabled) return;
13869
+ try {
13870
+ this.setupLive(config);
13871
+ } catch (err) {
13872
+ logger.warn(`Live setup failed \u2014 streaming disabled: ${err.message}`);
13873
+ }
13874
+ }
13875
+ onTestBegin(test) {
13876
+ if (!this.liveStreamer) return;
13877
+ try {
13878
+ this.liveStreamer.enqueueTest(mapTestBegin(test, this.liveShardKey));
13879
+ } catch {
13880
+ }
13510
13881
  }
13511
13882
  onTestEnd(test, result) {
13512
13883
  this.dataCollector.onTestEnd(test, result);
13884
+ if (!this.liveStreamer) return;
13885
+ try {
13886
+ this.liveStreamer.enqueueTest(mapTestEnd(test, result, this.liveShardKey));
13887
+ } catch {
13888
+ }
13889
+ }
13890
+ onStepBegin(test, _result, step) {
13891
+ if (!this.liveStreamer || !shouldEmitStep(step, this.liveSteps)) return;
13892
+ try {
13893
+ this.liveStreamer.enqueueEvent(mapStep(test.id, step, "begin", this.liveShardKey));
13894
+ } catch {
13895
+ }
13896
+ }
13897
+ onStepEnd(test, _result, step) {
13898
+ if (!this.liveStreamer || !shouldEmitStep(step, this.liveSteps)) return;
13899
+ try {
13900
+ this.liveStreamer.enqueueEvent(mapStep(test.id, step, "end", this.liveShardKey));
13901
+ } catch {
13902
+ }
13903
+ }
13904
+ onStdOut(chunk, test) {
13905
+ if (!this.liveStreamer || !this.liveConsole) return;
13906
+ try {
13907
+ this.liveStreamer.enqueueEvent(mapConsole("stdout", chunk, test?.id, this.liveShardKey));
13908
+ } catch {
13909
+ }
13910
+ }
13911
+ onStdErr(chunk, test) {
13912
+ if (!this.liveStreamer || !this.liveConsole) return;
13913
+ try {
13914
+ this.liveStreamer.enqueueEvent(mapConsole("stderr", chunk, test?.id, this.liveShardKey));
13915
+ } catch {
13916
+ }
13917
+ }
13918
+ async onExit() {
13919
+ if (!this.liveStreamer) return;
13920
+ try {
13921
+ await this.liveStreamer.drain();
13922
+ } catch {
13923
+ }
13513
13924
  }
13514
13925
  async onEnd(result) {
13515
- const licenseInfo = await this.resolveLicense();
13926
+ const licenseInfo = await this.license();
13516
13927
  if (!licenseInfo) return;
13517
13928
  const collected = this._collectData(result);
13518
13929
  if (!collected) return;
@@ -13619,9 +14030,18 @@ var PdfReporter = class {
13619
14030
  printsToStdio() {
13620
14031
  return false;
13621
14032
  }
14033
+ /**
14034
+ * Memoized license resolution shared by the live stream (kicked early in
14035
+ * onBegin) and PDF generation (awaited in onEnd) — at most one network trip.
14036
+ */
14037
+ license() {
14038
+ return this.licensePromise ?? (this.licensePromise = this.resolveLicense());
14039
+ }
13622
14040
  async resolveLicense() {
13623
14041
  try {
13624
- return await this.licenseClient.resolve();
14042
+ return await this.licenseClient.resolve(
14043
+ this.options.live.enabled ? { requireFeature: "live" } : {}
14044
+ );
13625
14045
  } catch (err) {
13626
14046
  logger.warn(
13627
14047
  `License resolution errored: ${err.message}. PDF generation skipped.`
@@ -13629,6 +14049,33 @@ var PdfReporter = class {
13629
14049
  return null;
13630
14050
  }
13631
14051
  }
14052
+ /**
14053
+ * Wires up live streaming: derives the run id (shared by all shards), kicks
14054
+ * the license resolution early, builds the streamer, and prints the watch
14055
+ * link to the CI logs. Throws are caught by the onBegin try/catch.
14056
+ */
14057
+ setupLive(config) {
14058
+ const live = this.options.live;
14059
+ const runId = deriveRunId({ runId: live.runId });
14060
+ const serverUrl = live.serverUrl ?? this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
14061
+ const shard = config.shard ?? null;
14062
+ this.liveShardKey = shardKeyOf(shard);
14063
+ this.liveSteps = live.steps;
14064
+ this.liveConsole = live.console;
14065
+ void this.license();
14066
+ const token = computeWatchToken(runId);
14067
+ const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
14068
+ this.liveStreamer = new LiveStreamer({
14069
+ serverUrl,
14070
+ runId,
14071
+ shardKey: this.liveShardKey,
14072
+ shardTotal: shard?.total ?? null,
14073
+ license: () => this.license(),
14074
+ env: detectCiEnv(),
14075
+ watchUrl,
14076
+ flushMs: live.flushMs
14077
+ });
14078
+ }
13632
14079
  resolveProjectName() {
13633
14080
  try {
13634
14081
  const pkgPath = path14.resolve(process.cwd(), "package.json");
@@ -11,8 +11,8 @@
11
11
  <th style="width:72px;text-align:right;">Duration</th>
12
12
  </tr>
13
13
  </thead>
14
- <tbody>
15
14
  {{#each failures}}
15
+ <tbody class="defect-row-group">
16
16
  <tr class="no-break">
17
17
  <td class="defect-id">DEF-{{addOne @index}}</td>
18
18
  <td style="font-weight:500;">{{testTitle}}</td>
@@ -24,9 +24,14 @@
24
24
  <tr class="defect-detail-row">
25
25
  <td colspan="5">
26
26
  <div class="defect-detail">
27
+ {{! Error sits at the failing step inside the trail. Only when there is no
28
+ identified failing step (capture.steps off, or no errored step) is it
29
+ shown here under the title as a fallback. }}
30
+ {{#unless execution.failingStep}}
27
31
  {{#if error.message}}
28
32
  <div class="defect-detail__error">{{error.message}}</div>
29
33
  {{/if}}
34
+ {{/unless}}
30
35
 
31
36
  {{#if execution.steps.length}}
32
37
  <div class="repro">
@@ -38,6 +43,7 @@
38
43
  <span class="repro-step__title">{{title}}</span>
39
44
  {{#if failed}}{{#if location}}<span class="repro-step__loc">{{location}}</span>{{/if}}{{/if}}
40
45
  <span class="repro-step__dur">{{formatDuration durationMs}}</span>
46
+ {{#if errorMessage}}<div class="repro-step__error">{{../error.message}}</div>{{/if}}
41
47
  </li>
42
48
  {{/each}}
43
49
  </ol>
@@ -64,8 +70,8 @@
64
70
  </td>
65
71
  </tr>
66
72
  {{/if}}
73
+ </tbody>
67
74
  {{/each}}
68
- </tbody>
69
75
  </table>
70
76
  </div>
71
77
  {{/if}}
@@ -409,6 +409,9 @@ code {
409
409
  design-system-consistent: a light bordered card (NO side rail, per the
410
410
  side-stripe ban in DESIGN.md), with hairline dividers between sub-blocks —
411
411
  mirroring the reworked .failure-detail and .retry-history patterns. */
412
+ /* Each failure is its own <tbody> so its summary row and detail card stay together
413
+ across a page break — the detail no longer strands under a repeated header. */
414
+ .defect-row-group { break-inside: avoid; page-break-inside: avoid; }
412
415
  .defect-detail-row td { padding: 0; border-top: none; }
413
416
  .defect-detail {
414
417
  border: 1px solid var(--border-2);
@@ -435,12 +438,19 @@ code {
435
438
  .repro { margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--border); }
436
439
  .repro-steps { list-style: none; margin: 6px 0 0; padding: 0; }
437
440
  .repro-step {
438
- display: flex; align-items: baseline; gap: 8px;
441
+ display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px;
439
442
  /* indent by tree depth (capped) — the value comes from `--d` on the element */
440
443
  padding: 2px 0 2px min(60px, calc(var(--d, 0) * 12px));
441
444
  font-size: 9px; line-height: 1.5;
442
445
  page-break-inside: avoid; break-inside: avoid;
443
446
  }
447
+ /* The error sits at the failing step — full width below its row, indented past the
448
+ category column so it reads as belonging to that step. */
449
+ .repro-step__error {
450
+ flex-basis: 100%; margin: 3px 0 2px; padding-left: 60px;
451
+ font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.55;
452
+ color: #8A2E1E; white-space: pre-wrap; word-break: break-word;
453
+ }
444
454
  .repro-step__cat {
445
455
  flex: none; min-width: 52px;
446
456
  font-family: 'JetBrains Mono', monospace; font-size: 8px; color: var(--text-3);