@tracelane/report 0.1.0-alpha.2 → 0.1.0-alpha.20
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/NOTICE +8 -0
- package/README.md +33 -5
- package/dist/_security/detectors/insecure-cookies.d.ts +4 -0
- package/dist/_security/detectors/insecure-cookies.d.ts.map +1 -0
- package/dist/_security/detectors/insecure-cookies.js +32 -0
- package/dist/_security/detectors/insecure-cookies.js.map +1 -0
- package/dist/_security/detectors/missing-headers.d.ts +4 -0
- package/dist/_security/detectors/missing-headers.d.ts.map +1 -0
- package/dist/_security/detectors/missing-headers.js +36 -0
- package/dist/_security/detectors/missing-headers.js.map +1 -0
- package/dist/_security/detectors/mixed-content.d.ts +5 -0
- package/dist/_security/detectors/mixed-content.d.ts.map +1 -0
- package/dist/_security/detectors/mixed-content.js +74 -0
- package/dist/_security/detectors/mixed-content.js.map +1 -0
- package/dist/_security/detectors/reverse-tabnabbing.d.ts +10 -0
- package/dist/_security/detectors/reverse-tabnabbing.d.ts.map +1 -0
- package/dist/_security/detectors/reverse-tabnabbing.js +44 -0
- package/dist/_security/detectors/reverse-tabnabbing.js.map +1 -0
- package/dist/_security/index.d.ts +29 -0
- package/dist/_security/index.d.ts.map +1 -0
- package/dist/_security/index.js +36 -0
- package/dist/_security/index.js.map +1 -0
- package/dist/_security/response-meta.d.ts +28 -0
- package/dist/_security/response-meta.d.ts.map +1 -0
- package/dist/_security/response-meta.js +50 -0
- package/dist/_security/response-meta.js.map +1 -0
- package/dist/_security/serialized-dom.d.ts +20 -0
- package/dist/_security/serialized-dom.d.ts.map +1 -0
- package/dist/_security/serialized-dom.js +32 -0
- package/dist/_security/serialized-dom.js.map +1 -0
- package/dist/_security/suppress.d.ts +7 -0
- package/dist/_security/suppress.d.ts.map +1 -0
- package/dist/_security/suppress.js +8 -0
- package/dist/_security/suppress.js.map +1 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/assets.js +60 -6
- package/dist/assets.js.map +1 -1
- package/dist/build-report.d.ts +19 -0
- package/dist/build-report.d.ts.map +1 -1
- package/dist/build-report.js +20 -2
- package/dist/build-report.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +11 -1
- package/dist/markdown.js.map +1 -1
- package/dist/metadata.d.ts +21 -2
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +98 -27
- package/dist/metadata.js.map +1 -1
- package/dist/panels.d.ts +40 -5
- package/dist/panels.d.ts.map +1 -1
- package/dist/panels.js +175 -23
- package/dist/panels.js.map +1 -1
- package/dist/report-writer.d.ts +43 -0
- package/dist/report-writer.d.ts.map +1 -0
- package/dist/report-writer.js +65 -0
- package/dist/report-writer.js.map +1 -0
- package/dist/template.d.ts +19 -0
- package/dist/template.d.ts.map +1 -1
- package/dist/template.js +987 -72
- package/dist/template.js.map +1 -1
- package/package.json +28 -7
package/dist/template.js
CHANGED
|
@@ -1,51 +1,582 @@
|
|
|
1
|
-
// HTML composition for the self-contained report (P1 PRD §F.1
|
|
1
|
+
// HTML composition for the self-contained report (P1 PRD §F.1 + Phase 6
|
|
2
|
+
// editorial-postmortem revamp).
|
|
2
3
|
//
|
|
3
4
|
// `renderReportHtml` assembles the single-file document from already-prepared
|
|
4
5
|
// pieces (the caller — build-report.ts — does the extraction + encoding). This
|
|
5
|
-
// module owns the static shell: the report CSS, the
|
|
6
|
-
// panel containers,
|
|
7
|
-
// (player UMD/CSS, fflate UMD
|
|
8
|
-
|
|
6
|
+
// module owns the static shell: the report CSS, the hero header markup, the
|
|
7
|
+
// player + tabbed-panel containers, the in-page bootstrap script, and the
|
|
8
|
+
// FAB. The large vendored assets (player UMD/CSS, fflate UMD, both variable
|
|
9
|
+
// fonts) and the data payloads are passed in.
|
|
10
|
+
import { loadFflateGunzipSource, loadFrauncesItalic, loadFrauncesNormal, loadJetBrainsMonoNormal, loadPlayerCss, loadPlayerUmd, } from './assets.js';
|
|
9
11
|
import { escapeHtml, serializeForScript } from './html.js';
|
|
10
|
-
import {
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import { renderHero } from './metadata.js';
|
|
13
|
+
/**
|
|
14
|
+
* Compose the SHELL_CSS at build time so the base64-encoded woff2 strings can
|
|
15
|
+
* be interpolated into `@font-face` rules. Returns a single CSS string the
|
|
16
|
+
* report's `<style>` tag wraps. Kept as a function (not a const) so the woff2
|
|
17
|
+
* reads happen at build time, not import time — matters because the loaders
|
|
18
|
+
* touch the filesystem.
|
|
19
|
+
*
|
|
20
|
+
* Aesthetic: editorial postmortem. Dark slate background, off-white text,
|
|
21
|
+
* teal-and-amber accent palette. Fraunces serif for the hero headline + section
|
|
22
|
+
* heads (italic-emphasized clause carries the failure colour), JetBrains Mono
|
|
23
|
+
* variable for every data row + the eyebrow + the meta strip + the panels.
|
|
24
|
+
*/
|
|
25
|
+
function buildShellCss() {
|
|
26
|
+
const frauncesNormal = loadFrauncesNormal();
|
|
27
|
+
const frauncesItalic = loadFrauncesItalic();
|
|
28
|
+
const jbMonoNormal = loadJetBrainsMonoNormal();
|
|
29
|
+
// Inline @font-face declarations — each `url(data:...)` is a base64 woff2 that
|
|
30
|
+
// resolves with no network request. font-display:block is intentional: the
|
|
31
|
+
// serif headline is the first thing the eye lands on and a fallback flash
|
|
32
|
+
// would betray the design (it's <50 ms anyway, the data URL is local).
|
|
33
|
+
const fontFaces = `
|
|
34
|
+
@font-face {
|
|
35
|
+
font-family: 'Fraunces';
|
|
36
|
+
font-style: normal;
|
|
37
|
+
font-display: block;
|
|
38
|
+
font-weight: 100 900;
|
|
39
|
+
src: url(data:font/woff2;base64,${frauncesNormal}) format('woff2');
|
|
40
|
+
}
|
|
41
|
+
@font-face {
|
|
42
|
+
font-family: 'Fraunces';
|
|
43
|
+
font-style: italic;
|
|
44
|
+
font-display: block;
|
|
45
|
+
font-weight: 100 900;
|
|
46
|
+
src: url(data:font/woff2;base64,${frauncesItalic}) format('woff2');
|
|
47
|
+
}
|
|
48
|
+
@font-face {
|
|
49
|
+
font-family: 'JetBrains Mono';
|
|
50
|
+
font-style: normal;
|
|
51
|
+
font-display: block;
|
|
52
|
+
font-weight: 100 800;
|
|
53
|
+
src: url(data:font/woff2;base64,${jbMonoNormal}) format('woff2');
|
|
54
|
+
}`;
|
|
55
|
+
// CSS variables + reset + every component in document order. ~6 KB minified.
|
|
56
|
+
const styles = `
|
|
57
|
+
:root {
|
|
58
|
+
color-scheme: dark;
|
|
59
|
+
--bg: #0f1115;
|
|
60
|
+
--surface: #171a20;
|
|
61
|
+
--surface-2: #1d2027;
|
|
62
|
+
--border: #2a2e36;
|
|
63
|
+
--border-strong: #383d47;
|
|
64
|
+
--text: #e7e5e1;
|
|
65
|
+
--muted: #8a92a0;
|
|
66
|
+
--muted-strong: #b8bfca;
|
|
67
|
+
--teal: #5eead4;
|
|
68
|
+
--teal-dim: rgba(94, 234, 212, 0.18);
|
|
69
|
+
--amber: #f5a364;
|
|
70
|
+
--amber-dim: rgba(245, 163, 100, 0.18);
|
|
71
|
+
--warn: #f0c674;
|
|
72
|
+
--serif: 'Fraunces', ui-serif, Georgia, serif;
|
|
73
|
+
--mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
|
74
|
+
}
|
|
15
75
|
* { box-sizing: border-box; }
|
|
16
|
-
body { margin:0;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
76
|
+
html, body { margin: 0; padding: 0; }
|
|
77
|
+
body {
|
|
78
|
+
font-family: var(--mono);
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
line-height: 1.55;
|
|
81
|
+
color: var(--text);
|
|
82
|
+
background: var(--bg);
|
|
83
|
+
-webkit-font-smoothing: antialiased;
|
|
84
|
+
-moz-osx-font-smoothing: grayscale;
|
|
85
|
+
}
|
|
86
|
+
a { color: var(--teal); text-decoration: none; }
|
|
87
|
+
a:hover { text-decoration: underline; }
|
|
88
|
+
|
|
89
|
+
/* ===== Hero: "What failed" ===== */
|
|
90
|
+
.hero {
|
|
91
|
+
padding: 48px 48px 32px;
|
|
92
|
+
border-bottom: 1px solid var(--border);
|
|
93
|
+
background:
|
|
94
|
+
radial-gradient(1200px 400px at -10% -20%, var(--amber-dim), transparent 50%),
|
|
95
|
+
radial-gradient(800px 300px at 110% 120%, var(--teal-dim), transparent 50%),
|
|
96
|
+
var(--bg);
|
|
97
|
+
}
|
|
98
|
+
.eyebrow {
|
|
99
|
+
font-family: var(--mono);
|
|
100
|
+
font-size: 11px;
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
letter-spacing: 0.14em;
|
|
103
|
+
text-transform: uppercase;
|
|
104
|
+
color: var(--muted);
|
|
105
|
+
display: flex;
|
|
106
|
+
gap: 12px;
|
|
107
|
+
align-items: center;
|
|
108
|
+
margin-bottom: 18px;
|
|
109
|
+
flex-wrap: wrap;
|
|
110
|
+
}
|
|
111
|
+
.eyebrow .dot {
|
|
112
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
113
|
+
background: var(--amber);
|
|
114
|
+
box-shadow: 0 0 0 4px var(--amber-dim);
|
|
115
|
+
animation: pulse 2.6s ease-in-out infinite;
|
|
116
|
+
}
|
|
117
|
+
.eyebrow .status { color: var(--amber); font-weight: 700; }
|
|
118
|
+
.eyebrow .status.passed { color: var(--teal); }
|
|
119
|
+
.eyebrow .status.skipped { color: var(--muted); }
|
|
120
|
+
.eyebrow .status.broken { color: var(--amber); }
|
|
121
|
+
.eyebrow .sep { color: var(--border-strong); }
|
|
122
|
+
|
|
123
|
+
@keyframes pulse {
|
|
124
|
+
0%, 100% { box-shadow: 0 0 0 4px var(--amber-dim); }
|
|
125
|
+
50% { box-shadow: 0 0 0 8px rgba(245, 163, 100, 0.06); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
h1.what {
|
|
129
|
+
font-family: var(--serif);
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
font-size: clamp(28px, 4vw, 44px);
|
|
132
|
+
line-height: 1.15;
|
|
133
|
+
letter-spacing: -0.02em;
|
|
134
|
+
margin: 0 0 12px;
|
|
135
|
+
color: var(--text);
|
|
136
|
+
max-width: 56ch;
|
|
137
|
+
}
|
|
138
|
+
h1.what em {
|
|
139
|
+
font-style: italic;
|
|
140
|
+
color: var(--amber);
|
|
141
|
+
font-weight: 500;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.error-message {
|
|
145
|
+
font-family: var(--mono);
|
|
146
|
+
font-size: 13px;
|
|
147
|
+
line-height: 1.65;
|
|
148
|
+
color: var(--muted-strong);
|
|
149
|
+
padding: 14px 16px;
|
|
150
|
+
border-left: 2px solid var(--amber);
|
|
151
|
+
background: rgba(245, 163, 100, 0.05);
|
|
152
|
+
margin: 18px 0 24px;
|
|
153
|
+
white-space: pre-wrap;
|
|
154
|
+
max-width: 90ch;
|
|
155
|
+
overflow-x: auto;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.meta-strip {
|
|
159
|
+
display: flex;
|
|
160
|
+
flex-wrap: wrap;
|
|
161
|
+
gap: 0;
|
|
162
|
+
border-top: 1px solid var(--border);
|
|
163
|
+
margin: 24px -48px -32px;
|
|
164
|
+
padding: 16px 48px;
|
|
165
|
+
background: rgba(0, 0, 0, 0.15);
|
|
166
|
+
}
|
|
167
|
+
.meta-strip .item {
|
|
168
|
+
padding: 4px 24px 4px 0;
|
|
169
|
+
margin-right: 24px;
|
|
170
|
+
border-right: 1px solid var(--border);
|
|
171
|
+
font-size: 12px;
|
|
172
|
+
}
|
|
173
|
+
.meta-strip .item:last-child { border-right: 0; margin-right: 0; }
|
|
174
|
+
.meta-strip .label {
|
|
175
|
+
color: var(--muted);
|
|
176
|
+
text-transform: uppercase;
|
|
177
|
+
letter-spacing: 0.08em;
|
|
178
|
+
font-size: 10px;
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
display: block;
|
|
181
|
+
margin-bottom: 2px;
|
|
182
|
+
}
|
|
183
|
+
.meta-strip .value { color: var(--text); font-weight: 500; }
|
|
184
|
+
.meta-strip .value a { color: var(--teal); font-weight: 500; }
|
|
185
|
+
.meta-strip .value code {
|
|
186
|
+
font-family: var(--mono);
|
|
187
|
+
font-size: 12px;
|
|
188
|
+
color: var(--teal);
|
|
189
|
+
background: transparent;
|
|
190
|
+
padding: 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ===== Banner (size pruned) ===== */
|
|
194
|
+
.banner {
|
|
195
|
+
padding: 10px 48px;
|
|
196
|
+
background: rgba(240, 198, 116, 0.12);
|
|
197
|
+
border-bottom: 1px solid var(--border);
|
|
198
|
+
color: var(--warn);
|
|
199
|
+
font-size: 12px;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ===== Main: replay + investigation ===== */
|
|
203
|
+
main.investigation {
|
|
204
|
+
display: grid;
|
|
205
|
+
grid-template-columns: minmax(0, 1.5fr) minmax(420px, 1fr);
|
|
206
|
+
min-height: 70vh;
|
|
207
|
+
border-bottom: 1px solid var(--border);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* Replay column */
|
|
211
|
+
.replay {
|
|
212
|
+
padding: 24px 32px;
|
|
213
|
+
border-right: 1px solid var(--border);
|
|
214
|
+
background: var(--bg);
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
gap: 18px;
|
|
218
|
+
min-width: 0;
|
|
219
|
+
}
|
|
220
|
+
.replay-header {
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: baseline;
|
|
223
|
+
justify-content: space-between;
|
|
224
|
+
gap: 24px;
|
|
225
|
+
flex-wrap: wrap;
|
|
226
|
+
}
|
|
227
|
+
.replay-header h2 {
|
|
228
|
+
font-family: var(--serif);
|
|
229
|
+
font-weight: 500;
|
|
230
|
+
font-style: italic;
|
|
231
|
+
font-size: 20px;
|
|
232
|
+
margin: 0;
|
|
233
|
+
color: var(--text);
|
|
234
|
+
}
|
|
235
|
+
.replay-header .timestamp {
|
|
236
|
+
font-family: var(--mono);
|
|
237
|
+
font-size: 11px;
|
|
238
|
+
color: var(--muted);
|
|
239
|
+
letter-spacing: 0.04em;
|
|
240
|
+
}
|
|
241
|
+
#player {
|
|
242
|
+
background: var(--surface);
|
|
243
|
+
border: 1px solid var(--border);
|
|
244
|
+
border-radius: 4px;
|
|
245
|
+
position: relative;
|
|
246
|
+
overflow: hidden;
|
|
247
|
+
width: 100%;
|
|
248
|
+
display: flex;
|
|
249
|
+
align-items: center;
|
|
250
|
+
justify-content: center;
|
|
251
|
+
}
|
|
252
|
+
#player .empty {
|
|
253
|
+
position: absolute; inset: 0;
|
|
254
|
+
display: flex; align-items: center; justify-content: center;
|
|
255
|
+
color: var(--muted);
|
|
256
|
+
font-family: var(--mono);
|
|
257
|
+
font-size: 12px;
|
|
258
|
+
text-align: center;
|
|
259
|
+
padding: 16px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* Investigation panels column */
|
|
263
|
+
.panels {
|
|
264
|
+
background: var(--surface);
|
|
265
|
+
display: flex;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
overflow: hidden;
|
|
268
|
+
min-width: 0;
|
|
269
|
+
}
|
|
270
|
+
.tabs {
|
|
271
|
+
display: flex;
|
|
272
|
+
border-bottom: 1px solid var(--border);
|
|
273
|
+
background: var(--surface-2);
|
|
274
|
+
overflow-x: auto;
|
|
275
|
+
scrollbar-width: none;
|
|
276
|
+
}
|
|
277
|
+
.tabs::-webkit-scrollbar { display: none; }
|
|
278
|
+
.tab {
|
|
279
|
+
padding: 14px 20px;
|
|
280
|
+
font-family: var(--mono);
|
|
281
|
+
font-size: 12px;
|
|
282
|
+
color: var(--muted);
|
|
283
|
+
cursor: pointer;
|
|
284
|
+
border: 0;
|
|
285
|
+
background: transparent;
|
|
286
|
+
border-bottom: 2px solid transparent;
|
|
287
|
+
text-transform: lowercase;
|
|
288
|
+
letter-spacing: 0.04em;
|
|
289
|
+
transition: color 0.15s ease, border-color 0.15s ease;
|
|
290
|
+
font-weight: 500;
|
|
291
|
+
white-space: nowrap;
|
|
292
|
+
}
|
|
293
|
+
.tab:hover { color: var(--muted-strong); }
|
|
294
|
+
.tab.active {
|
|
295
|
+
color: var(--text);
|
|
296
|
+
border-bottom-color: var(--teal);
|
|
297
|
+
}
|
|
298
|
+
.tab .count {
|
|
299
|
+
margin-left: 6px;
|
|
300
|
+
padding: 1px 6px;
|
|
301
|
+
border-radius: 8px;
|
|
302
|
+
background: var(--border);
|
|
303
|
+
color: var(--muted);
|
|
304
|
+
font-size: 10px;
|
|
305
|
+
font-weight: 600;
|
|
306
|
+
}
|
|
307
|
+
.tab.active .count { background: var(--teal-dim); color: var(--teal); }
|
|
308
|
+
/* "soon" pill on the placeholder Actions / Timeline tabs (audit A-10) — warns
|
|
309
|
+
the visitor the pane is a coming-soon stub before they click it. */
|
|
310
|
+
.tab .soon-pill {
|
|
311
|
+
margin-left: 6px;
|
|
312
|
+
padding: 1px 6px;
|
|
313
|
+
border-radius: 8px;
|
|
314
|
+
border: 1px solid var(--border-strong);
|
|
315
|
+
background: transparent;
|
|
316
|
+
color: var(--muted);
|
|
317
|
+
font-size: 9px;
|
|
318
|
+
font-weight: 600;
|
|
319
|
+
text-transform: uppercase;
|
|
320
|
+
letter-spacing: 0.08em;
|
|
321
|
+
vertical-align: middle;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.panel-toolbar {
|
|
325
|
+
padding: 10px 16px;
|
|
326
|
+
border-bottom: 1px solid var(--border);
|
|
327
|
+
display: flex;
|
|
328
|
+
gap: 12px;
|
|
329
|
+
align-items: center;
|
|
330
|
+
flex-wrap: wrap;
|
|
331
|
+
}
|
|
332
|
+
.panel-filter {
|
|
333
|
+
flex: 1;
|
|
334
|
+
min-width: 160px;
|
|
335
|
+
background: var(--bg);
|
|
336
|
+
border: 1px solid var(--border);
|
|
337
|
+
border-radius: 4px;
|
|
338
|
+
padding: 6px 12px;
|
|
339
|
+
color: var(--text);
|
|
340
|
+
font-family: var(--mono);
|
|
341
|
+
font-size: 12px;
|
|
342
|
+
transition: border-color 0.15s ease;
|
|
343
|
+
}
|
|
344
|
+
.panel-filter:focus { outline: none; border-color: var(--teal); }
|
|
345
|
+
.panel-filter::placeholder { color: var(--muted); }
|
|
346
|
+
.filter-chip {
|
|
347
|
+
padding: 4px 10px;
|
|
348
|
+
border-radius: 12px;
|
|
349
|
+
border: 1px solid var(--border);
|
|
350
|
+
font-family: var(--mono);
|
|
351
|
+
font-size: 10px;
|
|
352
|
+
text-transform: uppercase;
|
|
353
|
+
letter-spacing: 0.08em;
|
|
354
|
+
color: var(--muted);
|
|
355
|
+
background: transparent;
|
|
356
|
+
cursor: pointer;
|
|
357
|
+
transition: all 0.15s ease;
|
|
358
|
+
}
|
|
359
|
+
.filter-chip:hover { border-color: var(--border-strong); color: var(--muted-strong); }
|
|
360
|
+
.filter-chip.active {
|
|
361
|
+
border-color: var(--amber);
|
|
362
|
+
color: var(--amber);
|
|
363
|
+
background: var(--amber-dim);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.panel-pane { display: none; flex-direction: column; min-height: 0; flex: 1; }
|
|
367
|
+
.panel-pane.active { display: flex; }
|
|
368
|
+
|
|
369
|
+
.panel-content {
|
|
370
|
+
flex: 1;
|
|
371
|
+
overflow-y: auto;
|
|
372
|
+
padding: 8px 0;
|
|
373
|
+
}
|
|
374
|
+
.panel-empty {
|
|
375
|
+
padding: 24px 16px;
|
|
376
|
+
color: var(--muted);
|
|
377
|
+
font-style: italic;
|
|
378
|
+
text-align: center;
|
|
379
|
+
font-size: 12px;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* Time-sync: rows whose data-time is past the current playback time. */
|
|
383
|
+
.panel-content .row.is-future { display: none; }
|
|
384
|
+
.panel-content .row { cursor: pointer; }
|
|
385
|
+
|
|
386
|
+
/* Pending placeholder shown when rows exist but the playhead hasn't reached
|
|
387
|
+
any of them yet. Sibling of .panel-content inside .panel-pane. */
|
|
388
|
+
.panel-pending {
|
|
389
|
+
padding: 24px;
|
|
390
|
+
text-align: center;
|
|
391
|
+
color: var(--muted);
|
|
392
|
+
font-family: var(--mono);
|
|
393
|
+
font-size: 12px;
|
|
394
|
+
letter-spacing: 0.02em;
|
|
395
|
+
}
|
|
396
|
+
.panel-pending.is-hidden { display: none; }
|
|
397
|
+
|
|
398
|
+
/* Tab badge: <current> in default color, "/ <total>" in --muted. */
|
|
399
|
+
.tab .count-total {
|
|
400
|
+
color: var(--muted);
|
|
401
|
+
margin-left: 4px;
|
|
402
|
+
font-weight: normal;
|
|
403
|
+
}
|
|
404
|
+
.row {
|
|
405
|
+
padding: 8px 16px;
|
|
406
|
+
border-bottom: 1px solid var(--border);
|
|
407
|
+
display: grid;
|
|
408
|
+
grid-template-columns: max-content max-content 1fr;
|
|
409
|
+
gap: 14px;
|
|
410
|
+
font-family: var(--mono);
|
|
411
|
+
font-size: 11.5px;
|
|
412
|
+
line-height: 1.55;
|
|
413
|
+
transition: background 0.1s ease;
|
|
414
|
+
}
|
|
415
|
+
.row.error { background: rgba(245, 163, 100, 0.04); }
|
|
416
|
+
.row.hidden { display: none; }
|
|
417
|
+
.row .ts { color: var(--muted); font-size: 10.5px; padding-top: 1px; white-space: nowrap; }
|
|
418
|
+
.row .lvl {
|
|
419
|
+
font-weight: 700;
|
|
420
|
+
font-size: 10px;
|
|
421
|
+
text-transform: uppercase;
|
|
422
|
+
letter-spacing: 0.08em;
|
|
423
|
+
padding-top: 2px;
|
|
424
|
+
white-space: nowrap;
|
|
425
|
+
}
|
|
426
|
+
.row.error .lvl { color: var(--amber); }
|
|
427
|
+
.row.warn .lvl { color: var(--warn); }
|
|
428
|
+
.row.log .lvl,
|
|
429
|
+
.row.info .lvl,
|
|
430
|
+
.row.debug .lvl { color: var(--muted); }
|
|
431
|
+
.row .msg { color: var(--text); word-break: break-word; white-space: pre-wrap; }
|
|
432
|
+
.row .msg .method { color: var(--muted-strong); font-weight: 600; margin-right: 6px; }
|
|
433
|
+
.row.row-net .lvl.st-4,
|
|
434
|
+
.row.row-net .lvl.st-5 { color: var(--amber); }
|
|
435
|
+
.row.row-net .lvl.st-0 { color: var(--amber); } /* net error */
|
|
436
|
+
|
|
437
|
+
/* Advisory security findings: not time-synced (no seek), so not clickable. */
|
|
438
|
+
.row.row-sec { cursor: default; grid-template-columns: max-content 1fr; }
|
|
439
|
+
.row.row-sec.sev-high .lvl { color: var(--amber); }
|
|
440
|
+
.row.row-sec.sev-medium .lvl { color: var(--warn); }
|
|
441
|
+
.row.row-sec.sev-low .lvl { color: var(--muted-strong); }
|
|
442
|
+
.row.row-sec .sec-detail {
|
|
443
|
+
color: var(--muted);
|
|
444
|
+
font-size: 10.5px;
|
|
445
|
+
margin-top: 3px;
|
|
446
|
+
white-space: pre-wrap;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* Security panel subtitle (advisory framing) shown in its toolbar. */
|
|
450
|
+
.panel-toolbar-sec { flex-direction: column; align-items: stretch; gap: 8px; }
|
|
451
|
+
.panel-subtitle {
|
|
452
|
+
color: var(--muted);
|
|
453
|
+
font-size: 11px;
|
|
454
|
+
font-style: italic;
|
|
455
|
+
line-height: 1.5;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* "Coming soon" pane content (Actions / Timeline tabs reserved for follow-ups) */
|
|
459
|
+
.coming-soon {
|
|
460
|
+
padding: 32px 16px;
|
|
461
|
+
text-align: center;
|
|
462
|
+
color: var(--muted);
|
|
463
|
+
font-family: var(--mono);
|
|
464
|
+
font-size: 12px;
|
|
465
|
+
display: flex;
|
|
466
|
+
flex-direction: column;
|
|
467
|
+
gap: 6px;
|
|
468
|
+
}
|
|
469
|
+
.coming-soon strong { color: var(--text); font-weight: 600; }
|
|
470
|
+
|
|
471
|
+
/* Floating action button: Copy as Markdown */
|
|
472
|
+
.fab {
|
|
473
|
+
position: fixed;
|
|
474
|
+
right: 28px;
|
|
475
|
+
bottom: 28px;
|
|
476
|
+
z-index: 100;
|
|
477
|
+
display: flex;
|
|
478
|
+
align-items: center;
|
|
479
|
+
gap: 10px;
|
|
480
|
+
padding: 14px 22px;
|
|
481
|
+
background: var(--surface);
|
|
482
|
+
border: 1px solid var(--border-strong);
|
|
483
|
+
border-radius: 999px;
|
|
484
|
+
color: var(--text);
|
|
485
|
+
font-family: var(--mono);
|
|
486
|
+
font-size: 12px;
|
|
487
|
+
font-weight: 500;
|
|
488
|
+
cursor: pointer;
|
|
489
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(94, 234, 212, 0.06);
|
|
490
|
+
transition: all 0.2s ease;
|
|
491
|
+
}
|
|
492
|
+
.fab:hover {
|
|
493
|
+
border-color: var(--teal);
|
|
494
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(94, 234, 212, 0.3);
|
|
495
|
+
transform: translateY(-1px);
|
|
496
|
+
}
|
|
497
|
+
.fab.copied {
|
|
498
|
+
border-color: var(--teal);
|
|
499
|
+
color: var(--teal);
|
|
500
|
+
box-shadow: 0 0 0 4px var(--teal-dim), 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
501
|
+
}
|
|
502
|
+
.fab .icon {
|
|
503
|
+
width: 14px; height: 14px;
|
|
504
|
+
border: 1.5px solid var(--teal);
|
|
505
|
+
border-radius: 3px;
|
|
506
|
+
position: relative;
|
|
507
|
+
flex-shrink: 0;
|
|
508
|
+
}
|
|
509
|
+
.fab .icon::after {
|
|
510
|
+
content: '';
|
|
511
|
+
position: absolute;
|
|
512
|
+
top: -3px; left: 2px;
|
|
513
|
+
width: 10px; height: 11px;
|
|
514
|
+
border: 1.5px solid var(--teal);
|
|
515
|
+
border-radius: 2px;
|
|
516
|
+
background: var(--surface);
|
|
517
|
+
transition: opacity 0.2s ease;
|
|
518
|
+
}
|
|
519
|
+
.fab.copied .icon { border-color: var(--teal); }
|
|
520
|
+
.fab.copied .icon::after { opacity: 0; }
|
|
521
|
+
.fab.copied .icon::before {
|
|
522
|
+
content: '';
|
|
523
|
+
position: absolute;
|
|
524
|
+
top: 1px; left: 4px;
|
|
525
|
+
width: 4px; height: 8px;
|
|
526
|
+
border-right: 1.5px solid var(--teal);
|
|
527
|
+
border-bottom: 1.5px solid var(--teal);
|
|
528
|
+
transform: rotate(45deg);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/* Footer */
|
|
532
|
+
footer.attrib {
|
|
533
|
+
padding: 32px 48px;
|
|
534
|
+
border-top: 1px solid var(--border);
|
|
535
|
+
text-align: center;
|
|
536
|
+
font-family: var(--mono);
|
|
537
|
+
font-size: 11px;
|
|
538
|
+
color: var(--muted);
|
|
539
|
+
}
|
|
540
|
+
footer.attrib a { color: var(--muted-strong); }
|
|
541
|
+
footer.attrib em {
|
|
542
|
+
font-family: var(--serif);
|
|
543
|
+
font-style: italic;
|
|
544
|
+
font-size: 12px;
|
|
545
|
+
color: var(--muted-strong);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/* Mobile: stack the replay above panels; panels become full-width. */
|
|
549
|
+
@media (max-width: 900px) {
|
|
550
|
+
.hero { padding: 32px 24px 24px; }
|
|
551
|
+
.meta-strip { margin: 16px -24px -24px; padding: 12px 24px; gap: 4px 0; }
|
|
552
|
+
.meta-strip .item { border-right: 0; padding: 4px 0; margin-right: 24px; }
|
|
553
|
+
.banner { padding: 10px 24px; }
|
|
554
|
+
main.investigation { grid-template-columns: 1fr; }
|
|
555
|
+
.replay { border-right: 0; border-bottom: 1px solid var(--border); padding: 20px 24px; }
|
|
556
|
+
.panels { min-height: 50vh; }
|
|
557
|
+
.fab { right: 16px; bottom: 16px; padding: 12px 18px; font-size: 11px; }
|
|
558
|
+
h1.what { font-size: 28px; }
|
|
559
|
+
footer.attrib { padding: 24px 24px; }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/* rrweb-player wrapper: ensure it fills #player without overflow weirdness.
|
|
563
|
+
The controller has a white background; default text inherits our dark-theme
|
|
564
|
+
--text (near-white), making 2x/4x/8x speed buttons and the "skip inactive"
|
|
565
|
+
label invisible. Restore a dark text color scoped to the player controls. */
|
|
566
|
+
#player .rr-player { background: var(--surface) !important; }
|
|
567
|
+
#player .rr-controller__btns button { color: #11103e; }
|
|
568
|
+
#player .rr-controller__btns button.active { color: #fff; }
|
|
569
|
+
#player .rr-controller .switch .label { color: #11103e; }
|
|
42
570
|
`;
|
|
571
|
+
return fontFaces + styles;
|
|
572
|
+
}
|
|
43
573
|
/**
|
|
44
574
|
* The in-page bootstrap (runs at view time, plain ES5-ish JS so it executes in
|
|
45
575
|
* any browser without a build step). Reads the embedded payloads, decompresses
|
|
46
576
|
* the events with the inlined fflate, mounts rrweb-player, renders the panels,
|
|
47
|
-
*
|
|
48
|
-
*
|
|
577
|
+
* wires up tab switching + filter input + filter chips, and animates the
|
|
578
|
+
* Copy-as-Markdown FAB. Authored as a single string so it ships verbatim in a
|
|
579
|
+
* `<script>` — it must not reference any TS/Node symbol.
|
|
49
580
|
*/
|
|
50
581
|
const BOOTSTRAP = `
|
|
51
582
|
(function () {
|
|
@@ -53,21 +584,49 @@ const BOOTSTRAP = `
|
|
|
53
584
|
var bin = atob(b64);
|
|
54
585
|
var bytes = new Uint8Array(bin.length);
|
|
55
586
|
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
587
|
+
if (bytes.length === 0) return [];
|
|
56
588
|
var json = fflate.strFromU8(fflate.gunzipSync(bytes));
|
|
57
589
|
return JSON.parse(json);
|
|
58
590
|
}
|
|
59
591
|
|
|
60
592
|
var events = decodeEvents(EVENTS_GZ_B64);
|
|
61
593
|
|
|
62
|
-
// rrweb-player
|
|
63
|
-
// truncated/empty capture degrades to a message instead of throwing.
|
|
594
|
+
// ---- rrweb-player mount ------------------------------------------------
|
|
64
595
|
var playerEl = document.getElementById('player');
|
|
596
|
+
var rrPlayer = null;
|
|
65
597
|
if (events.length >= 2 && typeof rrwebPlayer !== 'undefined') {
|
|
66
|
-
|
|
598
|
+
// Size the player to fill its container, preserving the recording's
|
|
599
|
+
// aspect ratio. rrweb-player's defaults (1024x576) overflow most layouts.
|
|
600
|
+
var recAspect = (META.viewport && META.viewport.width && META.viewport.height)
|
|
601
|
+
? META.viewport.width / META.viewport.height
|
|
602
|
+
: 16 / 10;
|
|
603
|
+
var containerW = playerEl.clientWidth || 1024;
|
|
604
|
+
var maxIframeH = Math.max(window.innerHeight - 360, 280);
|
|
605
|
+
var iframeH = Math.min(Math.round(containerW / recAspect), maxIframeH);
|
|
606
|
+
var iframeW = Math.min(containerW, Math.round(iframeH * recAspect));
|
|
607
|
+
rrPlayer = new rrwebPlayer({
|
|
608
|
+
target: playerEl,
|
|
609
|
+
props: {
|
|
610
|
+
events: events,
|
|
611
|
+
width: iframeW,
|
|
612
|
+
height: iframeH,
|
|
613
|
+
showController: true,
|
|
614
|
+
autoPlay: false,
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
// Expose for headless probes (visible to dev-tools; no runtime impact on
|
|
618
|
+
// end users — the report HTML is read-only static content).
|
|
619
|
+
try { window.__tracelanePlayer = rrPlayer; } catch (_) {}
|
|
67
620
|
} else {
|
|
68
|
-
|
|
621
|
+
var msg = document.createElement('div');
|
|
622
|
+
msg.className = 'empty';
|
|
623
|
+
msg.textContent = events.length === 0
|
|
624
|
+
? 'No recorded events — the test crashed before the recorder produced a snapshot.'
|
|
625
|
+
: 'Only one event recorded — not enough timeline to replay.';
|
|
626
|
+
playerEl.appendChild(msg);
|
|
69
627
|
}
|
|
70
628
|
|
|
629
|
+
// ---- Helpers -----------------------------------------------------------
|
|
71
630
|
function el(tag, cls, text) {
|
|
72
631
|
var n = document.createElement(tag);
|
|
73
632
|
if (cls) n.className = cls;
|
|
@@ -75,37 +634,287 @@ const BOOTSTRAP = `
|
|
|
75
634
|
return n;
|
|
76
635
|
}
|
|
77
636
|
|
|
78
|
-
|
|
79
|
-
|
|
637
|
+
// "+1:23.456" relative timestamp from the session start.
|
|
638
|
+
function fmtRelTs(ts, firstTs) {
|
|
639
|
+
if (!firstTs || !ts) return '+0:00.000';
|
|
640
|
+
var delta = Math.max(0, ts - firstTs);
|
|
641
|
+
var seconds = delta / 1000;
|
|
642
|
+
var minutes = Math.floor(seconds / 60);
|
|
643
|
+
var rem = seconds - minutes * 60;
|
|
644
|
+
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
645
|
+
var ms = String(Math.floor((rem - Math.floor(rem)) * 1000));
|
|
646
|
+
while (ms.length < 3) ms = '0' + ms;
|
|
647
|
+
return '+' + minutes + ':' + pad(Math.floor(rem)) + '.' + ms;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ---- Panel rendering ---------------------------------------------------
|
|
651
|
+
function renderConsole(container, rows, firstTs) {
|
|
652
|
+
if (!rows.length) {
|
|
653
|
+
container.appendChild(el('div', 'panel-empty', 'No console output captured.'));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
80
656
|
for (var i = 0; i < rows.length; i++) {
|
|
81
657
|
var r = rows[i];
|
|
82
|
-
var
|
|
83
|
-
row
|
|
84
|
-
|
|
658
|
+
var lvl = (r.level || 'log').toLowerCase();
|
|
659
|
+
var row = el('div', 'row is-future ' + lvl);
|
|
660
|
+
var relMs = firstTs ? Math.max(0, r.timestamp - firstTs) : 0;
|
|
661
|
+
row.setAttribute('data-time', String(relMs));
|
|
662
|
+
// a11y: rows are click-to-seek; expose them to keyboard + AT users.
|
|
663
|
+
row.setAttribute('tabindex', '0');
|
|
664
|
+
row.setAttribute('role', 'button');
|
|
665
|
+
row.setAttribute('aria-label', 'Seek to ' + fmtRelTs(r.timestamp, firstTs));
|
|
666
|
+
row.appendChild(el('span', 'ts', fmtRelTs(r.timestamp, firstTs)));
|
|
667
|
+
row.appendChild(el('span', 'lvl', lvl));
|
|
668
|
+
row.appendChild(el('span', 'msg', r.message));
|
|
669
|
+
row.setAttribute('data-level', lvl);
|
|
670
|
+
row.setAttribute('data-text', r.message.toLowerCase());
|
|
85
671
|
container.appendChild(row);
|
|
86
672
|
}
|
|
87
673
|
}
|
|
88
674
|
|
|
89
|
-
function renderNetwork(container, rows) {
|
|
90
|
-
if (!rows.length) {
|
|
675
|
+
function renderNetwork(container, rows, firstTs) {
|
|
676
|
+
if (!rows.length) {
|
|
677
|
+
container.appendChild(el('div', 'panel-empty', 'No failed network requests captured.'));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
91
680
|
for (var i = 0; i < rows.length; i++) {
|
|
92
681
|
var r = rows[i];
|
|
93
|
-
var row = el('div', 'row');
|
|
94
|
-
|
|
95
|
-
row.
|
|
682
|
+
var row = el('div', 'row row-net is-future error');
|
|
683
|
+
var relMs = firstTs ? Math.max(0, r.timestamp - firstTs) : 0;
|
|
684
|
+
row.setAttribute('data-time', String(relMs));
|
|
685
|
+
// a11y: rows are click-to-seek; expose them to keyboard + AT users.
|
|
686
|
+
row.setAttribute('tabindex', '0');
|
|
687
|
+
row.setAttribute('role', 'button');
|
|
688
|
+
row.setAttribute('aria-label', 'Seek to ' + fmtRelTs(r.timestamp, firstTs));
|
|
689
|
+
row.appendChild(el('span', 'ts', fmtRelTs(r.timestamp, firstTs)));
|
|
690
|
+
var stCls = 'lvl st-' + String(r.status).charAt(0);
|
|
691
|
+
row.appendChild(el('span', stCls, String(r.status)));
|
|
692
|
+
var msg = el('span', 'msg');
|
|
693
|
+
if (r.method) {
|
|
694
|
+
var m = el('span', 'method', r.method);
|
|
695
|
+
msg.appendChild(m);
|
|
696
|
+
}
|
|
697
|
+
msg.appendChild(document.createTextNode(r.url));
|
|
698
|
+
row.appendChild(msg);
|
|
699
|
+
var text = (r.method || '') + ' ' + r.url + ' ' + String(r.status);
|
|
700
|
+
row.setAttribute('data-text', text.toLowerCase());
|
|
701
|
+
container.appendChild(row);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Advisory security-hygiene findings (Task 12). Unlike console/network rows
|
|
706
|
+
// these are NOT time-synced (a finding has no single moment in the replay) —
|
|
707
|
+
// each renders as a static, always-visible row: "[severity] title — evidence"
|
|
708
|
+
// with the detail as muted secondary text. The container is only present in
|
|
709
|
+
// the DOM when there are findings (the tab + pane are omitted when empty).
|
|
710
|
+
function renderSecurity(container, findings) {
|
|
711
|
+
if (!container) return;
|
|
712
|
+
if (!findings.length) {
|
|
713
|
+
container.appendChild(el('div', 'panel-empty', 'No advisory security findings.'));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
for (var i = 0; i < findings.length; i++) {
|
|
717
|
+
var f = findings[i];
|
|
718
|
+
var sev = (f.severity || 'low').toLowerCase();
|
|
719
|
+
var row = el('div', 'row row-sec sev-' + sev);
|
|
720
|
+
row.appendChild(el('span', 'lvl', sev));
|
|
721
|
+
var msg = el('span', 'msg');
|
|
722
|
+
msg.appendChild(document.createTextNode(f.title + ' — ' + f.evidence));
|
|
723
|
+
if (f.detail) {
|
|
724
|
+
msg.appendChild(el('div', 'sec-detail', f.detail));
|
|
725
|
+
}
|
|
726
|
+
row.appendChild(msg);
|
|
727
|
+
var text = (sev + ' ' + f.title + ' ' + f.evidence + ' ' + (f.detail || '')).toLowerCase();
|
|
728
|
+
row.setAttribute('data-text', text);
|
|
96
729
|
container.appendChild(row);
|
|
97
730
|
}
|
|
98
731
|
}
|
|
99
732
|
|
|
100
|
-
renderConsole(document.getElementById('console-rows'), CONSOLE);
|
|
101
|
-
renderNetwork(document.getElementById('network-rows'), NETWORK);
|
|
733
|
+
renderConsole(document.getElementById('console-rows'), CONSOLE, FIRST_TS);
|
|
734
|
+
renderNetwork(document.getElementById('network-rows'), NETWORK, FIRST_TS);
|
|
735
|
+
renderSecurity(document.getElementById('security-rows'), SECURITY);
|
|
736
|
+
|
|
737
|
+
// ---- Time-sync: reveal rows as playback advances ----------------------
|
|
738
|
+
// TODO(perf): re-queries DOM and re-parses data-time on every tick (~30 Hz).
|
|
739
|
+
// Fine at current demo scale (~10 rows). When Actions / Timeline panels ship
|
|
740
|
+
// and reports start carrying hundreds of entries, cache [rows, times] once
|
|
741
|
+
// after render and skip work when t === lastT.
|
|
742
|
+
function tickPanels(currentMs) {
|
|
743
|
+
var t = Math.max(0, Math.floor(currentMs || 0));
|
|
744
|
+
var names = ['console', 'network'];
|
|
745
|
+
for (var n = 0; n < names.length; n++) {
|
|
746
|
+
var name = names[n];
|
|
747
|
+
var container = document.getElementById(name + '-rows');
|
|
748
|
+
var pending = document.getElementById(name + '-pending');
|
|
749
|
+
var badgeCurrent = document.querySelector(
|
|
750
|
+
'.tab[data-pane="pane-' + name + '"] .count'
|
|
751
|
+
);
|
|
752
|
+
if (!container) continue;
|
|
753
|
+
var rows = container.querySelectorAll('.row');
|
|
754
|
+
// Detect "was at bottom" BEFORE flipping classes so auto-scroll only
|
|
755
|
+
// triggers when the user hasn't scrolled up to inspect older entries.
|
|
756
|
+
var wasAtBottom =
|
|
757
|
+
container.scrollHeight - container.scrollTop - container.clientHeight <= 20;
|
|
758
|
+
var visibleCount = 0;
|
|
759
|
+
var hasAnyRows = rows.length > 0;
|
|
760
|
+
for (var i = 0; i < rows.length; i++) {
|
|
761
|
+
var rowTime = parseInt(rows[i].getAttribute('data-time') || '0', 10);
|
|
762
|
+
var isFuture = rowTime > t;
|
|
763
|
+
rows[i].classList.toggle('is-future', isFuture);
|
|
764
|
+
if (!isFuture && !rows[i].classList.contains('hidden')) visibleCount++;
|
|
765
|
+
}
|
|
766
|
+
if (badgeCurrent) badgeCurrent.textContent = String(visibleCount);
|
|
767
|
+
if (pending) {
|
|
768
|
+
// Pending placeholder shows when rows exist but none visible yet.
|
|
769
|
+
pending.classList.toggle('is-hidden', !hasAnyRows || visibleCount > 0);
|
|
770
|
+
}
|
|
771
|
+
if (wasAtBottom && visibleCount > 0) {
|
|
772
|
+
container.scrollTop = container.scrollHeight;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (rrPlayer && typeof rrPlayer.addEventListener === 'function') {
|
|
778
|
+
rrPlayer.addEventListener('ui-update-current-time', function (e) {
|
|
779
|
+
// rrweb-player's CustomEvent shape: { payload: <ms> } in alpha.4. If
|
|
780
|
+
// the bundled version ever changes shape, fall back to reading the
|
|
781
|
+
// replayer directly.
|
|
782
|
+
var payload = e && e.payload;
|
|
783
|
+
if (typeof payload !== 'number') {
|
|
784
|
+
try { payload = rrPlayer.getReplayer().getCurrentTime(); } catch (_) { payload = 0; }
|
|
785
|
+
}
|
|
786
|
+
tickPanels(payload);
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
tickPanels(0);
|
|
790
|
+
|
|
791
|
+
// ---- Click/keyboard-to-seek: activating a row jumps the player ---------
|
|
792
|
+
// Rows are role="button" tabindex="0", so they're keyboard-focusable; Enter
|
|
793
|
+
// and Space activate them the same way a click does (a11y — audit A-7).
|
|
794
|
+
if (rrPlayer && typeof rrPlayer.goto === 'function') {
|
|
795
|
+
var seekContainers = ['console-rows', 'network-rows'];
|
|
796
|
+
var seekFromEvent = function (ctr, ev) {
|
|
797
|
+
var target = ev.target;
|
|
798
|
+
// Walk up to the .row ancestor (events may land on inner spans).
|
|
799
|
+
while (target && target !== ctr && !target.classList.contains('row')) {
|
|
800
|
+
target = target.parentNode;
|
|
801
|
+
}
|
|
802
|
+
if (!target || target === ctr) return;
|
|
803
|
+
var t = parseInt(target.getAttribute('data-time') || '0', 10);
|
|
804
|
+
if (isFinite(t) && t >= 0) {
|
|
805
|
+
rrPlayer.goto(t, false);
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
for (var sc = 0; sc < seekContainers.length; sc++) {
|
|
809
|
+
(function (containerId) {
|
|
810
|
+
var ctr = document.getElementById(containerId);
|
|
811
|
+
if (!ctr) return;
|
|
812
|
+
ctr.addEventListener('click', function (ev) {
|
|
813
|
+
seekFromEvent(ctr, ev);
|
|
814
|
+
});
|
|
815
|
+
ctr.addEventListener('keydown', function (ev) {
|
|
816
|
+
if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar') {
|
|
817
|
+
ev.preventDefault();
|
|
818
|
+
seekFromEvent(ctr, ev);
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
})(seekContainers[sc]);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ---- Tab switching -----------------------------------------------------
|
|
826
|
+
var tabs = document.querySelectorAll('.tab');
|
|
827
|
+
var panes = document.querySelectorAll('.panel-pane');
|
|
828
|
+
for (var t = 0; t < tabs.length; t++) {
|
|
829
|
+
(function (tab) {
|
|
830
|
+
tab.addEventListener('click', function () {
|
|
831
|
+
var targetId = tab.getAttribute('data-pane');
|
|
832
|
+
for (var i = 0; i < tabs.length; i++) {
|
|
833
|
+
tabs[i].classList.remove('active');
|
|
834
|
+
tabs[i].setAttribute('aria-selected', tabs[i] === tab ? 'true' : 'false');
|
|
835
|
+
}
|
|
836
|
+
for (var j = 0; j < panes.length; j++) {
|
|
837
|
+
panes[j].classList.toggle('active', panes[j].id === targetId);
|
|
838
|
+
}
|
|
839
|
+
tab.classList.add('active');
|
|
840
|
+
});
|
|
841
|
+
})(tabs[t]);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ---- Per-pane filter input + level chips ------------------------------
|
|
845
|
+
function wireFilters(paneEl) {
|
|
846
|
+
var input = paneEl.querySelector('.panel-filter');
|
|
847
|
+
var chips = paneEl.querySelectorAll('.filter-chip');
|
|
848
|
+
var rows = paneEl.querySelectorAll('.row');
|
|
849
|
+
|
|
850
|
+
var activeLevels = {};
|
|
851
|
+
for (var c = 0; c < chips.length; c++) {
|
|
852
|
+
if (chips[c].classList.contains('active')) {
|
|
853
|
+
activeLevels[chips[c].getAttribute('data-level') || ''] = true;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function applyFilter() {
|
|
858
|
+
var q = (input ? input.value : '').trim().toLowerCase();
|
|
859
|
+
var anyActive = false;
|
|
860
|
+
for (var k in activeLevels) { if (activeLevels[k]) { anyActive = true; break; } }
|
|
861
|
+
for (var i = 0; i < rows.length; i++) {
|
|
862
|
+
var row = rows[i];
|
|
863
|
+
var text = row.getAttribute('data-text') || '';
|
|
864
|
+
var level = row.getAttribute('data-level') || '';
|
|
865
|
+
// Level filter: if any chips are active, row must match one of them.
|
|
866
|
+
// Network rows have no level — treated as "always shown" by level filter.
|
|
867
|
+
var levelOk = !anyActive || !level || activeLevels[level];
|
|
868
|
+
var textOk = !q || text.indexOf(q) !== -1;
|
|
869
|
+
row.classList.toggle('hidden', !(levelOk && textOk));
|
|
870
|
+
}
|
|
871
|
+
// Re-tick so the badge reflects (filter ∩ time-sync) visibility. Use
|
|
872
|
+
// the player's current time when available so we don't reset to t=0.
|
|
873
|
+
var nowMs = 0;
|
|
874
|
+
try { if (rrPlayer) nowMs = rrPlayer.getReplayer().getCurrentTime(); } catch (_) {}
|
|
875
|
+
tickPanels(nowMs);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (input) input.addEventListener('input', applyFilter);
|
|
879
|
+
for (var c2 = 0; c2 < chips.length; c2++) {
|
|
880
|
+
(function (chip) {
|
|
881
|
+
chip.addEventListener('click', function () {
|
|
882
|
+
var lvl = chip.getAttribute('data-level') || '';
|
|
883
|
+
if (chip.classList.contains('active')) {
|
|
884
|
+
chip.classList.remove('active');
|
|
885
|
+
activeLevels[lvl] = false;
|
|
886
|
+
} else {
|
|
887
|
+
chip.classList.add('active');
|
|
888
|
+
activeLevels[lvl] = true;
|
|
889
|
+
}
|
|
890
|
+
applyFilter();
|
|
891
|
+
});
|
|
892
|
+
})(chips[c2]);
|
|
893
|
+
}
|
|
894
|
+
applyFilter();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
for (var p = 0; p < panes.length; p++) wireFilters(panes[p]);
|
|
102
898
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
899
|
+
// ---- Copy-as-Markdown FAB ----------------------------------------------
|
|
900
|
+
var fab = document.getElementById('copy-md');
|
|
901
|
+
if (fab) {
|
|
902
|
+
var fabLabel = fab.querySelector('.label');
|
|
903
|
+
var resetTimer = null;
|
|
904
|
+
fab.addEventListener('click', function () {
|
|
905
|
+
var done = function () {
|
|
906
|
+
fab.classList.add('copied');
|
|
907
|
+
if (fabLabel) fabLabel.textContent = 'Copied to clipboard';
|
|
908
|
+
if (resetTimer) clearTimeout(resetTimer);
|
|
909
|
+
resetTimer = setTimeout(function () {
|
|
910
|
+
fab.classList.remove('copied');
|
|
911
|
+
if (fabLabel) fabLabel.textContent = 'Copy as Markdown for AI';
|
|
912
|
+
}, 2000);
|
|
913
|
+
};
|
|
107
914
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
108
|
-
navigator.clipboard.writeText(MARKDOWN).then(done, function () {
|
|
915
|
+
navigator.clipboard.writeText(MARKDOWN).then(done, function () {
|
|
916
|
+
window.prompt('Copy the Markdown below:', MARKDOWN);
|
|
917
|
+
});
|
|
109
918
|
} else {
|
|
110
919
|
window.prompt('Copy the Markdown below:', MARKDOWN);
|
|
111
920
|
}
|
|
@@ -113,21 +922,66 @@ const BOOTSTRAP = `
|
|
|
113
922
|
}
|
|
114
923
|
})();
|
|
115
924
|
`;
|
|
925
|
+
/**
|
|
926
|
+
* Self-marketing footer (Phase 5 indirect-virality artifact).
|
|
927
|
+
*
|
|
928
|
+
* Every report shared in a PR comment or attached to a JIRA ticket becomes a
|
|
929
|
+
* tracked acquisition channel — the Loom / Calendly / Statuspage compounding
|
|
930
|
+
* pattern. UTM params let us attribute click-through downstream; the link
|
|
931
|
+
* targets the repo's `packages/tracelane-wdio` directory because the install
|
|
932
|
+
* command (`npm i @tracelane/wdio`) is what we want a reader to see first
|
|
933
|
+
* (per the research's "link to the install command, not the marketing site"
|
|
934
|
+
* rule).
|
|
935
|
+
*/
|
|
936
|
+
const FOOTER_HTML = '<footer class="attrib">' +
|
|
937
|
+
' <em>Generated by</em> <a href="https://github.com/Cubenest/rrweb-stack/tree/main/packages/tracelane-wdio?utm_source=tracelane-report&utm_medium=html-footer&utm_campaign=indirect-virality" rel="noopener">tracelane</a> — self-contained HTML test-failure replays. No SaaS, no telemetry, no signup.' +
|
|
938
|
+
'</footer>';
|
|
939
|
+
function formatRelativeMs(ms) {
|
|
940
|
+
const s = ms / 1000;
|
|
941
|
+
const m = Math.floor(s / 60);
|
|
942
|
+
const r = Math.floor(s - m * 60);
|
|
943
|
+
return `${m}:${r < 10 ? '0' : ''}${r}`;
|
|
944
|
+
}
|
|
116
945
|
/** Compose the full self-contained HTML document. */
|
|
117
946
|
export function renderReportHtml(data) {
|
|
118
|
-
const { meta, eventsGzB64, console: consoleRows, network, markdown, pruned } = data;
|
|
947
|
+
const { meta, eventsGzB64, console: consoleRows, network, security, markdown, pruned, eventCount, firstTs, lastTs, footer = true, } = data;
|
|
119
948
|
const title = `tracelane — ${meta.spec ?? '(no spec)'} :: ${meta.title} (${meta.status})`;
|
|
949
|
+
// Banner only when the events were pruned to fit the size cap (ADR-0005).
|
|
120
950
|
const banner = pruned
|
|
121
951
|
? '<div class="banner">Some recorded events were pruned to fit the 25 MB report budget — replay may skip detail.</div>'
|
|
122
952
|
: '';
|
|
953
|
+
const consoleCount = consoleRows.length;
|
|
954
|
+
const networkCount = network.length;
|
|
955
|
+
const securityCount = security.length;
|
|
956
|
+
// Advisory security tab + pane — rendered ONLY when there are findings
|
|
957
|
+
// (no zero-state; the analyzer is advisory). Mirrors the Console/Network
|
|
958
|
+
// panel markup but without time-sync (findings have no replay moment).
|
|
959
|
+
const securityTab = securityCount > 0
|
|
960
|
+
? `<button class="tab" type="button" role="tab" id="tab-security" aria-selected="false" aria-controls="pane-security" data-pane="pane-security">
|
|
961
|
+
Security <span class="count-total">${securityCount}</span>
|
|
962
|
+
</button>`
|
|
963
|
+
: '';
|
|
964
|
+
const securityPane = securityCount > 0
|
|
965
|
+
? `<div class="panel-pane" id="pane-security" role="tabpanel" aria-labelledby="tab-security">
|
|
966
|
+
<div class="panel-toolbar panel-toolbar-sec">
|
|
967
|
+
<span class="panel-subtitle">Observed during the test run — advisory hygiene signals, not a security audit.</span>
|
|
968
|
+
<input type="text" class="panel-filter" placeholder="Filter findings…" aria-label="Filter security findings" />
|
|
969
|
+
</div>
|
|
970
|
+
<div id="security-rows" class="panel-content"></div>
|
|
971
|
+
</div>`
|
|
972
|
+
: '';
|
|
123
973
|
// Data payloads embedded as JS consts, all escaped for inline-script safety.
|
|
124
|
-
// The events blob is base64 (already inline-safe); the rest go through
|
|
125
|
-
// serializeForScript to neutralise any `</script>` in user data.
|
|
126
974
|
const dataScript = `const META = ${serializeForScript(meta)};\n` +
|
|
127
975
|
`const EVENTS_GZ_B64 = "${eventsGzB64}";\n` +
|
|
128
976
|
`const CONSOLE = ${serializeForScript(consoleRows)};\n` +
|
|
129
977
|
`const NETWORK = ${serializeForScript(network)};\n` +
|
|
130
|
-
`const
|
|
978
|
+
`const SECURITY = ${serializeForScript(security)};\n` +
|
|
979
|
+
`const MARKDOWN = ${serializeForScript(markdown)};\n` +
|
|
980
|
+
`const FIRST_TS = ${firstTs};\n` +
|
|
981
|
+
`const LAST_TS = ${lastTs};`;
|
|
982
|
+
const sessionRangeText = firstTs && lastTs && lastTs > firstTs
|
|
983
|
+
? `+0:00 → +${formatRelativeMs(lastTs - firstTs)} · ${eventCount.toLocaleString('en-US')} events`
|
|
984
|
+
: `${eventCount.toLocaleString('en-US')} events`;
|
|
131
985
|
return `<!doctype html>
|
|
132
986
|
<html lang="en">
|
|
133
987
|
<head>
|
|
@@ -135,23 +989,84 @@ export function renderReportHtml(data) {
|
|
|
135
989
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
136
990
|
<title>${escapeHtml(title)}</title>
|
|
137
991
|
<style>${loadPlayerCss()}</style>
|
|
138
|
-
<style>${
|
|
992
|
+
<style>${buildShellCss()}</style>
|
|
139
993
|
</head>
|
|
140
994
|
<body>
|
|
141
|
-
${
|
|
995
|
+
${renderHero(meta, eventCount)}
|
|
142
996
|
${banner}
|
|
143
|
-
<
|
|
144
|
-
<
|
|
145
|
-
<
|
|
146
|
-
<
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
997
|
+
<main class="investigation">
|
|
998
|
+
<section class="replay" aria-label="Session replay">
|
|
999
|
+
<div class="replay-header">
|
|
1000
|
+
<h2>Replay</h2>
|
|
1001
|
+
<span class="timestamp">${escapeHtml(sessionRangeText)}</span>
|
|
1002
|
+
</div>
|
|
1003
|
+
<div id="player" role="img" aria-label="rrweb player"></div>
|
|
1004
|
+
</section>
|
|
1005
|
+
<aside class="panels" aria-label="Investigation panels">
|
|
1006
|
+
<div class="tabs" role="tablist">
|
|
1007
|
+
<button class="tab active" type="button" role="tab" id="tab-console" aria-selected="true" aria-controls="pane-console" data-pane="pane-console">
|
|
1008
|
+
Console <span class="count">0</span><span class="count-total">/ ${consoleCount}</span>
|
|
1009
|
+
</button>
|
|
1010
|
+
<button class="tab" type="button" role="tab" id="tab-network" aria-selected="false" aria-controls="pane-network" data-pane="pane-network">
|
|
1011
|
+
Network <span class="count">0</span><span class="count-total">/ ${networkCount}</span>
|
|
1012
|
+
</button>
|
|
1013
|
+
${securityTab}
|
|
1014
|
+
<button class="tab" type="button" role="tab" id="tab-actions" aria-selected="false" aria-controls="pane-actions" data-pane="pane-actions">
|
|
1015
|
+
Actions <span class="soon-pill">soon</span>
|
|
1016
|
+
</button>
|
|
1017
|
+
<button class="tab" type="button" role="tab" id="tab-timeline" aria-selected="false" aria-controls="pane-timeline" data-pane="pane-timeline">
|
|
1018
|
+
Timeline <span class="soon-pill">soon</span>
|
|
1019
|
+
</button>
|
|
1020
|
+
</div>
|
|
1021
|
+
|
|
1022
|
+
<div class="panel-pane active" id="pane-console" role="tabpanel" aria-labelledby="tab-console">
|
|
1023
|
+
<div class="panel-toolbar">
|
|
1024
|
+
<input type="text" class="panel-filter" placeholder="Filter console…" aria-label="Filter console messages" />
|
|
1025
|
+
<button class="filter-chip" type="button" data-level="error">errors</button>
|
|
1026
|
+
<button class="filter-chip" type="button" data-level="warn">warn</button>
|
|
1027
|
+
</div>
|
|
1028
|
+
<div id="console-rows" class="panel-content"></div>
|
|
1029
|
+
<div class="panel-pending" id="console-pending" role="status" aria-live="polite">Console output will appear during playback.</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
|
|
1032
|
+
<div class="panel-pane" id="pane-network" role="tabpanel" aria-labelledby="tab-network">
|
|
1033
|
+
<div class="panel-toolbar">
|
|
1034
|
+
<input type="text" class="panel-filter" placeholder="Filter URLs…" aria-label="Filter network requests" />
|
|
1035
|
+
</div>
|
|
1036
|
+
<div id="network-rows" class="panel-content"></div>
|
|
1037
|
+
<div class="panel-pending" id="network-pending" role="status" aria-live="polite">Network errors will appear during playback.</div>
|
|
1038
|
+
</div>
|
|
1039
|
+
|
|
1040
|
+
${securityPane}
|
|
1041
|
+
|
|
1042
|
+
<div class="panel-pane" id="pane-actions" role="tabpanel" aria-labelledby="tab-actions">
|
|
1043
|
+
<div class="coming-soon">
|
|
1044
|
+
<strong>Actions panel — coming soon</strong>
|
|
1045
|
+
<span>User-input event extraction lands in a follow-up changeset.</span>
|
|
1046
|
+
<span>Use the rrweb-player scrubber to walk through actions manually.</span>
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
|
|
1050
|
+
<div class="panel-pane" id="pane-timeline" role="tabpanel" aria-labelledby="tab-timeline">
|
|
1051
|
+
<div class="coming-soon">
|
|
1052
|
+
<strong>Timeline panel — coming soon</strong>
|
|
1053
|
+
<span>Use the rrweb-player scrubber above to navigate the recording today.</span>
|
|
1054
|
+
<span>A richer event-by-event timeline lands in a follow-up changeset.</span>
|
|
1055
|
+
</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
</aside>
|
|
150
1058
|
</main>
|
|
1059
|
+
|
|
1060
|
+
<button class="fab" id="copy-md" type="button" aria-label="Copy report as Markdown for AI">
|
|
1061
|
+
<span class="icon" aria-hidden="true"></span>
|
|
1062
|
+
<span class="label">Copy as Markdown for AI</span>
|
|
1063
|
+
</button>
|
|
1064
|
+
|
|
151
1065
|
<script>${loadFflateGunzipSource()}</script>
|
|
152
1066
|
<script>${loadPlayerUmd()}</script>
|
|
153
1067
|
<script>${dataScript}</script>
|
|
154
1068
|
<script>${BOOTSTRAP}</script>
|
|
1069
|
+
${footer ? FOOTER_HTML : ''}
|
|
155
1070
|
</body>
|
|
156
1071
|
</html>`;
|
|
157
1072
|
}
|