capybara-simulated 0.8.0 → 0.10.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/README.md +43 -18
- data/lib/capybara/simulated/asset_cache.rb +25 -12
- data/lib/capybara/simulated/browser.rb +4182 -338
- data/lib/capybara/simulated/driver.rb +205 -30
- data/lib/capybara/simulated/errors.rb +12 -0
- data/lib/capybara/simulated/js/bridge.bundle.js +14151 -3473
- data/lib/capybara/simulated/minitest.rb +22 -0
- data/lib/capybara/simulated/node.rb +18 -13
- data/lib/capybara/simulated/quickjs_runtime.rb +55 -10
- data/lib/capybara/simulated/runtime_shared.rb +71 -33
- data/lib/capybara/simulated/stack_resolver.rb +5 -0
- data/lib/capybara/simulated/trace.rb +38 -11
- data/lib/capybara/simulated/trace_persistence.rb +30 -4
- data/lib/capybara/simulated/trace_viewer.html +561 -207
- data/lib/capybara/simulated/v8_runtime.rb +265 -70
- data/lib/capybara/simulated/version.rb +1 -1
- data/lib/capybara/simulated/worker_runtime.rb +34 -11
- data/lib/capybara/simulated.rb +14 -0
- data/vendor/js/vendor.bundle.js +14 -14
- metadata +15 -1
|
@@ -7,138 +7,311 @@
|
|
|
7
7
|
<!--
|
|
8
8
|
Self-contained trace viewer. The `capybara-simulated trace <file.json>`
|
|
9
9
|
CLI replaces the data token in the script block below with the trace
|
|
10
|
-
JSON
|
|
11
|
-
|
|
10
|
+
JSON, with every `<` escaped to `\u003c` — not just `</`: a snapshot
|
|
11
|
+
containing `<!-- <script …` steers the HTML tokenizer into a state a
|
|
12
|
+
real `</script>` no longer ends, and escaping only the closing form
|
|
13
|
+
guarantees it can never leave (see `Trace.render_viewer`). Inline
|
|
14
|
+
embedding — not fetch /
|
|
12
15
|
`import … with { type: 'json' }` — is what lets the file open straight
|
|
13
16
|
from `file://` without a server (module/fetch loads are CORS-blocked
|
|
14
17
|
for `file://` origins). The Load button / drag-and-drop reads any other
|
|
15
18
|
trace JSON via FileReader, which is also CORS-free.
|
|
19
|
+
|
|
20
|
+
Everything here is inline for the same reason: no webfont, no CDN, no
|
|
21
|
+
second file. A trace is an artifact people open from a CI download,
|
|
22
|
+
often offline, so the design's IBM Plex Mono is served by whatever the
|
|
23
|
+
reader's system mono is instead.
|
|
16
24
|
-->
|
|
17
25
|
<script id="csim-trace" type="application/json">__CSIM_TRACE_DATA__</script>
|
|
18
26
|
<style>
|
|
19
27
|
:root {
|
|
20
|
-
--bg: #
|
|
21
|
-
--
|
|
22
|
-
--
|
|
23
|
-
--
|
|
28
|
+
--bg: #f6f7f8; --panel: #fff; --border: #e3e6ea; --hair: #eef0f3;
|
|
29
|
+
--text: #14171c; --text-2: #3d434c; --muted: #6a7280;
|
|
30
|
+
--accent: oklch(0.52 0.16 259); --accent-soft: oklch(0.97 0.02 259);
|
|
31
|
+
--red: oklch(0.48 0.19 27); --red-deep: oklch(0.42 0.19 27);
|
|
32
|
+
--red-line: oklch(0.9 0.05 27); --red-soft: oklch(0.97 0.02 27);
|
|
33
|
+
--red-tint: oklch(0.94 0.05 27); --amber: oklch(0.55 0.12 70);
|
|
34
|
+
--green: oklch(0.5 0.13 150);
|
|
35
|
+
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
36
|
+
--row-pad: 3px;
|
|
24
37
|
}
|
|
25
38
|
* { box-sizing: border-box; }
|
|
26
39
|
html, body { height: 100%; margin: 0; }
|
|
27
40
|
body {
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
42
|
+
background: var(--bg); color: var(--text);
|
|
43
|
+
font: 12.5px/1.45 system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
|
44
|
+
-webkit-font-smoothing: antialiased;
|
|
30
45
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
|
|
34
|
-
}
|
|
35
|
-
header h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
|
36
|
-
header .meta { color: var(--muted); font-size: 12px; display: flex; gap: 14px; flex-wrap: wrap; }
|
|
37
|
-
header .spacer { flex: 1; }
|
|
38
|
-
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
39
|
-
.badge.passed { background: #e6f4ea; color: var(--ok); }
|
|
40
|
-
.badge.failed { background: var(--error-soft); color: var(--error); }
|
|
46
|
+
.mono { font-family: var(--mono); }
|
|
47
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
41
48
|
button, label.btn {
|
|
42
|
-
font: inherit; font-size:
|
|
43
|
-
background: var(--panel); border-radius:
|
|
49
|
+
font: inherit; font-size: 11px; padding: 3px 9px; border: 1px solid var(--border);
|
|
50
|
+
background: var(--panel); border-radius: 4px; color: var(--text-2); cursor: pointer;
|
|
51
|
+
}
|
|
52
|
+
button:hover, label.btn:hover { background: #f1f3f5; }
|
|
53
|
+
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
54
|
+
::-webkit-scrollbar-thumb { background: #c9ced6; border: 3px solid #fff; border-radius: 6px; }
|
|
55
|
+
::-webkit-scrollbar-track { background: #fff; }
|
|
56
|
+
|
|
57
|
+
/* ── header ───────────────────────────────────────────────────────── */
|
|
58
|
+
header {
|
|
59
|
+
flex: none; display: flex; align-items: center; gap: 14px; height: 42px;
|
|
60
|
+
padding: 0 12px; background: var(--panel); border-bottom: 1px solid var(--border);
|
|
61
|
+
}
|
|
62
|
+
.brand { display: flex; align-items: baseline; gap: 7px; flex: none; }
|
|
63
|
+
.brand b { font-size: 11px; font-weight: 600; letter-spacing: .02em; }
|
|
64
|
+
.brand span { font-family: var(--mono); font-size: 10.5px; color: var(--muted); }
|
|
65
|
+
.rule { width: 1px; height: 18px; background: var(--border); flex: none; }
|
|
66
|
+
.ident { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
|
|
67
|
+
.ident .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
68
|
+
.ident .file { font-family: var(--mono); font-size: 11px; color: var(--muted);
|
|
69
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
70
|
+
.badge {
|
|
71
|
+
flex: none; padding: 1px 7px; border-radius: 3px; font-family: var(--mono);
|
|
72
|
+
font-size: 10px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
|
73
|
+
background: #f1f3f5; color: var(--muted);
|
|
74
|
+
}
|
|
75
|
+
.badge.failed { background: var(--red-tint); color: var(--red); }
|
|
76
|
+
.badge.passed { background: oklch(0.94 0.05 150); color: var(--green); }
|
|
77
|
+
.stats { display: flex; align-items: center; gap: 12px; flex: none;
|
|
78
|
+
font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
|
79
|
+
.actions { display: flex; align-items: center; gap: 6px; flex: none; }
|
|
80
|
+
|
|
81
|
+
/* ── layout ───────────────────────────────────────────────────────── */
|
|
82
|
+
main { flex: 1; display: flex; min-height: 0; overflow-x: auto; }
|
|
83
|
+
nav {
|
|
84
|
+
width: 306px; flex: none; display: flex; flex-direction: column;
|
|
85
|
+
background: var(--panel); border-right: 1px solid var(--border); min-height: 0;
|
|
86
|
+
}
|
|
87
|
+
.nav-head, .nav-foot {
|
|
88
|
+
flex: none; display: flex; align-items: center; gap: 10px; padding: 5px 10px;
|
|
89
|
+
}
|
|
90
|
+
.nav-head { justify-content: space-between; border-bottom: 1px solid var(--hair); }
|
|
91
|
+
.nav-foot { padding: 4px 10px; border-top: 1px solid var(--hair);
|
|
92
|
+
font-family: var(--mono); font-size: 10px; color: var(--muted); }
|
|
93
|
+
.cap { font-size: 10px; font-weight: 600; letter-spacing: .07em;
|
|
94
|
+
text-transform: uppercase; color: var(--muted); }
|
|
95
|
+
#steps { flex: 1; overflow-y: auto; min-height: 0; }
|
|
96
|
+
.jump {
|
|
97
|
+
font-family: var(--mono); font-size: 10px; padding: 1px 6px; border-radius: 3px;
|
|
98
|
+
border: 1px solid oklch(0.88 0.06 27); background: var(--red-soft); color: var(--red);
|
|
44
99
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
100
|
+
.jump:hover { background: var(--red-tint); }
|
|
101
|
+
.jump[hidden] { display: none; }
|
|
102
|
+
|
|
48
103
|
.step {
|
|
49
|
-
display: flex; align-items: baseline; gap:
|
|
50
|
-
border-bottom: 1px solid
|
|
104
|
+
display: flex; align-items: baseline; gap: 7px; padding: var(--row-pad) 10px;
|
|
105
|
+
cursor: pointer; border-bottom: 1px solid #f1f3f5; border-left: 2px solid transparent;
|
|
51
106
|
}
|
|
52
107
|
.step:hover { background: var(--bg); }
|
|
108
|
+
.step .idx { flex: none; font-family: var(--mono); font-size: 10.5px; color: var(--muted); }
|
|
109
|
+
.step .kind {
|
|
110
|
+
flex: none; width: 82px; font-family: var(--mono); font-size: 11px; font-weight: 600;
|
|
111
|
+
color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
112
|
+
}
|
|
113
|
+
.step .desc {
|
|
114
|
+
flex: 1; min-width: 0; font-family: var(--mono); font-size: 11.5px;
|
|
115
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
116
|
+
}
|
|
117
|
+
.step .mark { flex: none; width: 8px; text-align: center; font-family: var(--mono);
|
|
118
|
+
font-size: 11px; font-weight: 700; color: var(--amber); }
|
|
119
|
+
.step .dur { flex: none; font-family: var(--mono); font-size: 10.5px; color: var(--muted); }
|
|
53
120
|
.step.sel { background: var(--accent-soft); border-left-color: var(--accent); }
|
|
54
|
-
.step.
|
|
55
|
-
.step
|
|
56
|
-
.step .kind
|
|
57
|
-
.step
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.
|
|
73
|
-
.
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
.
|
|
121
|
+
.step.sel .kind { color: var(--accent); }
|
|
122
|
+
.step.err { border-left-color: var(--red); }
|
|
123
|
+
.step.err .kind, .step.err .mark { color: var(--red); }
|
|
124
|
+
.step.err.sel { background: var(--red-soft); }
|
|
125
|
+
|
|
126
|
+
/* ── detail ───────────────────────────────────────────────────────── */
|
|
127
|
+
section.pane { flex: 1; display: flex; flex-direction: column; min-width: 520px; min-height: 0; }
|
|
128
|
+
.banner {
|
|
129
|
+
flex: none; display: flex; align-items: flex-start; gap: 10px; padding: 8px 14px;
|
|
130
|
+
background: var(--red-soft); border-bottom: 1px solid var(--red-line);
|
|
131
|
+
}
|
|
132
|
+
.banner[hidden] { display: none; }
|
|
133
|
+
.banner .lbl { flex: none; margin-top: 1px; font-family: var(--mono); font-size: 10px;
|
|
134
|
+
font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--red); }
|
|
135
|
+
.banner .cls { font-family: var(--mono); font-size: 11.5px; font-weight: 600; color: var(--red-deep); }
|
|
136
|
+
.banner .msg { font-family: var(--mono); font-size: 11.5px; color: var(--text-2);
|
|
137
|
+
white-space: pre-wrap; word-break: break-word; }
|
|
138
|
+
#detail { flex: 1; overflow-y: auto; min-height: 0; padding: 14px 16px 40px; }
|
|
139
|
+
.head { display: flex; align-items: baseline; gap: 9px; }
|
|
140
|
+
.head .idx { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
|
141
|
+
.head h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
|
|
142
|
+
.desc-line { margin-top: 2px; font-family: var(--mono); font-size: 12px;
|
|
143
|
+
color: var(--text-2); word-break: break-word; }
|
|
144
|
+
.facts {
|
|
145
|
+
margin-top: 12px; display: flex; flex-wrap: wrap; gap: 0 22px; padding: 7px 10px;
|
|
146
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 5px;
|
|
147
|
+
}
|
|
148
|
+
.facts > div { display: flex; gap: 7px; align-items: baseline; min-width: 0; }
|
|
149
|
+
.facts .k { font-size: 10px; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
|
|
150
|
+
.facts .v { font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
|
151
|
+
.facts .v.from { color: var(--muted); }
|
|
152
|
+
.facts .v.to { font-weight: 600; }
|
|
153
|
+
.block { margin-top: 16px; }
|
|
154
|
+
.block > h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-weight: 600; }
|
|
155
|
+
.block > h3 .cap { flex: none; }
|
|
156
|
+
.block > h3 .count { font-family: var(--mono); font-size: 10px; color: var(--muted); font-weight: 400; }
|
|
157
|
+
.block > h3 .hair { flex: 1; height: 1px; background: var(--border); }
|
|
158
|
+
.block > h3 .note { font-size: 10.5px; color: var(--muted); font-weight: 400; }
|
|
159
|
+
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 4px; }
|
|
160
|
+
.panel-error { border-color: var(--red-line); border-left: 2px solid oklch(0.55 0.19 27); padding: 8px 11px; }
|
|
161
|
+
.panel-error .cls { font-family: var(--mono); font-size: 11.5px; font-weight: 600; color: oklch(0.46 0.19 27); }
|
|
162
|
+
.panel-error .msg { margin-top: 3px; font-family: var(--mono); font-size: 11.5px;
|
|
163
|
+
color: var(--text-2); white-space: pre-wrap; word-break: break-word; }
|
|
164
|
+
.log { display: flex; gap: 10px; padding: var(--row-pad) 10px; border-top: 1px solid #f1f3f5; }
|
|
78
165
|
.log:first-child { border-top: none; }
|
|
79
|
-
.log .sev {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.net
|
|
89
|
-
.net
|
|
90
|
-
|
|
91
|
-
|
|
166
|
+
.log .sev {
|
|
167
|
+
flex: none; width: 44px; padding-top: 2px; font-family: var(--mono); font-size: 10px;
|
|
168
|
+
font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted);
|
|
169
|
+
}
|
|
170
|
+
.log .sev.error { color: var(--red); }
|
|
171
|
+
.log .sev.warn, .log .sev.warning { color: var(--amber); }
|
|
172
|
+
.log .msg { min-width: 0; font-family: var(--mono); font-size: 11.5px;
|
|
173
|
+
white-space: pre-wrap; word-break: break-word; }
|
|
174
|
+
|
|
175
|
+
.net { overflow-x: auto; }
|
|
176
|
+
.net-row, .net-head {
|
|
177
|
+
display: grid; grid-template-columns: 58px minmax(120px, 1fr) 44px 130px 62px 56px;
|
|
178
|
+
gap: 10px; min-width: 560px; padding: var(--row-pad) 10px; align-items: baseline;
|
|
179
|
+
}
|
|
180
|
+
.net-head {
|
|
181
|
+
background: #fafbfc; border-bottom: 1px solid var(--hair); font-size: 10px;
|
|
182
|
+
font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted);
|
|
183
|
+
}
|
|
184
|
+
.net-row { cursor: pointer; border-bottom: 1px solid #f1f3f5; font-family: var(--mono); font-size: 11px; }
|
|
92
185
|
.net-row:hover { background: var(--bg); }
|
|
93
|
-
.net-row.open { background:
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
.net-
|
|
97
|
-
.net-
|
|
98
|
-
|
|
99
|
-
.net-
|
|
100
|
-
.
|
|
101
|
-
.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
186
|
+
.net-row.open { background: #f7f8f9; }
|
|
187
|
+
.net-row .m { font-size: 11px; font-weight: 600; }
|
|
188
|
+
.net-row .u { font-size: 11.5px; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
189
|
+
.net-row .s { font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
|
190
|
+
.net-row .s.ok { color: var(--green); } .net-row .s.redir { color: var(--amber); } .net-row .s.err { color: var(--red); }
|
|
191
|
+
.net-row .t, .net-row .z, .net-row .d { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
192
|
+
.net-row .z, .net-row .d { text-align: right; font-variant-numeric: tabular-nums; }
|
|
193
|
+
.net-head .z, .net-head .d { text-align: right; }
|
|
194
|
+
.net-detail {
|
|
195
|
+
padding: 8px 10px 10px 68px; min-width: 560px; background: #fafbfc;
|
|
196
|
+
border-bottom: 1px solid var(--hair); display: grid; gap: 8px;
|
|
197
|
+
}
|
|
198
|
+
.net-detail[hidden] { display: none; }
|
|
199
|
+
.net-sub { font-size: 10px; font-weight: 600; letter-spacing: .05em;
|
|
200
|
+
text-transform: uppercase; color: var(--muted); margin-bottom: 2px; }
|
|
201
|
+
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 1px 12px;
|
|
202
|
+
font-family: var(--mono); font-size: 11px; margin: 0; }
|
|
203
|
+
.kv dt { color: var(--muted); }
|
|
204
|
+
.kv dd { margin: 0; word-break: break-all; }
|
|
205
|
+
pre.body, pre.dom {
|
|
206
|
+
margin: 0; overflow: auto; padding: 7px 9px; background: var(--panel);
|
|
207
|
+
border: 1px solid var(--border); border-radius: 4px;
|
|
208
|
+
font-family: var(--mono); font-size: 11px; white-space: pre-wrap; word-break: break-word;
|
|
209
|
+
}
|
|
210
|
+
pre.body { max-height: 200px; }
|
|
211
|
+
pre.dom { max-height: 430px; font-size: 11.5px; line-height: 1.5; tab-size: 2; }
|
|
212
|
+
pre.dom.nowrap { white-space: pre; word-break: normal; }
|
|
213
|
+
.empty-note { padding: 9px 11px; border: 1px dashed #dfe3e8; border-radius: 4px;
|
|
214
|
+
color: var(--muted); font-size: 11.5px; }
|
|
215
|
+
.empty-note code { font-family: var(--mono); background: #f1f3f5; padding: 0 4px; border-radius: 3px; }
|
|
216
|
+
|
|
217
|
+
/* ── screenshot rail ──────────────────────────────────────────────── */
|
|
218
|
+
aside {
|
|
219
|
+
width: 348px; flex: none; display: flex; flex-direction: column;
|
|
220
|
+
background: var(--panel); border-left: 1px solid var(--border); min-height: 0;
|
|
221
|
+
}
|
|
222
|
+
aside[hidden] { display: none; }
|
|
223
|
+
.rail-tabs { flex: none; display: flex; align-items: center; gap: 2px;
|
|
224
|
+
padding: 4px 6px; border-bottom: 1px solid var(--hair); }
|
|
225
|
+
.rail-tabs .tab { border-color: transparent; background: transparent; color: var(--muted); }
|
|
226
|
+
.rail-tabs .tab.on { border-color: var(--border); background: #f1f3f5; color: var(--text); font-weight: 600; }
|
|
227
|
+
.rail-tabs .tab[hidden] { display: none; }
|
|
228
|
+
.rail-tabs .dims { flex: 1; text-align: right; padding-right: 4px;
|
|
229
|
+
font-family: var(--mono); font-size: 10px; color: var(--muted); }
|
|
230
|
+
.rail-body { flex: 1; overflow-y: auto; min-height: 0; padding: 10px; }
|
|
231
|
+
.frame {
|
|
232
|
+
border: 1px solid var(--border); border-radius: 4px; overflow: hidden;
|
|
233
|
+
background: repeating-linear-gradient(135deg, #f3f4f6 0 6px, #fafbfc 6px 12px);
|
|
234
|
+
}
|
|
235
|
+
.frame .bar { height: 22px; display: flex; align-items: center; gap: 6px; padding: 0 8px;
|
|
236
|
+
background: var(--panel); border-bottom: 1px solid var(--border); }
|
|
237
|
+
.frame .dot { width: 6px; height: 6px; border-radius: 50%; background: #d6dae0; flex: none; }
|
|
238
|
+
.frame .loc { font-family: var(--mono); font-size: 10px; color: var(--muted);
|
|
239
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
240
|
+
/* Scaled to the rail, never up: a viewport-sized shot is wider than the rail, and a short one
|
|
241
|
+
(a small `full: false` page) should not be blown up into a blur. */
|
|
242
|
+
.shot { display: block; max-width: 100%; width: auto; height: auto; background: #fff; }
|
|
243
|
+
.slot { height: 300px; display: flex; align-items: center; justify-content: center;
|
|
244
|
+
text-align: center; padding: 12px; }
|
|
245
|
+
.slot span { font-family: var(--mono); font-size: 10.5px; color: var(--muted);
|
|
246
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 3px; padding: 3px 7px; }
|
|
247
|
+
.caption { margin: 8px 0 0; font-size: 11px; color: var(--muted); text-wrap: pretty; }
|
|
248
|
+
|
|
249
|
+
/* ── empty / drop ─────────────────────────────────────────────────── */
|
|
250
|
+
#empty { flex: 1; display: flex; flex-direction: column; align-items: center;
|
|
251
|
+
justify-content: center; gap: 14px; color: var(--muted); text-align: center; }
|
|
252
|
+
#empty[hidden], main[hidden] { display: none; }
|
|
253
|
+
#drop-veil { position: fixed; inset: 0; background: oklch(0.52 0.16 259 / .08);
|
|
254
|
+
border: 3px dashed var(--accent); display: none; align-items: center;
|
|
255
|
+
justify-content: center; font-size: 18px; color: var(--accent); z-index: 10; }
|
|
114
256
|
#drop-veil.on { display: flex; }
|
|
115
257
|
</style>
|
|
116
258
|
</head>
|
|
117
259
|
<body>
|
|
118
260
|
<header>
|
|
119
|
-
<
|
|
120
|
-
<div class="
|
|
121
|
-
<
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
<div id="detail"></div>
|
|
127
|
-
<div id="empty">
|
|
128
|
-
<div style="font-size:40px">📊</div>
|
|
129
|
-
<div>No trace loaded.<br>Drop a <code>*.json</code> trace here or use <b>Load JSON…</b>.</div>
|
|
261
|
+
<div class="brand"><b>capybara-simulated</b><span>trace</span></div>
|
|
262
|
+
<div class="rule" id="rule" hidden></div>
|
|
263
|
+
<div class="ident" id="ident"></div>
|
|
264
|
+
<div class="stats" id="stats"></div>
|
|
265
|
+
<div class="actions">
|
|
266
|
+
<button type="button" id="rail-toggle" hidden>Hide preview</button>
|
|
267
|
+
<label class="btn">Load JSON…<input id="file" type="file" accept=".json,application/json" hidden></label>
|
|
130
268
|
</div>
|
|
269
|
+
</header>
|
|
270
|
+
<main id="main" hidden>
|
|
271
|
+
<nav>
|
|
272
|
+
<div class="nav-head">
|
|
273
|
+
<span class="cap">Steps</span>
|
|
274
|
+
<button type="button" class="jump" id="jump" hidden></button>
|
|
275
|
+
</div>
|
|
276
|
+
<div id="steps"></div>
|
|
277
|
+
<div class="nav-foot"><span>j / k move</span><span>f failure</span></div>
|
|
278
|
+
</nav>
|
|
279
|
+
<section class="pane">
|
|
280
|
+
<div class="banner" id="banner" hidden></div>
|
|
281
|
+
<div id="detail"></div>
|
|
282
|
+
</section>
|
|
283
|
+
<aside id="rail" hidden>
|
|
284
|
+
<div class="rail-tabs">
|
|
285
|
+
<button type="button" class="tab" id="tab-final">Final state</button>
|
|
286
|
+
<button type="button" class="tab" id="tab-step"></button>
|
|
287
|
+
<span class="dims" id="dims"></span>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="rail-body" id="rail-body"></div>
|
|
290
|
+
</aside>
|
|
131
291
|
</main>
|
|
292
|
+
<div id="empty">
|
|
293
|
+
<div style="font-size:40px">📊</div>
|
|
294
|
+
<div>No trace loaded.<br>Drop a <code>*.json</code> trace here or use <b>Load JSON…</b>.</div>
|
|
295
|
+
</div>
|
|
132
296
|
<div id="drop-veil">Drop trace JSON to load</div>
|
|
133
297
|
|
|
134
298
|
<script>
|
|
135
299
|
(function () {
|
|
136
300
|
'use strict';
|
|
137
|
-
var
|
|
138
|
-
var detailEl =
|
|
139
|
-
var
|
|
140
|
-
var
|
|
141
|
-
var
|
|
301
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
302
|
+
var stepsEl = $('steps'), detailEl = $('detail'), emptyEl = $('empty'), mainEl = $('main');
|
|
303
|
+
var identEl = $('ident'), statsEl = $('stats'), bannerEl = $('banner'), jumpEl = $('jump');
|
|
304
|
+
var ruleEl = $('rule');
|
|
305
|
+
var railEl = $('rail'), railBodyEl = $('rail-body'), railToggleEl = $('rail-toggle');
|
|
306
|
+
var tabFinalEl = $('tab-final'), tabStepEl = $('tab-step'), dimsEl = $('dims');
|
|
307
|
+
|
|
308
|
+
var trace = null; // the loaded trace
|
|
309
|
+
var rowEls = []; // the step rows, kept so moving the selection is a class toggle
|
|
310
|
+
var selected = 0; // index of the step being shown
|
|
311
|
+
var failureIdx = -1; // first step that errored, or -1
|
|
312
|
+
var railOpen = true; // the user's preference, kept across step changes
|
|
313
|
+
var shotTab = 'final'; // which screenshot the rail is showing
|
|
314
|
+
var wrap = false; // DOM-source soft wrapping
|
|
142
315
|
|
|
143
316
|
// Escapes both text and double-quoted-attribute contexts (severity /
|
|
144
317
|
// outcome land in class="…"), so a quote in trace data can't break out
|
|
@@ -148,8 +321,25 @@
|
|
|
148
321
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
149
322
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
150
323
|
}
|
|
324
|
+
function pad2(n) { return (n < 10 ? '0' : '') + n; }
|
|
325
|
+
// Trace data is whatever the app under test produced, and a field of the wrong SHAPE used to
|
|
326
|
+
// throw mid-render and leave a blank pane with nothing in the console.
|
|
327
|
+
function asArray(v) { return Array.isArray(v) ? v : []; }
|
|
328
|
+
// Trace-supplied words that land in `class="…"`, mapped to a KNOWN set rather than sanitised.
|
|
329
|
+
// `esc()` keeps a value inside its attribute, but not inside its own class: stripping the
|
|
330
|
+
// characters that could add a second class still lets a plain word collide with one of the
|
|
331
|
+
// viewer's own (an `outcome` of "hide" picked up a `display: none`). A whitelist cannot.
|
|
332
|
+
function outcomeClass(o) {
|
|
333
|
+
o = String(o == null ? '' : o).toLowerCase();
|
|
334
|
+
return o === 'failed' ? 'failed' : o === 'passed' ? 'passed' : '';
|
|
335
|
+
}
|
|
336
|
+
var SEVERITIES = {error: 1, warn: 1, warning: 1, debug: 1, log: 1, info: 1};
|
|
337
|
+
function sevClass(v) {
|
|
338
|
+
v = String(v == null ? '' : v).toLowerCase();
|
|
339
|
+
return SEVERITIES[v] ? v : '';
|
|
340
|
+
}
|
|
151
341
|
function readEmbedded() {
|
|
152
|
-
var el =
|
|
342
|
+
var el = $('csim-trace');
|
|
153
343
|
try {
|
|
154
344
|
var d = JSON.parse(el.textContent);
|
|
155
345
|
return (d && Array.isArray(d.steps)) ? d : null;
|
|
@@ -158,40 +348,82 @@
|
|
|
158
348
|
|
|
159
349
|
function load(data) {
|
|
160
350
|
if (!data || !Array.isArray(data.steps)) { return; }
|
|
161
|
-
trace = data;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
351
|
+
trace = data;
|
|
352
|
+
failureIdx = trace.steps.findIndex(function (s) { return !!s.error; });
|
|
353
|
+
// Open on the failure. That is what the reader came for, and on a long trace it is nowhere
|
|
354
|
+
// near the top; when nothing errored, the first step is the start of the story.
|
|
355
|
+
selected = failureIdx >= 0 ? failureIdx : 0;
|
|
356
|
+
shotTab = 'final';
|
|
357
|
+
emptyEl.hidden = true; mainEl.hidden = false;
|
|
358
|
+
try {
|
|
359
|
+
renderHeader(); renderSteps(); renderDetail();
|
|
360
|
+
} catch (e) {
|
|
361
|
+
// Better a visible complaint than the blank pane a mid-render throw used to leave.
|
|
362
|
+
detailEl.innerHTML = '<div class="empty-note">This trace could not be rendered: ' +
|
|
363
|
+
esc(e && e.message) + '</div>';
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderHeader() {
|
|
368
|
+
var m = trace.metadata || {};
|
|
369
|
+
ruleEl.hidden = false;
|
|
370
|
+
var ident = '';
|
|
371
|
+
if (m.outcome) { ident += '<span class="badge ' + outcomeClass(m.outcome) + '">' + esc(m.outcome) + '</span>'; }
|
|
372
|
+
if (m.title) { ident += '<span class="title">' + esc(m.title) + '</span>'; }
|
|
373
|
+
if (m.file) { ident += '<span class="file">' + esc(m.file) + '</span>'; }
|
|
374
|
+
identEl.innerHTML = ident;
|
|
375
|
+
|
|
173
376
|
var total = trace.steps.reduce(function (a, s) { return a + (s.duration_ms || 0); }, 0);
|
|
174
|
-
|
|
175
|
-
|
|
377
|
+
var stats = '<span>' + trace.steps.length + ' steps</span><span>' + total + ' ms</span>';
|
|
378
|
+
if (m.engine) { stats += '<span>engine ' + esc(m.engine) + '</span>'; }
|
|
379
|
+
statsEl.innerHTML = stats;
|
|
380
|
+
|
|
381
|
+
jumpEl.hidden = failureIdx < 0;
|
|
382
|
+
jumpEl.textContent = failureIdx < 0 ? '' : 'step ' + pad2(failureIdx);
|
|
176
383
|
}
|
|
177
384
|
|
|
385
|
+
// Built ONCE per trace. Rebuilding on every keypress cost 110 ms per `j` on a 5000-step trace
|
|
386
|
+
// (~9 fps held down) — and, worse, emptying the list reset its scroll, so the
|
|
387
|
+
// `scrollIntoView({block: 'nearest'})` that followed always landed the row against the bottom
|
|
388
|
+
// edge and `k` scrolled the wrong way. Moving the selection is two class toggles.
|
|
178
389
|
function renderSteps() {
|
|
179
390
|
stepsEl.innerHTML = '';
|
|
180
|
-
trace.steps.
|
|
391
|
+
rowEls = trace.steps.map(function (s, i) {
|
|
392
|
+
// `!` for the step that failed, `·` for one that only complained — enough to scan a long
|
|
393
|
+
// list for where things started going wrong without opening every step.
|
|
394
|
+
var noisy = asArray(s.console).some(function (c) {
|
|
395
|
+
var sev = String((c && c.severity) || '').toLowerCase();
|
|
396
|
+
return sev === 'error' || sev === 'warn' || sev === 'warning';
|
|
397
|
+
});
|
|
181
398
|
var row = document.createElement('div');
|
|
182
|
-
row.className = 'step' + (
|
|
399
|
+
row.className = 'step' + (s.error ? ' err' : '');
|
|
183
400
|
row.innerHTML =
|
|
184
|
-
'<span class="idx">' + i + '</span>' +
|
|
401
|
+
'<span class="idx">' + pad2(i) + '</span>' +
|
|
185
402
|
'<span class="kind">' + esc(s.kind) + '</span>' +
|
|
186
403
|
'<span class="desc">' + esc(s.description) + '</span>' +
|
|
404
|
+
'<span class="mark">' + (s.error ? '!' : noisy ? '·' : '') + '</span>' +
|
|
187
405
|
'<span class="dur">' + (s.duration_ms == null ? '' : s.duration_ms + 'ms') + '</span>';
|
|
188
|
-
row.onclick = function () {
|
|
406
|
+
row.onclick = function () { select(i); };
|
|
189
407
|
stepsEl.appendChild(row);
|
|
408
|
+
return row;
|
|
190
409
|
});
|
|
410
|
+
markSelected();
|
|
191
411
|
}
|
|
192
412
|
|
|
193
|
-
function
|
|
194
|
-
|
|
413
|
+
function markSelected() {
|
|
414
|
+
rowEls.forEach(function (row, i) { row.classList.toggle('sel', i === selected); });
|
|
415
|
+
var row = rowEls[selected];
|
|
416
|
+
// Also on first paint: the viewer opens on the FAILURE, which on a long trace is far below
|
|
417
|
+
// the fold — leaving the reader looking at step 00 with no selected row in sight.
|
|
418
|
+
if (row && row.scrollIntoView) { row.scrollIntoView({block: 'nearest'}); }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function select(i) {
|
|
422
|
+
selected = i;
|
|
423
|
+
// A step that errored has its own picture worth seeing (in `full` mode); anything else, the
|
|
424
|
+
// rail stays on the end state rather than blanking out.
|
|
425
|
+
shotTab = trace.steps[i] && trace.steps[i].shot_after ? 'step' : 'final';
|
|
426
|
+
markSelected(); renderDetail();
|
|
195
427
|
}
|
|
196
428
|
|
|
197
429
|
function fmtBytes(n) {
|
|
@@ -204,101 +436,227 @@
|
|
|
204
436
|
var c = Math.floor((+st || 0) / 100);
|
|
205
437
|
return c === 2 ? 'ok' : c === 3 ? 'redir' : (c >= 4 ? 'err' : '');
|
|
206
438
|
}
|
|
439
|
+
function originOf(u) { var m = /^[a-z]+:\/\/[^/]*/i.exec(String(u == null ? '' : u)); return m ? m[0] : ''; }
|
|
440
|
+
// Usually the origin is the same for every URL in a trace and eats the column, so the path is
|
|
441
|
+
// what tells them apart — but not always: a cross-origin navigation shown as its path alone
|
|
442
|
+
// reads as "/checkout → /checkout", an arrow claiming a change with nothing changed on screen.
|
|
443
|
+
// So the pair decides: same origin, show paths; different, show both in full.
|
|
444
|
+
function pathOf(u) { return String(u == null ? '' : u).replace(/^[a-z]+:\/\/[^/]*/i, '') || '/'; }
|
|
445
|
+
function urlPair(before, after) {
|
|
446
|
+
var same = originOf(before) === originOf(after);
|
|
447
|
+
return same ? {from: pathOf(before), to: pathOf(after)}
|
|
448
|
+
: {from: String(before == null ? '' : before), to: String(after == null ? '' : after)};
|
|
449
|
+
}
|
|
450
|
+
function block(title, extraHead, bodyHtml) {
|
|
451
|
+
return '<div class="block"><h3><span class="cap">' + title + '</span>' + extraHead +
|
|
452
|
+
'<span class="hair"></span></h3>' + bodyHtml + '</div>';
|
|
453
|
+
}
|
|
207
454
|
function headerTable(h) {
|
|
208
455
|
var keys = h ? Object.keys(h) : [];
|
|
209
|
-
if (!keys.length) { return '<div class="
|
|
210
|
-
return '<dl class="kv
|
|
456
|
+
if (!keys.length) { return '<div class="net-sub">—</div>'; }
|
|
457
|
+
return '<dl class="kv">' + keys.map(function (k) {
|
|
211
458
|
return '<dt>' + esc(k) + '</dt><dd>' + esc(h[k]) + '</dd>';
|
|
212
459
|
}).join('') + '</dl>';
|
|
213
460
|
}
|
|
214
461
|
function bodyBlock(label, body) {
|
|
215
462
|
if (body == null || body === '') { return ''; }
|
|
216
|
-
return '<div class="net-sub">' + label + '</div><pre class="
|
|
463
|
+
return '<div><div class="net-sub">' + label + '</div><pre class="body">' + esc(body) + '</pre></div>';
|
|
217
464
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
465
|
+
|
|
466
|
+
function renderBanner() {
|
|
467
|
+
// The failure, kept in view while reading any OTHER step — otherwise the reason the trace
|
|
468
|
+
// exists is one click away and easy to lose track of.
|
|
469
|
+
var show = failureIdx >= 0 && selected !== failureIdx;
|
|
470
|
+
bannerEl.hidden = !show;
|
|
471
|
+
if (!show) { return; }
|
|
472
|
+
var err = trace.steps[failureIdx].error || {};
|
|
473
|
+
bannerEl.innerHTML =
|
|
474
|
+
'<span class="lbl">failed</span>' +
|
|
475
|
+
'<div style="min-width:0;flex:1">' +
|
|
476
|
+
'<div class="cls">' + esc(err.class || 'Error') + '</div>' +
|
|
477
|
+
'<div class="msg">' + esc(err.message) + '</div>' +
|
|
478
|
+
'</div>' +
|
|
479
|
+
'<button type="button" id="banner-jump">step ' + pad2(failureIdx) + '</button>';
|
|
480
|
+
$('banner-jump').onclick = function () { select(failureIdx); };
|
|
225
481
|
}
|
|
226
482
|
|
|
227
483
|
function renderDetail() {
|
|
484
|
+
renderBanner(); renderRail();
|
|
228
485
|
var s = trace.steps[selected];
|
|
229
|
-
if (!s) {
|
|
230
|
-
|
|
231
|
-
|
|
486
|
+
if (!s) {
|
|
487
|
+
detailEl.innerHTML = '<div class="empty-note">This trace recorded no steps — the example ' +
|
|
488
|
+
'ended before any Capybara action ran, or tracing was turned off for it.</div>';
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
232
491
|
|
|
233
|
-
var
|
|
234
|
-
|
|
235
|
-
|
|
492
|
+
var html =
|
|
493
|
+
'<div class="head">' +
|
|
494
|
+
'<span class="idx">#' + pad2(selected) + '</span>' +
|
|
495
|
+
'<h2>' + esc(s.kind) + '</h2>' +
|
|
496
|
+
'<span class="badge' + (s.error ? ' failed' : '') + '">' + (s.error ? 'errored' : 'ok') + '</span>' +
|
|
497
|
+
'</div>' +
|
|
498
|
+
'<div class="desc-line">' + esc(s.description) + '</div>';
|
|
499
|
+
|
|
500
|
+
var facts =
|
|
501
|
+
'<div><span class="k">elapsed</span><span class="v">' + (s.elapsed_ms || 0) + ' ms</span></div>' +
|
|
502
|
+
'<div><span class="k">duration</span><span class="v">' + (s.duration_ms || 0) + ' ms</span></div>';
|
|
236
503
|
if (s.url_before != null || s.url_after != null) {
|
|
237
504
|
var changed = s.url_before !== s.url_after;
|
|
238
|
-
|
|
239
|
-
|
|
505
|
+
var u = urlPair(s.url_before, s.url_after);
|
|
506
|
+
facts += '<div><span class="k">url</span><span class="v from" title="' + esc(s.url_before) + '">' + esc(u.from) + '</span>' +
|
|
507
|
+
(changed ? '<span class="v from">→</span><span class="v to" title="' + esc(s.url_after) + '">' + esc(u.to) + '</span>' : '') +
|
|
508
|
+
'</div>';
|
|
240
509
|
}
|
|
241
|
-
|
|
242
|
-
html += section('Step', kv);
|
|
510
|
+
html += '<div class="facts">' + facts + '</div>';
|
|
243
511
|
|
|
244
512
|
if (s.error) {
|
|
245
|
-
html +=
|
|
246
|
-
'<div class="panel-error"><div class="cls
|
|
247
|
-
'</div><div class="msg
|
|
513
|
+
html += block('Error', '',
|
|
514
|
+
'<div class="card panel-error"><div class="cls">' + esc(s.error.class || 'Error') +
|
|
515
|
+
'</div><div class="msg">' + esc(s.error.message) + '</div></div>');
|
|
248
516
|
}
|
|
249
517
|
|
|
250
|
-
if (s.console
|
|
251
|
-
var logs = s.console.map(function (c) {
|
|
518
|
+
if (asArray(s.console).length) {
|
|
519
|
+
var logs = asArray(s.console).map(function (c) {
|
|
520
|
+
c = c || {};
|
|
252
521
|
var sev = String(c.severity || 'log').toLowerCase();
|
|
253
|
-
return '<div class="log"><span class="sev ' +
|
|
522
|
+
return '<div class="log"><span class="sev ' + sevClass(sev) + '">' + esc(sev) +
|
|
254
523
|
'</span><span class="msg">' + esc(c.message) + '</span></div>';
|
|
255
524
|
}).join('');
|
|
256
|
-
html +=
|
|
525
|
+
html += block('Console', '<span class="count">' + asArray(s.console).length + '</span>',
|
|
526
|
+
'<div class="card">' + logs + '</div>');
|
|
257
527
|
}
|
|
258
528
|
|
|
259
|
-
if (s.network
|
|
260
|
-
var rows = s.network.map(function (n, i) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
bodyBlock('Response body', n.response_body) +
|
|
277
|
-
'</td></tr>';
|
|
278
|
-
return head + detail;
|
|
529
|
+
if (asArray(s.network).length) {
|
|
530
|
+
var rows = asArray(s.network).map(function (n, i) {
|
|
531
|
+
n = n || {};
|
|
532
|
+
return '<div class="net-row" data-i="' + i + '">' +
|
|
533
|
+
'<span class="m">' + esc(n.method) + '</span>' +
|
|
534
|
+
'<span class="u">' + esc(n.url) + (n.redirected ? ' ↪' : '') + '</span>' +
|
|
535
|
+
'<span class="s ' + statusClass(n.status) + '">' + esc(n.status) + '</span>' +
|
|
536
|
+
'<span class="t">' + esc(String(n.content_type || '').split(';')[0]) + '</span>' +
|
|
537
|
+
'<span class="z">' + fmtBytes(n.size) + '</span>' +
|
|
538
|
+
'<span class="d">' + (n.duration_ms == null ? '' : n.duration_ms + 'ms') + '</span>' +
|
|
539
|
+
'</div>' +
|
|
540
|
+
'<div class="net-detail" data-i="' + i + '" hidden>' +
|
|
541
|
+
'<div><div class="net-sub">request headers</div>' + headerTable(n.request_headers) + '</div>' +
|
|
542
|
+
bodyBlock('request body', n.request_body) +
|
|
543
|
+
'<div><div class="net-sub">response headers</div>' + headerTable(n.response_headers) + '</div>' +
|
|
544
|
+
bodyBlock('response body', n.response_body) +
|
|
545
|
+
'</div>';
|
|
279
546
|
}).join('');
|
|
280
|
-
html +=
|
|
281
|
-
'<
|
|
282
|
-
'<
|
|
283
|
-
'<
|
|
547
|
+
html += block('Network',
|
|
548
|
+
'<span class="count">' + asArray(s.network).length + '</span>' +
|
|
549
|
+
'<span class="hair"></span><span class="note">click a row for headers & bodies</span>',
|
|
550
|
+
'<div class="card net">' +
|
|
551
|
+
'<div class="net-head"><span>method</span><span>url</span><span>status</span>' +
|
|
552
|
+
'<span>type</span><span class="z">size</span><span class="d">time</span></div>' + rows + '</div>');
|
|
284
553
|
}
|
|
285
554
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
555
|
+
// SOURCE, not a rendered preview. This used to re-render the snapshot in an iframe, which was
|
|
556
|
+
// both redundant beside the screenshot and misleading: the viewer's own browser draws it, and
|
|
557
|
+
// the snapshot's `<link>` stylesheets do not resolve from `file://` — so the "preview" was
|
|
558
|
+
// typically unstyled HTML, resembling neither what the step SAW nor what a browser would show.
|
|
559
|
+
// What the snapshot is uniquely good for is reading and copying.
|
|
560
|
+
var domHead = '<span class="count">' + (s.dom_after ? 'captured' : 'not captured') + '</span>' +
|
|
561
|
+
'<span class="hair"></span>' +
|
|
562
|
+
(s.dom_after ? '<button type="button" id="wrap-toggle">' + (wrap ? 'no wrap' : 'wrap') + '</button>' : '');
|
|
563
|
+
html += block('DOM snapshot', domHead, s.dom_after
|
|
564
|
+
? '<div id="dom-host"></div>'
|
|
565
|
+
: '<div class="empty-note">Not captured for this step — a snapshot is taken on an action ' +
|
|
566
|
+
'error, or for every step under <code>CSIM_TRACE=full</code>.</div>');
|
|
295
567
|
|
|
296
568
|
detailEl.innerHTML = html;
|
|
297
569
|
detailEl.scrollTop = 0;
|
|
298
|
-
if (s.network
|
|
299
|
-
if (s.dom_after) {
|
|
570
|
+
if (asArray(s.network).length) { wireNetwork(); }
|
|
571
|
+
if (s.dom_after) {
|
|
572
|
+
wireDomSource(s.dom_after);
|
|
573
|
+
$('wrap-toggle').onclick = function (e) {
|
|
574
|
+
// In place: re-rendering the pane for a wrapping toggle scrolled the reader back to the
|
|
575
|
+
// top and closed whichever network row they had open.
|
|
576
|
+
wrap = !wrap;
|
|
577
|
+
var pre = detailEl.querySelector('pre.dom');
|
|
578
|
+
if (pre) { pre.classList.toggle('nowrap', !wrap); }
|
|
579
|
+
e.currentTarget.textContent = wrap ? 'no wrap' : 'wrap';
|
|
580
|
+
};
|
|
581
|
+
}
|
|
300
582
|
}
|
|
301
583
|
|
|
584
|
+
function wireNetwork() {
|
|
585
|
+
detailEl.querySelectorAll('.net-row').forEach(function (row) {
|
|
586
|
+
row.onclick = function () {
|
|
587
|
+
var d = detailEl.querySelector('.net-detail[data-i="' + row.getAttribute('data-i') + '"]');
|
|
588
|
+
if (d) { d.hidden = !d.hidden; row.classList.toggle('open'); }
|
|
589
|
+
};
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── the screenshot rail ────────────────────────────────────────────
|
|
594
|
+
// Two pictures can exist and they mean different things: the state a FAILING example ENDED in
|
|
595
|
+
// (trace-level, painted once after the example) and the state at ONE step (`CSIM_TRACE=full`).
|
|
596
|
+
// The rail keeps them apart rather than letting a step's shot stand in for the ending.
|
|
597
|
+
// …and only ever an inline PNG. `esc()` keeps a value inside the attribute, but `src` is a URL
|
|
598
|
+
// context: `http://…` in a trace would make this file — whose whole contract is that it opens
|
|
599
|
+
// offline with no network — fetch something, which is both a beacon and a way to smuggle data
|
|
600
|
+
// out of a CI artifact. Anything that is not a `data:image/…` URL is treated as no screenshot.
|
|
601
|
+
function shotSrc(u) {
|
|
602
|
+
u = String(u == null ? '' : u);
|
|
603
|
+
return /^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]*$/i.test(u) ? u : null;
|
|
604
|
+
}
|
|
605
|
+
function railShots() {
|
|
606
|
+
var s = trace.steps[selected] || {};
|
|
607
|
+
return {final: shotSrc((trace.metadata || {}).screenshot), step: shotSrc(s.shot_after)};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function renderRail() {
|
|
611
|
+
var shots = railShots();
|
|
612
|
+
var any = !!(shots.final || shots.step);
|
|
613
|
+
railToggleEl.hidden = !any;
|
|
614
|
+
railEl.hidden = !any || !railOpen;
|
|
615
|
+
if (railEl.hidden) { return; }
|
|
616
|
+
|
|
617
|
+
if (shotTab === 'step' && !shots.step) { shotTab = 'final'; }
|
|
618
|
+
if (shotTab === 'final' && !shots.final) { shotTab = 'step'; }
|
|
619
|
+
tabFinalEl.hidden = !shots.final;
|
|
620
|
+
tabStepEl.hidden = !shots.step;
|
|
621
|
+
tabStepEl.textContent = 'Step ' + pad2(selected);
|
|
622
|
+
tabFinalEl.classList.toggle('on', shotTab === 'final');
|
|
623
|
+
tabStepEl.classList.toggle('on', shotTab === 'step');
|
|
624
|
+
|
|
625
|
+
var s = trace.steps[selected] || {};
|
|
626
|
+
var src = shotTab === 'step' ? shots.step : shots.final;
|
|
627
|
+
var loc = pathOf(shotTab === 'step' ? s.url_after : (trace.steps[trace.steps.length - 1] || {}).url_after);
|
|
628
|
+
railBodyEl.innerHTML =
|
|
629
|
+
'<div class="frame">' +
|
|
630
|
+
'<div class="bar"><span class="dot"></span><span class="loc">' + esc(loc) + '</span></div>' +
|
|
631
|
+
(src ? '<img class="shot" id="shot" alt="the page as the driver painted it" src="' + esc(src) + '">'
|
|
632
|
+
: '<div class="slot"><span>no screenshot</span></div>') +
|
|
633
|
+
'</div>' +
|
|
634
|
+
'<p class="caption">' + (shotTab === 'step'
|
|
635
|
+
? 'The page as the driver painted it at this step — the same boxes every geometry query ' +
|
|
636
|
+
'reads, so this is what the step saw. Per-step shots require <code>CSIM_TRACE=full</code>.'
|
|
637
|
+
: 'The state this example ended in, painted once after the test. Recorded for failures; ' +
|
|
638
|
+
'carried inline in the trace JSON.') + '</p>';
|
|
639
|
+
|
|
640
|
+
// The real pixel size, read off the decoded image rather than assumed.
|
|
641
|
+
dimsEl.textContent = '';
|
|
642
|
+
var img = $('shot');
|
|
643
|
+
if (img) {
|
|
644
|
+
var show = function () {
|
|
645
|
+
if (img.naturalWidth) { dimsEl.textContent = img.naturalWidth + ' × ' + img.naturalHeight; }
|
|
646
|
+
};
|
|
647
|
+
img.complete ? show() : (img.onload = show);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
railToggleEl.onclick = function () {
|
|
652
|
+
railOpen = !railOpen;
|
|
653
|
+
railToggleEl.textContent = railOpen ? 'Hide preview' : 'Show preview';
|
|
654
|
+
renderRail();
|
|
655
|
+
};
|
|
656
|
+
tabFinalEl.onclick = function () { shotTab = 'final'; renderRail(); };
|
|
657
|
+
tabStepEl.onclick = function () { shotTab = 'step'; renderRail(); };
|
|
658
|
+
jumpEl.onclick = function () { if (failureIdx >= 0) { select(failureIdx); } };
|
|
659
|
+
|
|
302
660
|
// Pretty-print a DOM-snapshot HTML string for the source view: the
|
|
303
661
|
// driver serializes flat (one line), so we re-parse with the browser's
|
|
304
662
|
// own DOMParser and re-emit indented. Returns null on any parse trouble
|
|
@@ -314,9 +672,14 @@
|
|
|
314
672
|
if (!doc || !doc.documentElement) { return null; }
|
|
315
673
|
var out = [];
|
|
316
674
|
var pad = function (d) { return ' '.repeat(d); };
|
|
675
|
+
// Attribute values and text are re-emitted as HTML, so they need HTML escaping — this block
|
|
676
|
+
// is offered for reading AND copying, and `title="say "hi""` does not paste back as the tree
|
|
677
|
+
// it came from. (The display `esc()` the caller applies is a separate layer.)
|
|
678
|
+
var attrEsc = function (v) { return String(v).replace(/&/g, '&').replace(/"/g, '"'); };
|
|
679
|
+
var textEsc = function (v) { return String(v).replace(/&/g, '&').replace(/</g, '<'); };
|
|
317
680
|
var attrs = function (el) {
|
|
318
681
|
return Array.prototype.map.call(el.attributes, function (a) {
|
|
319
|
-
return a.value === '' ? ' ' + a.name : ' ' + a.name + '="' + a.value + '"';
|
|
682
|
+
return a.value === '' ? ' ' + a.name : ' ' + a.name + '="' + attrEsc(a.value) + '"';
|
|
320
683
|
}).join('');
|
|
321
684
|
};
|
|
322
685
|
function emit(el, depth) {
|
|
@@ -340,11 +703,11 @@
|
|
|
340
703
|
if (kids.length === 0) {
|
|
341
704
|
out.push(pad(depth) + open + '</' + tag + '>');
|
|
342
705
|
} else if (kids.length === 1 && kids[0].nodeType === 3) {
|
|
343
|
-
out.push(pad(depth) + open + kids[0].nodeValue.trim() + '</' + tag + '>');
|
|
706
|
+
out.push(pad(depth) + open + textEsc(kids[0].nodeValue.trim()) + '</' + tag + '>');
|
|
344
707
|
} else {
|
|
345
708
|
out.push(pad(depth) + open);
|
|
346
709
|
kids.forEach(function (k) {
|
|
347
|
-
if (k.nodeType === 3) { out.push(pad(depth + 1) + k.nodeValue.trim()); }
|
|
710
|
+
if (k.nodeType === 3) { out.push(pad(depth + 1) + textEsc(k.nodeValue.trim())); }
|
|
348
711
|
else if (k.nodeType === 8) { out.push(pad(depth + 1) + '<!--' + k.nodeValue + '-->'); }
|
|
349
712
|
else { emit(k, depth + 1); }
|
|
350
713
|
});
|
|
@@ -356,24 +719,10 @@
|
|
|
356
719
|
return out.join('\n');
|
|
357
720
|
}
|
|
358
721
|
|
|
359
|
-
function
|
|
360
|
-
var
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
function preview() {
|
|
364
|
-
bPrev.classList.add('on'); bSrc.classList.remove('on');
|
|
365
|
-
var f = document.createElement('iframe');
|
|
366
|
-
f.className = 'dom'; f.setAttribute('sandbox', ''); // no scripts: a static visual snapshot
|
|
367
|
-
host.innerHTML = ''; host.appendChild(f);
|
|
368
|
-
f.srcdoc = domHtml;
|
|
369
|
-
}
|
|
370
|
-
function source() {
|
|
371
|
-
bSrc.classList.add('on'); bPrev.classList.remove('on');
|
|
372
|
-
var pretty = formatHtml(domHtml);
|
|
373
|
-
host.innerHTML = '<pre class="dom">' + esc(pretty != null ? pretty : domHtml) + '</pre>';
|
|
374
|
-
}
|
|
375
|
-
bPrev.onclick = preview; bSrc.onclick = source;
|
|
376
|
-
preview();
|
|
722
|
+
function wireDomSource(domHtml) {
|
|
723
|
+
var pretty = formatHtml(domHtml);
|
|
724
|
+
$('dom-host').innerHTML = '<pre class="dom' + (wrap ? '' : ' nowrap') + '">' +
|
|
725
|
+
esc(pretty != null ? pretty : domHtml) + '</pre>';
|
|
377
726
|
}
|
|
378
727
|
|
|
379
728
|
// ── loading other traces (FileReader → CORS-free, works on file://) ──
|
|
@@ -385,10 +734,10 @@
|
|
|
385
734
|
};
|
|
386
735
|
r.readAsText(file);
|
|
387
736
|
}
|
|
388
|
-
|
|
737
|
+
$('file').addEventListener('change', function (e) {
|
|
389
738
|
if (e.target.files[0]) { readFile(e.target.files[0]); }
|
|
390
739
|
});
|
|
391
|
-
var veil =
|
|
740
|
+
var veil = $('drop-veil');
|
|
392
741
|
window.addEventListener('dragover', function (e) { e.preventDefault(); veil.classList.add('on'); });
|
|
393
742
|
window.addEventListener('dragleave', function (e) { if (e.relatedTarget === null) { veil.classList.remove('on'); } });
|
|
394
743
|
window.addEventListener('drop', function (e) {
|
|
@@ -397,8 +746,13 @@
|
|
|
397
746
|
});
|
|
398
747
|
window.addEventListener('keydown', function (e) {
|
|
399
748
|
if (!trace) { return; }
|
|
400
|
-
if (e.
|
|
401
|
-
|
|
749
|
+
if (e.ctrlKey || e.metaKey || e.altKey) { return; } // Ctrl/Cmd+F is find-in-page, not `f`
|
|
750
|
+
var last = trace.steps.length - 1;
|
|
751
|
+
if (e.key === 'ArrowDown' || e.key === 'j') { select(Math.min(selected + 1, last)); }
|
|
752
|
+
else if (e.key === 'ArrowUp' || e.key === 'k') { select(Math.max(selected - 1, 0)); }
|
|
753
|
+
else if (e.key === 'f' && failureIdx >= 0) { select(failureIdx); }
|
|
754
|
+
else { return; }
|
|
755
|
+
e.preventDefault();
|
|
402
756
|
});
|
|
403
757
|
|
|
404
758
|
load(readEmbedded());
|