executable-stories-formatters 0.15.0 → 0.15.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/dist/cli.js +41 -9
- package/dist/cli.js.map +1 -1
- package/package.json +3 -2
- package/schemas/README.md +1 -1
- package/templates/astro-starlight/astro.config.mjs +57 -0
- package/templates/astro-starlight/gitignore +14 -0
- package/templates/astro-starlight/package.json +20 -0
- package/templates/astro-starlight/public/stories/assets/.gitkeep +0 -0
- package/templates/astro-starlight/public/stories/notes-index.json +4 -0
- package/templates/astro-starlight/public/stories/story-report.json +17 -0
- package/templates/astro-starlight/src/components/ApiOperations.astro +366 -0
- package/templates/astro-starlight/src/components/Checklist.astro +15 -0
- package/templates/astro-starlight/src/components/HealthDashboard.astro +171 -0
- package/templates/astro-starlight/src/components/PageTitle.astro +53 -0
- package/templates/astro-starlight/src/components/VerifiedBy.astro +281 -0
- package/templates/astro-starlight/src/components/VerifiedStep.astro +91 -0
- package/templates/astro-starlight/src/content/docs/examples/example-adr.mdx +45 -0
- package/templates/astro-starlight/src/content/docs/guides/behavior-portal.mdx +41 -0
- package/templates/astro-starlight/src/content/docs/guides/writing-docs.mdx +49 -0
- package/templates/astro-starlight/src/content/docs/index.mdx +49 -0
- package/templates/astro-starlight/src/content/docs/stories/.gitkeep +0 -0
- package/templates/astro-starlight/src/content.config.ts +18 -0
- package/templates/astro-starlight/src/lib/config.ts +50 -0
- package/templates/astro-starlight/src/lib/render-doc-entry.ts +154 -0
- package/templates/astro-starlight/src/lib/report-health.ts +61 -0
- package/templates/astro-starlight/src/lib/verification.ts +247 -0
- package/templates/astro-starlight/src/pages/explorer/explorer.css +729 -0
- package/templates/astro-starlight/src/pages/explorer/index.astro +404 -0
- package/templates/astro-starlight/src/styles/global.css +293 -0
- package/templates/astro-starlight/src/styles/themes/corporate.css +83 -0
- package/templates/astro-starlight/src/styles/themes/dashboard.css +76 -0
- package/templates/astro-starlight/src/styles/themes/default.css +86 -0
- package/templates/astro-starlight/src/styles/themes/minimal.css +87 -0
- package/templates/astro-starlight/src/styles/themes/playful.css +77 -0
- package/templates/astro-starlight/src/styles/themes/terminal.css +77 -0
- package/templates/astro-starlight/tsconfig.json +13 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure doc-entry → HTML renderer for the Scenario Explorer.
|
|
3
|
+
*
|
|
4
|
+
* The Explorer builds its detail pane from the story report at runtime; this is
|
|
5
|
+
* the string-building logic behind it, kept DOM-free so it can be unit-tested
|
|
6
|
+
* and so the page file stays a thin shell. Markdown/diagrams/code are emitted as
|
|
7
|
+
* placeholder markup (`data-md`, `.mermaid`, `language-*`) that the Explorer
|
|
8
|
+
* hydrates with the bundled marked/mermaid/highlight.js libraries.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the doc kinds in the formatter's HTML renderer
|
|
11
|
+
* (src/formatters/html/renderers/doc-entries.ts) but emits the Explorer's own
|
|
12
|
+
* card markup. `tag` is intentionally skipped — tags render as header pills.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Loose structural shape of a story-report doc entry (template is decoupled from the package types). */
|
|
16
|
+
export interface DocEntry {
|
|
17
|
+
kind: string;
|
|
18
|
+
text?: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
value?: unknown;
|
|
21
|
+
lang?: string;
|
|
22
|
+
content?: string;
|
|
23
|
+
columns?: string[];
|
|
24
|
+
rows?: string[][];
|
|
25
|
+
url?: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
markdown?: string;
|
|
28
|
+
code?: string;
|
|
29
|
+
path?: string;
|
|
30
|
+
alt?: string;
|
|
31
|
+
poster?: string;
|
|
32
|
+
caption?: string;
|
|
33
|
+
type?: string;
|
|
34
|
+
data?: unknown;
|
|
35
|
+
children?: DocEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function escapeHtml(value: unknown): string {
|
|
39
|
+
return String(value ?? "")
|
|
40
|
+
.replaceAll("&", "&")
|
|
41
|
+
.replaceAll("<", "<")
|
|
42
|
+
.replaceAll(">", ">")
|
|
43
|
+
.replaceAll('"', """);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isRemote = (p: string): boolean => /^(?:https?:|data:)/i.test(p);
|
|
47
|
+
const isWindowsFsPath = (p: string): boolean => /^(?:[A-Za-z]:[\\/]|\\\\)/.test(p);
|
|
48
|
+
|
|
49
|
+
const mediaCaption = (text: string | undefined): string =>
|
|
50
|
+
text ? `<div class="doc-media__caption">${escapeHtml(text)}</div>` : "";
|
|
51
|
+
|
|
52
|
+
const broken = (kind: string, p: string): string =>
|
|
53
|
+
`<div class="doc-media doc-media--broken">${kind} unavailable<code>${escapeHtml(p)}</code></div>`;
|
|
54
|
+
|
|
55
|
+
const renderScreenshot = (entry: DocEntry): string => {
|
|
56
|
+
const path = entry.path ?? "";
|
|
57
|
+
if (isWindowsFsPath(path)) return broken("Screenshot", path);
|
|
58
|
+
return `<div class="doc-media"><img src="${escapeHtml(path)}" alt="${escapeHtml(
|
|
59
|
+
entry.alt ?? "Screenshot",
|
|
60
|
+
)}" loading="lazy" />${mediaCaption(entry.alt)}</div>`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const renderVideo = (entry: DocEntry): string => {
|
|
64
|
+
const path = entry.path ?? "";
|
|
65
|
+
if (!isRemote(path) && isWindowsFsPath(path)) return broken("Video", path);
|
|
66
|
+
const poster = entry.poster ? ` poster="${escapeHtml(entry.poster)}"` : "";
|
|
67
|
+
return `<div class="doc-media"><video controls preload="metadata"${poster} src="${escapeHtml(
|
|
68
|
+
path,
|
|
69
|
+
)}">Your browser cannot play this video.</video>${mediaCaption(entry.caption)}</div>`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A labelled card wrapper used by most doc kinds. */
|
|
73
|
+
const labelled = (label: string, bodyHtml: string): string =>
|
|
74
|
+
`<div class="doc-entry"><div class="doc-entry__label">${escapeHtml(
|
|
75
|
+
label,
|
|
76
|
+
)}</div><div class="doc-entry__body">${bodyHtml}</div></div>`;
|
|
77
|
+
|
|
78
|
+
const renderVisualCustom = (data: Record<string, unknown>): string => {
|
|
79
|
+
const img = (src: unknown, label: string): string =>
|
|
80
|
+
typeof src === "string"
|
|
81
|
+
? `<div class="doc-visual-item"><div class="doc-visual-label">${escapeHtml(
|
|
82
|
+
label,
|
|
83
|
+
)}</div><img src="${escapeHtml(src)}" alt="${escapeHtml(label)}" /></div>`
|
|
84
|
+
: "";
|
|
85
|
+
return labelled(
|
|
86
|
+
`Visual check · ${escapeHtml(typeof data.status === "string" ? data.status : "unknown")}`,
|
|
87
|
+
`<div class="doc-visual-grid">${img(data.baseline, "Baseline")}${img(data.actual, "Actual")}${img(
|
|
88
|
+
data.diff,
|
|
89
|
+
"Diff",
|
|
90
|
+
)}</div>`,
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const RENDERERS: Record<string, (doc: DocEntry) => string> = {
|
|
95
|
+
// Tags already render as pills in the header — skip the noise.
|
|
96
|
+
tag: () => "",
|
|
97
|
+
note: (doc) => `<div class="doc-entry"><div class="doc-note">${escapeHtml(doc.text)}</div></div>`,
|
|
98
|
+
kv: (doc) => {
|
|
99
|
+
const value = typeof doc.value === "string" ? doc.value : JSON.stringify(doc.value, null, 2);
|
|
100
|
+
return `<div class="doc-entry"><div class="doc-kv"><span class="doc-kv__label">${escapeHtml(
|
|
101
|
+
doc.label,
|
|
102
|
+
)}</span><span class="doc-kv__value">${escapeHtml(value)}</span></div></div>`;
|
|
103
|
+
},
|
|
104
|
+
code: (doc) => {
|
|
105
|
+
const langClass = doc.lang ? ` class="language-${escapeHtml(doc.lang)}"` : "";
|
|
106
|
+
return labelled(
|
|
107
|
+
doc.label || doc.lang || "code",
|
|
108
|
+
`<pre><code${langClass}>${escapeHtml(doc.content)}</code></pre>`,
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
table: (doc) => {
|
|
112
|
+
const head = (doc.columns ?? []).map((c) => `<th>${escapeHtml(c)}</th>`).join("");
|
|
113
|
+
const body = (doc.rows ?? [])
|
|
114
|
+
.map((r) => `<tr>${r.map((c) => `<td>${escapeHtml(c)}</td>`).join("")}</tr>`)
|
|
115
|
+
.join("");
|
|
116
|
+
return labelled(
|
|
117
|
+
doc.label || "table",
|
|
118
|
+
`<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`,
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
link: (doc) =>
|
|
122
|
+
`<div class="doc-entry"><a class="doc-link-a" href="${escapeHtml(
|
|
123
|
+
doc.url,
|
|
124
|
+
)}" target="_blank" rel="noopener noreferrer">${escapeHtml(doc.label || doc.url)}</a></div>`,
|
|
125
|
+
section: (doc) =>
|
|
126
|
+
labelled(
|
|
127
|
+
doc.title || "section",
|
|
128
|
+
`<div class="doc-section-content" data-md="${escapeHtml(
|
|
129
|
+
encodeURIComponent(doc.markdown ?? ""),
|
|
130
|
+
)}"><pre>${escapeHtml(doc.markdown ?? "")}</pre></div>`,
|
|
131
|
+
),
|
|
132
|
+
mermaid: (doc) =>
|
|
133
|
+
labelled(doc.title || "diagram", `<pre class="mermaid">${escapeHtml(doc.code ?? "")}</pre>`),
|
|
134
|
+
screenshot: (doc) => `<div class="doc-entry"><div class="doc-entry__body">${renderScreenshot(doc)}</div></div>`,
|
|
135
|
+
video: (doc) => `<div class="doc-entry"><div class="doc-entry__body">${renderVideo(doc)}</div></div>`,
|
|
136
|
+
custom: (doc) =>
|
|
137
|
+
doc.type === "visual" && doc.data && typeof doc.data === "object"
|
|
138
|
+
? renderVisualCustom(doc.data as Record<string, unknown>)
|
|
139
|
+
: labelled(doc.type || "custom", `<pre><code>${escapeHtml(JSON.stringify(doc.data, null, 2))}</code></pre>`),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export function renderDocEntry(doc: DocEntry): string {
|
|
143
|
+
let html = RENDERERS[doc.kind]?.(doc) ?? "";
|
|
144
|
+
if (html && doc.children && doc.children.length > 0) {
|
|
145
|
+
const childHtml = doc.children.map(renderDocEntry).join("");
|
|
146
|
+
if (childHtml) html += `<div class="doc-children">${childHtml}</div>`;
|
|
147
|
+
}
|
|
148
|
+
return html;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function renderDocs(docs: DocEntry[] | undefined): string {
|
|
152
|
+
if (!docs?.length) return '<p class="meta">No docs attached.</p>';
|
|
153
|
+
return docs.map(renderDocEntry).join("") || '<p class="meta">No docs attached.</p>';
|
|
154
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health summary for the dashboard landing page.
|
|
3
|
+
*
|
|
4
|
+
* Confluence dashboards are the first thing a team sees and they are static.
|
|
5
|
+
* This one is computed from the latest test run, so the home page always
|
|
6
|
+
* reflects whether the documented system is actually green right now.
|
|
7
|
+
*
|
|
8
|
+
* Pure (no Astro imports) so it can be unit-tested.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { flattenReport, type StoryReportLike, type ScenarioLike } from "./verification";
|
|
12
|
+
|
|
13
|
+
export interface FailingScenario {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
feature?: string;
|
|
17
|
+
sourceFile?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HealthSummary {
|
|
21
|
+
total: number;
|
|
22
|
+
passed: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
skipped: number;
|
|
25
|
+
pending: number;
|
|
26
|
+
/** Passed / total, 0..1 (0 when there are no scenarios). */
|
|
27
|
+
passRate: number;
|
|
28
|
+
lastRunMs?: number;
|
|
29
|
+
runId?: string;
|
|
30
|
+
failing: FailingScenario[];
|
|
31
|
+
/** True when no run has populated the report yet. */
|
|
32
|
+
empty: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function summarizeHealth(report: StoryReportLike): HealthSummary {
|
|
36
|
+
const scenarios: ScenarioLike[] = flattenReport(report);
|
|
37
|
+
const count = (status: string) => scenarios.filter((s) => s.status === status).length;
|
|
38
|
+
|
|
39
|
+
const passed = count("passed");
|
|
40
|
+
const failed = count("failed");
|
|
41
|
+
const skipped = count("skipped");
|
|
42
|
+
const pending = count("pending");
|
|
43
|
+
const total = scenarios.length;
|
|
44
|
+
|
|
45
|
+
const failing: FailingScenario[] = scenarios
|
|
46
|
+
.filter((s) => s.status === "failed")
|
|
47
|
+
.map((s) => ({ id: s.id, title: s.title, feature: s.feature, sourceFile: s.sourceFile }));
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
total,
|
|
51
|
+
passed,
|
|
52
|
+
failed,
|
|
53
|
+
skipped,
|
|
54
|
+
pending,
|
|
55
|
+
passRate: total > 0 ? passed / total : 0,
|
|
56
|
+
lastRunMs: report.finishedAtMs && report.finishedAtMs > 0 ? report.finishedAtMs : undefined,
|
|
57
|
+
runId: report.runId && report.runId.length > 0 ? report.runId : undefined,
|
|
58
|
+
failing,
|
|
59
|
+
empty: total === 0,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verified-by resolver — the core of living documentation.
|
|
3
|
+
*
|
|
4
|
+
* A docs page declares which stories prove it is still true:
|
|
5
|
+
*
|
|
6
|
+
* ---
|
|
7
|
+
* title: ADR 0007 — Cap combined discount at 30%
|
|
8
|
+
* verifiedBy: [checkout--caps-the-discount-at-30-percent, pricing]
|
|
9
|
+
* ---
|
|
10
|
+
*
|
|
11
|
+
* At build time each reference is resolved against the latest test run
|
|
12
|
+
* (story-report.json) so the page renders a live status badge. If the
|
|
13
|
+
* referenced story is missing or failing, the page says so loudly — that is
|
|
14
|
+
* the mechanism that stops documentation from rotting.
|
|
15
|
+
*
|
|
16
|
+
* This module is intentionally free of any Astro/runtime imports so it can be
|
|
17
|
+
* unit-tested in isolation and reused anywhere.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ScenarioStatus = "passed" | "failed" | "skipped" | "pending";
|
|
21
|
+
|
|
22
|
+
/** Overall verification verdict for a set of references. */
|
|
23
|
+
export type VerificationStatus =
|
|
24
|
+
/** Every matched story passed in the last run. */
|
|
25
|
+
| "verified"
|
|
26
|
+
/** At least one matched story failed. */
|
|
27
|
+
| "failing"
|
|
28
|
+
/** Stories matched but some were skipped/pending — not actually exercised. */
|
|
29
|
+
| "not-run"
|
|
30
|
+
/** No story matched — the page references something that doesn't exist. */
|
|
31
|
+
| "unverified";
|
|
32
|
+
|
|
33
|
+
export interface ScenarioLike {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
status: ScenarioStatus;
|
|
37
|
+
tags?: string[];
|
|
38
|
+
tickets?: Array<{ id: string; url?: string }>;
|
|
39
|
+
/** Feature title (filled in when flattening a report). */
|
|
40
|
+
feature?: string;
|
|
41
|
+
sourceFile?: string;
|
|
42
|
+
sourceLine?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StoryReportLike {
|
|
46
|
+
runId?: string;
|
|
47
|
+
finishedAtMs?: number;
|
|
48
|
+
features?: Array<{
|
|
49
|
+
title?: string;
|
|
50
|
+
sourceFile?: string;
|
|
51
|
+
scenarios?: ScenarioLike[];
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Per-reference resolution: which scenarios a single `verifiedBy` entry matched. */
|
|
56
|
+
export interface RefResolution {
|
|
57
|
+
ref: string;
|
|
58
|
+
matched: ScenarioLike[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface VerificationResult {
|
|
62
|
+
status: VerificationStatus;
|
|
63
|
+
/** Run finish time, used as the "last verified" timestamp. */
|
|
64
|
+
lastVerifiedMs?: number;
|
|
65
|
+
runId?: string;
|
|
66
|
+
/** Number of distinct scenarios matched across all references. */
|
|
67
|
+
total: number;
|
|
68
|
+
passed: number;
|
|
69
|
+
failed: number;
|
|
70
|
+
/** skipped + pending. */
|
|
71
|
+
notRun: number;
|
|
72
|
+
refs: RefResolution[];
|
|
73
|
+
/** References that matched no scenario at all. */
|
|
74
|
+
missingRefs: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Flatten a story report into a single list of scenarios with feature context. */
|
|
78
|
+
export function flattenReport(report: StoryReportLike): ScenarioLike[] {
|
|
79
|
+
const features = report.features ?? [];
|
|
80
|
+
return features.flatMap((feature) =>
|
|
81
|
+
(feature.scenarios ?? []).map((scenario) => ({
|
|
82
|
+
...scenario,
|
|
83
|
+
feature: scenario.feature ?? feature.title,
|
|
84
|
+
sourceFile: scenario.sourceFile ?? feature.sourceFile,
|
|
85
|
+
})),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve a single scenario id against the latest report. */
|
|
90
|
+
export function findScenarioById(
|
|
91
|
+
report: StoryReportLike,
|
|
92
|
+
scenarioId: string,
|
|
93
|
+
): ScenarioLike | undefined {
|
|
94
|
+
return flattenReport(report).find((scenario) => scenario.id === scenarioId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Convenience check for human-authored pages that point at one scenario id. */
|
|
98
|
+
export function hasScenarioId(report: StoryReportLike, scenarioId: string): boolean {
|
|
99
|
+
return findScenarioById(report, scenarioId) !== undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Does a single reference identify this scenario? id, tag, ticket id, or exact title. */
|
|
103
|
+
function scenarioMatchesRef(scenario: ScenarioLike, ref: string): boolean {
|
|
104
|
+
if (scenario.id === ref) return true;
|
|
105
|
+
if (scenario.title === ref) return true;
|
|
106
|
+
if ((scenario.tags ?? []).includes(ref)) return true;
|
|
107
|
+
if ((scenario.tickets ?? []).some((ticket) => ticket.id === ref)) return true;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toRefArray(refs: string | string[]): string[] {
|
|
112
|
+
const list = Array.isArray(refs) ? refs : [refs];
|
|
113
|
+
// Trim, drop empties, de-duplicate while preserving order.
|
|
114
|
+
const seen = new Set<string>();
|
|
115
|
+
const out: string[] = [];
|
|
116
|
+
for (const raw of list) {
|
|
117
|
+
const ref = raw.trim();
|
|
118
|
+
if (ref && !seen.has(ref)) {
|
|
119
|
+
seen.add(ref);
|
|
120
|
+
out.push(ref);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve `verifiedBy` references against a story report.
|
|
128
|
+
*
|
|
129
|
+
* Status semantics (deliberately strict — a doc is only "verified" when the
|
|
130
|
+
* evidence actually passed):
|
|
131
|
+
* - any matched scenario failed → "failing"
|
|
132
|
+
* - every matched scenario passed → "verified"
|
|
133
|
+
* - matched but some skipped/pending → "not-run"
|
|
134
|
+
* - nothing matched → "unverified"
|
|
135
|
+
*/
|
|
136
|
+
export function resolveVerification(
|
|
137
|
+
refs: string | string[],
|
|
138
|
+
report: StoryReportLike,
|
|
139
|
+
): VerificationResult {
|
|
140
|
+
const refList = toRefArray(refs);
|
|
141
|
+
const scenarios = flattenReport(report);
|
|
142
|
+
|
|
143
|
+
const refResolutions: RefResolution[] = refList.map((ref) => ({
|
|
144
|
+
ref,
|
|
145
|
+
matched: scenarios.filter((scenario) => scenarioMatchesRef(scenario, ref)),
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
// De-duplicate matched scenarios across references (a tag + id can overlap).
|
|
149
|
+
const matchedById = new Map<string, ScenarioLike>();
|
|
150
|
+
for (const resolution of refResolutions) {
|
|
151
|
+
for (const scenario of resolution.matched) {
|
|
152
|
+
matchedById.set(scenario.id, scenario);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const matched = [...matchedById.values()];
|
|
156
|
+
|
|
157
|
+
const passed = matched.filter((s) => s.status === "passed").length;
|
|
158
|
+
const failed = matched.filter((s) => s.status === "failed").length;
|
|
159
|
+
const notRun = matched.filter(
|
|
160
|
+
(s) => s.status === "skipped" || s.status === "pending",
|
|
161
|
+
).length;
|
|
162
|
+
|
|
163
|
+
let status: VerificationStatus;
|
|
164
|
+
if (matched.length === 0) {
|
|
165
|
+
status = "unverified";
|
|
166
|
+
} else if (failed > 0) {
|
|
167
|
+
status = "failing";
|
|
168
|
+
} else if (notRun > 0) {
|
|
169
|
+
status = "not-run";
|
|
170
|
+
} else {
|
|
171
|
+
status = "verified";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
status,
|
|
176
|
+
lastVerifiedMs: report.finishedAtMs && report.finishedAtMs > 0 ? report.finishedAtMs : undefined,
|
|
177
|
+
runId: report.runId && report.runId.length > 0 ? report.runId : undefined,
|
|
178
|
+
total: matched.length,
|
|
179
|
+
passed,
|
|
180
|
+
failed,
|
|
181
|
+
notRun,
|
|
182
|
+
refs: refResolutions,
|
|
183
|
+
missingRefs: refResolutions.filter((r) => r.matched.length === 0).map((r) => r.ref),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface StatusPresentation {
|
|
188
|
+
label: string;
|
|
189
|
+
icon: string;
|
|
190
|
+
/** Short human sentence describing the verdict. */
|
|
191
|
+
summary: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whole days since the verification run completed, if known. */
|
|
195
|
+
export function verificationAgeDays(
|
|
196
|
+
result: VerificationResult,
|
|
197
|
+
nowMs = Date.now(),
|
|
198
|
+
): number | undefined {
|
|
199
|
+
if (!result.lastVerifiedMs) return undefined;
|
|
200
|
+
return Math.floor(Math.max(0, nowMs - result.lastVerifiedMs) / 86_400_000);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Simple freshness heuristic for portal UI warnings. */
|
|
204
|
+
export function isVerificationStale(
|
|
205
|
+
result: VerificationResult,
|
|
206
|
+
staleAfterDays: number,
|
|
207
|
+
nowMs = Date.now(),
|
|
208
|
+
): boolean {
|
|
209
|
+
const ageDays = verificationAgeDays(result, nowMs);
|
|
210
|
+
return ageDays !== undefined && ageDays >= staleAfterDays;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Presentation metadata for each status — kept here so badge + tests agree. */
|
|
214
|
+
export function presentStatus(result: VerificationResult): StatusPresentation {
|
|
215
|
+
switch (result.status) {
|
|
216
|
+
case "verified":
|
|
217
|
+
return {
|
|
218
|
+
label: "Verified",
|
|
219
|
+
icon: "✓",
|
|
220
|
+
summary:
|
|
221
|
+
result.total === 1
|
|
222
|
+
? "Verified by a passing story"
|
|
223
|
+
: `Verified by ${result.total} passing stories`,
|
|
224
|
+
};
|
|
225
|
+
case "failing":
|
|
226
|
+
return {
|
|
227
|
+
label: "Failing",
|
|
228
|
+
icon: "✕",
|
|
229
|
+
summary: `${result.failed} of ${result.total} verifying stories are failing`,
|
|
230
|
+
};
|
|
231
|
+
case "not-run":
|
|
232
|
+
return {
|
|
233
|
+
label: "Not run",
|
|
234
|
+
icon: "⏳",
|
|
235
|
+
summary: "Verifying stories exist but were not exercised in the last run",
|
|
236
|
+
};
|
|
237
|
+
case "unverified":
|
|
238
|
+
return {
|
|
239
|
+
label: "Unverified",
|
|
240
|
+
icon: "⚠",
|
|
241
|
+
summary:
|
|
242
|
+
result.missingRefs.length > 0
|
|
243
|
+
? `No story matches: ${result.missingRefs.join(", ")}`
|
|
244
|
+
: "No verifying story is linked",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|