chrome-e2e-sdk 0.0.0-stage → 1.0.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +739 -2
  3. package/dist/assertion/Assertion.d.ts +87 -0
  4. package/dist/assertion/Assertion.d.ts.map +1 -0
  5. package/dist/assertion/Assertion.js +459 -0
  6. package/dist/assertion/Assertion.js.map +1 -0
  7. package/dist/assertion/recorder.d.ts +41 -0
  8. package/dist/assertion/recorder.d.ts.map +1 -0
  9. package/dist/assertion/recorder.js +37 -0
  10. package/dist/assertion/recorder.js.map +1 -0
  11. package/dist/browser/Browser.d.ts +44 -0
  12. package/dist/browser/Browser.d.ts.map +1 -0
  13. package/dist/browser/Browser.js +167 -0
  14. package/dist/browser/Browser.js.map +1 -0
  15. package/dist/browser/Locator.d.ts +94 -0
  16. package/dist/browser/Locator.d.ts.map +1 -0
  17. package/dist/browser/Locator.js +533 -0
  18. package/dist/browser/Locator.js.map +1 -0
  19. package/dist/browser/Page.d.ts +272 -0
  20. package/dist/browser/Page.d.ts.map +1 -0
  21. package/dist/browser/Page.js +979 -0
  22. package/dist/browser/Page.js.map +1 -0
  23. package/dist/client/MCPClient.d.ts +72 -0
  24. package/dist/client/MCPClient.d.ts.map +1 -0
  25. package/dist/client/MCPClient.js +302 -0
  26. package/dist/client/MCPClient.js.map +1 -0
  27. package/dist/client/MCPCommand.d.ts +51 -0
  28. package/dist/client/MCPCommand.d.ts.map +1 -0
  29. package/dist/client/MCPCommand.js +74 -0
  30. package/dist/client/MCPCommand.js.map +1 -0
  31. package/dist/env/loadEnv.d.ts +8 -0
  32. package/dist/env/loadEnv.d.ts.map +1 -0
  33. package/dist/env/loadEnv.js +19 -0
  34. package/dist/env/loadEnv.js.map +1 -0
  35. package/dist/errors/E2EError.d.ts +50 -0
  36. package/dist/errors/E2EError.d.ts.map +1 -0
  37. package/dist/errors/E2EError.js +90 -0
  38. package/dist/errors/E2EError.js.map +1 -0
  39. package/dist/index.d.ts +32 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +37 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/logger.d.ts +12 -0
  44. package/dist/logger.d.ts.map +1 -0
  45. package/dist/logger.js +57 -0
  46. package/dist/logger.js.map +1 -0
  47. package/dist/network/Network.d.ts +72 -0
  48. package/dist/network/Network.d.ts.map +1 -0
  49. package/dist/network/Network.js +236 -0
  50. package/dist/network/Network.js.map +1 -0
  51. package/dist/test/HtmlReport.d.ts +26 -0
  52. package/dist/test/HtmlReport.d.ts.map +1 -0
  53. package/dist/test/HtmlReport.js +653 -0
  54. package/dist/test/HtmlReport.js.map +1 -0
  55. package/dist/test/Runner.d.ts +146 -0
  56. package/dist/test/Runner.d.ts.map +1 -0
  57. package/dist/test/Runner.js +390 -0
  58. package/dist/test/Runner.js.map +1 -0
  59. package/dist/test/index.d.ts +4 -0
  60. package/dist/test/index.d.ts.map +1 -0
  61. package/dist/test/index.js +3 -0
  62. package/dist/test/index.js.map +1 -0
  63. package/dist/version.d.ts +2 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +4 -0
  66. package/dist/version.js.map +1 -0
  67. package/package.json +63 -4
@@ -0,0 +1,653 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ /**
4
+ * How much diagnostic data to inline before deferring to summary.json.
5
+ * A report is for reading at a glance; the full payloads stay in the JSON.
6
+ */
7
+ const CONSOLE_LIMIT = 30;
8
+ const NETWORK_LIMIT = 20;
9
+ /** Longest URL rendered in full before it is clipped for display. */
10
+ const URL_DISPLAY_LIMIT = 160;
11
+ /** Passed assertions rendered per test; the rest are pointed at in the JSON. */
12
+ const CHECK_LIMIT = 30;
13
+ /**
14
+ * Screenshots above this size are linked by path rather than embedded, so a
15
+ * handful of full-page captures cannot turn the report into a 40 MB file.
16
+ */
17
+ const INLINE_SCREENSHOT_LIMIT = 1024 * 1024;
18
+ /**
19
+ * Renders the summary as a single self-contained HTML file.
20
+ *
21
+ * No build step and no CDN: the stylesheet and the small filter script are
22
+ * inlined, and the page works offline, which is what a CI artifact needs.
23
+ */
24
+ export async function renderHtmlReport(summary, options) {
25
+ const t = STRINGS[options.locale === 'en' ? 'en' : 'zh-CN'];
26
+ const embedded = new Map();
27
+ const rows = [];
28
+ for (const result of summary.results) {
29
+ const screenshot = result.screenshotPath
30
+ ? await describeScreenshot(result.screenshotPath, options.htmlPath, embedded, result.title, t)
31
+ : null;
32
+ rows.push({
33
+ title: result.title,
34
+ status: result.status,
35
+ durationMs: result.durationMs,
36
+ attempts: result.attempts,
37
+ failedAt: result.failedAt,
38
+ error: result.error,
39
+ skipReason: result.skipReason,
40
+ pageUrl: result.pageUrl,
41
+ expectedUrl: result.expectedUrl,
42
+ pageTitle: result.pageTitle,
43
+ loadState: typeof result.errorDetails?.['readyState'] === 'string'
44
+ ? result.errorDetails['readyState']
45
+ : undefined,
46
+ screenshot,
47
+ screenshotError: result.screenshotError,
48
+ console: pickConsole(result.consoleMessages),
49
+ network: pickNetwork(result.networkRequests),
50
+ checks: pickChecks(result.checks)
51
+ });
52
+ }
53
+ const html = `<!doctype html>
54
+ <html lang="${t.lang}">
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <meta name="viewport" content="width=device-width, initial-scale=1">
58
+ <title>${escapeHtml(t.title)}</title>
59
+ <style>${STYLES}</style>
60
+ </head>
61
+ <body>
62
+ <main>
63
+ <header class="head">
64
+ <h1>${escapeHtml(t.title)}</h1>
65
+ <p class="sub">${escapeHtml(formatTimestamp(summary))}</p>
66
+ </header>
67
+
68
+ <section class="cards" role="list">
69
+ ${card(t.total, summary.total, 'total')}
70
+ ${card(t.passed, summary.passed, 'passed')}
71
+ ${card(t.failed, summary.failed, 'failed')}
72
+ ${card(t.skipped, summary.skipped, 'skipped')}
73
+ </section>
74
+
75
+ ${summary.durationMs > 0 ? `<p class="duration">${escapeHtml(t.finishedIn)} ${formatDuration(summary.durationMs)}</p>` : ''}
76
+
77
+ <nav class="filters" aria-label="${escapeAttribute(t.filterLabel)}">
78
+ <button type="button" class="filter is-active" data-filter="all">${escapeHtml(t.all)} <span>${summary.total}</span></button>
79
+ <button type="button" class="filter" data-filter="failed">${escapeHtml(t.failed)} <span>${summary.failed}</span></button>
80
+ <button type="button" class="filter" data-filter="passed">${escapeHtml(t.passed)} <span>${summary.passed}</span></button>
81
+ <button type="button" class="filter" data-filter="skipped">${escapeHtml(t.skipped)} <span>${summary.skipped}</span></button>
82
+ </nav>
83
+
84
+ ${rows.length === 0 ? `<p class="empty">${escapeHtml(t.noTests)}</p>` : ''}
85
+
86
+ <ul class="tests">
87
+ ${rows.map((row, index) => renderTest(row, index, options.jsonPath, t)).join('\n')}
88
+ </ul>
89
+ </main>
90
+ <script>${SCRIPT}</script>
91
+ </body>
92
+ </html>
93
+ `;
94
+ return { html, embedded };
95
+ }
96
+ const ZH = {
97
+ lang: 'zh-CN',
98
+ title: '测试报告',
99
+ total: '总数',
100
+ passed: '通过',
101
+ failed: '失败',
102
+ skipped: '跳过',
103
+ all: '全部',
104
+ finishedIn: '总耗时',
105
+ error: '错误',
106
+ checks: '已通过的检查',
107
+ checkTarget: '对象',
108
+ basicInfo: '基本信息',
109
+ status: '状态',
110
+ statuses: { passed: '通过', failed: '失败', skipped: '跳过' },
111
+ duration: '耗时',
112
+ attemptsLabel: '尝试次数',
113
+ failedTime: '失败时间',
114
+ skipReason: '跳过原因',
115
+ skipReasons: { explicit: '用例被标记为 skip', focused: '存在 test.only,本用例未参与运行' },
116
+ page: '页面',
117
+ titleLabel: '标题',
118
+ url: '地址',
119
+ expectedUrl: '目标地址',
120
+ loadState: '加载状态',
121
+ screenshot: '失败截图',
122
+ notCaptured: '未能截取:',
123
+ fileMissing: '文件在磁盘上不存在。',
124
+ largeLinked: '文件较大,且位于报告目录之外,因此使用绝对路径引用。',
125
+ console: '控制台',
126
+ network: '网络',
127
+ attempts: (n) => `${n} 次尝试`,
128
+ showingFirst: (shown, total) => `仅显示前 ${shown} 条,共 ${total} 条。`,
129
+ fullDataIn: '完整数据见 JSON 汇总。',
130
+ fullDataInFile: (path) => `完整数据见 ${path}。`,
131
+ noTests: '本次没有执行任何用例。',
132
+ filterLabel: '筛选用例',
133
+ consoleLevels: { log: '日志', info: '信息', debug: '调试', warn: '警告', warning: '警告', error: '错误' },
134
+ statusLabels: { failed: '失败', pending: '等待' }
135
+ };
136
+ const EN = {
137
+ lang: 'en',
138
+ title: 'Test report',
139
+ total: 'Total',
140
+ passed: 'Passed',
141
+ failed: 'Failed',
142
+ skipped: 'Skipped',
143
+ all: 'All',
144
+ finishedIn: 'Finished in',
145
+ error: 'Error',
146
+ checks: 'Checks that passed',
147
+ checkTarget: 'Target',
148
+ basicInfo: 'Summary',
149
+ status: 'Status',
150
+ statuses: { passed: 'Passed', failed: 'Failed', skipped: 'Skipped' },
151
+ duration: 'Duration',
152
+ attemptsLabel: 'Attempts',
153
+ failedTime: 'Failed at',
154
+ skipReason: 'Skip reason',
155
+ skipReasons: { explicit: 'Marked with skip', focused: 'Excluded by a focused test.only' },
156
+ page: 'Page',
157
+ titleLabel: 'Title',
158
+ url: 'URL',
159
+ expectedUrl: 'Expected URL',
160
+ loadState: 'Load state',
161
+ screenshot: 'Screenshot',
162
+ notCaptured: 'Not captured: ',
163
+ fileMissing: 'File not found on disk.',
164
+ largeLinked: 'Large file, linked by absolute path because it is outside the report directory.',
165
+ console: 'Console',
166
+ network: 'Network',
167
+ attempts: (n) => `${n} attempts`,
168
+ showingFirst: (shown, total) => `Showing the first ${shown} of ${total}.`,
169
+ fullDataIn: 'Full data in the JSON summary.',
170
+ fullDataInFile: (path) => `Full data in ${path}.`,
171
+ noTests: 'No tests were run.',
172
+ filterLabel: 'Filter tests',
173
+ consoleLevels: { log: 'log', info: 'info', debug: 'debug', warn: 'warn', warning: 'warning', error: 'error' },
174
+ statusLabels: { failed: 'failed', pending: 'pending' }
175
+ };
176
+ const STRINGS = {
177
+ 'zh-CN': ZH,
178
+ en: EN
179
+ };
180
+ function renderTest(row, index, jsonPath, t) {
181
+ const details = [];
182
+ // Always present, so every row is openable whatever its status. A skipped
183
+ // test has no page behind it, and a green one is exactly the case a reader
184
+ // wants to inspect: did it really go where it claims?
185
+ const basics = [
186
+ `<div><dt>${escapeHtml(t.status)}</dt><dd>${escapeHtml(t.statuses[row.status])}</dd></div>`,
187
+ `<div><dt>${escapeHtml(t.duration)}</dt><dd>${formatDuration(row.durationMs)}</dd></div>`
188
+ ];
189
+ if (row.attempts !== undefined) {
190
+ basics.push(`<div><dt>${escapeHtml(t.attemptsLabel)}</dt><dd>${row.attempts}</dd></div>`);
191
+ }
192
+ if (row.failedAt) {
193
+ basics.push(`<div><dt>${escapeHtml(t.failedTime)}</dt><dd><time datetime="${escapeAttribute(row.failedAt)}">${escapeHtml(formatClock(row.failedAt))}</time></dd></div>`);
194
+ }
195
+ if (row.skipReason) {
196
+ basics.push(`<div><dt>${escapeHtml(t.skipReason)}</dt><dd>${escapeHtml(t.skipReasons[row.skipReason])}</dd></div>`);
197
+ }
198
+ details.push(`<h4>${escapeHtml(t.basicInfo)}</h4><dl class="kv">${basics.join('')}</dl>`);
199
+ if (row.error) {
200
+ details.push(`<h4>${escapeHtml(t.error)}</h4><pre class="err">${escapeHtml(row.error)}</pre>`);
201
+ }
202
+ if (row.pageUrl || row.pageTitle || row.expectedUrl || row.loadState) {
203
+ const parts = [];
204
+ if (row.pageTitle)
205
+ parts.push(`<div><dt>${escapeHtml(t.titleLabel)}</dt><dd>${escapeHtml(row.pageTitle)}</dd></div>`);
206
+ if (row.pageUrl)
207
+ parts.push(`<div><dt>${escapeHtml(t.url)}</dt>${urlCell(row.pageUrl)}</div>`);
208
+ // Shown only when it differs from pageUrl. A test that died mid-navigation
209
+ // otherwise reads as if it never left the previous page, which sends the
210
+ // reader looking at the wrong thing entirely.
211
+ if (row.expectedUrl && row.expectedUrl !== row.pageUrl) {
212
+ parts.push(`<div><dt>${escapeHtml(t.expectedUrl)}</dt>${urlCell(row.expectedUrl)}</div>`);
213
+ }
214
+ if (row.loadState) {
215
+ parts.push(`<div><dt>${escapeHtml(t.loadState)}</dt><dd>${escapeHtml(row.loadState)}</dd></div>`);
216
+ }
217
+ details.push(`<h4>${escapeHtml(t.page)}</h4><dl class="kv">${parts.join('')}</dl>`);
218
+ }
219
+ if (row.screenshot) {
220
+ const note = row.screenshot.note ? `<p class="note">${escapeHtml(row.screenshot.note)}</p>` : '';
221
+ details.push(`<h4>${escapeHtml(t.screenshot)}</h4>${note}<a href="${escapeAttribute(row.screenshot.src)}" target="_blank" rel="noreferrer"><img class="shot" src="${escapeAttribute(row.screenshot.src)}" alt="${escapeAttribute(t.screenshot)}" loading="lazy"></a>`);
222
+ }
223
+ else if (row.screenshotError) {
224
+ details.push(`<h4>${escapeHtml(t.screenshot)}</h4><p class="note">${escapeHtml(t.notCaptured)}${escapeHtml(row.screenshotError)}</p>`);
225
+ }
226
+ // After the error, so a red row leads with what went wrong. This is the only
227
+ // section a green row has beyond the basics, which is the point: a passing
228
+ // test should be able to show what it actually verified.
229
+ if (row.checks.rows.length > 0) {
230
+ details.push(renderChecks(row.checks, jsonPath, t));
231
+ }
232
+ if (row.console.rows.length > 0) {
233
+ details.push(renderConsole(row.console, jsonPath, t));
234
+ }
235
+ if (row.network.rows.length > 0) {
236
+ details.push(renderNetwork(row.network, jsonPath, t));
237
+ }
238
+ const [suite = row.title, ...rest] = row.title.split(' > ');
239
+ const canExpand = details.length > 0;
240
+ return `<li class="test" data-status="${row.status}" data-index="${index}">
241
+ <button type="button" class="row"${canExpand ? ' aria-expanded="false"' : ' disabled'}>
242
+ <span class="dot ${row.status}" aria-hidden="true"></span>
243
+ <span class="name"><span class="suite">${escapeHtml(suite)}</span>${rest.map((part) => `<span class="part">${escapeHtml(part)}</span>`).join('')}</span>
244
+ <span class="meta">${row.attempts && row.attempts > 1 ? `<span class="tag">${escapeHtml(t.attempts(row.attempts))}</span>` : ''}${row.failedAt ? `<time datetime="${escapeAttribute(row.failedAt)}">${escapeHtml(formatClock(row.failedAt))}</time>` : ''}<span class="dur">${formatDuration(row.durationMs)}</span></span>
245
+ </button>
246
+ ${canExpand ? `<div class="detail" hidden>${details.join('')}</div>` : ''}
247
+ </li>`;
248
+ }
249
+ /**
250
+ * Renders a URL, shortened when it is long.
251
+ *
252
+ * A data: URL carrying a whole HTML document runs to thousands of characters
253
+ * and buries everything around it. The full value stays in the title attribute
254
+ * and in summary.json; only the on-screen text is clipped.
255
+ */
256
+ function urlCell(url) {
257
+ const clipped = url.length > URL_DISPLAY_LIMIT ? `${url.slice(0, URL_DISPLAY_LIMIT)}…` : url;
258
+ return `<dd class="wrap" title="${escapeAttribute(url)}">${escapeHtml(clipped)}</dd>`;
259
+ }
260
+ function renderChecks(checks, jsonPath, t) {
261
+ const items = checks.rows
262
+ .map((row) => {
263
+ const expectation = `${row.negated ? 'not ' : ''}${row.expectation}`;
264
+ const target = row.target
265
+ ? `<span class="target">${escapeHtml(t.checkTarget)}: ${escapeHtml(row.target)}</span>`
266
+ : '';
267
+ return `<li><span class="method">${escapeHtml(row.method)}</span><span class="txt">${escapeHtml(expectation)}</span>${target}</li>`;
268
+ })
269
+ .join('');
270
+ return `<h4>${escapeHtml(t.checks)} <span class="count">${checks.total}</span></h4><ul class="log checks">${items}</ul>${truncationNote(checks.total, jsonPath, t, CHECK_LIMIT)}`;
271
+ }
272
+ function renderConsole(console, jsonPath, t) {
273
+ const items = console.rows
274
+ .map((row) => {
275
+ const level = row.type === 'error' ? 'error' : row.type === 'warn' || row.type === 'warning' ? 'warn' : '';
276
+ const label = t.consoleLevels[row.type] ?? row.type;
277
+ return `<li class="${level ? `is-${level}` : ''}"><span class="kind">${escapeHtml(label)}</span><span class="txt">${escapeHtml(row.text)}</span></li>`;
278
+ })
279
+ .join('');
280
+ return `<h4>${escapeHtml(t.console)} <span class="count">${console.total}</span></h4><ul class="log">${items}</ul>${truncationNote(console.total, jsonPath, t, CONSOLE_LIMIT)}`;
281
+ }
282
+ function renderNetwork(network, jsonPath, t) {
283
+ const items = network.rows
284
+ .map((row) => {
285
+ const status = row.status !== undefined
286
+ ? `<span class="code ${statusClass(row.status)}">${row.status}</span>`
287
+ : row.failure
288
+ ? `<span class="code err">${escapeHtml(t.statusLabels.failed)}</span>`
289
+ : `<span class="code pend">${escapeHtml(t.statusLabels.pending)}</span>`;
290
+ const failure = row.failure ? `<span class="fail">${escapeHtml(row.failure)}</span>` : '';
291
+ return `<li>${status}<span class="verb">${escapeHtml(row.method)}</span><span class="txt">${escapeHtml(row.url)}</span>${failure}</li>`;
292
+ })
293
+ .join('');
294
+ return `<h4>${escapeHtml(t.network)} <span class="count">${network.total}</span></h4><ul class="log net">${items}</ul>${truncationNote(network.total, jsonPath, t, NETWORK_LIMIT)}`;
295
+ }
296
+ function truncationNote(total, jsonPath, t, limit) {
297
+ if (total <= limit)
298
+ return '';
299
+ const where = jsonPath ? t.fullDataInFile(`<code>${escapeHtml(jsonPath)}</code>`) : escapeHtml(t.fullDataIn);
300
+ return `<p class="note">${escapeHtml(t.showingFirst(limit, total))} ${where}</p>`;
301
+ }
302
+ function card(label, value, kind) {
303
+ return `<div class="card ${kind}" role="listitem"><span class="n">${value}</span><span class="l">${escapeHtml(label)}</span></div>`;
304
+ }
305
+ /**
306
+ * Referencing a screenshot by a relative path keeps the report small, but only
307
+ * works while the report and the screenshots sit under a shared root. When they
308
+ * do not, the absolute path is used so the link still resolves.
309
+ */
310
+ async function describeScreenshot(absolutePath, htmlPath, embedded, key, t) {
311
+ const resolved = resolve(absolutePath);
312
+ const size = await stat(resolved).then((s) => s.size).catch(() => undefined);
313
+ if (size !== undefined && size <= INLINE_SCREENSHOT_LIMIT) {
314
+ const { readFile } = await import('node:fs/promises');
315
+ const bytes = await readFile(resolved);
316
+ const uri = `data:image/png;base64,${bytes.toString('base64')}`;
317
+ embedded.set(key, uri);
318
+ return { src: uri };
319
+ }
320
+ const fromHtml = relative(dirnameOf(htmlPath), resolved);
321
+ const usable = fromHtml && !fromHtml.startsWith('..') && !isAbsolute(fromHtml);
322
+ return {
323
+ src: usable ? fromHtml.split(sep).join('/') : resolved.split(sep).join('/'),
324
+ note: size === undefined ? t.fileMissing : usable ? undefined : t.largeLinked
325
+ };
326
+ }
327
+ function dirnameOf(filePath) {
328
+ const index = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf(sep));
329
+ return index <= 0 ? '.' : filePath.slice(0, index);
330
+ }
331
+ function pickChecks(checks) {
332
+ const all = Array.isArray(checks) ? checks : [];
333
+ const rows = all.slice(0, CHECK_LIMIT).flatMap((check) => {
334
+ if (typeof check !== 'object' || check === null)
335
+ return [];
336
+ return typeof check.method === 'string' && typeof check.expectation === 'string'
337
+ ? [{
338
+ method: check.method,
339
+ expectation: check.expectation,
340
+ target: typeof check.target === 'string' ? check.target : undefined,
341
+ negated: check.negated === true
342
+ }]
343
+ : [];
344
+ });
345
+ return { rows, total: all.length };
346
+ }
347
+ function pickConsole(messages) {
348
+ const all = Array.isArray(messages) ? messages : [];
349
+ const rows = all.slice(0, CONSOLE_LIMIT).flatMap((message) => {
350
+ if (typeof message !== 'object' || message === null)
351
+ return [];
352
+ const record = message;
353
+ return typeof record['type'] === 'string' && typeof record['text'] === 'string'
354
+ ? [{ type: record['type'], text: record['text'], id: typeof record['id'] === 'number' ? record['id'] : undefined }]
355
+ : [];
356
+ });
357
+ return { rows, total: all.length };
358
+ }
359
+ function pickNetwork(requests) {
360
+ const all = Array.isArray(requests) ? requests : [];
361
+ const rows = all.slice(0, NETWORK_LIMIT).flatMap((request) => {
362
+ if (typeof request !== 'object' || request === null)
363
+ return [];
364
+ const record = request;
365
+ if (typeof record['url'] !== 'string')
366
+ return [];
367
+ const response = record['response'];
368
+ const status = typeof record['status'] === 'number'
369
+ ? record['status']
370
+ : typeof response === 'object' && response !== null && typeof response['status'] === 'number'
371
+ ? (response['status'])
372
+ : undefined;
373
+ return [
374
+ {
375
+ method: typeof record['method'] === 'string' ? record['method'] : 'GET',
376
+ url: record['url'],
377
+ status,
378
+ resourceType: typeof record['resourceType'] === 'string' ? record['resourceType'] : undefined,
379
+ failure: typeof record['failure'] === 'string' ? record['failure'] : undefined
380
+ }
381
+ ];
382
+ });
383
+ return { rows, total: all.length };
384
+ }
385
+ function statusClass(status) {
386
+ if (status >= 500)
387
+ return 'err';
388
+ if (status >= 400)
389
+ return 'warn';
390
+ if (status >= 200 && status < 300)
391
+ return 'ok';
392
+ return 'pend';
393
+ }
394
+ function formatDuration(ms) {
395
+ if (ms < 1000)
396
+ return `${ms}ms`;
397
+ if (ms < 60000)
398
+ return `${(ms / 1000).toFixed(2)}s`;
399
+ return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
400
+ }
401
+ function formatTimestamp(summary) {
402
+ const failed = summary.results.find((result) => result.failedAt);
403
+ return failed?.failedAt ? new Date(failed.failedAt).toLocaleString() : new Date().toLocaleString();
404
+ }
405
+ function formatClock(iso) {
406
+ const date = new Date(iso);
407
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleTimeString();
408
+ }
409
+ function escapeHtml(value) {
410
+ return value
411
+ .replace(/&/g, '&amp;')
412
+ .replace(/</g, '&lt;')
413
+ .replace(/>/g, '&gt;');
414
+ }
415
+ function escapeAttribute(value) {
416
+ return escapeHtml(value).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
417
+ }
418
+ const STYLES = `
419
+ :root {
420
+ color-scheme: light dark;
421
+ --bg: #fbfbfd;
422
+ --surface: #ffffff;
423
+ --border: #e6e6ec;
424
+ --text: #16161a;
425
+ --muted: #6b6b76;
426
+ --accent: #3b5bdb;
427
+ --ok: #2f9e44;
428
+ --warn: #e8a33d;
429
+ --err: #e03131;
430
+ --shadow: 0 1px 2px rgba(16, 16, 24, .04), 0 8px 24px rgba(16, 16, 24, .04);
431
+ --radius: 12px;
432
+ }
433
+ @media (prefers-color-scheme: dark) {
434
+ :root {
435
+ --bg: #0d0d10;
436
+ --surface: #16161a;
437
+ --border: #26262d;
438
+ --text: #ededf0;
439
+ --muted: #9a9aa6;
440
+ --accent: #7d94f5;
441
+ --ok: #51cf66;
442
+ --warn: #ffc078;
443
+ --err: #ff6b6b;
444
+ --shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 24px rgba(0, 0, 0, .25);
445
+ }
446
+ }
447
+ * { box-sizing: border-box; }
448
+ body {
449
+ margin: 0;
450
+ padding: 40px 20px 80px;
451
+ background: var(--bg);
452
+ color: var(--text);
453
+ font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
454
+ "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
455
+ -webkit-font-smoothing: antialiased;
456
+ }
457
+ main { max-width: 940px; margin: 0 auto; }
458
+ .head { margin-bottom: 28px; }
459
+ h1 { margin: 0; font-size: 24px; font-weight: 650; letter-spacing: -.01em; }
460
+ .sub { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
461
+ .cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 12px; }
462
+ .card {
463
+ background: var(--surface);
464
+ border: 1px solid var(--border);
465
+ border-radius: var(--radius);
466
+ padding: 16px 18px;
467
+ box-shadow: var(--shadow);
468
+ }
469
+ .card .n { display: block; font-size: 28px; font-weight: 640; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
470
+ .card .l { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
471
+ .card.passed .n { color: var(--ok); }
472
+ .card.failed .n { color: var(--err); }
473
+ .card.skipped .n { color: var(--muted); }
474
+ .duration { margin: 0 0 24px; color: var(--muted); font-size: 13px; }
475
+ .filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
476
+ .filter {
477
+ font: inherit;
478
+ font-size: 13px;
479
+ color: var(--muted);
480
+ background: var(--surface);
481
+ border: 1px solid var(--border);
482
+ border-radius: 999px;
483
+ padding: 6px 14px;
484
+ cursor: pointer;
485
+ transition: color .15s, border-color .15s, background .15s;
486
+ }
487
+ .filter span { font-variant-numeric: tabular-nums; opacity: .65; margin-left: 4px; }
488
+ .filter:hover { color: var(--text); border-color: var(--muted); }
489
+ .filter.is-active { color: var(--accent); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
490
+ .filter:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
491
+ .tests { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
492
+ .test {
493
+ background: var(--surface);
494
+ border: 1px solid var(--border);
495
+ border-radius: var(--radius);
496
+ box-shadow: var(--shadow);
497
+ overflow: hidden;
498
+ }
499
+ .test[hidden] { display: none; }
500
+ .row {
501
+ width: 100%;
502
+ display: flex;
503
+ align-items: center;
504
+ gap: 12px;
505
+ padding: 13px 16px;
506
+ font: inherit;
507
+ text-align: left;
508
+ color: inherit;
509
+ background: none;
510
+ border: 0;
511
+ cursor: pointer;
512
+ }
513
+ .row:disabled { cursor: default; }
514
+ .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
515
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--muted); }
516
+ .dot.passed { background: var(--ok); }
517
+ .dot.failed { background: var(--err); }
518
+ .dot.skipped { background: var(--muted); }
519
+ .name { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
520
+ .suite { color: var(--muted); font-size: 13px; }
521
+ .part::before { content: "›"; margin-right: 6px; color: var(--muted); }
522
+ .meta { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
523
+ .tag { border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; }
524
+ .detail { padding: 4px 16px 18px 16px; border-top: 1px solid var(--border); }
525
+ .detail h4 {
526
+ margin: 18px 0 8px;
527
+ font-size: 11px;
528
+ font-weight: 620;
529
+ text-transform: uppercase;
530
+ letter-spacing: .07em;
531
+ color: var(--muted);
532
+ display: flex;
533
+ align-items: center;
534
+ gap: 6px;
535
+ }
536
+ .detail h4:first-child { margin-top: 14px; }
537
+ .count { background: var(--border); color: var(--muted); border-radius: 999px; padding: 0 6px; font-size: 10px; letter-spacing: 0; }
538
+ pre.err {
539
+ margin: 0;
540
+ padding: 12px 14px;
541
+ background: color-mix(in srgb, var(--err) 8%, var(--bg));
542
+ border: 1px solid color-mix(in srgb, var(--err) 25%, var(--border));
543
+ border-radius: 8px;
544
+ color: var(--text);
545
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
546
+ white-space: pre-wrap;
547
+ word-break: break-word;
548
+ }
549
+ .kv { margin: 0; display: flex; flex-direction: column; gap: 4px; }
550
+ .kv > div { display: flex; gap: 10px; font-size: 13px; }
551
+ .kv dt { color: var(--muted); min-width: 48px; }
552
+ .kv dd { margin: 0; }
553
+ .wrap { word-break: break-all; }
554
+ .shot { display: block; max-width: 100%; border: 1px solid var(--border); border-radius: 8px; background: #fff; }
555
+ .log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
556
+ .log li {
557
+ display: flex;
558
+ align-items: baseline;
559
+ gap: 8px;
560
+ padding: 5px 8px;
561
+ border-radius: 6px;
562
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
563
+ }
564
+ .log li:nth-child(odd) { background: color-mix(in srgb, var(--text) 3%, transparent); }
565
+ .log .txt { flex: 1; min-width: 0; word-break: break-word; }
566
+ .log .kind, .log .code, .log .verb {
567
+ flex: none;
568
+ padding: 1px 6px;
569
+ border-radius: 5px;
570
+ font-size: 11px;
571
+ background: var(--border);
572
+ color: var(--muted);
573
+ }
574
+ .log li.is-error .kind { background: color-mix(in srgb, var(--err) 18%, transparent); color: var(--err); }
575
+ .log li.is-warn .kind { background: color-mix(in srgb, var(--warn) 20%, transparent); color: var(--warn); }
576
+ .log .code.ok { color: var(--ok); }
577
+ .log .code.warn { color: var(--warn); }
578
+ .log .code.err { color: var(--err); }
579
+ /* Reuses the .log row layout; only the pass-specific parts are added. */
580
+ .checks .method { color: var(--ok); }
581
+ .checks .target { flex: none; color: var(--muted); font-size: 11px; }
582
+ .log .fail { color: var(--err); font-size: 11px; }
583
+ .note { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
584
+ .note code { font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
585
+ .empty { color: var(--muted); }
586
+ @media (max-width: 640px) {
587
+ body { padding: 24px 14px 60px; }
588
+ .cards { grid-template-columns: repeat(2, 1fr); }
589
+ .meta time { display: none; }
590
+ }
591
+ @media print {
592
+ body { padding: 0; background: #fff; }
593
+ .filters { display: none; }
594
+ .test { break-inside: avoid; box-shadow: none; }
595
+ .detail[hidden] { display: block; }
596
+ }
597
+ `;
598
+ const SCRIPT = `
599
+ // Status filter and row expansion. Kept inline so the report needs no assets
600
+ // and works offline from a CI artifact.
601
+ (function () {
602
+ var buttons = Array.prototype.slice.call(document.querySelectorAll('.filter'))
603
+ var tests = Array.prototype.slice.call(document.querySelectorAll('.test'))
604
+
605
+ function apply(filter) {
606
+ tests.forEach(function (test) {
607
+ test.hidden = filter !== 'all' && test.dataset.status !== filter
608
+ })
609
+ buttons.forEach(function (button) {
610
+ var active = button.dataset.filter === filter
611
+ button.classList.toggle('is-active', active)
612
+ button.setAttribute('aria-pressed', String(active))
613
+ })
614
+ if (history.replaceState) {
615
+ history.replaceState(null, '', filter === 'all' ? location.pathname + location.search : '#' + filter)
616
+ }
617
+ }
618
+
619
+ function setExpanded(test, open) {
620
+ var row = test.querySelector('.row')
621
+ var detail = test.querySelector('.detail')
622
+ if (!row || !detail) return
623
+ row.setAttribute('aria-expanded', String(open))
624
+ detail.hidden = !open
625
+ }
626
+
627
+ buttons.forEach(function (button) {
628
+ button.addEventListener('click', function () {
629
+ apply(button.dataset.filter)
630
+ })
631
+ })
632
+
633
+ tests.forEach(function (test) {
634
+ var row = test.querySelector('.row')
635
+ if (!row) return
636
+ row.addEventListener('click', function () {
637
+ setExpanded(test, row.getAttribute('aria-expanded') !== 'true')
638
+ })
639
+ })
640
+
641
+ // Deep links: #failed filters to failures, #expand-all opens every detail.
642
+ // Both make a report shareable in a way a screenshot cannot be.
643
+ var wanted = location.hash.slice(1)
644
+ if (['all', 'passed', 'failed', 'skipped'].indexOf(wanted) !== -1) {
645
+ apply(wanted)
646
+ } else if (wanted === 'expand-all') {
647
+ tests.forEach(function (test) {
648
+ setExpanded(test, true)
649
+ })
650
+ }
651
+ })()
652
+ `;
653
+ //# sourceMappingURL=HtmlReport.js.map