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.
- package/LICENSE +21 -0
- package/README.md +651 -0
- package/bin/web-tester.js +35 -0
- package/package.json +64 -0
- package/src/browser/attrs.ts +79 -0
- package/src/browser/session.ts +139 -0
- package/src/cli.ts +1488 -0
- package/src/impact.ts +165 -0
- package/src/init.ts +260 -0
- package/src/inspector/capture.ts +293 -0
- package/src/inspector/deep.ts +147 -0
- package/src/inspector/packs.ts +98 -0
- package/src/inspector/report.ts +667 -0
- package/src/inspector/run.ts +544 -0
- package/src/inspector/steps.ts +380 -0
- package/src/inspector/summarise.ts +178 -0
- package/src/inspector/verdict.ts +275 -0
- package/src/journeys.ts +78 -0
- package/src/kb.ts +84 -0
- package/src/map/classify.ts +149 -0
- package/src/map/crawl.ts +394 -0
- package/src/map/generate.ts +253 -0
- package/src/map/report.ts +112 -0
- package/src/map/run.ts +219 -0
- package/src/sitemap.ts +75 -0
- package/src/sweep.ts +476 -0
- package/src/templates/agent-section.md +77 -0
- package/src/templates/dot-web-tester/impact-rules.json +36 -0
- package/src/templates/dot-web-tester/instructions/getting-started.md +62 -0
- package/src/templates/dot-web-tester/instructions/recipes.md +105 -0
- package/src/templates/dot-web-tester/journeys/example-signup.json +17 -0
- package/src/templates/dot-web-tester/urls-smoke.txt +19 -0
- package/src/templates/skill.md +59 -0
- package/src/util/log.ts +26 -0
- package/src/util/paths.ts +141 -0
- package/src/util/prompt.ts +50 -0
- package/tsconfig.json +14 -0
|
@@ -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, "&")
|
|
23
|
+
.replace(/</g, "<")
|
|
24
|
+
.replace(/>/g, ">")
|
|
25
|
+
.replace(/"/g, """)
|
|
26
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|