scenescout 3.21.3 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,7 +49,7 @@ export function moduleOf(route) {
49
49
  * to the point, is stable — the same routes produce the same split every time,
50
50
  * so a re-run of a lane can be given the same brief.
51
51
  */
52
- export function splitRoutes(routes, laneCount) {
52
+ export function splitRoutes(routes, laneCount, order) {
53
53
  const lanes = Math.max(1, Math.min(Math.floor(laneCount) || 1, MAX_LANES));
54
54
  const byModule = new Map();
55
55
  for (const route of routes) {
@@ -63,9 +63,16 @@ export function splitRoutes(routes, laneCount) {
63
63
  // Biggest first, then by name, and each module's own routes sorted: nothing
64
64
  // about the split may depend on the order the routes were discovered in, or
65
65
  // a lane that has to be re-run cannot be handed the same brief.
66
- const modules = [...byModule.entries()]
66
+ const sorted = [...byModule.entries()]
67
67
  .map(([name, list]) => [name, [...list].sort((a, b) => a.localeCompare(b))])
68
68
  .sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
69
+ // Given an order (a run continuing an earlier one, from-run.ts), a route's
70
+ // place in it decides: the modules are dealt by their earliest route, and
71
+ // each lane takes its routes in that order, so every lane starts on what the
72
+ // order puts first. Routes it does not name go after, in the stable order.
73
+ const rank = order ? new Map(order.map((r, i) => [r, i])) : null;
74
+ const at = (r) => rank?.get(r) ?? Number.MAX_SAFE_INTEGER;
75
+ const modules = rank ? [...sorted].sort((a, b) => Math.min(...a[1].map(at)) - Math.min(...b[1].map(at))) : sorted;
69
76
  const out = Array.from({ length: lanes }, () => ({ modules: [], routes: [] }));
70
77
  for (const [name, list] of modules) {
71
78
  let smallest = 0;
@@ -75,6 +82,9 @@ export function splitRoutes(routes, laneCount) {
75
82
  out[smallest].modules.push(name);
76
83
  out[smallest].routes.push(...list);
77
84
  }
85
+ if (rank)
86
+ for (const lane of out)
87
+ lane.routes = [...lane.routes].sort((a, b) => at(a) - at(b));
78
88
  // A lane with nothing to do is a browser held open for no reason.
79
89
  return out.filter((lane) => lane.routes.length > 0);
80
90
  }
@@ -93,7 +103,7 @@ function signInArgument(opts) {
93
103
  }
94
104
  /** The lanes to run, each with the objective to attach with. */
95
105
  export function planLanes(routes, laneCount, opts = {}) {
96
- return splitRoutes(routes, laneCount).map((lane, i) => ({
106
+ return splitRoutes(routes, laneCount, opts.order).map((lane, i) => ({
97
107
  lane: laneName(lane.modules, i),
98
108
  objective: laneObjective(lane.modules, opts.goal),
99
109
  modules: lane.modules,
@@ -101,6 +111,23 @@ export function planLanes(routes, laneCount, opts = {}) {
101
111
  landing: landingOf(lane.routes),
102
112
  }));
103
113
  }
114
+ /**
115
+ * The lanes of a replay (from-run.ts replayPlan): one per session of the
116
+ * recorded run, with its routes in the order it took them, not a new split.
117
+ * Named as a split's lanes are, by what they own, and made unique.
118
+ */
119
+ export function replayBriefs(lanes, goal) {
120
+ const taken = new Set();
121
+ return lanes.map((l, i) => {
122
+ const modules = [...new Set(l.routes.map(moduleOf))];
123
+ const base = laneName(modules, i);
124
+ let lane = base;
125
+ for (let k = 2; taken.has(lane); k += 1)
126
+ lane = `${base.slice(0, LANE_NAME_MAX - `-${k}`.length)}-${k}`;
127
+ taken.add(lane);
128
+ return { lane, objective: laneObjective(modules, goal), modules, routes: [...l.routes], landing: landingOf(l.routes) };
129
+ });
130
+ }
104
131
  /**
105
132
  * The first of a lane's routes a browser can open as it stands. A route the
106
133
  * engine normalised (`/orders/:id`) is a pattern, not an address; a lane with
@@ -143,6 +170,9 @@ export function formatBriefs(briefs, opts = {}) {
143
170
  const mode = opts.mode ?? "read-only";
144
171
  const lines = [
145
172
  `LANE PLAN — ${briefs.length} lane(s) over ${briefs.reduce((n, b) => n + b.routes.length, 0)} route(s).`,
173
+ ...(opts.fromRunNote
174
+ ? [`This run ${opts.fromRunNote}. Each lane's routes are listed in the order to take them, and what to do first is under each lane: pass it on.`]
175
+ : []),
146
176
  ``,
147
177
  `Give each lane its own agent. Every lane attaches with its own session name, so the browsers run genuinely in parallel, and lands on its own first route rather than the home page:`,
148
178
  ` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${signInArgument(opts)}, objective: "<objective>" }`,
@@ -158,6 +188,8 @@ export function formatBriefs(briefs, opts = {}) {
158
188
  lines.push(`owns: ${b.modules.join(", ")} (${b.routes.length} route(s))`);
159
189
  lines.push(`landing: ${b.landing}`);
160
190
  lines.push(`routes: ${b.routes.slice(0, 20).join(", ")}${b.routes.length > 20 ? ` … and ${b.routes.length - 20} more` : ""}`);
191
+ for (const l of opts.laneLines?.(b) ?? [])
192
+ lines.push(l);
161
193
  lines.push(``);
162
194
  }
163
195
  return lines.join("\n");
@@ -33,6 +33,12 @@ function shotFields(shot) {
33
33
  return { frame: shot.frame };
34
34
  return shot?.pastCap ? { pastCap: true } : {};
35
35
  }
36
+ /** What a step should bring about: its own `expected`, else an expect-* step's assertion, else nothing. */
37
+ export function expectedOf(step) {
38
+ if (step.expected !== undefined)
39
+ return step.expected;
40
+ return step.action.startsWith("expect-") ? describeStep(step) : undefined;
41
+ }
36
42
  /**
37
43
  * Each step of a replayed flow with its caption, its result and its frame.
38
44
  * The replay stops at the first step that breaks, so every step before it
@@ -44,11 +50,12 @@ export function journeySteps(steps, outcome, frames = []) {
44
50
  return steps.map((step, i) => {
45
51
  const n = i + 1;
46
52
  const shot = broke === null || n <= broke ? shotFields(frames[i]) : {};
47
- const base = { n, caption: describeStep(step), ...shot };
53
+ const expected = expectedOf(step);
54
+ const base = { n, caption: describeStep(step), ...(expected !== undefined ? { expected } : {}), ...shot };
48
55
  if (broke === null || n < broke)
49
56
  return { ...base, result: "passed" };
50
57
  if (n > broke)
51
- return { n, caption: base.caption, result: "not-run" };
58
+ return { n, caption: base.caption, ...(expected !== undefined ? { expected } : {}), result: "not-run" };
52
59
  // `broke` is set only when the outcome is not "passed".
53
60
  const failed = outcome;
54
61
  return { ...base, result: failed.status, reason: failed.reason, path: failed.path };
@@ -59,6 +66,8 @@ export function journeyOf(flow, outcome, frames) {
59
66
  return {
60
67
  name: flow.name,
61
68
  file: flow.file,
69
+ ...(flow.id !== undefined ? { id: flow.id } : {}),
70
+ ...(flow.requirements !== undefined ? { requirements: [...flow.requirements] } : {}),
62
71
  status: outcome.status,
63
72
  steps: journeySteps(flow.steps, outcome, frames),
64
73
  ...(outcome.status === "passed" ? {} : { firstFailing: outcome.step }),
@@ -119,6 +128,7 @@ export function redactReplay(replay) {
119
128
  steps: j.steps.map((s) => ({
120
129
  ...s,
121
130
  caption: redactRoute(s.caption),
131
+ ...(s.expected !== undefined ? { expected: redactRoute(s.expected) } : {}),
122
132
  ...(s.reason !== undefined ? { reason: redactRoute(s.reason) } : {}),
123
133
  ...(s.path !== undefined ? { path: redactRoute(s.path) } : {}),
124
134
  })),
@@ -0,0 +1,503 @@
1
+ /**
2
+ * The template-driven test report of a recorded `scenescout check`
3
+ * (`--record --template <file.json>`): the same run the replay page shows, laid
4
+ * out as a document a reviewer signs off on paper or in their own system.
5
+ *
6
+ * The template decides the layout only: the title block, the document ID, the
7
+ * order of the sections, the columns of the test table, the sign-off roles,
8
+ * free text and every word the page uses. The data is the run's: each saved
9
+ * flow is a test, each of its steps a row, and a step's result comes from the
10
+ * replay's assertions alone, never from a model. A step that failed or was
11
+ * refused is a deviation. The manifest lists the SHA-256 of every evidence
12
+ * file, so a reader can tell the frames and videos beside the report are the
13
+ * ones the run wrote.
14
+ *
15
+ * Everything here but readReportTemplate and evidenceIndex is pure: the same
16
+ * result, template and evidence render the same bytes (a golden file in
17
+ * scripts/report-test.ts holds that). check-run.ts reads the files and writes the page.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { z } from "zod";
23
+ import { REPLAY_FILE, replayFrames, replayVideos } from "./check-replay.js";
24
+ import { summarise } from "./check.js";
25
+ import { parseJsonFile } from "./flow.js";
26
+ import { escapeHtml } from "./replay.js";
27
+ /** The page's generator mark: an earlier run's report is removed or replaced only when it carries it. */
28
+ export const REPORT_GENERATOR = "scenescout-check-report";
29
+ /** The file the report is written to when the template names none. */
30
+ export const DEFAULT_REPORT_FILE = "report.html";
31
+ /** What the cell of a missing value shows. */
32
+ export const MISSING = "—";
33
+ /** A report's file name: plain, in the output folder itself, ending .html. */
34
+ export const REPORT_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.html$/;
35
+ /** A name Windows reserves for a device whatever its extension (CON.html opens the console): never a report's file. */
36
+ export const WINDOWS_DEVICE_RE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])\./i;
37
+ /** Whether a name may be a report's file: plain, ending .html, not replay.html and not a Windows device name. */
38
+ export function isReportFileName(name) {
39
+ return REPORT_FILE_RE.test(name) && !WINDOWS_DEVICE_RE.test(name) && name.toLowerCase() !== REPLAY_FILE;
40
+ }
41
+ /**
42
+ * The report file an earlier check.json records it wrote (its `testReport`),
43
+ * or null: anything that is not a plain report file name is ignored, so the
44
+ * value can never point outside the output folder or at the replay page.
45
+ */
46
+ export function recordedReportFile(checkJson) {
47
+ if (typeof checkJson !== "object" || checkJson === null)
48
+ return null;
49
+ const file = checkJson.testReport;
50
+ return typeof file === "string" && isReportFileName(file) ? file : null;
51
+ }
52
+ /** Whether an HTML file is a report a check wrote. */
53
+ export function isGeneratedReport(html) {
54
+ return html.includes(`<meta name="generator" content="${REPORT_GENERATOR}">`);
55
+ }
56
+ // ── the template ────────────────────────────────────────────────────────────
57
+ /** The columns a test table may have, in any order. */
58
+ export const REPORT_COLUMNS = ["testId", "requirements", "step", "expected", "actual", "result", "evidence"];
59
+ /** The sections a template may order. Each data section appears at most once; `text` as often as wanted. */
60
+ export const REPORT_SECTIONS = ["summary", "tests", "deviations", "manifest", "signoff", "text"];
61
+ /** The tokens the title block may use, each replaced by a fact of the run. */
62
+ export const REPORT_TOKENS = {
63
+ date: "the day the run started, YYYY-MM-DD (UTC)",
64
+ time: "the time the run started, HH:MM:SS (UTC)",
65
+ run: "the run's start as YYYYMMDD-HHMMSS (UTC)",
66
+ version: "the SceneScout version",
67
+ commit: "the commit the run was of (GITHUB_SHA), shortened to 12 characters, or — when none",
68
+ };
69
+ /** Every word the page writes that is not data. A template may replace any of them, so it carries its own vocabulary. */
70
+ export const DEFAULT_LABELS = {
71
+ pass: "Pass",
72
+ fail: "Fail",
73
+ refused: "Could not run",
74
+ notRun: "Not run",
75
+ asExpected: "As expected",
76
+ summary: "Summary",
77
+ tests: "Test results",
78
+ deviations: "Deviations",
79
+ manifest: "Evidence manifest",
80
+ signoff: "Sign-off",
81
+ testId: "Test ID",
82
+ requirements: "Requirements",
83
+ step: "Step",
84
+ expected: "Expected result",
85
+ actual: "Actual result",
86
+ result: "Result",
87
+ evidence: "Evidence",
88
+ frame: "Frame after the step",
89
+ video: "Video",
90
+ test: "Test",
91
+ number: "#",
92
+ noDeviations: "No deviations.",
93
+ noTests: "No saved flow ran.",
94
+ overall: "Overall result",
95
+ target: "Target",
96
+ started: "Started",
97
+ ended: "Ended",
98
+ tool: "SceneScout version",
99
+ commit: "Commit",
100
+ testsRun: "Tests",
101
+ testsPassed: "Passed",
102
+ testsFailed: "Failed",
103
+ testsNotRun: "Could not run",
104
+ gateIssues: "Issues failing the check's gate",
105
+ file: "File",
106
+ bytes: "Bytes",
107
+ sha256: "SHA-256",
108
+ notFound: "not found",
109
+ name: "Name",
110
+ role: "Role",
111
+ signature: "Signature",
112
+ signedDate: "Date",
113
+ meaning: "Meaning of signature",
114
+ documentId: "Document ID",
115
+ };
116
+ const text = (what, max) => z.string().trim().min(1, `is ${what}`).max(max, `is at most ${max} characters`);
117
+ const TOKEN_RE = /\{([^{}]*)\}/g;
118
+ /** Text that may use the title block's tokens: every `{…}` must name one. */
119
+ const tokenText = (what, max) => text(what, max).superRefine((value, ctx) => {
120
+ for (const m of value.matchAll(TOKEN_RE)) {
121
+ if (!Object.hasOwn(REPORT_TOKENS, m[1]))
122
+ ctx.addIssue({
123
+ code: z.ZodIssueCode.custom,
124
+ message: `uses {${m[1]}}, which is not a token: use ${Object.keys(REPORT_TOKENS)
125
+ .map((t) => `{${t}}`)
126
+ .join(", ")}`,
127
+ });
128
+ }
129
+ });
130
+ const paragraphs = z.array(text("a paragraph of text", 4000)).max(50, "holds at most 50 paragraphs");
131
+ const heading = text("the section's heading", 200);
132
+ const columnSchema = z.union([z.enum(REPORT_COLUMNS), z.object({ key: z.enum(REPORT_COLUMNS), heading }).strict()], {
133
+ errorMap: () => ({ message: `must be one of ${REPORT_COLUMNS.join(", ")}, or { "key": one of those, "heading": "…" }` }),
134
+ });
135
+ const signoffRole = z.union([text("the role that signs", 200), z.object({ role: text("the role that signs", 200), meaning: text("what the signature means", 500).optional() }).strict()], {
136
+ errorMap: () => ({ message: 'must be the role that signs, e.g. "Reviewer", or { "role": "…", "meaning": "…" }' }),
137
+ });
138
+ const sectionSchema = z.discriminatedUnion("type", [
139
+ z.object({ type: z.literal("summary"), heading: heading.optional(), paragraphs: paragraphs.optional() }).strict(),
140
+ z
141
+ .object({
142
+ type: z.literal("tests"),
143
+ heading: heading.optional(),
144
+ paragraphs: paragraphs.optional(),
145
+ columns: z
146
+ .array(columnSchema)
147
+ .min(1, "needs at least one column")
148
+ .refine((cols) => new Set(cols.map((c) => (typeof c === "string" ? c : c.key))).size === cols.length, { message: "names a column twice" }),
149
+ })
150
+ .strict(),
151
+ z.object({ type: z.literal("deviations"), heading: heading.optional(), paragraphs: paragraphs.optional() }).strict(),
152
+ z.object({ type: z.literal("manifest"), heading: heading.optional(), paragraphs: paragraphs.optional() }).strict(),
153
+ z
154
+ .object({
155
+ type: z.literal("signoff"),
156
+ heading: heading.optional(),
157
+ paragraphs: paragraphs.optional(),
158
+ roles: z.array(signoffRole).min(1, "needs at least one role").max(20, "holds at most 20 roles"),
159
+ })
160
+ .strict(),
161
+ z.object({ type: z.literal("text"), heading: heading.optional(), paragraphs: paragraphs.min(1, "needs at least one paragraph") }).strict(),
162
+ ]);
163
+ const labelsSchema = z
164
+ .object(Object.fromEntries(Object.keys(DEFAULT_LABELS).map((k) => [k, text("the word to use", 200).optional()])))
165
+ .strict();
166
+ const templateSchema = z
167
+ .object({
168
+ /** The file name the report is written to, beside replay.html. */
169
+ file: z
170
+ .string()
171
+ .regex(REPORT_FILE_RE, "must be a plain file name ending .html, e.g. test-report.html")
172
+ .refine((f) => f.toLowerCase() !== REPLAY_FILE, { message: `must not be ${REPLAY_FILE}, which the replay page is written to` })
173
+ .refine((f) => !WINDOWS_DEVICE_RE.test(f), { message: "must not be a name Windows reserves for a device, such as CON.html or NUL.html" })
174
+ .optional(),
175
+ title: tokenText("the document's title", 300),
176
+ subtitle: tokenText("a line under the title", 300).optional(),
177
+ documentId: tokenText("the document ID pattern, e.g. TR-{run}", 200).optional(),
178
+ fields: z
179
+ .array(z.object({ label: text("the field's label", 200), value: tokenText("the field's value", 500) }).strict())
180
+ .max(20, "holds at most 20 fields")
181
+ .optional(),
182
+ labels: labelsSchema.optional(),
183
+ sections: z
184
+ .array(sectionSchema)
185
+ .min(1, "needs at least one section")
186
+ .max(50, "holds at most 50 sections")
187
+ .superRefine((sections, ctx) => {
188
+ const seen = new Set();
189
+ sections.forEach((s, i) => {
190
+ if (s.type !== "text" && seen.has(s.type))
191
+ ctx.addIssue({
192
+ code: z.ZodIssueCode.custom,
193
+ path: [i, "type"],
194
+ message: `"${s.type}" appears more than once: each section but text appears at most once`,
195
+ });
196
+ seen.add(s.type);
197
+ });
198
+ }),
199
+ })
200
+ .strict();
201
+ /** Validate a template's text. Every mistake names the file and the field, as a flow's do. */
202
+ export function parseReportTemplate(raw, file) {
203
+ const parsed = parseJsonFile(raw, file, templateSchema);
204
+ if (!parsed.ok) {
205
+ // A section's discriminator is "type", not a flow's "action": say so in its own words.
206
+ return { ok: false, error: parsed.error.replace(/must be one of navigate, [^;]*/g, `must be one of ${REPORT_SECTIONS.join(", ")}`) };
207
+ }
208
+ return { ok: true, template: parsed.data };
209
+ }
210
+ /** Read and validate a template file, or throw a sentence naming it. */
211
+ export function readReportTemplate(file) {
212
+ let raw;
213
+ try {
214
+ raw = fs.readFileSync(file, "utf8");
215
+ }
216
+ catch (err) {
217
+ throw new Error(`--template: cannot read ${file} (${err instanceof Error ? err.message : String(err)})`);
218
+ }
219
+ const parsed = parseReportTemplate(raw, file);
220
+ if (!parsed.ok)
221
+ throw new Error(`--template: ${parsed.error}`);
222
+ return parsed.template;
223
+ }
224
+ /** The file the template's report is written to. */
225
+ export function reportFileOf(template) {
226
+ return template.file ?? DEFAULT_REPORT_FILE;
227
+ }
228
+ /** Why --template cannot be used with these options, or null: the report renders a recorded run, so recording must be on. */
229
+ export function templateRecordError(options) {
230
+ if (options.template === undefined || options.record === true)
231
+ return null;
232
+ return "--template renders the report from a recorded check's frames: add --record (or set SCENESCOUT_RECORD=on)";
233
+ }
234
+ /** The files a recorded check's report vouches for, in this order: check.json, the replay page, every frame and every video. */
235
+ export function evidenceFiles(result) {
236
+ if (!result.replay)
237
+ return ["check.json"];
238
+ return ["check.json", REPLAY_FILE, ...replayFrames(result.replay), ...replayVideos(result.replay)];
239
+ }
240
+ /** Hash each file beside the report. A file that is not there is listed as missing rather than left out. */
241
+ export function evidenceIndex(outDir, files) {
242
+ return files.map((rel) => {
243
+ try {
244
+ const data = fs.readFileSync(path.join(outDir, ...rel.split("/")));
245
+ return { path: rel, bytes: data.length, sha256: createHash("sha256").update(data).digest("hex") };
246
+ }
247
+ catch (err) {
248
+ if (err.code === "ENOENT")
249
+ return { path: rel, bytes: null, sha256: null };
250
+ throw err;
251
+ }
252
+ });
253
+ }
254
+ /** Every test in the order the replay shows them: the check's own session first, then each role. */
255
+ function testsOf(result) {
256
+ return (result.replay?.roles ?? []).flatMap((r) => r.journeys.map((journey) => ({ journey, role: r.own ? null : r.role })));
257
+ }
258
+ function tokensOf(result, meta) {
259
+ const started = result.replay?.startedAt ?? result.generatedAt;
260
+ const date = started.slice(0, 10);
261
+ const time = started.slice(11, 19);
262
+ return {
263
+ date,
264
+ time,
265
+ run: `${date.replace(/-/g, "")}-${time.replace(/:/g, "")}`,
266
+ version: meta.version,
267
+ commit: meta.commit ? meta.commit.slice(0, 12) : MISSING,
268
+ };
269
+ }
270
+ function fill(value, tokens) {
271
+ return value.replace(TOKEN_RE, (whole, name) => (Object.hasOwn(tokens, name) ? tokens[name] : whole));
272
+ }
273
+ /** A UTC time as the page shows it. */
274
+ function stamp(iso) {
275
+ return iso.length >= 19 ? `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC` : iso;
276
+ }
277
+ /** Escaped text, or the missing mark. */
278
+ function cell(value) {
279
+ return value === undefined || value === null || value === "" ? MISSING : escapeHtml(value);
280
+ }
281
+ function resultWord(result, L) {
282
+ return result === "passed" ? L.pass : result === "failed" ? L.fail : result === "refused" ? L.refused : L.notRun;
283
+ }
284
+ /** What a step did, in words: as expected when it passed, why when it did not, nothing when it never ran. */
285
+ function actualOf(step, L) {
286
+ if (step.result === "passed")
287
+ return L.asExpected;
288
+ if (step.result === "not-run")
289
+ return undefined;
290
+ return [step.reason, step.path ? `on ${step.path}` : ""].filter(Boolean).join(" ") || undefined;
291
+ }
292
+ function paragraphsHtml(list) {
293
+ return (list ?? []).map((p) => `<p>${escapeHtml(p)}</p>`).join("");
294
+ }
295
+ function sectionHead(section, fallback) {
296
+ return `<h2>${escapeHtml(section.heading ?? fallback)}</h2>${paragraphsHtml(section.paragraphs)}`;
297
+ }
298
+ function frameCellHtml(step, L) {
299
+ if (!step.frame)
300
+ return MISSING;
301
+ const src = escapeHtml(step.frame);
302
+ return `<a class="frame" href="${src}" target="_blank" rel="noreferrer" data-testid="report-frame-open"><img loading="lazy" src="${src}" alt="${escapeHtml(`${L.frame}: ${step.caption}`)}"><span>${escapeHtml(step.frame)}</span></a>`;
303
+ }
304
+ function summaryHtml(section, result, meta, L) {
305
+ const tests = testsOf(result);
306
+ const { passed, couldNotRun, failing } = summarise(result);
307
+ const count = (status) => tests.filter((t) => t.journey.status === status).length;
308
+ // A test that failed fails the report even when the gate let it through (--fail-on never): a pass here is a pass of every test.
309
+ const green = couldNotRun === 0 && passed && count("failed") === 0;
310
+ const overall = couldNotRun > 0 ? L.refused : green ? L.pass : L.fail;
311
+ const row = (term, value, cls = "") => `<tr${cls ? ` class="${cls}"` : ""}><th scope="row">${escapeHtml(term)}</th><td>${value}</td></tr>`;
312
+ const overallClass = green ? "pass" : "fail";
313
+ return (`<section class="summary" data-section="summary">${sectionHead(section, L.summary)}<table class="facts">` +
314
+ row(L.overall, `<span class="verdict ${overallClass}" data-testid="report-verdict">${escapeHtml(overall)}</span>`) +
315
+ row(L.target, cell(result.url)) +
316
+ row(L.started, cell(stamp(result.replay?.startedAt ?? result.generatedAt))) +
317
+ row(L.ended, cell(stamp(result.generatedAt))) +
318
+ row(L.tool, cell(meta.version)) +
319
+ row(L.commit, cell(meta.commit)) +
320
+ row(L.testsRun, String(tests.length)) +
321
+ row(L.testsPassed, String(count("passed"))) +
322
+ row(L.testsFailed, String(count("failed"))) +
323
+ row(L.testsNotRun, String(count("refused"))) +
324
+ row(L.deviations, String(deviationsOf(result).length)) +
325
+ row(L.gateIssues, String(failing)) +
326
+ `</table></section>`);
327
+ }
328
+ function columnsOf(section, L) {
329
+ return section.columns.map((c) => (typeof c === "string" ? { key: c, heading: L[c] } : { key: c.key, heading: c.heading }));
330
+ }
331
+ function testsHtml(section, result, L) {
332
+ const tests = testsOf(result);
333
+ const columns = columnsOf(section, L);
334
+ const head = `<thead><tr>${columns.map((c) => `<th scope="col" data-column="${c.key}">${escapeHtml(c.heading)}</th>`).join("")}</tr></thead>`;
335
+ const bodies = tests.map(({ journey: j, role }, ti) => {
336
+ const verdictClass = j.status === "passed" ? "pass" : "fail";
337
+ const video = j.video
338
+ ? ` <a class="video" href="${escapeHtml(j.video)}" target="_blank" rel="noreferrer" data-testid="report-video-open">${escapeHtml(L.video)}: ${escapeHtml(j.video)}</a>`
339
+ : "";
340
+ const caption = `<tr class="test-head ${verdictClass}"><th colspan="${columns.length}" scope="rowgroup">` +
341
+ `<span class="verdict ${verdictClass}">${escapeHtml(resultWord(j.status, L))}</span> ` +
342
+ `<b>${cell(j.id)}</b> ${escapeHtml(j.name)} <span class="file">${escapeHtml(j.file)}${role === null ? "" : ` · ${escapeHtml(L.role)}: ${escapeHtml(role)}`}</span>${video}</th></tr>`;
343
+ const rows = j.steps.map((s, si) => {
344
+ const cells = columns.map(({ key }) => {
345
+ switch (key) {
346
+ case "testId":
347
+ case "requirements":
348
+ // One cell for the whole test, spanning its steps.
349
+ if (si > 0)
350
+ return "";
351
+ return `<td rowspan="${j.steps.length}" class="span">${key === "testId" ? cell(j.id) : j.requirements && j.requirements.length > 0 ? j.requirements.map(escapeHtml).join(", ") : MISSING}</td>`;
352
+ case "step":
353
+ return `<td class="step"><span class="n">${s.n}.</span> ${escapeHtml(s.caption)}</td>`;
354
+ case "expected":
355
+ return `<td>${cell(s.expected)}</td>`;
356
+ case "actual":
357
+ return `<td>${cell(actualOf(s, L))}</td>`;
358
+ case "result":
359
+ return `<td class="result r-${s.result}" data-result="${s.result}">${escapeHtml(resultWord(s.result, L))}</td>`;
360
+ case "evidence":
361
+ return `<td class="evidence">${frameCellHtml(s, L)}</td>`;
362
+ }
363
+ });
364
+ return `<tr class="${s.result}">${cells.join("")}</tr>`;
365
+ });
366
+ return `<tbody id="test-${ti + 1}" data-status="${j.status}">${caption}${rows.join("")}</tbody>`;
367
+ });
368
+ return (`<section class="tests" data-section="tests">${sectionHead(section, L.tests)}` +
369
+ (tests.length === 0 ? `<p class="none">${escapeHtml(L.noTests)}</p>` : `<table class="grid">${head}${bodies.join("")}</table>`) +
370
+ `</section>`);
371
+ }
372
+ /** Every step that failed or was refused, in the order of the tests. */
373
+ export function deviationsOf(result, labels = {}) {
374
+ const L = { ...DEFAULT_LABELS, ...labels };
375
+ return testsOf(result).flatMap(({ journey: j }) => j.steps.flatMap((s) => s.result === "failed" || s.result === "refused"
376
+ ? [
377
+ {
378
+ test: j.name,
379
+ ...(j.id !== undefined ? { testId: j.id } : {}),
380
+ step: s.n,
381
+ caption: s.caption,
382
+ ...(s.expected !== undefined ? { expected: s.expected } : {}),
383
+ ...(actualOf(s, L) !== undefined ? { actual: actualOf(s, L) } : {}),
384
+ result: s.result,
385
+ },
386
+ ]
387
+ : []));
388
+ }
389
+ function deviationsHtml(section, result, L) {
390
+ const list = deviationsOf(result, L);
391
+ const body = list.length === 0
392
+ ? `<p class="none" data-testid="report-no-deviations">${escapeHtml(L.noDeviations)}</p>`
393
+ : `<table class="grid"><thead><tr><th scope="col">${escapeHtml(L.number)}</th><th scope="col">${escapeHtml(L.testId)}</th><th scope="col">${escapeHtml(L.test)}</th><th scope="col">${escapeHtml(L.step)}</th><th scope="col">${escapeHtml(L.expected)}</th><th scope="col">${escapeHtml(L.actual)}</th><th scope="col">${escapeHtml(L.result)}</th></tr></thead><tbody>` +
394
+ list
395
+ .map((d, i) => `<tr class="deviation" data-testid="report-deviation"><td>${i + 1}</td><td>${cell(d.testId)}</td><td>${escapeHtml(d.test)}</td><td><span class="n">${d.step}.</span> ${escapeHtml(d.caption)}</td><td>${cell(d.expected)}</td><td>${cell(d.actual)}</td><td>${escapeHtml(resultWord(d.result, L))}</td></tr>`)
396
+ .join("") +
397
+ `</tbody></table>`;
398
+ return `<section class="deviations" data-section="deviations">${sectionHead(section, L.deviations)}${body}</section>`;
399
+ }
400
+ function manifestHtml(section, result, meta, evidence, L) {
401
+ const facts = `<table class="facts">` +
402
+ `<tr><th scope="row">${escapeHtml(L.tool)}</th><td>${cell(meta.version)}</td></tr>` +
403
+ `<tr><th scope="row">${escapeHtml(L.target)}</th><td>${cell(result.url)}</td></tr>` +
404
+ `<tr><th scope="row">${escapeHtml(L.started)}</th><td>${cell(stamp(result.replay?.startedAt ?? result.generatedAt))}</td></tr>` +
405
+ `<tr><th scope="row">${escapeHtml(L.ended)}</th><td>${cell(stamp(result.generatedAt))}</td></tr>` +
406
+ `</table>`;
407
+ const rows = evidence
408
+ .map((e) => `<tr data-testid="report-manifest-entry"><td class="path">${escapeHtml(e.path)}</td><td>${e.bytes === null ? escapeHtml(L.notFound) : e.bytes}</td><td class="hash">${e.sha256 === null ? escapeHtml(L.notFound) : escapeHtml(e.sha256)}</td></tr>`)
409
+ .join("");
410
+ return (`<section class="manifest" data-section="manifest">${sectionHead(section, L.manifest)}${facts}` +
411
+ `<table class="grid"><thead><tr><th scope="col">${escapeHtml(L.file)}</th><th scope="col">${escapeHtml(L.bytes)}</th><th scope="col">${escapeHtml(L.sha256)}</th></tr></thead><tbody>${rows}</tbody></table></section>`);
412
+ }
413
+ function signoffHtml(section, L) {
414
+ const rows = section.roles
415
+ .map((r) => {
416
+ const role = typeof r === "string" ? r : r.role;
417
+ const meaning = typeof r === "string" ? undefined : r.meaning;
418
+ return `<tr class="signature"><td>${escapeHtml(role)}</td><td class="blank"></td><td class="blank"></td><td class="blank"></td><td>${meaning === undefined ? "" : escapeHtml(meaning)}</td></tr>`;
419
+ })
420
+ .join("");
421
+ return (`<section class="signoff" data-section="signoff">${sectionHead(section, L.signoff)}` +
422
+ `<table class="grid sign"><thead><tr><th scope="col">${escapeHtml(L.role)}</th><th scope="col">${escapeHtml(L.name)}</th><th scope="col">${escapeHtml(L.signature)}</th><th scope="col">${escapeHtml(L.signedDate)}</th><th scope="col">${escapeHtml(L.meaning)}</th></tr></thead><tbody>${rows}</tbody></table></section>`);
423
+ }
424
+ const STYLE = `
425
+ :root { color-scheme: light; --line:#c9ced6; --text:#15181d; --muted:#5d6673; --pass:#047857; --pass-bg:#d1fae5; --fail:#b91c1c; --fail-bg:#fee2e2; --head:#eef0f3; }
426
+ * { box-sizing:border-box; }
427
+ body { margin:0; background:#fff; color:var(--text); font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; }
428
+ main { max-width:1100px; margin:0 auto; padding:24px 16px 64px; }
429
+ header.title { border-bottom:2px solid var(--text); padding-bottom:12px; margin-bottom:8px; }
430
+ header.title h1 { font-size:22px; margin:0 0 4px; }
431
+ header.title .subtitle { margin:0 0 8px; color:var(--muted); }
432
+ h2 { font-size:17px; margin:28px 0 8px; border-bottom:1px solid var(--line); padding-bottom:4px; }
433
+ table { border-collapse:collapse; width:100%; margin:8px 0; }
434
+ th, td { border:1px solid var(--line); padding:5px 8px; text-align:left; vertical-align:top; overflow-wrap:anywhere; }
435
+ thead th, table.facts th { background:var(--head); }
436
+ table.facts { width:auto; min-width:50%; }
437
+ tr.test-head th { background:var(--head); font-weight:400; }
438
+ .verdict { display:inline-block; padding:0 8px; border-radius:4px; font-weight:700; }
439
+ .verdict.pass { color:var(--pass); background:var(--pass-bg); } .verdict.fail { color:var(--fail); background:var(--fail-bg); }
440
+ .file { color:var(--muted); font-size:12px; }
441
+ .n { color:var(--muted); font-weight:700; }
442
+ td.step { font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
443
+ td.r-passed { color:var(--pass); font-weight:700; } td.r-failed, td.r-refused { color:var(--fail); font-weight:700; } td.r-not-run { color:var(--muted); }
444
+ tr.failed td, tr.refused td { background:var(--fail-bg); }
445
+ a.frame { display:block; color:var(--muted); font-size:11px; }
446
+ a.frame img { display:block; max-width:220px; max-height:150px; object-fit:cover; object-position:top; border:1px solid var(--line); }
447
+ td.hash, td.path { font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
448
+ table.sign td.blank { height:2.6em; min-width:8em; }
449
+ .none { color:var(--muted); font-style:italic; }
450
+ @media print { main { max-width:none; padding:0; } a { color:inherit; text-decoration:none; } tbody, tr { break-inside:avoid; } }
451
+ `;
452
+ /**
453
+ * The whole report: one HTML file with no scripts, no event handlers and no
454
+ * external assets. The frames and videos it links sit beside it, as they do
455
+ * for the replay page. Every word from the template and every value from the
456
+ * app is escaped.
457
+ */
458
+ export function buildTemplateReport(result, template, evidence, meta) {
459
+ const L = { ...DEFAULT_LABELS, ...(template.labels ?? {}) };
460
+ const tokens = tokensOf(result, meta);
461
+ const title = fill(template.title, tokens);
462
+ const fields = [
463
+ ...(template.documentId !== undefined ? [{ label: L.documentId, value: fill(template.documentId, tokens) }] : []),
464
+ ...(template.fields ?? []).map((f) => ({ label: f.label, value: fill(f.value, tokens) })),
465
+ ];
466
+ const sections = template.sections.map((s) => {
467
+ switch (s.type) {
468
+ case "summary":
469
+ return summaryHtml(s, result, meta, L);
470
+ case "tests":
471
+ return testsHtml(s, result, L);
472
+ case "deviations":
473
+ return deviationsHtml(s, result, L);
474
+ case "manifest":
475
+ return manifestHtml(s, result, meta, evidence, L);
476
+ case "signoff":
477
+ return signoffHtml(s, L);
478
+ case "text":
479
+ return `<section class="text" data-section="text">${s.heading !== undefined ? `<h2>${escapeHtml(s.heading)}</h2>` : ""}${paragraphsHtml(s.paragraphs)}</section>`;
480
+ }
481
+ });
482
+ return `<!doctype html>
483
+ <html lang="en">
484
+ <head>
485
+ <meta charset="utf-8">
486
+ <meta name="viewport" content="width=device-width, initial-scale=1">
487
+ <meta name="generator" content="${REPORT_GENERATOR}">
488
+ <title>${escapeHtml(title)}</title>
489
+ <style>${STYLE}</style>
490
+ </head>
491
+ <body>
492
+ <main>
493
+ <header class="title">
494
+ <h1 data-testid="report-title">${escapeHtml(title)}</h1>
495
+ ${template.subtitle !== undefined ? `<p class="subtitle">${escapeHtml(fill(template.subtitle, tokens))}</p>\n` : ""}${fields.length > 0
496
+ ? `<table class="facts">${fields.map((f) => `<tr><th scope="row">${escapeHtml(f.label)}</th><td>${escapeHtml(f.value)}</td></tr>`).join("")}</table>\n`
497
+ : ""}</header>
498
+ ${sections.join("\n")}
499
+ </main>
500
+ </body>
501
+ </html>
502
+ `;
503
+ }