lumitrace 0.4.2 → 0.5.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.
- checksums.yaml +4 -4
- data/docs/spec.md +79 -1
- data/docs/tutorial.ja.md +4 -0
- data/docs/tutorial.md +4 -0
- data/lib/lumitrace/generate_resulted_html.rb +323 -391
- data/lib/lumitrace/generate_resulted_html_renderer.js +774 -0
- data/lib/lumitrace/record_instrument.rb +79 -22
- data/lib/lumitrace/version.rb +1 -1
- data/lib/lumitrace.rb +31 -4
- data/runv/index.html +1182 -420
- data/runv/sync_inline.rb +13 -1
- data/test/test_lumitrace.rb +137 -0
- metadata +2 -1
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const payloadEl = document.getElementById("lumitrace-payload");
|
|
3
|
+
const app = document.getElementById("lumitrace-app");
|
|
4
|
+
if (!payloadEl || !app) return;
|
|
5
|
+
|
|
6
|
+
const SL = 0;
|
|
7
|
+
const SC = 1;
|
|
8
|
+
const EL = 2;
|
|
9
|
+
const EC = 3;
|
|
10
|
+
|
|
11
|
+
function escHtml(s) {
|
|
12
|
+
return String(s)
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/\"/g, """);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeTypeCounts(types) {
|
|
20
|
+
if (!types) return {};
|
|
21
|
+
if (Array.isArray(types)) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const t of types) {
|
|
24
|
+
const key = String(t || "");
|
|
25
|
+
if (!key) continue;
|
|
26
|
+
out[key] = (out[key] || 0) + 1;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
if (typeof types === "object") {
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [k, v] of Object.entries(types)) {
|
|
33
|
+
const key = String(k || "");
|
|
34
|
+
if (!key) continue;
|
|
35
|
+
let count = Number(v) || 0;
|
|
36
|
+
if (count <= 0) count = 1;
|
|
37
|
+
out[key] = (out[key] || 0) + count;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
const key = String(types || "");
|
|
42
|
+
return key ? { [key]: 1 } : {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function typeListText(types, onlyIfMultiple) {
|
|
46
|
+
const counts = normalizeTypeCounts(types);
|
|
47
|
+
const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
|
|
48
|
+
if (onlyIfMultiple && entries.length <= 1) return null;
|
|
49
|
+
if (entries.length === 0) return "(no types)";
|
|
50
|
+
return "types: " + entries.map(([k, v]) => `${k}(${v})`).join(", ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function valueTypeName(v) {
|
|
54
|
+
if (v === null) return "NilClass";
|
|
55
|
+
if (Array.isArray(v)) return "Array";
|
|
56
|
+
switch (typeof v) {
|
|
57
|
+
case "number": return Number.isInteger(v) ? "Integer" : "Float";
|
|
58
|
+
case "string": return "String";
|
|
59
|
+
case "boolean": return "Boolean";
|
|
60
|
+
case "undefined": return "NilClass";
|
|
61
|
+
case "object": return "Object";
|
|
62
|
+
default: return typeof v;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatValue(v, type) {
|
|
67
|
+
const value = v == null ? "nil" : String(v);
|
|
68
|
+
return `${value} (${type || valueTypeName(v)})`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function lastValueToPair(lastValue) {
|
|
72
|
+
if (lastValue == null) return [null, null];
|
|
73
|
+
if (typeof lastValue !== "object" || Array.isArray(lastValue)) return [lastValue, null];
|
|
74
|
+
const type = lastValue.type || null;
|
|
75
|
+
if (Object.prototype.hasOwnProperty.call(lastValue, "value")) return [lastValue.value, type];
|
|
76
|
+
if (Object.prototype.hasOwnProperty.call(lastValue, "preview")) return [lastValue.preview, type];
|
|
77
|
+
if (Object.prototype.hasOwnProperty.call(lastValue, "inspect")) return [lastValue.inspect, type];
|
|
78
|
+
return [JSON.stringify(lastValue), type];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function summarizeValues(values, total, allTypes) {
|
|
82
|
+
if (!values || values.length === 0) {
|
|
83
|
+
const multi = typeListText(allTypes, false);
|
|
84
|
+
return multi || "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const n = total == null ? values.length : Number(total);
|
|
88
|
+
const lastVals = values.slice(-3);
|
|
89
|
+
const firstIndex = n - lastVals.length + 1;
|
|
90
|
+
const lines = [];
|
|
91
|
+
const extra = n - lastVals.length;
|
|
92
|
+
if (extra > 0) lines.push(`... (+${extra} more)`);
|
|
93
|
+
|
|
94
|
+
lastVals.forEach((v, i) => {
|
|
95
|
+
const idx = firstIndex + i;
|
|
96
|
+
const [valueText, typeText] = lastValueToPair(v);
|
|
97
|
+
lines.push(`#${idx}: ${formatValue(valueText, typeText)}`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const multi = typeListText(allTypes, true);
|
|
101
|
+
if (multi) lines.push(multi);
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function lineClassFor(expected, executed) {
|
|
106
|
+
if (executed > 0) return " hit";
|
|
107
|
+
if (expected > 0) return " miss";
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function lineInRanges(line, ranges) {
|
|
112
|
+
if (!ranges || ranges.length === 0) return true;
|
|
113
|
+
return ranges.some((range) => {
|
|
114
|
+
if (!Array.isArray(range) || range.length < 2) return false;
|
|
115
|
+
const start = Number(range[0]);
|
|
116
|
+
const end = Number(range[1]);
|
|
117
|
+
return line >= start && line <= end;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sourceLines(source) {
|
|
122
|
+
const matches = String(source || "").match(/[^\n]*\n|[^\n]+/g);
|
|
123
|
+
if (!matches) return [];
|
|
124
|
+
return matches.map((line) => line.endsWith("\n") ? line.slice(0, -1) : line);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function lineStatsForFile(trace) {
|
|
128
|
+
const expectedByLine = Object.create(null);
|
|
129
|
+
const executedByLine = Object.create(null);
|
|
130
|
+
const seen = Object.create(null);
|
|
131
|
+
|
|
132
|
+
for (const event of trace || []) {
|
|
133
|
+
const loc = event && event.location;
|
|
134
|
+
if (!Array.isArray(loc) || loc.length < 4) continue;
|
|
135
|
+
const sl = Number(loc[SL]);
|
|
136
|
+
const el = Number(loc[EL]);
|
|
137
|
+
if (!(sl > 0) || !(el > 0)) continue;
|
|
138
|
+
const key = `${sl}:${loc[SC]}:${el}:${loc[EC]}`;
|
|
139
|
+
if (seen[key]) continue;
|
|
140
|
+
seen[key] = true;
|
|
141
|
+
for (let line = sl; line <= el; line += 1) {
|
|
142
|
+
expectedByLine[line] = (expectedByLine[line] || 0) + 1;
|
|
143
|
+
if (Number(event.total) > 0) {
|
|
144
|
+
executedByLine[line] = (executedByLine[line] || 0) + 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { expectedByLine, executedByLine };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function expressionLineCoverageForTrace(trace) {
|
|
153
|
+
const expectedLines = new Set();
|
|
154
|
+
const executedLines = new Set();
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
|
|
157
|
+
for (const event of trace || []) {
|
|
158
|
+
if (!event || event.kind === "arg") continue;
|
|
159
|
+
const loc = event.location;
|
|
160
|
+
if (!Array.isArray(loc) || loc.length < 4) continue;
|
|
161
|
+
|
|
162
|
+
const sl = Number(loc[SL]);
|
|
163
|
+
const sc = Number(loc[SC]);
|
|
164
|
+
const el = Number(loc[EL]);
|
|
165
|
+
const ec = Number(loc[EC]);
|
|
166
|
+
if (!(sl > 0) || !(el > 0)) continue;
|
|
167
|
+
|
|
168
|
+
const key = `${sl}:${sc}:${el}:${ec}`;
|
|
169
|
+
if (seen.has(key)) continue;
|
|
170
|
+
seen.add(key);
|
|
171
|
+
|
|
172
|
+
for (let line = sl; line <= el; line += 1) {
|
|
173
|
+
expectedLines.add(line);
|
|
174
|
+
if (Number(event.total) > 0) executedLines.add(line);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
executed: executedLines.size,
|
|
180
|
+
expected: expectedLines.size
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function aggregateEventsForLine(trace, lineno, lineLen, fileIndex) {
|
|
185
|
+
const buckets = new Map();
|
|
186
|
+
const spans = [];
|
|
187
|
+
|
|
188
|
+
for (const event of trace || []) {
|
|
189
|
+
const loc = event && event.location;
|
|
190
|
+
if (!Array.isArray(loc) || loc.length < 4) continue;
|
|
191
|
+
const sline = Number(loc[SL]);
|
|
192
|
+
const scol = Number(loc[SC]);
|
|
193
|
+
const eline = Number(loc[EL]);
|
|
194
|
+
const ecol = Number(loc[EC]);
|
|
195
|
+
if (lineno < sline || lineno > eline) continue;
|
|
196
|
+
|
|
197
|
+
let s;
|
|
198
|
+
let t;
|
|
199
|
+
let marker;
|
|
200
|
+
if (sline === eline) {
|
|
201
|
+
s = scol;
|
|
202
|
+
t = ecol;
|
|
203
|
+
marker = true;
|
|
204
|
+
} else if (lineno === sline) {
|
|
205
|
+
s = scol;
|
|
206
|
+
t = lineLen;
|
|
207
|
+
marker = false;
|
|
208
|
+
} else if (lineno === eline) {
|
|
209
|
+
s = 0;
|
|
210
|
+
t = ecol;
|
|
211
|
+
marker = true;
|
|
212
|
+
} else {
|
|
213
|
+
s = 0;
|
|
214
|
+
t = lineLen;
|
|
215
|
+
marker = false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!(t > s)) continue;
|
|
219
|
+
spans.push({ start_col: s, end_col: t });
|
|
220
|
+
|
|
221
|
+
const keyId = `${fileIndex}:${sline}:${scol}:${eline}:${ecol}`;
|
|
222
|
+
buckets.set(keyId, {
|
|
223
|
+
key_id: keyId,
|
|
224
|
+
start_col: s,
|
|
225
|
+
end_col: t,
|
|
226
|
+
marker,
|
|
227
|
+
kind: event.kind || "expr",
|
|
228
|
+
name: event.name || null,
|
|
229
|
+
sampled_values: event.sampled_values || [],
|
|
230
|
+
types: event.types || {},
|
|
231
|
+
total: Number(event.total) || 0,
|
|
232
|
+
suppress_miss: false
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const out = Array.from(buckets.values());
|
|
237
|
+
for (const b of out) {
|
|
238
|
+
const depth = spans.filter((sp) => b.start_col >= sp.start_col && b.end_col <= sp.end_col).length;
|
|
239
|
+
b.depth = Math.min(5, Math.max(1, depth));
|
|
240
|
+
}
|
|
241
|
+
out.sort((a, b) => a.start_col - b.start_col);
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function renderLineHtml(lineText, events) {
|
|
246
|
+
const opens = Object.create(null);
|
|
247
|
+
const closes = Object.create(null);
|
|
248
|
+
|
|
249
|
+
for (const e of events) {
|
|
250
|
+
const s = Number(e.start_col);
|
|
251
|
+
const t = Number(e.end_col);
|
|
252
|
+
if (!(t > s)) continue;
|
|
253
|
+
|
|
254
|
+
const values = e.sampled_values || [];
|
|
255
|
+
const allTypes = e.types || {};
|
|
256
|
+
const total = Number(e.total) || 0;
|
|
257
|
+
const label = e.kind === "arg" && e.name ? `arg ${e.name}` : null;
|
|
258
|
+
|
|
259
|
+
let valueText;
|
|
260
|
+
if (total === 0) {
|
|
261
|
+
valueText = label ? `${label}: (not hit)` : "(not hit)";
|
|
262
|
+
} else {
|
|
263
|
+
const summary = summarizeValues(values, total, allTypes);
|
|
264
|
+
if (label) {
|
|
265
|
+
valueText = summary ? `${label}: ${summary}` : label;
|
|
266
|
+
} else {
|
|
267
|
+
valueText = summary;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const tooltipHtml = escHtml(valueText);
|
|
272
|
+
const depthClass = `depth-${e.depth || 1}`;
|
|
273
|
+
const missClass = total === 0 && !e.suppress_miss ? " miss" : "";
|
|
274
|
+
const keyAttr = escHtml(e.key_id || "");
|
|
275
|
+
const openTag = `<span class=\"expr hit ${depthClass}${missClass}\" data-key=\"${keyAttr}\">`;
|
|
276
|
+
|
|
277
|
+
let closeTag = "</span>";
|
|
278
|
+
if (e.marker !== false) {
|
|
279
|
+
let marker = "🔎";
|
|
280
|
+
if (total === 0) marker = "∅";
|
|
281
|
+
else if (e.kind === "arg") marker = "🧷";
|
|
282
|
+
|
|
283
|
+
let markerClass = "marker";
|
|
284
|
+
if (total === 0 && !e.suppress_miss) markerClass = "marker miss";
|
|
285
|
+
if (e.kind === "arg") markerClass += " arg";
|
|
286
|
+
closeTag = `<span class=\"${markerClass}\" data-key=\"${keyAttr}\" aria-hidden=\"true\">${marker}<span class=\"tooltip\">${tooltipHtml}</span></span></span>`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const len = t - s;
|
|
290
|
+
(opens[s] ||= []).push({ len, start: s, end: t, tag: openTag });
|
|
291
|
+
(closes[t] ||= []).push({ len, start: s, end: t, tag: closeTag });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const positions = Array.from(new Set([
|
|
295
|
+
...Object.keys(opens).map(Number),
|
|
296
|
+
...Object.keys(closes).map(Number)
|
|
297
|
+
])).sort((a, b) => a - b);
|
|
298
|
+
|
|
299
|
+
let out = "";
|
|
300
|
+
let cursor = 0;
|
|
301
|
+
|
|
302
|
+
for (const pos of positions) {
|
|
303
|
+
if (pos > cursor) out += escHtml(lineText.slice(cursor, pos));
|
|
304
|
+
if (closes[pos]) {
|
|
305
|
+
closes[pos]
|
|
306
|
+
.slice()
|
|
307
|
+
.sort((a, b) => (b.start - a.start) || (a.len - b.len))
|
|
308
|
+
.forEach((c) => { out += c.tag; });
|
|
309
|
+
}
|
|
310
|
+
if (opens[pos]) {
|
|
311
|
+
opens[pos]
|
|
312
|
+
.slice()
|
|
313
|
+
.sort((a, b) => b.end - a.end)
|
|
314
|
+
.forEach((o) => { out += o.tag; });
|
|
315
|
+
}
|
|
316
|
+
cursor = pos;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (cursor < lineText.length) out += escHtml(lineText.slice(cursor));
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildLineNode(lineno, lineText, lineClass, innerHtml, options) {
|
|
324
|
+
const opts = options || {};
|
|
325
|
+
const line = document.createElement("span");
|
|
326
|
+
line.className = `line${lineClass}`;
|
|
327
|
+
line.dataset.line = String(lineno);
|
|
328
|
+
line.id = `line-${lineno}`;
|
|
329
|
+
|
|
330
|
+
const ln = opts.lineHref ? document.createElement("a") : document.createElement("span");
|
|
331
|
+
ln.className = `ln${opts.lineHref ? " ln-link" : ""}`;
|
|
332
|
+
ln.textContent = String(lineno);
|
|
333
|
+
if (opts.lineHref) {
|
|
334
|
+
ln.href = opts.lineHref(lineno);
|
|
335
|
+
ln.title = `Link to line ${lineno}`;
|
|
336
|
+
ln.addEventListener("click", (event) => {
|
|
337
|
+
if (!opts.onLineClick) return;
|
|
338
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
opts.onLineClick(lineno);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
line.appendChild(ln);
|
|
344
|
+
line.appendChild(document.createTextNode(" "));
|
|
345
|
+
|
|
346
|
+
if (innerHtml == null) {
|
|
347
|
+
line.appendChild(document.createTextNode(lineText));
|
|
348
|
+
} else {
|
|
349
|
+
const wrapper = document.createElement("span");
|
|
350
|
+
wrapper.innerHTML = innerHtml;
|
|
351
|
+
while (wrapper.firstChild) line.appendChild(wrapper.firstChild);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return line;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function buildEllipsisNode() {
|
|
358
|
+
const line = document.createElement("span");
|
|
359
|
+
line.className = "line ellipsis";
|
|
360
|
+
line.dataset.line = "...";
|
|
361
|
+
|
|
362
|
+
const ln = document.createElement("span");
|
|
363
|
+
ln.className = "ln";
|
|
364
|
+
ln.textContent = "...";
|
|
365
|
+
line.appendChild(ln);
|
|
366
|
+
return line;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function renderFileSection(file, fileIndex, options) {
|
|
370
|
+
const opts = options || {};
|
|
371
|
+
const section = document.createElement("section");
|
|
372
|
+
section.className = "file-section";
|
|
373
|
+
|
|
374
|
+
if (opts.includeTitle !== false) {
|
|
375
|
+
const h2 = document.createElement("h2");
|
|
376
|
+
h2.className = "file";
|
|
377
|
+
h2.textContent = file.display_path || file.path || `file-${fileIndex + 1}`;
|
|
378
|
+
section.appendChild(h2);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const pre = document.createElement("pre");
|
|
382
|
+
pre.className = "code";
|
|
383
|
+
const code = document.createElement("code");
|
|
384
|
+
pre.appendChild(code);
|
|
385
|
+
|
|
386
|
+
const lines = sourceLines(file.source || "");
|
|
387
|
+
const ranges = Array.isArray(file.ranges) ? file.ranges : null;
|
|
388
|
+
const trace = Array.isArray(file.trace) ? file.trace : [];
|
|
389
|
+
const { expectedByLine, executedByLine } = lineStatsForFile(trace);
|
|
390
|
+
|
|
391
|
+
let prevLineno = null;
|
|
392
|
+
let firstLineno = null;
|
|
393
|
+
let lastLineno = null;
|
|
394
|
+
|
|
395
|
+
lines.forEach((lineText, idx) => {
|
|
396
|
+
const lineno = idx + 1;
|
|
397
|
+
if (!lineInRanges(lineno, ranges)) return;
|
|
398
|
+
if (firstLineno == null) firstLineno = lineno;
|
|
399
|
+
if (prevLineno != null && lineno > prevLineno + 1) {
|
|
400
|
+
code.appendChild(buildEllipsisNode());
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const evs = aggregateEventsForLine(trace, lineno, lineText.length, fileIndex);
|
|
404
|
+
const expected = expectedByLine[lineno] || 0;
|
|
405
|
+
const executed = executedByLine[lineno] || 0;
|
|
406
|
+
const lineClass = lineClassFor(expected, executed);
|
|
407
|
+
if (expected > 0 && executed === 0) {
|
|
408
|
+
evs.forEach((e) => { e.suppress_miss = true; });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (evs.length === 0) {
|
|
412
|
+
code.appendChild(buildLineNode(lineno, lineText, lineClass, null, opts));
|
|
413
|
+
} else {
|
|
414
|
+
code.appendChild(buildLineNode(lineno, lineText, lineClass, renderLineHtml(lineText, evs), opts));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
prevLineno = lineno;
|
|
418
|
+
lastLineno = lineno;
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
if (firstLineno != null && firstLineno > 1) {
|
|
422
|
+
code.insertBefore(buildEllipsisNode(), code.firstChild);
|
|
423
|
+
}
|
|
424
|
+
if (lastLineno != null && lastLineno < lines.length) {
|
|
425
|
+
code.appendChild(buildEllipsisNode());
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
section.appendChild(pre);
|
|
429
|
+
return section;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function bindMarkerHover(root) {
|
|
433
|
+
root.querySelectorAll(".marker").forEach((marker) => {
|
|
434
|
+
marker.addEventListener("mouseenter", () => {
|
|
435
|
+
root.querySelectorAll(".expr.active").forEach((el) => el.classList.remove("active"));
|
|
436
|
+
const key = marker.dataset.key;
|
|
437
|
+
if (key) {
|
|
438
|
+
root.querySelectorAll(`.expr[data-key=\"${key}\"]`).forEach((el) => el.classList.add("active"));
|
|
439
|
+
} else {
|
|
440
|
+
const expr = marker.closest(".expr");
|
|
441
|
+
if (expr) expr.classList.add("active");
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
marker.addEventListener("mouseleave", () => {
|
|
445
|
+
const key = marker.dataset.key;
|
|
446
|
+
if (key) {
|
|
447
|
+
root.querySelectorAll(`.expr[data-key=\"${key}\"]`).forEach((el) => el.classList.remove("active"));
|
|
448
|
+
} else {
|
|
449
|
+
const expr = marker.closest(".expr");
|
|
450
|
+
if (expr) expr.classList.remove("active");
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function fileDisplayPath(file, idx) {
|
|
457
|
+
return String((file && (file.display_path || file.path)) || `file-${idx + 1}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function fileUrlKey(file, idx) {
|
|
461
|
+
return fileDisplayPath(file, idx).replace(/\\/g, "/");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function encodeHashFileKey(key) {
|
|
465
|
+
return encodeURIComponent(String(key || "")).replace(/%2F/g, "/");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function decodeHashFileKey(value) {
|
|
469
|
+
try {
|
|
470
|
+
return decodeURIComponent(String(value || ""));
|
|
471
|
+
} catch (_e) {
|
|
472
|
+
return String(value || "");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function parseHashStateFromLocation() {
|
|
477
|
+
const raw = String(window.location.hash || "").replace(/^#/, "");
|
|
478
|
+
if (!raw) return { fileKey: null, line: null };
|
|
479
|
+
|
|
480
|
+
if (!raw.includes("=")) {
|
|
481
|
+
return { fileKey: decodeHashFileKey(raw), line: null };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const params = new URLSearchParams(raw);
|
|
485
|
+
const fileRaw = params.get("file");
|
|
486
|
+
const lineRaw = params.get("line");
|
|
487
|
+
const lineNum = lineRaw == null ? null : Number.parseInt(lineRaw, 10);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
fileKey: fileRaw ? decodeHashFileKey(fileRaw) : null,
|
|
491
|
+
line: Number.isFinite(lineNum) && lineNum > 0 ? lineNum : null
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function buildHashForSelection(fileKey, line) {
|
|
496
|
+
const parts = [];
|
|
497
|
+
if (fileKey) parts.push(`file=${encodeHashFileKey(fileKey)}`);
|
|
498
|
+
if (line && Number(line) > 0) parts.push(`line=${Number(line)}`);
|
|
499
|
+
return `#${parts.join("&")}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function setLocationHashSelection(fileKey, line) {
|
|
503
|
+
const hash = buildHashForSelection(fileKey, line);
|
|
504
|
+
if (window.location.hash === hash) return;
|
|
505
|
+
if (window.history && typeof window.history.replaceState === "function") {
|
|
506
|
+
try {
|
|
507
|
+
window.history.replaceState(null, "", hash);
|
|
508
|
+
return;
|
|
509
|
+
} catch (_e) {
|
|
510
|
+
// `about:srcdoc` (runv preview) can reject replaceState with a file:// hash URL.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
window.location.hash = hash;
|
|
515
|
+
} catch (_e) {
|
|
516
|
+
// Ignore hash update failures in restricted embedding contexts.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function buildFileTree(files) {
|
|
521
|
+
const root = { dirs: new Map(), files: [] };
|
|
522
|
+
|
|
523
|
+
files.forEach((file, idx) => {
|
|
524
|
+
const key = fileUrlKey(file, idx);
|
|
525
|
+
const display = fileDisplayPath(file, idx).replace(/\\/g, "/");
|
|
526
|
+
const parts = display.split("/").filter(Boolean);
|
|
527
|
+
const filename = parts.pop() || display || `file-${idx + 1}`;
|
|
528
|
+
|
|
529
|
+
let node = root;
|
|
530
|
+
let currentPath = "";
|
|
531
|
+
for (const part of parts) {
|
|
532
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
533
|
+
if (!node.dirs.has(part)) {
|
|
534
|
+
node.dirs.set(part, { name: part, path: currentPath, dirs: new Map(), files: [] });
|
|
535
|
+
}
|
|
536
|
+
node = node.dirs.get(part);
|
|
537
|
+
}
|
|
538
|
+
const coverage = expressionLineCoverageForTrace(Array.isArray(file.trace) ? file.trace : []);
|
|
539
|
+
const coverageText = coverage.expected > 0 ? ` (${coverage.executed}/${coverage.expected})` : "";
|
|
540
|
+
node.files.push({ label: filename, key, file, index: idx, path: display, coverageText });
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
return root;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function treeNodeContainsSelection(node, selectedKey) {
|
|
547
|
+
if (!node) return false;
|
|
548
|
+
if ((node.files || []).some((f) => f.key === selectedKey)) return true;
|
|
549
|
+
for (const child of (node.dirs || new Map()).values()) {
|
|
550
|
+
if (treeNodeContainsSelection(child, selectedKey)) return true;
|
|
551
|
+
}
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function renderFileTreeNode(node, selectedKey, onSelect, level) {
|
|
556
|
+
const ul = document.createElement("ul");
|
|
557
|
+
ul.className = "tree-list";
|
|
558
|
+
ul.dataset.level = String(level || 0);
|
|
559
|
+
|
|
560
|
+
const dirs = Array.from((node.dirs || new Map()).values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
561
|
+
const files = Array.from(node.files || []).sort((a, b) => a.path.localeCompare(b.path));
|
|
562
|
+
|
|
563
|
+
dirs.forEach((dirNode) => {
|
|
564
|
+
const li = document.createElement("li");
|
|
565
|
+
li.className = "tree-dir";
|
|
566
|
+
|
|
567
|
+
const details = document.createElement("details");
|
|
568
|
+
details.className = "tree-folder";
|
|
569
|
+
details.open = true;
|
|
570
|
+
|
|
571
|
+
const summary = document.createElement("summary");
|
|
572
|
+
summary.className = "tree-folder-label";
|
|
573
|
+
summary.textContent = dirNode.name;
|
|
574
|
+
details.appendChild(summary);
|
|
575
|
+
|
|
576
|
+
details.appendChild(renderFileTreeNode(dirNode, selectedKey, onSelect, (level || 0) + 1));
|
|
577
|
+
li.appendChild(details);
|
|
578
|
+
ul.appendChild(li);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
files.forEach((entry) => {
|
|
582
|
+
const li = document.createElement("li");
|
|
583
|
+
li.className = "tree-file";
|
|
584
|
+
|
|
585
|
+
const btn = document.createElement("button");
|
|
586
|
+
btn.type = "button";
|
|
587
|
+
btn.className = "tree-file-btn";
|
|
588
|
+
const name = document.createElement("span");
|
|
589
|
+
name.className = "tree-file-name";
|
|
590
|
+
name.textContent = entry.label;
|
|
591
|
+
btn.appendChild(name);
|
|
592
|
+
|
|
593
|
+
if (entry.coverageText) {
|
|
594
|
+
const meta = document.createElement("span");
|
|
595
|
+
meta.className = "tree-file-meta";
|
|
596
|
+
meta.textContent = entry.coverageText;
|
|
597
|
+
btn.appendChild(meta);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
btn.dataset.fileKey = entry.key;
|
|
601
|
+
if (entry.key === selectedKey) {
|
|
602
|
+
btn.classList.add("active");
|
|
603
|
+
btn.setAttribute("aria-current", "page");
|
|
604
|
+
}
|
|
605
|
+
btn.addEventListener("click", () => onSelect(entry.key));
|
|
606
|
+
li.appendChild(btn);
|
|
607
|
+
ul.appendChild(li);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
return ul;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function preferredInitialFileKey(files) {
|
|
614
|
+
if (!files || files.length === 0) return null;
|
|
615
|
+
const hashKey = parseHashStateFromLocation().fileKey;
|
|
616
|
+
if (hashKey) {
|
|
617
|
+
const byDisplay = files.find((file, idx) => fileUrlKey(file, idx) === hashKey);
|
|
618
|
+
if (byDisplay) return fileUrlKey(byDisplay, files.indexOf(byDisplay));
|
|
619
|
+
|
|
620
|
+
const byPath = files.find((file) => String(file.path || "") === hashKey);
|
|
621
|
+
if (byPath) return fileUrlKey(byPath, files.indexOf(byPath));
|
|
622
|
+
}
|
|
623
|
+
return fileUrlKey(files[0], 0);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function preferredInitialLineNumber() {
|
|
627
|
+
return parseHashStateFromLocation().line;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function focusLineInViewer(viewer, lineno) {
|
|
631
|
+
viewer.querySelectorAll(".line.line-target").forEach((el) => el.classList.remove("line-target"));
|
|
632
|
+
if (!(lineno > 0)) return;
|
|
633
|
+
const target = viewer.querySelector(`.line[data-line="${lineno}"]`);
|
|
634
|
+
if (!target) return;
|
|
635
|
+
target.classList.add("line-target");
|
|
636
|
+
if (typeof target.scrollIntoView === "function") {
|
|
637
|
+
target.scrollIntoView({ block: "center", inline: "nearest" });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function render(payload) {
|
|
642
|
+
app.textContent = "";
|
|
643
|
+
|
|
644
|
+
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
|
645
|
+
if (files.length === 0) {
|
|
646
|
+
const empty = document.createElement("p");
|
|
647
|
+
empty.className = "hint";
|
|
648
|
+
empty.textContent = "No files to render.";
|
|
649
|
+
app.appendChild(empty);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const hint = document.createElement("div");
|
|
654
|
+
hint.className = "hint";
|
|
655
|
+
hint.textContent = "Hover highlighted text to see recorded values.";
|
|
656
|
+
app.appendChild(hint);
|
|
657
|
+
|
|
658
|
+
const mode = document.createElement("div");
|
|
659
|
+
mode.className = "mode";
|
|
660
|
+
mode.textContent = payload && payload.meta && payload.meta.mode_text ? payload.meta.mode_text : "";
|
|
661
|
+
|
|
662
|
+
const commandText = payload && payload.meta && payload.meta.command ? String(payload.meta.command) : "";
|
|
663
|
+
if (commandText) {
|
|
664
|
+
const command = document.createElement("div");
|
|
665
|
+
command.className = "command";
|
|
666
|
+
command.textContent = `Command: ${commandText}`;
|
|
667
|
+
app.appendChild(command);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
app.appendChild(mode);
|
|
671
|
+
|
|
672
|
+
const shell = document.createElement("div");
|
|
673
|
+
shell.className = `report-layout${files.length <= 1 ? " single-file" : ""}`;
|
|
674
|
+
app.appendChild(shell);
|
|
675
|
+
|
|
676
|
+
const sidebar = document.createElement("aside");
|
|
677
|
+
sidebar.className = "report-sidebar";
|
|
678
|
+
shell.appendChild(sidebar);
|
|
679
|
+
|
|
680
|
+
const treeTitle = document.createElement("div");
|
|
681
|
+
treeTitle.className = "tree-title";
|
|
682
|
+
treeTitle.textContent = `Files (${files.length})`;
|
|
683
|
+
sidebar.appendChild(treeTitle);
|
|
684
|
+
|
|
685
|
+
const treeMount = document.createElement("div");
|
|
686
|
+
treeMount.className = "tree-scroll";
|
|
687
|
+
sidebar.appendChild(treeMount);
|
|
688
|
+
|
|
689
|
+
const main = document.createElement("section");
|
|
690
|
+
main.className = "report-main";
|
|
691
|
+
shell.appendChild(main);
|
|
692
|
+
|
|
693
|
+
const mainHead = document.createElement("div");
|
|
694
|
+
mainHead.className = "report-main-head";
|
|
695
|
+
main.appendChild(mainHead);
|
|
696
|
+
|
|
697
|
+
const currentPath = document.createElement("div");
|
|
698
|
+
currentPath.className = "current-file";
|
|
699
|
+
mainHead.appendChild(currentPath);
|
|
700
|
+
|
|
701
|
+
const viewer = document.createElement("div");
|
|
702
|
+
viewer.className = "report-viewer";
|
|
703
|
+
main.appendChild(viewer);
|
|
704
|
+
|
|
705
|
+
const fileKeyToEntry = new Map();
|
|
706
|
+
files.forEach((file, idx) => fileKeyToEntry.set(fileUrlKey(file, idx), { file, idx }));
|
|
707
|
+
|
|
708
|
+
let selectedKey = preferredInitialFileKey(files);
|
|
709
|
+
let selectedLine = preferredInitialLineNumber();
|
|
710
|
+
|
|
711
|
+
function renderTree() {
|
|
712
|
+
treeMount.textContent = "";
|
|
713
|
+
const treeRoot = buildFileTree(files);
|
|
714
|
+
treeMount.appendChild(renderFileTreeNode(treeRoot, selectedKey, (key) => selectFile(key, true, null), 0));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function renderSelectedFile() {
|
|
718
|
+
viewer.textContent = "";
|
|
719
|
+
const entry = fileKeyToEntry.get(selectedKey);
|
|
720
|
+
if (!entry) return;
|
|
721
|
+
|
|
722
|
+
currentPath.textContent = fileDisplayPath(entry.file, entry.idx);
|
|
723
|
+
|
|
724
|
+
viewer.appendChild(renderFileSection(entry.file, entry.idx, {
|
|
725
|
+
includeTitle: false,
|
|
726
|
+
lineHref: (lineno) => buildHashForSelection(selectedKey, lineno),
|
|
727
|
+
onLineClick: (lineno) => selectLine(lineno, true)
|
|
728
|
+
}));
|
|
729
|
+
bindMarkerHover(viewer);
|
|
730
|
+
focusLineInViewer(viewer, selectedLine);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function selectLine(lineno, updateHash) {
|
|
734
|
+
const n = Number(lineno);
|
|
735
|
+
selectedLine = Number.isFinite(n) && n > 0 ? n : null;
|
|
736
|
+
renderSelectedFile();
|
|
737
|
+
if (updateHash) setLocationHashSelection(selectedKey, selectedLine);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function selectFile(key, updateHash, nextLine) {
|
|
741
|
+
if (!fileKeyToEntry.has(key)) return;
|
|
742
|
+
if (arguments.length >= 3) {
|
|
743
|
+
selectedLine = nextLine && Number(nextLine) > 0 ? Number(nextLine) : null;
|
|
744
|
+
} else if (key !== selectedKey) {
|
|
745
|
+
selectedLine = null;
|
|
746
|
+
}
|
|
747
|
+
selectedKey = key;
|
|
748
|
+
renderTree();
|
|
749
|
+
renderSelectedFile();
|
|
750
|
+
if (updateHash) setLocationHashSelection(key, selectedLine);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
window.addEventListener("hashchange", () => {
|
|
754
|
+
const state = parseHashStateFromLocation();
|
|
755
|
+
if (state.fileKey && state.fileKey !== selectedKey && fileKeyToEntry.has(state.fileKey)) {
|
|
756
|
+
selectFile(state.fileKey, false, state.line);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (state.fileKey === selectedKey || (!state.fileKey && selectedKey)) {
|
|
760
|
+
selectedLine = state.line;
|
|
761
|
+
renderSelectedFile();
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
selectFile(selectedKey, true);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
const payload = JSON.parse(payloadEl.textContent || "{}");
|
|
770
|
+
render(payload);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
app.textContent = `Failed to render Lumitrace HTML: ${error}`;
|
|
773
|
+
}
|
|
774
|
+
})();
|