@ia-qa/self-healing 1.6.7 → 1.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -0
- package/TUTORIAL.md +112 -2
- package/assets/logoKawaiiGreenTiny.png +0 -0
- package/dist/cli/args.js +4 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/diff.d.ts +6 -1
- package/dist/cli/diff.js +114 -4
- package/dist/cli/diff.js.map +1 -1
- package/dist/cli/emit.d.ts +71 -0
- package/dist/cli/emit.js +150 -0
- package/dist/cli/emit.js.map +1 -0
- package/dist/cli/fix.js +36 -0
- package/dist/cli/fix.js.map +1 -1
- package/dist/cli/history.d.ts +12 -0
- package/dist/cli/history.js +95 -0
- package/dist/cli/history.js.map +1 -0
- package/dist/cli/index.js +53 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.js +32 -0
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/map.js +60 -0
- package/dist/cli/map.js.map +1 -1
- package/dist/cli/run.js +77 -7
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/ui.d.ts +16 -0
- package/dist/cli/ui.js +275 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/evidence.d.ts +130 -0
- package/dist/evidence.js +232 -0
- package/dist/evidence.js.map +1 -0
- package/dist/history.d.ts +145 -0
- package/dist/history.js +230 -0
- package/dist/history.js.map +1 -0
- package/dist/htmlReport.d.ts +47 -0
- package/dist/htmlReport.js +76 -1
- package/dist/htmlReport.js.map +1 -1
- package/dist/junit.d.ts +53 -0
- package/dist/junit.js +150 -0
- package/dist/junit.js.map +1 -0
- package/dist/mcp/server.js +41 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/ui/icon.d.ts +41 -0
- package/dist/ui/icon.js +115 -0
- package/dist/ui/icon.js.map +1 -0
- package/dist/ui/lock.d.ts +40 -0
- package/dist/ui/lock.js +138 -0
- package/dist/ui/lock.js.map +1 -0
- package/dist/ui/page.d.ts +1 -0
- package/dist/ui/page.js +584 -0
- package/dist/ui/page.js.map +1 -0
- package/dist/ui/registry.d.ts +62 -0
- package/dist/ui/registry.js +139 -0
- package/dist/ui/registry.js.map +1 -0
- package/dist/ui/server.d.ts +67 -0
- package/dist/ui/server.js +604 -0
- package/dist/ui/server.js.map +1 -0
- package/dist/ui/shortcut.d.ts +90 -0
- package/dist/ui/shortcut.js +176 -0
- package/dist/ui/shortcut.js.map +1 -0
- package/package.json +2 -1
- package/skills/ia-qa-heal/SKILL.md +27 -0
package/dist/ui/page.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderConsoleHtml = renderConsoleHtml;
|
|
4
|
+
/**
|
|
5
|
+
* The console's single page: inline HTML, CSS and JS, no bundler, no dependency,
|
|
6
|
+
* no external asset. Same discipline as the HTML report — a page that reaches
|
|
7
|
+
* the network is a page that can leak the contracts of the user's app.
|
|
8
|
+
*
|
|
9
|
+
* The chart palette is not a taste call. Verdicts are a **status** scale
|
|
10
|
+
* (good / warning / critical), so the three steps are reserved, always carry an
|
|
11
|
+
* icon and a label beside the colour, and were validated against this surface
|
|
12
|
+
* rather than eyeballed: `#0d9488 / #d97706 / #e11d48` on `#18181b` clears the
|
|
13
|
+
* lightness band, the chroma floor, CVD separation (ΔE 9.4 worst adjacent),
|
|
14
|
+
* the normal-vision floor (16.6) and 3:1 contrast. The bright brand green and
|
|
15
|
+
* amber that the rest of the product uses do **not** clear that bar as chart
|
|
16
|
+
* fills, which is exactly the kind of thing nobody catches by looking.
|
|
17
|
+
*
|
|
18
|
+
* The hotspots chart is a single series and so needs no legend — its title
|
|
19
|
+
* names it — and one hue is enough.
|
|
20
|
+
*/
|
|
21
|
+
const server_1 = require("./server");
|
|
22
|
+
function renderConsoleHtml(token) {
|
|
23
|
+
return `<!DOCTYPE html>
|
|
24
|
+
<html lang="en"><head>
|
|
25
|
+
<meta charset="UTF-8">
|
|
26
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
27
|
+
<title>ia-qa-heal — console</title>
|
|
28
|
+
<style>
|
|
29
|
+
:root{
|
|
30
|
+
--bg:#09090b;--surface:#18181b;--surface2:#111113;--border:#27272a;
|
|
31
|
+
--text:#fafafa;--muted:#a1a1aa;--orange:#f97316;--orange2:#fb923c;
|
|
32
|
+
--pass:#0d9488;--fix:#d97706;--block:#e11d48;--info:#8b5cf6;
|
|
33
|
+
}
|
|
34
|
+
*{box-sizing:border-box}
|
|
35
|
+
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
36
|
+
a{color:var(--orange2)}
|
|
37
|
+
header{border-bottom:1px solid var(--border);background:linear-gradient(180deg,#1c1005,var(--surface2));padding:18px 0}
|
|
38
|
+
.wrap{max-width:1180px;margin:0 auto;padding:0 22px}
|
|
39
|
+
.head{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
|
40
|
+
.head-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
41
|
+
.picker{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)}
|
|
42
|
+
.picker select{font:inherit;font-size:12.5px;max-width:340px;background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:9px;padding:6px 10px;cursor:pointer}
|
|
43
|
+
.picker select:hover{border-color:var(--orange)}
|
|
44
|
+
.brand{font-size:12.5px;font-weight:700;color:var(--orange2);text-decoration:none;border:1px solid #f9731655;background:#f9731614;border-radius:99px;padding:5px 13px;white-space:nowrap}
|
|
45
|
+
.brand:hover{background:#f9731626}
|
|
46
|
+
footer{border-top:1px solid var(--border);background:var(--surface2);padding:20px 0;margin-top:10px}
|
|
47
|
+
.foot{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
|
|
48
|
+
.foot a{font-weight:600;text-decoration:none}
|
|
49
|
+
.foot a:hover{text-decoration:underline}
|
|
50
|
+
h1{margin:0;font-size:17px;letter-spacing:-.01em}
|
|
51
|
+
h2{margin:0 0 12px;font-size:14px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
|
|
52
|
+
.sub{color:var(--muted);font-size:12.5px;margin-top:3px}
|
|
53
|
+
main{padding:22px 0 60px}
|
|
54
|
+
section{margin-bottom:26px}
|
|
55
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px}
|
|
56
|
+
.grid{display:grid;gap:16px}
|
|
57
|
+
/* Without this the shorter card is stretched to the taller one and shows a
|
|
58
|
+
block of dead space under its last tile. */
|
|
59
|
+
.cols{grid-template-columns:minmax(0,2.2fr) minmax(260px,1fr);align-items:start}
|
|
60
|
+
@media(max-width:900px){.cols{grid-template-columns:1fr}}
|
|
61
|
+
.verdict{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
|
62
|
+
.big{font-size:26px;font-weight:800;letter-spacing:-.02em}
|
|
63
|
+
.v-PASS{color:var(--pass)}.v-FIX{color:var(--fix)}.v-BLOCK{color:var(--block)}
|
|
64
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-top:16px}
|
|
65
|
+
.tile{background:var(--surface2);border:1px solid var(--border);border-radius:11px;padding:13px 15px}
|
|
66
|
+
.tile .n{font-size:22px;font-weight:700;letter-spacing:-.02em}
|
|
67
|
+
.tile .l{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:2px}
|
|
68
|
+
.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--muted);margin-top:10px}
|
|
69
|
+
.legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;vertical-align:middle}
|
|
70
|
+
.caveat{margin-top:10px;font-size:12px;color:#fcd34d;background:#f59e0b12;border:1px solid #f59e0b33;border-radius:8px;padding:8px 11px}
|
|
71
|
+
.btns{display:flex;flex-direction:column;gap:8px}
|
|
72
|
+
button{font:inherit;font-size:13px;text-align:left;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:9px;padding:9px 12px;cursor:pointer}
|
|
73
|
+
button:hover:not(:disabled){border-color:var(--orange);color:var(--orange2)}
|
|
74
|
+
button:disabled{opacity:.5;cursor:progress}
|
|
75
|
+
button.w{border-color:#e11d4840}
|
|
76
|
+
button.w:hover:not(:disabled){border-color:var(--block);color:#fda4af}
|
|
77
|
+
.log{margin-top:12px;background:#000;border:1px solid var(--border);border-radius:9px;padding:11px;font:11.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#d4d4d8;white-space:pre-wrap;max-height:320px;overflow:auto}
|
|
78
|
+
.res{margin-top:12px;border-radius:10px;padding:12px 14px;border:1px solid}
|
|
79
|
+
.res-ok{background:#0d948814;border-color:#0d948855}
|
|
80
|
+
.res-fix{background:#d9770614;border-color:#d9770655}
|
|
81
|
+
.res-block{background:#e11d4814;border-color:#e11d4855}
|
|
82
|
+
.res-info,.res-warn{background:#8b5cf614;border-color:#8b5cf655}
|
|
83
|
+
.res-stopped{background:#a1a1aa14;border-color:#a1a1aa55}
|
|
84
|
+
.res-head{font-weight:700;font-size:13.5px;display:flex;gap:8px;align-items:baseline}
|
|
85
|
+
.res-what{margin-top:7px;font-size:12.5px;color:var(--muted);line-height:1.5}
|
|
86
|
+
.res-run{margin-top:9px;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px}
|
|
87
|
+
.spin{width:11px;height:11px;border:2px solid var(--border);border-top-color:var(--orange);border-radius:50%;display:inline-block;animation:sp .7s linear infinite}
|
|
88
|
+
@keyframes sp{to{transform:rotate(360deg)}}
|
|
89
|
+
details.tech{margin-top:10px}
|
|
90
|
+
details.tech summary{cursor:pointer;font-size:11.5px;color:var(--muted);list-style:none}
|
|
91
|
+
details.tech summary::-webkit-details-marker{display:none}
|
|
92
|
+
details.tech summary::before{content:"▸ ";color:var(--orange2)}
|
|
93
|
+
details.tech[open] summary::before{content:"▾ "}
|
|
94
|
+
.dec{border:1px solid var(--border);border-radius:12px;padding:15px;margin-bottom:13px;background:var(--surface2)}
|
|
95
|
+
.dec-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:5px}
|
|
96
|
+
.badge{font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:2px 9px;border-radius:99px;border:1px solid}
|
|
97
|
+
.b-ambiguous{color:var(--fix);border-color:#d9770655;background:#d9770614}
|
|
98
|
+
.b-lost{color:var(--block);border-color:#e11d4855;background:#e11d4814}
|
|
99
|
+
.b-rebound{color:var(--block);border-color:#e11d4855;background:#e11d4814}
|
|
100
|
+
.dec-label{font-weight:600}
|
|
101
|
+
.dec-page{font-size:11.5px;color:var(--muted);margin-left:auto}
|
|
102
|
+
.num{font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums;border:1px solid var(--border);border-radius:99px;padding:1px 8px}
|
|
103
|
+
.todo{margin:9px 0 4px;font-size:13px;line-height:1.55;background:#f9731610;border-left:3px solid var(--orange);border-radius:0 8px 8px 0;padding:9px 12px}
|
|
104
|
+
.todo b{color:var(--orange2)}
|
|
105
|
+
.todo code{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:0 5px;font-size:11.5px;color:var(--orange2)}
|
|
106
|
+
.dec.flash{animation:fl 2.2s ease-out}
|
|
107
|
+
@keyframes fl{0%,55%{box-shadow:0 0 0 3px var(--orange)}100%{box-shadow:0 0 0 0 transparent}}
|
|
108
|
+
.lede{margin-top:12px;font-size:13px;color:var(--text);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
109
|
+
.lede-ok{color:var(--muted)}
|
|
110
|
+
.cta{font-size:12.5px;padding:5px 12px;border-radius:99px;border:1px solid var(--orange);background:#f9731618;color:var(--orange2);font-weight:600;cursor:pointer}
|
|
111
|
+
.cta:hover{background:#f9731630}
|
|
112
|
+
#goto .cta{margin-top:10px}
|
|
113
|
+
.tile.clickable{cursor:pointer}
|
|
114
|
+
.tile.clickable:hover{border-color:var(--orange)}
|
|
115
|
+
.proj{display:grid;gap:7px}
|
|
116
|
+
.prow{display:flex;gap:14px;font-size:12.5px;align-items:baseline;flex-wrap:wrap}
|
|
117
|
+
.pk{width:120px;color:var(--muted);flex-shrink:0}
|
|
118
|
+
.pv{flex:1;min-width:0;word-break:break-all}
|
|
119
|
+
.pv code{background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:1px 6px;font-size:11.5px;color:var(--orange2)}
|
|
120
|
+
.sel{font:11.5px ui-monospace,Menlo,monospace;color:var(--orange2);word-break:break-all}
|
|
121
|
+
.sites{margin-top:7px;font-size:11.5px;color:var(--muted)}
|
|
122
|
+
.sites code{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:0 5px;color:#c4b5fd;margin-right:5px}
|
|
123
|
+
.evid-title{font-size:11.5px;color:var(--orange2);font-weight:700;margin:12px 0 8px}
|
|
124
|
+
.figs{display:flex;gap:14px;flex-wrap:wrap}
|
|
125
|
+
figure{margin:0}
|
|
126
|
+
.crop{position:relative;overflow:hidden;border:1px solid var(--border);border-radius:8px;background:var(--bg)}
|
|
127
|
+
.crop-inner{position:absolute;top:0;left:0;transform-origin:0 0;background-repeat:no-repeat}
|
|
128
|
+
.crop-hi{position:absolute;outline:2px solid var(--orange);box-shadow:0 0 0 9999px rgba(0,0,0,.58);border-radius:2px}
|
|
129
|
+
figcaption{margin-top:6px;font-size:11px;color:var(--muted);max-width:360px}
|
|
130
|
+
figcaption b{color:var(--text)}
|
|
131
|
+
figcaption code{display:block;margin-top:2px;font-size:10.5px;color:var(--orange2);word-break:break-all}
|
|
132
|
+
.copy{margin-top:6px;font-size:11px;padding:3px 9px}
|
|
133
|
+
.empty{color:var(--muted);font-size:13px}
|
|
134
|
+
table{width:100%;border-collapse:collapse;font-size:12px;margin-top:12px}
|
|
135
|
+
th,td{text-align:left;padding:6px 9px;border-bottom:1px solid var(--border)}
|
|
136
|
+
th{color:var(--muted);font-weight:600;font-size:10.5px;text-transform:uppercase;letter-spacing:.05em}
|
|
137
|
+
td.n{text-align:right;font-variant-numeric:tabular-nums}
|
|
138
|
+
.axis{fill:#71717a;font-size:10px}
|
|
139
|
+
.note{color:var(--muted);font-size:12px;margin-top:8px}
|
|
140
|
+
</style>
|
|
141
|
+
</head><body>
|
|
142
|
+
<header><div class="wrap head">
|
|
143
|
+
<div>
|
|
144
|
+
<h1>🩹 ia-qa-heal <span style="color:var(--muted);font-weight:400">console</span></h1>
|
|
145
|
+
<div class="sub" id="sub">loading…</div>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="head-right">
|
|
148
|
+
<label class="picker" id="pickerWrap" style="display:none">
|
|
149
|
+
<span>Project</span>
|
|
150
|
+
<select id="picker"></select>
|
|
151
|
+
</label>
|
|
152
|
+
<a class="brand" href="https://ia-qa.com" target="_blank" rel="noopener">ia-qa.com ↗</a>
|
|
153
|
+
</div>
|
|
154
|
+
</div></header>
|
|
155
|
+
<main><div class="wrap">
|
|
156
|
+
<section class="grid cols">
|
|
157
|
+
<div class="card" id="verdictCard"><div class="empty">loading…</div></div>
|
|
158
|
+
<div class="card">
|
|
159
|
+
<h2>Actions</h2>
|
|
160
|
+
<div class="btns" id="actions"></div>
|
|
161
|
+
<div class="note">Buttons marked ✎ change files on your computer. The others only look. Nothing is ever committed, and nothing leaves this machine.</div>
|
|
162
|
+
<div id="result"></div>
|
|
163
|
+
</div>
|
|
164
|
+
</section>
|
|
165
|
+
|
|
166
|
+
<section class="card">
|
|
167
|
+
<h2>Decisions waiting for you</h2>
|
|
168
|
+
<div id="decisions"><div class="empty">loading…</div></div>
|
|
169
|
+
</section>
|
|
170
|
+
|
|
171
|
+
<section class="card">
|
|
172
|
+
<h2>Which project is this?</h2>
|
|
173
|
+
<div id="project"><div class="empty">loading…</div></div>
|
|
174
|
+
</section>
|
|
175
|
+
|
|
176
|
+
<section class="card">
|
|
177
|
+
<h2>History</h2>
|
|
178
|
+
<div id="history"><div class="empty">loading…</div></div>
|
|
179
|
+
</section>
|
|
180
|
+
</div></main>
|
|
181
|
+
<footer><div class="wrap foot">
|
|
182
|
+
<span>Local-first self-healing for UI tests — nothing leaves your machine.</span>
|
|
183
|
+
<span><a href="https://ia-qa.com" target="_blank" rel="noopener">ia-qa.com</a> · <a href="https://ia-qa.com/method" target="_blank" rel="noopener">Method</a> · <a href="https://www.npmjs.com/package/@ia-qa/self-healing" target="_blank" rel="noopener">@ia-qa/self-healing</a></span>
|
|
184
|
+
</div></footer>
|
|
185
|
+
|
|
186
|
+
<script>
|
|
187
|
+
const T = ${JSON.stringify(token)};
|
|
188
|
+
const q = (s) => document.querySelector(s);
|
|
189
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
190
|
+
const VC = { PASS:'var(--pass)', FIX:'var(--fix)', BLOCK:'var(--block)' };
|
|
191
|
+
const VI = { PASS:'✅', FIX:'🔧', BLOCK:'⛔' };
|
|
192
|
+
|
|
193
|
+
async function load() {
|
|
194
|
+
const r = await fetch('/api/state?t=' + T);
|
|
195
|
+
render(await r.json());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function render(s) {
|
|
199
|
+
renderPicker(s); renderHead(s); renderVerdict(s); renderActions(s);
|
|
200
|
+
renderDecisions(s); renderHistory(s); renderGoto(s);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* The project switcher. It only ever offers folders the server already knows —
|
|
204
|
+
the value posted back is one of these strings, and the server resolves it
|
|
205
|
+
against the same list. Nothing here can name a directory the user never
|
|
206
|
+
opened a console in. */
|
|
207
|
+
function renderPicker(s) {
|
|
208
|
+
const list = s.projects || [];
|
|
209
|
+
const wrap = q('#pickerWrap');
|
|
210
|
+
// Always shown, even with one project. Hiding it until a second appears made
|
|
211
|
+
// the switcher undiscoverable at exactly the moment someone needed to find
|
|
212
|
+
// it — and since the only way to get that second project used to be a
|
|
213
|
+
// terminal, the feature could never start existing.
|
|
214
|
+
wrap.style.display = '';
|
|
215
|
+
const sel = q('#picker');
|
|
216
|
+
sel.innerHTML =
|
|
217
|
+
list.map((p) =>
|
|
218
|
+
'<option value="' + esc(p.dir) + '"' + (p.dir === s.activeDir ? ' selected' : '') + '>'
|
|
219
|
+
+ esc(shortDir(p.dir)) + (p.baseUrl ? ' — ' + esc(p.baseUrl) : '') + '</option>'
|
|
220
|
+
).join('')
|
|
221
|
+
+ '<option value="__add__">+ Add another project…</option>';
|
|
222
|
+
|
|
223
|
+
sel.onchange = async () => {
|
|
224
|
+
const wantsAdd = sel.value === '__add__';
|
|
225
|
+
const dir = wantsAdd ? askForFolder() : sel.value;
|
|
226
|
+
if (!dir) { sel.value = s.activeDir; return; } // cancelled — put the menu back
|
|
227
|
+
sel.disabled = true;
|
|
228
|
+
try {
|
|
229
|
+
const r = await fetch('/api/project' + (wantsAdd ? '/add' : '') + '?t=' + T, {
|
|
230
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
231
|
+
body: JSON.stringify({ dir }),
|
|
232
|
+
});
|
|
233
|
+
const next = await r.json();
|
|
234
|
+
if (next.error) { alert(next.error); sel.disabled = false; render(s); return; }
|
|
235
|
+
q('#result').innerHTML = ''; // a result from the previous project would be a lie here
|
|
236
|
+
render(next);
|
|
237
|
+
scrollTo({ top: 0, behavior: 'smooth' });
|
|
238
|
+
} catch (e) { alert('Could not switch project: ' + e); render(s); }
|
|
239
|
+
sel.disabled = false;
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* A browser cannot open a folder picker without a file input, and a file input
|
|
244
|
+
gives us a file, not the directory the user meant. Asking for the path is the
|
|
245
|
+
honest version — and it is what someone copies out of their editor's title
|
|
246
|
+
bar or their terminal anyway. */
|
|
247
|
+
function askForFolder() {
|
|
248
|
+
const v = prompt(
|
|
249
|
+
'Add a project to this console.\\n\\n'
|
|
250
|
+
+ 'Paste the full path of the project folder — the one you would open in your '
|
|
251
|
+
+ 'editor. It must already contain a .ia-qa folder (run "ia-qa-heal init" there '
|
|
252
|
+
+ 'if it does not).',
|
|
253
|
+
''
|
|
254
|
+
);
|
|
255
|
+
return v && v.trim() ? v.trim() : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Absolute paths are long and all alike at the front. The tail is what tells
|
|
259
|
+
two projects apart. */
|
|
260
|
+
function shortDir(dir) {
|
|
261
|
+
const parts = String(dir).split(/[\\\\/]/).filter(Boolean);
|
|
262
|
+
return parts.length <= 2 ? dir : '…' + '/' + parts.slice(-2).join('/');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* "Needs you to decide" used to be the end of the sentence: the thing to decide
|
|
266
|
+
was several hundred pixels below, with nothing connecting the two. This is the
|
|
267
|
+
link — placed inside the result card, where the person is actually looking. */
|
|
268
|
+
function renderGoto(s) {
|
|
269
|
+
const box = q('#goto');
|
|
270
|
+
if (!box) return;
|
|
271
|
+
const n = (s.decisions || []).length;
|
|
272
|
+
if (n === 0) { box.innerHTML = ''; return; }
|
|
273
|
+
box.innerHTML = '<button class="cta">👇 Show me the ' + n + ' thing' + (n === 1 ? '' : 's') + ' waiting for me</button>';
|
|
274
|
+
box.querySelector('button').onclick = () => jumpToDecisions();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function jumpToDecisions() {
|
|
278
|
+
const first = document.querySelector('.dec');
|
|
279
|
+
const target = first || q('#decisions');
|
|
280
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
281
|
+
// A scroll alone leaves you wondering which of them you were sent to.
|
|
282
|
+
if (first) { first.classList.add('flash'); setTimeout(() => first.classList.remove('flash'), 2200); }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* Which app, and which tests. A console opened from a desktop icon has to say
|
|
286
|
+
this out loud: "localhost:3000" is every project this person has, and the
|
|
287
|
+
folder it is bound to is the only thing that distinguishes them. */
|
|
288
|
+
function renderHead(s) {
|
|
289
|
+
const p = s.project || {};
|
|
290
|
+
q('#sub').innerHTML = p.error ? esc(p.error)
|
|
291
|
+
: '🌐 ' + esc(p.baseUrl || '(no baseUrl)') + ' · ' + esc(p.pages || 0) + ' pages';
|
|
292
|
+
const fw = (p.frameworks || []).filter((f) => f !== 'pom');
|
|
293
|
+
q('#project').innerHTML =
|
|
294
|
+
'<div class="proj">'
|
|
295
|
+
+ row2('App', esc(p.baseUrl || '—'))
|
|
296
|
+
+ row2('Project folder', '<code>' + esc(p.dir || '—') + '</code>')
|
|
297
|
+
+ row2('Tests', (p.testPaths && p.testPaths.length)
|
|
298
|
+
? '<code>' + p.testPaths.map(esc).join('</code> <code>') + '</code>'
|
|
299
|
+
+ (p.scannedFiles ? ' · ' + esc(p.scannedFiles) + ' files read' : '')
|
|
300
|
+
+ (fw.length ? ' · ' + fw.map(esc).join(', ') : '')
|
|
301
|
+
: '<span style="color:var(--muted)">none declared — add "testPaths" to config.json, then “Read my test files again”</span>')
|
|
302
|
+
+ row2('Run with', p.testCommand ? '<code>' + esc(p.testCommand) + '</code>' : '<span style="color:var(--muted)">not set</span>')
|
|
303
|
+
+ '</div>'
|
|
304
|
+
+ '<div class="note">This console is bound to that folder. To look at another project, run <code>ia-qa-heal ui</code> from it (or give it its own desktop icon with <code>ia-qa-heal ui --shortcut</code>).</div>';
|
|
305
|
+
}
|
|
306
|
+
function row2(k, v) {
|
|
307
|
+
return '<div class="prow"><span class="pk">' + esc(k) + '</span><span class="pv">' + v + '</span></div>';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function renderVerdict(s) {
|
|
311
|
+
const v = s.verdict;
|
|
312
|
+
if (!v) {
|
|
313
|
+
q('#verdictCard').innerHTML = '<div class="empty">Nothing to compare yet. Capture the app, promote a baseline, then change something and re-capture.</div>';
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const t = v.totals || {};
|
|
317
|
+
const cov = s.coverage || { measured: 0, stale: 0 };
|
|
318
|
+
const total = cov.measured + cov.stale;
|
|
319
|
+
const waiting = (s.decisions || []).length;
|
|
320
|
+
q('#verdictCard').innerHTML =
|
|
321
|
+
'<div class="verdict"><span class="big v-' + esc(v.verdict) + '">' + (VI[v.verdict]||'') + ' ' + esc(v.verdict) + '</span>'
|
|
322
|
+
+ '<span style="color:var(--muted);font-size:12.5px">' + esc(cov.measured) + ' of ' + esc(total) + ' mapped pages measured'
|
|
323
|
+
+ (cov.stale ? ' · ' + esc(cov.stale) + ' not covered by this verdict' : '') + '</span></div>'
|
|
324
|
+
+ (waiting > 0
|
|
325
|
+
? '<div class="lede">' + waiting + ' element' + (waiting === 1 ? '' : 's') + ' need' + (waiting === 1 ? 's' : '')
|
|
326
|
+
+ ' you to decide — the tool refused to guess. <button class="cta" id="lede-go">Show me 👇</button></div>'
|
|
327
|
+
: '<div class="lede lede-ok">Nothing is waiting on you.</div>')
|
|
328
|
+
+ '<div class="tiles">'
|
|
329
|
+
+ tile(t.ok, 'ok') + tile(t.healable, 'healable', 'var(--fix)') + tile(t.ambiguous, 'ambiguous', 'var(--fix)', true)
|
|
330
|
+
+ tile(t.lost, 'lost', 'var(--block)', true) + tile(t.rebound, 'rebound', 'var(--block)', true)
|
|
331
|
+
+ tile((s.nameDrift||[]).length, 'name drift', 'var(--info)')
|
|
332
|
+
+ '</div>';
|
|
333
|
+
const go = q('#lede-go');
|
|
334
|
+
if (go) go.onclick = jumpToDecisions;
|
|
335
|
+
// A tile counting something a human must look at is itself the way there.
|
|
336
|
+
document.querySelectorAll('#verdictCard .tile.clickable').forEach((el) => (el.onclick = jumpToDecisions));
|
|
337
|
+
}
|
|
338
|
+
function tile(n, l, c, jumps) {
|
|
339
|
+
const on = jumps && (n ?? 0) > 0;
|
|
340
|
+
return '<div class="tile' + (on ? ' clickable' : '') + '"'
|
|
341
|
+
+ (on ? ' title="Jump to the ' + esc(l) + ' element' + (n === 1 ? '' : 's') + ' waiting for you"' : '')
|
|
342
|
+
+ '><div class="n"' + (c && n > 0 ? ' style="color:' + c + '"' : '') + '>' + esc(n ?? 0) + '</div>'
|
|
343
|
+
+ '<div class="l">' + esc(l) + (on ? ' ↓' : '') + '</div></div>';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* A result is shown in plain words first. The raw command output stays one
|
|
347
|
+
click away — a manual QA should never have to read "exit 0" to find out
|
|
348
|
+
whether something worked, and a developer should never lose the detail. */
|
|
349
|
+
const TONE_WORD = {
|
|
350
|
+
ok: { icon: '✅', word: 'All good' },
|
|
351
|
+
fix: { icon: '🔧', word: 'Repairs are ready' },
|
|
352
|
+
block: { icon: '⛔', word: 'Needs you to decide' },
|
|
353
|
+
warn: { icon: '⚠️', word: 'Worth a look' },
|
|
354
|
+
info: { icon: 'ℹ️', word: 'Done' },
|
|
355
|
+
// Not a verdict about the app — the command never got far enough to have one.
|
|
356
|
+
stopped: { icon: '🚧', word: "It couldn't run yet" },
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
function renderActions(s) {
|
|
360
|
+
q('#actions').innerHTML = (s.actions || []).map((a) =>
|
|
361
|
+
'<button data-a="' + esc(a.id) + '" title="' + esc(a.means) + '"' + (a.writes ? ' class="w"' : '') + '>'
|
|
362
|
+
+ esc(a.label) + (a.writes ? ' ✎' : '') + '</button>'
|
|
363
|
+
).join('');
|
|
364
|
+
document.querySelectorAll('#actions button').forEach((b) => {
|
|
365
|
+
b.onclick = async () => {
|
|
366
|
+
const id = b.dataset.a;
|
|
367
|
+
const label = b.textContent.replace(' ✎', '').trim();
|
|
368
|
+
const writes = b.classList.contains('w');
|
|
369
|
+
if (writes && !confirm('“' + label + '” changes files on your computer.\\n\\nNothing is committed — you can still review or undo it afterwards.\\n\\nGo ahead?')) return;
|
|
370
|
+
|
|
371
|
+
document.querySelectorAll('#actions button').forEach((x) => (x.disabled = true));
|
|
372
|
+
const box = q('#result');
|
|
373
|
+
box.innerHTML = '<div class="res res-info"><div class="res-run"><span class="spin"></span> Running “' + esc(label) + '” — this can take a moment.</div></div>';
|
|
374
|
+
try {
|
|
375
|
+
const r = await fetch('/api/run?t=' + T, {
|
|
376
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
377
|
+
body: JSON.stringify({ action: id }),
|
|
378
|
+
});
|
|
379
|
+
const out = await r.json();
|
|
380
|
+
const t = TONE_WORD[out.tone] || TONE_WORD.info;
|
|
381
|
+
// When it never ran, the action's "what this means" describes something
|
|
382
|
+
// that did not happen — so it is left out rather than contradicting the
|
|
383
|
+
// headline right under it.
|
|
384
|
+
const means = out.tone === 'stopped' ? '' : (out.means || '');
|
|
385
|
+
box.innerHTML =
|
|
386
|
+
'<div class="res res-' + esc(out.tone || 'info') + '">'
|
|
387
|
+
+ '<div class="res-head">' + t.icon + ' <span>' + esc(t.word) + '</span></div>'
|
|
388
|
+
+ '<div class="res-what"><b style="color:var(--text)">' + esc(out.headline || '') + '</b></div>'
|
|
389
|
+
+ (means ? '<div class="res-what">' + esc(means) + '</div>' : '')
|
|
390
|
+
+ '<div id="goto"></div>'
|
|
391
|
+
+ '<details class="tech"><summary>Technical detail</summary>'
|
|
392
|
+
+ '<div class="log">' + esc(out.output || '') + '</div></details>'
|
|
393
|
+
+ '</div>';
|
|
394
|
+
} catch (e) {
|
|
395
|
+
box.innerHTML = '<div class="res res-block"><div class="res-head">⛔ <span>It could not run</span></div>'
|
|
396
|
+
+ '<div class="res-what">The console lost contact with the command. It is still running on your machine — check the terminal you started it from.</div></div>';
|
|
397
|
+
}
|
|
398
|
+
document.querySelectorAll('#actions button').forEach((x) => (x.disabled = false));
|
|
399
|
+
load();
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function cropHtml(page, c) {
|
|
405
|
+
const w = Math.round(c.width / c.scale), h = Math.round(c.height / c.scale);
|
|
406
|
+
return '<div class="crop" style="width:' + c.width + 'px;height:' + c.height + 'px">'
|
|
407
|
+
+ '<div class="crop-inner" style="width:' + w + 'px;height:' + h + 'px;transform:scale(' + c.scale + ');'
|
|
408
|
+
+ "background-image:url('/shot/" + encodeURIComponent(page) + ".jpg?t=" + T + "');"
|
|
409
|
+
+ 'background-position:-' + c.offsetX + 'px -' + c.offsetY + 'px">'
|
|
410
|
+
+ '<div class="crop-hi" style="left:' + c.hi.left + 'px;top:' + c.hi.top + 'px;width:' + c.hi.width + 'px;height:' + c.hi.height + 'px"></div>'
|
|
411
|
+
+ '</div></div>';
|
|
412
|
+
}
|
|
413
|
+
function figHtml(page, item) {
|
|
414
|
+
return '<figure>' + cropHtml(page, item.crop)
|
|
415
|
+
+ '<figcaption><b>' + esc(item.label) + '</b><code>' + esc(item.selector) + '</code>'
|
|
416
|
+
+ '<button class="copy" data-copy="' + esc(item.selector) + '">copy selector</button></figcaption></figure>';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/* The sentence the console owed and never said: what this person is supposed to
|
|
420
|
+
DO. Evidence without an instruction is a puzzle, not a tool — and the answer
|
|
421
|
+
differs per status, so it cannot be one generic line at the top. */
|
|
422
|
+
function todo(r) {
|
|
423
|
+
const where = r.sites && r.sites.length
|
|
424
|
+
? 'your test at <b>' + esc(r.sites[0].file) + ':' + esc(r.sites[0].line) + '</b>'
|
|
425
|
+
: 'the test that uses it';
|
|
426
|
+
if (r.rebound) {
|
|
427
|
+
return 'Your test still passes — on the wrong element. Open ' + where
|
|
428
|
+
+ ' and point it at the right one, or ask a developer for a <code>data-testid</code> on it.';
|
|
429
|
+
}
|
|
430
|
+
if (r.status === 'ambiguous') {
|
|
431
|
+
return 'Pick the right one from the pictures below, copy its selector, and paste it into ' + where
|
|
432
|
+
+ '. The durable fix is a <code>data-testid</code> on that element — then it can never be confused again.';
|
|
433
|
+
}
|
|
434
|
+
if (r.status === 'lost') {
|
|
435
|
+
return 'This element is not on the page any more. Open ' + where
|
|
436
|
+
+ ' and either point it somewhere else or delete the step — nothing can repair this automatically.';
|
|
437
|
+
}
|
|
438
|
+
return 'Open ' + where + ' and check it still does what you meant.';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function renderDecisions(s) {
|
|
442
|
+
const d = s.decisions || [];
|
|
443
|
+
if (d.length === 0) {
|
|
444
|
+
q('#decisions').innerHTML = '<div class="empty">Nothing is waiting on a human. Every drifted row either matched deterministically or did not drift.</div>';
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
q('#decisions').innerHTML = d.map((r, i) => {
|
|
448
|
+
const kind = r.rebound ? 'rebound' : r.status;
|
|
449
|
+
let h = '<div class="dec" id="dec-' + i + '"><div class="dec-head">'
|
|
450
|
+
+ '<span class="num">' + (i + 1) + ' of ' + d.length + '</span>'
|
|
451
|
+
+ '<span class="badge b-' + esc(kind) + '">' + esc(kind) + '</span>'
|
|
452
|
+
+ '<span class="dec-label">' + esc(r.label) + '</span>'
|
|
453
|
+
+ '<span class="dec-page">on page “' + esc(r.page) + '”</span></div>'
|
|
454
|
+
+ '<div class="todo"><b>What to do:</b> ' + todo(r) + '</div>'
|
|
455
|
+
+ '<div class="sel">' + esc(r.selector) + '</div>';
|
|
456
|
+
if (r.rebound) h += '<div class="note" style="color:#fda4af">⚠️ This selector still resolves — onto ' + esc(r.reboundTo) + '. Your suite is green today while acting on the wrong element.</div>';
|
|
457
|
+
if (r.sites && r.sites.length) {
|
|
458
|
+
h += '<div class="sites">📇 ' + r.sites.slice(0, 6).map((x) => '<code>' + esc(x.file) + ':' + esc(x.line) + '</code>').join('')
|
|
459
|
+
+ (r.sites.length > 6 ? '+' + (r.sites.length - 6) + ' more' : '') + '</div>';
|
|
460
|
+
} else {
|
|
461
|
+
h += '<div class="sites">Not referenced by any test in your inventory — nothing in your suite breaks today, but the app did change here.</div>';
|
|
462
|
+
}
|
|
463
|
+
if (r.nowAt) h += '<div class="evid-title">⚠️ What that selector points at today</div><div class="figs">' + figHtml(r.page, r.nowAt) + '</div>';
|
|
464
|
+
if (r.candidates && r.candidates.length) {
|
|
465
|
+
h += '<div class="evid-title">Which one did the test mean? ' + r.candidates.length + ' candidate' + (r.candidates.length === 1 ? '' : 's') + ' on the page now</div>'
|
|
466
|
+
+ '<div class="figs">' + r.candidates.map((c) => figHtml(r.page, c)).join('') + '</div>';
|
|
467
|
+
}
|
|
468
|
+
if (r.note) h += '<div class="note">' + esc(r.note) + '</div>';
|
|
469
|
+
if (!r.nowAt && !(r.candidates || []).length && !r.note) {
|
|
470
|
+
h += '<div class="note">No picture available — run <code>ia-qa-heal map --screenshots</code> to see this element in place.</div>';
|
|
471
|
+
}
|
|
472
|
+
return h + '</div>';
|
|
473
|
+
}).join('');
|
|
474
|
+
document.querySelectorAll('[data-copy]').forEach((b) => {
|
|
475
|
+
b.onclick = () => { navigator.clipboard.writeText(b.dataset.copy); b.textContent = 'copied ✓'; setTimeout(() => (b.textContent = 'copy selector'), 1400); };
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* ---- history ---------------------------------------------------------- */
|
|
480
|
+
|
|
481
|
+
function renderHistory(s) {
|
|
482
|
+
const h = s.history || {}; const sum = h.summary || {}; const series = (sum.trend && sum.trend.series) || [];
|
|
483
|
+
if (!series.length) {
|
|
484
|
+
q('#history').innerHTML = '<div class="empty">No comparable runs recorded yet. Every <code>diff</code> and <code>run</code> writes one line to <code>.ia-qa/history.jsonl</code> — a trend needs a few.</div>';
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const drift = (e) => (e.counts ? e.counts.healable + e.counts.ambiguous + e.counts.lost + e.counts.rebound : 0);
|
|
488
|
+
let html = '<div class="tiles">'
|
|
489
|
+
+ tile(sum.runs, 'runs recorded')
|
|
490
|
+
+ tile(Math.round((sum.driftRate || 0) * 100) + '%', 'runs with drift')
|
|
491
|
+
+ tile(Math.round((sum.coverage || 0) * 100) + '%', 'mean coverage')
|
|
492
|
+
+ tile(sum.healed, 'rewrites applied')
|
|
493
|
+
+ '</div>';
|
|
494
|
+
|
|
495
|
+
html += '<div class="evid-title" style="margin-top:18px">Drift per run — ' + esc(series.length) + ' comparable run' + (series.length === 1 ? '' : 's')
|
|
496
|
+
+ (sum.trend.source ? ' captured by <code>' + esc(sum.trend.source) + '</code>' : '') + '</div>';
|
|
497
|
+
html += barChart(series, drift);
|
|
498
|
+
html += '<div class="legend">'
|
|
499
|
+
+ Object.keys(VC).map((k) => '<span><i style="background:' + VC[k] + '"></i>' + VI[k] + ' ' + k + '</span>').join('')
|
|
500
|
+
+ '</div>';
|
|
501
|
+
if (sum.trend.reason) html += '<div class="caveat">⚠️ ' + esc(sum.trend.reason) + ' Plotting them on one axis would invent a trend out of a capture artifact.</div>';
|
|
502
|
+
|
|
503
|
+
if ((sum.hotspots || []).length) {
|
|
504
|
+
html += '<div class="evid-title" style="margin-top:20px">Where the churn lives — total drifted elements per page</div>' + hotspots(sum.hotspots);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
html += '<table><thead><tr><th>When</th><th>Verb</th><th>Verdict</th><th class="n">Drift</th><th class="n">Coverage</th><th>Commit</th></tr></thead><tbody>'
|
|
508
|
+
+ series.slice(-12).reverse().map((e) =>
|
|
509
|
+
'<tr><td>' + esc(new Date(e.ts).toLocaleString()) + '</td><td>' + esc(e.verb) + '</td>'
|
|
510
|
+
+ '<td style="color:' + (VC[e.verdict] || 'inherit') + '">' + (VI[e.verdict] || '') + ' ' + esc(e.verdict) + '</td>'
|
|
511
|
+
+ '<td class="n">' + drift(e) + '</td>'
|
|
512
|
+
+ '<td class="n">' + (e.coverage ? e.coverage.measured + '/' + e.coverage.total : '—') + '</td>'
|
|
513
|
+
+ '<td>' + esc(e.commit || '—') + '</td></tr>').join('')
|
|
514
|
+
+ '</tbody></table>';
|
|
515
|
+
q('#history').innerHTML = html;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/* Bars: one per run, height by drift count, fill by verdict. Single measure,
|
|
519
|
+
single axis. 2px gap between bars, 4px rounded top anchored to the baseline. */
|
|
520
|
+
function barChart(series, valueOf) {
|
|
521
|
+
const W = 1100, H = 170, PB = 24, PL = 34;
|
|
522
|
+
const n = series.length;
|
|
523
|
+
const max = Math.max(1, ...series.map(valueOf));
|
|
524
|
+
const bw = Math.max(3, Math.min(34, (W - PL) / n - 2));
|
|
525
|
+
const step = (W - PL) / n;
|
|
526
|
+
const y = (v) => H - PB - (v / max) * (H - PB - 12);
|
|
527
|
+
let bars = '', grid = '';
|
|
528
|
+
[0, 0.5, 1].forEach((f) => {
|
|
529
|
+
const v = Math.round(max * f), yy = y(v);
|
|
530
|
+
grid += '<line x1="' + PL + '" x2="' + W + '" y1="' + yy + '" y2="' + yy + '" stroke="#27272a" stroke-width="1"/>'
|
|
531
|
+
+ '<text class="axis" x="' + (PL - 7) + '" y="' + (yy + 3) + '" text-anchor="end">' + v + '</text>';
|
|
532
|
+
});
|
|
533
|
+
series.forEach((e, i) => {
|
|
534
|
+
const v = valueOf(e), yy = y(v), x = PL + i * step + 1;
|
|
535
|
+
const hgt = Math.max(v > 0 ? 3 : 2, H - PB - yy);
|
|
536
|
+
bars += '<rect x="' + x + '" y="' + (H - PB - hgt) + '" width="' + bw + '" height="' + hgt + '" rx="3" fill="'
|
|
537
|
+
+ (VC[e.verdict] || '#3f3f46') + '"><title>' + new Date(e.ts).toLocaleString() + ' — ' + e.verdict + ' · '
|
|
538
|
+
+ v + ' drifted' + (e.commit ? ' · ' + e.commit : '') + '</title></rect>';
|
|
539
|
+
});
|
|
540
|
+
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="100%" height="' + H + '" role="img" aria-label="Drifted elements per run">'
|
|
541
|
+
+ grid + bars
|
|
542
|
+
+ '<line x1="' + PL + '" x2="' + W + '" y1="' + (H - PB) + '" y2="' + (H - PB) + '" stroke="#3f3f46" stroke-width="1"/>'
|
|
543
|
+
+ '<text class="axis" x="' + PL + '" y="' + (H - 8) + '">oldest</text>'
|
|
544
|
+
+ '<text class="axis" x="' + W + '" y="' + (H - 8) + '" text-anchor="end">latest</text></svg>';
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/* One series, so no legend — the title names it. Direct-labelled, since there
|
|
548
|
+
are at most five rows and a value axis would be one more thing to read. */
|
|
549
|
+
function hotspots(rows) {
|
|
550
|
+
const max = Math.max(1, ...rows.map((r) => r.drift));
|
|
551
|
+
return '<div style="margin-top:6px">' + rows.map((r) =>
|
|
552
|
+
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">'
|
|
553
|
+
+ '<div style="width:170px;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(r.name) + '</div>'
|
|
554
|
+
+ '<div style="flex:1;background:#27272a;border-radius:4px;height:14px;overflow:hidden">'
|
|
555
|
+
+ '<div style="width:' + Math.round((r.drift / max) * 100) + '%;height:100%;background:var(--orange);border-radius:4px"></div></div>'
|
|
556
|
+
+ '<div style="width:82px;text-align:right;font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums">'
|
|
557
|
+
+ esc(r.drift) + ' in ' + esc(r.runs) + ' run' + (r.runs === 1 ? '' : 's') + '</div></div>'
|
|
558
|
+
).join('') + '</div>';
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/* The console stops when you stop looking at it. Closing the tab cannot end a
|
|
562
|
+
process by itself, so the page says it is still here — and says goodbye on the
|
|
563
|
+
way out. A reload cancels the goodbye simply by arriving first. */
|
|
564
|
+
const PING_MS = ${server_1.HEARTBEAT_INTERVAL_MS};
|
|
565
|
+
setInterval(() => { fetch('/api/ping?t=' + T).catch(() => {}); }, PING_MS);
|
|
566
|
+
let saidBye = false;
|
|
567
|
+
function bye() {
|
|
568
|
+
if (saidBye) return;
|
|
569
|
+
saidBye = true;
|
|
570
|
+
// sendBeacon survives the page being torn down; a plain fetch does not.
|
|
571
|
+
// keepalive is the fallback where sendBeacon is unavailable. Neither is
|
|
572
|
+
// guaranteed to arrive — the server's idle timeout is what actually bounds
|
|
573
|
+
// this, and the beacon only makes the common case instant.
|
|
574
|
+
if (navigator.sendBeacon) navigator.sendBeacon('/api/bye?t=' + T);
|
|
575
|
+
else fetch('/api/bye?t=' + T, { keepalive: true }).catch(() => {});
|
|
576
|
+
}
|
|
577
|
+
addEventListener('pagehide', bye);
|
|
578
|
+
addEventListener('beforeunload', bye);
|
|
579
|
+
|
|
580
|
+
load();
|
|
581
|
+
</script>
|
|
582
|
+
</body></html>`;
|
|
583
|
+
}
|
|
584
|
+
//# sourceMappingURL=page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page.js","sourceRoot":"","sources":["../../src/ui/page.ts"],"names":[],"mappings":";;AAmBA,8CAijBC;AApkBD;;;;;;;;;;;;;;;;GAgBG;AACH,qCAAiD;AAEjD,SAAgB,iBAAiB,CAAC,KAAa;IAC7C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAoKG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAyXf,8BAAqB;;;;;;;;;;;;;;;;;;eAkBxB,CAAC;AAChB,CAAC"}
|