executable-stories-formatters 0.10.0 → 0.11.1
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 +35 -0
- package/dist/adapters.d.cts +1 -1
- package/dist/adapters.d.ts +1 -1
- package/dist/cli.js +1667 -149
- package/dist/cli.js.map +1 -1
- package/dist/{index-CbWFyoTx.d.cts → index-DF16Xl5i.d.cts} +7 -0
- package/dist/{index-CbWFyoTx.d.ts → index-DF16Xl5i.d.ts} +7 -0
- package/dist/index.cjs +372 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +129 -7
- package/dist/index.d.ts +129 -7
- package/dist/index.js +363 -42
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/schemas/raw-run.schema.json +19 -0
- package/schemas/story-report-v1.json +17 -0
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs } from "util";
|
|
5
|
-
import * as
|
|
6
|
-
import * as
|
|
5
|
+
import * as fs14 from "fs";
|
|
6
|
+
import * as path15 from "path";
|
|
7
7
|
|
|
8
8
|
// src/validation/schema-validator.ts
|
|
9
9
|
import Ajv from "ajv/dist/2020.js";
|
|
@@ -209,6 +209,11 @@ var raw_run_schema_default = {
|
|
|
209
209
|
type: "integer",
|
|
210
210
|
minimum: 0,
|
|
211
211
|
description: "Order in which the story was defined in source (for stable sorting)."
|
|
212
|
+
},
|
|
213
|
+
otelSpans: {
|
|
214
|
+
type: "array",
|
|
215
|
+
items: { type: "object" },
|
|
216
|
+
description: "OpenTelemetry spans captured for this scenario (from autotel), used to render a trace waterfall."
|
|
212
217
|
}
|
|
213
218
|
},
|
|
214
219
|
required: ["scenario"],
|
|
@@ -397,6 +402,20 @@ var raw_run_schema_default = {
|
|
|
397
402
|
required: ["kind", "path", "phase"],
|
|
398
403
|
additionalProperties: false
|
|
399
404
|
},
|
|
405
|
+
{
|
|
406
|
+
type: "object",
|
|
407
|
+
description: "Video reference with optional caption and poster image.",
|
|
408
|
+
properties: {
|
|
409
|
+
kind: { const: "video" },
|
|
410
|
+
path: { type: "string" },
|
|
411
|
+
caption: { type: "string" },
|
|
412
|
+
poster: { type: "string" },
|
|
413
|
+
phase: { $ref: "#/$defs/DocPhase" },
|
|
414
|
+
children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
|
|
415
|
+
},
|
|
416
|
+
required: ["kind", "path", "phase"],
|
|
417
|
+
additionalProperties: false
|
|
418
|
+
},
|
|
400
419
|
{
|
|
401
420
|
type: "object",
|
|
402
421
|
description: "Custom documentation entry with arbitrary data.",
|
|
@@ -539,17 +558,17 @@ function validateRawRun(data) {
|
|
|
539
558
|
return { valid: true, errors: [] };
|
|
540
559
|
}
|
|
541
560
|
const errors = (validate.errors ?? []).map((err) => {
|
|
542
|
-
const
|
|
561
|
+
const path16 = err.instancePath || "/";
|
|
543
562
|
const message = err.message ?? "unknown error";
|
|
544
563
|
if (err.keyword === "additionalProperties") {
|
|
545
564
|
const extra = err.params.additionalProperty;
|
|
546
|
-
return `${
|
|
565
|
+
return `${path16}: ${message} \u2014 '${extra}'`;
|
|
547
566
|
}
|
|
548
567
|
if (err.keyword === "enum") {
|
|
549
568
|
const allowed = err.params.allowedValues;
|
|
550
|
-
return `${
|
|
569
|
+
return `${path16}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
|
|
551
570
|
}
|
|
552
|
-
return `${
|
|
571
|
+
return `${path16}: ${message}`;
|
|
553
572
|
});
|
|
554
573
|
return { valid: false, errors };
|
|
555
574
|
}
|
|
@@ -1024,7 +1043,7 @@ ${result.errors.join("\n")}`);
|
|
|
1024
1043
|
|
|
1025
1044
|
// src/index.ts
|
|
1026
1045
|
import "fs";
|
|
1027
|
-
import * as
|
|
1046
|
+
import * as path9 from "path";
|
|
1028
1047
|
import * as fsPromises from "fs/promises";
|
|
1029
1048
|
|
|
1030
1049
|
// src/converters/acl/lines.ts
|
|
@@ -1486,6 +1505,15 @@ function copyDocEntry(entry) {
|
|
|
1486
1505
|
phase: entry.phase,
|
|
1487
1506
|
...children
|
|
1488
1507
|
};
|
|
1508
|
+
case "video":
|
|
1509
|
+
return {
|
|
1510
|
+
kind: "video",
|
|
1511
|
+
path: entry.path,
|
|
1512
|
+
...entry.caption ? { caption: entry.caption } : {},
|
|
1513
|
+
...entry.poster ? { poster: entry.poster } : {},
|
|
1514
|
+
phase: entry.phase,
|
|
1515
|
+
...children
|
|
1516
|
+
};
|
|
1489
1517
|
case "custom":
|
|
1490
1518
|
return {
|
|
1491
1519
|
kind: "custom",
|
|
@@ -1603,23 +1631,23 @@ function buildFeature(relSourceFile, group) {
|
|
|
1603
1631
|
function ensureUniqueFeatureIds(features) {
|
|
1604
1632
|
const seen = /* @__PURE__ */ new Map();
|
|
1605
1633
|
for (const f of features) {
|
|
1606
|
-
const
|
|
1607
|
-
if (
|
|
1608
|
-
seen.set(f.id,
|
|
1634
|
+
const count2 = seen.get(f.id) ?? 0;
|
|
1635
|
+
if (count2 > 0) f.id = `${f.id}-${count2 + 1}`;
|
|
1636
|
+
seen.set(f.id, count2 + 1);
|
|
1609
1637
|
}
|
|
1610
1638
|
}
|
|
1611
1639
|
function ensureUniqueScenarioIds(feature) {
|
|
1612
1640
|
const seen = /* @__PURE__ */ new Map();
|
|
1613
1641
|
for (const s of feature.scenarios) {
|
|
1614
|
-
const
|
|
1615
|
-
if (
|
|
1616
|
-
const newId = `${s.id}-${
|
|
1642
|
+
const count2 = seen.get(s.id) ?? 0;
|
|
1643
|
+
if (count2 > 0) {
|
|
1644
|
+
const newId = `${s.id}-${count2 + 1}`;
|
|
1617
1645
|
for (const step of s.steps) {
|
|
1618
1646
|
step.id = step.id.replace(s.id, newId);
|
|
1619
1647
|
}
|
|
1620
1648
|
s.id = newId;
|
|
1621
1649
|
}
|
|
1622
|
-
seen.set(s.id,
|
|
1650
|
+
seen.set(s.id, count2 + 1);
|
|
1623
1651
|
}
|
|
1624
1652
|
}
|
|
1625
1653
|
function toStoryReport(run) {
|
|
@@ -14600,10 +14628,10 @@ function renderDocMermaid(entry, deps) {
|
|
|
14600
14628
|
function renderDocScreenshot(entry, deps) {
|
|
14601
14629
|
const alt = entry.alt ?? "Screenshot";
|
|
14602
14630
|
const embedEnabled = deps.embedScreenshots ?? true;
|
|
14603
|
-
const
|
|
14604
|
-
const embedAttempted = !
|
|
14631
|
+
const isRemote2 = /^(?:https?:|data:)/i.test(entry.path);
|
|
14632
|
+
const embedAttempted = !isRemote2 && embedEnabled && !!deps.readScreenshot;
|
|
14605
14633
|
const inlined = embedAttempted ? deps.readScreenshot(entry.path) : void 0;
|
|
14606
|
-
const isAbsoluteFsPath = !
|
|
14634
|
+
const isAbsoluteFsPath = !isRemote2 && /^(?:[/\\]|[A-Za-z]:[/\\])/.test(entry.path);
|
|
14607
14635
|
if (embedAttempted && inlined === void 0 && isAbsoluteFsPath) {
|
|
14608
14636
|
const captionHtml = entry.alt ? `<div class="doc-screenshot-caption">${deps.escapeHtml(entry.alt)}</div>` : "";
|
|
14609
14637
|
return `<div class="doc-screenshot doc-screenshot-missing">
|
|
@@ -14618,6 +14646,23 @@ function renderDocScreenshot(entry, deps) {
|
|
|
14618
14646
|
${entry.alt ? `<div class="doc-screenshot-caption">${deps.escapeHtml(entry.alt)}</div>` : ""}
|
|
14619
14647
|
</div>`;
|
|
14620
14648
|
}
|
|
14649
|
+
function renderDocVideo(entry, deps) {
|
|
14650
|
+
const isRemote2 = /^(?:https?:|data:)/i.test(entry.path);
|
|
14651
|
+
const isAbsoluteFsPath = !isRemote2 && /^(?:[/\\]|[A-Za-z]:[/\\])/.test(entry.path);
|
|
14652
|
+
const captionHtml = entry.caption ? `<div class="doc-video-caption">${deps.escapeHtml(entry.caption)}</div>` : "";
|
|
14653
|
+
if ((deps.embedScreenshots ?? true) && isAbsoluteFsPath) {
|
|
14654
|
+
return `<div class="doc-video doc-video-missing">
|
|
14655
|
+
<div class="doc-video-missing-label">Video unavailable</div>
|
|
14656
|
+
<div class="doc-video-missing-path">${deps.escapeHtml(entry.path)}</div>
|
|
14657
|
+
${captionHtml}
|
|
14658
|
+
</div>`;
|
|
14659
|
+
}
|
|
14660
|
+
const poster = entry.poster ? ` poster="${deps.escapeHtml(entry.poster)}"` : "";
|
|
14661
|
+
return `<div class="doc-video">
|
|
14662
|
+
<video class="doc-video-player" controls preload="metadata"${poster} src="${deps.escapeHtml(entry.path)}"></video>
|
|
14663
|
+
${captionHtml}
|
|
14664
|
+
</div>`;
|
|
14665
|
+
}
|
|
14621
14666
|
function renderDocCustom(entry, deps) {
|
|
14622
14667
|
if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
|
|
14623
14668
|
const data = entry.data;
|
|
@@ -14671,6 +14716,9 @@ function renderDocEntry(entry, deps) {
|
|
|
14671
14716
|
case "screenshot":
|
|
14672
14717
|
html = renderDocScreenshot(entry, deps);
|
|
14673
14718
|
break;
|
|
14719
|
+
case "video":
|
|
14720
|
+
html = renderDocVideo(entry, deps);
|
|
14721
|
+
break;
|
|
14674
14722
|
case "custom":
|
|
14675
14723
|
html = renderDocCustom(entry, deps);
|
|
14676
14724
|
break;
|
|
@@ -15992,6 +16040,19 @@ var MarkdownFormatter = class {
|
|
|
15992
16040
|
case "screenshot":
|
|
15993
16041
|
lines.push(`${indent}`);
|
|
15994
16042
|
break;
|
|
16043
|
+
case "video": {
|
|
16044
|
+
const poster = entry.poster ? ` poster="${entry.poster}"` : "";
|
|
16045
|
+
lines.push(`${indent}`);
|
|
16046
|
+
lines.push(`${indent}<video controls preload="metadata"${poster} class="doc-video">`);
|
|
16047
|
+
lines.push(`${indent} <source src="${entry.path}" />`);
|
|
16048
|
+
lines.push(`${indent}</video>`);
|
|
16049
|
+
if (entry.caption) {
|
|
16050
|
+
lines.push(`${indent}`);
|
|
16051
|
+
lines.push(`${indent}*${entry.caption}*`);
|
|
16052
|
+
}
|
|
16053
|
+
lines.push(`${indent}`);
|
|
16054
|
+
break;
|
|
16055
|
+
}
|
|
15995
16056
|
case "custom":
|
|
15996
16057
|
if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
|
|
15997
16058
|
const data = entry.data;
|
|
@@ -16084,6 +16145,88 @@ function groupBy6(items, keyFn) {
|
|
|
16084
16145
|
return map;
|
|
16085
16146
|
}
|
|
16086
16147
|
|
|
16148
|
+
// src/formatters/release-manifest.ts
|
|
16149
|
+
import { createHash as createHash2 } from "crypto";
|
|
16150
|
+
var ReleaseManifestFormatter = class {
|
|
16151
|
+
format(run) {
|
|
16152
|
+
const manifest = toReleaseManifest(run);
|
|
16153
|
+
const lines = [];
|
|
16154
|
+
lines.push("# Release Manifest");
|
|
16155
|
+
lines.push("");
|
|
16156
|
+
lines.push(`Generated: ${manifest.generatedAt}`);
|
|
16157
|
+
lines.push(`Run: ${manifest.run.startedAt} to ${manifest.run.finishedAt}`);
|
|
16158
|
+
if (manifest.run.branch) lines.push(`Branch: ${manifest.run.branch}`);
|
|
16159
|
+
if (manifest.run.gitSha) lines.push(`Commit: ${manifest.run.gitSha}`);
|
|
16160
|
+
lines.push(`Tested-together hash: \`${manifest.testedTogetherHash}\``);
|
|
16161
|
+
lines.push("");
|
|
16162
|
+
lines.push("| Scenarios | Passed | Failed | Skipped | Pending |");
|
|
16163
|
+
lines.push("| ---: | ---: | ---: | ---: | ---: |");
|
|
16164
|
+
lines.push(`| ${manifest.run.total} | ${manifest.run.passed} | ${manifest.run.failed} | ${manifest.run.skipped} | ${manifest.run.pending} |`);
|
|
16165
|
+
lines.push("");
|
|
16166
|
+
lines.push("## Scenarios");
|
|
16167
|
+
lines.push("");
|
|
16168
|
+
lines.push("| Status | Scenario | Source | Tags |");
|
|
16169
|
+
lines.push("| --- | --- | --- | --- |");
|
|
16170
|
+
for (const scenario of manifest.scenarios) {
|
|
16171
|
+
const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
|
|
16172
|
+
const tags = scenario.tags.length > 0 ? scenario.tags.map((tag) => `\`${tag}\``).join(", ") : "";
|
|
16173
|
+
lines.push(`| ${renderStatus(scenario.status)} | ${escapePipe(scenario.title)} | \`${source}\` | ${tags} |`);
|
|
16174
|
+
}
|
|
16175
|
+
return lines.join("\n");
|
|
16176
|
+
}
|
|
16177
|
+
};
|
|
16178
|
+
function toReleaseManifest(run) {
|
|
16179
|
+
const scenarios = [...run.testCases].sort((a, b) => a.id.localeCompare(b.id)).map((tc) => ({
|
|
16180
|
+
id: tc.id,
|
|
16181
|
+
title: tc.story.scenario,
|
|
16182
|
+
status: tc.status,
|
|
16183
|
+
sourceFile: tc.sourceFile,
|
|
16184
|
+
sourceLine: tc.sourceLine,
|
|
16185
|
+
tags: tc.tags
|
|
16186
|
+
}));
|
|
16187
|
+
const fingerprint = scenarios.map((scenario) => `${scenario.id}:${scenario.status}`).join("\n");
|
|
16188
|
+
return {
|
|
16189
|
+
schemaVersion: "1.0",
|
|
16190
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16191
|
+
run: {
|
|
16192
|
+
startedAt: new Date(run.startedAtMs).toISOString(),
|
|
16193
|
+
finishedAt: new Date(run.finishedAtMs).toISOString(),
|
|
16194
|
+
gitSha: run.gitSha,
|
|
16195
|
+
branch: getBranch(run),
|
|
16196
|
+
total: run.testCases.length,
|
|
16197
|
+
passed: count(run.testCases, "passed"),
|
|
16198
|
+
failed: count(run.testCases, "failed"),
|
|
16199
|
+
skipped: count(run.testCases, "skipped"),
|
|
16200
|
+
pending: count(run.testCases, "pending")
|
|
16201
|
+
},
|
|
16202
|
+
testedTogetherHash: createHash2("sha256").update(fingerprint).digest("hex"),
|
|
16203
|
+
scenarios
|
|
16204
|
+
};
|
|
16205
|
+
}
|
|
16206
|
+
function getBranch(run) {
|
|
16207
|
+
return run.ci?.branch;
|
|
16208
|
+
}
|
|
16209
|
+
function count(testCases, status) {
|
|
16210
|
+
return testCases.filter((tc) => tc.status === status).length;
|
|
16211
|
+
}
|
|
16212
|
+
function renderStatus(status) {
|
|
16213
|
+
switch (status) {
|
|
16214
|
+
case "passed":
|
|
16215
|
+
return "passed";
|
|
16216
|
+
case "failed":
|
|
16217
|
+
return "failed";
|
|
16218
|
+
case "skipped":
|
|
16219
|
+
return "skipped";
|
|
16220
|
+
case "pending":
|
|
16221
|
+
return "pending";
|
|
16222
|
+
default:
|
|
16223
|
+
return status;
|
|
16224
|
+
}
|
|
16225
|
+
}
|
|
16226
|
+
function escapePipe(value) {
|
|
16227
|
+
return value.replace(/\|/g, "\\|");
|
|
16228
|
+
}
|
|
16229
|
+
|
|
16087
16230
|
// src/formatters/cucumber-messages/synthesize-feature.ts
|
|
16088
16231
|
function extractFeatureName(testCases, uri) {
|
|
16089
16232
|
for (const tc of testCases) {
|
|
@@ -16091,8 +16234,8 @@ function extractFeatureName(testCases, uri) {
|
|
|
16091
16234
|
return tc.titlePath[0];
|
|
16092
16235
|
}
|
|
16093
16236
|
}
|
|
16094
|
-
const
|
|
16095
|
-
return
|
|
16237
|
+
const basename4 = uri.replace(/^.*[\\/]/, "").replace(/\.[^.]+$/, "");
|
|
16238
|
+
return basename4.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
16096
16239
|
}
|
|
16097
16240
|
function synthesizeFeature(uri, testCases) {
|
|
16098
16241
|
const featureName = extractFeatureName(testCases, uri);
|
|
@@ -16150,7 +16293,7 @@ function synthesizeFeature(uri, testCases) {
|
|
|
16150
16293
|
}
|
|
16151
16294
|
|
|
16152
16295
|
// src/utils/cucumber-messages.ts
|
|
16153
|
-
import { createHash as
|
|
16296
|
+
import { createHash as createHash3 } from "crypto";
|
|
16154
16297
|
function msToTimestamp(ms) {
|
|
16155
16298
|
const seconds = Math.floor(ms / 1e3);
|
|
16156
16299
|
const nanos = Math.round(ms % 1e3 * 1e6);
|
|
@@ -16216,7 +16359,7 @@ function statusToCucumberStatus(status) {
|
|
|
16216
16359
|
}
|
|
16217
16360
|
function deterministicId(kind, salt, ...parts) {
|
|
16218
16361
|
const input = [salt, kind, ...parts].join("::");
|
|
16219
|
-
return
|
|
16362
|
+
return createHash3("sha1").update(input).digest("hex").slice(0, 36);
|
|
16220
16363
|
}
|
|
16221
16364
|
|
|
16222
16365
|
// src/formatters/cucumber-messages/build-gherkin-document.ts
|
|
@@ -16704,8 +16847,8 @@ function extractDocAttachments(step) {
|
|
|
16704
16847
|
}
|
|
16705
16848
|
return attachments;
|
|
16706
16849
|
}
|
|
16707
|
-
function guessMediaType(
|
|
16708
|
-
const lower =
|
|
16850
|
+
function guessMediaType(path16) {
|
|
16851
|
+
const lower = path16.toLowerCase();
|
|
16709
16852
|
if (lower.endsWith(".png")) return "image/png";
|
|
16710
16853
|
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
16711
16854
|
if (lower.endsWith(".gif")) return "image/gif";
|
|
@@ -16846,11 +16989,11 @@ var CucumberHtmlFormatter = class {
|
|
|
16846
16989
|
for (const envelope of envelopes) {
|
|
16847
16990
|
const accepted = htmlStream.write(envelope);
|
|
16848
16991
|
if (!accepted) {
|
|
16849
|
-
await new Promise((
|
|
16992
|
+
await new Promise((resolve12) => htmlStream.once("drain", resolve12));
|
|
16850
16993
|
}
|
|
16851
16994
|
}
|
|
16852
|
-
await new Promise((
|
|
16853
|
-
collector.on("finish",
|
|
16995
|
+
await new Promise((resolve12, reject) => {
|
|
16996
|
+
collector.on("finish", resolve12);
|
|
16854
16997
|
collector.on("error", reject);
|
|
16855
16998
|
htmlStream.end();
|
|
16856
16999
|
});
|
|
@@ -16944,15 +17087,15 @@ function createPrCommentSummary(diff, maxScenarios = 10) {
|
|
|
16944
17087
|
function getCommit(run) {
|
|
16945
17088
|
return run.ci?.commitSha ?? run.gitSha;
|
|
16946
17089
|
}
|
|
16947
|
-
function
|
|
17090
|
+
function getBranch2(run) {
|
|
16948
17091
|
return run.ci?.branch;
|
|
16949
17092
|
}
|
|
16950
17093
|
function pickAutoBaseline(currentRun, candidates) {
|
|
16951
|
-
const currentBranch =
|
|
17094
|
+
const currentBranch = getBranch2(currentRun);
|
|
16952
17095
|
const currentCommit = getCommit(currentRun);
|
|
16953
17096
|
return [...candidates].sort((a, b) => {
|
|
16954
|
-
const aSameBranch = Boolean(currentBranch &&
|
|
16955
|
-
const bSameBranch = Boolean(currentBranch &&
|
|
17097
|
+
const aSameBranch = Boolean(currentBranch && getBranch2(a.run) && currentBranch === getBranch2(a.run));
|
|
17098
|
+
const bSameBranch = Boolean(currentBranch && getBranch2(b.run) && currentBranch === getBranch2(b.run));
|
|
16956
17099
|
if (aSameBranch !== bSameBranch) {
|
|
16957
17100
|
return Number(bSameBranch) - Number(aSameBranch);
|
|
16958
17101
|
}
|
|
@@ -17181,6 +17324,8 @@ function formatDocEntry(doc) {
|
|
|
17181
17324
|
return `${escapeHtml2(doc.title ?? "mermaid diagram")}: <code>${escapeHtml2(doc.code)}</code>`;
|
|
17182
17325
|
case "screenshot":
|
|
17183
17326
|
return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
|
|
17327
|
+
case "video":
|
|
17328
|
+
return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
|
|
17184
17329
|
case "custom":
|
|
17185
17330
|
return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
|
|
17186
17331
|
}
|
|
@@ -17637,6 +17782,8 @@ function formatDocEntry2(doc) {
|
|
|
17637
17782
|
return `${doc.title ?? "mermaid diagram"}: \`${doc.code}\``;
|
|
17638
17783
|
case "screenshot":
|
|
17639
17784
|
return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
|
|
17785
|
+
case "video":
|
|
17786
|
+
return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
|
|
17640
17787
|
case "custom":
|
|
17641
17788
|
return `${doc.type}: ${JSON.stringify(doc.data)}`;
|
|
17642
17789
|
}
|
|
@@ -17882,19 +18029,35 @@ function replaceAssetRef(html, original, replacement) {
|
|
|
17882
18029
|
return html;
|
|
17883
18030
|
}
|
|
17884
18031
|
|
|
18032
|
+
// src/utils/source-file.ts
|
|
18033
|
+
function cleanTestStem(fileName) {
|
|
18034
|
+
const base = fileName.split(/[\\/]/).pop() ?? fileName;
|
|
18035
|
+
const stripped = base.replace(/\.(story\.)?(test|spec|cy)\.[cm]?[jt]sx?$/i, "");
|
|
18036
|
+
if (stripped !== base) return stripped;
|
|
18037
|
+
return base.replace(/\.[^.]+$/, "");
|
|
18038
|
+
}
|
|
18039
|
+
function humanizeSourceFile(fileName) {
|
|
18040
|
+
return cleanTestStem(fileName).split(/[-_.\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
18041
|
+
}
|
|
18042
|
+
|
|
17885
18043
|
// src/formatters/astro.ts
|
|
17886
18044
|
var AstroFormatter = class _AstroFormatter {
|
|
17887
18045
|
markdownFormatter;
|
|
17888
18046
|
title;
|
|
18047
|
+
perFileTitle;
|
|
17889
18048
|
constructor(options = {}) {
|
|
17890
18049
|
this.title = options.markdown?.title ?? "User Stories";
|
|
18050
|
+
this.perFileTitle = options.perFileTitle ?? false;
|
|
17891
18051
|
this.markdownFormatter = new MarkdownFormatter({
|
|
17892
18052
|
...options.markdown,
|
|
17893
18053
|
title: this.title,
|
|
17894
18054
|
stepStyle: "gherkin",
|
|
17895
18055
|
includeFrontMatter: false,
|
|
17896
18056
|
includeSummaryTable: false,
|
|
17897
|
-
includeMetadata: false
|
|
18057
|
+
includeMetadata: false,
|
|
18058
|
+
// A per-file page is one file already — group by suite/describe so the
|
|
18059
|
+
// body shows clean section headings, not the redundant source path.
|
|
18060
|
+
groupBy: this.perFileTitle ? "suite" : options.markdown?.groupBy ?? "file"
|
|
17898
18061
|
});
|
|
17899
18062
|
}
|
|
17900
18063
|
format(run) {
|
|
@@ -17904,13 +18067,31 @@ var AstroFormatter = class _AstroFormatter {
|
|
|
17904
18067
|
return `${frontmatter}
|
|
17905
18068
|
${body}`;
|
|
17906
18069
|
}
|
|
18070
|
+
/**
|
|
18071
|
+
* Title for the page. A per-file page (one source file — i.e. colocated mode)
|
|
18072
|
+
* is titled by its suite/describe name, falling back to a humanized filename,
|
|
18073
|
+
* so the docs nav reads "Convert Currency" not "User Stories" six times over.
|
|
18074
|
+
* Multi-file (aggregated) pages keep the configured title.
|
|
18075
|
+
*/
|
|
18076
|
+
deriveTitle(run) {
|
|
18077
|
+
if (!this.perFileTitle) return this.title;
|
|
18078
|
+
const sourceFiles = new Set(
|
|
18079
|
+
run.testCases.map((tc) => tc.sourceFile).filter((f) => f && f !== "unknown")
|
|
18080
|
+
);
|
|
18081
|
+
if (sourceFiles.size !== 1) return this.title;
|
|
18082
|
+
const suites = new Set(
|
|
18083
|
+
run.testCases.map((tc) => tc.titlePath?.[0]).filter((s) => Boolean(s))
|
|
18084
|
+
);
|
|
18085
|
+
if (suites.size === 1) return [...suites][0];
|
|
18086
|
+
return humanizeSourceFile([...sourceFiles][0]) || this.title;
|
|
18087
|
+
}
|
|
17907
18088
|
buildFrontmatter(run) {
|
|
17908
18089
|
const badge = _AstroFormatter.computeBadge(run.testCases);
|
|
17909
|
-
const
|
|
17910
|
-
const description = `${
|
|
18090
|
+
const count2 = run.testCases.length;
|
|
18091
|
+
const description = `${count2} scenario${count2 !== 1 ? "s" : ""} \u2014 ${badge.text.toLowerCase()}`;
|
|
17911
18092
|
const lines = [
|
|
17912
18093
|
"---",
|
|
17913
|
-
`title: ${this.
|
|
18094
|
+
`title: ${yamlScalar(this.deriveTitle(run))}`,
|
|
17914
18095
|
`description: ${description}`,
|
|
17915
18096
|
"sidebar:",
|
|
17916
18097
|
" badge:",
|
|
@@ -17928,6 +18109,12 @@ ${body}`;
|
|
|
17928
18109
|
return { text: "Passed", variant: "success" };
|
|
17929
18110
|
}
|
|
17930
18111
|
};
|
|
18112
|
+
function yamlScalar(value) {
|
|
18113
|
+
if (/[:#[\]{}&*!|>'"%@`]|^[\s-]|\s$/.test(value)) {
|
|
18114
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
18115
|
+
}
|
|
18116
|
+
return value;
|
|
18117
|
+
}
|
|
17931
18118
|
|
|
17932
18119
|
// src/formatters/confluence.ts
|
|
17933
18120
|
var ConfluenceFormatter = class {
|
|
@@ -18204,6 +18391,15 @@ ${tc.errorStack}` : "");
|
|
|
18204
18391
|
])
|
|
18205
18392
|
);
|
|
18206
18393
|
break;
|
|
18394
|
+
case "video":
|
|
18395
|
+
content.push(
|
|
18396
|
+
paragraph([
|
|
18397
|
+
text(entry.caption ?? "Video", strong()),
|
|
18398
|
+
text(": "),
|
|
18399
|
+
link(entry.path, entry.path)
|
|
18400
|
+
])
|
|
18401
|
+
);
|
|
18402
|
+
break;
|
|
18207
18403
|
case "custom":
|
|
18208
18404
|
content.push(paragraph([text(`[${entry.type}]`, strong())]));
|
|
18209
18405
|
content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
|
|
@@ -18373,6 +18569,13 @@ function scanMarkdownAssets(markdown) {
|
|
|
18373
18569
|
found.add(src);
|
|
18374
18570
|
}
|
|
18375
18571
|
}
|
|
18572
|
+
const posterRe = /<video[^>]+\bposter=["']([^"']+)["'][^>]*>/gi;
|
|
18573
|
+
while ((match = posterRe.exec(stripped)) !== null) {
|
|
18574
|
+
const src = match[1].trim();
|
|
18575
|
+
if (isLocalPath(src)) {
|
|
18576
|
+
found.add(src);
|
|
18577
|
+
}
|
|
18578
|
+
}
|
|
18376
18579
|
return Array.from(found);
|
|
18377
18580
|
}
|
|
18378
18581
|
function splitByCode(markdown) {
|
|
@@ -18423,6 +18626,19 @@ function rewriteProseSegment(prose, assetsBaseUrl, pathMap) {
|
|
|
18423
18626
|
return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
|
|
18424
18627
|
}
|
|
18425
18628
|
);
|
|
18629
|
+
result = result.replace(
|
|
18630
|
+
/(<video[^>]+\bposter=["'])([^"']+)(["'][^>]*>)/gi,
|
|
18631
|
+
(full, pre, src, post) => {
|
|
18632
|
+
const trimmed = src.trim();
|
|
18633
|
+
if (!isLocalPath(trimmed)) return full;
|
|
18634
|
+
if (pathMap) {
|
|
18635
|
+
const mapped = pathMap.get(trimmed);
|
|
18636
|
+
if (mapped === void 0) return full;
|
|
18637
|
+
return `${pre}${assetsBaseUrl}/${mapped}${post}`;
|
|
18638
|
+
}
|
|
18639
|
+
return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
|
|
18640
|
+
}
|
|
18641
|
+
);
|
|
18426
18642
|
return result;
|
|
18427
18643
|
}
|
|
18428
18644
|
function rewriteAssetPaths(markdown, assetsBaseUrl, pathMap) {
|
|
@@ -19220,10 +19436,10 @@ _...and ${summary.failedTests.length - maxFailedTests} more_`;
|
|
|
19220
19436
|
}
|
|
19221
19437
|
async function sendSlackNotification(args, deps) {
|
|
19222
19438
|
const { summary, webhookUrl, maxFailedTests = 5 } = args;
|
|
19223
|
-
const { fetch, logger } = deps;
|
|
19439
|
+
const { fetch: fetch2, logger } = deps;
|
|
19224
19440
|
const payload = buildSlackPayload(summary, maxFailedTests);
|
|
19225
19441
|
try {
|
|
19226
|
-
const response = await
|
|
19442
|
+
const response = await fetch2(webhookUrl, {
|
|
19227
19443
|
method: "POST",
|
|
19228
19444
|
headers: { "Content-Type": "application/json" },
|
|
19229
19445
|
body: JSON.stringify(payload)
|
|
@@ -19374,10 +19590,10 @@ function buildTeamsPayload(summary, maxFailedTests) {
|
|
|
19374
19590
|
}
|
|
19375
19591
|
async function sendTeamsNotification(args, deps) {
|
|
19376
19592
|
const { summary, webhookUrl, maxFailedTests = 5 } = args;
|
|
19377
|
-
const { fetch, logger } = deps;
|
|
19593
|
+
const { fetch: fetch2, logger } = deps;
|
|
19378
19594
|
const payload = buildTeamsPayload(summary, maxFailedTests);
|
|
19379
19595
|
try {
|
|
19380
|
-
const response = await
|
|
19596
|
+
const response = await fetch2(webhookUrl, {
|
|
19381
19597
|
method: "POST",
|
|
19382
19598
|
headers: { "Content-Type": "application/json" },
|
|
19383
19599
|
body: JSON.stringify(payload)
|
|
@@ -19425,7 +19641,7 @@ function signBody(args) {
|
|
|
19425
19641
|
// src/notifiers/webhook.ts
|
|
19426
19642
|
async function sendWebhookNotification(args, deps) {
|
|
19427
19643
|
const { summary, options } = args;
|
|
19428
|
-
const { fetch, logger } = deps;
|
|
19644
|
+
const { fetch: fetch2, logger } = deps;
|
|
19429
19645
|
const payload = {
|
|
19430
19646
|
schemaVersion: 1,
|
|
19431
19647
|
event: "test_run_finished",
|
|
@@ -19447,7 +19663,7 @@ async function sendWebhookNotification(args, deps) {
|
|
|
19447
19663
|
}
|
|
19448
19664
|
}
|
|
19449
19665
|
try {
|
|
19450
|
-
const response = await
|
|
19666
|
+
const response = await fetch2(options.url, {
|
|
19451
19667
|
method: options.method ?? "POST",
|
|
19452
19668
|
headers,
|
|
19453
19669
|
body
|
|
@@ -19526,7 +19742,7 @@ async function sendNotifications(args, deps) {
|
|
|
19526
19742
|
logger.warn("notifications: skipped (fetch unavailable)");
|
|
19527
19743
|
return;
|
|
19528
19744
|
}
|
|
19529
|
-
const
|
|
19745
|
+
const fetch2 = deps.fetch;
|
|
19530
19746
|
const slackWebhookUrl = notification?.slackWebhookUrl ?? env.SLACK_WEBHOOK_URL;
|
|
19531
19747
|
const teamsWebhookUrl = notification?.teamsWebhookUrl ?? env.TEAMS_WEBHOOK_URL;
|
|
19532
19748
|
const globalCondition = notification?.condition ?? "on-failure";
|
|
@@ -19542,7 +19758,7 @@ async function sendNotifications(args, deps) {
|
|
|
19542
19758
|
promises.push(
|
|
19543
19759
|
sendSlackNotification(
|
|
19544
19760
|
{ summary, webhookUrl: slackWebhookUrl, maxFailedTests },
|
|
19545
|
-
{ fetch, logger }
|
|
19761
|
+
{ fetch: fetch2, logger }
|
|
19546
19762
|
).then(() => void 0)
|
|
19547
19763
|
);
|
|
19548
19764
|
}
|
|
@@ -19550,7 +19766,7 @@ async function sendNotifications(args, deps) {
|
|
|
19550
19766
|
promises.push(
|
|
19551
19767
|
sendTeamsNotification(
|
|
19552
19768
|
{ summary, webhookUrl: teamsWebhookUrl, maxFailedTests },
|
|
19553
|
-
{ fetch, logger }
|
|
19769
|
+
{ fetch: fetch2, logger }
|
|
19554
19770
|
).then(() => void 0)
|
|
19555
19771
|
);
|
|
19556
19772
|
}
|
|
@@ -19560,7 +19776,7 @@ async function sendNotifications(args, deps) {
|
|
|
19560
19776
|
promises.push(
|
|
19561
19777
|
sendWebhookNotification(
|
|
19562
19778
|
{ summary, options: webhook, maxFailedTests },
|
|
19563
|
-
{ fetch, logger }
|
|
19779
|
+
{ fetch: fetch2, logger }
|
|
19564
19780
|
).then(() => void 0)
|
|
19565
19781
|
);
|
|
19566
19782
|
}
|
|
@@ -19854,18 +20070,18 @@ function deriveChangeType(tags) {
|
|
|
19854
20070
|
}
|
|
19855
20071
|
return "unknown";
|
|
19856
20072
|
}
|
|
19857
|
-
function extensionOf(
|
|
19858
|
-
const base =
|
|
20073
|
+
function extensionOf(path16) {
|
|
20074
|
+
const base = path16.split("/").pop() ?? path16;
|
|
19859
20075
|
const dot = base.lastIndexOf(".");
|
|
19860
20076
|
return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
|
|
19861
20077
|
}
|
|
19862
|
-
function isTestFile(
|
|
19863
|
-
return TEST_INFIX.test(
|
|
20078
|
+
function isTestFile(path16) {
|
|
20079
|
+
return TEST_INFIX.test(path16);
|
|
19864
20080
|
}
|
|
19865
|
-
function isReviewableSource(
|
|
19866
|
-
if (isTestFile(
|
|
19867
|
-
if (
|
|
19868
|
-
return CODE_EXTENSIONS.has(extensionOf(
|
|
20081
|
+
function isReviewableSource(path16) {
|
|
20082
|
+
if (isTestFile(path16)) return false;
|
|
20083
|
+
if (path16.endsWith(".d.ts")) return false;
|
|
20084
|
+
return CODE_EXTENSIONS.has(extensionOf(path16));
|
|
19869
20085
|
}
|
|
19870
20086
|
function testBaseKey(testFile) {
|
|
19871
20087
|
return testFile.replace(TEST_INFIX, "");
|
|
@@ -19969,7 +20185,7 @@ function toClaim(testCase, changedSourcePaths) {
|
|
|
19969
20185
|
const { strength, reasons } = gradeEvidence(testCase, audience);
|
|
19970
20186
|
const key = testBaseKey(testCase.sourceFile);
|
|
19971
20187
|
const coversFiles = changedSourcePaths.filter(
|
|
19972
|
-
(
|
|
20188
|
+
(path16) => sourceBaseKey(path16) === key
|
|
19973
20189
|
);
|
|
19974
20190
|
return {
|
|
19975
20191
|
id: testCase.id,
|
|
@@ -20499,11 +20715,116 @@ applyTheme(getEffectiveTheme());` : "";
|
|
|
20499
20715
|
}
|
|
20500
20716
|
};
|
|
20501
20717
|
|
|
20718
|
+
// src/deploy/ledger.ts
|
|
20719
|
+
import * as fs7 from "fs";
|
|
20720
|
+
import * as path8 from "path";
|
|
20721
|
+
function createEmptyLedger() {
|
|
20722
|
+
return {
|
|
20723
|
+
deployments: [],
|
|
20724
|
+
schemaVersion: 1
|
|
20725
|
+
};
|
|
20726
|
+
}
|
|
20727
|
+
function loadLedger(ledgerPath) {
|
|
20728
|
+
const resolved = path8.resolve(ledgerPath);
|
|
20729
|
+
if (!fs7.existsSync(resolved)) {
|
|
20730
|
+
return createEmptyLedger();
|
|
20731
|
+
}
|
|
20732
|
+
try {
|
|
20733
|
+
const raw = JSON.parse(fs7.readFileSync(resolved, "utf8"));
|
|
20734
|
+
if (raw.schemaVersion !== 1) {
|
|
20735
|
+
throw new Error(`Unsupported ledger schemaVersion: ${raw.schemaVersion}`);
|
|
20736
|
+
}
|
|
20737
|
+
return raw;
|
|
20738
|
+
} catch (err) {
|
|
20739
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
20740
|
+
throw new Error(`Failed to load deployment ledger at ${resolved}: ${msg}`);
|
|
20741
|
+
}
|
|
20742
|
+
}
|
|
20743
|
+
function saveLedger(ledger, ledgerPath) {
|
|
20744
|
+
const resolved = path8.resolve(ledgerPath);
|
|
20745
|
+
const dir = path8.dirname(resolved);
|
|
20746
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
20747
|
+
fs7.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
|
|
20748
|
+
}
|
|
20749
|
+
function getLatestDeployment(ledger, environment) {
|
|
20750
|
+
return [...ledger.deployments].reverse().find((d) => d.environment === environment);
|
|
20751
|
+
}
|
|
20752
|
+
|
|
20753
|
+
// src/deploy/index.ts
|
|
20754
|
+
function recordDeployment(args) {
|
|
20755
|
+
const ledger = loadLedger(args.ledgerPath);
|
|
20756
|
+
const previous = getLatestDeployment(ledger, args.environment);
|
|
20757
|
+
const entry = {
|
|
20758
|
+
environment: args.environment,
|
|
20759
|
+
tag: args.tag,
|
|
20760
|
+
sha: args.run.gitSha,
|
|
20761
|
+
runFile: args.runFilePath,
|
|
20762
|
+
scenarioIds: args.run.testCases.map((tc) => tc.id),
|
|
20763
|
+
scenarioStatuses: Object.fromEntries(args.run.testCases.map((tc) => [tc.id, tc.status])),
|
|
20764
|
+
timestamp: new Date(args.run.finishedAtMs).toISOString(),
|
|
20765
|
+
summary: countStatuses(args.run)
|
|
20766
|
+
};
|
|
20767
|
+
ledger.deployments.push(entry);
|
|
20768
|
+
if (previous) {
|
|
20769
|
+
const previousIds = new Set(previous.scenarioIds);
|
|
20770
|
+
const added = entry.scenarioIds.filter((id) => !previousIds.has(id)).length;
|
|
20771
|
+
const removed = previous.scenarioIds.filter((id) => !entry.scenarioIds.includes(id)).length;
|
|
20772
|
+
if (added > 0 || removed > 0) {
|
|
20773
|
+
}
|
|
20774
|
+
}
|
|
20775
|
+
saveLedger(ledger, args.ledgerPath);
|
|
20776
|
+
return { entry, ledgerPath: args.ledgerPath };
|
|
20777
|
+
}
|
|
20778
|
+
function getDeploymentStatus(ledgerPath) {
|
|
20779
|
+
const ledger = loadLedger(ledgerPath);
|
|
20780
|
+
const environments = {};
|
|
20781
|
+
for (const entry of ledger.deployments) {
|
|
20782
|
+
environments[entry.environment] = {
|
|
20783
|
+
latest: entry,
|
|
20784
|
+
previousDeployment: environments[entry.environment]?.latest
|
|
20785
|
+
};
|
|
20786
|
+
}
|
|
20787
|
+
return { environments, ledgerPath };
|
|
20788
|
+
}
|
|
20789
|
+
function getEnvironmentDrift(ledgerPath, envA, envB) {
|
|
20790
|
+
const ledger = loadLedger(ledgerPath);
|
|
20791
|
+
const aEntry = getLatestDeployment(ledger, envA);
|
|
20792
|
+
const bEntry = getLatestDeployment(ledger, envB);
|
|
20793
|
+
if (!aEntry) {
|
|
20794
|
+
throw new Error(`No deployment found for environment "${envA}"`);
|
|
20795
|
+
}
|
|
20796
|
+
if (!bEntry) {
|
|
20797
|
+
throw new Error(`No deployment found for environment "${envB}"`);
|
|
20798
|
+
}
|
|
20799
|
+
const aIds = new Set(aEntry.scenarioIds);
|
|
20800
|
+
const bIds = new Set(bEntry.scenarioIds);
|
|
20801
|
+
const onlyInA = aEntry.scenarioIds.filter((id) => !bIds.has(id));
|
|
20802
|
+
const onlyInB = bEntry.scenarioIds.filter((id) => !aIds.has(id));
|
|
20803
|
+
const inBoth = aEntry.scenarioIds.filter((id) => bIds.has(id));
|
|
20804
|
+
const statusChanged = inBoth.map((id) => ({
|
|
20805
|
+
id,
|
|
20806
|
+
statusA: aEntry.scenarioStatuses?.[id] ?? "unknown",
|
|
20807
|
+
statusB: bEntry.scenarioStatuses?.[id] ?? "unknown"
|
|
20808
|
+
})).filter((item) => item.statusA !== item.statusB);
|
|
20809
|
+
return { environmentA: envA, environmentB: envB, onlyInA, onlyInB, inBoth, statusChanged, aEntry, bEntry };
|
|
20810
|
+
}
|
|
20811
|
+
function countStatuses(run) {
|
|
20812
|
+
const summary = { total: 0, passed: 0, failed: 0, skipped: 0, pending: 0 };
|
|
20813
|
+
for (const tc of run.testCases) {
|
|
20814
|
+
summary.total++;
|
|
20815
|
+
if (tc.status in summary) {
|
|
20816
|
+
summary[tc.status]++;
|
|
20817
|
+
}
|
|
20818
|
+
}
|
|
20819
|
+
return summary;
|
|
20820
|
+
}
|
|
20821
|
+
|
|
20502
20822
|
// src/index.ts
|
|
20503
20823
|
var FORMAT_EXTENSIONS = {
|
|
20504
20824
|
astro: ".md",
|
|
20505
20825
|
"behavior-manifest-json": ".behavior-manifest.json",
|
|
20506
20826
|
markdown: ".md",
|
|
20827
|
+
"release-manifest": ".release-manifest.md",
|
|
20507
20828
|
html: ".html",
|
|
20508
20829
|
"cucumber-html": ".cucumber.html",
|
|
20509
20830
|
junit: ".junit.xml",
|
|
@@ -20513,6 +20834,10 @@ var FORMAT_EXTENSIONS = {
|
|
|
20513
20834
|
"scenario-index-json": ".scenarios-index.json",
|
|
20514
20835
|
"story-report-json": ".story-report.json"
|
|
20515
20836
|
};
|
|
20837
|
+
function joinNameAndExt(name, ext) {
|
|
20838
|
+
const stutter = `.${name}.`;
|
|
20839
|
+
return ext.startsWith(stutter) ? `${name}.${ext.slice(stutter.length)}` : `${name}${ext}`;
|
|
20840
|
+
}
|
|
20516
20841
|
var TEST_EXTENSIONS = [
|
|
20517
20842
|
".test.ts",
|
|
20518
20843
|
".test.tsx",
|
|
@@ -20538,11 +20863,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
|
|
|
20538
20863
|
const ext = FORMAT_EXTENSIONS[format];
|
|
20539
20864
|
const effectiveName = outputName + (outputNameSuffix ?? "");
|
|
20540
20865
|
if (mode === "aggregated") {
|
|
20541
|
-
return toPosix(
|
|
20866
|
+
return toPosix(path9.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
|
|
20542
20867
|
}
|
|
20543
20868
|
const normalizedSource = toPosix(sourceFile);
|
|
20544
|
-
const dirOfSource =
|
|
20545
|
-
let baseName =
|
|
20869
|
+
const dirOfSource = path9.posix.dirname(normalizedSource);
|
|
20870
|
+
let baseName = path9.posix.basename(normalizedSource);
|
|
20546
20871
|
for (const testExt of TEST_EXTENSIONS) {
|
|
20547
20872
|
if (baseName.endsWith(testExt)) {
|
|
20548
20873
|
baseName = baseName.slice(0, -testExt.length);
|
|
@@ -20551,9 +20876,12 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
|
|
|
20551
20876
|
}
|
|
20552
20877
|
const fileName = `${baseName}.${effectiveName}${ext}`;
|
|
20553
20878
|
if (colocatedStyle === "adjacent") {
|
|
20554
|
-
return toPosix(
|
|
20879
|
+
return toPosix(path9.posix.join(dirOfSource, fileName));
|
|
20880
|
+
}
|
|
20881
|
+
if (colocatedStyle === "flat") {
|
|
20882
|
+
return toPosix(path9.posix.join(baseOutputDir, `${cleanTestStem(normalizedSource)}${ext}`));
|
|
20555
20883
|
}
|
|
20556
|
-
return toPosix(
|
|
20884
|
+
return toPosix(path9.posix.join(baseOutputDir, dirOfSource, fileName));
|
|
20557
20885
|
}
|
|
20558
20886
|
function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
|
|
20559
20887
|
const groups = /* @__PURE__ */ new Map();
|
|
@@ -20760,8 +21088,8 @@ var ReportGenerator = class {
|
|
|
20760
21088
|
if (astroPaths) {
|
|
20761
21089
|
for (const mdPath of astroPaths) {
|
|
20762
21090
|
const content = await fsPromises.readFile(mdPath, "utf8");
|
|
20763
|
-
const mdDir =
|
|
20764
|
-
const assetsDir =
|
|
21091
|
+
const mdDir = path9.dirname(mdPath);
|
|
21092
|
+
const assetsDir = path9.resolve(this.options.astro.assetsDir);
|
|
20765
21093
|
const result = copyMarkdownAssets({
|
|
20766
21094
|
markdown: content,
|
|
20767
21095
|
markdownDir: mdDir,
|
|
@@ -20792,9 +21120,9 @@ var ReportGenerator = class {
|
|
|
20792
21120
|
if (groups.size === 0 && this.options.output.mode === "aggregated") {
|
|
20793
21121
|
const ext = FORMAT_EXTENSIONS[format];
|
|
20794
21122
|
const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
|
|
20795
|
-
const outputPath = toPosix(
|
|
21123
|
+
const outputPath = toPosix(path9.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
|
|
20796
21124
|
const content = await this.formatContent(run, format);
|
|
20797
|
-
const dir =
|
|
21125
|
+
const dir = path9.dirname(outputPath);
|
|
20798
21126
|
await fsPromises.mkdir(dir, { recursive: true });
|
|
20799
21127
|
await this.deps.writeFile(outputPath, content);
|
|
20800
21128
|
return [outputPath];
|
|
@@ -20806,7 +21134,7 @@ var ReportGenerator = class {
|
|
|
20806
21134
|
testCases
|
|
20807
21135
|
};
|
|
20808
21136
|
const content = await this.formatContent(groupRun, format);
|
|
20809
|
-
const dir =
|
|
21137
|
+
const dir = path9.dirname(outputPath);
|
|
20810
21138
|
await fsPromises.mkdir(dir, { recursive: true });
|
|
20811
21139
|
await this.deps.writeFile(outputPath, content);
|
|
20812
21140
|
writtenPaths.push(outputPath);
|
|
@@ -20872,6 +21200,8 @@ var ReportGenerator = class {
|
|
|
20872
21200
|
case "astro": {
|
|
20873
21201
|
const formatter = new AstroFormatter({
|
|
20874
21202
|
assetsBaseUrl: this.options.astro.assetsBaseUrl,
|
|
21203
|
+
// Colocated = one page per file, so title each by its own suite/file.
|
|
21204
|
+
perFileTitle: this.options.output.mode === "colocated",
|
|
20875
21205
|
markdown: this.options.astro.markdown
|
|
20876
21206
|
});
|
|
20877
21207
|
return formatter.format(run);
|
|
@@ -20913,6 +21243,10 @@ var ReportGenerator = class {
|
|
|
20913
21243
|
});
|
|
20914
21244
|
return formatter.format(run);
|
|
20915
21245
|
}
|
|
21246
|
+
case "release-manifest": {
|
|
21247
|
+
const formatter = new ReleaseManifestFormatter();
|
|
21248
|
+
return formatter.format(run);
|
|
21249
|
+
}
|
|
20916
21250
|
case "story-report-json": {
|
|
20917
21251
|
const formatter = new StoryReportJsonFormatter({
|
|
20918
21252
|
pretty: this.options.storyReportJson.pretty
|
|
@@ -20944,7 +21278,7 @@ async function generateRunComparison(args) {
|
|
|
20944
21278
|
await fsPromises.mkdir(outputDir, { recursive: true });
|
|
20945
21279
|
for (const format of args.formats) {
|
|
20946
21280
|
const ext = format === "html" ? ".html" : ".md";
|
|
20947
|
-
const outputPath = toPosix(
|
|
21281
|
+
const outputPath = toPosix(path9.join(outputDir, `${outputName}${ext}`));
|
|
20948
21282
|
const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
|
|
20949
21283
|
await fsPromises.writeFile(outputPath, content, "utf8");
|
|
20950
21284
|
files.push(outputPath);
|
|
@@ -20953,50 +21287,745 @@ async function generateRunComparison(args) {
|
|
|
20953
21287
|
}
|
|
20954
21288
|
|
|
20955
21289
|
// src/init-astro.ts
|
|
20956
|
-
import * as
|
|
20957
|
-
import * as
|
|
21290
|
+
import * as fs9 from "fs";
|
|
21291
|
+
import * as path10 from "path";
|
|
20958
21292
|
import { fileURLToPath } from "url";
|
|
20959
|
-
var __dirname =
|
|
21293
|
+
var __dirname = path10.dirname(fileURLToPath(import.meta.url));
|
|
21294
|
+
var FRAMEWORK_DIRS = ["src/components", "src/lib", "src/styles", "src/pages"];
|
|
21295
|
+
var FRAMEWORK_FILES = ["tsconfig.json"];
|
|
20960
21296
|
function initAstro(options = {}) {
|
|
20961
21297
|
const targetDir = options.targetDir ?? "./story-docs";
|
|
20962
21298
|
const force = options.force ?? false;
|
|
20963
|
-
|
|
20964
|
-
|
|
21299
|
+
const update = options.update ?? false;
|
|
21300
|
+
const templateDir = path10.resolve(__dirname, "..", "templates", "astro-starlight");
|
|
21301
|
+
if (!fs9.existsSync(templateDir)) {
|
|
21302
|
+
throw new Error(
|
|
21303
|
+
`Template directory not found at ${templateDir}. Ensure the package is installed correctly.`
|
|
21304
|
+
);
|
|
21305
|
+
}
|
|
21306
|
+
if (update) {
|
|
21307
|
+
return updateFrameworkFiles(templateDir, targetDir);
|
|
21308
|
+
}
|
|
21309
|
+
if (fs9.existsSync(targetDir)) {
|
|
21310
|
+
const entries = fs9.readdirSync(targetDir);
|
|
20965
21311
|
if (entries.length > 0 && !force) {
|
|
20966
21312
|
throw new Error(
|
|
20967
|
-
`Directory "${targetDir}" already exists and is not empty. Use --force to overwrite.`
|
|
21313
|
+
`Directory "${targetDir}" already exists and is not empty. Use --force to overwrite, or --update to refresh framework files only.`
|
|
20968
21314
|
);
|
|
20969
21315
|
}
|
|
20970
21316
|
}
|
|
20971
|
-
const templateDir = path9.resolve(__dirname, "..", "templates", "astro-starlight");
|
|
20972
|
-
if (!fs8.existsSync(templateDir)) {
|
|
20973
|
-
throw new Error(
|
|
20974
|
-
`Template directory not found at ${templateDir}. Ensure the package is installed correctly.`
|
|
20975
|
-
);
|
|
20976
|
-
}
|
|
20977
21317
|
copyDirRecursive(templateDir, targetDir);
|
|
20978
21318
|
return { targetDir };
|
|
20979
21319
|
}
|
|
20980
|
-
function
|
|
20981
|
-
|
|
20982
|
-
|
|
21320
|
+
function updateFrameworkFiles(templateDir, targetDir) {
|
|
21321
|
+
if (!fs9.existsSync(targetDir) || !fs9.existsSync(path10.join(targetDir, "astro.config.mjs"))) {
|
|
21322
|
+
throw new Error(
|
|
21323
|
+
`"${targetDir}" does not look like a scaffolded docs site. Run init-astro (without --update) first.`
|
|
21324
|
+
);
|
|
21325
|
+
}
|
|
21326
|
+
const updated = [];
|
|
21327
|
+
for (const dir of FRAMEWORK_DIRS) {
|
|
21328
|
+
const src = path10.join(templateDir, dir);
|
|
21329
|
+
if (!fs9.existsSync(src)) continue;
|
|
21330
|
+
copyDirRecursive(src, path10.join(targetDir, dir), (rel) => updated.push(path10.join(dir, rel)));
|
|
21331
|
+
}
|
|
21332
|
+
for (const file of FRAMEWORK_FILES) {
|
|
21333
|
+
const src = path10.join(templateDir, file);
|
|
21334
|
+
if (!fs9.existsSync(src)) continue;
|
|
21335
|
+
fs9.copyFileSync(src, path10.join(targetDir, file));
|
|
21336
|
+
updated.push(file);
|
|
21337
|
+
}
|
|
21338
|
+
if (mergeDependencies(templateDir, targetDir)) updated.push("package.json (deps)");
|
|
21339
|
+
return { targetDir, updatedFiles: updated };
|
|
21340
|
+
}
|
|
21341
|
+
function mergeDependencies(templateDir, targetDir) {
|
|
21342
|
+
const tmplPkgPath = path10.join(templateDir, "package.json");
|
|
21343
|
+
const userPkgPath = path10.join(targetDir, "package.json");
|
|
21344
|
+
if (!fs9.existsSync(tmplPkgPath) || !fs9.existsSync(userPkgPath)) return false;
|
|
21345
|
+
const tmpl = JSON.parse(fs9.readFileSync(tmplPkgPath, "utf8"));
|
|
21346
|
+
const user = JSON.parse(fs9.readFileSync(userPkgPath, "utf8"));
|
|
21347
|
+
user.dependencies = user.dependencies ?? {};
|
|
21348
|
+
let changed = false;
|
|
21349
|
+
for (const [name, version] of Object.entries(tmpl.dependencies ?? {})) {
|
|
21350
|
+
if (!(name in user.dependencies)) {
|
|
21351
|
+
user.dependencies[name] = version;
|
|
21352
|
+
changed = true;
|
|
21353
|
+
}
|
|
21354
|
+
}
|
|
21355
|
+
if (changed) {
|
|
21356
|
+
fs9.writeFileSync(userPkgPath, `${JSON.stringify(user, null, 2)}
|
|
21357
|
+
`, "utf8");
|
|
21358
|
+
}
|
|
21359
|
+
return changed;
|
|
21360
|
+
}
|
|
21361
|
+
function copyDirRecursive(src, dest, onFile, baseSrc = src) {
|
|
21362
|
+
fs9.mkdirSync(dest, { recursive: true });
|
|
21363
|
+
const entries = fs9.readdirSync(src, { withFileTypes: true });
|
|
20983
21364
|
for (const entry of entries) {
|
|
20984
|
-
const srcPath =
|
|
20985
|
-
const destPath =
|
|
21365
|
+
const srcPath = path10.join(src, entry.name);
|
|
21366
|
+
const destPath = path10.join(dest, entry.name);
|
|
20986
21367
|
if (entry.isDirectory()) {
|
|
20987
|
-
copyDirRecursive(srcPath, destPath);
|
|
21368
|
+
copyDirRecursive(srcPath, destPath, onFile, baseSrc);
|
|
20988
21369
|
} else {
|
|
20989
|
-
|
|
21370
|
+
fs9.copyFileSync(srcPath, destPath);
|
|
21371
|
+
onFile?.(path10.relative(baseSrc, srcPath));
|
|
21372
|
+
}
|
|
21373
|
+
}
|
|
21374
|
+
}
|
|
21375
|
+
|
|
21376
|
+
// src/scaffold-doc.ts
|
|
21377
|
+
import * as fs10 from "fs";
|
|
21378
|
+
import * as path11 from "path";
|
|
21379
|
+
var TEMPLATES = ["adr", "runbook", "decision-log", "incident"];
|
|
21380
|
+
function slugify3(input) {
|
|
21381
|
+
return input.toLowerCase().trim().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
|
|
21382
|
+
}
|
|
21383
|
+
function isoDate(today) {
|
|
21384
|
+
return today.toISOString().slice(0, 10);
|
|
21385
|
+
}
|
|
21386
|
+
function nextSeq(dir) {
|
|
21387
|
+
let max = 0;
|
|
21388
|
+
try {
|
|
21389
|
+
for (const entry of fs10.readdirSync(dir)) {
|
|
21390
|
+
const match = /^(\d{1,4})-/.exec(entry);
|
|
21391
|
+
if (match) max = Math.max(max, Number.parseInt(match[1], 10));
|
|
21392
|
+
}
|
|
21393
|
+
} catch {
|
|
21394
|
+
}
|
|
21395
|
+
return String(max + 1).padStart(4, "0");
|
|
21396
|
+
}
|
|
21397
|
+
var COMPONENTS = "../../../components";
|
|
21398
|
+
var TEMPLATE_SPECS = {
|
|
21399
|
+
adr: {
|
|
21400
|
+
subdir: "adr",
|
|
21401
|
+
filename: (slug2, ctx) => `${ctx.seq}-${slug2}`,
|
|
21402
|
+
content: (ctx) => `---
|
|
21403
|
+
title: 'ADR ${ctx.seq} \u2014 ${ctx.name}'
|
|
21404
|
+
description: '${ctx.name}'
|
|
21405
|
+
# Link the stories that prove this decision. The badge under the title turns
|
|
21406
|
+
# red the moment any of them fail, so this record can't drift from the code.
|
|
21407
|
+
verifiedBy: []
|
|
21408
|
+
---
|
|
21409
|
+
|
|
21410
|
+
## Status
|
|
21411
|
+
|
|
21412
|
+
**Proposed** \u2014 proposed \xB7 accepted \xB7 superseded \xB7 deprecated
|
|
21413
|
+
|
|
21414
|
+
## Context
|
|
21415
|
+
|
|
21416
|
+
_What problem are we solving? What constraints and forces apply?_
|
|
21417
|
+
|
|
21418
|
+
## Decision
|
|
21419
|
+
|
|
21420
|
+
_What did we decide to do?_
|
|
21421
|
+
|
|
21422
|
+
## Consequences
|
|
21423
|
+
|
|
21424
|
+
_What becomes easier, and what becomes harder, as a result?_
|
|
21425
|
+
|
|
21426
|
+
## Verified by
|
|
21427
|
+
|
|
21428
|
+
Add the story ids or tags that exercise this decision to \`verifiedBy\` in the
|
|
21429
|
+
frontmatter above. Until you do, the badge reads **Unverified** \u2014 by design.
|
|
21430
|
+
`
|
|
21431
|
+
},
|
|
21432
|
+
runbook: {
|
|
21433
|
+
subdir: "runbooks",
|
|
21434
|
+
filename: (slug2) => slug2,
|
|
21435
|
+
content: (ctx) => `---
|
|
21436
|
+
title: 'Runbook \u2014 ${ctx.name}'
|
|
21437
|
+
description: 'Operational runbook for ${ctx.name}'
|
|
21438
|
+
---
|
|
21439
|
+
|
|
21440
|
+
import Checklist from '${COMPONENTS}/Checklist.astro';
|
|
21441
|
+
import VerifiedStep from '${COMPONENTS}/VerifiedStep.astro';
|
|
21442
|
+
|
|
21443
|
+
_When to use this runbook, prerequisites, and who to contact._
|
|
21444
|
+
|
|
21445
|
+
## Steps
|
|
21446
|
+
|
|
21447
|
+
Each step linked with \`story=\` shows a live green check when its test passed in
|
|
21448
|
+
the last run \u2014 so this runbook is trustworthy, not aspirational.
|
|
21449
|
+
|
|
21450
|
+
<Checklist>
|
|
21451
|
+
<VerifiedStep story="">Describe the first action, and link the story that verifies it.</VerifiedStep>
|
|
21452
|
+
<VerifiedStep>A manual step with no automated check.</VerifiedStep>
|
|
21453
|
+
</Checklist>
|
|
21454
|
+
|
|
21455
|
+
## Rollback
|
|
21456
|
+
|
|
21457
|
+
_How to safely undo if something goes wrong._
|
|
21458
|
+
`
|
|
21459
|
+
},
|
|
21460
|
+
"decision-log": {
|
|
21461
|
+
subdir: "decisions",
|
|
21462
|
+
filename: (slug2) => slug2,
|
|
21463
|
+
content: (ctx) => `---
|
|
21464
|
+
title: 'Decision log \u2014 ${ctx.name}'
|
|
21465
|
+
description: 'Running log of decisions for ${ctx.name}'
|
|
21466
|
+
---
|
|
21467
|
+
|
|
21468
|
+
A lightweight running log. For weightier decisions, scaffold a full ADR with
|
|
21469
|
+
\`executable-stories new adr\`.
|
|
21470
|
+
|
|
21471
|
+
| Date | Decision | Owner | Verified by |
|
|
21472
|
+
| ---- | -------- | ----- | ----------- |
|
|
21473
|
+
| ${ctx.isoDate} | _What was decided_ | _Who_ | _story id or tag_ |
|
|
21474
|
+
`
|
|
21475
|
+
},
|
|
21476
|
+
incident: {
|
|
21477
|
+
subdir: "incidents",
|
|
21478
|
+
filename: (slug2, ctx) => `${ctx.isoDate}-${slug2}`,
|
|
21479
|
+
content: (ctx) => `---
|
|
21480
|
+
title: 'Incident \u2014 ${ctx.name}'
|
|
21481
|
+
description: 'Post-mortem for ${ctx.name}'
|
|
21482
|
+
# Link the regression story added to stop this recurring.
|
|
21483
|
+
verifiedBy: []
|
|
21484
|
+
---
|
|
21485
|
+
|
|
21486
|
+
## Summary
|
|
21487
|
+
|
|
21488
|
+
_What happened, who was affected, and for how long._
|
|
21489
|
+
|
|
21490
|
+
## Timeline
|
|
21491
|
+
|
|
21492
|
+
| Time | Event |
|
|
21493
|
+
| ---- | ----- |
|
|
21494
|
+
| ${ctx.isoDate} | Detected |
|
|
21495
|
+
|
|
21496
|
+
## Root cause
|
|
21497
|
+
|
|
21498
|
+
_The underlying cause, not just the trigger._
|
|
21499
|
+
|
|
21500
|
+
## Resolution
|
|
21501
|
+
|
|
21502
|
+
_How it was fixed._
|
|
21503
|
+
|
|
21504
|
+
## Action items
|
|
21505
|
+
|
|
21506
|
+
- [ ] Add a regression story and link it in \`verifiedBy\` so a silent recurrence
|
|
21507
|
+
becomes a failing badge.
|
|
21508
|
+
`
|
|
21509
|
+
}
|
|
21510
|
+
};
|
|
21511
|
+
function isTemplateName(value) {
|
|
21512
|
+
return TEMPLATES.includes(value);
|
|
21513
|
+
}
|
|
21514
|
+
function scaffoldDoc(options) {
|
|
21515
|
+
const { template } = options;
|
|
21516
|
+
if (!isTemplateName(template)) {
|
|
21517
|
+
throw new Error(
|
|
21518
|
+
`Unknown template "${template}". Available: ${TEMPLATES.join(", ")}.`
|
|
21519
|
+
);
|
|
21520
|
+
}
|
|
21521
|
+
const spec = TEMPLATE_SPECS[template];
|
|
21522
|
+
const baseDir = options.baseDir ?? path11.join("src", "content", "docs");
|
|
21523
|
+
const today = options.today ?? /* @__PURE__ */ new Date();
|
|
21524
|
+
const name = (options.name ?? "").trim() || defaultName(template);
|
|
21525
|
+
const slug2 = slugify3(name);
|
|
21526
|
+
const dir = path11.join(baseDir, spec.subdir);
|
|
21527
|
+
const ctx = {
|
|
21528
|
+
name,
|
|
21529
|
+
slug: slug2,
|
|
21530
|
+
isoDate: isoDate(today),
|
|
21531
|
+
seq: nextSeq(dir)
|
|
21532
|
+
};
|
|
21533
|
+
const filename = `${spec.filename(slug2, ctx)}.mdx`;
|
|
21534
|
+
const filePath = path11.join(dir, filename);
|
|
21535
|
+
if (fs10.existsSync(filePath) && !options.force) {
|
|
21536
|
+
throw new Error(
|
|
21537
|
+
`File "${filePath}" already exists. Use --force to overwrite.`
|
|
21538
|
+
);
|
|
21539
|
+
}
|
|
21540
|
+
fs10.mkdirSync(dir, { recursive: true });
|
|
21541
|
+
fs10.writeFileSync(filePath, spec.content(ctx), "utf8");
|
|
21542
|
+
return { template, path: filePath, title: titleFor2(template, ctx) };
|
|
21543
|
+
}
|
|
21544
|
+
function defaultName(template) {
|
|
21545
|
+
switch (template) {
|
|
21546
|
+
case "adr":
|
|
21547
|
+
return "Untitled decision";
|
|
21548
|
+
case "runbook":
|
|
21549
|
+
return "Untitled runbook";
|
|
21550
|
+
case "decision-log":
|
|
21551
|
+
return "Decisions";
|
|
21552
|
+
case "incident":
|
|
21553
|
+
return "Untitled incident";
|
|
21554
|
+
}
|
|
21555
|
+
}
|
|
21556
|
+
function titleFor2(template, ctx) {
|
|
21557
|
+
switch (template) {
|
|
21558
|
+
case "adr":
|
|
21559
|
+
return `ADR ${ctx.seq} \u2014 ${ctx.name}`;
|
|
21560
|
+
case "runbook":
|
|
21561
|
+
return `Runbook \u2014 ${ctx.name}`;
|
|
21562
|
+
case "decision-log":
|
|
21563
|
+
return `Decision log \u2014 ${ctx.name}`;
|
|
21564
|
+
case "incident":
|
|
21565
|
+
return `Incident \u2014 ${ctx.name}`;
|
|
21566
|
+
}
|
|
21567
|
+
}
|
|
21568
|
+
|
|
21569
|
+
// src/check-links.ts
|
|
21570
|
+
import * as fs11 from "fs";
|
|
21571
|
+
import * as path12 from "path";
|
|
21572
|
+
function stripCode(markdown) {
|
|
21573
|
+
let out = markdown.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1\s*$/gm, "");
|
|
21574
|
+
out = out.replace(/(`+)(?:(?!\1).)+\1/g, "");
|
|
21575
|
+
return out;
|
|
21576
|
+
}
|
|
21577
|
+
function extractLinks(markdown) {
|
|
21578
|
+
const stripped = stripCode(markdown);
|
|
21579
|
+
const found = [];
|
|
21580
|
+
const mdRe = /!?\[[^\]]*\]\(\s*<?([^)\s"'<>]+)>?(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
21581
|
+
let match;
|
|
21582
|
+
while ((match = mdRe.exec(stripped)) !== null) {
|
|
21583
|
+
found.push(match[1].trim());
|
|
21584
|
+
}
|
|
21585
|
+
const htmlRe = /<[a-z][^>]*\b(?:href|src)=["']([^"']+)["']/gi;
|
|
21586
|
+
while ((match = htmlRe.exec(stripped)) !== null) {
|
|
21587
|
+
found.push(match[1].trim());
|
|
21588
|
+
}
|
|
21589
|
+
return found.filter(Boolean);
|
|
21590
|
+
}
|
|
21591
|
+
function classifyLink(link2) {
|
|
21592
|
+
if (/^(?:https?:)?\/\//i.test(link2)) return "external";
|
|
21593
|
+
if (/^mailto:/i.test(link2)) return "mail";
|
|
21594
|
+
if (link2.startsWith("#")) return "anchor";
|
|
21595
|
+
if (link2.startsWith("/")) return "root";
|
|
21596
|
+
return "internal";
|
|
21597
|
+
}
|
|
21598
|
+
function resolutionCandidates(fromFile, link2) {
|
|
21599
|
+
const withoutAnchor = link2.split("#")[0];
|
|
21600
|
+
if (!withoutAnchor) return [];
|
|
21601
|
+
const base = path12.resolve(path12.dirname(fromFile), withoutAnchor);
|
|
21602
|
+
const candidates = [base];
|
|
21603
|
+
if (!path12.extname(base)) {
|
|
21604
|
+
candidates.push(`${base}.md`, `${base}.mdx`);
|
|
21605
|
+
candidates.push(path12.join(base, "index.md"), path12.join(base, "index.mdx"));
|
|
21606
|
+
}
|
|
21607
|
+
return candidates;
|
|
21608
|
+
}
|
|
21609
|
+
function resolvesOnDisk(fromFile, link2) {
|
|
21610
|
+
return resolutionCandidates(fromFile, link2).some(
|
|
21611
|
+
(candidate) => fs11.existsSync(candidate) && fs11.statSync(candidate).isFile()
|
|
21612
|
+
);
|
|
21613
|
+
}
|
|
21614
|
+
function collectDocFiles(target) {
|
|
21615
|
+
const stat = fs11.statSync(target);
|
|
21616
|
+
if (stat.isFile()) return [target];
|
|
21617
|
+
const out = [];
|
|
21618
|
+
const walk = (dir) => {
|
|
21619
|
+
for (const entry of fs11.readdirSync(dir, { withFileTypes: true })) {
|
|
21620
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
21621
|
+
const full = path12.join(dir, entry.name);
|
|
21622
|
+
if (entry.isDirectory()) walk(full);
|
|
21623
|
+
else if (/\.mdx?$/.test(entry.name)) out.push(full);
|
|
21624
|
+
}
|
|
21625
|
+
};
|
|
21626
|
+
walk(target);
|
|
21627
|
+
return out;
|
|
21628
|
+
}
|
|
21629
|
+
async function isExternalAlive(url, timeoutMs) {
|
|
21630
|
+
const attempt = async (method) => {
|
|
21631
|
+
const controller = new AbortController();
|
|
21632
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
21633
|
+
try {
|
|
21634
|
+
const res = await fetch(url, { method, signal: controller.signal, redirect: "follow" });
|
|
21635
|
+
return res.status < 400;
|
|
21636
|
+
} finally {
|
|
21637
|
+
clearTimeout(timer);
|
|
21638
|
+
}
|
|
21639
|
+
};
|
|
21640
|
+
try {
|
|
21641
|
+
return await attempt("HEAD");
|
|
21642
|
+
} catch {
|
|
21643
|
+
try {
|
|
21644
|
+
return await attempt("GET");
|
|
21645
|
+
} catch {
|
|
21646
|
+
return false;
|
|
21647
|
+
}
|
|
21648
|
+
}
|
|
21649
|
+
}
|
|
21650
|
+
async function checkLinks(options) {
|
|
21651
|
+
const { target, checkExternal = false, externalTimeoutMs = 8e3 } = options;
|
|
21652
|
+
if (!fs11.existsSync(target)) {
|
|
21653
|
+
throw new Error(`Path not found: ${target}`);
|
|
21654
|
+
}
|
|
21655
|
+
const files = collectDocFiles(target);
|
|
21656
|
+
const broken = [];
|
|
21657
|
+
let linksChecked = 0;
|
|
21658
|
+
let externalChecked = 0;
|
|
21659
|
+
let skipped = 0;
|
|
21660
|
+
const externalCache = /* @__PURE__ */ new Map();
|
|
21661
|
+
for (const file of files) {
|
|
21662
|
+
const content = fs11.readFileSync(file, "utf8");
|
|
21663
|
+
for (const link2 of extractLinks(content)) {
|
|
21664
|
+
const kind = classifyLink(link2);
|
|
21665
|
+
if (kind === "anchor" || kind === "mail" || kind === "root") {
|
|
21666
|
+
skipped += 1;
|
|
21667
|
+
continue;
|
|
21668
|
+
}
|
|
21669
|
+
if (kind === "external") {
|
|
21670
|
+
if (!checkExternal) {
|
|
21671
|
+
skipped += 1;
|
|
21672
|
+
continue;
|
|
21673
|
+
}
|
|
21674
|
+
externalChecked += 1;
|
|
21675
|
+
linksChecked += 1;
|
|
21676
|
+
let pending = externalCache.get(link2);
|
|
21677
|
+
if (!pending) {
|
|
21678
|
+
pending = isExternalAlive(link2, externalTimeoutMs);
|
|
21679
|
+
externalCache.set(link2, pending);
|
|
21680
|
+
}
|
|
21681
|
+
if (!await pending) {
|
|
21682
|
+
broken.push({ file, link: link2, reason: "external URL unreachable" });
|
|
21683
|
+
}
|
|
21684
|
+
continue;
|
|
21685
|
+
}
|
|
21686
|
+
linksChecked += 1;
|
|
21687
|
+
if (!resolvesOnDisk(file, link2)) {
|
|
21688
|
+
broken.push({ file, link: link2, reason: "target file not found" });
|
|
21689
|
+
}
|
|
21690
|
+
}
|
|
21691
|
+
}
|
|
21692
|
+
return {
|
|
21693
|
+
filesScanned: files.length,
|
|
21694
|
+
linksChecked,
|
|
21695
|
+
brokenCount: broken.length,
|
|
21696
|
+
broken,
|
|
21697
|
+
externalChecked,
|
|
21698
|
+
skipped
|
|
21699
|
+
};
|
|
21700
|
+
}
|
|
21701
|
+
function formatLinkReport(report) {
|
|
21702
|
+
const lines = [];
|
|
21703
|
+
lines.push(
|
|
21704
|
+
`Scanned ${report.filesScanned} file(s), checked ${report.linksChecked} link(s) (${report.skipped} skipped).`
|
|
21705
|
+
);
|
|
21706
|
+
if (report.brokenCount === 0) {
|
|
21707
|
+
lines.push("\u2713 No broken links.");
|
|
21708
|
+
} else {
|
|
21709
|
+
lines.push(`\u2715 ${report.brokenCount} broken link(s):`);
|
|
21710
|
+
for (const b of report.broken) {
|
|
21711
|
+
lines.push(` ${b.file}: ${b.link} \u2014 ${b.reason}`);
|
|
20990
21712
|
}
|
|
20991
21713
|
}
|
|
21714
|
+
return lines.join("\n");
|
|
21715
|
+
}
|
|
21716
|
+
|
|
21717
|
+
// src/import-openapi.ts
|
|
21718
|
+
import * as fs12 from "fs";
|
|
21719
|
+
import * as path13 from "path";
|
|
21720
|
+
import { parse as parseYamlString } from "yaml";
|
|
21721
|
+
var HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head"];
|
|
21722
|
+
function parseYaml(raw, specPath) {
|
|
21723
|
+
try {
|
|
21724
|
+
return parseYamlString(raw);
|
|
21725
|
+
} catch (err) {
|
|
21726
|
+
throw new Error(`Could not parse YAML spec ${specPath}: ${err.message}`, {
|
|
21727
|
+
cause: err
|
|
21728
|
+
});
|
|
21729
|
+
}
|
|
21730
|
+
}
|
|
21731
|
+
function parseSpec(specPath) {
|
|
21732
|
+
if (!fs12.existsSync(specPath)) throw new Error(`Spec not found: ${specPath}`);
|
|
21733
|
+
const raw = fs12.readFileSync(specPath, "utf8");
|
|
21734
|
+
const ext = path13.extname(specPath).toLowerCase();
|
|
21735
|
+
if (ext === ".json") return JSON.parse(raw);
|
|
21736
|
+
if (ext === ".yaml" || ext === ".yml") return parseYaml(raw, specPath);
|
|
21737
|
+
try {
|
|
21738
|
+
return JSON.parse(raw);
|
|
21739
|
+
} catch {
|
|
21740
|
+
return parseYaml(raw, specPath);
|
|
21741
|
+
}
|
|
21742
|
+
}
|
|
21743
|
+
function extractEndpoints(spec) {
|
|
21744
|
+
const paths = spec.paths ?? {};
|
|
21745
|
+
const endpoints = [];
|
|
21746
|
+
for (const [route, item] of Object.entries(paths)) {
|
|
21747
|
+
if (!item || typeof item !== "object") continue;
|
|
21748
|
+
for (const method of HTTP_METHODS) {
|
|
21749
|
+
const op = item[method];
|
|
21750
|
+
if (!op || typeof op !== "object") continue;
|
|
21751
|
+
const tags = Array.isArray(op.tags) && op.tags.length > 0 ? op.tags : ["API"];
|
|
21752
|
+
endpoints.push({
|
|
21753
|
+
method: method.toUpperCase(),
|
|
21754
|
+
path: route,
|
|
21755
|
+
operationId: typeof op.operationId === "string" ? op.operationId : void 0,
|
|
21756
|
+
summary: typeof op.summary === "string" && op.summary || typeof op.description === "string" && op.description || "",
|
|
21757
|
+
tag: String(tags[0])
|
|
21758
|
+
});
|
|
21759
|
+
}
|
|
21760
|
+
}
|
|
21761
|
+
return endpoints;
|
|
21762
|
+
}
|
|
21763
|
+
function loadScenarios(runFile) {
|
|
21764
|
+
if (!runFile) return [];
|
|
21765
|
+
if (!fs12.existsSync(runFile)) throw new Error(`Run file not found: ${runFile}`);
|
|
21766
|
+
const report = JSON.parse(fs12.readFileSync(runFile, "utf8"));
|
|
21767
|
+
return (report.features ?? []).flatMap((f) => f.scenarios ?? []);
|
|
21768
|
+
}
|
|
21769
|
+
function endpointRefs(endpoint) {
|
|
21770
|
+
const refs = [
|
|
21771
|
+
endpoint.operationId,
|
|
21772
|
+
`${endpoint.method} ${endpoint.path}`,
|
|
21773
|
+
endpoint.path
|
|
21774
|
+
].filter((r) => Boolean(r));
|
|
21775
|
+
return refs;
|
|
21776
|
+
}
|
|
21777
|
+
function scenarioMatchesEndpoint(scenario, refs) {
|
|
21778
|
+
const tags = scenario.tags ?? [];
|
|
21779
|
+
return refs.some(
|
|
21780
|
+
(ref) => scenario.id === ref || scenario.title === ref || tags.includes(ref)
|
|
21781
|
+
);
|
|
21782
|
+
}
|
|
21783
|
+
function computeCoverage(endpoints, scenarios) {
|
|
21784
|
+
return endpoints.map((endpoint) => {
|
|
21785
|
+
const refs = endpointRefs(endpoint);
|
|
21786
|
+
const matched = scenarios.filter((s) => scenarioMatchesEndpoint(s, refs));
|
|
21787
|
+
let status;
|
|
21788
|
+
if (matched.length === 0) status = "uncovered";
|
|
21789
|
+
else if (matched.some((s) => s.status === "failed")) status = "failing";
|
|
21790
|
+
else status = "covered";
|
|
21791
|
+
return {
|
|
21792
|
+
endpoint,
|
|
21793
|
+
status,
|
|
21794
|
+
stories: matched.map((s) => ({
|
|
21795
|
+
id: s.id ?? s.title ?? "",
|
|
21796
|
+
title: s.title ?? s.id ?? "story",
|
|
21797
|
+
status: s.status ?? "passed"
|
|
21798
|
+
}))
|
|
21799
|
+
};
|
|
21800
|
+
});
|
|
21801
|
+
}
|
|
21802
|
+
function slug(input) {
|
|
21803
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "api";
|
|
21804
|
+
}
|
|
21805
|
+
function yamlQuote(value) {
|
|
21806
|
+
return value.replace(/'/g, "''");
|
|
21807
|
+
}
|
|
21808
|
+
function coverageSummary(rows) {
|
|
21809
|
+
return {
|
|
21810
|
+
total: rows.length,
|
|
21811
|
+
covered: rows.filter((r) => r.status === "covered").length,
|
|
21812
|
+
failing: rows.filter((r) => r.status === "failing").length,
|
|
21813
|
+
uncovered: rows.filter((r) => r.status === "uncovered").length
|
|
21814
|
+
};
|
|
21815
|
+
}
|
|
21816
|
+
function renderTagPage(tag, rows, hasRun) {
|
|
21817
|
+
const endpoints = rows.map((r) => ({
|
|
21818
|
+
method: r.endpoint.method,
|
|
21819
|
+
path: r.endpoint.path,
|
|
21820
|
+
summary: r.endpoint.summary || "",
|
|
21821
|
+
status: r.status,
|
|
21822
|
+
stories: r.stories
|
|
21823
|
+
}));
|
|
21824
|
+
const summary = coverageSummary(rows);
|
|
21825
|
+
return `---
|
|
21826
|
+
title: 'API \u2014 ${yamlQuote(tag)}'
|
|
21827
|
+
description: 'Endpoints for ${yamlQuote(tag)}, linked to the stories that exercise them.'
|
|
21828
|
+
---
|
|
21829
|
+
|
|
21830
|
+
import ApiOperations from '@components/ApiOperations.astro';
|
|
21831
|
+
|
|
21832
|
+
<ApiOperations
|
|
21833
|
+
tag={${JSON.stringify(tag)}}
|
|
21834
|
+
hasRun={${hasRun}}
|
|
21835
|
+
summary={${JSON.stringify(summary)}}
|
|
21836
|
+
endpoints={${JSON.stringify(endpoints)}}
|
|
21837
|
+
/>
|
|
21838
|
+
`;
|
|
21839
|
+
}
|
|
21840
|
+
function renderIndex(groups, hasRun, totals) {
|
|
21841
|
+
const rows = [...groups.entries()].map(([tag, eps]) => {
|
|
21842
|
+
const covered = eps.filter((e) => e.status === "covered").length;
|
|
21843
|
+
const cov = hasRun ? ` | ${covered}/${eps.length} covered` : "";
|
|
21844
|
+
return `- [${tag}](./${slug(tag)}/) \u2014 ${eps.length} endpoint(s)${cov}`;
|
|
21845
|
+
}).join("\n");
|
|
21846
|
+
const coverageNote = hasRun ? `
|
|
21847
|
+
**${totals.coveredCount} of ${totals.endpointCount} endpoints** are covered by a passing story.` + (totals.uncoveredCount > 0 ? ` \u26A0 ${totals.uncoveredCount} endpoint(s) have no verifying test.` : "") : "\nRe-run with `--run <story-report.json>` to show per-endpoint test coverage.";
|
|
21848
|
+
return `---
|
|
21849
|
+
title: 'API reference'
|
|
21850
|
+
description: 'API endpoints generated from OpenAPI, linked to verifying stories.'
|
|
21851
|
+
---
|
|
21852
|
+
|
|
21853
|
+
${coverageNote}
|
|
21854
|
+
|
|
21855
|
+
${rows}
|
|
21856
|
+
`;
|
|
21857
|
+
}
|
|
21858
|
+
async function importOpenApi(options) {
|
|
21859
|
+
const spec = parseSpec(options.specPath);
|
|
21860
|
+
const endpoints = extractEndpoints(spec);
|
|
21861
|
+
if (endpoints.length === 0) {
|
|
21862
|
+
throw new Error(`No endpoints found in ${options.specPath} (expected an OpenAPI "paths" object).`);
|
|
21863
|
+
}
|
|
21864
|
+
const scenarios = loadScenarios(options.runFile);
|
|
21865
|
+
const hasRun = Boolean(options.runFile);
|
|
21866
|
+
const coverage = computeCoverage(endpoints, scenarios);
|
|
21867
|
+
const groups = /* @__PURE__ */ new Map();
|
|
21868
|
+
for (const item of coverage) {
|
|
21869
|
+
const list = groups.get(item.endpoint.tag) ?? [];
|
|
21870
|
+
list.push(item);
|
|
21871
|
+
groups.set(item.endpoint.tag, list);
|
|
21872
|
+
}
|
|
21873
|
+
const outputDir = options.outputDir ?? path13.join("src", "content", "docs", "api");
|
|
21874
|
+
if (fs12.existsSync(outputDir) && !options.force) {
|
|
21875
|
+
const entries = fs12.readdirSync(outputDir);
|
|
21876
|
+
if (entries.length > 0) {
|
|
21877
|
+
throw new Error(`Output directory "${outputDir}" is not empty. Use --force to overwrite.`);
|
|
21878
|
+
}
|
|
21879
|
+
}
|
|
21880
|
+
fs12.mkdirSync(outputDir, { recursive: true });
|
|
21881
|
+
const coveredCount = coverage.filter((c) => c.status === "covered").length;
|
|
21882
|
+
const uncoveredCount = coverage.filter((c) => c.status === "uncovered").length;
|
|
21883
|
+
fs12.writeFileSync(
|
|
21884
|
+
path13.join(outputDir, "index.mdx"),
|
|
21885
|
+
renderIndex(groups, hasRun, { endpointCount: endpoints.length, coveredCount, uncoveredCount }),
|
|
21886
|
+
"utf8"
|
|
21887
|
+
);
|
|
21888
|
+
for (const [tag, rows] of groups) {
|
|
21889
|
+
const dir = path13.join(outputDir, slug(tag));
|
|
21890
|
+
fs12.mkdirSync(dir, { recursive: true });
|
|
21891
|
+
fs12.writeFileSync(path13.join(dir, "index.mdx"), renderTagPage(tag, rows, hasRun), "utf8");
|
|
21892
|
+
}
|
|
21893
|
+
return {
|
|
21894
|
+
outputDir,
|
|
21895
|
+
pageCount: groups.size + 1,
|
|
21896
|
+
endpointCount: endpoints.length,
|
|
21897
|
+
coveredCount,
|
|
21898
|
+
uncoveredCount
|
|
21899
|
+
};
|
|
21900
|
+
}
|
|
21901
|
+
|
|
21902
|
+
// src/build-docs.ts
|
|
21903
|
+
import * as fs13 from "fs";
|
|
21904
|
+
import * as path14 from "path";
|
|
21905
|
+
var BuildDocsError = class extends Error {
|
|
21906
|
+
constructor(message, kind) {
|
|
21907
|
+
super(message);
|
|
21908
|
+
this.kind = kind;
|
|
21909
|
+
this.name = "BuildDocsError";
|
|
21910
|
+
}
|
|
21911
|
+
};
|
|
21912
|
+
var isRemote = (p) => /^(?:https?:|data:)/i.test(p);
|
|
21913
|
+
function bundleExplorerAssets(reportPath, assetsDir, baseUrl = "/stories/assets") {
|
|
21914
|
+
if (!fs13.existsSync(reportPath)) return 0;
|
|
21915
|
+
const report = JSON.parse(fs13.readFileSync(reportPath, "utf8"));
|
|
21916
|
+
let copied = 0;
|
|
21917
|
+
const bundle = (value) => {
|
|
21918
|
+
const rel = copyAsset(path14.resolve(value), assetsDir);
|
|
21919
|
+
copied++;
|
|
21920
|
+
return `${baseUrl}/${path14.basename(rel)}`;
|
|
21921
|
+
};
|
|
21922
|
+
const visit = (entries) => {
|
|
21923
|
+
for (const entry of entries ?? []) {
|
|
21924
|
+
const e = entry;
|
|
21925
|
+
if (e.kind === "screenshot" || e.kind === "video") {
|
|
21926
|
+
if (typeof e.path === "string" && !isRemote(e.path) && fs13.existsSync(e.path)) {
|
|
21927
|
+
e.path = bundle(e.path);
|
|
21928
|
+
}
|
|
21929
|
+
if (typeof e.poster === "string" && !isRemote(e.poster) && fs13.existsSync(e.poster)) {
|
|
21930
|
+
e.poster = bundle(e.poster);
|
|
21931
|
+
}
|
|
21932
|
+
}
|
|
21933
|
+
if (Array.isArray(e.children)) visit(e.children);
|
|
21934
|
+
}
|
|
21935
|
+
};
|
|
21936
|
+
for (const feature of report.features ?? []) {
|
|
21937
|
+
for (const scenario of feature.scenarios ?? []) {
|
|
21938
|
+
visit(scenario.docEntries);
|
|
21939
|
+
}
|
|
21940
|
+
}
|
|
21941
|
+
if (copied > 0) {
|
|
21942
|
+
fs13.writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf8");
|
|
21943
|
+
}
|
|
21944
|
+
return copied;
|
|
21945
|
+
}
|
|
21946
|
+
function clearGeneratedPages(dir) {
|
|
21947
|
+
if (!fs13.existsSync(dir)) return;
|
|
21948
|
+
for (const entry of fs13.readdirSync(dir, { withFileTypes: true })) {
|
|
21949
|
+
const full = path14.join(dir, entry.name);
|
|
21950
|
+
if (entry.isDirectory()) {
|
|
21951
|
+
clearGeneratedPages(full);
|
|
21952
|
+
if (fs13.readdirSync(full).length === 0) fs13.rmdirSync(full);
|
|
21953
|
+
} else if (/\.mdx?$/.test(entry.name)) {
|
|
21954
|
+
fs13.rmSync(full);
|
|
21955
|
+
}
|
|
21956
|
+
}
|
|
21957
|
+
}
|
|
21958
|
+
function loadCanonicalRun(rawRunPath, synthesize) {
|
|
21959
|
+
try {
|
|
21960
|
+
const data = JSON.parse(fs13.readFileSync(path14.resolve(rawRunPath), "utf8"));
|
|
21961
|
+
if (data.schemaVersion !== 1) {
|
|
21962
|
+
throw new BuildDocsError(`Unsupported schemaVersion ${data.schemaVersion}. Supported: 1.`, "schema");
|
|
21963
|
+
}
|
|
21964
|
+
const schemaResult = validateRawRun(data);
|
|
21965
|
+
if (!schemaResult.valid) {
|
|
21966
|
+
throw new BuildDocsError(
|
|
21967
|
+
`Schema validation failed:
|
|
21968
|
+
${schemaResult.errors.map((e) => ` ${e}`).join("\n")}`,
|
|
21969
|
+
"schema"
|
|
21970
|
+
);
|
|
21971
|
+
}
|
|
21972
|
+
let raw = data;
|
|
21973
|
+
if (synthesize) raw = synthesizeStories(raw);
|
|
21974
|
+
const canonical = canonicalizeRun(raw);
|
|
21975
|
+
assertValidRun(canonical);
|
|
21976
|
+
return canonical;
|
|
21977
|
+
} catch (err) {
|
|
21978
|
+
if (err instanceof BuildDocsError) throw err;
|
|
21979
|
+
throw new BuildDocsError(`Could not read raw run "${rawRunPath}": ${err.message}`, "input");
|
|
21980
|
+
}
|
|
21981
|
+
}
|
|
21982
|
+
async function buildDocs(options) {
|
|
21983
|
+
const siteDir = path14.resolve(options.siteDir);
|
|
21984
|
+
const storiesPublicDir = path14.join(siteDir, "public", "stories");
|
|
21985
|
+
const assetsDir = path14.join(storiesPublicDir, "assets");
|
|
21986
|
+
const storyPagesDir = path14.join(siteDir, "src", "content", "docs", "stories");
|
|
21987
|
+
const apiDir = path14.join(siteDir, "src", "content", "docs", "api");
|
|
21988
|
+
const reportPath = path14.join(storiesPublicDir, "story-report.json");
|
|
21989
|
+
const canonical = loadCanonicalRun(options.rawRunPath, options.synthesizeStories ?? true);
|
|
21990
|
+
try {
|
|
21991
|
+
await new ReportGenerator({
|
|
21992
|
+
formats: ["story-report-json"],
|
|
21993
|
+
outputDir: storiesPublicDir,
|
|
21994
|
+
outputName: "story-report"
|
|
21995
|
+
}).generate(canonical);
|
|
21996
|
+
clearGeneratedPages(storyPagesDir);
|
|
21997
|
+
await new ReportGenerator({
|
|
21998
|
+
formats: ["astro"],
|
|
21999
|
+
outputDir: storyPagesDir,
|
|
22000
|
+
outputName: "index",
|
|
22001
|
+
output: { mode: "colocated", colocatedStyle: "flat" },
|
|
22002
|
+
assetMode: "copy",
|
|
22003
|
+
astro: { assetsDir, assetsBaseUrl: "/stories/assets" }
|
|
22004
|
+
}).generate(canonical);
|
|
22005
|
+
const bundledAssets = bundleExplorerAssets(reportPath, assetsDir);
|
|
22006
|
+
let apiPages = 0;
|
|
22007
|
+
if (options.openapiPath) {
|
|
22008
|
+
const res = await importOpenApi({
|
|
22009
|
+
specPath: path14.resolve(options.openapiPath),
|
|
22010
|
+
outputDir: apiDir,
|
|
22011
|
+
runFile: reportPath,
|
|
22012
|
+
force: true
|
|
22013
|
+
});
|
|
22014
|
+
apiPages = res.pageCount;
|
|
22015
|
+
}
|
|
22016
|
+
return { siteDir, bundledAssets, apiPages };
|
|
22017
|
+
} catch (err) {
|
|
22018
|
+
if (err instanceof BuildDocsError) throw err;
|
|
22019
|
+
throw new BuildDocsError(`Generation failed: ${err.message}`, "generation");
|
|
22020
|
+
}
|
|
20992
22021
|
}
|
|
20993
22022
|
|
|
20994
22023
|
// src/config.ts
|
|
20995
|
-
import { existsSync as
|
|
20996
|
-
import { resolve as
|
|
22024
|
+
import { existsSync as existsSync12 } from "fs";
|
|
22025
|
+
import { resolve as resolve10 } from "path";
|
|
20997
22026
|
async function loadConfig(configPath) {
|
|
20998
|
-
const resolved = configPath ?
|
|
20999
|
-
if (!
|
|
22027
|
+
const resolved = configPath ? resolve10(configPath) : resolve10(process.cwd(), "executable-stories.config.js");
|
|
22028
|
+
if (!existsSync12(resolved)) return {};
|
|
21000
22029
|
const mod = await import(resolved);
|
|
21001
22030
|
const config = mod.default;
|
|
21002
22031
|
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
@@ -21015,6 +22044,7 @@ var EXIT_GENERATION = 3;
|
|
|
21015
22044
|
var EXIT_USAGE = 4;
|
|
21016
22045
|
var EXIT_COMPARE_GATE = 5;
|
|
21017
22046
|
var EXIT_REVIEW_GATE = 5;
|
|
22047
|
+
var EXIT_RELEASE_GATE = 6;
|
|
21018
22048
|
var HELP_TEXT = `
|
|
21019
22049
|
executable-stories \u2014 Generate reports from test results JSON.
|
|
21020
22050
|
|
|
@@ -21022,27 +22052,39 @@ USAGE
|
|
|
21022
22052
|
executable-stories format <file> [options]
|
|
21023
22053
|
executable-stories format --stdin [options]
|
|
21024
22054
|
executable-stories compare <baseline-file> <current-file> [options]
|
|
22055
|
+
executable-stories gate-release <dev-run.json> <rc-run.json> [options]
|
|
21025
22056
|
executable-stories review <file> --changed-files <path> [options]
|
|
21026
22057
|
executable-stories list <file> [options]
|
|
21027
22058
|
executable-stories validate <file>
|
|
21028
22059
|
executable-stories validate --stdin
|
|
21029
22060
|
executable-stories init-astro [directory]
|
|
22061
|
+
executable-stories new <template> "<name>" [options]
|
|
22062
|
+
executable-stories check-links <dir> [options]
|
|
22063
|
+
executable-stories import-openapi <spec> [options]
|
|
21030
22064
|
executable-stories publish-confluence <file.adf.json> [options]
|
|
21031
22065
|
executable-stories publish-jira <file.adf.json> [options]
|
|
22066
|
+
executable-stories deploy record <file> --env <env> [--tag <tag>] [options]
|
|
22067
|
+
executable-stories deploy status [options]
|
|
22068
|
+
executable-stories deploy diff <env-a> <env-b> [options]
|
|
21032
22069
|
|
|
21033
22070
|
SUBCOMMANDS
|
|
21034
22071
|
format Read raw test results and generate reports
|
|
21035
22072
|
watch Regenerate reports whenever the raw-run file changes (live agent index)
|
|
21036
22073
|
compare Compare two runs and generate a diff report
|
|
22074
|
+
gate-release Verify a release candidate against the dev test baseline (RC gate)
|
|
21037
22075
|
review Generate an Evidence Review of AI-authored changes (correlate a run to the diff)
|
|
21038
22076
|
list List scenarios from a test run (text table or JSON)
|
|
21039
22077
|
validate Validate a JSON file against the schema (no output generated)
|
|
21040
22078
|
init-astro Scaffold an Astro docs site for story output (Starlight with themed CSS)
|
|
22079
|
+
new Scaffold a docs page from a template (adr, runbook, decision-log, incident)
|
|
22080
|
+
check-links Scan docs for broken internal/external links (CI-friendly exit code)
|
|
22081
|
+
import-openapi Generate API doc pages from an OpenAPI spec, linked to verifying stories
|
|
21041
22082
|
publish-confluence Publish an ADF JSON file to a Confluence page via REST API
|
|
21042
22083
|
publish-jira Publish an ADF JSON file to a Jira issue (as comment or description)
|
|
22084
|
+
deploy Record deployments, show environment status, detect drift
|
|
21043
22085
|
|
|
21044
22086
|
OPTIONS
|
|
21045
|
-
--format <formats> Comma-separated formats: html, markdown, junit, cucumber-json, cucumber-messages, cucumber-html, astro, confluence, story-report-json, scenario-index-json, behavior-manifest-json, or custom names from config (default: html)
|
|
22087
|
+
--format <formats> Comma-separated formats: html, markdown, release-manifest, junit, cucumber-json, cucumber-messages, cucumber-html, astro, confluence, story-report-json, scenario-index-json, behavior-manifest-json, or custom names from config (default: html)
|
|
21046
22088
|
astro Themed Markdown (for Astro docs sites with matching CSS)
|
|
21047
22089
|
confluence Atlassian Document Format (ADF) JSON for Confluence / Jira
|
|
21048
22090
|
behavior-manifest-json Agent-readable behavior manifest and debugger warnings
|
|
@@ -21086,7 +22128,10 @@ OPTIONS
|
|
|
21086
22128
|
--pr-summary-file <path> Write the PR-friendly markdown summary to a file
|
|
21087
22129
|
--fail-on-regression Exit non-zero when any regression is detected in compare
|
|
21088
22130
|
--fail-on-added-failures Exit non-zero when newly added scenarios are failing
|
|
22131
|
+
--fail-on-removal Exit non-zero when scenarios are removed from the baseline
|
|
22132
|
+
--fail-on-new Exit non-zero when new scenarios appear that weren't in the baseline
|
|
21089
22133
|
--max-regressions <n> Exit non-zero when regressions exceed threshold
|
|
22134
|
+
--release-policy <path> (gate-release) Path to JSON policy file with allowed exceptions
|
|
21090
22135
|
--changed-files <path> (review) Changed files: JSON (ChangedFile[] or {changedFiles,baseRef,headRef}) or "git diff --name-status" text
|
|
21091
22136
|
--base-ref <ref> (review) Base ref label shown in the report (informational)
|
|
21092
22137
|
--head-ref <ref> (review) Head ref label shown in the report (informational)
|
|
@@ -21105,6 +22150,24 @@ COMPARE
|
|
|
21105
22150
|
compare supports --format html,markdown
|
|
21106
22151
|
compare uses the same --input-type for both baseline and current files
|
|
21107
22152
|
|
|
22153
|
+
GATE-RELEASE
|
|
22154
|
+
gate-release compares a dev environment test run (baseline) against a
|
|
22155
|
+
release candidate test run to verify the RC matches what was tested in dev.
|
|
22156
|
+
By default, fails if scenarios are omitted or regressed.
|
|
22157
|
+
--fail-on-regression and --fail-on-removal are enabled by default.
|
|
22158
|
+
Supports --release-policy for exception lists.
|
|
22159
|
+
|
|
22160
|
+
DEPLOY
|
|
22161
|
+
executable-stories deploy record <file> --env <env> [--tag <tag>]
|
|
22162
|
+
Record a deployment of a test run to an environment (e.g. dev, staging, prod).
|
|
22163
|
+
The deployment ledger is at .executable-stories/deployments.json by default.
|
|
22164
|
+
|
|
22165
|
+
executable-stories deploy status [--ledger <path>]
|
|
22166
|
+
Show the latest deployment for each environment.
|
|
22167
|
+
|
|
22168
|
+
executable-stories deploy diff <env-a> <env-b> [--ledger <path>]
|
|
22169
|
+
Show scenario drift between two environments (what's in one but not the other).
|
|
22170
|
+
|
|
21108
22171
|
INIT-ASTRO
|
|
21109
22172
|
executable-stories init-astro [directory] Scaffold into directory (default: ./story-docs)
|
|
21110
22173
|
--force Overwrite existing directory
|
|
@@ -21156,6 +22219,7 @@ EXIT CODES
|
|
|
21156
22219
|
3 Formatter/generation failure
|
|
21157
22220
|
4 Bad arguments / usage error
|
|
21158
22221
|
5 Compare gate failed
|
|
22222
|
+
6 Release gate failed
|
|
21159
22223
|
`.trim();
|
|
21160
22224
|
async function parseCliArgs(argv) {
|
|
21161
22225
|
const args = argv.slice(2);
|
|
@@ -21164,9 +22228,9 @@ async function parseCliArgs(argv) {
|
|
|
21164
22228
|
process.exit(EXIT_SUCCESS);
|
|
21165
22229
|
}
|
|
21166
22230
|
const subcommand = args[0];
|
|
21167
|
-
if (subcommand !== "format" && subcommand !== "watch" && subcommand !== "compare" && subcommand !== "review" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
|
|
22231
|
+
if (subcommand !== "format" && subcommand !== "watch" && subcommand !== "compare" && subcommand !== "gate-release" && subcommand !== "deploy" && subcommand !== "review" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "build-docs" && subcommand !== "new" && subcommand !== "check-links" && subcommand !== "import-openapi" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
|
|
21168
22232
|
console.error(
|
|
21169
|
-
`Unknown subcommand: "${subcommand}". Use "format", "watch", "compare", "review", "list", "validate", "init-astro", "publish-confluence", or "publish-jira".`
|
|
22233
|
+
`Unknown subcommand: "${subcommand}". Use "format", "watch", "compare", "gate-release", "deploy", "review", "list", "validate", "init-astro", "build-docs", "new", "check-links", "import-openapi", "publish-confluence", or "publish-jira".`
|
|
21170
22234
|
);
|
|
21171
22235
|
process.exit(EXIT_USAGE);
|
|
21172
22236
|
}
|
|
@@ -21178,12 +22242,21 @@ async function parseCliArgs(argv) {
|
|
|
21178
22242
|
await runPublishJira(args.slice(1));
|
|
21179
22243
|
process.exit(EXIT_SUCCESS);
|
|
21180
22244
|
}
|
|
22245
|
+
if (subcommand === "deploy") {
|
|
22246
|
+
process.exit(await runDeploy(args.slice(1)));
|
|
22247
|
+
}
|
|
21181
22248
|
if (subcommand === "init-astro") {
|
|
21182
22249
|
const initArgs = args.slice(1);
|
|
21183
22250
|
const targetDir = initArgs.find((a) => !a.startsWith("--")) ?? "./story-docs";
|
|
21184
22251
|
const force = initArgs.includes("--force");
|
|
22252
|
+
const update = initArgs.includes("--update");
|
|
21185
22253
|
try {
|
|
21186
|
-
const result = initAstro({ targetDir, force });
|
|
22254
|
+
const result = initAstro({ targetDir, force, update });
|
|
22255
|
+
if (update) {
|
|
22256
|
+
console.log(`Updated framework files in ${result.targetDir} (content left untouched)`);
|
|
22257
|
+
console.log(` Refreshed: ${result.updatedFiles?.length ?? 0} file(s)`);
|
|
22258
|
+
process.exit(EXIT_SUCCESS);
|
|
22259
|
+
}
|
|
21187
22260
|
console.log(`Scaffolded Astro docs site at ${result.targetDir}`);
|
|
21188
22261
|
console.log("");
|
|
21189
22262
|
console.log("Themes available in src/styles/themes/:");
|
|
@@ -21196,23 +22269,26 @@ async function parseCliArgs(argv) {
|
|
|
21196
22269
|
console.log("");
|
|
21197
22270
|
console.log("To change theme, edit astro.config.mjs customCss array.");
|
|
21198
22271
|
console.log("");
|
|
21199
|
-
console.log("");
|
|
21200
22272
|
console.log("Next steps:");
|
|
21201
22273
|
console.log(` cd ${result.targetDir}`);
|
|
21202
22274
|
console.log(" pnpm install # or npm install");
|
|
21203
22275
|
console.log(" pnpm dev # start the dev server");
|
|
21204
22276
|
console.log("");
|
|
21205
|
-
console.log("Generate story
|
|
21206
|
-
console.log(` executable-stories
|
|
22277
|
+
console.log("Generate everything (story pages, explorer data, API pages) in one step:");
|
|
22278
|
+
console.log(` executable-stories build-docs run.json --site-dir ${result.targetDir} [--openapi spec.json]`);
|
|
21207
22279
|
console.log("");
|
|
21208
|
-
console.log("
|
|
21209
|
-
console.log(` executable-stories
|
|
22280
|
+
console.log("Later, pull template/design improvements without losing your content:");
|
|
22281
|
+
console.log(` executable-stories init-astro ${result.targetDir} --update`);
|
|
21210
22282
|
process.exit(EXIT_SUCCESS);
|
|
21211
22283
|
} catch (err) {
|
|
21212
22284
|
console.error(`Error: ${err.message}`);
|
|
21213
22285
|
process.exit(EXIT_USAGE);
|
|
21214
22286
|
}
|
|
21215
22287
|
}
|
|
22288
|
+
if (subcommand === "new") process.exit(runNew(args.slice(1)));
|
|
22289
|
+
if (subcommand === "check-links") process.exit(await runCheckLinks(args.slice(1)));
|
|
22290
|
+
if (subcommand === "import-openapi") process.exit(await runImportOpenApi(args.slice(1)));
|
|
22291
|
+
if (subcommand === "build-docs") process.exit(await runBuildDocs(args.slice(1)));
|
|
21216
22292
|
const { values, positionals } = parseArgs({
|
|
21217
22293
|
args: args.slice(1),
|
|
21218
22294
|
options: {
|
|
@@ -21262,7 +22338,10 @@ async function parseCliArgs(argv) {
|
|
|
21262
22338
|
"pr-summary-file": { type: "string" },
|
|
21263
22339
|
"fail-on-regression": { type: "boolean", default: false },
|
|
21264
22340
|
"fail-on-added-failures": { type: "boolean", default: false },
|
|
22341
|
+
"fail-on-removal": { type: "boolean", default: false },
|
|
22342
|
+
"fail-on-new": { type: "boolean", default: false },
|
|
21265
22343
|
"max-regressions": { type: "string" },
|
|
22344
|
+
"release-policy": { type: "string" },
|
|
21266
22345
|
"changed-files": { type: "string" },
|
|
21267
22346
|
"base-ref": { type: "string" },
|
|
21268
22347
|
"head-ref": { type: "string" },
|
|
@@ -21281,20 +22360,21 @@ async function parseCliArgs(argv) {
|
|
|
21281
22360
|
const useStdin = values.stdin;
|
|
21282
22361
|
const baselineValue = values.baseline;
|
|
21283
22362
|
const baselineMode = baselineValue === "auto" ? "auto" : "explicit";
|
|
21284
|
-
const
|
|
21285
|
-
const
|
|
21286
|
-
const
|
|
21287
|
-
|
|
22363
|
+
const isCompareLike = subcommand === "compare" || subcommand === "gate-release";
|
|
22364
|
+
const inputFile = isCompareLike ? void 0 : positionals[0];
|
|
22365
|
+
const baselineFile = isCompareLike ? baselineMode === "auto" ? baselineValue && baselineValue !== "auto" ? baselineValue : positionals.length > 1 ? positionals[0] : void 0 : baselineValue && baselineValue !== "auto" ? baselineValue : positionals.length > 1 ? positionals[0] : void 0 : void 0;
|
|
22366
|
+
const currentFile = isCompareLike ? positionals.length > 1 ? positionals[1] : positionals[0] : void 0;
|
|
22367
|
+
if (isCompareLike) {
|
|
21288
22368
|
if (useStdin) {
|
|
21289
|
-
console.error(
|
|
22369
|
+
console.error(`Error: ${subcommand} does not support --stdin. Pass baseline and current files.`);
|
|
21290
22370
|
process.exit(EXIT_USAGE);
|
|
21291
22371
|
}
|
|
21292
22372
|
if (!currentFile) {
|
|
21293
|
-
console.error(
|
|
22373
|
+
console.error(`Error: ${subcommand} requires <current-file>, and either <baseline-file> or --baseline auto.`);
|
|
21294
22374
|
process.exit(EXIT_USAGE);
|
|
21295
22375
|
}
|
|
21296
22376
|
if (baselineMode === "explicit" && !baselineFile) {
|
|
21297
|
-
console.error(
|
|
22377
|
+
console.error(`Error: ${subcommand} requires <baseline-file> and <current-file>, or use --baseline auto.`);
|
|
21298
22378
|
process.exit(EXIT_USAGE);
|
|
21299
22379
|
}
|
|
21300
22380
|
} else if (!useStdin && !inputFile) {
|
|
@@ -21308,7 +22388,7 @@ async function parseCliArgs(argv) {
|
|
|
21308
22388
|
}
|
|
21309
22389
|
const pluginConfig = await loadConfig(values["config"]);
|
|
21310
22390
|
const customFormatterNames = new Set(Object.keys(pluginConfig.formatters ?? {}));
|
|
21311
|
-
const builtInFormats = /* @__PURE__ */ new Set(["astro", "behavior-manifest-json", "confluence", "html", "markdown", "junit", "cucumber-json", "cucumber-messages", "cucumber-html", "scenario-index-json", "story-report-json"]);
|
|
22391
|
+
const builtInFormats = /* @__PURE__ */ new Set(["astro", "behavior-manifest-json", "confluence", "html", "markdown", "release-manifest", "junit", "cucumber-json", "cucumber-messages", "cucumber-html", "scenario-index-json", "story-report-json"]);
|
|
21312
22392
|
const formatStr = values.format;
|
|
21313
22393
|
const allRequestedFormats = formatStr.split(",").map((f) => f.trim());
|
|
21314
22394
|
const builtInRequested = allRequestedFormats.filter((f) => builtInFormats.has(f));
|
|
@@ -21316,7 +22396,7 @@ async function parseCliArgs(argv) {
|
|
|
21316
22396
|
const unknownFormats = allRequestedFormats.filter((f) => !builtInFormats.has(f) && !customFormatterNames.has(f));
|
|
21317
22397
|
if (unknownFormats.length > 0) {
|
|
21318
22398
|
const knownCustom = customFormatterNames.size > 0 ? `, ${[...customFormatterNames].join(", ")}` : "";
|
|
21319
|
-
console.error(`Error: Unknown format(s): ${unknownFormats.join(", ")}. Valid built-in: astro, behavior-manifest-json, confluence, html, markdown, junit, cucumber-json, cucumber-messages, cucumber-html, scenario-index-json, story-report-json${knownCustom}.`);
|
|
22399
|
+
console.error(`Error: Unknown format(s): ${unknownFormats.join(", ")}. Valid built-in: astro, behavior-manifest-json, confluence, html, markdown, release-manifest, junit, cucumber-json, cucumber-messages, cucumber-html, scenario-index-json, story-report-json${knownCustom}.`);
|
|
21320
22400
|
process.exit(EXIT_USAGE);
|
|
21321
22401
|
}
|
|
21322
22402
|
const formats = builtInRequested;
|
|
@@ -21454,7 +22534,10 @@ async function parseCliArgs(argv) {
|
|
|
21454
22534
|
prSummaryFile: values["pr-summary-file"],
|
|
21455
22535
|
failOnRegression: values["fail-on-regression"],
|
|
21456
22536
|
failOnAddedFailures: values["fail-on-added-failures"],
|
|
22537
|
+
failOnRemoval: values["fail-on-removal"],
|
|
22538
|
+
failOnNew: values["fail-on-new"],
|
|
21457
22539
|
maxRegressions,
|
|
22540
|
+
releasePolicy: values["release-policy"],
|
|
21458
22541
|
changedFilesPath: values["changed-files"],
|
|
21459
22542
|
baseRef: values["base-ref"],
|
|
21460
22543
|
headRef: values["head-ref"],
|
|
@@ -21468,27 +22551,27 @@ async function readInput(args) {
|
|
|
21468
22551
|
if (args.stdin) {
|
|
21469
22552
|
return readStdin();
|
|
21470
22553
|
}
|
|
21471
|
-
const filePath =
|
|
21472
|
-
if (!
|
|
22554
|
+
const filePath = path15.resolve(args.inputFile);
|
|
22555
|
+
if (!fs14.existsSync(filePath)) {
|
|
21473
22556
|
console.error(`Error: File not found: ${filePath}`);
|
|
21474
22557
|
process.exit(EXIT_USAGE);
|
|
21475
22558
|
}
|
|
21476
|
-
return
|
|
22559
|
+
return fs14.readFileSync(filePath, "utf8");
|
|
21477
22560
|
}
|
|
21478
22561
|
function readFileInput(filePath) {
|
|
21479
|
-
const resolved =
|
|
21480
|
-
if (!
|
|
22562
|
+
const resolved = path15.resolve(filePath);
|
|
22563
|
+
if (!fs14.existsSync(resolved)) {
|
|
21481
22564
|
console.error(`Error: File not found: ${resolved}`);
|
|
21482
22565
|
process.exit(EXIT_USAGE);
|
|
21483
22566
|
}
|
|
21484
|
-
return
|
|
22567
|
+
return fs14.readFileSync(resolved, "utf8");
|
|
21485
22568
|
}
|
|
21486
22569
|
function readStdin() {
|
|
21487
|
-
return new Promise((
|
|
22570
|
+
return new Promise((resolve12, reject) => {
|
|
21488
22571
|
const chunks = [];
|
|
21489
22572
|
process.stdin.setEncoding("utf8");
|
|
21490
22573
|
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
21491
|
-
process.stdin.on("end", () =>
|
|
22574
|
+
process.stdin.on("end", () => resolve12(chunks.join("")));
|
|
21492
22575
|
process.stdin.on("error", reject);
|
|
21493
22576
|
});
|
|
21494
22577
|
}
|
|
@@ -21614,14 +22697,14 @@ function tryNormalizeRunFromText(text2, args) {
|
|
|
21614
22697
|
}
|
|
21615
22698
|
}
|
|
21616
22699
|
function listBaselineCandidates(currentFile, args) {
|
|
21617
|
-
const baselineDir =
|
|
21618
|
-
const currentResolved =
|
|
21619
|
-
if (!
|
|
22700
|
+
const baselineDir = path15.resolve(args.baselineDir ?? path15.dirname(currentFile));
|
|
22701
|
+
const currentResolved = path15.resolve(currentFile);
|
|
22702
|
+
if (!fs14.existsSync(baselineDir)) {
|
|
21620
22703
|
console.error(`Error: baseline directory not found: ${baselineDir}`);
|
|
21621
22704
|
process.exit(EXIT_USAGE);
|
|
21622
22705
|
}
|
|
21623
|
-
const entries =
|
|
21624
|
-
return entries.filter((entry) => entry.isFile()).map((entry) =>
|
|
22706
|
+
const entries = fs14.readdirSync(baselineDir, { withFileTypes: true });
|
|
22707
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => path15.join(baselineDir, entry.name)).filter((candidate) => path15.resolve(candidate) !== currentResolved).filter(
|
|
21625
22708
|
(candidate) => args.inputType === "ndjson" ? candidate.endsWith(".ndjson") : candidate.endsWith(".json")
|
|
21626
22709
|
);
|
|
21627
22710
|
}
|
|
@@ -21629,14 +22712,14 @@ function resolveBaselineAuto(currentFile, currentRun, args) {
|
|
|
21629
22712
|
const candidates = listBaselineCandidates(currentFile, args);
|
|
21630
22713
|
const comparable = [];
|
|
21631
22714
|
for (const candidate of candidates) {
|
|
21632
|
-
const run = tryNormalizeRunFromText(
|
|
22715
|
+
const run = tryNormalizeRunFromText(fs14.readFileSync(candidate, "utf8"), args);
|
|
21633
22716
|
if (run) {
|
|
21634
22717
|
comparable.push({ file: candidate, run });
|
|
21635
22718
|
}
|
|
21636
22719
|
}
|
|
21637
22720
|
if (comparable.length === 0) {
|
|
21638
22721
|
console.error(
|
|
21639
|
-
`Error: no compatible baseline files found in ${
|
|
22722
|
+
`Error: no compatible baseline files found in ${path15.resolve(args.baselineDir ?? path15.dirname(currentFile))}.`
|
|
21640
22723
|
);
|
|
21641
22724
|
process.exit(EXIT_USAGE);
|
|
21642
22725
|
}
|
|
@@ -21673,6 +22756,50 @@ async function main() {
|
|
|
21673
22756
|
process.exit(EXIT_GENERATION);
|
|
21674
22757
|
}
|
|
21675
22758
|
}
|
|
22759
|
+
if (args.subcommand === "gate-release") {
|
|
22760
|
+
const gatedArgs = {
|
|
22761
|
+
...args,
|
|
22762
|
+
failOnRegression: true,
|
|
22763
|
+
failOnRemoval: true
|
|
22764
|
+
// failOnNew is opt-in via --fail-on-new flag
|
|
22765
|
+
};
|
|
22766
|
+
let policy;
|
|
22767
|
+
if (args.releasePolicy) {
|
|
22768
|
+
policy = loadReleasePolicy(args.releasePolicy);
|
|
22769
|
+
}
|
|
22770
|
+
const currentText = readFileInput(gatedArgs.currentFile);
|
|
22771
|
+
const current = applySelection(normalizeRunFromText(currentText, gatedArgs).run, gatedArgs);
|
|
22772
|
+
const baselineFile = gatedArgs.baselineMode === "auto" ? resolveBaselineAuto(gatedArgs.currentFile, current, gatedArgs) : gatedArgs.baselineFile;
|
|
22773
|
+
const baselineText = readFileInput(baselineFile);
|
|
22774
|
+
const baseline = applySelection(normalizeRunFromText(baselineText, gatedArgs).run, gatedArgs);
|
|
22775
|
+
try {
|
|
22776
|
+
const result = await generateCompareReports(baseline, current, baselineFile, gatedArgs);
|
|
22777
|
+
const effectiveResult = policy ? applyReleasePolicy(result, policy) : result;
|
|
22778
|
+
printCompareResult(effectiveResult, gatedArgs, startMs);
|
|
22779
|
+
const gateFailures = evaluateCompareGate(effectiveResult, gatedArgs);
|
|
22780
|
+
if (gateFailures.length > 0) {
|
|
22781
|
+
for (const failure of gateFailures) {
|
|
22782
|
+
console.error(`Release gate failed: ${failure}`);
|
|
22783
|
+
}
|
|
22784
|
+
if (policy) {
|
|
22785
|
+
console.error(`Release policy: ${args.releasePolicy}`);
|
|
22786
|
+
if (policy.allowedOmissions && policy.allowedOmissions.length > 0) {
|
|
22787
|
+
console.error(` Allowed omissions: ${policy.allowedOmissions.join(", ")}`);
|
|
22788
|
+
}
|
|
22789
|
+
if (policy.allowedRegressions && policy.allowedRegressions.length > 0) {
|
|
22790
|
+
console.error(` Allowed regressions: ${policy.allowedRegressions.join(", ")}`);
|
|
22791
|
+
}
|
|
22792
|
+
}
|
|
22793
|
+
process.exit(EXIT_RELEASE_GATE);
|
|
22794
|
+
}
|
|
22795
|
+
console.error("Release gate passed: RC matches dev baseline.");
|
|
22796
|
+
process.exit(EXIT_SUCCESS);
|
|
22797
|
+
} catch (err) {
|
|
22798
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
22799
|
+
console.error(`Release gate check failed: ${msg}`);
|
|
22800
|
+
process.exit(EXIT_GENERATION);
|
|
22801
|
+
}
|
|
22802
|
+
}
|
|
21676
22803
|
if (args.subcommand === "review") {
|
|
21677
22804
|
const text3 = await readInput(args);
|
|
21678
22805
|
const run = applySelection(normalizeRunFromText(text3, args).run, args);
|
|
@@ -21774,9 +22901,9 @@ async function main() {
|
|
|
21774
22901
|
process.exit(EXIT_SCHEMA_VALIDATION);
|
|
21775
22902
|
}
|
|
21776
22903
|
if (args.emitCanonical) {
|
|
21777
|
-
const outPath =
|
|
21778
|
-
|
|
21779
|
-
|
|
22904
|
+
const outPath = path15.resolve(args.emitCanonical);
|
|
22905
|
+
fs14.mkdirSync(path15.dirname(outPath), { recursive: true });
|
|
22906
|
+
fs14.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
|
|
21780
22907
|
}
|
|
21781
22908
|
try {
|
|
21782
22909
|
const result = await generateReports(run, args);
|
|
@@ -21833,9 +22960,9 @@ ${msg}`);
|
|
|
21833
22960
|
}
|
|
21834
22961
|
const run = data;
|
|
21835
22962
|
if (args.emitCanonical) {
|
|
21836
|
-
const outPath =
|
|
21837
|
-
|
|
21838
|
-
|
|
22963
|
+
const outPath = path15.resolve(args.emitCanonical);
|
|
22964
|
+
fs14.mkdirSync(path15.dirname(outPath), { recursive: true });
|
|
22965
|
+
fs14.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
|
|
21839
22966
|
}
|
|
21840
22967
|
try {
|
|
21841
22968
|
const result = await generateReports(run, args);
|
|
@@ -21891,9 +23018,9 @@ ${msg}`);
|
|
|
21891
23018
|
process.exit(EXIT_CANONICAL_VALIDATION);
|
|
21892
23019
|
}
|
|
21893
23020
|
if (args.emitCanonical) {
|
|
21894
|
-
const outPath =
|
|
21895
|
-
|
|
21896
|
-
|
|
23021
|
+
const outPath = path15.resolve(args.emitCanonical);
|
|
23022
|
+
fs14.mkdirSync(path15.dirname(outPath), { recursive: true });
|
|
23023
|
+
fs14.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
|
|
21897
23024
|
}
|
|
21898
23025
|
try {
|
|
21899
23026
|
const result = await generateReports(canonical, args, droppedMissingStory);
|
|
@@ -21918,9 +23045,9 @@ function runCustomFormatters(run, customRequested, formatters, args) {
|
|
|
21918
23045
|
const ext = formatter.fileExtension ?? formatName;
|
|
21919
23046
|
const baseName = args.outputName ?? "report";
|
|
21920
23047
|
const filename = args.outputNameTimestamp ? `${baseName}-${Math.floor(run.startedAtMs / 1e3)}.${ext}` : `${baseName}.${ext}`;
|
|
21921
|
-
const filepath =
|
|
21922
|
-
|
|
21923
|
-
|
|
23048
|
+
const filepath = path15.join(outputDir, filename);
|
|
23049
|
+
fs14.mkdirSync(outputDir, { recursive: true });
|
|
23050
|
+
fs14.writeFileSync(filepath, content, "utf8");
|
|
21924
23051
|
console.log(`Generated: ${filepath}`);
|
|
21925
23052
|
} catch (err) {
|
|
21926
23053
|
console.error(`Error running custom formatter "${formatName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -21970,13 +23097,13 @@ async function dispatchNotifications(run, args) {
|
|
|
21970
23097
|
}
|
|
21971
23098
|
function runHistoryPipeline(run, args) {
|
|
21972
23099
|
if (!args.historyFile) return;
|
|
21973
|
-
const historyPath =
|
|
23100
|
+
const historyPath = path15.resolve(args.historyFile);
|
|
21974
23101
|
const store = loadHistory(
|
|
21975
23102
|
{ filePath: historyPath },
|
|
21976
23103
|
{
|
|
21977
23104
|
readFile: (p) => {
|
|
21978
23105
|
try {
|
|
21979
|
-
return
|
|
23106
|
+
return fs14.readFileSync(p, "utf8");
|
|
21980
23107
|
} catch {
|
|
21981
23108
|
return void 0;
|
|
21982
23109
|
}
|
|
@@ -21989,11 +23116,11 @@ function runHistoryPipeline(run, args) {
|
|
|
21989
23116
|
run,
|
|
21990
23117
|
maxRuns: args.maxHistoryRuns
|
|
21991
23118
|
});
|
|
21992
|
-
const dir =
|
|
21993
|
-
|
|
23119
|
+
const dir = path15.dirname(historyPath);
|
|
23120
|
+
fs14.mkdirSync(dir, { recursive: true });
|
|
21994
23121
|
saveHistory(
|
|
21995
23122
|
{ filePath: historyPath, store: updated },
|
|
21996
|
-
{ writeFile: (p, content) =>
|
|
23123
|
+
{ writeFile: (p, content) => fs14.writeFileSync(p, content, "utf8") }
|
|
21997
23124
|
);
|
|
21998
23125
|
let metricsCount = 0;
|
|
21999
23126
|
for (const testId of Object.keys(updated.tests)) {
|
|
@@ -22070,6 +23197,7 @@ async function generateCompareReports(baseline, current, baselineFile, args) {
|
|
|
22070
23197
|
(scenario) => scenario.kind === "added" && scenario.current?.status === "failed"
|
|
22071
23198
|
).length,
|
|
22072
23199
|
summary: result.diff.summary,
|
|
23200
|
+
scenarios: result.diff.scenarios,
|
|
22073
23201
|
prSummary: args.prSummary || args.prSummaryFile ? createPrCommentSummary(result.diff) : void 0
|
|
22074
23202
|
};
|
|
22075
23203
|
}
|
|
@@ -22140,11 +23268,11 @@ function writeReviewReport(review, args) {
|
|
|
22140
23268
|
const outputDir = args.outputDir ?? "reports";
|
|
22141
23269
|
const baseName = args.outputName ?? "evidence-review";
|
|
22142
23270
|
const suffix = args.outputNameTimestamp ? `-${Math.floor(review.run.startedAtMs / 1e3)}` : "";
|
|
22143
|
-
|
|
22144
|
-
const mdPath =
|
|
22145
|
-
const htmlPath =
|
|
22146
|
-
|
|
22147
|
-
|
|
23271
|
+
fs14.mkdirSync(outputDir, { recursive: true });
|
|
23272
|
+
const mdPath = path15.join(outputDir, `${baseName}${suffix}.md`);
|
|
23273
|
+
const htmlPath = path15.join(outputDir, `${baseName}${suffix}.html`);
|
|
23274
|
+
fs14.writeFileSync(mdPath, markdown, "utf8");
|
|
23275
|
+
fs14.writeFileSync(htmlPath, html, "utf8");
|
|
22148
23276
|
return [mdPath, htmlPath];
|
|
22149
23277
|
}
|
|
22150
23278
|
function evaluateReviewGate(review, args) {
|
|
@@ -22190,9 +23318,9 @@ function printResult(result, args, startMs, droppedMissingStory = 0) {
|
|
|
22190
23318
|
function printCompareResult(result, args, startMs) {
|
|
22191
23319
|
const durationMs = Date.now() - startMs;
|
|
22192
23320
|
if (result.prSummary && args.prSummaryFile) {
|
|
22193
|
-
const outputPath =
|
|
22194
|
-
|
|
22195
|
-
|
|
23321
|
+
const outputPath = path15.resolve(args.prSummaryFile);
|
|
23322
|
+
fs14.mkdirSync(path15.dirname(outputPath), { recursive: true });
|
|
23323
|
+
fs14.writeFileSync(outputPath, result.prSummary, "utf8");
|
|
22196
23324
|
}
|
|
22197
23325
|
if (args.jsonSummary) {
|
|
22198
23326
|
console.log(
|
|
@@ -22220,6 +23348,44 @@ function printCompareResult(result, args, startMs) {
|
|
|
22220
23348
|
console.log(result.prSummary);
|
|
22221
23349
|
}
|
|
22222
23350
|
}
|
|
23351
|
+
function loadReleasePolicy(policyPath) {
|
|
23352
|
+
const resolved = path15.resolve(policyPath);
|
|
23353
|
+
if (!fs14.existsSync(resolved)) {
|
|
23354
|
+
console.error(`Error: release policy file not found: ${resolved}`);
|
|
23355
|
+
process.exit(EXIT_USAGE);
|
|
23356
|
+
}
|
|
23357
|
+
try {
|
|
23358
|
+
const raw = JSON.parse(fs14.readFileSync(resolved, "utf8"));
|
|
23359
|
+
return {
|
|
23360
|
+
allowedOmissions: Array.isArray(raw.allowedOmissions) ? raw.allowedOmissions : [],
|
|
23361
|
+
allowedRegressions: Array.isArray(raw.allowedRegressions) ? raw.allowedRegressions : [],
|
|
23362
|
+
allowNewScenarios: Boolean(raw.allowNewScenarios)
|
|
23363
|
+
};
|
|
23364
|
+
} catch (err) {
|
|
23365
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
23366
|
+
console.error(`Error reading release policy: ${msg}`);
|
|
23367
|
+
process.exit(EXIT_USAGE);
|
|
23368
|
+
}
|
|
23369
|
+
}
|
|
23370
|
+
function applyReleasePolicy(result, policy) {
|
|
23371
|
+
const allowedOmissionSet = new Set(policy.allowedOmissions ?? []);
|
|
23372
|
+
const allowedRegressionSet = new Set(policy.allowedRegressions ?? []);
|
|
23373
|
+
const adjustedOmissions = result.scenarios.filter(
|
|
23374
|
+
(scenario) => scenario.kind === "removed" && !allowedOmissionSet.has(scenario.id)
|
|
23375
|
+
).length;
|
|
23376
|
+
const adjustedRegressions = result.scenarios.filter(
|
|
23377
|
+
(scenario) => scenario.kind === "regressed" && !allowedRegressionSet.has(scenario.id)
|
|
23378
|
+
).length;
|
|
23379
|
+
const adjustedSummary = {
|
|
23380
|
+
...result.summary,
|
|
23381
|
+
removed: adjustedOmissions,
|
|
23382
|
+
regressed: adjustedRegressions
|
|
23383
|
+
};
|
|
23384
|
+
return {
|
|
23385
|
+
...result,
|
|
23386
|
+
summary: adjustedSummary
|
|
23387
|
+
};
|
|
23388
|
+
}
|
|
22223
23389
|
function evaluateCompareGate(result, args) {
|
|
22224
23390
|
const failures = [];
|
|
22225
23391
|
if (args.failOnRegression && result.summary.regressed > 0) {
|
|
@@ -22232,6 +23398,16 @@ function evaluateCompareGate(result, args) {
|
|
|
22232
23398
|
`new failing scenarios detected (${result.addedFailures}) with --fail-on-added-failures`
|
|
22233
23399
|
);
|
|
22234
23400
|
}
|
|
23401
|
+
if (args.failOnRemoval && result.summary.removed > 0) {
|
|
23402
|
+
failures.push(
|
|
23403
|
+
`removed scenarios detected (${result.summary.removed}) with --fail-on-removal`
|
|
23404
|
+
);
|
|
23405
|
+
}
|
|
23406
|
+
if (args.failOnNew && result.summary.added > 0) {
|
|
23407
|
+
failures.push(
|
|
23408
|
+
`new scenarios detected (${result.summary.added}) with --fail-on-new`
|
|
23409
|
+
);
|
|
23410
|
+
}
|
|
22235
23411
|
if (args.maxRegressions !== void 0 && result.summary.regressed > args.maxRegressions) {
|
|
22236
23412
|
failures.push(
|
|
22237
23413
|
`regressions ${result.summary.regressed} exceed --max-regressions ${args.maxRegressions}`
|
|
@@ -22283,7 +23459,7 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
|
|
|
22283
23459
|
console.error("Error: missing ADF file argument. Run with --help for usage.");
|
|
22284
23460
|
process.exit(EXIT_USAGE);
|
|
22285
23461
|
}
|
|
22286
|
-
if (!
|
|
23462
|
+
if (!fs14.existsSync(inputFile)) {
|
|
22287
23463
|
console.error(`Error: file not found: ${inputFile}`);
|
|
22288
23464
|
process.exit(EXIT_USAGE);
|
|
22289
23465
|
}
|
|
@@ -22311,7 +23487,7 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
|
|
|
22311
23487
|
console.error("Error: --title is required when creating a new page");
|
|
22312
23488
|
process.exit(EXIT_USAGE);
|
|
22313
23489
|
}
|
|
22314
|
-
const adf =
|
|
23490
|
+
const adf = fs14.readFileSync(path15.resolve(inputFile), "utf8");
|
|
22315
23491
|
if (dryRun) {
|
|
22316
23492
|
console.log(
|
|
22317
23493
|
JSON.stringify(
|
|
@@ -22390,7 +23566,7 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
|
|
|
22390
23566
|
console.error("Error: missing ADF file argument. Run with --help for usage.");
|
|
22391
23567
|
process.exit(EXIT_USAGE);
|
|
22392
23568
|
}
|
|
22393
|
-
if (!
|
|
23569
|
+
if (!fs14.existsSync(inputFile)) {
|
|
22394
23570
|
console.error(`Error: file not found: ${inputFile}`);
|
|
22395
23571
|
process.exit(EXIT_USAGE);
|
|
22396
23572
|
}
|
|
@@ -22417,7 +23593,7 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
|
|
|
22417
23593
|
process.exit(EXIT_USAGE);
|
|
22418
23594
|
}
|
|
22419
23595
|
const mode = modeRaw;
|
|
22420
|
-
const adf =
|
|
23596
|
+
const adf = fs14.readFileSync(path15.resolve(inputFile), "utf8");
|
|
22421
23597
|
if (dryRun) {
|
|
22422
23598
|
console.log(
|
|
22423
23599
|
JSON.stringify(
|
|
@@ -22458,6 +23634,348 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
|
|
|
22458
23634
|
process.exit(EXIT_GENERATION);
|
|
22459
23635
|
}
|
|
22460
23636
|
}
|
|
23637
|
+
function runNew(rawArgs) {
|
|
23638
|
+
const { values, positionals } = parseArgs({
|
|
23639
|
+
args: rawArgs,
|
|
23640
|
+
options: { dir: { type: "string" }, force: { type: "boolean", default: false } },
|
|
23641
|
+
allowPositionals: true,
|
|
23642
|
+
strict: true
|
|
23643
|
+
});
|
|
23644
|
+
const template = positionals[0];
|
|
23645
|
+
const name = positionals.slice(1).join(" ");
|
|
23646
|
+
if (!template) {
|
|
23647
|
+
console.error(`Usage: executable-stories new <template> "<name>" [--dir <docs-dir>] [--force]`);
|
|
23648
|
+
console.error(`Templates: ${TEMPLATES.join(", ")}`);
|
|
23649
|
+
return EXIT_USAGE;
|
|
23650
|
+
}
|
|
23651
|
+
try {
|
|
23652
|
+
const result = scaffoldDoc({
|
|
23653
|
+
template,
|
|
23654
|
+
name,
|
|
23655
|
+
baseDir: values.dir,
|
|
23656
|
+
force: values.force
|
|
23657
|
+
});
|
|
23658
|
+
console.log(`Created ${result.template}: ${result.path}`);
|
|
23659
|
+
console.log(` Title: ${result.title}`);
|
|
23660
|
+
console.log("");
|
|
23661
|
+
console.log("Next: fill in the content and link verifying stories in `verifiedBy`.");
|
|
23662
|
+
return EXIT_SUCCESS;
|
|
23663
|
+
} catch (err) {
|
|
23664
|
+
console.error(`Error: ${err.message}`);
|
|
23665
|
+
return EXIT_USAGE;
|
|
23666
|
+
}
|
|
23667
|
+
}
|
|
23668
|
+
async function runCheckLinks(rawArgs) {
|
|
23669
|
+
const { values, positionals } = parseArgs({
|
|
23670
|
+
args: rawArgs,
|
|
23671
|
+
options: {
|
|
23672
|
+
external: { type: "boolean", default: false },
|
|
23673
|
+
json: { type: "boolean", default: false }
|
|
23674
|
+
},
|
|
23675
|
+
allowPositionals: true,
|
|
23676
|
+
strict: true
|
|
23677
|
+
});
|
|
23678
|
+
try {
|
|
23679
|
+
const report = await checkLinks({
|
|
23680
|
+
target: positionals[0] ?? ".",
|
|
23681
|
+
checkExternal: values.external
|
|
23682
|
+
});
|
|
23683
|
+
console.log(values.json ? JSON.stringify(report, null, 2) : formatLinkReport(report));
|
|
23684
|
+
return report.brokenCount > 0 ? EXIT_GENERATION : EXIT_SUCCESS;
|
|
23685
|
+
} catch (err) {
|
|
23686
|
+
console.error(`Error: ${err.message}`);
|
|
23687
|
+
return EXIT_USAGE;
|
|
23688
|
+
}
|
|
23689
|
+
}
|
|
23690
|
+
async function runImportOpenApi(rawArgs) {
|
|
23691
|
+
const { values, positionals } = parseArgs({
|
|
23692
|
+
args: rawArgs,
|
|
23693
|
+
options: {
|
|
23694
|
+
"output-dir": { type: "string" },
|
|
23695
|
+
run: { type: "string" },
|
|
23696
|
+
force: { type: "boolean", default: false }
|
|
23697
|
+
},
|
|
23698
|
+
allowPositionals: true,
|
|
23699
|
+
strict: true
|
|
23700
|
+
});
|
|
23701
|
+
const spec = positionals[0];
|
|
23702
|
+
if (!spec) {
|
|
23703
|
+
console.error(`Usage: executable-stories import-openapi <spec.json|yaml> [--output-dir <dir>] [--run <story-report.json>] [--force]`);
|
|
23704
|
+
return EXIT_USAGE;
|
|
23705
|
+
}
|
|
23706
|
+
try {
|
|
23707
|
+
const result = await importOpenApi({
|
|
23708
|
+
specPath: spec,
|
|
23709
|
+
outputDir: values["output-dir"],
|
|
23710
|
+
runFile: values.run,
|
|
23711
|
+
force: values.force
|
|
23712
|
+
});
|
|
23713
|
+
console.log(`Generated ${result.pageCount} API page(s) at ${result.outputDir}`);
|
|
23714
|
+
console.log(` Covered endpoints: ${result.coveredCount} / ${result.endpointCount}`);
|
|
23715
|
+
if (result.uncoveredCount > 0) {
|
|
23716
|
+
console.log(` \u26A0 ${result.uncoveredCount} endpoint(s) have no verifying story`);
|
|
23717
|
+
}
|
|
23718
|
+
return EXIT_SUCCESS;
|
|
23719
|
+
} catch (err) {
|
|
23720
|
+
console.error(`Error: ${err.message}`);
|
|
23721
|
+
return EXIT_USAGE;
|
|
23722
|
+
}
|
|
23723
|
+
}
|
|
23724
|
+
async function runBuildDocs(rawArgs) {
|
|
23725
|
+
const { values, positionals } = parseArgs({
|
|
23726
|
+
args: rawArgs,
|
|
23727
|
+
options: {
|
|
23728
|
+
"site-dir": { type: "string" },
|
|
23729
|
+
openapi: { type: "string" },
|
|
23730
|
+
"no-synthesize-stories": { type: "boolean", default: false }
|
|
23731
|
+
},
|
|
23732
|
+
allowPositionals: true,
|
|
23733
|
+
strict: true
|
|
23734
|
+
});
|
|
23735
|
+
const rawRunPath = positionals[0];
|
|
23736
|
+
if (!rawRunPath) {
|
|
23737
|
+
console.error(
|
|
23738
|
+
`Usage: executable-stories build-docs <raw-run.json> [--site-dir <dir>] [--openapi <spec>]`
|
|
23739
|
+
);
|
|
23740
|
+
return EXIT_USAGE;
|
|
23741
|
+
}
|
|
23742
|
+
try {
|
|
23743
|
+
const result = await buildDocs({
|
|
23744
|
+
rawRunPath,
|
|
23745
|
+
siteDir: values["site-dir"] ?? ".",
|
|
23746
|
+
openapiPath: values.openapi,
|
|
23747
|
+
synthesizeStories: !values["no-synthesize-stories"]
|
|
23748
|
+
});
|
|
23749
|
+
console.log(`\u2713 Living docs generated in ${result.siteDir}`);
|
|
23750
|
+
console.log(` \u2022 Explorer data \u2192 public/stories/story-report.json`);
|
|
23751
|
+
console.log(` \u2022 Story pages \u2192 src/content/docs/stories`);
|
|
23752
|
+
if (result.bundledAssets > 0) {
|
|
23753
|
+
console.log(` \u2022 Bundled assets \u2192 public/stories/assets (${result.bundledAssets})`);
|
|
23754
|
+
}
|
|
23755
|
+
if (result.apiPages > 0) {
|
|
23756
|
+
console.log(` \u2022 API pages \u2192 src/content/docs/api (${result.apiPages})`);
|
|
23757
|
+
}
|
|
23758
|
+
const rel = path15.relative(process.cwd(), result.siteDir) || ".";
|
|
23759
|
+
console.log(`
|
|
23760
|
+
Preview: cd ${rel} && npm run dev`);
|
|
23761
|
+
return EXIT_SUCCESS;
|
|
23762
|
+
} catch (err) {
|
|
23763
|
+
if (err instanceof BuildDocsError) {
|
|
23764
|
+
console.error(err.message);
|
|
23765
|
+
if (err.kind === "schema") return EXIT_SCHEMA_VALIDATION;
|
|
23766
|
+
if (err.kind === "generation") return EXIT_GENERATION;
|
|
23767
|
+
return EXIT_USAGE;
|
|
23768
|
+
}
|
|
23769
|
+
console.error(`Error: ${err.message}`);
|
|
23770
|
+
return EXIT_USAGE;
|
|
23771
|
+
}
|
|
23772
|
+
}
|
|
23773
|
+
async function runDeploy(rawArgs) {
|
|
23774
|
+
const mode = rawArgs[0];
|
|
23775
|
+
if (!mode || !["record", "status", "diff"].includes(mode)) {
|
|
23776
|
+
console.error("Usage: executable-stories deploy <record|status|diff> [options]");
|
|
23777
|
+
console.error(" deploy record <file> --env <env> [--tag <tag>] [--ledger <path>]");
|
|
23778
|
+
console.error(" deploy status [--ledger <path>] [--json]");
|
|
23779
|
+
console.error(" deploy diff <env-a> <env-b> [--ledger <path>] [--json]");
|
|
23780
|
+
return EXIT_USAGE;
|
|
23781
|
+
}
|
|
23782
|
+
const { values, positionals } = parseArgs({
|
|
23783
|
+
args: rawArgs.slice(1),
|
|
23784
|
+
options: {
|
|
23785
|
+
env: { type: "string" },
|
|
23786
|
+
tag: { type: "string" },
|
|
23787
|
+
ledger: { type: "string", default: ".executable-stories/deployments.json" },
|
|
23788
|
+
json: { type: "boolean", default: false },
|
|
23789
|
+
help: { type: "boolean", default: false }
|
|
23790
|
+
},
|
|
23791
|
+
allowPositionals: true,
|
|
23792
|
+
strict: true
|
|
23793
|
+
});
|
|
23794
|
+
if (values.help) {
|
|
23795
|
+
console.log(`executable-stories deploy \u2014 Track deployments across environments.
|
|
23796
|
+
|
|
23797
|
+
USAGE
|
|
23798
|
+
executable-stories deploy record <file> --env <env> [--tag <tag>] [--ledger <path>]
|
|
23799
|
+
executable-stories deploy status [--ledger <path>] [--json]
|
|
23800
|
+
executable-stories deploy diff <env-a> <env-b> [--ledger <path>] [--json]
|
|
23801
|
+
|
|
23802
|
+
OPTIONS
|
|
23803
|
+
--env <env> Environment name (e.g. dev, staging, production)
|
|
23804
|
+
--tag <tag> Optional Git tag for this deployment (e.g. v1.2.3)
|
|
23805
|
+
--ledger <path> Path to deployment ledger JSON (default: .executable-stories/deployments.json)
|
|
23806
|
+
--json Output as JSON instead of text`);
|
|
23807
|
+
return EXIT_SUCCESS;
|
|
23808
|
+
}
|
|
23809
|
+
const ledgerPath = values.ledger;
|
|
23810
|
+
if (mode === "record") {
|
|
23811
|
+
const inputFile = positionals[0];
|
|
23812
|
+
if (!inputFile) {
|
|
23813
|
+
console.error("Error: deploy record requires an input file.");
|
|
23814
|
+
return EXIT_USAGE;
|
|
23815
|
+
}
|
|
23816
|
+
const env = values.env;
|
|
23817
|
+
if (!env) {
|
|
23818
|
+
console.error("Error: deploy record requires --env <environment>.");
|
|
23819
|
+
return EXIT_USAGE;
|
|
23820
|
+
}
|
|
23821
|
+
const text2 = readFileInput(inputFile);
|
|
23822
|
+
const { run } = normalizeRunFromText(text2, {
|
|
23823
|
+
...createDefaultCliArgs(),
|
|
23824
|
+
inputType: "raw",
|
|
23825
|
+
inputFile
|
|
23826
|
+
});
|
|
23827
|
+
const applied = applySelection(run, createDefaultCliArgs());
|
|
23828
|
+
const result = recordDeployment({
|
|
23829
|
+
run: applied,
|
|
23830
|
+
environment: env,
|
|
23831
|
+
tag: values.tag,
|
|
23832
|
+
ledgerPath,
|
|
23833
|
+
runFilePath: inputFile
|
|
23834
|
+
});
|
|
23835
|
+
console.error(
|
|
23836
|
+
`Recorded deployment to "${result.entry.environment}" at ${result.entry.timestamp}`
|
|
23837
|
+
);
|
|
23838
|
+
console.error(
|
|
23839
|
+
` Scenarios: ${result.entry.summary.total} (${result.entry.summary.passed} passed, ${result.entry.summary.failed} failed)`
|
|
23840
|
+
);
|
|
23841
|
+
if (result.entry.tag) {
|
|
23842
|
+
console.error(` Tag: ${result.entry.tag}`);
|
|
23843
|
+
}
|
|
23844
|
+
console.error(` Ledger: ${result.ledgerPath}`);
|
|
23845
|
+
return EXIT_SUCCESS;
|
|
23846
|
+
}
|
|
23847
|
+
if (mode === "status") {
|
|
23848
|
+
const status = getDeploymentStatus(ledgerPath);
|
|
23849
|
+
const envs = Object.keys(status.environments);
|
|
23850
|
+
if (envs.length === 0) {
|
|
23851
|
+
console.error("No deployments recorded yet.");
|
|
23852
|
+
return EXIT_SUCCESS;
|
|
23853
|
+
}
|
|
23854
|
+
if (values.json) {
|
|
23855
|
+
console.log(JSON.stringify(status, null, 2));
|
|
23856
|
+
} else {
|
|
23857
|
+
for (const envName of envs) {
|
|
23858
|
+
const env = status.environments[envName];
|
|
23859
|
+
if (!env) continue;
|
|
23860
|
+
const e = env.latest;
|
|
23861
|
+
console.log(`${envName}:`);
|
|
23862
|
+
console.log(` Deployed: ${e.timestamp}`);
|
|
23863
|
+
console.log(` SHA: ${e.sha ?? "unknown"}`);
|
|
23864
|
+
console.log(` Tag: ${e.tag ?? "none"}`);
|
|
23865
|
+
console.log(` Scenarios: ${e.summary.total} (${e.summary.passed} passed, ${e.summary.failed} failed, ${e.summary.skipped} skipped, ${e.summary.pending} pending)`);
|
|
23866
|
+
if (env.previousDeployment) {
|
|
23867
|
+
const prev = env.previousDeployment;
|
|
23868
|
+
const added = e.scenarioIds.filter((id) => !new Set(prev.scenarioIds).has(id)).length;
|
|
23869
|
+
const removed = prev.scenarioIds.filter((id) => !new Set(e.scenarioIds).has(id)).length;
|
|
23870
|
+
if (added > 0 || removed > 0) {
|
|
23871
|
+
console.log(` Drift from previous: +${added} added, -${removed} removed`);
|
|
23872
|
+
}
|
|
23873
|
+
}
|
|
23874
|
+
console.log();
|
|
23875
|
+
}
|
|
23876
|
+
console.log(`Ledger: ${ledgerPath}`);
|
|
23877
|
+
}
|
|
23878
|
+
return EXIT_SUCCESS;
|
|
23879
|
+
}
|
|
23880
|
+
if (mode === "diff") {
|
|
23881
|
+
const envA = positionals[0];
|
|
23882
|
+
const envB = positionals[1];
|
|
23883
|
+
if (!envA || !envB) {
|
|
23884
|
+
console.error("Error: deploy diff requires two environment names.");
|
|
23885
|
+
return EXIT_USAGE;
|
|
23886
|
+
}
|
|
23887
|
+
try {
|
|
23888
|
+
const drift = getEnvironmentDrift(ledgerPath, envA, envB);
|
|
23889
|
+
if (values.json) {
|
|
23890
|
+
console.log(JSON.stringify(drift, null, 2));
|
|
23891
|
+
} else {
|
|
23892
|
+
console.log(`Environment drift: ${envA} \u2194 ${envB}`);
|
|
23893
|
+
console.log(` ${envA}: ${drift.aEntry.summary.total} scenarios (${drift.aEntry.timestamp})`);
|
|
23894
|
+
console.log(` ${envB}: ${drift.bEntry.summary.total} scenarios (${drift.bEntry.timestamp})`);
|
|
23895
|
+
console.log(` In both: ${drift.inBoth.length}`);
|
|
23896
|
+
console.log(` Only in ${envA}: ${drift.onlyInA.length}`);
|
|
23897
|
+
console.log(` Only in ${envB}: ${drift.onlyInB.length}`);
|
|
23898
|
+
console.log(` Status changed: ${drift.statusChanged.length}`);
|
|
23899
|
+
if (drift.onlyInA.length > 0) {
|
|
23900
|
+
console.log(`
|
|
23901
|
+
Only in ${envA}:`);
|
|
23902
|
+
for (const id of drift.onlyInA.slice(0, 20)) {
|
|
23903
|
+
console.log(` - ${id}`);
|
|
23904
|
+
}
|
|
23905
|
+
if (drift.onlyInA.length > 20) {
|
|
23906
|
+
console.log(` ... and ${drift.onlyInA.length - 20} more`);
|
|
23907
|
+
}
|
|
23908
|
+
}
|
|
23909
|
+
if (drift.onlyInB.length > 0) {
|
|
23910
|
+
console.log(`
|
|
23911
|
+
Only in ${envB}:`);
|
|
23912
|
+
for (const id of drift.onlyInB.slice(0, 20)) {
|
|
23913
|
+
console.log(` - ${id}`);
|
|
23914
|
+
}
|
|
23915
|
+
if (drift.onlyInB.length > 20) {
|
|
23916
|
+
console.log(` ... and ${drift.onlyInB.length - 20} more`);
|
|
23917
|
+
}
|
|
23918
|
+
}
|
|
23919
|
+
if (drift.statusChanged.length > 0) {
|
|
23920
|
+
console.log("\n Status changed:");
|
|
23921
|
+
for (const item of drift.statusChanged.slice(0, 20)) {
|
|
23922
|
+
console.log(` - ${item.id}: ${item.statusA} -> ${item.statusB}`);
|
|
23923
|
+
}
|
|
23924
|
+
if (drift.statusChanged.length > 20) {
|
|
23925
|
+
console.log(` ... and ${drift.statusChanged.length - 20} more`);
|
|
23926
|
+
}
|
|
23927
|
+
}
|
|
23928
|
+
}
|
|
23929
|
+
} catch (err) {
|
|
23930
|
+
console.error(`Error: ${err.message}`);
|
|
23931
|
+
return EXIT_USAGE;
|
|
23932
|
+
}
|
|
23933
|
+
return EXIT_SUCCESS;
|
|
23934
|
+
}
|
|
23935
|
+
return EXIT_USAGE;
|
|
23936
|
+
}
|
|
23937
|
+
function createDefaultCliArgs() {
|
|
23938
|
+
return {
|
|
23939
|
+
subcommand: "format",
|
|
23940
|
+
stdin: false,
|
|
23941
|
+
formats: [],
|
|
23942
|
+
inputType: "raw",
|
|
23943
|
+
outputDir: "reports",
|
|
23944
|
+
outputName: "index",
|
|
23945
|
+
outputNameTimestamp: false,
|
|
23946
|
+
sortTestCases: "none",
|
|
23947
|
+
include: [],
|
|
23948
|
+
exclude: [],
|
|
23949
|
+
includeTags: [],
|
|
23950
|
+
excludeTags: [],
|
|
23951
|
+
synthesizeStories: true,
|
|
23952
|
+
htmlTitle: "Test Results",
|
|
23953
|
+
htmlTheme: "default",
|
|
23954
|
+
htmlNoSyntaxHighlighting: false,
|
|
23955
|
+
htmlNoMermaid: false,
|
|
23956
|
+
htmlNoMarkdown: false,
|
|
23957
|
+
htmlNoToc: false,
|
|
23958
|
+
htmlThemePicker: false,
|
|
23959
|
+
jsonSummary: false,
|
|
23960
|
+
listFormat: "text",
|
|
23961
|
+
notify: "never",
|
|
23962
|
+
maxFailedTests: 5,
|
|
23963
|
+
maxHistoryRuns: 10,
|
|
23964
|
+
webhookUrls: [],
|
|
23965
|
+
webhookHeaders: {},
|
|
23966
|
+
webhookMethod: "POST",
|
|
23967
|
+
webhookHmacHeader: "X-Signature",
|
|
23968
|
+
webhookHmacTimestamp: false,
|
|
23969
|
+
assetMode: "none",
|
|
23970
|
+
allowMissingAssets: false,
|
|
23971
|
+
prSummary: false,
|
|
23972
|
+
failOnRegression: false,
|
|
23973
|
+
failOnAddedFailures: false,
|
|
23974
|
+
failOnRemoval: false,
|
|
23975
|
+
failOnNew: false,
|
|
23976
|
+
baselineMode: "explicit"
|
|
23977
|
+
};
|
|
23978
|
+
}
|
|
22461
23979
|
main().catch((err) => {
|
|
22462
23980
|
console.error(err);
|
|
22463
23981
|
process.exit(EXIT_USAGE);
|