@reportforge/playwright-pdf 0.23.0 → 0.24.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 +1 -1
- package/dist/index.d.mts +6 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +78 -16
- package/dist/index.mjs +78 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -245,7 +245,7 @@ Sharded runs need no extra config: every shard of one CI run converges on the sa
|
|
|
245
245
|
|
|
246
246
|
Live streaming is best-effort and entitlement-gated: if the server is unreachable the run is unaffected, and an active subscription with the `live` feature is required. If you've configured Slack/Teams/Discord notifications, the watch link is also posted to those channels when the run starts.
|
|
247
247
|
|
|
248
|
-
> **Security:** the watch link exposes test titles + statuses (and step titles when `steps` is on). Leaving `console: false` is recommended: enabling it streams your tests' stdout/stderr to anyone holding the link. Full details at [reportforge.org/docs/advanced/live](https://reportforge.org/docs/advanced/live).
|
|
248
|
+
> **Security:** the watch link exposes test titles + statuses (and step titles when `steps` is on) to anyone holding it — no login needed, which is the point: share it with whoever should watch. The token is scoped to that one run, and rotating your license key never breaks an already-shared link. Leaving `console: false` is recommended: enabling it streams your tests' stdout/stderr to anyone holding the link. Full details at [reportforge.org/docs/advanced/live](https://reportforge.org/docs/advanced/live).
|
|
249
249
|
|
|
250
250
|
### Shard Merging
|
|
251
251
|
|
package/dist/index.d.mts
CHANGED
|
@@ -552,9 +552,12 @@ declare class PdfReporter implements Reporter {
|
|
|
552
552
|
* Announces the watch link once the run is confirmed live-entitled. Gating on
|
|
553
553
|
* the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
|
|
554
554
|
* feature) guarantees the link is never printed or posted for a run that will
|
|
555
|
-
* never stream — no dead links. The
|
|
556
|
-
*
|
|
557
|
-
*
|
|
555
|
+
* never stream — no dead links. The link's token is the server-ISSUED one from
|
|
556
|
+
* the announce handshake (random, key-independent — a key rotation can never
|
|
557
|
+
* kill the link); the key-derived HMAC is minted only as a fallback when the
|
|
558
|
+
* handshake fails (network blip, older server). The chat ping fires from the
|
|
559
|
+
* primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
|
|
560
|
+
* posts one message, not N. The banner still prints from every shard's own log.
|
|
558
561
|
*/
|
|
559
562
|
private announceLive;
|
|
560
563
|
private resolveProjectName;
|
package/dist/index.d.ts
CHANGED
|
@@ -552,9 +552,12 @@ declare class PdfReporter implements Reporter {
|
|
|
552
552
|
* Announces the watch link once the run is confirmed live-entitled. Gating on
|
|
553
553
|
* the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
|
|
554
554
|
* feature) guarantees the link is never printed or posted for a run that will
|
|
555
|
-
* never stream — no dead links. The
|
|
556
|
-
*
|
|
557
|
-
*
|
|
555
|
+
* never stream — no dead links. The link's token is the server-ISSUED one from
|
|
556
|
+
* the announce handshake (random, key-independent — a key rotation can never
|
|
557
|
+
* kill the link); the key-derived HMAC is minted only as a fallback when the
|
|
558
|
+
* handshake fails (network blip, older server). The chat ping fires from the
|
|
559
|
+
* primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
|
|
560
|
+
* posts one message, not N. The banner still prints from every shard's own log.
|
|
558
561
|
*/
|
|
559
562
|
private announceLive;
|
|
560
563
|
private resolveProjectName;
|
package/dist/index.js
CHANGED
|
@@ -14248,6 +14248,7 @@ var LiveStreamer = class {
|
|
|
14248
14248
|
this.enabled = true;
|
|
14249
14249
|
this.gated = false;
|
|
14250
14250
|
this.gatePromise = null;
|
|
14251
|
+
this.announcePromise = null;
|
|
14251
14252
|
this.jwt = null;
|
|
14252
14253
|
this.envSent = false;
|
|
14253
14254
|
this.lastWarnMs = 0;
|
|
@@ -14280,6 +14281,58 @@ var LiveStreamer = class {
|
|
|
14280
14281
|
this.capPreLicenseBuffer();
|
|
14281
14282
|
this.scheduleFlush();
|
|
14282
14283
|
}
|
|
14284
|
+
/**
|
|
14285
|
+
* One immediate empty "hello" batch, sent at run start before the banner
|
|
14286
|
+
* prints. It binds the run server-side (so early link-openers and the
|
|
14287
|
+
* dashboard see it instantly instead of a pending window) and returns the
|
|
14288
|
+
* server-ISSUED watch token from the ack — the capability that survives
|
|
14289
|
+
* every license-key lifecycle event, unlike the key-derived HMAC fallback.
|
|
14290
|
+
* Returns null on any failure (not entitled, network error, older server
|
|
14291
|
+
* whose ack carries no token); the caller then falls back to the HMAC link.
|
|
14292
|
+
* Memoized so drain() can await the same in-flight promise (see drain()).
|
|
14293
|
+
*/
|
|
14294
|
+
announce() {
|
|
14295
|
+
return this.announcePromise ?? (this.announcePromise = this.announceOnce());
|
|
14296
|
+
}
|
|
14297
|
+
async announceOnce() {
|
|
14298
|
+
const entitled = await this.ensureGate();
|
|
14299
|
+
if (!entitled || !this.jwt) return null;
|
|
14300
|
+
const body = {
|
|
14301
|
+
runId: this.config.runId,
|
|
14302
|
+
shardKey: this.config.shardKey,
|
|
14303
|
+
shardTotal: this.config.shardTotal,
|
|
14304
|
+
status: "running",
|
|
14305
|
+
counts: this.snapshotCounts(),
|
|
14306
|
+
tests: [],
|
|
14307
|
+
events: [],
|
|
14308
|
+
...this.envSent ? {} : { env: this.config.env }
|
|
14309
|
+
};
|
|
14310
|
+
const controller = new AbortController();
|
|
14311
|
+
const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS3);
|
|
14312
|
+
try {
|
|
14313
|
+
const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
|
|
14314
|
+
method: "POST",
|
|
14315
|
+
headers: {
|
|
14316
|
+
"content-type": "application/json",
|
|
14317
|
+
authorization: `Bearer ${this.jwt}`
|
|
14318
|
+
},
|
|
14319
|
+
body: JSON.stringify(body),
|
|
14320
|
+
signal: controller.signal
|
|
14321
|
+
});
|
|
14322
|
+
if (!res.ok) {
|
|
14323
|
+
await res.body?.cancel().catch(() => {
|
|
14324
|
+
});
|
|
14325
|
+
return null;
|
|
14326
|
+
}
|
|
14327
|
+
const ack = await res.json().catch(() => null);
|
|
14328
|
+
if (body.env) this.envSent = true;
|
|
14329
|
+
return typeof ack?.watchToken === "string" && ack.watchToken ? ack.watchToken : null;
|
|
14330
|
+
} catch {
|
|
14331
|
+
return null;
|
|
14332
|
+
} finally {
|
|
14333
|
+
clearTimeout(timeout);
|
|
14334
|
+
}
|
|
14335
|
+
}
|
|
14283
14336
|
/**
|
|
14284
14337
|
* Final flush: cancels the pending timer, emits the terminal `finished` batch,
|
|
14285
14338
|
* then awaits in-flight requests bounded by LIVE_DRAIN_DEADLINE_MS. Called from
|
|
@@ -14291,6 +14344,7 @@ var LiveStreamer = class {
|
|
|
14291
14344
|
clearTimeout(this.flushTimer);
|
|
14292
14345
|
this.flushTimer = null;
|
|
14293
14346
|
}
|
|
14347
|
+
if (this.announcePromise) await this.announcePromise;
|
|
14294
14348
|
await this.flush("finished");
|
|
14295
14349
|
await Promise.race([
|
|
14296
14350
|
Promise.allSettled([...this.inFlight]),
|
|
@@ -14468,7 +14522,7 @@ function computeWatchToken(runId, key) {
|
|
|
14468
14522
|
}
|
|
14469
14523
|
function buildWatchUrl(serverUrl, runId, token) {
|
|
14470
14524
|
const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
|
|
14471
|
-
return `${base}/live/${encodeURIComponent(runId)}
|
|
14525
|
+
return `${base}/live/${encodeURIComponent(runId)}#t=${encodeURIComponent(token)}`;
|
|
14472
14526
|
}
|
|
14473
14527
|
function formatLiveBanner(url) {
|
|
14474
14528
|
const label = "Live Tracker";
|
|
@@ -14584,7 +14638,7 @@ var PdfReporter = class {
|
|
|
14584
14638
|
if (this.options.redact.enabled) {
|
|
14585
14639
|
this.redactor = new Redactor(this.options.redact);
|
|
14586
14640
|
}
|
|
14587
|
-
this.version = true ? "0.
|
|
14641
|
+
this.version = true ? "0.24.0" : "0.x";
|
|
14588
14642
|
logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
|
|
14589
14643
|
this.licenseClient = new LicenseClient({
|
|
14590
14644
|
licenseKey: this.options.licenseKey,
|
|
@@ -14849,18 +14903,8 @@ var PdfReporter = class {
|
|
|
14849
14903
|
this.liveSteps = live.steps;
|
|
14850
14904
|
this.liveConsole = live.console;
|
|
14851
14905
|
void this.license();
|
|
14852
|
-
const token = computeWatchToken(runId, this.options.licenseKey);
|
|
14853
|
-
const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
|
|
14854
14906
|
const env = detectCiEnv();
|
|
14855
14907
|
if (this.redactor && env?.buildUrl) env.buildUrl = this.redactor.redactText(env.buildUrl);
|
|
14856
|
-
if (watchUrl) {
|
|
14857
|
-
void this.announceLive(watchUrl, env, shard).catch(() => {
|
|
14858
|
-
});
|
|
14859
|
-
} else {
|
|
14860
|
-
logger.warn(
|
|
14861
|
-
"Live is enabled but no RF_LICENSE_KEY was found \u2014 cannot mint a watch link or stream. Set RF_LICENSE_KEY (or the `licenseKey` option)."
|
|
14862
|
-
);
|
|
14863
|
-
}
|
|
14864
14908
|
this.liveStreamer = new LiveStreamer({
|
|
14865
14909
|
serverUrl,
|
|
14866
14910
|
runId,
|
|
@@ -14871,16 +14915,27 @@ var PdfReporter = class {
|
|
|
14871
14915
|
env,
|
|
14872
14916
|
flushMs: live.flushMs
|
|
14873
14917
|
});
|
|
14918
|
+
if (!this.options.licenseKey?.trim() && !process.env.RF_LICENSE_KEY?.trim()) {
|
|
14919
|
+
logger.warn(
|
|
14920
|
+
"Live is enabled but no RF_LICENSE_KEY was found \u2014 cannot mint a watch link or stream. Set RF_LICENSE_KEY (or the `licenseKey` option)."
|
|
14921
|
+
);
|
|
14922
|
+
return;
|
|
14923
|
+
}
|
|
14924
|
+
void this.announceLive(serverUrl, runId, env, shard).catch(() => {
|
|
14925
|
+
});
|
|
14874
14926
|
}
|
|
14875
14927
|
/**
|
|
14876
14928
|
* Announces the watch link once the run is confirmed live-entitled. Gating on
|
|
14877
14929
|
* the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
|
|
14878
14930
|
* feature) guarantees the link is never printed or posted for a run that will
|
|
14879
|
-
* never stream — no dead links. The
|
|
14880
|
-
*
|
|
14881
|
-
*
|
|
14931
|
+
* never stream — no dead links. The link's token is the server-ISSUED one from
|
|
14932
|
+
* the announce handshake (random, key-independent — a key rotation can never
|
|
14933
|
+
* kill the link); the key-derived HMAC is minted only as a fallback when the
|
|
14934
|
+
* handshake fails (network blip, older server). The chat ping fires from the
|
|
14935
|
+
* primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
|
|
14936
|
+
* posts one message, not N. The banner still prints from every shard's own log.
|
|
14882
14937
|
*/
|
|
14883
|
-
async announceLive(
|
|
14938
|
+
async announceLive(serverUrl, runId, env, shard) {
|
|
14884
14939
|
const info = await this.license();
|
|
14885
14940
|
if (!info) {
|
|
14886
14941
|
logger.warn("Live is enabled but no active license resolved \u2014 no watch link or streaming.");
|
|
@@ -14898,6 +14953,13 @@ var PdfReporter = class {
|
|
|
14898
14953
|
);
|
|
14899
14954
|
return;
|
|
14900
14955
|
}
|
|
14956
|
+
const issued = await this.liveStreamer?.announce();
|
|
14957
|
+
const token = issued ?? computeWatchToken(runId, this.options.licenseKey);
|
|
14958
|
+
if (!token) {
|
|
14959
|
+
logger.warn("Live is enabled but no watch link could be established \u2014 streaming continues without one.");
|
|
14960
|
+
return;
|
|
14961
|
+
}
|
|
14962
|
+
const watchUrl = buildWatchUrl(serverUrl, runId, token);
|
|
14901
14963
|
if (isLevelEnabled("info")) {
|
|
14902
14964
|
console.log(formatLiveBanner(watchUrl));
|
|
14903
14965
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -14249,6 +14249,7 @@ var LiveStreamer = class {
|
|
|
14249
14249
|
this.enabled = true;
|
|
14250
14250
|
this.gated = false;
|
|
14251
14251
|
this.gatePromise = null;
|
|
14252
|
+
this.announcePromise = null;
|
|
14252
14253
|
this.jwt = null;
|
|
14253
14254
|
this.envSent = false;
|
|
14254
14255
|
this.lastWarnMs = 0;
|
|
@@ -14281,6 +14282,58 @@ var LiveStreamer = class {
|
|
|
14281
14282
|
this.capPreLicenseBuffer();
|
|
14282
14283
|
this.scheduleFlush();
|
|
14283
14284
|
}
|
|
14285
|
+
/**
|
|
14286
|
+
* One immediate empty "hello" batch, sent at run start before the banner
|
|
14287
|
+
* prints. It binds the run server-side (so early link-openers and the
|
|
14288
|
+
* dashboard see it instantly instead of a pending window) and returns the
|
|
14289
|
+
* server-ISSUED watch token from the ack — the capability that survives
|
|
14290
|
+
* every license-key lifecycle event, unlike the key-derived HMAC fallback.
|
|
14291
|
+
* Returns null on any failure (not entitled, network error, older server
|
|
14292
|
+
* whose ack carries no token); the caller then falls back to the HMAC link.
|
|
14293
|
+
* Memoized so drain() can await the same in-flight promise (see drain()).
|
|
14294
|
+
*/
|
|
14295
|
+
announce() {
|
|
14296
|
+
return this.announcePromise ?? (this.announcePromise = this.announceOnce());
|
|
14297
|
+
}
|
|
14298
|
+
async announceOnce() {
|
|
14299
|
+
const entitled = await this.ensureGate();
|
|
14300
|
+
if (!entitled || !this.jwt) return null;
|
|
14301
|
+
const body = {
|
|
14302
|
+
runId: this.config.runId,
|
|
14303
|
+
shardKey: this.config.shardKey,
|
|
14304
|
+
shardTotal: this.config.shardTotal,
|
|
14305
|
+
status: "running",
|
|
14306
|
+
counts: this.snapshotCounts(),
|
|
14307
|
+
tests: [],
|
|
14308
|
+
events: [],
|
|
14309
|
+
...this.envSent ? {} : { env: this.config.env }
|
|
14310
|
+
};
|
|
14311
|
+
const controller = new AbortController();
|
|
14312
|
+
const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS3);
|
|
14313
|
+
try {
|
|
14314
|
+
const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
|
|
14315
|
+
method: "POST",
|
|
14316
|
+
headers: {
|
|
14317
|
+
"content-type": "application/json",
|
|
14318
|
+
authorization: `Bearer ${this.jwt}`
|
|
14319
|
+
},
|
|
14320
|
+
body: JSON.stringify(body),
|
|
14321
|
+
signal: controller.signal
|
|
14322
|
+
});
|
|
14323
|
+
if (!res.ok) {
|
|
14324
|
+
await res.body?.cancel().catch(() => {
|
|
14325
|
+
});
|
|
14326
|
+
return null;
|
|
14327
|
+
}
|
|
14328
|
+
const ack = await res.json().catch(() => null);
|
|
14329
|
+
if (body.env) this.envSent = true;
|
|
14330
|
+
return typeof ack?.watchToken === "string" && ack.watchToken ? ack.watchToken : null;
|
|
14331
|
+
} catch {
|
|
14332
|
+
return null;
|
|
14333
|
+
} finally {
|
|
14334
|
+
clearTimeout(timeout);
|
|
14335
|
+
}
|
|
14336
|
+
}
|
|
14284
14337
|
/**
|
|
14285
14338
|
* Final flush: cancels the pending timer, emits the terminal `finished` batch,
|
|
14286
14339
|
* then awaits in-flight requests bounded by LIVE_DRAIN_DEADLINE_MS. Called from
|
|
@@ -14292,6 +14345,7 @@ var LiveStreamer = class {
|
|
|
14292
14345
|
clearTimeout(this.flushTimer);
|
|
14293
14346
|
this.flushTimer = null;
|
|
14294
14347
|
}
|
|
14348
|
+
if (this.announcePromise) await this.announcePromise;
|
|
14295
14349
|
await this.flush("finished");
|
|
14296
14350
|
await Promise.race([
|
|
14297
14351
|
Promise.allSettled([...this.inFlight]),
|
|
@@ -14469,7 +14523,7 @@ function computeWatchToken(runId, key) {
|
|
|
14469
14523
|
}
|
|
14470
14524
|
function buildWatchUrl(serverUrl, runId, token) {
|
|
14471
14525
|
const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
|
|
14472
|
-
return `${base}/live/${encodeURIComponent(runId)}
|
|
14526
|
+
return `${base}/live/${encodeURIComponent(runId)}#t=${encodeURIComponent(token)}`;
|
|
14473
14527
|
}
|
|
14474
14528
|
function formatLiveBanner(url) {
|
|
14475
14529
|
const label = "Live Tracker";
|
|
@@ -14585,7 +14639,7 @@ var PdfReporter = class {
|
|
|
14585
14639
|
if (this.options.redact.enabled) {
|
|
14586
14640
|
this.redactor = new Redactor(this.options.redact);
|
|
14587
14641
|
}
|
|
14588
|
-
this.version = true ? "0.
|
|
14642
|
+
this.version = true ? "0.24.0" : "0.x";
|
|
14589
14643
|
logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
|
|
14590
14644
|
this.licenseClient = new LicenseClient({
|
|
14591
14645
|
licenseKey: this.options.licenseKey,
|
|
@@ -14850,18 +14904,8 @@ var PdfReporter = class {
|
|
|
14850
14904
|
this.liveSteps = live.steps;
|
|
14851
14905
|
this.liveConsole = live.console;
|
|
14852
14906
|
void this.license();
|
|
14853
|
-
const token = computeWatchToken(runId, this.options.licenseKey);
|
|
14854
|
-
const watchUrl = token ? buildWatchUrl(serverUrl, runId, token) : void 0;
|
|
14855
14907
|
const env = detectCiEnv();
|
|
14856
14908
|
if (this.redactor && env?.buildUrl) env.buildUrl = this.redactor.redactText(env.buildUrl);
|
|
14857
|
-
if (watchUrl) {
|
|
14858
|
-
void this.announceLive(watchUrl, env, shard).catch(() => {
|
|
14859
|
-
});
|
|
14860
|
-
} else {
|
|
14861
|
-
logger.warn(
|
|
14862
|
-
"Live is enabled but no RF_LICENSE_KEY was found \u2014 cannot mint a watch link or stream. Set RF_LICENSE_KEY (or the `licenseKey` option)."
|
|
14863
|
-
);
|
|
14864
|
-
}
|
|
14865
14909
|
this.liveStreamer = new LiveStreamer({
|
|
14866
14910
|
serverUrl,
|
|
14867
14911
|
runId,
|
|
@@ -14872,16 +14916,27 @@ var PdfReporter = class {
|
|
|
14872
14916
|
env,
|
|
14873
14917
|
flushMs: live.flushMs
|
|
14874
14918
|
});
|
|
14919
|
+
if (!this.options.licenseKey?.trim() && !process.env.RF_LICENSE_KEY?.trim()) {
|
|
14920
|
+
logger.warn(
|
|
14921
|
+
"Live is enabled but no RF_LICENSE_KEY was found \u2014 cannot mint a watch link or stream. Set RF_LICENSE_KEY (or the `licenseKey` option)."
|
|
14922
|
+
);
|
|
14923
|
+
return;
|
|
14924
|
+
}
|
|
14925
|
+
void this.announceLive(serverUrl, runId, env, shard).catch(() => {
|
|
14926
|
+
});
|
|
14875
14927
|
}
|
|
14876
14928
|
/**
|
|
14877
14929
|
* Announces the watch link once the run is confirmed live-entitled. Gating on
|
|
14878
14930
|
* the SAME resolved entitlement the streamer requires (`jwt` + the `'live'`
|
|
14879
14931
|
* feature) guarantees the link is never printed or posted for a run that will
|
|
14880
|
-
* never stream — no dead links. The
|
|
14881
|
-
*
|
|
14882
|
-
*
|
|
14932
|
+
* never stream — no dead links. The link's token is the server-ISSUED one from
|
|
14933
|
+
* the announce handshake (random, key-independent — a key rotation can never
|
|
14934
|
+
* kill the link); the key-derived HMAC is minted only as a fallback when the
|
|
14935
|
+
* handshake fails (network blip, older server). The chat ping fires from the
|
|
14936
|
+
* primary shard only (unsharded, or `shard.current === 1`) so an N-way matrix
|
|
14937
|
+
* posts one message, not N. The banner still prints from every shard's own log.
|
|
14883
14938
|
*/
|
|
14884
|
-
async announceLive(
|
|
14939
|
+
async announceLive(serverUrl, runId, env, shard) {
|
|
14885
14940
|
const info = await this.license();
|
|
14886
14941
|
if (!info) {
|
|
14887
14942
|
logger.warn("Live is enabled but no active license resolved \u2014 no watch link or streaming.");
|
|
@@ -14899,6 +14954,13 @@ var PdfReporter = class {
|
|
|
14899
14954
|
);
|
|
14900
14955
|
return;
|
|
14901
14956
|
}
|
|
14957
|
+
const issued = await this.liveStreamer?.announce();
|
|
14958
|
+
const token = issued ?? computeWatchToken(runId, this.options.licenseKey);
|
|
14959
|
+
if (!token) {
|
|
14960
|
+
logger.warn("Live is enabled but no watch link could be established \u2014 streaming continues without one.");
|
|
14961
|
+
return;
|
|
14962
|
+
}
|
|
14963
|
+
const watchUrl = buildWatchUrl(serverUrl, runId, token);
|
|
14902
14964
|
if (isLevelEnabled("info")) {
|
|
14903
14965
|
console.log(formatLiveBanner(watchUrl));
|
|
14904
14966
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reportforge/playwright-pdf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Playwright Test reporter that generates designed PDF reports: minimal, detailed, and executive templates with CI/CD integrations",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"author": "ReportForge",
|