web-tester-for-claude 0.4.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.
@@ -0,0 +1,667 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import type { RunPaths } from "../util/paths";
3
+ import type {
4
+ ConsoleEntry,
5
+ NetworkEntry,
6
+ PageErrorEntry
7
+ } from "./capture";
8
+ import type { InspectResult, StepReport } from "./run";
9
+ import type { Expectation } from "./verdict";
10
+
11
+ function describeExpectationHtml(e: Expectation): string {
12
+ if (e.kind === "text") return `<code>text=${esc(e.text)}</code>`;
13
+ if (e.kind === "no-text") return `<code>no-text=${esc(e.text)}</code>`;
14
+ if (e.kind === "selector") return `<code>selector=${esc(e.selector)}</code>`;
15
+ if (e.kind === "no-selector")
16
+ return `<code>no-selector=${esc(e.selector)}</code>`;
17
+ return `<code>attr ${esc(e.name)}=${esc(e.value)}</code>`;
18
+ }
19
+
20
+ function esc(s: string): string {
21
+ return s
22
+ .replace(/&/g, "&amp;")
23
+ .replace(/</g, "&lt;")
24
+ .replace(/>/g, "&gt;")
25
+ .replace(/"/g, "&quot;")
26
+ .replace(/'/g, "&#039;");
27
+ }
28
+
29
+ function consoleClass(type: string): string {
30
+ if (type === "error") return "log-error";
31
+ if (type === "warning") return "log-warn";
32
+ if (type === "info") return "log-info";
33
+ return "log-log";
34
+ }
35
+
36
+ function networkClass(entry: NetworkEntry): string {
37
+ if (entry.failureText) return "net-fail";
38
+ const status = entry.status ?? 0;
39
+ if (status >= 500) return "net-fail";
40
+ if (status >= 400) return "net-warn";
41
+ return "net-ok";
42
+ }
43
+
44
+ function shortenUrl(url: string, max = 80): string {
45
+ if (url.length <= max) return url;
46
+ const u = new URL(url);
47
+ const path = u.pathname + u.search;
48
+ if (path.length <= max - u.host.length - 3) return `${u.host}${path}`;
49
+ return `${u.host}${path.slice(0, max - u.host.length - 4)}…`;
50
+ }
51
+
52
+ function renderConsole(entries: ConsoleEntry[]): string {
53
+ if (!entries.length) return '<div class="empty">no console output</div>';
54
+ return entries
55
+ .map(
56
+ (e) =>
57
+ `<div class="log-row ${consoleClass(e.type)}">
58
+ <span class="log-type">${esc(e.type)}</span>
59
+ <span class="log-text">${esc(e.text)}</span>
60
+ ${e.location ? `<span class="log-loc">${esc(e.location)}</span>` : ""}
61
+ </div>`
62
+ )
63
+ .join("");
64
+ }
65
+
66
+ function renderNetworkBody(e: NetworkEntry): string {
67
+ const parts: string[] = [];
68
+ if (e.requestBody)
69
+ parts.push(
70
+ `<div class="body-label">request</div><pre class="net-pre">${esc(e.requestBody)}</pre>`
71
+ );
72
+ if (e.responseBody)
73
+ parts.push(
74
+ `<div class="body-label">response</div><pre class="net-pre">${esc(e.responseBody)}</pre>`
75
+ );
76
+ if (!parts.length) return "";
77
+ return `<details class="net-bodies"><summary>body</summary>${parts.join("")}</details>`;
78
+ }
79
+
80
+ function renderNetwork(entries: NetworkEntry[]): string {
81
+ if (!entries.length) return '<div class="empty">no network activity</div>';
82
+ return entries
83
+ .map((e) => {
84
+ const status = e.failureText
85
+ ? esc(e.failureText)
86
+ : e.status !== null
87
+ ? `${e.status}`
88
+ : "—";
89
+ const row = `<div class="net-row ${networkClass(e)}">
90
+ <span class="net-method">${esc(e.method)}</span>
91
+ <span class="net-status">${status}</span>
92
+ <span class="net-url" title="${esc(e.url)}">${esc(shortenUrl(e.url, 90))}</span>
93
+ ${e.durationMs !== null ? `<span class="net-time">${e.durationMs}ms</span>` : ""}
94
+ </div>`;
95
+ const body = renderNetworkBody(e);
96
+ return body ? `<div class="net-entry">${row}${body}</div>` : row;
97
+ })
98
+ .join("");
99
+ }
100
+
101
+ function renderDeepErrors(
102
+ deepErrors: InspectResult["deepErrors"],
103
+ rejections: InspectResult["unhandledRejections"]
104
+ ): string {
105
+ if (!deepErrors?.length && !rejections?.length) return "";
106
+ const errorBlocks = (deepErrors ?? [])
107
+ .map((e) => {
108
+ const scopes = e.scopes
109
+ .map((s) => {
110
+ const rows = Object.entries(s.vars)
111
+ .map(
112
+ ([k, v]) =>
113
+ `<tr><td>${esc(k)}</td><td><code>${esc(v)}</code></td></tr>`
114
+ )
115
+ .join("");
116
+ return `<div class="scope"><div class="scope-type">${esc(s.type)} scope</div>
117
+ <table class="attrs">${rows}</table></div>`;
118
+ })
119
+ .join("");
120
+ return `<div class="page-error">
121
+ <div class="page-error-msg">${esc(e.reason)}</div>
122
+ <div class="scope-meta">in <code>${esc(e.functionName)}</code>${e.location ? ` · ${esc(e.location)}` : ""}</div>
123
+ ${scopes}
124
+ </div>`;
125
+ })
126
+ .join("");
127
+ const rejectionBlock = rejections?.length
128
+ ? `<h3 style="font-size:11px; color: var(--muted); margin: 12px 0 6px;">unhandled rejections (${rejections.length})</h3>
129
+ ${rejections
130
+ .map((r) => `<div class="page-error"><div class="page-error-msg">${esc(r)}</div></div>`)
131
+ .join("")}`
132
+ : "";
133
+ return `<section class="card">
134
+ <h2>deep errors</h2>
135
+ ${errorBlocks}
136
+ ${rejectionBlock}
137
+ </section>`;
138
+ }
139
+
140
+ function renderPageErrors(errs: PageErrorEntry[]): string {
141
+ if (!errs.length) return "";
142
+ return errs
143
+ .map(
144
+ (e) =>
145
+ `<div class="page-error">
146
+ <div class="page-error-msg">${esc(e.message)}</div>
147
+ ${e.stack ? `<pre class="page-error-stack">${esc(e.stack)}</pre>` : ""}
148
+ </div>`
149
+ )
150
+ .join("");
151
+ }
152
+
153
+ function renderStep(step: StepReport): string {
154
+ const stateClass = step.ok ? "ok" : "fail";
155
+ const screenshot = step.screenshot
156
+ ? `<a class="thumb" href="${esc(step.screenshot)}" data-lightbox>
157
+ <img loading="lazy" src="${esc(step.screenshot)}" alt="step ${step.index}" />
158
+ </a>`
159
+ : '<div class="thumb empty">no screenshot</div>';
160
+ const consoleCount = step.console.length;
161
+ const networkCount = step.network.length;
162
+ const errorCount = step.pageErrors.length;
163
+ const consoleErrorCount = step.console.filter((c) => c.type === "error").length;
164
+ const failedNetCount = step.network.filter(
165
+ (n) => n.failureText !== null || (n.status !== null && n.status >= 400)
166
+ ).length;
167
+ const evalBlock =
168
+ step.evalResult !== undefined
169
+ ? `<details class="step-pane">
170
+ <summary>eval result</summary>
171
+ <pre>${esc(JSON.stringify(step.evalResult, null, 2))}</pre>
172
+ </details>`
173
+ : "";
174
+ const errBlock = step.error
175
+ ? `<div class="step-error">${esc(step.error)}</div>`
176
+ : "";
177
+ return `<article class="step step-${stateClass}" id="step-${step.index}" data-step="${step.index}">
178
+ <header class="step-head">
179
+ <span class="step-index">${step.index}</span>
180
+ <span class="step-state">${step.ok ? "ok" : "fail"}</span>
181
+ <span class="step-label">${esc(step.label)}</span>
182
+ <span class="step-duration">${step.durationMs}ms</span>
183
+ </header>
184
+ <div class="step-url">${esc(step.url)}</div>
185
+ ${errBlock}
186
+ <div class="step-body">
187
+ ${screenshot}
188
+ <div class="step-panes">
189
+ <details class="step-pane" ${consoleErrorCount > 0 ? "open" : ""}>
190
+ <summary>
191
+ console <span class="badge">${consoleCount}</span>
192
+ ${consoleErrorCount > 0 ? `<span class="badge badge-err">${consoleErrorCount} err</span>` : ""}
193
+ </summary>
194
+ <div class="log-list">${renderConsole(step.console)}</div>
195
+ </details>
196
+ <details class="step-pane" ${failedNetCount > 0 ? "open" : ""}>
197
+ <summary>
198
+ network <span class="badge">${networkCount}</span>
199
+ ${failedNetCount > 0 ? `<span class="badge badge-err">${failedNetCount} failed</span>` : ""}
200
+ </summary>
201
+ <div class="net-list">${renderNetwork(step.network)}</div>
202
+ </details>
203
+ ${
204
+ errorCount > 0
205
+ ? `<details class="step-pane" open>
206
+ <summary>page errors <span class="badge badge-err">${errorCount}</span></summary>
207
+ <div class="errors-list">${renderPageErrors(step.pageErrors)}</div>
208
+ </details>`
209
+ : ""
210
+ }
211
+ ${evalBlock}
212
+ </div>
213
+ </div>
214
+ </article>`;
215
+ }
216
+
217
+ /**
218
+ * Tiny markdown renderer for the Sonnet summary block. Covers only what the
219
+ * prompt asks for: paragraphs, `- ` bullet lists, `**bold**`, inline `code`.
220
+ * We escape first, then re-introduce the formatting markers, so no raw HTML
221
+ * from the model output ever reaches the page.
222
+ */
223
+ function renderSummaryMarkdown(raw: string): string {
224
+ const lines = raw.replace(/\r/g, "").split("\n");
225
+ const blocks: string[] = [];
226
+ let para: string[] = [];
227
+ let bullets: string[] = [];
228
+
229
+ const flushPara = (): void => {
230
+ if (!para.length) return;
231
+ blocks.push(`<p>${formatInline(para.join(" "))}</p>`);
232
+ para = [];
233
+ };
234
+ const flushBullets = (): void => {
235
+ if (!bullets.length) return;
236
+ blocks.push(
237
+ `<ul>${bullets.map((b) => `<li>${formatInline(b)}</li>`).join("")}</ul>`
238
+ );
239
+ bullets = [];
240
+ };
241
+
242
+ for (const line of lines) {
243
+ const trimmed = line.trim();
244
+ if (!trimmed) {
245
+ flushPara();
246
+ flushBullets();
247
+ continue;
248
+ }
249
+ const bullet = trimmed.match(/^[-*]\s+(.*)$/);
250
+ if (bullet?.[1]) {
251
+ flushPara();
252
+ bullets.push(bullet[1]);
253
+ continue;
254
+ }
255
+ flushBullets();
256
+ para.push(trimmed);
257
+ }
258
+ flushPara();
259
+ flushBullets();
260
+ return blocks.join("");
261
+ }
262
+
263
+ function formatInline(text: string): string {
264
+ let out = esc(text);
265
+ out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
266
+ out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
267
+ return out;
268
+ }
269
+
270
+ function renderAttrs(
271
+ attrs: { name: string; value: string; label: string }[]
272
+ ): string {
273
+ if (!attrs.length) return '<div class="empty">no data-attr-* markers on page</div>';
274
+ return `<table class="attrs">
275
+ <thead><tr><th>name</th><th>value</th><th>label</th></tr></thead>
276
+ <tbody>
277
+ ${attrs
278
+ .map(
279
+ (a) =>
280
+ `<tr><td>${esc(a.name)}</td><td>${esc(a.value)}</td><td>${esc(a.label)}</td></tr>`
281
+ )
282
+ .join("")}
283
+ </tbody>
284
+ </table>`;
285
+ }
286
+
287
+ const CSS = `
288
+ :root {
289
+ --bg: #fafaf9;
290
+ --surface: #ffffff;
291
+ --border: #e7e5e4;
292
+ --border-strong: #d6d3d1;
293
+ --text: #18181b;
294
+ --muted: #57534e;
295
+ --subtle: #a8a29e;
296
+ --err: #b91c1c;
297
+ --err-bg: #fef2f2;
298
+ --warn: #a16207;
299
+ }
300
+ * { box-sizing: border-box; }
301
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); }
302
+ body { font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
303
+ a { color: var(--text); text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 2px; }
304
+ a:hover { text-decoration-color: var(--text); }
305
+ code, .mono { font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
306
+ code { background: var(--bg); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); }
307
+ pre { font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg); color: var(--text); padding: 10px 12px; border-radius: 4px; border: 1px solid var(--border); overflow: auto; margin: 6px 0 0; max-height: 360px; }
308
+ details summary { cursor: pointer; user-select: none; }
309
+ details summary::-webkit-details-marker { display: none; }
310
+ details summary::before { content: "›"; display: inline-block; width: 1em; color: var(--subtle); transition: transform 0.1s; font-weight: 600; }
311
+ details[open] > summary::before { transform: rotate(90deg); }
312
+
313
+ .layout { display: grid; grid-template-columns: minmax(0, 1fr); max-width: 1240px; margin: 0 auto; padding: 28px 24px; gap: 24px; }
314
+ @media (min-width: 1100px) { .layout { grid-template-columns: 340px minmax(0, 1fr); } }
315
+
316
+ header.top { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 10px; padding-bottom: 18px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
317
+ header.top h1 { font-size: 20px; font-weight: 600; margin: 0; letter-spacing: -0.01em; line-height: 1.3; }
318
+ header.top h1 .verdict { font-size: 12px; font-weight: 500; color: var(--muted); margin-left: 8px; }
319
+ header.top h1 .verdict-fail { color: var(--err); }
320
+ .meta { color: var(--muted); font-size: 12px; }
321
+ .meta code { background: transparent; border: 0; padding: 0; color: var(--text); }
322
+ .meta .sep { color: var(--subtle); margin: 0 4px; }
323
+
324
+ .totals { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 6px; font-size: 13px; }
325
+ .totals .stat { color: var(--muted); }
326
+ .totals .stat strong { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
327
+ .totals .stat-err strong { color: var(--err); }
328
+
329
+ .side { position: sticky; top: 20px; align-self: start; display: flex; flex-direction: column; gap: 14px; max-height: calc(100vh - 40px); overflow-y: auto; }
330
+ .video-card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px; }
331
+ .video-card video { width: 100%; border-radius: 3px; background: #000; display: block; }
332
+ .video-controls { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 11px; color: var(--muted); flex-wrap: wrap; }
333
+ .video-controls button { padding: 2px 8px; border: 1px solid var(--border); background: var(--surface); color: var(--muted); border-radius: 3px; cursor: pointer; font: inherit; font-size: 11px; font-variant-numeric: tabular-nums; }
334
+ .video-controls button:hover { color: var(--text); border-color: var(--border-strong); }
335
+ .video-controls button.active { background: var(--text); color: var(--surface); border-color: var(--text); }
336
+
337
+ .timeline { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 8px 4px; }
338
+ .timeline h3 { font-size: 11px; font-weight: 600; color: var(--muted); margin: 4px 8px 4px; letter-spacing: 0; }
339
+ .timeline a { display: flex; gap: 8px; padding: 5px 8px; border-radius: 3px; color: var(--text); font-size: 12px; align-items: center; text-decoration: none; }
340
+ .timeline a:hover { background: var(--bg); }
341
+ .timeline a.fail { color: var(--err); }
342
+ .timeline a .idx { color: var(--subtle); width: 1.5em; text-align: right; font-variant-numeric: tabular-nums; }
343
+ .timeline a .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
344
+ .timeline a .ms { color: var(--subtle); font-size: 11px; font-variant-numeric: tabular-nums; }
345
+
346
+ .main { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
347
+ .summary-card { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--text); border-radius: 6px; padding: 16px 18px; }
348
+ .summary-card .summary-tag { font-size: 11px; font-weight: 600; color: var(--muted); margin: 0 0 8px; letter-spacing: 0.02em; text-transform: uppercase; display: flex; align-items: center; gap: 6px; }
349
+ .summary-card .summary-tag::before { content: "✱"; color: var(--subtle); }
350
+ .summary-card .summary-body { font-size: 14px; line-height: 1.6; color: var(--text); }
351
+ .summary-card .summary-body p { margin: 0 0 8px; }
352
+ .summary-card .summary-body p:last-child { margin-bottom: 0; }
353
+ .summary-card .summary-body strong { font-weight: 600; }
354
+ .summary-card .summary-body ul { margin: 4px 0 8px; padding-left: 20px; }
355
+ .summary-card .summary-body li { margin: 2px 0; }
356
+ .summary-card .summary-body code { background: var(--bg); }
357
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 16px 18px; }
358
+ .card h2 { font-size: 12px; font-weight: 600; color: var(--muted); margin: 0 0 12px; letter-spacing: 0.02em; text-transform: uppercase; }
359
+ .urls { font-size: 13px; color: var(--text); display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; }
360
+ .urls dt { color: var(--muted); font-size: 12px; }
361
+ .urls dd { margin: 0; word-break: break-all; }
362
+
363
+ .snapshot-pair { display: grid; grid-template-columns: 1fr; gap: 12px; }
364
+ @media (min-width: 700px) { .snapshot-pair { grid-template-columns: 1fr 1fr; } }
365
+ .snapshot h3 { font-size: 11px; font-weight: 600; color: var(--muted); margin: 0 0 6px; }
366
+ .snapshot img { width: 100%; border-radius: 3px; border: 1px solid var(--border); cursor: zoom-in; display: block; }
367
+
368
+ .step { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 14px 16px; scroll-margin-top: 12px; }
369
+ .step-fail { border-color: var(--err); background: var(--err-bg); }
370
+ .step-head { display: flex; align-items: baseline; gap: 10px; }
371
+ .step-index { color: var(--subtle); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; min-width: 1.5em; }
372
+ .step-state { display: none; }
373
+ .step-fail .step-state { display: inline; font-size: 11px; font-weight: 600; color: var(--err); text-transform: uppercase; letter-spacing: 0.04em; }
374
+ .step-label { font-weight: 500; flex: 1; min-width: 0; overflow-wrap: break-word; }
375
+ .step-duration { color: var(--subtle); font-size: 11px; font-variant-numeric: tabular-nums; }
376
+ .step-url { color: var(--muted); font-size: 11px; margin: 6px 0 0; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
377
+ .step-error { color: var(--err); padding: 8px 10px; margin: 8px 0 0; border: 1px solid var(--err); background: var(--surface); border-radius: 3px; font: 12px/1.5 ui-monospace, monospace; }
378
+
379
+ .step-body { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 12px; }
380
+ @media (min-width: 800px) { .step-body { grid-template-columns: 220px minmax(0, 1fr); } }
381
+ .thumb { display: block; }
382
+ .thumb img { width: 100%; border-radius: 3px; border: 1px solid var(--border); cursor: zoom-in; display: block; background: #000; }
383
+ .thumb.empty { background: var(--bg); color: var(--subtle); padding: 30px; border-radius: 3px; border: 1px dashed var(--border); text-align: center; font-style: italic; font-size: 12px; }
384
+
385
+ .step-panes { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
386
+ .step-pane summary { font-size: 12px; padding: 4px 0; color: var(--muted); display: flex; align-items: center; gap: 6px; }
387
+ .step-pane .badge { color: var(--muted); padding: 0 4px; font-size: 11px; font-variant-numeric: tabular-nums; }
388
+ .step-pane .badge-err { color: var(--err); font-weight: 600; }
389
+ .step-pane[open] > summary { color: var(--text); }
390
+
391
+ .log-list, .net-list { background: var(--bg); border: 1px solid var(--border); border-radius: 3px; padding: 6px; max-height: 280px; overflow: auto; font: 11px/1.5 ui-monospace, monospace; }
392
+ .log-row { display: grid; grid-template-columns: 60px 1fr; gap: 8px; padding: 2px 4px; border-radius: 2px; }
393
+ .log-row .log-type { color: var(--subtle); font-weight: 600; text-transform: lowercase; }
394
+ .log-row.log-error .log-type { color: var(--err); }
395
+ .log-row.log-warn .log-type { color: var(--warn); }
396
+ .log-row .log-loc { grid-column: 2; color: var(--subtle); font-size: 10px; }
397
+ .log-text { word-break: break-word; color: var(--text); }
398
+
399
+ .net-row { display: grid; grid-template-columns: 50px 60px 1fr 60px; gap: 8px; padding: 2px 4px; border-radius: 2px; align-items: center; }
400
+ .net-row .net-method { color: var(--muted); font-weight: 600; }
401
+ .net-row .net-status { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
402
+ .net-row.net-warn .net-status { color: var(--warn); }
403
+ .net-row.net-fail .net-status { color: var(--err); }
404
+ .net-row .net-url { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
405
+ .net-row .net-time { color: var(--subtle); font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
406
+ .net-entry { display: flex; flex-direction: column; }
407
+ .net-bodies { margin: 0 0 4px 6px; }
408
+ .net-bodies summary { font-size: 11px; color: var(--subtle); padding: 2px 0; }
409
+ .net-bodies .body-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--subtle); margin: 6px 0 2px; }
410
+ .net-pre { max-height: 220px; margin: 0; }
411
+
412
+ .page-error { border-left: 2px solid var(--err); padding: 6px 10px; margin: 6px 0; background: var(--err-bg); border-radius: 0 3px 3px 0; }
413
+ .page-error-msg { color: var(--err); font-weight: 500; }
414
+ .page-error-stack { background: var(--surface); border: 1px solid var(--border); color: var(--text); margin-top: 6px; font-size: 10px; max-height: 200px; }
415
+ .scope-meta { color: var(--muted); font-size: 11px; margin: 4px 0 6px; }
416
+ .scope { margin: 6px 0 0; }
417
+ .scope-type { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 6px 0 2px; }
418
+ .scope .attrs code { background: var(--bg); }
419
+
420
+ .attrs { width: 100%; border-collapse: collapse; font-size: 12px; }
421
+ .attrs th, .attrs td { padding: 4px 8px; border-bottom: 1px solid var(--border); text-align: left; }
422
+ .attrs th { color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
423
+ .empty { color: var(--muted); font-style: italic; padding: 6px 0; font-size: 12px; }
424
+
425
+ .global-logs { display: grid; grid-template-columns: 1fr; gap: 12px; }
426
+ @media (min-width: 800px) { .global-logs { grid-template-columns: 1fr 1fr; } }
427
+
428
+ .verdict-card { border-left: 3px solid #15803d; }
429
+ .verdict-card-fail { border-left-color: var(--err); background: var(--err-bg); }
430
+ .verdict-card h2 { display: flex; align-items: center; gap: 8px; }
431
+ .verdict-card .vd-ok { color: #15803d; font-weight: 700; text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; }
432
+ .verdict-card .vd-fail { color: var(--err); font-weight: 700; text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; }
433
+ .verdict-triggers { margin: 0 0 10px; padding-left: 18px; color: var(--err); font-size: 13px; }
434
+
435
+ /* Lightbox */
436
+ #lightbox { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: none; align-items: center; justify-content: center; z-index: 1000; cursor: zoom-out; padding: 24px; }
437
+ #lightbox.open { display: flex; }
438
+ #lightbox img { max-width: 100%; max-height: 100%; border-radius: 4px; }
439
+ `;
440
+
441
+ const JS = `
442
+ // Lightbox
443
+ const lb = document.getElementById('lightbox');
444
+ const lbImg = lb.querySelector('img');
445
+ document.querySelectorAll('[data-lightbox], .snapshot img').forEach((el) => {
446
+ const handler = (e) => {
447
+ e.preventDefault();
448
+ const src = el.tagName === 'A' ? el.getAttribute('href') : el.getAttribute('src');
449
+ lbImg.src = src;
450
+ lb.classList.add('open');
451
+ };
452
+ el.addEventListener('click', handler);
453
+ });
454
+ lb.addEventListener('click', () => { lb.classList.remove('open'); lbImg.src = ''; });
455
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { lb.classList.remove('open'); lbImg.src = ''; } });
456
+
457
+ // Video speed control
458
+ const video = document.querySelector('.video-card video');
459
+ if (video) {
460
+ const RATES = [0.5, 1, 1.5, 2.5, 4];
461
+ const DEFAULT_RATE = 2.5;
462
+ const setRate = (rate) => {
463
+ video.playbackRate = rate;
464
+ document.querySelectorAll('.video-controls .rate').forEach((b) => {
465
+ b.classList.toggle('active', Number(b.dataset.rate) === rate);
466
+ });
467
+ };
468
+ video.addEventListener('loadedmetadata', () => setRate(DEFAULT_RATE));
469
+ video.addEventListener('play', () => { video.playbackRate = video.playbackRate || DEFAULT_RATE; }, { once: true });
470
+ document.querySelectorAll('.video-controls .rate').forEach((b) => {
471
+ b.addEventListener('click', () => setRate(Number(b.dataset.rate)));
472
+ });
473
+ }
474
+ `;
475
+
476
+ export function writeReport(result: InspectResult, paths: RunPaths): void {
477
+ writeFileSync(paths.resultPath, JSON.stringify(result, null, 2));
478
+
479
+ const hasGate =
480
+ result.expectations.length > 0 || result.failOn.length > 0;
481
+ const verdictText = result.ok
482
+ ? hasGate
483
+ ? "pass"
484
+ : "completed"
485
+ : "fail";
486
+ const verdictClass = result.ok ? "" : "verdict-fail";
487
+
488
+ const verdictBlock =
489
+ result.verdictTriggers.length > 0 || result.expectations.length > 0
490
+ ? `<section class="card verdict-card ${result.ok ? "" : "verdict-card-fail"}">
491
+ <h2>verdict ${result.ok ? "<span class=\"vd-ok\">pass</span>" : "<span class=\"vd-fail\">fail</span>"}</h2>
492
+ ${
493
+ result.verdictTriggers.length > 0
494
+ ? `<ul class="verdict-triggers">${result.verdictTriggers
495
+ .map((t) => `<li>${esc(t)}</li>`)
496
+ .join("")}</ul>`
497
+ : ""
498
+ }
499
+ ${
500
+ result.expectations.length > 0
501
+ ? `<table class="attrs">
502
+ <thead><tr><th>expect</th><th>result</th><th>detail</th></tr></thead>
503
+ <tbody>${result.expectations
504
+ .map((r) => {
505
+ const desc = describeExpectationHtml(r.expectation);
506
+ return `<tr><td>${desc}</td><td>${r.ok ? "<span class=\"vd-ok\">pass</span>" : "<span class=\"vd-fail\">fail</span>"}</td><td>${esc(r.detail ?? "")}</td></tr>`;
507
+ })
508
+ .join("")}</tbody>
509
+ </table>`
510
+ : ""
511
+ }
512
+ </section>`
513
+ : "";
514
+ const consoleErr = result.console.totals.error ?? 0;
515
+ const consoleWarn = result.console.totals.warning ?? 0;
516
+
517
+ const rates = [0.5, 1, 1.5, 2.5, 4];
518
+ const videoBlock = result.video
519
+ ? `<aside class="video-card">
520
+ <video controls preload="metadata" src="${esc(result.video)}"></video>
521
+ <div class="video-controls">
522
+ <span>speed</span>
523
+ ${rates.map((r) => `<button class="rate" data-rate="${r}">${r}x</button>`).join("")}
524
+ </div>
525
+ </aside>`
526
+ : "";
527
+
528
+ const timeline = result.steps.length
529
+ ? `<nav class="timeline">
530
+ <h3>steps</h3>
531
+ ${result.steps
532
+ .map(
533
+ (s) => `<a href="#step-${s.index}" class="${s.ok ? "" : "fail"}">
534
+ <span class="dot"></span>
535
+ <span class="idx">${s.index}</span>
536
+ <span class="lbl">${esc(s.label)}</span>
537
+ <span class="ms">${s.durationMs}ms</span>
538
+ </a>`
539
+ )
540
+ .join("")}
541
+ </nav>`
542
+ : "";
543
+
544
+ const html = `<!doctype html>
545
+ <html lang="en"><head>
546
+ <meta charset="utf-8">
547
+ <meta name="viewport" content="width=device-width,initial-scale=1">
548
+ <title>web-tester · ${esc(result.requestedUrl)}</title>
549
+ <style>${CSS}</style>
550
+ </head><body>
551
+ <div class="layout">
552
+ <header class="top">
553
+ <h1>
554
+ ${esc(result.title || result.requestedUrl)}
555
+ <span class="verdict ${verdictClass}">${verdictText}</span>
556
+ </h1>
557
+ <div class="meta">
558
+ <code>${esc(result.runId)}</code><span class="sep">·</span>
559
+ <code>${esc(result.baseUrl)}</code><span class="sep">·</span>
560
+ ${esc(result.startedAt)}<span class="sep">·</span>
561
+ ${result.durationMs}ms
562
+ </div>
563
+ <div class="totals">
564
+ <div class="stat"><strong>${result.steps.length}</strong> steps</div>
565
+ <div class="stat ${result.failedSteps > 0 ? "stat-err" : ""}"><strong>${result.failedSteps}</strong> failed</div>
566
+ <div class="stat"><strong>${result.network.count}</strong> network</div>
567
+ <div class="stat ${result.network.failedCount > 0 ? "stat-err" : ""}"><strong>${result.network.failedCount}</strong> 4xx</div>
568
+ <div class="stat ${consoleErr > 0 ? "stat-err" : ""}"><strong>${consoleErr}</strong> console errors</div>
569
+ <div class="stat"><strong>${consoleWarn}</strong> warnings</div>
570
+ <div class="stat ${result.pageErrors.length > 0 ? "stat-err" : ""}"><strong>${result.pageErrors.length}</strong> page errors</div>
571
+ </div>
572
+ </header>
573
+
574
+ <div class="side">
575
+ ${videoBlock}
576
+ ${timeline}
577
+ </div>
578
+
579
+ <div class="main">
580
+ ${verdictBlock}
581
+ ${
582
+ result.summary
583
+ ? `<section class="summary-card">
584
+ <div class="summary-tag">summary</div>
585
+ <div class="summary-body">${renderSummaryMarkdown(result.summary)}</div>
586
+ </section>`
587
+ : ""
588
+ }
589
+ <section class="card">
590
+ <h2>URLs</h2>
591
+ <dl class="urls">
592
+ <dt>requested</dt><dd><a href="${esc(result.requestedUrl)}" target="_blank">${esc(result.requestedUrl)}</a></dd>
593
+ <dt>final</dt><dd><a href="${esc(result.finalUrl)}" target="_blank">${esc(result.finalUrl)}</a></dd>
594
+ </dl>
595
+ </section>
596
+
597
+ <section class="card">
598
+ <h2>snapshots</h2>
599
+ <div class="snapshot-pair">
600
+ <div class="snapshot">
601
+ <h3>initial</h3>
602
+ <img src="${esc(result.initial.screenshot)}" alt="initial" />
603
+ </div>
604
+ <div class="snapshot">
605
+ <h3>final</h3>
606
+ <img src="${esc(result.final.screenshot)}" alt="final" />
607
+ </div>
608
+ </div>
609
+ <details style="margin-top: 12px;">
610
+ <summary>data-attr-* (initial / final)</summary>
611
+ <div class="snapshot-pair" style="margin-top: 8px;">
612
+ <div>
613
+ <h3 style="font-size:11px; color: var(--muted); margin: 0 0 4px;">initial (${result.initial.attrs.length})</h3>
614
+ ${renderAttrs(result.initial.attrs)}
615
+ </div>
616
+ <div>
617
+ <h3 style="font-size:11px; color: var(--muted); margin: 0 0 4px;">final (${result.final.attrs.length})</h3>
618
+ ${renderAttrs(result.final.attrs)}
619
+ </div>
620
+ </div>
621
+ </details>
622
+ </section>
623
+
624
+ ${
625
+ result.steps.length
626
+ ? `<section class="card">
627
+ <h2>steps</h2>
628
+ <div class="step-list" style="display: flex; flex-direction: column; gap: 12px;">
629
+ ${result.steps.map(renderStep).join("")}
630
+ </div>
631
+ </section>`
632
+ : ""
633
+ }
634
+
635
+ ${
636
+ result.pageErrors.length
637
+ ? `<section class="card">
638
+ <h2>page errors</h2>
639
+ ${renderPageErrors(result.pageErrors)}
640
+ </section>`
641
+ : ""
642
+ }
643
+
644
+ ${renderDeepErrors(result.deepErrors, result.unhandledRejections)}
645
+
646
+ <section class="card">
647
+ <h2>global logs</h2>
648
+ <div class="global-logs">
649
+ <details>
650
+ <summary>console (${result.console.entries.length})</summary>
651
+ <div class="log-list" style="max-height: 480px;">${renderConsole(result.console.entries)}</div>
652
+ </details>
653
+ <details>
654
+ <summary>network (${result.network.entries.length})</summary>
655
+ <div class="net-list" style="max-height: 480px;">${renderNetwork(result.network.entries)}</div>
656
+ </details>
657
+ </div>
658
+ </section>
659
+ </div>
660
+ </div>
661
+
662
+ <div id="lightbox"><img alt="" /></div>
663
+ <script>${JS}</script>
664
+ </body></html>`;
665
+
666
+ writeFileSync(paths.reportHtmlPath, html);
667
+ }