@ia-qa/self-healing 1.6.7 โ†’ 1.6.10

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.
Files changed (88) hide show
  1. package/README.md +111 -0
  2. package/TUTORIAL.md +128 -2
  3. package/assets/logoKawaiiGreenTiny.png +0 -0
  4. package/dist/aom.d.ts +8 -0
  5. package/dist/aom.js.map +1 -1
  6. package/dist/cli/args.js +4 -2
  7. package/dist/cli/args.js.map +1 -1
  8. package/dist/cli/audit.js +2 -8
  9. package/dist/cli/audit.js.map +1 -1
  10. package/dist/cli/diff.d.ts +25 -1
  11. package/dist/cli/diff.js +176 -20
  12. package/dist/cli/diff.js.map +1 -1
  13. package/dist/cli/emit.d.ts +71 -0
  14. package/dist/cli/emit.js +150 -0
  15. package/dist/cli/emit.js.map +1 -0
  16. package/dist/cli/fix.js +41 -3
  17. package/dist/cli/fix.js.map +1 -1
  18. package/dist/cli/history.d.ts +12 -0
  19. package/dist/cli/history.js +95 -0
  20. package/dist/cli/history.js.map +1 -0
  21. package/dist/cli/index.js +68 -3
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/cli/ingest.js +5 -8
  24. package/dist/cli/ingest.js.map +1 -1
  25. package/dist/cli/init.js +32 -0
  26. package/dist/cli/init.js.map +1 -1
  27. package/dist/cli/map.d.ts +14 -1
  28. package/dist/cli/map.js +144 -33
  29. package/dist/cli/map.js.map +1 -1
  30. package/dist/cli/run.js +91 -8
  31. package/dist/cli/run.js.map +1 -1
  32. package/dist/cli/ui.d.ts +16 -0
  33. package/dist/cli/ui.js +294 -0
  34. package/dist/cli/ui.js.map +1 -0
  35. package/dist/cli-ai/index.js +3 -3
  36. package/dist/cli-ai/index.js.map +1 -1
  37. package/dist/config.d.ts +50 -0
  38. package/dist/config.js +63 -1
  39. package/dist/config.js.map +1 -1
  40. package/dist/evidence.d.ts +130 -0
  41. package/dist/evidence.js +232 -0
  42. package/dist/evidence.js.map +1 -0
  43. package/dist/fixEngine.d.ts +2 -0
  44. package/dist/fixEngine.js +7 -0
  45. package/dist/fixEngine.js.map +1 -1
  46. package/dist/history.d.ts +145 -0
  47. package/dist/history.js +230 -0
  48. package/dist/history.js.map +1 -0
  49. package/dist/htmlReport.d.ts +47 -0
  50. package/dist/htmlReport.js +103 -1
  51. package/dist/htmlReport.js.map +1 -1
  52. package/dist/ingest.d.ts +18 -0
  53. package/dist/ingest.js +0 -0
  54. package/dist/ingest.js.map +1 -1
  55. package/dist/junit.d.ts +53 -0
  56. package/dist/junit.js +150 -0
  57. package/dist/junit.js.map +1 -0
  58. package/dist/mcp/server.js +46 -5
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/nameMask.d.ts +110 -0
  61. package/dist/nameMask.js +0 -0
  62. package/dist/nameMask.js.map +1 -0
  63. package/dist/ui/icon.d.ts +41 -0
  64. package/dist/ui/icon.js +115 -0
  65. package/dist/ui/icon.js.map +1 -0
  66. package/dist/ui/lock.d.ts +40 -0
  67. package/dist/ui/lock.js +138 -0
  68. package/dist/ui/lock.js.map +1 -0
  69. package/dist/ui/page.d.ts +1 -0
  70. package/dist/ui/page.js +657 -0
  71. package/dist/ui/page.js.map +1 -0
  72. package/dist/ui/registry.d.ts +62 -0
  73. package/dist/ui/registry.js +139 -0
  74. package/dist/ui/registry.js.map +1 -0
  75. package/dist/ui/server.d.ts +76 -0
  76. package/dist/ui/server.js +607 -0
  77. package/dist/ui/server.js.map +1 -0
  78. package/dist/ui/shortcut.d.ts +90 -0
  79. package/dist/ui/shortcut.js +176 -0
  80. package/dist/ui/shortcut.js.map +1 -0
  81. package/dist/update.d.ts +104 -0
  82. package/dist/update.js +297 -0
  83. package/dist/update.js.map +1 -0
  84. package/dist/volatile.d.ts +2 -0
  85. package/dist/volatile.js +2 -0
  86. package/dist/volatile.js.map +1 -1
  87. package/package.json +3 -2
  88. package/skills/ia-qa-heal/SKILL.md +37 -0
@@ -0,0 +1,657 @@
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
+ const update_1 = require("../update");
23
+ function renderConsoleHtml(token) {
24
+ // Which bytes are actually running โ€” stamped into the page, not fetched.
25
+ //
26
+ // It used to appear in one place only: the update banner, which renders *only*
27
+ // when a newer version exists. So the console stated its version exactly when
28
+ // it was out of date, and was anonymous the rest of the time โ€” including on
29
+ // every screenshot anyone sends when something looks wrong.
30
+ //
31
+ // This audience cannot find it any other way. `ia-qa-heal --version` needs the
32
+ // terminal the desktop shortcut exists to avoid, and that shortcut pins an
33
+ // absolute path to one `dist/`, so "the version I installed" and "the version
34
+ // this icon launches" drift apart with nothing on screen to say so.
35
+ //
36
+ // Interpolated server-side rather than carried in `/api/state`: it is a property
37
+ // of the running process, not of the selected project, so it must not change when
38
+ // the picker does โ€” and it has to be right before the first fetch resolves.
39
+ const version = (0, update_1.currentVersion)();
40
+ return `<!DOCTYPE html>
41
+ <html lang="en"><head>
42
+ <meta charset="UTF-8">
43
+ <meta name="viewport" content="width=device-width,initial-scale=1">
44
+ <title>ia-qa-heal โ€” console</title>
45
+ <style>
46
+ :root{
47
+ --bg:#09090b;--surface:#18181b;--surface2:#111113;--border:#27272a;
48
+ --text:#fafafa;--muted:#a1a1aa;--orange:#f97316;--orange2:#fb923c;
49
+ --pass:#0d9488;--fix:#d97706;--block:#e11d48;--info:#8b5cf6;
50
+ }
51
+ *{box-sizing:border-box}
52
+ body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
53
+ a{color:var(--orange2)}
54
+ header{border-bottom:1px solid var(--border);background:linear-gradient(180deg,#1c1005,var(--surface2));padding:18px 0}
55
+ .wrap{max-width:1180px;margin:0 auto;padding:0 22px}
56
+ .head{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
57
+ .head-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
58
+ .picker{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)}
59
+ .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}
60
+ .picker select:hover{border-color:var(--orange)}
61
+ .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}
62
+ .brand:hover{background:#f9731626}
63
+ footer{border-top:1px solid var(--border);background:var(--surface2);padding:20px 0;margin-top:10px}
64
+ .foot{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
65
+ .foot a{font-weight:600;text-decoration:none}
66
+ .foot a:hover{text-decoration:underline}
67
+ h1{margin:0;font-size:17px;letter-spacing:-.01em}
68
+ /* Deliberately quiet and never in the verdict palette: the version is identity,
69
+ not status, and a coloured chip up here would compete with the one thing on
70
+ this page that is allowed to shout. Selectable, because the reason to read it
71
+ is usually to paste it into a bug report. */
72
+ .ver{margin-left:9px;font-size:11.5px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--muted);border:1px solid var(--border);border-radius:99px;padding:2px 8px;vertical-align:middle;white-space:nowrap}
73
+ h2{margin:0 0 12px;font-size:14px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
74
+ .sub{color:var(--muted);font-size:12.5px;margin-top:3px}
75
+ main{padding:22px 0 60px}
76
+ section{margin-bottom:26px}
77
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px}
78
+ .grid{display:grid;gap:16px}
79
+ /* Without this the shorter card is stretched to the taller one and shows a
80
+ block of dead space under its last tile. */
81
+ .cols{grid-template-columns:minmax(0,2.2fr) minmax(260px,1fr);align-items:start}
82
+ @media(max-width:900px){.cols{grid-template-columns:1fr}}
83
+ .verdict{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
84
+ .big{font-size:26px;font-weight:800;letter-spacing:-.02em}
85
+ .v-PASS{color:var(--pass)}.v-FIX{color:var(--fix)}.v-BLOCK{color:var(--block)}
86
+ .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-top:16px}
87
+ .tile{background:var(--surface2);border:1px solid var(--border);border-radius:11px;padding:13px 15px}
88
+ .tile .n{font-size:22px;font-weight:700;letter-spacing:-.02em}
89
+ .tile .l{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:2px}
90
+ .legend{display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--muted);margin-top:10px}
91
+ .legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;vertical-align:middle}
92
+ .caveat{margin-top:10px;font-size:12px;color:#fcd34d;background:#f59e0b12;border:1px solid #f59e0b33;border-radius:8px;padding:8px 11px}
93
+ .btns{display:flex;flex-direction:column;gap:8px}
94
+ 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}
95
+ button:hover:not(:disabled){border-color:var(--orange);color:var(--orange2)}
96
+ button:disabled{opacity:.5;cursor:progress}
97
+ button.w{border-color:#e11d4840}
98
+ button.w:hover:not(:disabled){border-color:var(--block);color:#fda4af}
99
+ .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}
100
+ .res{margin-top:12px;border-radius:10px;padding:12px 14px;border:1px solid}
101
+ .res-ok{background:#0d948814;border-color:#0d948855}
102
+ .res-fix{background:#d9770614;border-color:#d9770655}
103
+ .res-block{background:#e11d4814;border-color:#e11d4855}
104
+ .res-info,.res-warn{background:#8b5cf614;border-color:#8b5cf655}
105
+ .res-stopped{background:#a1a1aa14;border-color:#a1a1aa55}
106
+ .res-head{font-weight:700;font-size:13.5px;display:flex;gap:8px;align-items:baseline}
107
+ .res-what{margin-top:7px;font-size:12.5px;color:var(--muted);line-height:1.5}
108
+ .res-run{margin-top:9px;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px}
109
+ .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}
110
+ @keyframes sp{to{transform:rotate(360deg)}}
111
+ details.tech{margin-top:10px}
112
+ details.tech summary{cursor:pointer;font-size:11.5px;color:var(--muted);list-style:none}
113
+ details.tech summary::-webkit-details-marker{display:none}
114
+ details.tech summary::before{content:"โ–ธ ";color:var(--orange2)}
115
+ details.tech[open] summary::before{content:"โ–พ "}
116
+ .dec{border:1px solid var(--border);border-radius:12px;padding:15px;margin-bottom:13px;background:var(--surface2)}
117
+ .dec-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:5px}
118
+ .badge{font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:2px 9px;border-radius:99px;border:1px solid}
119
+ .b-ambiguous{color:var(--fix);border-color:#d9770655;background:#d9770614}
120
+ .b-lost{color:var(--block);border-color:#e11d4855;background:#e11d4814}
121
+ .b-rebound{color:var(--block);border-color:#e11d4855;background:#e11d4814}
122
+ .dec-label{font-weight:600}
123
+ .dec-page{font-size:11.5px;color:var(--muted);margin-left:auto}
124
+ .num{font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums;border:1px solid var(--border);border-radius:99px;padding:1px 8px}
125
+ .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}
126
+ .todo b{color:var(--orange2)}
127
+ .todo code{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:0 5px;font-size:11.5px;color:var(--orange2)}
128
+ .dec.flash{animation:fl 2.2s ease-out}
129
+ @keyframes fl{0%,55%{box-shadow:0 0 0 3px var(--orange)}100%{box-shadow:0 0 0 0 transparent}}
130
+ .lede{margin-top:12px;font-size:13px;color:var(--text);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
131
+ .lede-ok{color:var(--muted)}
132
+ .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}
133
+ .cta:hover{background:#f9731630}
134
+ #goto .cta{margin-top:10px}
135
+ .tile.clickable{cursor:pointer}
136
+ .tile.clickable:hover{border-color:var(--orange)}
137
+ .proj{display:grid;gap:7px}
138
+ .prow{display:flex;gap:14px;font-size:12.5px;align-items:baseline;flex-wrap:wrap}
139
+ .pk{width:120px;color:var(--muted);flex-shrink:0}
140
+ .pv{flex:1;min-width:0;word-break:break-all}
141
+ .pv code{background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:1px 6px;font-size:11.5px;color:var(--orange2)}
142
+ .sel{font:11.5px ui-monospace,Menlo,monospace;color:var(--orange2);word-break:break-all}
143
+ .sites{margin-top:7px;font-size:11.5px;color:var(--muted)}
144
+ .sites code{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:0 5px;color:#c4b5fd;margin-right:5px}
145
+ .evid-title{font-size:11.5px;color:var(--orange2);font-weight:700;margin:12px 0 8px}
146
+ .figs{display:flex;gap:14px;flex-wrap:wrap}
147
+ figure{margin:0}
148
+ .crop{position:relative;overflow:hidden;border:1px solid var(--border);border-radius:8px;background:var(--bg)}
149
+ .crop-inner{position:absolute;top:0;left:0;transform-origin:0 0;background-repeat:no-repeat}
150
+ .crop-hi{position:absolute;outline:2px solid var(--orange);box-shadow:0 0 0 9999px rgba(0,0,0,.58);border-radius:2px}
151
+ figcaption{margin-top:6px;font-size:11px;color:var(--muted);max-width:360px}
152
+ figcaption b{color:var(--text)}
153
+ figcaption code{display:block;margin-top:2px;font-size:10.5px;color:var(--orange2);word-break:break-all}
154
+ .copy{margin-top:6px;font-size:11px;padding:3px 9px}
155
+ /* The update banner. Informational, so it borrows the info colour rather than
156
+ the verdict palette โ€” a new version is not a FIX and must never read as one. */
157
+ .upd{border:1px solid #8b5cf655;background:#8b5cf610;border-radius:12px;padding:14px 16px;margin-bottom:22px}
158
+ .upd-h{font-weight:700;font-size:13.5px}
159
+ .upd-how{color:var(--muted);font-size:12.5px;margin-top:5px}
160
+ .upd-cmd{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px}
161
+ .upd-cmd code{background:#000;border:1px solid var(--border);border-radius:8px;padding:7px 11px;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e4e4e7}
162
+ .upd-cmd button{margin:0;font-size:11.5px;padding:6px 11px}
163
+ .upd-foot{color:var(--muted);font-size:11.5px;margin-top:10px}
164
+ .empty{color:var(--muted);font-size:13px}
165
+ table{width:100%;border-collapse:collapse;font-size:12px;margin-top:12px}
166
+ th,td{text-align:left;padding:6px 9px;border-bottom:1px solid var(--border)}
167
+ th{color:var(--muted);font-weight:600;font-size:10.5px;text-transform:uppercase;letter-spacing:.05em}
168
+ td.n{text-align:right;font-variant-numeric:tabular-nums}
169
+ .axis{fill:#71717a;font-size:10px}
170
+ .note{color:var(--muted);font-size:12px;margin-top:8px}
171
+ </style>
172
+ </head><body>
173
+ <header><div class="wrap head">
174
+ <div>
175
+ <h1>๐Ÿฉน ia-qa-heal <span style="color:var(--muted);font-weight:400">console</span><span class="ver" title="${update_1.PACKAGE_NAME} ${version}">v${version}</span></h1>
176
+ <div class="sub" id="sub">loadingโ€ฆ</div>
177
+ </div>
178
+ <div class="head-right">
179
+ <label class="picker" id="pickerWrap" style="display:none">
180
+ <span>Project</span>
181
+ <select id="picker"></select>
182
+ </label>
183
+ <a class="brand" href="https://ia-qa.com" target="_blank" rel="noopener">ia-qa.com โ†—</a>
184
+ </div>
185
+ </div></header>
186
+ <main><div class="wrap">
187
+ <div id="update"></div>
188
+ <section class="grid cols">
189
+ <div class="card" id="verdictCard"><div class="empty">loadingโ€ฆ</div></div>
190
+ <div class="card">
191
+ <h2>Actions</h2>
192
+ <div class="btns" id="actions"></div>
193
+ <div class="note">Buttons marked โœŽ change files on your computer. The others only look. Nothing is ever committed, and none of it is uploaded anywhere.</div>
194
+ <div id="result"></div>
195
+ </div>
196
+ </section>
197
+
198
+ <section class="card">
199
+ <h2>Decisions waiting for you</h2>
200
+ <div id="decisions"><div class="empty">loadingโ€ฆ</div></div>
201
+ </section>
202
+
203
+ <section class="card">
204
+ <h2>Which project is this?</h2>
205
+ <div id="project"><div class="empty">loadingโ€ฆ</div></div>
206
+ </section>
207
+
208
+ <section class="card">
209
+ <h2>History</h2>
210
+ <div id="history"><div class="empty">loadingโ€ฆ</div></div>
211
+ </section>
212
+ </div></main>
213
+ <footer><div class="wrap foot">
214
+ <span>Local-first self-healing for UI tests โ€” your app, your tests and your results never leave your machine.</span>
215
+ <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>
216
+ </div></footer>
217
+
218
+ <script>
219
+ const T = ${JSON.stringify(token)};
220
+ const q = (s) => document.querySelector(s);
221
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
222
+ const VC = { PASS:'var(--pass)', FIX:'var(--fix)', BLOCK:'var(--block)' };
223
+ const VI = { PASS:'โœ…', FIX:'๐Ÿ”ง', BLOCK:'โ›”' };
224
+
225
+ async function load() {
226
+ const r = await fetch('/api/state?t=' + T);
227
+ render(await r.json());
228
+ }
229
+
230
+ function render(s) {
231
+ renderPicker(s); renderUpdate(s); renderHead(s); renderVerdict(s); renderActions(s);
232
+ renderDecisions(s); renderHistory(s); renderGoto(s); bindCopy();
233
+ }
234
+
235
+ /* "A newer version exists" โ€” the one thing this audience cannot find out on its
236
+ own, because the desktop icon launches a fixed path and its whole premise is
237
+ that nobody here owns a terminal.
238
+
239
+ It stops at the command. The console's authority is exactly the CLI's โ€” every
240
+ button posts an id from a server-side allowlist โ€” and "npm install" is not a
241
+ CLI verb: it is a different binary that runs whatever install scripts the
242
+ registry hands it. Wiring that to a button would widen a leaked token from
243
+ "edits my test files" to "runs arbitrary code as me", which is the one trade
244
+ this package must not make. So: the exact command, pre-selected, one click to
245
+ copy. */
246
+ function renderUpdate(s) {
247
+ const u = s.update;
248
+ const el = q('#update');
249
+ if (!u) { el.innerHTML = ''; return; }
250
+ const when = new Date(u.checkedAt);
251
+ const stamp = isNaN(when) ? 'recently' : when.toLocaleDateString();
252
+ el.innerHTML =
253
+ '<div class="upd">'
254
+ + '<div class="upd-h">โฌ†๏ธ Version ' + esc(u.latest) + ' is out โ€” this console is running ' + esc(u.current) + '.</div>'
255
+ + '<div class="upd-how">' + esc(u.how) + '</div>'
256
+ + (u.command
257
+ ? '<div class="upd-cmd"><code>' + esc(u.command) + '</code>'
258
+ + '<button class="copy" data-copy="' + esc(u.command) + '" data-copy-label="copy command">copy command</button></div>'
259
+ : '')
260
+ + '<div class="upd-foot">Checked with ' + esc(u.registry || 'the npm registry') + ' on ' + esc(stamp)
261
+ + ' โ€” a version number, nothing about your app or your tests. It is the only thing this console looks up online, at most once a day;'
262
+ + ' start it with <code>ia-qa-heal ui --offline</code> to skip it. Nothing is installed for you.</div>'
263
+ + '</div>';
264
+ }
265
+
266
+ /* The project switcher. It only ever offers folders the server already knows โ€”
267
+ the value posted back is one of these strings, and the server resolves it
268
+ against the same list. Nothing here can name a directory the user never
269
+ opened a console in. */
270
+ function renderPicker(s) {
271
+ const list = s.projects || [];
272
+ const wrap = q('#pickerWrap');
273
+ // Always shown, even with one project. Hiding it until a second appears made
274
+ // the switcher undiscoverable at exactly the moment someone needed to find
275
+ // it โ€” and since the only way to get that second project used to be a
276
+ // terminal, the feature could never start existing.
277
+ wrap.style.display = '';
278
+ const sel = q('#picker');
279
+ sel.innerHTML =
280
+ list.map((p) =>
281
+ '<option value="' + esc(p.dir) + '"' + (p.dir === s.activeDir ? ' selected' : '') + '>'
282
+ + esc(shortDir(p.dir)) + (p.baseUrl ? ' โ€” ' + esc(p.baseUrl) : '') + '</option>'
283
+ ).join('')
284
+ + '<option value="__add__">๏ผ‹ Add another projectโ€ฆ</option>';
285
+
286
+ sel.onchange = async () => {
287
+ const wantsAdd = sel.value === '__add__';
288
+ const dir = wantsAdd ? askForFolder() : sel.value;
289
+ if (!dir) { sel.value = s.activeDir; return; } // cancelled โ€” put the menu back
290
+ sel.disabled = true;
291
+ try {
292
+ const r = await fetch('/api/project' + (wantsAdd ? '/add' : '') + '?t=' + T, {
293
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
294
+ body: JSON.stringify({ dir }),
295
+ });
296
+ const next = await r.json();
297
+ if (next.error) { alert(next.error); sel.disabled = false; render(s); return; }
298
+ q('#result').innerHTML = ''; // a result from the previous project would be a lie here
299
+ render(next);
300
+ scrollTo({ top: 0, behavior: 'smooth' });
301
+ } catch (e) { alert('Could not switch project: ' + e); render(s); }
302
+ sel.disabled = false;
303
+ };
304
+ }
305
+
306
+ /* A browser cannot open a folder picker without a file input, and a file input
307
+ gives us a file, not the directory the user meant. Asking for the path is the
308
+ honest version โ€” and it is what someone copies out of their editor's title
309
+ bar or their terminal anyway. */
310
+ function askForFolder() {
311
+ const v = prompt(
312
+ 'Add a project to this console.\\n\\n'
313
+ + 'Paste the full path of the project folder โ€” the one you would open in your '
314
+ + 'editor. It must already contain a .ia-qa folder (run "ia-qa-heal init" there '
315
+ + 'if it does not).',
316
+ ''
317
+ );
318
+ return v && v.trim() ? v.trim() : null;
319
+ }
320
+
321
+ /* Absolute paths are long and all alike at the front. The tail is what tells
322
+ two projects apart. */
323
+ function shortDir(dir) {
324
+ const parts = String(dir).split(/[\\\\/]/).filter(Boolean);
325
+ return parts.length <= 2 ? dir : 'โ€ฆ' + '/' + parts.slice(-2).join('/');
326
+ }
327
+
328
+ /* "Needs you to decide" used to be the end of the sentence: the thing to decide
329
+ was several hundred pixels below, with nothing connecting the two. This is the
330
+ link โ€” placed inside the result card, where the person is actually looking. */
331
+ function renderGoto(s) {
332
+ const box = q('#goto');
333
+ if (!box) return;
334
+ const n = (s.decisions || []).length;
335
+ if (n === 0) { box.innerHTML = ''; return; }
336
+ box.innerHTML = '<button class="cta">๐Ÿ‘‡ Show me the ' + n + ' thing' + (n === 1 ? '' : 's') + ' waiting for me</button>';
337
+ box.querySelector('button').onclick = () => jumpToDecisions();
338
+ }
339
+
340
+ function jumpToDecisions() {
341
+ const first = document.querySelector('.dec');
342
+ const target = first || q('#decisions');
343
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
344
+ // A scroll alone leaves you wondering which of them you were sent to.
345
+ if (first) { first.classList.add('flash'); setTimeout(() => first.classList.remove('flash'), 2200); }
346
+ }
347
+
348
+ /* Which app, and which tests. A console opened from a desktop icon has to say
349
+ this out loud: "localhost:3000" is every project this person has, and the
350
+ folder it is bound to is the only thing that distinguishes them. */
351
+ function renderHead(s) {
352
+ const p = s.project || {};
353
+ q('#sub').innerHTML = p.error ? esc(p.error)
354
+ : '๐ŸŒ ' + esc(p.baseUrl || '(no baseUrl)') + ' ยท ' + esc(p.pages || 0) + ' pages';
355
+ const fw = (p.frameworks || []).filter((f) => f !== 'pom');
356
+ q('#project').innerHTML =
357
+ '<div class="proj">'
358
+ + row2('App', esc(p.baseUrl || 'โ€”'))
359
+ + row2('Project folder', '<code>' + esc(p.dir || 'โ€”') + '</code>')
360
+ + row2('Tests', (p.testPaths && p.testPaths.length)
361
+ ? '<code>' + p.testPaths.map(esc).join('</code> <code>') + '</code>'
362
+ + (p.scannedFiles ? ' ยท ' + esc(p.scannedFiles) + ' files read' : '')
363
+ + (fw.length ? ' ยท ' + fw.map(esc).join(', ') : '')
364
+ : '<span style="color:var(--muted)">none declared โ€” add "testPaths" to config.json, then โ€œRead my test files againโ€</span>')
365
+ + row2('Run with', p.testCommand ? '<code>' + esc(p.testCommand) + '</code>' : '<span style="color:var(--muted)">not set</span>')
366
+ + '</div>'
367
+ + '<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>';
368
+ }
369
+ function row2(k, v) {
370
+ return '<div class="prow"><span class="pk">' + esc(k) + '</span><span class="pv">' + v + '</span></div>';
371
+ }
372
+
373
+ function renderVerdict(s) {
374
+ const v = s.verdict;
375
+ if (!v) {
376
+ q('#verdictCard').innerHTML = '<div class="empty">Nothing to compare yet. Capture the app, promote a baseline, then change something and re-capture.</div>';
377
+ return;
378
+ }
379
+ const t = v.totals || {};
380
+ const cov = s.coverage || { measured: 0, stale: 0 };
381
+ const total = cov.measured + cov.stale;
382
+ const waiting = (s.decisions || []).length;
383
+ q('#verdictCard').innerHTML =
384
+ '<div class="verdict"><span class="big v-' + esc(v.verdict) + '">' + (VI[v.verdict]||'') + ' ' + esc(v.verdict) + '</span>'
385
+ + '<span style="color:var(--muted);font-size:12.5px">' + esc(cov.measured) + ' of ' + esc(total) + ' mapped pages measured'
386
+ + (cov.stale ? ' ยท ' + esc(cov.stale) + ' not covered by this verdict' : '') + '</span></div>'
387
+ + (waiting > 0
388
+ ? '<div class="lede">' + waiting + ' element' + (waiting === 1 ? '' : 's') + ' need' + (waiting === 1 ? 's' : '')
389
+ + ' you to decide โ€” the tool refused to guess. <button class="cta" id="lede-go">Show me ๐Ÿ‘‡</button></div>'
390
+ : '<div class="lede lede-ok">Nothing is waiting on you.</div>')
391
+ + '<div class="tiles">'
392
+ + tile(t.ok, 'ok') + tile(t.healable, 'healable', 'var(--fix)') + tile(t.ambiguous, 'ambiguous', 'var(--fix)', true)
393
+ + tile(t.lost, 'lost', 'var(--block)', true) + tile(t.rebound, 'rebound', 'var(--block)', true)
394
+ + tile((s.nameDrift||[]).length, 'name drift', 'var(--info)')
395
+ + '</div>';
396
+ const go = q('#lede-go');
397
+ if (go) go.onclick = jumpToDecisions;
398
+ // A tile counting something a human must look at is itself the way there.
399
+ document.querySelectorAll('#verdictCard .tile.clickable').forEach((el) => (el.onclick = jumpToDecisions));
400
+ }
401
+ function tile(n, l, c, jumps) {
402
+ const on = jumps && (n ?? 0) > 0;
403
+ return '<div class="tile' + (on ? ' clickable' : '') + '"'
404
+ + (on ? ' title="Jump to the ' + esc(l) + ' element' + (n === 1 ? '' : 's') + ' waiting for you"' : '')
405
+ + '><div class="n"' + (c && n > 0 ? ' style="color:' + c + '"' : '') + '>' + esc(n ?? 0) + '</div>'
406
+ + '<div class="l">' + esc(l) + (on ? ' โ†“' : '') + '</div></div>';
407
+ }
408
+
409
+ /* A result is shown in plain words first. The raw command output stays one
410
+ click away โ€” a manual QA should never have to read "exit 0" to find out
411
+ whether something worked, and a developer should never lose the detail. */
412
+ const TONE_WORD = {
413
+ ok: { icon: 'โœ…', word: 'All good' },
414
+ fix: { icon: '๐Ÿ”ง', word: 'Repairs are ready' },
415
+ block: { icon: 'โ›”', word: 'Needs you to decide' },
416
+ warn: { icon: 'โš ๏ธ', word: 'Worth a look' },
417
+ info: { icon: 'โ„น๏ธ', word: 'Done' },
418
+ // Not a verdict about the app โ€” the command never got far enough to have one.
419
+ stopped: { icon: '๐Ÿšง', word: "It couldn't run yet" },
420
+ };
421
+
422
+ function renderActions(s) {
423
+ q('#actions').innerHTML = (s.actions || []).map((a) =>
424
+ '<button data-a="' + esc(a.id) + '" title="' + esc(a.means) + '"' + (a.writes ? ' class="w"' : '') + '>'
425
+ + esc(a.label) + (a.writes ? ' โœŽ' : '') + '</button>'
426
+ ).join('');
427
+ document.querySelectorAll('#actions button').forEach((b) => {
428
+ b.onclick = async () => {
429
+ const id = b.dataset.a;
430
+ const label = b.textContent.replace(' โœŽ', '').trim();
431
+ const writes = b.classList.contains('w');
432
+ 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;
433
+
434
+ document.querySelectorAll('#actions button').forEach((x) => (x.disabled = true));
435
+ const box = q('#result');
436
+ 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>';
437
+ try {
438
+ const r = await fetch('/api/run?t=' + T, {
439
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
440
+ body: JSON.stringify({ action: id }),
441
+ });
442
+ const out = await r.json();
443
+ const t = TONE_WORD[out.tone] || TONE_WORD.info;
444
+ // When it never ran, the action's "what this means" describes something
445
+ // that did not happen โ€” so it is left out rather than contradicting the
446
+ // headline right under it.
447
+ const means = out.tone === 'stopped' ? '' : (out.means || '');
448
+ box.innerHTML =
449
+ '<div class="res res-' + esc(out.tone || 'info') + '">'
450
+ + '<div class="res-head">' + t.icon + ' <span>' + esc(t.word) + '</span></div>'
451
+ + '<div class="res-what"><b style="color:var(--text)">' + esc(out.headline || '') + '</b></div>'
452
+ + (means ? '<div class="res-what">' + esc(means) + '</div>' : '')
453
+ + '<div id="goto"></div>'
454
+ + '<details class="tech"><summary>Technical detail</summary>'
455
+ + '<div class="log">' + esc(out.output || '') + '</div></details>'
456
+ + '</div>';
457
+ } catch (e) {
458
+ box.innerHTML = '<div class="res res-block"><div class="res-head">โ›” <span>It could not run</span></div>'
459
+ + '<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>';
460
+ }
461
+ document.querySelectorAll('#actions button').forEach((x) => (x.disabled = false));
462
+ load();
463
+ };
464
+ });
465
+ }
466
+
467
+ function cropHtml(page, c) {
468
+ const w = Math.round(c.width / c.scale), h = Math.round(c.height / c.scale);
469
+ return '<div class="crop" style="width:' + c.width + 'px;height:' + c.height + 'px">'
470
+ + '<div class="crop-inner" style="width:' + w + 'px;height:' + h + 'px;transform:scale(' + c.scale + ');'
471
+ + "background-image:url('/shot/" + encodeURIComponent(page) + ".jpg?t=" + T + "');"
472
+ + 'background-position:-' + c.offsetX + 'px -' + c.offsetY + 'px">'
473
+ + '<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>'
474
+ + '</div></div>';
475
+ }
476
+ function figHtml(page, item) {
477
+ return '<figure>' + cropHtml(page, item.crop)
478
+ + '<figcaption><b>' + esc(item.label) + '</b><code>' + esc(item.selector) + '</code>'
479
+ + '<button class="copy" data-copy="' + esc(item.selector) + '">copy selector</button></figcaption></figure>';
480
+ }
481
+
482
+ /* The sentence the console owed and never said: what this person is supposed to
483
+ DO. Evidence without an instruction is a puzzle, not a tool โ€” and the answer
484
+ differs per status, so it cannot be one generic line at the top. */
485
+ function todo(r) {
486
+ const where = r.sites && r.sites.length
487
+ ? 'your test at <b>' + esc(r.sites[0].file) + ':' + esc(r.sites[0].line) + '</b>'
488
+ : 'the test that uses it';
489
+ if (r.rebound) {
490
+ return 'Your test still passes โ€” on the wrong element. Open ' + where
491
+ + ' and point it at the right one, or ask a developer for a <code>data-testid</code> on it.';
492
+ }
493
+ if (r.status === 'ambiguous') {
494
+ return 'Pick the right one from the pictures below, copy its selector, and paste it into ' + where
495
+ + '. The durable fix is a <code>data-testid</code> on that element โ€” then it can never be confused again.';
496
+ }
497
+ if (r.status === 'lost') {
498
+ return 'This element is not on the page any more. Open ' + where
499
+ + ' and either point it somewhere else or delete the step โ€” nothing can repair this automatically.';
500
+ }
501
+ return 'Open ' + where + ' and check it still does what you meant.';
502
+ }
503
+
504
+ function renderDecisions(s) {
505
+ const d = s.decisions || [];
506
+ if (d.length === 0) {
507
+ q('#decisions').innerHTML = '<div class="empty">Nothing is waiting on a human. Every drifted row either matched deterministically or did not drift.</div>';
508
+ return;
509
+ }
510
+ q('#decisions').innerHTML = d.map((r, i) => {
511
+ const kind = r.rebound ? 'rebound' : r.status;
512
+ let h = '<div class="dec" id="dec-' + i + '"><div class="dec-head">'
513
+ + '<span class="num">' + (i + 1) + ' of ' + d.length + '</span>'
514
+ + '<span class="badge b-' + esc(kind) + '">' + esc(kind) + '</span>'
515
+ + '<span class="dec-label">' + esc(r.label) + '</span>'
516
+ + '<span class="dec-page">on page โ€œ' + esc(r.page) + 'โ€</span></div>'
517
+ + '<div class="todo"><b>What to do:</b> ' + todo(r) + '</div>'
518
+ + '<div class="sel">' + esc(r.selector) + '</div>';
519
+ 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>';
520
+ if (r.sites && r.sites.length) {
521
+ h += '<div class="sites">๐Ÿ“‡ ' + r.sites.slice(0, 6).map((x) => '<code>' + esc(x.file) + ':' + esc(x.line) + '</code>').join('')
522
+ + (r.sites.length > 6 ? '+' + (r.sites.length - 6) + ' more' : '') + '</div>';
523
+ } else {
524
+ 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>';
525
+ }
526
+ if (r.nowAt) h += '<div class="evid-title">โš ๏ธ What that selector points at today</div><div class="figs">' + figHtml(r.page, r.nowAt) + '</div>';
527
+ if (r.candidates && r.candidates.length) {
528
+ 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>'
529
+ + '<div class="figs">' + r.candidates.map((c) => figHtml(r.page, c)).join('') + '</div>';
530
+ }
531
+ if (r.note) h += '<div class="note">' + esc(r.note) + '</div>';
532
+ if (!r.nowAt && !(r.candidates || []).length && !r.note) {
533
+ h += '<div class="note">No picture available โ€” run <code>ia-qa-heal map --screenshots</code> to see this element in place.</div>';
534
+ }
535
+ return h + '</div>';
536
+ }).join('');
537
+ }
538
+
539
+ /* Bound once per render, over the whole document: copy buttons live in the
540
+ decision cards and in the update banner, and each restores its own label. */
541
+ function bindCopy() {
542
+ document.querySelectorAll('[data-copy]').forEach((b) => {
543
+ const label = b.dataset.copyLabel || b.textContent;
544
+ b.onclick = () => {
545
+ navigator.clipboard.writeText(b.dataset.copy);
546
+ b.textContent = 'copied โœ“';
547
+ setTimeout(() => (b.textContent = label), 1400);
548
+ };
549
+ });
550
+ }
551
+
552
+ /* ---- history ---------------------------------------------------------- */
553
+
554
+ function renderHistory(s) {
555
+ const h = s.history || {}; const sum = h.summary || {}; const series = (sum.trend && sum.trend.series) || [];
556
+ if (!series.length) {
557
+ 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>';
558
+ return;
559
+ }
560
+ const drift = (e) => (e.counts ? e.counts.healable + e.counts.ambiguous + e.counts.lost + e.counts.rebound : 0);
561
+ let html = '<div class="tiles">'
562
+ + tile(sum.runs, 'runs recorded')
563
+ + tile(Math.round((sum.driftRate || 0) * 100) + '%', 'runs with drift')
564
+ + tile(Math.round((sum.coverage || 0) * 100) + '%', 'mean coverage')
565
+ + tile(sum.healed, 'rewrites applied')
566
+ + '</div>';
567
+
568
+ html += '<div class="evid-title" style="margin-top:18px">Drift per run โ€” ' + esc(series.length) + ' comparable run' + (series.length === 1 ? '' : 's')
569
+ + (sum.trend.source ? ' captured by <code>' + esc(sum.trend.source) + '</code>' : '') + '</div>';
570
+ html += barChart(series, drift);
571
+ html += '<div class="legend">'
572
+ + Object.keys(VC).map((k) => '<span><i style="background:' + VC[k] + '"></i>' + VI[k] + ' ' + k + '</span>').join('')
573
+ + '</div>';
574
+ 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>';
575
+
576
+ if ((sum.hotspots || []).length) {
577
+ html += '<div class="evid-title" style="margin-top:20px">Where the churn lives โ€” total drifted elements per page</div>' + hotspots(sum.hotspots);
578
+ }
579
+
580
+ 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>'
581
+ + series.slice(-12).reverse().map((e) =>
582
+ '<tr><td>' + esc(new Date(e.ts).toLocaleString()) + '</td><td>' + esc(e.verb) + '</td>'
583
+ + '<td style="color:' + (VC[e.verdict] || 'inherit') + '">' + (VI[e.verdict] || '') + ' ' + esc(e.verdict) + '</td>'
584
+ + '<td class="n">' + drift(e) + '</td>'
585
+ + '<td class="n">' + (e.coverage ? e.coverage.measured + '/' + e.coverage.total : 'โ€”') + '</td>'
586
+ + '<td>' + esc(e.commit || 'โ€”') + '</td></tr>').join('')
587
+ + '</tbody></table>';
588
+ q('#history').innerHTML = html;
589
+ }
590
+
591
+ /* Bars: one per run, height by drift count, fill by verdict. Single measure,
592
+ single axis. 2px gap between bars, 4px rounded top anchored to the baseline. */
593
+ function barChart(series, valueOf) {
594
+ const W = 1100, H = 170, PB = 24, PL = 34;
595
+ const n = series.length;
596
+ const max = Math.max(1, ...series.map(valueOf));
597
+ const bw = Math.max(3, Math.min(34, (W - PL) / n - 2));
598
+ const step = (W - PL) / n;
599
+ const y = (v) => H - PB - (v / max) * (H - PB - 12);
600
+ let bars = '', grid = '';
601
+ [0, 0.5, 1].forEach((f) => {
602
+ const v = Math.round(max * f), yy = y(v);
603
+ grid += '<line x1="' + PL + '" x2="' + W + '" y1="' + yy + '" y2="' + yy + '" stroke="#27272a" stroke-width="1"/>'
604
+ + '<text class="axis" x="' + (PL - 7) + '" y="' + (yy + 3) + '" text-anchor="end">' + v + '</text>';
605
+ });
606
+ series.forEach((e, i) => {
607
+ const v = valueOf(e), yy = y(v), x = PL + i * step + 1;
608
+ const hgt = Math.max(v > 0 ? 3 : 2, H - PB - yy);
609
+ bars += '<rect x="' + x + '" y="' + (H - PB - hgt) + '" width="' + bw + '" height="' + hgt + '" rx="3" fill="'
610
+ + (VC[e.verdict] || '#3f3f46') + '"><title>' + new Date(e.ts).toLocaleString() + ' โ€” ' + e.verdict + ' ยท '
611
+ + v + ' drifted' + (e.commit ? ' ยท ' + e.commit : '') + '</title></rect>';
612
+ });
613
+ return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="100%" height="' + H + '" role="img" aria-label="Drifted elements per run">'
614
+ + grid + bars
615
+ + '<line x1="' + PL + '" x2="' + W + '" y1="' + (H - PB) + '" y2="' + (H - PB) + '" stroke="#3f3f46" stroke-width="1"/>'
616
+ + '<text class="axis" x="' + PL + '" y="' + (H - 8) + '">oldest</text>'
617
+ + '<text class="axis" x="' + W + '" y="' + (H - 8) + '" text-anchor="end">latest</text></svg>';
618
+ }
619
+
620
+ /* One series, so no legend โ€” the title names it. Direct-labelled, since there
621
+ are at most five rows and a value axis would be one more thing to read. */
622
+ function hotspots(rows) {
623
+ const max = Math.max(1, ...rows.map((r) => r.drift));
624
+ return '<div style="margin-top:6px">' + rows.map((r) =>
625
+ '<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">'
626
+ + '<div style="width:170px;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(r.name) + '</div>'
627
+ + '<div style="flex:1;background:#27272a;border-radius:4px;height:14px;overflow:hidden">'
628
+ + '<div style="width:' + Math.round((r.drift / max) * 100) + '%;height:100%;background:var(--orange);border-radius:4px"></div></div>'
629
+ + '<div style="width:82px;text-align:right;font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums">'
630
+ + esc(r.drift) + ' in ' + esc(r.runs) + ' run' + (r.runs === 1 ? '' : 's') + '</div></div>'
631
+ ).join('') + '</div>';
632
+ }
633
+
634
+ /* The console stops when you stop looking at it. Closing the tab cannot end a
635
+ process by itself, so the page says it is still here โ€” and says goodbye on the
636
+ way out. A reload cancels the goodbye simply by arriving first. */
637
+ const PING_MS = ${server_1.HEARTBEAT_INTERVAL_MS};
638
+ setInterval(() => { fetch('/api/ping?t=' + T).catch(() => {}); }, PING_MS);
639
+ let saidBye = false;
640
+ function bye() {
641
+ if (saidBye) return;
642
+ saidBye = true;
643
+ // sendBeacon survives the page being torn down; a plain fetch does not.
644
+ // keepalive is the fallback where sendBeacon is unavailable. Neither is
645
+ // guaranteed to arrive โ€” the server's idle timeout is what actually bounds
646
+ // this, and the beacon only makes the common case instant.
647
+ if (navigator.sendBeacon) navigator.sendBeacon('/api/bye?t=' + T);
648
+ else fetch('/api/bye?t=' + T, { keepalive: true }).catch(() => {});
649
+ }
650
+ addEventListener('pagehide', bye);
651
+ addEventListener('beforeunload', bye);
652
+
653
+ load();
654
+ </script>
655
+ </body></html>`;
656
+ }
657
+ //# sourceMappingURL=page.js.map