@lingxia/test 0.12.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.
Files changed (57) hide show
  1. package/dist/expect.d.ts +26 -0
  2. package/dist/expect.d.ts.map +1 -0
  3. package/dist/expect.js +220 -0
  4. package/dist/expect.js.map +1 -0
  5. package/dist/fixture.d.ts +96 -0
  6. package/dist/fixture.d.ts.map +1 -0
  7. package/dist/fixture.js +703 -0
  8. package/dist/fixture.js.map +1 -0
  9. package/dist/format.d.ts +14 -0
  10. package/dist/format.d.ts.map +1 -0
  11. package/dist/format.js +179 -0
  12. package/dist/format.js.map +1 -0
  13. package/dist/host.d.ts +29 -0
  14. package/dist/host.d.ts.map +1 -0
  15. package/dist/host.js +204 -0
  16. package/dist/host.js.map +1 -0
  17. package/dist/ids.d.ts +26 -0
  18. package/dist/ids.d.ts.map +1 -0
  19. package/dist/ids.js +92 -0
  20. package/dist/ids.js.map +1 -0
  21. package/dist/index.d.ts +9 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +7 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/inventory.d.ts +24 -0
  26. package/dist/inventory.d.ts.map +1 -0
  27. package/dist/inventory.js +38 -0
  28. package/dist/inventory.js.map +1 -0
  29. package/dist/junit.d.ts +8 -0
  30. package/dist/junit.d.ts.map +1 -0
  31. package/dist/junit.js +97 -0
  32. package/dist/junit.js.map +1 -0
  33. package/dist/locator.d.ts +62 -0
  34. package/dist/locator.d.ts.map +1 -0
  35. package/dist/locator.js +124 -0
  36. package/dist/locator.js.map +1 -0
  37. package/dist/report.d.ts +15 -0
  38. package/dist/report.d.ts.map +1 -0
  39. package/dist/report.js +967 -0
  40. package/dist/report.js.map +1 -0
  41. package/dist/runtime.d.ts +33 -0
  42. package/dist/runtime.d.ts.map +1 -0
  43. package/dist/runtime.js +487 -0
  44. package/dist/runtime.js.map +1 -0
  45. package/dist/spec-api.d.ts +15 -0
  46. package/dist/spec-api.d.ts.map +1 -0
  47. package/dist/spec-api.js +2 -0
  48. package/dist/spec-api.js.map +1 -0
  49. package/dist/types.d.ts +234 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +2 -0
  52. package/dist/types.js.map +1 -0
  53. package/dist/version.d.ts +21 -0
  54. package/dist/version.d.ts.map +1 -0
  55. package/dist/version.js +21 -0
  56. package/dist/version.js.map +1 -0
  57. package/package.json +56 -0
package/dist/report.js ADDED
@@ -0,0 +1,967 @@
1
+ import { escapeHtml } from "./format.js";
2
+ import { CAPABILITY_INDEX, LAYER_TITLE, PUBLIC_CAPABILITIES, isMeasured, } from "./inventory.js";
3
+ import { PACKAGE_NAME, VERSION } from "./version.js";
4
+ /** A status the report groups, filters, and colours by. */
5
+ const STATUS_ORDER = [
6
+ "failed",
7
+ "timeout",
8
+ "xpass",
9
+ "passed",
10
+ "xfail",
11
+ "skipped",
12
+ ];
13
+ const STATUS_LABEL = {
14
+ passed: "passed",
15
+ failed: "failed",
16
+ skipped: "pending",
17
+ timeout: "timed out",
18
+ xfail: "expected fail",
19
+ xpass: "unexpected pass",
20
+ };
21
+ export function renderHtml(report) {
22
+ const meta = report.meta ?? { started_at: "", duration_ms: report.duration_ms, args: {} };
23
+ const args = meta.args ?? {};
24
+ const suites = groupSuites(report.cases);
25
+ const stats = collectStats(report);
26
+ const verdict = verdictOf(report);
27
+ const subject = meta.subject;
28
+ // The report is about the app, not about the tool that ran it.
29
+ const appName = subject?.app_name || subject?.appid || "";
30
+ const target = meta.platform || args.platform || "";
31
+ const renderer = meta.framework || args.framework || "";
32
+ // The subject line answers "what was tested, and where" before anything else.
33
+ const subjectLine = [appName || "lxapp", target, renderer]
34
+ .filter(Boolean)
35
+ .map(escapeHtml)
36
+ .join(" · ");
37
+ const documentTitle = `${appName || "lxapp"}${target ? ` on ${target}` : ""} test report`;
38
+ const chips = [
39
+ ...(subject?.appid && subject.appid !== appName ? [chip("id", subject.appid)] : []),
40
+ ...(subject?.version ? [chip("version", subject.version)] : []),
41
+ ...(subject?.release_type ? [chip("build", subject.release_type)] : []),
42
+ chip("started", meta.started_at ? meta.started_at.replace("T", " ").replace(/\.\d+Z$/, "Z") : "—"),
43
+ chip("duration", formatDuration(report.duration_ms)),
44
+ chip("runner", `${PACKAGE_NAME} ${VERSION}`),
45
+ ...Object.entries(args)
46
+ .filter(([key]) => key !== "platform" && key !== "framework")
47
+ .map(([key, value]) => chip(key, value)),
48
+ ].join("");
49
+ const flags = [
50
+ report.partial ? flag("partial", "The run ended early; results below are incomplete.") : "",
51
+ report.filtered ? flag("filtered", "A --grep or spec.only selection was in effect.") : "",
52
+ ].filter(Boolean).join("");
53
+ return `<!DOCTYPE html>
54
+ <html lang="en">
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <meta name="viewport" content="width=device-width, initial-scale=1">
58
+ <title>${escapeHtml(documentTitle)}</title>
59
+ <style>${STYLE}</style>
60
+ </head>
61
+ <body>
62
+ <a class="skip-link" href="#results">Skip to results</a>
63
+ <main>
64
+ <header class="hero ${verdict.tone}">
65
+ <button type="button" id="theme" class="theme-toggle" title="Switch between light and dark">Theme</button>
66
+ <div class="hero-text">
67
+ <p class="eyebrow">${subjectLine}</p>
68
+ <h1><span class="verdict">${escapeHtml(verdict.label)}</span></h1>
69
+ <p class="lede">${escapeHtml(verdict.detail)}</p>
70
+ <div class="chips">${chips}</div>
71
+ ${flags ? `<div class="flags">${flags}</div>` : ""}
72
+ </div>
73
+ ${renderDonut(report)}
74
+ </header>
75
+
76
+ <section class="metrics" aria-label="Run totals">
77
+ ${metric("specs", String(report.total), "registered and selected")}
78
+ ${metric("assertions", String(stats.assertions), `${stats.failedAssertions} failed`)}
79
+ ${metric("steps", String(stats.steps), "recorded")}
80
+ ${stats.surface
81
+ ? metric("lx API", percent(stats.logicBehaviour, stats.logicTotal), `${stats.logicBehaviour}/${stats.logicTotal} Logic capabilities behaviour-proven`)
82
+ : metric("capabilities", String(stats.provenTags), `of ${stats.declaredTags} declared tags proven`)}
83
+ </section>
84
+
85
+ ${renderBar(report)}
86
+
87
+ <div class="toolbar" role="search">
88
+ <input id="q" type="search" placeholder="Search id, title, suite, cover tag, matcher…" aria-label="Filter specs">
89
+ <div class="pills" role="group" aria-label="Filter by status">
90
+ ${renderPill("all", `all ${report.total}`, true, "")}
91
+ ${STATUS_ORDER.filter((status) => countOf(report, status) > 0)
92
+ .map((status) => renderPill(status, `${STATUS_LABEL[status]} ${countOf(report, status)}`, false, tone(status)))
93
+ .join("")}
94
+ </div>
95
+ <div class="actions">
96
+ <button type="button" id="expand">Expand all</button>
97
+ <button type="button" id="collapse">Collapse all</button>
98
+ </div>
99
+ </div>
100
+
101
+ ${renderCoverage(report)}
102
+ ${renderSlowest(report)}
103
+
104
+ <div id="results">
105
+ ${suites.map((suite) => renderSuite(suite)).join("")}
106
+ </div>
107
+ <p class="no-match" id="no-match" hidden>Nothing matches this filter.</p>
108
+
109
+ <footer>
110
+ Generated by ${escapeHtml(PACKAGE_NAME)} ${escapeHtml(VERSION)} &middot; single file, no network access.
111
+ </footer>
112
+ </main>
113
+ <script>${SCRIPT}</script>
114
+ </body>
115
+ </html>`;
116
+ }
117
+ /* ------------------------------------------------------------------ */
118
+ /* structure */
119
+ /* ------------------------------------------------------------------ */
120
+ function groupSuites(cases) {
121
+ const suites = [];
122
+ const index = new Map();
123
+ for (const item of cases) {
124
+ const name = item.suite ?? "specs";
125
+ let suite = index.get(name);
126
+ if (!suite) {
127
+ suite = { name, cases: [] };
128
+ index.set(name, suite);
129
+ suites.push(suite);
130
+ }
131
+ suite.cases.push(item);
132
+ }
133
+ return suites;
134
+ }
135
+ function countOf(report, status) {
136
+ switch (status) {
137
+ case "passed": return report.passed;
138
+ case "failed": return report.failed;
139
+ case "skipped": return report.skipped;
140
+ case "timeout": return report.timeout;
141
+ case "xfail": return report.xfail;
142
+ case "xpass": return report.xpass;
143
+ default: return 0;
144
+ }
145
+ }
146
+ function collectStats(report) {
147
+ let assertions = 0;
148
+ let failedAssertions = 0;
149
+ let steps = 0;
150
+ for (const item of report.cases) {
151
+ const flat = flattenAssertions(item);
152
+ assertions += flat.length;
153
+ failedAssertions += flat.filter((entry) => !entry.passed).length;
154
+ steps += countSteps(item.steps);
155
+ }
156
+ const surface = report.meta?.surface_coverage === true;
157
+ const states = collectCovers(report, surface);
158
+ const logic = coverageTotals(states, "logic");
159
+ return {
160
+ assertions,
161
+ failedAssertions,
162
+ steps,
163
+ logicBehaviour: logic.behaviour,
164
+ logicTotal: logic.total,
165
+ provenTags: [...states.values()].filter((state) => state.behaviour).length,
166
+ declaredTags: states.size,
167
+ surface,
168
+ };
169
+ }
170
+ function countSteps(steps) {
171
+ let total = 0;
172
+ for (const step of steps)
173
+ total += 1 + countSteps(step.steps ?? []);
174
+ return total;
175
+ }
176
+ function verdictOf(report) {
177
+ const broken = report.failed + report.timeout + report.xpass;
178
+ if (report.partial) {
179
+ return {
180
+ label: "Incomplete",
181
+ detail: `The run stopped before every spec reported. ${report.passed} of ${report.total} specs passed so far.`,
182
+ tone: "tone-warn",
183
+ };
184
+ }
185
+ if (broken > 0) {
186
+ return {
187
+ label: "Failed",
188
+ detail: broken === 1
189
+ ? "1 spec did not hold its contract."
190
+ : `${broken} specs did not hold their contract.`,
191
+ tone: "tone-fail",
192
+ };
193
+ }
194
+ if (report.total === 0) {
195
+ return { label: "Empty", detail: "No spec matched this selection.", tone: "tone-warn" };
196
+ }
197
+ return {
198
+ label: "Passed",
199
+ detail: report.skipped > 0
200
+ ? `${report.passed} specs held their contract; ${report.skipped} stay pending.`
201
+ : `All ${report.passed} specs held their contract.`,
202
+ tone: "tone-pass",
203
+ };
204
+ }
205
+ /* ------------------------------------------------------------------ */
206
+ /* header widgets */
207
+ /* ------------------------------------------------------------------ */
208
+ function chip(key, value) {
209
+ return `<span class="chip"><b>${escapeHtml(key)}</b>${escapeHtml(value)}</span>`;
210
+ }
211
+ function flag(name, hint) {
212
+ return `<span class="flag" title="${escapeHtml(hint)}">${escapeHtml(name)}</span>`;
213
+ }
214
+ function metric(label, value, hint) {
215
+ return `<div class="metric"><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd><small>${escapeHtml(hint)}</small></div>`;
216
+ }
217
+ function renderDonut(report) {
218
+ const graded = report.passed + report.xfail + report.failed + report.timeout + report.xpass;
219
+ const rate = graded > 0 ? (report.passed + report.xfail) / graded : 0;
220
+ const circumference = 2 * Math.PI * 52;
221
+ const dash = (rate * circumference).toFixed(2);
222
+ const percent = graded > 0 ? `${Math.round(rate * 100)}%` : "—";
223
+ return `<div class="donut" role="img" aria-label="${escapeHtml(percent)} of graded specs passed">
224
+ <svg viewBox="0 0 120 120" width="132" height="132">
225
+ <circle class="donut-track" cx="60" cy="60" r="52"></circle>
226
+ <circle class="donut-value" cx="60" cy="60" r="52"
227
+ stroke-dasharray="${dash} ${(circumference - Number(dash)).toFixed(2)}"
228
+ transform="rotate(-90 60 60)"></circle>
229
+ </svg>
230
+ <div class="donut-label"><strong>${escapeHtml(percent)}</strong><span>graded pass rate</span></div>
231
+ </div>`;
232
+ }
233
+ function renderBar(report) {
234
+ const segments = STATUS_ORDER
235
+ .map((status) => ({ status, count: countOf(report, status) }))
236
+ .filter((entry) => entry.count > 0);
237
+ if (segments.length === 0)
238
+ return "";
239
+ const total = segments.reduce((sum, entry) => sum + entry.count, 0);
240
+ const parts = segments
241
+ .map((entry) => `<span class="seg ${tone(entry.status)}" style="flex:${entry.count}"
242
+ title="${escapeHtml(`${entry.count} ${STATUS_LABEL[entry.status]}`)}"></span>`)
243
+ .join("");
244
+ return `<div class="bar" role="img" aria-label="${escapeHtml(segments.map((entry) => `${entry.count} ${STATUS_LABEL[entry.status]}`).join(", "))}">${parts}</div><p class="bar-caption">${total} spec${total === 1 ? "" : "s"} &middot; ${formatDuration(report.duration_ms)}</p>`;
245
+ }
246
+ function renderPill(filter, label, pressed, toneClass) {
247
+ return `<button type="button" class="pill ${toneClass}" data-filter="${escapeHtml(filter)}" aria-pressed="${pressed}">${escapeHtml(label)}</button>`;
248
+ }
249
+ /* ------------------------------------------------------------------ */
250
+ /* coverage */
251
+ /* ------------------------------------------------------------------ */
252
+ function emptyState() {
253
+ return { behaviour: false, shape: false, specs: [], declared: false };
254
+ }
255
+ function collectCovers(report, seedSurface) {
256
+ const states = new Map();
257
+ const touch = (name) => {
258
+ let state = states.get(name);
259
+ if (!state) {
260
+ state = emptyState();
261
+ states.set(name, state);
262
+ }
263
+ return state;
264
+ };
265
+ // Seeding the published surface turns every untested capability into a
266
+ // visible hole. That is what a conformance suite wants and what an ordinary
267
+ // lxapp does not: it never claimed the rest of the API.
268
+ if (seedSurface) {
269
+ for (const capability of PUBLIC_CAPABILITIES) {
270
+ if (isMeasured(capability.layer))
271
+ touch(capability.name);
272
+ }
273
+ }
274
+ for (const item of report.cases) {
275
+ const proven = item.status === "passed" || item.status === "xfail";
276
+ for (const tag of item.covers) {
277
+ const shapeOnly = tag.startsWith("shape:");
278
+ const name = shapeOnly ? tag.slice("shape:".length) : tag;
279
+ // A harness-driver tag is not product coverage; keep it out of the panel.
280
+ const capability = CAPABILITY_INDEX.get(name);
281
+ if (capability && !isMeasured(capability.layer))
282
+ continue;
283
+ const state = touch(name);
284
+ state.declared = true;
285
+ if (!state.specs.includes(item.id))
286
+ state.specs.push(item.id);
287
+ if (!proven)
288
+ continue;
289
+ if (shapeOnly)
290
+ state.shape = true;
291
+ else
292
+ state.behaviour = true;
293
+ }
294
+ }
295
+ return states;
296
+ }
297
+ function groupCovers(states) {
298
+ const groups = new Map();
299
+ const bucket = (group, layer) => {
300
+ let entry = groups.get(group);
301
+ if (!entry) {
302
+ entry = { group, layer, rows: [], behaviour: 0, shape: 0 };
303
+ groups.set(group, entry);
304
+ }
305
+ return entry;
306
+ };
307
+ for (const [name, state] of states) {
308
+ const capability = CAPABILITY_INDEX.get(name);
309
+ // A tag outside the published surface is still worth showing — it is
310
+ // either a project-local contract or a stale tag after an API rename.
311
+ const entry = capability
312
+ ? bucket(capability.group, capability.layer)
313
+ : bucket("project tags", "custom");
314
+ entry.rows.push({ name, state });
315
+ if (state.behaviour)
316
+ entry.behaviour += 1;
317
+ if (state.shape || state.behaviour)
318
+ entry.shape += 1;
319
+ }
320
+ for (const entry of groups.values()) {
321
+ entry.rows.sort((left, right) => left.name.localeCompare(right.name));
322
+ }
323
+ return [...groups.values()].sort((left, right) => left.group.localeCompare(right.group));
324
+ }
325
+ function coverageTotals(states, layer) {
326
+ let behaviour = 0;
327
+ let shape = 0;
328
+ let total = 0;
329
+ for (const capability of PUBLIC_CAPABILITIES) {
330
+ if (capability.layer !== layer || !isMeasured(capability.layer))
331
+ continue;
332
+ total += 1;
333
+ const state = states.get(capability.name);
334
+ if (!state)
335
+ continue;
336
+ if (state.behaviour)
337
+ behaviour += 1;
338
+ if (state.shape || state.behaviour)
339
+ shape += 1;
340
+ }
341
+ return { behaviour, shape, total };
342
+ }
343
+ function percent(part, whole) {
344
+ return whole > 0 ? `${Math.round((part / whole) * 100)}%` : "—";
345
+ }
346
+ function renderCoverage(report) {
347
+ const surface = report.meta?.surface_coverage === true;
348
+ const states = collectCovers(report, surface);
349
+ if (states.size === 0)
350
+ return "";
351
+ const groups = groupCovers(states);
352
+ const sections = ["logic", "object", "custom"]
353
+ .map((layer) => renderCoverageSection(layer, groups.filter((group) => group.layer === layer), states, surface))
354
+ .filter(Boolean)
355
+ .join("");
356
+ if (sections.length === 0)
357
+ return "";
358
+ const logic = coverageTotals(states, "logic");
359
+ const declared = [...states.values()];
360
+ const proven = declared.filter((state) => state.behaviour).length;
361
+ const heading = surface
362
+ ? `<span class="panel-title">lx API coverage</span>
363
+ <span class="panel-sub">${logic.behaviour}/${logic.total} Logic capabilities behaviour-proven
364
+ (${percent(logic.behaviour, logic.total)}), ${percent(logic.shape, logic.total)} shape-proven</span>`
365
+ : `<span class="panel-title">Capability coverage</span>
366
+ <span class="panel-sub">${proven}/${declared.length} declared capabilities proven by a passing spec</span>`;
367
+ return `<details class="panel" open>
368
+ <summary>${heading}</summary>
369
+ <div class="panel-body">
370
+ <ul class="legend">
371
+ <li><span class="cover cover-ok">behaviour</span> a passing spec asserted it</li>
372
+ <li><span class="cover cover-shape">shape</span> proven only to exist</li>
373
+ <li><span class="cover cover-pending">declared</span> claimed by a pending or failing spec</li>
374
+ ${surface ? `<li><span class="cover cover-none">uncovered</span> no spec at all</li>` : ""}
375
+ </ul>
376
+ ${sections}
377
+ </div>
378
+ </details>`;
379
+ }
380
+ function renderCoverageSection(layer, groups, states, surface) {
381
+ if (groups.length === 0)
382
+ return "";
383
+ const title = layer === "custom" ? "Project tags" : LAYER_TITLE[layer];
384
+ const rows = groups.reduce((sum, group) => sum + group.rows.length, 0);
385
+ const head = layer === "custom"
386
+ ? `${rows} tags outside the published surface`
387
+ : surface
388
+ ? (() => {
389
+ const totals = coverageTotals(states, layer);
390
+ return `${totals.behaviour}/${totals.total} behaviour &middot; ${totals.shape}/${totals.total} shape`;
391
+ })()
392
+ : `${groups.reduce((sum, group) => sum + group.behaviour, 0)}/${rows} behaviour`;
393
+ const body = groups.map((group) => {
394
+ const chips = group.rows.map(({ name, state }) => {
395
+ const klass = state.behaviour
396
+ ? "cover-ok"
397
+ : state.shape
398
+ ? "cover-shape"
399
+ : state.declared
400
+ ? "cover-pending"
401
+ : "cover-none";
402
+ const hint = state.specs.length > 0 ? `covered by ${state.specs.join(", ")}` : "no spec declares this tag";
403
+ return `<span class="cover ${klass}" title="${escapeHtml(hint)}"
404
+ data-search="${escapeHtml(`${name} ${state.specs.join(" ")}`)}">${escapeHtml(name)}</span>`;
405
+ }).join("");
406
+ return `<div class="cover-group">
407
+ <h3>${escapeHtml(group.group)} <small>${group.behaviour}/${group.rows.length}</small></h3>
408
+ <div class="cover-tags">${chips}</div>
409
+ </div>`;
410
+ }).join("");
411
+ return `<section class="cover-section">
412
+ <h2 class="cover-section-head">${escapeHtml(title)} <small>${head}</small></h2>
413
+ <div class="cover-grid">${body}</div>
414
+ </section>`;
415
+ }
416
+ function renderSlowest(report) {
417
+ const ranked = report.cases
418
+ .filter((item) => item.status !== "skipped")
419
+ .slice()
420
+ .sort((a, b) => b.duration_ms - a.duration_ms)
421
+ .slice(0, 8);
422
+ if (ranked.length < 2)
423
+ return "";
424
+ const max = ranked[0].duration_ms || 1;
425
+ const rows = ranked.map((item) => `<tr>
426
+ <td><a href="#case-${escapeHtml(item.id)}">${escapeHtml(item.title)}</a></td>
427
+ <td class="num">${formatDuration(item.duration_ms)}</td>
428
+ <td class="spark"><span style="width:${Math.max(2, Math.round((item.duration_ms / max) * 100))}%"></span></td>
429
+ </tr>`).join("");
430
+ return `<details class="panel">
431
+ <summary><span class="panel-title">Slowest specs</span>
432
+ <span class="panel-sub">where the wall clock went</span></summary>
433
+ <div class="panel-body"><table class="slowest"><tbody>${rows}</tbody></table></div>
434
+ </details>`;
435
+ }
436
+ /* ------------------------------------------------------------------ */
437
+ /* suites and cases */
438
+ /* ------------------------------------------------------------------ */
439
+ function renderSuite(suite) {
440
+ const counts = STATUS_ORDER
441
+ .map((status) => ({ status, count: suite.cases.filter((item) => item.status === status).length }))
442
+ .filter((entry) => entry.count > 0)
443
+ .map((entry) => `<span class="mini ${tone(entry.status)}">${entry.count} ${escapeHtml(STATUS_LABEL[entry.status])}</span>`)
444
+ .join("");
445
+ const duration = suite.cases.reduce((sum, item) => sum + item.duration_ms, 0);
446
+ return `<section class="suite" data-suite="${escapeHtml(suite.name)}">
447
+ <h2 class="suite-head">
448
+ <span class="suite-name">${escapeHtml(suite.name)}</span>
449
+ <span class="suite-meta">${counts}<span class="mini">${formatDuration(duration)}</span></span>
450
+ </h2>
451
+ ${suite.cases.map((item) => renderCase(item, suite.name)).join("")}
452
+ </section>`;
453
+ }
454
+ function renderCase(item, suiteName) {
455
+ const assertions = flattenAssertions(item);
456
+ const passedAsserts = assertions.filter((entry) => entry.passed).length;
457
+ const failedAsserts = assertions.length - passedAsserts;
458
+ const covers = item.covers.length > 0
459
+ ? `<div class="covers">${item.covers.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}</div>`
460
+ : "";
461
+ const reason = item.reason ? `<p class="reason">${escapeHtml(item.reason)}</p>` : "";
462
+ const error = renderError(item);
463
+ const empty = assertions.length === 0 && item.steps.length === 0
464
+ ? item.status === "skipped"
465
+ ? `<p class="empty">Pending: registered as a known hole, no assertions run.</p>`
466
+ : `<p class="empty">No assertion was recorded for this spec.</p>`
467
+ : "";
468
+ const shots = renderAttachments(item);
469
+ const files = "";
470
+ const search = [
471
+ item.id,
472
+ item.title,
473
+ item.status,
474
+ suiteName,
475
+ item.reason ?? "",
476
+ item.error?.message ?? "",
477
+ ...item.covers,
478
+ ...assertions.map((entry) => `${entry.matcher} ${entry.expected} ${entry.actual}`),
479
+ ].join(" ");
480
+ const assertMeta = assertions.length > 0
481
+ ? ` &middot; ${passedAsserts} assertion${passedAsserts === 1 ? "" : "s"} passed${failedAsserts ? `, ${failedAsserts} failed` : ""}`
482
+ : "";
483
+ const open = isBroken(item.status) ? " open" : "";
484
+ const where = item.file ? ` &middot; ${escapeHtml(item.file)}${item.line ? `:${item.line}` : ""}` : "";
485
+ return `<details class="case ${tone(item.status)}" id="case-${escapeHtml(item.id)}"
486
+ data-status="${escapeHtml(item.status)}" data-search="${escapeHtml(search)}"${open}>
487
+ <summary>
488
+ <span class="badge ${tone(item.status)}">${escapeHtml(STATUS_LABEL[item.status] ?? item.status)}</span>
489
+ <span class="case-title">${escapeHtml(item.title)}</span>
490
+ <span class="case-time">${formatDuration(item.duration_ms)}</span>
491
+ </summary>
492
+ <div class="body">
493
+ <p class="meta"><code>${escapeHtml(item.id)}</code>${where}${assertMeta}</p>
494
+ ${covers}
495
+ ${reason}
496
+ ${error}
497
+ ${renderSteps(item.steps)}
498
+ ${renderAssertions(item.assertions ?? [], "spec assertions")}
499
+ ${empty}
500
+ ${shots}
501
+ ${files}
502
+ </div>
503
+ </details>`;
504
+ }
505
+ /**
506
+ * An artifact the reader cannot open from the report is an artifact they will
507
+ * not look at, so images inline and text/JSON preview in place. Everything
508
+ * else names the path the CLI wrote it to.
509
+ */
510
+ function renderAttachments(item) {
511
+ if (item.attachments.length === 0)
512
+ return "";
513
+ const blocks = item.attachments.map((attachment) => {
514
+ const inlined = inlineFromAttachments(attachment.name, item);
515
+ // The path is relative to the report, so the link opens the real file
516
+ // whenever the report is read from the directory the CLI wrote it to.
517
+ const link = `<a class="path" href="${escapeHtml(attachment.path)}">${escapeHtml(attachment.path)}</a>`;
518
+ if (inlined?.dataUrl) {
519
+ return `<figure class="shot">
520
+ <img alt="${escapeHtml(attachment.name)}" src="${inlined.dataUrl}" loading="lazy">
521
+ <figcaption>${escapeHtml(attachment.name)} &middot; ${link}</figcaption>
522
+ </figure>`;
523
+ }
524
+ if (inlined?.text !== undefined) {
525
+ return `<details class="artifact">
526
+ <summary>${escapeHtml(attachment.name)} ${link}</summary>
527
+ <pre>${escapeHtml(inlined.text)}</pre>
528
+ </details>`;
529
+ }
530
+ return `<div class="artifact-path"><b>${escapeHtml(attachment.name)}</b> ${link}</div>`;
531
+ });
532
+ return `<div class="artifacts"><h4>artifacts</h4>${blocks.join("")}</div>`;
533
+ }
534
+ function renderError(item) {
535
+ if (!item.error)
536
+ return "";
537
+ const error = item.error;
538
+ const compare = error.expected !== undefined || error.actual !== undefined
539
+ ? `<div class="compare">
540
+ <div class="side expected"><h4>expected</h4><pre>${escapeHtml(error.expected ?? "—")}</pre></div>
541
+ <div class="side actual"><h4>actual</h4><pre>${escapeHtml(error.actual ?? "—")}</pre></div>
542
+ </div>`
543
+ : "";
544
+ const at = error.location ? `<p class="at">at ${escapeHtml(error.location)}</p>` : "";
545
+ const inStep = error.step ? `<p class="at">in step <code>${escapeHtml(error.step)}</code></p>` : "";
546
+ const stack = error.stack
547
+ ? `<details class="stack"><summary>stack</summary><pre>${escapeHtml(error.stack)}</pre></details>`
548
+ : "";
549
+ return `<div class="failure">
550
+ <h3>${escapeHtml(error.name)}${error.matcher ? ` &middot; <code>${escapeHtml(error.matcher)}</code>` : ""}</h3>
551
+ <pre class="message">${escapeHtml(error.message)}</pre>
552
+ ${compare}${at}${inStep}${stack}
553
+ </div>`;
554
+ }
555
+ function renderSteps(steps) {
556
+ if (steps.length === 0)
557
+ return "";
558
+ const total = Math.max(1, ...steps.map((step) => step.duration_ms));
559
+ const items = steps.map((step) => {
560
+ const err = step.error ? `<pre class="message">${escapeHtml(step.error.message)}</pre>` : "";
561
+ const width = Math.max(2, Math.round((step.duration_ms / total) * 100));
562
+ const action = step.kind === "action";
563
+ const label = action
564
+ ? `<code class="action-name">${escapeHtml(step.name)}</code>${step.detail ? ` <span class="action-detail">${escapeHtml(step.detail)}</span>` : ""}${step.repeat ? ` <span class="repeat">&times;${step.repeat}</span>` : ""}`
565
+ : `<span class="step-name">${escapeHtml(step.name)}</span>`;
566
+ const badge = action && step.status === "passed"
567
+ ? ""
568
+ : `<span class="badge sm ${tone(step.status)}">${escapeHtml(step.status)}</span>`;
569
+ return `<li class="step${action ? " action" : ""}">
570
+ <div class="step-head">
571
+ ${badge}${label}
572
+ <span class="step-time">${step.duration_ms}ms</span>
573
+ <span class="step-bar"><span style="width:${width}%"></span></span>
574
+ </div>
575
+ ${err}${renderAssertions(step.assertions ?? [], "")}${renderSteps(step.steps ?? [])}
576
+ </li>`;
577
+ }).join("");
578
+ return `<ul class="steps">${items}</ul>`;
579
+ }
580
+ function renderAssertions(assertions, caption) {
581
+ if (assertions.length === 0)
582
+ return "";
583
+ const rows = assertions.map((entry) => `<tr class="${entry.passed ? "row-pass" : "row-fail"}">
584
+ <td class="${entry.passed ? "pass" : "fail"}">${entry.passed ? "pass" : "fail"}</td>
585
+ <td><code>${escapeHtml(entry.matcher)}</code></td>
586
+ <td>${escapeHtml(entry.expected)}</td>
587
+ <td>${escapeHtml(entry.actual)}</td>
588
+ </tr>`).join("");
589
+ return `<table class="assertions">
590
+ ${caption ? `<caption>${escapeHtml(caption)}</caption>` : ""}
591
+ <thead><tr><th>result</th><th>matcher</th><th>expected</th><th>actual</th></tr></thead>
592
+ <tbody>${rows}</tbody>
593
+ </table>`;
594
+ }
595
+ /* ------------------------------------------------------------------ */
596
+ /* helpers */
597
+ /* ------------------------------------------------------------------ */
598
+ function flattenAssertions(item) {
599
+ const out = [...(item.assertions ?? [])];
600
+ const walk = (steps) => {
601
+ for (const step of steps) {
602
+ out.push(...(step.assertions ?? []));
603
+ walk(step.steps ?? []);
604
+ }
605
+ };
606
+ walk(item.steps ?? []);
607
+ return out;
608
+ }
609
+ function isBroken(status) {
610
+ return status === "failed" || status === "timeout" || status === "xpass";
611
+ }
612
+ function tone(status) {
613
+ if (status === "passed" || status === "xfail")
614
+ return "pass";
615
+ if (status === "skipped")
616
+ return "skip";
617
+ return "fail";
618
+ }
619
+ export function formatDuration(ms) {
620
+ if (ms < 1000)
621
+ return `${Math.round(ms)}ms`;
622
+ if (ms < 60000)
623
+ return `${(ms / 1000).toFixed(2)}s`;
624
+ const minutes = Math.floor(ms / 60000);
625
+ const seconds = ((ms % 60000) / 1000).toFixed(0).padStart(2, "0");
626
+ return `${minutes}m ${seconds}s`;
627
+ }
628
+ const inlineStore = new Map();
629
+ export function rememberInline(specId, name, payload) {
630
+ let bucket = inlineStore.get(specId);
631
+ if (!bucket) {
632
+ bucket = new Map();
633
+ inlineStore.set(specId, bucket);
634
+ }
635
+ bucket.set(name, payload);
636
+ }
637
+ export function clearInline() {
638
+ inlineStore.clear();
639
+ }
640
+ function inlineFromAttachments(name, item) {
641
+ return inlineStore.get(item.id)?.get(name);
642
+ }
643
+ export function dataUrl(mimeType, base64) {
644
+ return `data:${mimeType};base64,${base64}`;
645
+ }
646
+ export function countStatuses(cases) {
647
+ const counts = { total: cases.length, passed: 0, failed: 0, skipped: 0, xfail: 0, xpass: 0, timeout: 0 };
648
+ for (const item of cases) {
649
+ if (item.status === "passed")
650
+ counts.passed += 1;
651
+ else if (item.status === "failed")
652
+ counts.failed += 1;
653
+ else if (item.status === "skipped")
654
+ counts.skipped += 1;
655
+ else if (item.status === "xfail")
656
+ counts.xfail += 1;
657
+ else if (item.status === "xpass")
658
+ counts.xpass += 1;
659
+ else if (item.status === "timeout")
660
+ counts.timeout += 1;
661
+ }
662
+ return counts;
663
+ }
664
+ /* ------------------------------------------------------------------ */
665
+ /* assets */
666
+ /* ------------------------------------------------------------------ */
667
+ const STYLE = `
668
+ /* Light is the base palette; dark redefines only tokens, once for the
669
+ un-stamped system default and once for an explicit toggle. Every component
670
+ below reads tokens, so no colour is ever defined only inside a guard. */
671
+ :root {
672
+ color-scheme: light dark;
673
+ --bg:#f5f6f8; --panel:#ffffff; --ink:#161b22; --muted:#5a6572; --line:#dfe4ea;
674
+ --pass:#0f7a53; --fail:#b8202a; --skip:#8a6207; --accent:#3352c9;
675
+ --pass-soft:#e6f4ee; --fail-soft:#fceceb; --skip-soft:#fbf2dd;
676
+ --shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.08);
677
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
678
+ /* The report opens from disk in CI and must never fetch a font. */
679
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
680
+ }
681
+ @media (prefers-color-scheme: dark) {
682
+ :root:not([data-theme="light"]) {
683
+ --bg:#0f1216; --panel:#171b21; --ink:#e4e8ee; --muted:#8d97a4; --line:#262c34;
684
+ --pass:#49c07d; --fail:#f06a68; --skip:#d3a03a; --accent:#7a9bff;
685
+ --pass-soft:#122318; --fail-soft:#2a1517; --skip-soft:#272013;
686
+ --shadow:0 1px 2px rgba(0,0,0,.45);
687
+ }
688
+ }
689
+ :root[data-theme="dark"] {
690
+ --bg:#0f1216; --panel:#171b21; --ink:#e4e8ee; --muted:#8d97a4; --line:#262c34;
691
+ --pass:#49c07d; --fail:#f06a68; --skip:#d3a03a; --accent:#7a9bff;
692
+ --pass-soft:#122318; --fail-soft:#2a1517; --skip-soft:#272013;
693
+ --shadow:0 1px 2px rgba(0,0,0,.45);
694
+ }
695
+ * { box-sizing: border-box; }
696
+ body { margin:0; background:var(--bg); color:var(--ink); font:14px/1.55 var(--sans); }
697
+ main { max-width:1180px; margin:0 auto; padding:28px 20px 72px; }
698
+ a { color:var(--accent); }
699
+ .skip-link { position:absolute; left:-9999px; }
700
+ .skip-link:focus { left:8px; top:8px; background:var(--panel); padding:8px 12px; border-radius:8px; }
701
+
702
+ .hero { position:relative; display:flex; gap:24px; align-items:center; justify-content:space-between; flex-wrap:wrap;
703
+ background:var(--panel); border:1px solid var(--line); border-left:6px solid var(--muted);
704
+ border-radius:14px; padding:22px 24px; box-shadow:var(--shadow); }
705
+ .hero.tone-pass { border-left-color:var(--pass); }
706
+ .hero.tone-fail { border-left-color:var(--fail); }
707
+ .hero.tone-warn { border-left-color:var(--skip); }
708
+ .hero-text { min-width:min(100%,320px); flex:1; }
709
+ .eyebrow { margin:0; font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
710
+ h1 { margin:2px 0 4px; font-size:30px; line-height:1.15; letter-spacing:-.02em; text-wrap:balance; }
711
+ .tone-pass .verdict { color:var(--pass); }
712
+ .tone-fail .verdict { color:var(--fail); }
713
+ .tone-warn .verdict { color:var(--skip); }
714
+ .lede { margin:0 0 14px; color:var(--muted); }
715
+ .chips { display:flex; flex-wrap:wrap; gap:6px; }
716
+ .chip { display:inline-flex; gap:6px; align-items:baseline; font-size:12px; padding:3px 9px;
717
+ border:1px solid var(--line); border-radius:999px; font-family:var(--mono); }
718
+ .chip b { font-weight:600; color:var(--muted); font-family:inherit; }
719
+ .flags { margin-top:10px; display:flex; gap:6px; }
720
+ .flag { font-size:11px; text-transform:uppercase; letter-spacing:.08em; padding:3px 9px;
721
+ border-radius:999px; background:var(--skip-soft); color:var(--skip); border:1px solid var(--line); }
722
+
723
+ .theme-toggle { position:absolute; top:12px; right:14px; padding:4px 10px; font:inherit;
724
+ font-size:12px; color:var(--muted); background:transparent; border:1px solid var(--line);
725
+ border-radius:999px; cursor:pointer; }
726
+ .theme-toggle:hover { color:var(--ink); border-color:var(--accent); }
727
+ .donut { display:flex; align-items:center; gap:14px; }
728
+ .donut svg { flex:none; }
729
+ .donut-track { fill:none; stroke:var(--line); stroke-width:12; }
730
+ .donut-value { fill:none; stroke:var(--pass); stroke-width:12; stroke-linecap:round; }
731
+ .tone-fail .donut-value { stroke:var(--fail); }
732
+ .tone-warn .donut-value { stroke:var(--skip); }
733
+ .donut-label { display:flex; flex-direction:column; }
734
+ .donut-label strong { font-size:26px; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
735
+ .donut-label span { font-size:12px; color:var(--muted); }
736
+
737
+ .metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:12px; margin:16px 0 0; }
738
+ .metric { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 14px; box-shadow:var(--shadow); }
739
+ .metric dt { font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
740
+ .metric dd { margin:2px 0 0; font-size:24px; font-weight:600; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
741
+ .metric small { color:var(--muted); }
742
+
743
+ .bar { display:flex; height:8px; border-radius:999px; overflow:hidden; margin:18px 0 6px; background:var(--line); }
744
+ .seg.pass { background:var(--pass); } .seg.fail { background:var(--fail); } .seg.skip { background:var(--skip); }
745
+ .bar-caption { margin:0 0 18px; color:var(--muted); font-size:12px; }
746
+
747
+ .toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin:0 0 18px;
748
+ position:sticky; top:0; z-index:5; padding:10px 0; background:var(--bg); }
749
+ .toolbar input { flex:1 1 260px; min-width:200px; padding:9px 12px; border-radius:10px;
750
+ border:1px solid var(--line); background:var(--panel); color:var(--ink); font:inherit; }
751
+ .pills { display:flex; gap:6px; flex-wrap:wrap; }
752
+ .pill { padding:6px 12px; border-radius:999px; border:1px solid var(--line); background:var(--panel);
753
+ color:var(--ink); font:inherit; font-size:13px; cursor:pointer; }
754
+ .pill.pass { color:var(--pass); } .pill.fail { color:var(--fail); } .pill.skip { color:var(--skip); }
755
+ .pill[aria-pressed="true"] { background:var(--ink); color:var(--bg); border-color:var(--ink); }
756
+ .actions { display:flex; gap:6px; }
757
+ .actions button { padding:6px 12px; border-radius:10px; border:1px solid var(--line);
758
+ background:var(--panel); color:var(--muted); font:inherit; font-size:13px; cursor:pointer; }
759
+ .actions button:hover, .pill:hover { border-color:var(--accent); }
760
+ :focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
761
+ @media (prefers-reduced-motion: reduce) { * { animation:none !important; transition:none !important; } }
762
+
763
+ .panel { background:var(--panel); border:1px solid var(--line); border-radius:12px;
764
+ margin:0 0 14px; box-shadow:var(--shadow); }
765
+ .panel > summary { cursor:pointer; padding:13px 16px; display:flex; gap:10px; align-items:baseline; }
766
+ .panel > summary::-webkit-details-marker { display:none; }
767
+ .panel > summary::before { content:"▸"; color:var(--muted); }
768
+ .panel[open] > summary::before { content:"▾"; }
769
+ .panel-title { font-weight:600; }
770
+ .panel-sub { color:var(--muted); font-size:12px; }
771
+ .panel-body { padding:0 16px 16px; }
772
+ .cover-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:14px; }
773
+ .cover-group h3 { margin:0 0 6px; font-size:13px; font-family:var(--mono); }
774
+ .cover-group h3 small { color:var(--muted); font-weight:400; }
775
+ .cover-tags { display:flex; flex-wrap:wrap; gap:4px; }
776
+ .cover { font-size:11px; font-family:var(--mono); padding:2px 7px; border-radius:6px; border:1px solid var(--line); }
777
+ .cover-ok { background:var(--pass-soft); color:var(--pass); border-color:color-mix(in srgb, var(--pass) 30%, var(--line)); }
778
+ .cover-shape { background:transparent; color:var(--pass); border-style:dashed; }
779
+ .cover-pending { background:var(--skip-soft); color:var(--skip); }
780
+ .cover-none { color:var(--muted); opacity:.6; }
781
+ .legend { margin:0 0 14px; padding:0; list-style:none; color:var(--muted); font-size:12px;
782
+ display:flex; gap:6px 18px; flex-wrap:wrap; }
783
+ .legend li { display:flex; gap:6px; align-items:center; white-space:nowrap; }
784
+ .cover-section { margin:0 0 18px; }
785
+ .cover-section-head { font-size:13px; margin:0 0 8px; display:flex; gap:8px; align-items:baseline; flex-wrap:wrap; }
786
+ .cover-section-head small { color:var(--muted); font-weight:400; }
787
+ table.slowest { width:100%; border-collapse:collapse; }
788
+ table.slowest td { padding:5px 8px; border-bottom:1px solid var(--line); }
789
+ table.slowest td.num { text-align:right; font-family:var(--mono); white-space:nowrap; }
790
+ td.spark { width:40%; }
791
+ td.spark span { display:block; height:6px; border-radius:999px; background:var(--accent); opacity:.65; }
792
+
793
+ .suite { margin:0 0 22px; }
794
+ .suite-head { display:flex; justify-content:space-between; gap:12px; align-items:baseline;
795
+ font-size:13px; margin:0 0 8px; padding:0 2px; flex-wrap:wrap; }
796
+ .suite-name { font-family:var(--mono); font-weight:600; }
797
+ .suite-meta { display:flex; gap:6px; flex-wrap:wrap; }
798
+ .mini { font-size:11px; color:var(--muted); padding:2px 7px; border:1px solid var(--line); border-radius:999px; }
799
+ .mini.pass { color:var(--pass); } .mini.fail { color:var(--fail); } .mini.skip { color:var(--skip); }
800
+
801
+ .case { background:var(--panel); border:1px solid var(--line); border-radius:10px; margin:0 0 8px; }
802
+ .case.fail { border-color:color-mix(in srgb, var(--fail) 45%, var(--line)); }
803
+ .case > summary { display:flex; gap:10px; align-items:center; cursor:pointer; padding:11px 14px; list-style:none; }
804
+ .case > summary::-webkit-details-marker { display:none; }
805
+ .case-title { flex:1; font-weight:500; }
806
+ .case-time { color:var(--muted); font-family:var(--mono); font-size:12px; font-variant-numeric:tabular-nums; }
807
+ .badge { font-size:11px; text-transform:uppercase; letter-spacing:.05em; padding:2px 8px;
808
+ border-radius:6px; white-space:nowrap; }
809
+ .badge.pass { background:var(--pass-soft); color:var(--pass); }
810
+ .badge.fail { background:var(--fail-soft); color:var(--fail); }
811
+ .badge.skip { background:var(--skip-soft); color:var(--skip); }
812
+ .badge.sm { font-size:10px; padding:1px 6px; }
813
+ .body { padding:0 14px 14px; border-top:1px solid var(--line); }
814
+ .meta { color:var(--muted); font-size:12px; margin:10px 0; }
815
+ .covers { display:flex; flex-wrap:wrap; gap:4px; margin:0 0 10px; }
816
+ .tag { font-size:11px; font-family:var(--mono); padding:2px 7px; border-radius:6px;
817
+ background:var(--bg); border:1px solid var(--line); color:var(--muted); }
818
+ .reason { margin:0 0 10px; padding:9px 12px; border-left:3px solid var(--skip);
819
+ background:var(--skip-soft); border-radius:0 8px 8px 0; }
820
+ .failure { border:1px solid color-mix(in srgb, var(--fail) 35%, var(--line));
821
+ background:var(--fail-soft); border-radius:10px; padding:12px 14px; margin:0 0 12px; }
822
+ .failure h3 { margin:0 0 8px; font-size:13px; color:var(--fail); }
823
+ pre { white-space:pre-wrap; word-break:break-word; margin:0; font-family:var(--mono); font-size:12.5px; }
824
+ pre.message { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }
825
+ .compare { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin:10px 0 0; }
826
+ .side h4 { margin:0 0 4px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
827
+ .side pre { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:8px 10px; }
828
+ .side.expected pre { border-left:3px solid var(--pass); }
829
+ .side.actual pre { border-left:3px solid var(--fail); }
830
+ .at { margin:8px 0 0; color:var(--muted); font-size:12px; font-family:var(--mono); }
831
+ .stack { margin-top:8px; }
832
+ .stack summary { cursor:pointer; color:var(--muted); font-size:12px; }
833
+ .stack pre { margin-top:6px; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:10px 12px; max-height:280px; overflow:auto; }
834
+
835
+ .steps { list-style:none; margin:0 0 10px; padding:0; }
836
+ .steps .steps { margin:6px 0 0 18px; padding-left:10px; border-left:1px solid var(--line); }
837
+ .step { margin:6px 0; }
838
+ .step-head { display:flex; gap:8px; align-items:center; }
839
+ .step-name { flex:0 1 auto; font-weight:500; }
840
+ .step.action > .step-head { font-size:12.5px; color:var(--muted); }
841
+ .action-name { font-family:var(--mono); color:var(--ink); }
842
+ .action-detail { font-family:var(--mono); overflow:hidden; text-overflow:ellipsis;
843
+ white-space:nowrap; max-width:46ch; }
844
+ .repeat { font-size:11px; padding:1px 6px; border-radius:999px; border:1px solid var(--line); }
845
+ .step-time { color:var(--muted); font-family:var(--mono); font-size:12px; }
846
+ .step-bar { flex:1; height:4px; background:var(--line); border-radius:999px; overflow:hidden; min-width:40px; }
847
+ .step-bar span { display:block; height:100%; background:var(--accent); opacity:.55; }
848
+
849
+ table.assertions { width:100%; border-collapse:collapse; margin:8px 0; font-size:12.5px; display:block; overflow-x:auto; }
850
+ table.assertions caption { text-align:left; color:var(--muted); font-size:11px;
851
+ text-transform:uppercase; letter-spacing:.06em; padding-bottom:4px; }
852
+ table.assertions th, table.assertions td { text-align:left; vertical-align:top; padding:5px 8px;
853
+ border-bottom:1px solid var(--line); }
854
+ table.assertions th { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.05em; }
855
+ table.assertions td { font-family:var(--mono); word-break:break-word; }
856
+ table.assertions td:first-child, table.assertions td:nth-child(2) { white-space:nowrap; word-break:normal; }
857
+ table.assertions td.pass { color:var(--pass); } table.assertions td.fail { color:var(--fail); }
858
+ .row-fail { background:var(--fail-soft); }
859
+ .empty { color:var(--muted); margin:8px 0; }
860
+ .shot { margin:10px 0 0; }
861
+ .shot img { max-width:100%; border-radius:8px; border:1px solid var(--line); display:block; }
862
+ .shot figcaption { color:var(--muted); font-size:11px; margin-top:4px; font-family:var(--mono); }
863
+ .artifacts { margin-top:12px; }
864
+ .artifacts h4 { margin:0 0 6px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
865
+ .artifact { border:1px solid var(--line); border-radius:8px; margin:0 0 6px; background:var(--bg); }
866
+ .artifact > summary { cursor:pointer; padding:7px 10px; font-family:var(--mono); font-size:12px; }
867
+ .artifact .path, .shot .path { color:var(--muted); font-size:11px; }
868
+ .artifact pre { padding:0 10px 10px; max-height:340px; overflow:auto; font-size:12px; }
869
+ .artifact-path { color:var(--muted); font-size:11px; font-family:var(--mono); word-break:break-all; margin:0 0 4px; }
870
+ .artifact-path b { font-family:inherit; }
871
+ .no-match { text-align:center; color:var(--muted); padding:32px 0; }
872
+ footer { margin-top:32px; color:var(--muted); font-size:12px; text-align:center; }
873
+ @media print {
874
+ .toolbar, .actions { display:none; }
875
+ .case, .panel { break-inside:avoid; }
876
+ }
877
+ `;
878
+ const SCRIPT = `
879
+ (function () {
880
+ var root = document.documentElement;
881
+ var stored = null;
882
+ try { stored = localStorage.getItem("lxdev-report-theme"); } catch (e) {}
883
+ if (stored) root.setAttribute("data-theme", stored);
884
+ var themeButton = document.getElementById("theme");
885
+ if (themeButton) themeButton.addEventListener("click", function () {
886
+ var dark = root.getAttribute("data-theme") === "dark"
887
+ || (!root.getAttribute("data-theme") && matchMedia("(prefers-color-scheme: dark)").matches);
888
+ var next = dark ? "light" : "dark";
889
+ root.setAttribute("data-theme", next);
890
+ try { localStorage.setItem("lxdev-report-theme", next); } catch (e) {}
891
+ });
892
+
893
+ var filter = "all";
894
+ var query = "";
895
+ var cases = [].slice.call(document.querySelectorAll(".case"));
896
+ var suites = [].slice.call(document.querySelectorAll(".suite"));
897
+ var pills = [].slice.call(document.querySelectorAll("[data-filter]"));
898
+ var covers = [].slice.call(document.querySelectorAll(".cover"));
899
+ var noMatch = document.getElementById("no-match");
900
+
901
+ function apply() {
902
+ var needle = query.toLowerCase();
903
+ var shown = 0;
904
+ cases.forEach(function (node) {
905
+ var status = node.getAttribute("data-status");
906
+ var hay = (node.getAttribute("data-search") || "").toLowerCase();
907
+ var statusOk = filter === "all" || status === filter;
908
+ var textOk = !needle || hay.indexOf(needle) !== -1;
909
+ var visible = statusOk && textOk;
910
+ node.hidden = !visible;
911
+ if (visible) shown += 1;
912
+ });
913
+ suites.forEach(function (suite) {
914
+ var any = [].slice.call(suite.querySelectorAll(".case")).some(function (node) { return !node.hidden; });
915
+ suite.hidden = !any;
916
+ });
917
+ covers.forEach(function (node) {
918
+ var hay = (node.getAttribute("data-search") || "").toLowerCase();
919
+ node.style.opacity = !needle || hay.indexOf(needle) !== -1 ? "" : "0.25";
920
+ });
921
+ if (noMatch) noMatch.hidden = shown !== 0;
922
+ }
923
+
924
+ pills.forEach(function (pill) {
925
+ pill.addEventListener("click", function () {
926
+ filter = pill.getAttribute("data-filter") || "all";
927
+ pills.forEach(function (other) {
928
+ other.setAttribute("aria-pressed", other === pill ? "true" : "false");
929
+ });
930
+ apply();
931
+ });
932
+ });
933
+
934
+ var box = document.getElementById("q");
935
+ if (box) box.addEventListener("input", function (event) {
936
+ query = event.target.value || "";
937
+ apply();
938
+ });
939
+ document.addEventListener("keydown", function (event) {
940
+ if (event.key === "/" && document.activeElement !== box && box) {
941
+ event.preventDefault();
942
+ box.focus();
943
+ }
944
+ });
945
+
946
+ function setAll(open) {
947
+ cases.forEach(function (node) { if (!node.hidden) node.open = open; });
948
+ }
949
+ var expand = document.getElementById("expand");
950
+ var collapse = document.getElementById("collapse");
951
+ if (expand) expand.addEventListener("click", function () { setAll(true); });
952
+ if (collapse) collapse.addEventListener("click", function () { setAll(false); });
953
+
954
+ if (location.hash) {
955
+ var target = document.getElementById(location.hash.slice(1));
956
+ if (target) target.open = true;
957
+ }
958
+ document.addEventListener("click", function (event) {
959
+ var link = event.target.closest ? event.target.closest("a[href^='#case-']") : null;
960
+ if (!link) return;
961
+ var target = document.getElementById(link.getAttribute("href").slice(1));
962
+ if (target) target.open = true;
963
+ });
964
+ apply();
965
+ })();
966
+ `;
967
+ //# sourceMappingURL=report.js.map