@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/README.md +28 -0
- package/dist/cli/index.js +17 -1
- package/dist/index.d.mts +71 -1
- package/dist/index.d.ts +71 -1
- package/dist/index.js +545 -14
- package/dist/index.mjs +541 -10
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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-${
|
|
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
|
|
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-${
|
|
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
|
|
13580
|
+
result?.sent.push(name);
|
|
13482
13581
|
} catch (err) {
|
|
13483
13582
|
logger.warn(`[notify] ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
13484
|
-
result
|
|
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.
|
|
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.
|
|
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");
|