bee-ladybug 0.5.4 → 0.6.1
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/README.MD +17 -1
- package/package.json +1 -1
- package/src/adapters/AntAdapter.js +52 -1
- package/src/adapters/CSSAdapter.js +131 -3
- package/src/adapters/CanvasAdapter.js +87 -1
- package/src/adapters/SpiderAdapter.js +53 -2
- package/src/adapters/WebDOMAdapter.js +209 -1
- package/src/adapters/python/PythonBridge.js +74 -2
- package/src/core/BeeLadybugCore.js +48 -4
- package/src/core/ReportExporter.js +695 -0
- package/src/core/UIOverlay.js +3 -1
- package/src/index.d.ts +89 -0
- package/src/index.js +1 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReportExporter — turns a core snapshot into HTML, JSON, or CSV.
|
|
3
|
+
*
|
|
4
|
+
* No adapter imports. Attached modules are asked through the snapshot
|
|
5
|
+
* hook stored by the core. A missing hook is skipped.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const LATEST_TYPES = Object.freeze([
|
|
9
|
+
'fps',
|
|
10
|
+
'metric',
|
|
11
|
+
'coord',
|
|
12
|
+
'log',
|
|
13
|
+
'warn',
|
|
14
|
+
'error',
|
|
15
|
+
'state',
|
|
16
|
+
'sys',
|
|
17
|
+
'telemetry'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {'html' | 'json' | 'csv'} ReportFormat
|
|
22
|
+
* @typedef {'error' | 'warn' | 'ok'} ReportSeverity
|
|
23
|
+
* @typedef {{ severity: ReportSeverity, selector: string, rule: string, value: string, source: string }} ReportItem
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a report and, when the overlay is mounted, download it.
|
|
28
|
+
* Headless cores (`ui: false`, or no document) return the value and do not download.
|
|
29
|
+
*
|
|
30
|
+
* JSON returns the plain object. HTML and CSV return strings.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} core
|
|
33
|
+
* @param {{ format?: ReportFormat }} [options]
|
|
34
|
+
* @returns {object | string}
|
|
35
|
+
*/
|
|
36
|
+
export function exportReport(core, options = {}) {
|
|
37
|
+
if (!core || typeof core.getState !== 'function' || typeof core.collectSnapshots !== 'function') {
|
|
38
|
+
throw new TypeError('exportReport() needs a BeeLadybug core.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const format = normalizeFormat(options.format);
|
|
42
|
+
const report = buildReport(core);
|
|
43
|
+
|
|
44
|
+
if (format === 'json') {
|
|
45
|
+
saveIfMounted(core, report, 'json', 'application/json', JSON.stringify(report, null, 2));
|
|
46
|
+
return report;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (format === 'csv') {
|
|
50
|
+
const text = renderCsv(report);
|
|
51
|
+
saveIfMounted(core, report, 'csv', 'text/csv', text);
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const html = renderHtml(report);
|
|
56
|
+
saveIfMounted(core, report, 'html', 'text/html', html);
|
|
57
|
+
return html;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Skip hidden boxes, the WordPress admin bar, and BeeLadybug's own chrome.
|
|
62
|
+
* Adapters call this while they still have the element. The exported row does not.
|
|
63
|
+
*
|
|
64
|
+
* @param {object | null | undefined} node
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function isReportNoise(node) {
|
|
68
|
+
if (!node || typeof node !== 'object') return false;
|
|
69
|
+
const el = node.nodeType === 1 ? node : (node.parentElement || node);
|
|
70
|
+
if (!el || typeof el !== 'object') return false;
|
|
71
|
+
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
let current = el;
|
|
74
|
+
while (current && typeof current === 'object') {
|
|
75
|
+
if (seen.has(current)) break;
|
|
76
|
+
seen.add(current);
|
|
77
|
+
if (current.id === 'wpadminbar' || elementId(current) === 'wpadminbar') return true;
|
|
78
|
+
if (hasAbClass(classText(current))) return true;
|
|
79
|
+
if (current.tagName === 'BEE-LADYBUG-OVERLAY') return true;
|
|
80
|
+
if (hasBeeAttribute(current)) return true;
|
|
81
|
+
if (typeof current.closest === 'function') {
|
|
82
|
+
try {
|
|
83
|
+
const bar = current.closest('#wpadminbar');
|
|
84
|
+
if (bar) return true;
|
|
85
|
+
} catch {
|
|
86
|
+
/* closest is optional */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
current = current.parentElement || (current.parentNode && current.parentNode.nodeType === 1 ? current.parentNode : null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const style = readStyle(el);
|
|
93
|
+
if (style && (style.display === 'none' || style.visibility === 'hidden')) return true;
|
|
94
|
+
|
|
95
|
+
if (typeof el.getBoundingClientRect === 'function') {
|
|
96
|
+
const rect = el.getBoundingClientRect();
|
|
97
|
+
if (rect && (Number(rect.width) === 0 || Number(rect.height) === 0)) return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {object} core
|
|
105
|
+
*/
|
|
106
|
+
function buildReport(core) {
|
|
107
|
+
const state = core.getState();
|
|
108
|
+
const generatedAt = new Date();
|
|
109
|
+
const env = readEnvironment();
|
|
110
|
+
const logs = readLogs(core);
|
|
111
|
+
const series = readSeries(core);
|
|
112
|
+
const latest = readLatest(core);
|
|
113
|
+
const adapters = readAdapters(core);
|
|
114
|
+
const summary = countFindings(adapters, logs);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
version: state.version,
|
|
118
|
+
generatedAt: generatedAt.toISOString(),
|
|
119
|
+
fileStamp: fileStamp(generatedAt),
|
|
120
|
+
url: env.url,
|
|
121
|
+
viewport: env.viewport,
|
|
122
|
+
clock: {
|
|
123
|
+
wallTime: generatedAt.getTime(),
|
|
124
|
+
simTime: state.simTime,
|
|
125
|
+
frame: state.frameCount,
|
|
126
|
+
frozen: state.frozen,
|
|
127
|
+
timeScale: state.timeScale,
|
|
128
|
+
coreFps: state.coreFps
|
|
129
|
+
},
|
|
130
|
+
summary,
|
|
131
|
+
logs,
|
|
132
|
+
series,
|
|
133
|
+
latest,
|
|
134
|
+
adapters
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} core
|
|
140
|
+
*/
|
|
141
|
+
function readLogs(core) {
|
|
142
|
+
const logs = typeof core.getLogs === 'function' ? core.getLogs() : [];
|
|
143
|
+
return logs.map((packet) => ({
|
|
144
|
+
type: packet.type,
|
|
145
|
+
level: packet.level ?? null,
|
|
146
|
+
source: packet.source,
|
|
147
|
+
message: packetMessage(packet),
|
|
148
|
+
wallTime: packet.wallTime,
|
|
149
|
+
simTime: packet.simTime,
|
|
150
|
+
frame: packet.frame
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* FPS plus every metric name currently on the HUD.
|
|
156
|
+
* @param {object} core
|
|
157
|
+
*/
|
|
158
|
+
function readSeries(core) {
|
|
159
|
+
const names = new Set(['fps']);
|
|
160
|
+
const hud = typeof core.getHud === 'function' ? core.getHud() : {};
|
|
161
|
+
for (const [name, entry] of Object.entries(hud)) {
|
|
162
|
+
if (entry && entry.type === 'metric') names.add(name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const series = [];
|
|
166
|
+
for (const name of names) {
|
|
167
|
+
const history = core.getHistory(name);
|
|
168
|
+
if (!history || !history.values || !history.values.length) continue;
|
|
169
|
+
series.push({
|
|
170
|
+
name: history.name,
|
|
171
|
+
min: history.min,
|
|
172
|
+
max: history.max,
|
|
173
|
+
avg: history.avg,
|
|
174
|
+
last: history.last,
|
|
175
|
+
samples: history.values.slice()
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return series;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {object} core
|
|
183
|
+
*/
|
|
184
|
+
function readLatest(core) {
|
|
185
|
+
if (typeof core.getLatest !== 'function') return [];
|
|
186
|
+
const packets = [];
|
|
187
|
+
for (const type of LATEST_TYPES) {
|
|
188
|
+
const packet = core.getLatest(type);
|
|
189
|
+
if (!packet) continue;
|
|
190
|
+
packets.push({
|
|
191
|
+
id: packet.id,
|
|
192
|
+
type: packet.type,
|
|
193
|
+
payload: packet.payload,
|
|
194
|
+
wallTime: packet.wallTime,
|
|
195
|
+
simTime: packet.simTime,
|
|
196
|
+
frame: packet.frame,
|
|
197
|
+
source: packet.source,
|
|
198
|
+
level: packet.level ?? null
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return packets;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {object} core
|
|
206
|
+
*/
|
|
207
|
+
function readAdapters(core) {
|
|
208
|
+
const raw = core.collectSnapshots();
|
|
209
|
+
const sections = [];
|
|
210
|
+
for (const entry of raw) {
|
|
211
|
+
if (entry.error) {
|
|
212
|
+
sections.push({
|
|
213
|
+
name: entry.name,
|
|
214
|
+
items: [makeItem({
|
|
215
|
+
severity: 'warn',
|
|
216
|
+
rule: 'snapshot',
|
|
217
|
+
value: entry.error,
|
|
218
|
+
source: entry.name
|
|
219
|
+
})]
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const result = entry.result;
|
|
224
|
+
const name = result && typeof result.name === 'string' && result.name
|
|
225
|
+
? result.name
|
|
226
|
+
: entry.name;
|
|
227
|
+
const list = result && Array.isArray(result.items) ? result.items : [];
|
|
228
|
+
sections.push({
|
|
229
|
+
name,
|
|
230
|
+
items: groupIdentical(list.filter((row) => row && typeof row === 'object').map((row) => makeItem(row, name)))
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return sections;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {object} row
|
|
238
|
+
* @param {string} [fallbackSource]
|
|
239
|
+
* @returns {ReportItem}
|
|
240
|
+
*/
|
|
241
|
+
function makeItem(row, fallbackSource = '') {
|
|
242
|
+
return {
|
|
243
|
+
severity: normalizeSeverity(row.severity),
|
|
244
|
+
selector: asText(row.selector),
|
|
245
|
+
rule: asText(row.rule),
|
|
246
|
+
value: asText(row.value),
|
|
247
|
+
source: asText(row.source) || fallbackSource
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Identical selector + rule + value collapse to one row. The count is part of the value.
|
|
253
|
+
* @param {ReportItem[]} items
|
|
254
|
+
* @returns {ReportItem[]}
|
|
255
|
+
*/
|
|
256
|
+
function groupIdentical(items) {
|
|
257
|
+
const order = [];
|
|
258
|
+
const map = new Map();
|
|
259
|
+
for (let i = 0; i < items.length; i++) {
|
|
260
|
+
const item = items[i];
|
|
261
|
+
const key = `${item.selector}\0${item.rule}\0${item.value}`;
|
|
262
|
+
const prev = map.get(key);
|
|
263
|
+
if (!prev) {
|
|
264
|
+
const row = { ...item, count: 1 };
|
|
265
|
+
map.set(key, row);
|
|
266
|
+
order.push(row);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
prev.count += 1;
|
|
270
|
+
}
|
|
271
|
+
return order.map((row) => ({
|
|
272
|
+
severity: row.severity,
|
|
273
|
+
selector: row.selector,
|
|
274
|
+
rule: row.rule,
|
|
275
|
+
value: row.count > 1 ? `${row.value} ×${row.count}` : row.value,
|
|
276
|
+
source: row.source
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {Array<{ items: ReportItem[] }>} adapters
|
|
282
|
+
* @param {Array<{ type: string, level: string | null }>} logs
|
|
283
|
+
*/
|
|
284
|
+
function countFindings(adapters, logs) {
|
|
285
|
+
const summary = { error: 0, warn: 0, ok: 0 };
|
|
286
|
+
for (const section of adapters) {
|
|
287
|
+
for (const item of section.items) summary[item.severity] += 1;
|
|
288
|
+
}
|
|
289
|
+
for (let i = 0; i < logs.length; i++) summary[logSeverity(logs[i])] += 1;
|
|
290
|
+
return summary;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {string | undefined} format
|
|
295
|
+
* @returns {ReportFormat}
|
|
296
|
+
*/
|
|
297
|
+
function normalizeFormat(format) {
|
|
298
|
+
if (format === 'json' || format === 'csv' || format === 'html') return format;
|
|
299
|
+
return 'html';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @param {unknown} value
|
|
304
|
+
* @returns {ReportSeverity}
|
|
305
|
+
*/
|
|
306
|
+
function normalizeSeverity(value) {
|
|
307
|
+
const text = String(value || '').toLowerCase();
|
|
308
|
+
if (text === 'error') return 'error';
|
|
309
|
+
if (text === 'warn' || text === 'warning' || text === 'warm') return 'warn';
|
|
310
|
+
return 'ok';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @param {object} report
|
|
315
|
+
*/
|
|
316
|
+
function renderCsv(report) {
|
|
317
|
+
const lines = ['adapter,severity,selector,rule,value,source'];
|
|
318
|
+
for (const log of report.logs) {
|
|
319
|
+
lines.push(csvRow([
|
|
320
|
+
'core',
|
|
321
|
+
logSeverity(log),
|
|
322
|
+
'',
|
|
323
|
+
log.type,
|
|
324
|
+
log.message,
|
|
325
|
+
log.source
|
|
326
|
+
]));
|
|
327
|
+
}
|
|
328
|
+
for (const series of report.series) {
|
|
329
|
+
lines.push(csvRow([
|
|
330
|
+
'core',
|
|
331
|
+
'ok',
|
|
332
|
+
'',
|
|
333
|
+
series.name,
|
|
334
|
+
`min ${formatNum(series.min)} / avg ${formatNum(series.avg)} / max ${formatNum(series.max)}`,
|
|
335
|
+
'core'
|
|
336
|
+
]));
|
|
337
|
+
}
|
|
338
|
+
for (const section of report.adapters) {
|
|
339
|
+
for (const item of section.items) {
|
|
340
|
+
lines.push(csvRow([
|
|
341
|
+
section.name,
|
|
342
|
+
item.severity,
|
|
343
|
+
item.selector,
|
|
344
|
+
item.rule,
|
|
345
|
+
item.value,
|
|
346
|
+
item.source
|
|
347
|
+
]));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return `${lines.join('\n')}\n`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {unknown[]} cells
|
|
355
|
+
*/
|
|
356
|
+
function csvRow(cells) {
|
|
357
|
+
return cells.map(csvCell).join(',');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {unknown} value
|
|
362
|
+
*/
|
|
363
|
+
function csvCell(value) {
|
|
364
|
+
const text = value == null ? '' : String(value);
|
|
365
|
+
if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
|
366
|
+
return text;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @param {{ type: string, level: string | null }} log
|
|
371
|
+
* @returns {ReportSeverity}
|
|
372
|
+
*/
|
|
373
|
+
function logSeverity(log) {
|
|
374
|
+
const level = String(log.level || '').toLowerCase();
|
|
375
|
+
if (level === 'warn' || level === 'warning' || level === 'warm') return 'warn';
|
|
376
|
+
if (level === 'error' || log.type === 'error') return 'error';
|
|
377
|
+
if (log.type === 'warn') return 'warn';
|
|
378
|
+
return 'ok';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {object} report
|
|
383
|
+
*/
|
|
384
|
+
function renderHtml(report) {
|
|
385
|
+
const title = `BeeLadybug report ${report.fileStamp}`;
|
|
386
|
+
const viewport = report.viewport
|
|
387
|
+
? `${report.viewport.width}×${report.viewport.height}`
|
|
388
|
+
: 'n/a';
|
|
389
|
+
const sections = report.adapters.map((section) => renderSection(section)).join('\n');
|
|
390
|
+
|
|
391
|
+
return `<!DOCTYPE html>
|
|
392
|
+
<html lang="en">
|
|
393
|
+
<head>
|
|
394
|
+
<meta charset="utf-8">
|
|
395
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
396
|
+
<title>${escapeHtml(title)}</title>
|
|
397
|
+
<style>
|
|
398
|
+
:root { color-scheme: dark; }
|
|
399
|
+
* { box-sizing: border-box; }
|
|
400
|
+
body {
|
|
401
|
+
margin: 0;
|
|
402
|
+
background: #07080c;
|
|
403
|
+
color: #f1f2f6;
|
|
404
|
+
font: 14px/1.45 ui-monospace, Consolas, monospace;
|
|
405
|
+
}
|
|
406
|
+
main { max-width: 1100px; margin: 0 auto; padding: 28px 22px 64px; }
|
|
407
|
+
h1 { margin: 0 0 8px; color: #ff3b4e; font-size: 22px; letter-spacing: 0.04em; }
|
|
408
|
+
h2 { margin: 28px 0 10px; font-size: 13px; letter-spacing: 0.14em; color: #8b91a3; }
|
|
409
|
+
.meta { color: #8b91a3; margin: 0 0 18px; }
|
|
410
|
+
.counts { display: flex; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; }
|
|
411
|
+
.count { padding: 8px 12px; background: #101218; border: 1px solid #2a2e3a; }
|
|
412
|
+
.count b { display: block; font-size: 20px; }
|
|
413
|
+
.count.error b { color: #ff3b4e; }
|
|
414
|
+
.count.warn b { color: #ffd36a; }
|
|
415
|
+
.count.ok b { color: #3dd68c; }
|
|
416
|
+
table { width: 100%; border-collapse: collapse; margin: 0 0 8px; }
|
|
417
|
+
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #2a2e3a; vertical-align: top; }
|
|
418
|
+
th { color: #8b91a3; font-weight: 600; cursor: pointer; background: #101218; }
|
|
419
|
+
tr.sev-error td { color: #ff8b96; }
|
|
420
|
+
tr.sev-warn td { color: #ffd36a; }
|
|
421
|
+
tr.sev-ok td { color: #d5d8e2; }
|
|
422
|
+
td.sev { font-weight: 700; }
|
|
423
|
+
tr.sev-error td.sev { color: #ff3b4e; }
|
|
424
|
+
tr.sev-warn td.sev { color: #ffd36a; }
|
|
425
|
+
tr.sev-ok td.sev { color: #3dd68c; }
|
|
426
|
+
.empty { color: #8b91a3; }
|
|
427
|
+
@media print {
|
|
428
|
+
body { background: #07080c; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
429
|
+
main { max-width: none; padding: 0; }
|
|
430
|
+
h2, table, .count { break-inside: avoid; }
|
|
431
|
+
thead { display: table-header-group; }
|
|
432
|
+
th { cursor: default; }
|
|
433
|
+
}
|
|
434
|
+
</style>
|
|
435
|
+
</head>
|
|
436
|
+
<body>
|
|
437
|
+
<main>
|
|
438
|
+
<h1>${escapeHtml(title)}</h1>
|
|
439
|
+
<p class="meta">v${escapeHtml(report.version)} · ${escapeHtml(report.generatedAt)} · sim ${escapeHtml(formatSim(report.clock.simTime))} · frame ${escapeHtml(report.clock.frame)} · ${escapeHtml(report.clock.frozen ? 'FREEZE' : `${formatNum(report.clock.timeScale)}x`)}<br>
|
|
440
|
+
${escapeHtml(report.url || 'n/a')} · viewport ${escapeHtml(viewport)}</p>
|
|
441
|
+
<div class="counts">
|
|
442
|
+
<div class="count error"><b>${report.summary.error}</b>errors</div>
|
|
443
|
+
<div class="count warn"><b>${report.summary.warn}</b>warnings</div>
|
|
444
|
+
<div class="count ok"><b>${report.summary.ok}</b>ok</div>
|
|
445
|
+
</div>
|
|
446
|
+
<h2>CORE · LOG</h2>
|
|
447
|
+
${renderLogTable(report.logs)}
|
|
448
|
+
<h2>CORE · SERIES</h2>
|
|
449
|
+
${renderSeriesTable(report.series)}
|
|
450
|
+
${sections || '<h2>ADAPTERS</h2><p class="empty">No attached module exposed a snapshot.</p>'}
|
|
451
|
+
</main>
|
|
452
|
+
<script>
|
|
453
|
+
document.querySelectorAll('table').forEach((table) => {
|
|
454
|
+
table.querySelectorAll('thead th').forEach((th, index) => {
|
|
455
|
+
th.addEventListener('click', () => {
|
|
456
|
+
const body = table.tBodies[0];
|
|
457
|
+
if (!body) return;
|
|
458
|
+
const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
|
|
459
|
+
table.querySelectorAll('thead th').forEach((other) => { delete other.dataset.dir; });
|
|
460
|
+
th.dataset.dir = dir;
|
|
461
|
+
const mul = dir === 'asc' ? 1 : -1;
|
|
462
|
+
[...body.rows].sort((a, b) => {
|
|
463
|
+
const av = a.cells[index] ? a.cells[index].textContent : '';
|
|
464
|
+
const bv = b.cells[index] ? b.cells[index].textContent : '';
|
|
465
|
+
const an = Number(av);
|
|
466
|
+
const bn = Number(bv);
|
|
467
|
+
if (av !== '' && bv !== '' && Number.isFinite(an) && Number.isFinite(bn)) return (an - bn) * mul;
|
|
468
|
+
return av.localeCompare(bv) * mul;
|
|
469
|
+
}).forEach((row) => body.appendChild(row));
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
</script>
|
|
474
|
+
</body>
|
|
475
|
+
</html>
|
|
476
|
+
`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* @param {Array<{ type: string, level: string | null, source: string, message: string, simTime: number, frame: number }>} logs
|
|
481
|
+
*/
|
|
482
|
+
function renderLogTable(logs) {
|
|
483
|
+
if (!logs.length) return '<p class="empty">No log lines.</p>';
|
|
484
|
+
const rows = logs.map((log) => {
|
|
485
|
+
const severity = logSeverity(log);
|
|
486
|
+
return `<tr class="sev-${severity}"><td class="sev">${escapeHtml(severity)}</td><td>${escapeHtml(log.type)}</td><td>${escapeHtml(log.source)}</td><td>${escapeHtml(log.message)}</td><td>${escapeHtml(formatSim(log.simTime))}</td><td>${escapeHtml(log.frame)}</td></tr>`;
|
|
487
|
+
}).join('');
|
|
488
|
+
return `<table>
|
|
489
|
+
<thead><tr><th>severity</th><th>type</th><th>source</th><th>message</th><th>sim</th><th>frame</th></tr></thead>
|
|
490
|
+
<tbody>${rows}</tbody>
|
|
491
|
+
</table>`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* @param {Array<{ name: string, min: number, avg: number, max: number, last: number, samples: number[] }>} series
|
|
496
|
+
*/
|
|
497
|
+
function renderSeriesTable(series) {
|
|
498
|
+
if (!series.length) return '<p class="empty">No fps or metric samples.</p>';
|
|
499
|
+
const rows = series.map((entry) => `<tr class="sev-ok"><td>${escapeHtml(entry.name)}</td><td>${escapeHtml(formatNum(entry.min))}</td><td>${escapeHtml(formatNum(entry.avg))}</td><td>${escapeHtml(formatNum(entry.max))}</td><td>${escapeHtml(formatNum(entry.last))}</td><td>${entry.samples.length}</td></tr>`).join('');
|
|
500
|
+
return `<table>
|
|
501
|
+
<thead><tr><th>name</th><th>min</th><th>avg</th><th>max</th><th>last</th><th>samples</th></tr></thead>
|
|
502
|
+
<tbody>${rows}</tbody>
|
|
503
|
+
</table>`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @param {{ name: string, items: ReportItem[] }} section
|
|
508
|
+
*/
|
|
509
|
+
function renderSection(section) {
|
|
510
|
+
const title = escapeHtml(section.name);
|
|
511
|
+
if (!section.items.length) {
|
|
512
|
+
return `<h2>${title}</h2><p class="empty">No rows.</p>`;
|
|
513
|
+
}
|
|
514
|
+
const rows = section.items.map((item) => `<tr class="sev-${item.severity}"><td class="sev">${escapeHtml(item.severity)}</td><td>${escapeHtml(item.selector)}</td><td>${escapeHtml(item.rule)}</td><td>${escapeHtml(item.value)}</td><td>${escapeHtml(item.source)}</td></tr>`).join('');
|
|
515
|
+
return `<h2>${title}</h2>
|
|
516
|
+
<table>
|
|
517
|
+
<thead><tr><th>severity</th><th>selector</th><th>rule</th><th>value</th><th>source</th></tr></thead>
|
|
518
|
+
<tbody>${rows}</tbody>
|
|
519
|
+
</table>`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* @param {object} core
|
|
524
|
+
* @param {{ fileStamp: string }} report
|
|
525
|
+
* @param {string} ext
|
|
526
|
+
* @param {string} mime
|
|
527
|
+
* @param {string} text
|
|
528
|
+
*/
|
|
529
|
+
function saveIfMounted(core, report, ext, mime, text) {
|
|
530
|
+
if (!core.ui) return;
|
|
531
|
+
if (typeof document === 'undefined' || !document.body) return;
|
|
532
|
+
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function' || typeof Blob === 'undefined') return;
|
|
533
|
+
|
|
534
|
+
const filename = `report-${report.fileStamp}.${ext}`;
|
|
535
|
+
const type = `${mime};charset=utf-8`;
|
|
536
|
+
let blob;
|
|
537
|
+
try {
|
|
538
|
+
blob = typeof File === 'function'
|
|
539
|
+
? new File([text], filename, { type })
|
|
540
|
+
: new Blob([text], { type });
|
|
541
|
+
} catch {
|
|
542
|
+
if (typeof Blob !== 'function') return;
|
|
543
|
+
blob = new Blob([text], { type });
|
|
544
|
+
}
|
|
545
|
+
const url = URL.createObjectURL(blob);
|
|
546
|
+
const link = document.createElement('a');
|
|
547
|
+
link.href = url;
|
|
548
|
+
link.download = filename;
|
|
549
|
+
link.setAttribute('download', filename);
|
|
550
|
+
link.rel = 'noopener';
|
|
551
|
+
document.body.appendChild(link);
|
|
552
|
+
link.click();
|
|
553
|
+
link.remove();
|
|
554
|
+
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function readEnvironment() {
|
|
558
|
+
if (typeof window === 'undefined' || typeof location === 'undefined') {
|
|
559
|
+
return { url: '', viewport: null };
|
|
560
|
+
}
|
|
561
|
+
const width = Number(window.innerWidth);
|
|
562
|
+
const height = Number(window.innerHeight);
|
|
563
|
+
return {
|
|
564
|
+
url: String(location.href || ''),
|
|
565
|
+
viewport: Number.isFinite(width) && Number.isFinite(height)
|
|
566
|
+
? { width, height }
|
|
567
|
+
: null
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* @param {object} packet
|
|
573
|
+
*/
|
|
574
|
+
function packetMessage(packet) {
|
|
575
|
+
const payload = packet && packet.payload;
|
|
576
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
577
|
+
if (typeof payload.message === 'string') return payload.message;
|
|
578
|
+
if (payload.value != null) return String(payload.value);
|
|
579
|
+
return '';
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* @param {unknown} value
|
|
584
|
+
*/
|
|
585
|
+
function asText(value) {
|
|
586
|
+
if (value == null) return '';
|
|
587
|
+
if (typeof value === 'string') return value;
|
|
588
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
589
|
+
try {
|
|
590
|
+
return JSON.stringify(value);
|
|
591
|
+
} catch {
|
|
592
|
+
return String(value);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* @param {number} value
|
|
598
|
+
*/
|
|
599
|
+
function formatNum(value) {
|
|
600
|
+
const n = Number(value);
|
|
601
|
+
if (!Number.isFinite(n)) return '';
|
|
602
|
+
if (Number.isInteger(n)) return String(n);
|
|
603
|
+
return String(Math.round(n * 100) / 100);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* @param {number} ms
|
|
608
|
+
*/
|
|
609
|
+
function formatSim(ms) {
|
|
610
|
+
const total = Math.max(0, Number(ms) || 0) / 1000;
|
|
611
|
+
const minutes = Math.floor(total / 60);
|
|
612
|
+
const seconds = total - minutes * 60;
|
|
613
|
+
return `${String(minutes).padStart(2, '0')}:${seconds.toFixed(1).padStart(4, '0')}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* @param {Date} date
|
|
618
|
+
*/
|
|
619
|
+
function fileStamp(date) {
|
|
620
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
621
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* @param {unknown} value
|
|
626
|
+
*/
|
|
627
|
+
function escapeHtml(value) {
|
|
628
|
+
return String(value ?? '')
|
|
629
|
+
.replace(/&/g, '&')
|
|
630
|
+
.replace(/</g, '<')
|
|
631
|
+
.replace(/>/g, '>')
|
|
632
|
+
.replace(/"/g, '"');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function classText(node) {
|
|
636
|
+
if (!node || typeof node !== 'object') return '';
|
|
637
|
+
const parts = [];
|
|
638
|
+
if (typeof node.className === 'string' && node.className) parts.push(node.className);
|
|
639
|
+
else if (node.className && typeof node.className === 'object' && 'baseVal' in node.className && node.className.baseVal) {
|
|
640
|
+
parts.push(String(node.className.baseVal));
|
|
641
|
+
}
|
|
642
|
+
if (node.classList && typeof node.classList.length === 'number') {
|
|
643
|
+
try {
|
|
644
|
+
parts.push([...node.classList].join(' '));
|
|
645
|
+
} catch {
|
|
646
|
+
/* classList may not be iterable */
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (typeof node.getAttribute === 'function') {
|
|
650
|
+
const attr = node.getAttribute('class');
|
|
651
|
+
if (attr) parts.push(attr);
|
|
652
|
+
}
|
|
653
|
+
return parts.join(' ');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function elementId(node) {
|
|
657
|
+
if (!node || typeof node !== 'object') return '';
|
|
658
|
+
if (typeof node.id === 'string' && node.id) return node.id;
|
|
659
|
+
if (typeof node.getAttribute === 'function') return node.getAttribute('id') || '';
|
|
660
|
+
return '';
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* @param {unknown} className
|
|
665
|
+
*/
|
|
666
|
+
function hasAbClass(className) {
|
|
667
|
+
const raw = typeof className === 'string'
|
|
668
|
+
? className
|
|
669
|
+
: (className && typeof className === 'object' && 'baseVal' in className
|
|
670
|
+
? String(className.baseVal)
|
|
671
|
+
: '');
|
|
672
|
+
return raw.split(/\s+/).some((token) => token.startsWith('ab-'));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* @param {object} el
|
|
677
|
+
*/
|
|
678
|
+
function hasBeeAttribute(el) {
|
|
679
|
+
if (typeof el.hasAttribute !== 'function') return false;
|
|
680
|
+
return el.hasAttribute('data-bee-ladybug-dom')
|
|
681
|
+
|| el.hasAttribute('data-bee-ladybug-canvas')
|
|
682
|
+
|| el.hasAttribute('data-bee-ladybug-css');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* @param {object} el
|
|
687
|
+
*/
|
|
688
|
+
function readStyle(el) {
|
|
689
|
+
if (typeof getComputedStyle !== 'function') return null;
|
|
690
|
+
try {
|
|
691
|
+
return getComputedStyle(el);
|
|
692
|
+
} catch {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
}
|