api-tracer-kit 1.0.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +466 -0
  4. package/cli/bin/api-tracer.mjs +266 -0
  5. package/cli/config.mjs +224 -0
  6. package/cli/import.mjs +231 -0
  7. package/cli/index.mjs +10 -0
  8. package/cli/presets.mjs +212 -0
  9. package/cli/report.mjs +346 -0
  10. package/cli/scan.mjs +142 -0
  11. package/cli/server.mjs +1576 -0
  12. package/cli/shape.mjs +90 -0
  13. package/cli/test.mjs +342 -0
  14. package/cli/web/app.css +1424 -0
  15. package/cli/web/app.js +2260 -0
  16. package/cli/web/favicon.svg +5 -0
  17. package/cli/web/index.html +159 -0
  18. package/cli/web/logo.svg +7 -0
  19. package/dist/axios.cjs +856 -0
  20. package/dist/axios.cjs.map +1 -0
  21. package/dist/axios.d.cts +27 -0
  22. package/dist/axios.d.ts +27 -0
  23. package/dist/axios.js +853 -0
  24. package/dist/axios.js.map +1 -0
  25. package/dist/index.cjs +872 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/index.d.cts +74 -0
  28. package/dist/index.d.ts +74 -0
  29. package/dist/index.js +857 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/react.cjs +896 -0
  32. package/dist/react.cjs.map +1 -0
  33. package/dist/react.d.cts +22 -0
  34. package/dist/react.d.ts +22 -0
  35. package/dist/react.js +893 -0
  36. package/dist/react.js.map +1 -0
  37. package/dist/tracer-BUWdU2lG.d.ts +76 -0
  38. package/dist/tracer-DG2YUqK0.d.cts +76 -0
  39. package/dist/types-Bl2-K6_g.d.cts +111 -0
  40. package/dist/types-Bl2-K6_g.d.ts +111 -0
  41. package/dist/ui.cjs +1162 -0
  42. package/dist/ui.cjs.map +1 -0
  43. package/dist/ui.d.cts +16 -0
  44. package/dist/ui.d.ts +16 -0
  45. package/dist/ui.js +1157 -0
  46. package/dist/ui.js.map +1 -0
  47. package/package.json +92 -0
package/cli/web/app.js ADDED
@@ -0,0 +1,2260 @@
1
+ /* API Tracker UI. Plain DOM, no framework, no build step. */
2
+
3
+ const $ = (sel, root = document) => root.querySelector(sel);
4
+ const esc = (s) =>
5
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
6
+
7
+ const state = {
8
+ endpoints: [],
9
+ baseUrls: {},
10
+ auth: {},
11
+ env: 'dev',
12
+ results: {}, // id -> { ok, status, ms, error, at }
13
+ tokensHeld: [],
14
+ vars: {},
15
+ samples: {}, // endpoint id -> request captured from real traffic
16
+ live: { on: false, count: 0, unmatched: [] },
17
+ contracts: {},
18
+ locks: { recording: false, env: null },
19
+ login: { supported: false, fields: [] }, // the sign-in flow the server declares
20
+ name: 'API',
21
+ tokenOwner: null,
22
+ lastRun: null,
23
+ lastRunOnlyFailed: false,
24
+ openModule: null,
25
+ selected: null,
26
+ drafts: new Map(), // filled from localStorage at boot
27
+ response: null,
28
+ tab: 'body',
29
+ busy: false,
30
+ };
31
+
32
+ let toastTimer;
33
+ function toast(msg) {
34
+ const el = $('#toast');
35
+ el.textContent = msg;
36
+ el.classList.add('on');
37
+ clearTimeout(toastTimer);
38
+ toastTimer = setTimeout(() => el.classList.remove('on'), 3000);
39
+ }
40
+
41
+ /**
42
+ * Resolves a request against the page itself, so the console works whether it is
43
+ * served at / or mounted under a path like /api-console/.
44
+ *
45
+ * Credentials are stripped: fetch() refuses a URL containing them, which is what
46
+ * you get if someone opens the console as https://user:pass@host/api-console/.
47
+ */
48
+ const REQUEST_BASE = (() => {
49
+ const base = new URL(document.baseURI);
50
+ base.username = '';
51
+ base.password = '';
52
+ return base.toString();
53
+ })();
54
+
55
+ const url = (path) => new URL(path.replace(/^\//, ''), REQUEST_BASE).toString();
56
+
57
+ async function api(path, opts) {
58
+ const res = await fetch(url(path), opts);
59
+ const data = await res.json();
60
+ if (!res.ok) throw new Error(data.error || res.statusText);
61
+ return data;
62
+ }
63
+
64
+ const post = (path, body) =>
65
+ api(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
66
+
67
+ /** the two downloads read headers off the response, so they call fetch directly */
68
+ const fetchAt = (path) => fetch(url(path));
69
+
70
+ /* ------------------------------------------------------------------ drafts */
71
+
72
+ const DRAFT_KEY = 'api-tracker-drafts';
73
+
74
+ function loadDrafts() {
75
+ try {
76
+ return new Map(Object.entries(JSON.parse(localStorage.getItem(DRAFT_KEY) ?? '{}')));
77
+ } catch {
78
+ return new Map(); // private window, cleared storage, or corrupt value
79
+ }
80
+ }
81
+
82
+ let saveDraftTimer;
83
+ function saveDrafts() {
84
+ clearTimeout(saveDraftTimer);
85
+ saveDraftTimer = setTimeout(() => {
86
+ try {
87
+ localStorage.setItem(DRAFT_KEY, JSON.stringify(Object.fromEntries(state.drafts)));
88
+ } catch {
89
+ /* storage full or blocked; drafts just stop surviving reloads */
90
+ }
91
+ }, 300);
92
+ }
93
+
94
+ function draftFor(ep) {
95
+ const sample = state.samples[ep.id];
96
+ let d = state.drafts.get(ep.id);
97
+ // a capture newer than your last edit wins: recording the app then opening the
98
+ // endpoint should show what the app actually sent, not a stale empty draft
99
+ if (d && !(sample && sample.seenAt > (d.editedAt ?? ''))) return d;
100
+ if (d && sample) toast(`filled from the call the app just made (${sample.from})`);
101
+ d = {
102
+ holes: Object.fromEntries(
103
+ ep.holes.map((h) => [h.expr, sample?.pathValues?.[h.expr] ?? '']),
104
+ ),
105
+ params: JSON.stringify(sample?.params ?? {}, null, 2),
106
+ rawQuery: '',
107
+ paramsMode: 'json', // 'json' | 'raw' | 'stringified'
108
+ stringifyKey: 'params',
109
+ data: JSON.stringify(sample?.data ?? {}, null, 2),
110
+ // the app's own encoding wins; otherwise assume JSON for methods that carry a body
111
+ bodyType:
112
+ sample?.bodyType ??
113
+ (['POST', 'PUT', 'PATCH', 'DELETE'].includes(ep.method) ? 'json' : 'none'),
114
+ headers: d?.headers ?? [],
115
+ tab: d?.tab ?? (['POST', 'PUT', 'PATCH', 'DELETE'].includes(ep.method) ? 'body' : 'params'),
116
+ capture: d?.capture ?? [], // capture rules are yours; keep them across refills
117
+ editedAt: '',
118
+ };
119
+ state.drafts.set(ep.id, d);
120
+ return d;
121
+ }
122
+
123
+ /** call after any draft edit so a reload does not lose typed ids and payloads */
124
+ function touchDraft(d) {
125
+ if (d) d.editedAt = new Date().toISOString();
126
+ saveDrafts();
127
+ }
128
+
129
+ /** axios-style params -> query string; same rules as the server */
130
+ function toQueryString(params, prefix = '') {
131
+ const out = [];
132
+ const enc = encodeURIComponent;
133
+ for (const [rawKey, v] of Object.entries(params)) {
134
+ const key = prefix ? `${prefix}[${rawKey}]` : rawKey;
135
+ if (v === undefined || v === null || v === '') continue;
136
+ if (Array.isArray(v)) {
137
+ for (const item of v) {
138
+ if (item !== null && typeof item === 'object') out.push(toQueryString(item, `${key}[]`));
139
+ else out.push(`${enc(`${key}[]`)}=${enc(item)}`);
140
+ }
141
+ } else if (typeof v === 'object') {
142
+ out.push(toQueryString(v, key));
143
+ } else {
144
+ out.push(`${enc(key)}=${enc(v)}`);
145
+ }
146
+ }
147
+ return out.filter(Boolean).join('&');
148
+ }
149
+
150
+ /** `a=1&b[]=2` -> { a: '1', b: ['2'] }, enough to round-trip back to the JSON editor */
151
+ function fromQueryString(raw) {
152
+ const out = {};
153
+ for (const [k, v] of new URLSearchParams(raw.trim().replace(/^[?&]+/, ''))) {
154
+ if (k.endsWith('[]')) {
155
+ const key = k.slice(0, -2);
156
+ (out[key] ??= []).push(v);
157
+ } else {
158
+ out[k] = v;
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ const bodyLabel = (t) =>
165
+ ({ json: 'application/json', formdata: 'multipart/form-data', urlencoded: 'application/x-www-form-urlencoded', none: 'no body' })[t] ?? t;
166
+
167
+ /** how many query params are actually going out, for the tab counter */
168
+ function countParams(d) {
169
+ if (d.paramsMode === 'raw') {
170
+ return d.rawQuery.trim().replace(/^[?&]+/, '') ? new URLSearchParams(d.rawQuery.trim().replace(/^[?&]+/, '')).size : 0;
171
+ }
172
+ try {
173
+ return Object.keys(JSON.parse(d.params || '{}')).length;
174
+ } catch {
175
+ return 0;
176
+ }
177
+ }
178
+
179
+ /** the JSON body as editable key/value rows, the way form-data is really shaped */
180
+ function bodyRows(d) {
181
+ try {
182
+ return Object.entries(JSON.parse(d.data || '{}')).map(([k, v]) => ({
183
+ k,
184
+ v: typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v),
185
+ on: true,
186
+ }));
187
+ } catch {
188
+ return [];
189
+ }
190
+ }
191
+
192
+ /** Postman-style key/value table */
193
+ function kvRows(rows, kind) {
194
+ return `<table class="kv">${rows
195
+ .map(
196
+ (r, i) => `<tr>
197
+ <td class="x"><input type="checkbox" data-kv-on="${kind}:${i}" ${r.on ? 'checked' : ''} /></td>
198
+ <td class="k"><input data-kv-k="${kind}:${i}" value="${esc(r.k)}" placeholder="key" /></td>
199
+ <td><input data-kv-v="${kind}:${i}" value="${esc(r.v)}" placeholder="value" /></td>
200
+ <td class="x"><button class="link" data-kv-del="${kind}:${i}">&times;</button></td>
201
+ </tr>`,
202
+ )
203
+ .join('')}</table>`;
204
+ }
205
+
206
+ /** mirrors the server's URL builder so the preview matches what will be sent */
207
+ function previewUrl(ep, d) {
208
+ let path = ep.subUrl;
209
+ for (const h of ep.holes) path = path.replaceAll(`\${${h.expr}}`, d.holes[h.expr] || `:${h.label}`);
210
+ const base = ep.absolute || /^https?:\/\//.test(path) ? '' : state.baseUrls[state.env] ?? '';
211
+ let qs = '';
212
+ if (d.paramsMode === 'raw') {
213
+ qs = d.rawQuery.trim().replace(/^[?&]+/, '');
214
+ } else {
215
+ try {
216
+ const parsed = JSON.parse(d.params || '{}');
217
+ qs =
218
+ d.paramsMode === 'stringified'
219
+ ? `${encodeURIComponent(d.stringifyKey)}=${encodeURIComponent(JSON.stringify(parsed))}`
220
+ : toQueryString(parsed);
221
+ } catch {
222
+ /* invalid JSON is reported by the editor itself */
223
+ }
224
+ }
225
+ return `${base}${path}${qs ? `?${qs}` : ''}`;
226
+ }
227
+
228
+ /* ---------------------------------------------------------- code colouring */
229
+
230
+ /**
231
+ * JSON -> highlighted HTML, VS Code Dark+ colours.
232
+ *
233
+ * Deliberately tolerant: this runs on every keystroke, including while the JSON
234
+ * is half-typed and invalid, so it tokenises rather than parses.
235
+ */
236
+ function highlightJson(text) {
237
+ const token =
238
+ /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
239
+
240
+ let out = '';
241
+ let last = 0;
242
+ let m;
243
+ while ((m = token.exec(text))) {
244
+ out += esc(text.slice(last, m.index)); // punctuation and whitespace
245
+ const [whole, str, colon, literal, number] = m;
246
+ if (str) {
247
+ // a string followed by a colon is a property name, and reads differently
248
+ out += colon
249
+ ? `<span class="t-key">${esc(str)}</span>${esc(colon)}`
250
+ : `<span class="t-str">${esc(str)}</span>`;
251
+ } else if (literal) {
252
+ out += `<span class="t-lit">${esc(literal)}</span>`;
253
+ } else {
254
+ out += `<span class="t-num">${esc(number)}</span>`;
255
+ }
256
+ last = m.index + whole.length;
257
+ }
258
+ return `${out + esc(text.slice(last))}\n`; // trailing newline keeps the last line visible
259
+ }
260
+
261
+ /** a textarea with a highlighted layer behind it, kept in sync */
262
+ function codeEditor(id, value, { placeholder = '', rows = 12 } = {}) {
263
+ return `<div class="code-edit">
264
+ <div class="gutter" data-gutter-for="${id}"></div>
265
+ <pre class="hl" data-hl-for="${id}" aria-hidden="true"></pre>
266
+ <textarea id="${id}" spellcheck="false" wrap="off" rows="${rows}"
267
+ placeholder="${esc(placeholder)}">${esc(value)}</textarea>
268
+ </div>`;
269
+ }
270
+
271
+ /** paints the highlight layer and line numbers for one editor */
272
+ function paintEditor(ta) {
273
+ const hl = $(`[data-hl-for="${ta.id}"]`);
274
+ const gutter = $(`[data-gutter-for="${ta.id}"]`);
275
+ if (hl) hl.innerHTML = highlightJson(ta.value);
276
+ if (gutter) {
277
+ const lines = ta.value.split('\n').length;
278
+ gutter.innerHTML = Array.from({ length: lines }, (_, i) => i + 1).join('<br />');
279
+ }
280
+ }
281
+
282
+ /** the layers only line up while they scroll together */
283
+ function syncEditorScroll(ta) {
284
+ const hl = $(`[data-hl-for="${ta.id}"]`);
285
+ const gutter = $(`[data-gutter-for="${ta.id}"]`);
286
+ if (hl) {
287
+ hl.scrollTop = ta.scrollTop;
288
+ hl.scrollLeft = ta.scrollLeft;
289
+ }
290
+ if (gutter) gutter.scrollTop = ta.scrollTop;
291
+ }
292
+
293
+ function wireEditor(ta) {
294
+ if (!ta) return;
295
+ paintEditor(ta);
296
+ ta.addEventListener('scroll', () => syncEditorScroll(ta));
297
+ ta.addEventListener('input', () => {
298
+ paintEditor(ta);
299
+ syncEditorScroll(ta);
300
+ });
301
+ // Tab should indent, not jump out of the editor
302
+ ta.addEventListener('keydown', (e) => {
303
+ if (e.key !== 'Tab' || e.shiftKey) return;
304
+ e.preventDefault();
305
+ const { selectionStart: a, selectionEnd: b } = ta;
306
+ ta.value = `${ta.value.slice(0, a)} ${ta.value.slice(b)}`;
307
+ ta.selectionStart = ta.selectionEnd = a + 2;
308
+ ta.dispatchEvent(new Event('input'));
309
+ });
310
+ }
311
+
312
+ /* -------------------------------------------------------------------- tree */
313
+
314
+ function matching() {
315
+ const q = $('#search').value.trim().toLowerCase();
316
+ const method = $('#filter-method').value;
317
+ const status = $('#filter-status').value;
318
+
319
+ return state.endpoints.filter((e) => {
320
+ if (method && e.method !== method) return false;
321
+ const r = state.results[e.id];
322
+ if (status === 'pass' && !r?.ok) return false;
323
+ if (status === 'fail' && (!r || r.ok)) return false;
324
+ if (status === 'untested' && r) return false;
325
+ if (status === 'regressed' && !(r && !r.ok && r.wasOk)) return false;
326
+ if (status === 'unused' && e.usedIn !== 0) return false;
327
+ if (status === 'drift' && !r?.drift) return false;
328
+ if (status === 'captured' && !state.samples[e.id]) return false;
329
+ if (status === 'uncaptured' && state.samples[e.id]) return false;
330
+ if (status === 'uncatalogued' && !e.uncatalogued) return false;
331
+ if (!q) return true;
332
+ return `${e.name} ${e.subUrl} ${e.module}`.toLowerCase().includes(q);
333
+ });
334
+ }
335
+
336
+ function groupByModule(list) {
337
+ const map = new Map();
338
+ for (const e of list) {
339
+ if (!map.has(e.module)) map.set(e.module, []);
340
+ map.get(e.module).push(e);
341
+ }
342
+ // keep the adopted-from-traffic bucket at the bottom, out of the alphabet
343
+ return [...map.entries()].sort((a, b) => {
344
+ const rank = (n) => (n === '(uncatalogued)' ? 1 : 0);
345
+ return rank(a[0]) - rank(b[0]) || a[0].localeCompare(b[0]);
346
+ });
347
+ }
348
+
349
+ function statusDot(id) {
350
+ const r = state.results[id];
351
+ if (!r) return '<span class="dot"></span>';
352
+ // broke since the last run: worth spotting without reading the whole list
353
+ if (r.ok && r.drift) {
354
+ return `<span class="dot drift" title="passing, but the response shape changed: ${esc(
355
+ r.drift.summary,
356
+ )}"></span>`;
357
+ }
358
+ const cls = r.ok ? 'pass' : r.wasOk ? 'fail regressed' : 'fail';
359
+ const title = r.ok
360
+ ? 'passing'
361
+ : r.failedInReplay
362
+ ? 'failed when replayed'
363
+ : r.wasOk
364
+ ? 'was passing, now failing'
365
+ : 'failing';
366
+ return `<span class="dot ${cls}" title="${title}"></span>`;
367
+ }
368
+
369
+ function renderTree() {
370
+ const groups = groupByModule(matching());
371
+
372
+ $('#tree').innerHTML =
373
+ groups
374
+ .map(([name, eps]) => {
375
+ const open = state.openModule === name;
376
+ const failed = eps.filter((e) => state.results[e.id] && !state.results[e.id].ok).length;
377
+ const passed = eps.filter((e) => state.results[e.id]?.ok).length;
378
+ const rows = open
379
+ ? eps
380
+ .map(
381
+ (e) => `<button class="endpoint ${state.selected === e.id ? 'active' : ''}"
382
+ data-id="${esc(e.id)}" title="${esc(e.method)} ${esc(e.subUrl)}">
383
+ ${statusDot(e.id)}
384
+ <span class="method m-${esc(e.method)}">${esc(e.method)}</span>
385
+ <span class="nm">${esc(e.name)}</span>
386
+ ${state.samples[e.id] ? '<span class="tick" title="sample from real traffic">&#9679;</span>' : ''}
387
+ </button>`,
388
+ )
389
+ .join('')
390
+ : '';
391
+ const caught = eps.filter((e) => state.samples[e.id]).length;
392
+ const tally = failed
393
+ ? `<span class="n fail">${failed} failing</span>`
394
+ : passed
395
+ ? `<span class="n pass">${passed}/${eps.length}</span>`
396
+ : caught
397
+ ? `<span class="n pass">${caught}/${eps.length} seen</span>`
398
+ : `<span class="n">${eps.length}</span>`;
399
+ return `<button class="module ${open ? 'active' : ''} ${
400
+ name === '(uncatalogued)' ? 'odd' : ''
401
+ }" data-module="${esc(name)}"
402
+ aria-expanded="${open}">${open ? '&#9662;' : '&#9656;'} ${esc(name)}${tally}</button>${rows}`;
403
+ })
404
+ .join('') || '<p class="muted" style="padding:14px">nothing matches</p>';
405
+
406
+ renderHealth();
407
+ }
408
+
409
+ function renderHealth() {
410
+ const total = state.endpoints.length;
411
+ const run = Object.keys(state.results).length;
412
+ const pass = Object.values(state.results).filter((r) => r.ok).length;
413
+ const fail = run - pass;
414
+
415
+ $('#health').innerHTML = run
416
+ ? `<div class="bar" title="${pass} passing, ${fail} failing, ${total - run} not run">
417
+ <i class="p" style="width:${(pass / total) * 100}%"></i>
418
+ <i class="f" style="width:${(fail / total) * 100}%"></i>
419
+ </div>
420
+ <span><b class="pass">${pass}</b> pass &middot; <b class="fail">${fail}</b> fail &middot;
421
+ <span class="muted">${total - run} not run</span></span>`
422
+ : `<span class="muted">${total} endpoints, none run yet</span>`;
423
+ }
424
+
425
+ /**
426
+ * The last replay, kept on the home page so closing the dialog or opening one of
427
+ * the endpoints does not lose the results.
428
+ */
429
+ function lastRunPanel() {
430
+ const run = state.lastRun;
431
+ if (!run?.finished?.length) return '';
432
+
433
+ const rows = state.lastRunOnlyFailed ? run.finished.filter((r) => !r.ok) : run.finished;
434
+ const when = run.startedAt ? new Date(run.startedAt).toLocaleString() : '';
435
+
436
+ return `
437
+ <div class="lastrun">
438
+ <div class="lastrun-head">
439
+ <b>Last replay</b>
440
+ <span class="pass">${run.passed} passed</span>
441
+ <span class="fail">${run.failed} failed</span>
442
+ ${run.cancelled ? '<span class="badge warn">stopped early</span>' : ''}
443
+ <span class="muted">${esc(when)}</span>
444
+ <span class="spacer"></span>
445
+ ${
446
+ run.failed
447
+ ? `<button class="link" id="lastrun-filter">${
448
+ state.lastRunOnlyFailed ? 'show all' : 'only failed'
449
+ }</button>`
450
+ : ''
451
+ }
452
+ <button class="link" id="lastrun-again">replay again</button>
453
+ </div>
454
+ ${ranList(rows)}
455
+ </div>`;
456
+ }
457
+
458
+ /**
459
+ * How much of the API the app has actually exercised. An endpoint nobody has
460
+ * triggered has never been proven either way, which is different from failing.
461
+ */
462
+ function coveragePanel() {
463
+ const scanned = state.endpoints.filter((e) => !e.uncatalogued);
464
+ const extra = state.endpoints.filter((e) => e.uncatalogued);
465
+ const total = scanned.length;
466
+ const seen = scanned.filter((e) => state.samples[e.id]).length;
467
+ const drifted = Object.values(state.results).filter((r) => r.drift).length;
468
+ if (!seen) {
469
+ return `<p class="muted">Nothing captured yet. Turn on <b>Live</b> and use the app, or import
470
+ a HAR.</p>`;
471
+ }
472
+
473
+ const byModule = new Map();
474
+ for (const e of scanned) {
475
+ const m = byModule.get(e.module) ?? { total: 0, seen: 0 };
476
+ m.total++;
477
+ if (state.samples[e.id]) m.seen++;
478
+ byModule.set(e.module, m);
479
+ }
480
+ const untouched = [...byModule.entries()].filter(([, m]) => !m.seen);
481
+
482
+ return `
483
+ <div class="coverage">
484
+ <div class="bar" style="width:220px">
485
+ <i class="p" style="width:${(seen / total) * 100}%"></i>
486
+ </div>
487
+ <p><b>${seen}</b> of ${total} endpoints exercised
488
+ (${Math.round((seen / total) * 100)}%)${
489
+ drifted ? ` &middot; <b class="warn">${drifted}</b> with a changed response shape` : ''
490
+ }</p>
491
+ ${
492
+ extra.length
493
+ ? `<p class="muted"><b class="warn">${extra.length}</b> call${
494
+ extra.length === 1 ? '' : 's'
495
+ } recorded that no service file explains &mdash;
496
+ <button class="link" id="show-uncatalogued">show them</button></p>`
497
+ : ''
498
+ }
499
+ <p class="muted">
500
+ ${untouched.length
501
+ ? `${untouched.length} module${untouched.length === 1 ? '' : 's'} untouched:
502
+ ${untouched.slice(0, 8).map(([n]) => esc(n)).join(', ')}${
503
+ untouched.length > 8 ? `, and ${untouched.length - 8} more` : ''
504
+ }`
505
+ : 'every module has at least one captured call'}
506
+ </p>
507
+ <p><button class="link" id="show-uncaptured">show what is still uncaptured</button>
508
+ ${drifted ? '<button class="link" id="show-drift">show contract drift</button>' : ''}</p>
509
+ </div>`;
510
+ }
511
+
512
+ /* ---------------------------------------------------------------- overview */
513
+
514
+ function renderModule(name) {
515
+ const eps = state.endpoints.filter((e) => e.module === name);
516
+ const shown = matching().filter((e) => e.module === name);
517
+ const captured = eps.filter((e) => state.samples[e.id]).length;
518
+
519
+ $('#panel').innerHTML = `
520
+ ${crumbs(name)}
521
+ <h1>${esc(name)}</h1>
522
+ <div class="sub">
523
+ <span>${eps.length} endpoints</span>
524
+ <span class="badge ${captured ? 'pass' : ''}">${captured} captured</span>
525
+ ${
526
+ name === '(uncatalogued)'
527
+ ? '<span class="badge warn">seen in traffic, not in the source</span>'
528
+ : `<span class="badge">${esc(name)}</span>`
529
+ }
530
+ <span class="spacer"></span>
531
+ <button class="primary" id="run-module">Run all GETs in this module</button>
532
+ <button id="export-har" ${captured ? '' : 'disabled'}
533
+ title="${captured ? `${captured} captured request(s)` : 'nothing captured in this module yet'}">
534
+ Export HAR</button>
535
+ <button id="export-curl" ${captured ? '' : 'disabled'}>Export cURL</button>
536
+ <button id="export-postman" ${captured ? '' : 'disabled'}>Export Postman</button>
537
+ </div>
538
+ ${
539
+ name === '(uncatalogued)'
540
+ ? `<p class="muted" style="margin-top:-6px">
541
+ Your app called these, but nothing in the scanned source builds them. Either
542
+ the call is made outside the services layer, or the scanner and the code disagree about
543
+ a URL &mdash; often a stale path left commented in the service file.
544
+ <button class="link" id="forget-uncatalogued">forget these</button>
545
+ </p>`
546
+ : ''
547
+ }
548
+ <p class="muted" style="margin-top:-6px">
549
+ Bulk runs only fire GETs, with every path placeholder left blank &mdash; enough to prove the
550
+ endpoint answers, not to exercise it properly. POST/PUT/PATCH/DELETE are never run in bulk
551
+ because they change real data; open one and send it deliberately.
552
+ </p>
553
+ <table class="list">
554
+ <thead><tr><th></th><th>method</th><th>function</th><th>path</th><th>last run</th></tr></thead>
555
+ <tbody>
556
+ ${shown
557
+ .map((e) => {
558
+ const r = state.results[e.id];
559
+ return `<tr data-id="${esc(e.id)}">
560
+ <td>${statusDot(e.id)}</td>
561
+ <td class="method m-${esc(e.method)}">${esc(e.method)}</td>
562
+ <td>${esc(e.name)}</td>
563
+ <td class="path">${esc(e.subUrl)}${
564
+ e.usedIn === 0 ? ' <span class="badge warn">unused</span>' : ''
565
+ }</td>
566
+ <td class="${r ? (r.ok ? 'pass' : 'fail') : 'muted'}">
567
+ ${r ? esc(r.error ?? `${r.status}${r.innerCode ? ` (body ${r.innerCode})` : ''} in ${r.ms}ms`) : '&mdash;'}
568
+ ${r?.failedInReplay ? '<span class="badge fail">failed when replayed</span>' : ''}
569
+ </td>
570
+ </tr>`;
571
+ })
572
+ .join('')}
573
+ </tbody>
574
+ </table>`;
575
+
576
+ wireCrumbs();
577
+ $('#run-module').onclick = () => runBatch(eps.map((e) => e.id), `module ${name}`);
578
+ const forget = $('#forget-uncatalogued');
579
+ if (forget) {
580
+ forget.onclick = async () => {
581
+ await api('/api/uncatalogued', { method: 'DELETE' });
582
+ await load();
583
+ toast('uncatalogued endpoints cleared');
584
+ };
585
+ }
586
+ $('#export-har').onclick = () => download(name, 'har');
587
+ $('#export-curl').onclick = () => download(name, 'curl');
588
+ $('#export-postman').onclick = () => download(name, 'postman');
589
+ $('#panel').querySelector('tbody').onclick = (e) => {
590
+ const tr = e.target.closest('tr');
591
+ if (tr) select(tr.dataset.id);
592
+ };
593
+ }
594
+
595
+ /* ------------------------------------------------------------------ detail */
596
+
597
+ function renderDetail() {
598
+ const ep = state.endpoints.find((e) => e.id === state.selected);
599
+ if (!ep) return;
600
+ const d = draftFor(ep);
601
+ const r = state.results[ep.id];
602
+ const sample = state.samples[ep.id];
603
+ // what the app actually sent beats what the scanner inferred
604
+ const showParams = ep.usesParams || Object.keys(sample?.params ?? {}).length > 0;
605
+ const sendsBody =
606
+ ['POST', 'PUT', 'PATCH', 'DELETE'].includes(ep.method) ||
607
+ Object.keys(sample?.data ?? {}).length > 0;
608
+
609
+ $('#panel').innerHTML = `
610
+ ${crumbs(ep.module, ep.name)}
611
+ <h1><span class="method m-${esc(ep.method)}">${esc(ep.method)}</span> ${esc(ep.name)}</h1>
612
+ <div class="sub">
613
+ <span class="badge">${esc(ep.module)}</span>
614
+ <span>${esc(ep.file)}:${ep.line}</span>
615
+ ${r ? `<span class="badge ${r.ok ? 'pass' : 'fail'}">${r.ok ? 'passing' : 'failing'}</span>` : ''}
616
+ ${ep.absolute ? '<span class="badge warn">third-party URL, token not sent</span>' : ''}
617
+ ${
618
+ ep.customToken === 'ACCESS_TOKEN'
619
+ ? '<span class="badge">ACCESS_TOKEN signed per call</span>'
620
+ : ep.customToken
621
+ ? `<span class="badge warn">uses ${esc(ep.customToken)}</span>`
622
+ : ''
623
+ }
624
+ ${ep.dynamicUrl ? '<span class="badge warn">URL supplied by caller</span>' : ''}
625
+ ${
626
+ ep.uncatalogued
627
+ ? '<span class="badge warn" title="the app called this, but nothing in the scanned source builds it">not in the source</span>'
628
+ : ''
629
+ }
630
+ ${
631
+ ep.uncatalogued
632
+ ? ''
633
+ : ep.usedIn === 0
634
+ ? '<span class="badge warn" title="nothing outside its own service file references this function">unused in app</span>'
635
+ : `<span class="badge">used in ${ep.usedIn} file${ep.usedIn === 1 ? '' : 's'}</span>`
636
+ }
637
+ ${r?.failedInReplay ? '<span class="badge fail">failed when replayed</span>' : ''}
638
+ ${r && !r.ok && r.wasOk ? '<span class="badge fail">broke since last run</span>' : ''}
639
+ ${
640
+ state.samples[ep.id]
641
+ ? `<span class="badge pass" title="${esc(state.samples[ep.id].from)}">from real traffic</span>`
642
+ : ''
643
+ }
644
+ ${
645
+ state.samples[ep.id]?.bodyType && state.samples[ep.id].bodyType !== 'json'
646
+ ? `<span class="badge warn">${esc(state.samples[ep.id].bodyType)}</span>`
647
+ : ''
648
+ }
649
+ </div>
650
+
651
+ ${
652
+ r?.drift
653
+ ? `<div class="drift-box">
654
+ <b class="warn">Response shape changed</b> since the baseline recorded
655
+ ${state.contracts[ep.id] ? new Date(state.contracts[ep.id].at).toLocaleString() : ''}.
656
+ <div class="mono" style="margin:6px 0">${esc(r.drift.summary)}</div>
657
+ <button class="link" id="accept-contract">This change is expected, make it the baseline</button>
658
+ </div>`
659
+ : ''
660
+ }
661
+
662
+ <div class="urlbar">
663
+ <span class="method m-${esc(ep.method)}">${esc(ep.method)}</span>
664
+ <span class="mono" id="url-preview"></span>
665
+ </div>
666
+
667
+ ${
668
+ sample
669
+ ? `<p class="muted" style="margin:-4px 0 12px">
670
+ Filled from the call your app made ${
671
+ sample.live ? 'live' : 'in the imported traffic'
672
+ } &mdash; <span class="mono">${esc(sample.from)}</span>${
673
+ sample.status ? `, answered ${sample.status}` : ''
674
+ }, ${new Date(sample.seenAt).toLocaleString()}.
675
+ ${
676
+ sample.bodyType && sample.bodyType !== 'json'
677
+ ? `<br />Your app sent this as
678
+ <b>${sample.bodyType === 'formdata' ? 'multipart/form-data' : 'form-urlencoded'}</b>,
679
+ so Send replays it that way. Edit the fields below as normal.`
680
+ : ''
681
+ }
682
+ </p>`
683
+ : ''
684
+ }
685
+
686
+ ${
687
+ ep.holes.length
688
+ ? `<fieldset><legend>path values</legend><div class="holes">
689
+ ${ep.holes
690
+ .map(
691
+ (h) => `<label>${esc(h.expr)}
692
+ <input data-hole="${esc(h.expr)}" value="${esc(d.holes[h.expr] ?? '')}"
693
+ placeholder="${esc(h.label)}" /></label>`,
694
+ )
695
+ .join('')}
696
+ </div></fieldset>`
697
+ : ''
698
+ }
699
+
700
+ <div class="reqtabs">
701
+ ${[
702
+ ['params', `Params${countParams(d) ? ` <b>${countParams(d)}</b>` : ''}`],
703
+ ['headers', `Headers${d.headers.filter((h) => h.on && h.k).length ? ` <b>${d.headers.filter((h) => h.on && h.k).length}</b>` : ''}`],
704
+ ['body', `Body${d.bodyType !== 'none' ? ' <i class="dotmark"></i>' : ''}`],
705
+ ['capture', `Capture${(d.capture ?? []).filter((c) => c.name).length ? ` <b>${d.capture.filter((c) => c.name).length}</b>` : ''}`],
706
+ ]
707
+ .map(
708
+ ([key, label]) =>
709
+ `<button data-reqtab="${key}" class="${d.tab === key ? 'on' : ''}">${label}</button>`,
710
+ )
711
+ .join('')}
712
+ <span class="spacer"></span>
713
+ <span class="muted">${esc(bodyLabel(d.bodyType))}</span>
714
+ </div>
715
+
716
+ <div class="reqpane">
717
+ ${
718
+ d.tab === 'params'
719
+ ? `<div class="editor-bar">
720
+ <div class="tabs">
721
+ <button data-pmode="json" class="${d.paramsMode === 'json' ? 'on' : ''}">JSON</button>
722
+ <button data-pmode="stringified" class="${d.paramsMode === 'stringified' ? 'on' : ''}"
723
+ title="whole object as one encoded JSON param">stringified</button>
724
+ <button data-pmode="raw" class="${d.paramsMode === 'raw' ? 'on' : ''}">query string</button>
725
+ </div>
726
+ ${
727
+ d.paramsMode === 'stringified'
728
+ ? `<label class="inline">key <input id="f-stringify-key" size="8"
729
+ value="${esc(d.stringifyKey)}" /></label>`
730
+ : ''
731
+ }
732
+ ${d.paramsMode !== 'raw' ? '<button class="link" data-fmt="params">format</button>' : ''}
733
+ <span class="err" id="err-params"></span>
734
+ </div>
735
+ ${
736
+ d.paramsMode === 'raw'
737
+ ? `<textarea id="f-rawquery" spellcheck="false" placeholder="page=1&per_page=20&ids[]=3"
738
+ style="min-height:64px">${esc(d.rawQuery)}</textarea>
739
+ <p class="muted" style="margin:6px 0 0">Sent exactly as typed &mdash; nothing is
740
+ re-encoded.</p>`
741
+ : `${codeEditor('f-params', d.params, { rows: 9 })}
742
+ ${
743
+ d.paramsMode === 'stringified'
744
+ ? `<p class="muted" style="margin:6px 0 0">Sent as
745
+ <code>?${esc(d.stringifyKey)}=</code> plus
746
+ <code>encodeURIComponent(JSON.stringify(params))</code>.</p>`
747
+ : ''
748
+ }`
749
+ }`
750
+ : ''
751
+ }
752
+
753
+ ${
754
+ d.tab === 'headers'
755
+ ? `${kvRows(d.headers, 'hdr')}
756
+ <button class="link" data-kv-add="hdr">+ add</button>
757
+ <p class="muted" style="margin:6px 0 0">
758
+ <code>${esc(state.auth.header)}</code> and <code>Content-Type</code> are added by the
759
+ server on send. Anything here is merged on top.
760
+ </p>`
761
+ : ''
762
+ }
763
+
764
+ ${
765
+ d.tab === 'body'
766
+ ? `<div class="editor-bar bodytypes">
767
+ ${[
768
+ ['none', 'none'],
769
+ ['json', 'JSON'],
770
+ ['formdata', 'multipart/form-data'],
771
+ ['urlencoded', 'x-www-form-urlencoded'],
772
+ ]
773
+ .map(
774
+ ([key, label]) => `<label class="radio ${d.bodyType === key ? 'on' : ''}">
775
+ <input type="radio" name="bodytype" value="${key}"
776
+ ${d.bodyType === key ? 'checked' : ''} /> ${label}</label>`,
777
+ )
778
+ .join('')}
779
+ ${d.bodyType === 'json' ? '<button class="link" data-fmt="data">format</button>' : ''}
780
+ <span class="err" id="err-data"></span>
781
+ </div>
782
+ ${
783
+ d.bodyType === 'none'
784
+ ? '<p class="muted">No body is sent.</p>'
785
+ : d.bodyType === 'json'
786
+ ? codeEditor('f-data', d.data, { rows: 14 })
787
+ : `${kvRows(bodyRows(d), 'body')}
788
+ <button class="link" data-kv-add="body">+ add</button>
789
+ <p class="muted" style="margin:6px 0 0">
790
+ Sent as ${esc(bodyLabel(d.bodyType))}, field by field &mdash; the same way your
791
+ app sent it.
792
+ </p>`
793
+ }`
794
+ : ''
795
+ }
796
+
797
+ ${
798
+ d.tab === 'capture'
799
+ ? `<div id="capture-rows">
800
+ ${(d.capture ?? [])
801
+ .map(
802
+ (c, i) => `<div class="row" data-cap="${i}">
803
+ <input data-cap-name="${i}" value="${esc(c.name)}" placeholder="variable name" size="14" />
804
+ <span class="muted">=</span>
805
+ <input data-cap-path="${i}" value="${esc(c.path)}" placeholder="data.0.id" />
806
+ <button class="link" data-cap-del="${i}">&times;</button>
807
+ </div>`,
808
+ )
809
+ .join('')}
810
+ </div>
811
+ <button class="link" id="cap-add">+ add</button>
812
+ <p class="muted" style="margin:6px 0 0">
813
+ After a passing response, the value at that path is stored as <code>{{name}}</code> for later
814
+ calls. Dotted paths walk arrays: <code>data.0.id</code>.
815
+ </p>`
816
+ : ''
817
+ }
818
+ </div>
819
+
820
+ <div class="row">
821
+ <button class="primary" id="send">Send</button>
822
+ ${sendsBody ? '<span class="muted">this writes to the real API on the selected environment</span>' : ''}
823
+ <span class="spacer"></span>
824
+ ${state.samples[ep.id] ? '<button id="reset-sample">Reset to captured</button>' : ''}
825
+ <button id="copy-curl">Copy as cURL</button>
826
+ </div>
827
+
828
+ <div id="response"></div>`;
829
+
830
+ wireCrumbs();
831
+ for (const el of document.querySelectorAll('[data-hole]')) {
832
+ el.oninput = () => {
833
+ d.holes[el.dataset.hole] = el.value;
834
+ touchDraft(d);
835
+ updatePreview(ep, d);
836
+ };
837
+ }
838
+ for (const which of ['params', 'data']) {
839
+ const ta = $(`#f-${which}`);
840
+ if (!ta) continue;
841
+ // fromUser: rendering the pane must not mark the draft as edited, or a live
842
+ // refill would think it is about to overwrite your work
843
+ const validate = (fromUser) => {
844
+ d[which] = ta.value;
845
+ if (fromUser) touchDraft(d);
846
+ const err = $(`#err-${which}`);
847
+ try {
848
+ JSON.parse(ta.value || '{}');
849
+ ta.classList.remove('invalid');
850
+ err.textContent = '';
851
+ } catch (e) {
852
+ ta.classList.add('invalid');
853
+ err.textContent = e.message;
854
+ }
855
+ if (which === 'params') updatePreview(ep, d);
856
+ };
857
+ ta.oninput = () => validate(true);
858
+ validate(false);
859
+ wireEditor(ta);
860
+ }
861
+ const rawTa = $('#f-rawquery');
862
+ if (rawTa) {
863
+ rawTa.oninput = () => {
864
+ d.rawQuery = rawTa.value;
865
+ touchDraft(d);
866
+ updatePreview(ep, d);
867
+ };
868
+ }
869
+ const keyIn = $('#f-stringify-key');
870
+ if (keyIn) {
871
+ keyIn.oninput = () => {
872
+ d.stringifyKey = keyIn.value;
873
+ touchDraft(d);
874
+ updatePreview(ep, d);
875
+ };
876
+ }
877
+ for (const btn of document.querySelectorAll('[data-pmode]')) {
878
+ btn.onclick = () => {
879
+ const next = btn.dataset.pmode;
880
+ if (next === d.paramsMode) return;
881
+ // json and stringified share the same editor, so only raw needs converting
882
+ if (next !== 'raw' && d.paramsMode !== 'raw') {
883
+ d.paramsMode = next;
884
+ touchDraft(d);
885
+ renderDetail();
886
+ return;
887
+ }
888
+ // carry what is already typed across, so switching never loses work
889
+ if (next === 'raw') {
890
+ try {
891
+ d.rawQuery = toQueryString(JSON.parse(d.params || '{}'));
892
+ } catch {
893
+ toast('params are not valid JSON, starting the query string empty');
894
+ d.rawQuery = '';
895
+ }
896
+ } else {
897
+ d.params = JSON.stringify(fromQueryString(d.rawQuery), null, 2);
898
+ }
899
+ d.paramsMode = next;
900
+ touchDraft(d);
901
+ renderDetail();
902
+ };
903
+ }
904
+ for (const btn of document.querySelectorAll('[data-fmt]')) {
905
+ btn.onclick = () => {
906
+ const ta = $(`#f-${btn.dataset.fmt}`);
907
+ try {
908
+ ta.value = JSON.stringify(JSON.parse(ta.value || '{}'), null, 2);
909
+ ta.dispatchEvent(new Event('input'));
910
+ } catch {
911
+ toast('not valid JSON, cannot format');
912
+ }
913
+ };
914
+ }
915
+
916
+ for (const btn of document.querySelectorAll('[data-reqtab]')) {
917
+ btn.onclick = () => {
918
+ d.tab = btn.dataset.reqtab;
919
+ touchDraft(d);
920
+ renderDetail();
921
+ };
922
+ }
923
+
924
+ for (const radio of document.querySelectorAll('input[name="bodytype"]')) {
925
+ radio.onchange = () => {
926
+ d.bodyType = radio.value;
927
+ touchDraft(d);
928
+ renderDetail();
929
+ };
930
+ }
931
+
932
+ // key/value tables: headers, and the body when it is form-data or urlencoded
933
+ const rowsFor = (kind) => (kind === 'hdr' ? d.headers : bodyRows(d));
934
+ const writeBack = (kind, rows) => {
935
+ if (kind === 'hdr') {
936
+ d.headers = rows;
937
+ } else {
938
+ d.data = JSON.stringify(
939
+ Object.fromEntries(rows.filter((r) => r.on && r.k).map((r) => [r.k, r.v])),
940
+ null,
941
+ 2,
942
+ );
943
+ }
944
+ touchDraft(d);
945
+ };
946
+
947
+ for (const el of document.querySelectorAll('[data-kv-k], [data-kv-v], [data-kv-on]')) {
948
+ const [kind, idx] = (el.dataset.kvK ?? el.dataset.kvV ?? el.dataset.kvOn).split(':');
949
+ const i = Number(idx);
950
+ const field = el.dataset.kvK !== undefined ? 'k' : el.dataset.kvV !== undefined ? 'v' : 'on';
951
+ const handler = () => {
952
+ const rows = rowsFor(kind);
953
+ rows[i][field] = field === 'on' ? el.checked : el.value;
954
+ writeBack(kind, rows);
955
+ // a body row rename would rebuild the table under the cursor, so only redraw on toggle
956
+ if (field === 'on') renderDetail();
957
+ };
958
+ if (field === 'on') el.onchange = handler;
959
+ else el.oninput = handler;
960
+ }
961
+ for (const el of document.querySelectorAll('[data-kv-del]')) {
962
+ el.onclick = () => {
963
+ const [kind, idx] = el.dataset.kvDel.split(':');
964
+ const rows = rowsFor(kind);
965
+ rows.splice(Number(idx), 1);
966
+ writeBack(kind, rows);
967
+ renderDetail();
968
+ };
969
+ }
970
+ for (const el of document.querySelectorAll('[data-kv-add]')) {
971
+ el.onclick = () => {
972
+ const kind = el.dataset.kvAdd;
973
+ const rows = [...rowsFor(kind), { k: '', v: '', on: true }];
974
+ if (kind === 'hdr') d.headers = rows;
975
+ else {
976
+ // an empty key would vanish on write-back, so keep the row in the JSON
977
+ const obj = rows.filter((r) => r.k).reduce((a, r) => ({ ...a, [r.k]: r.v }), {});
978
+ obj[''] = '';
979
+ d.data = JSON.stringify(obj, null, 2);
980
+ }
981
+ touchDraft(d);
982
+ renderDetail();
983
+ };
984
+ }
985
+
986
+ const capAdd = $('#cap-add');
987
+ if (capAdd) {
988
+ capAdd.onclick = () => {
989
+ d.capture = [...(d.capture ?? []), { name: '', path: '' }];
990
+ touchDraft(d);
991
+ renderDetail();
992
+ };
993
+ }
994
+ for (const el of document.querySelectorAll('[data-cap-name], [data-cap-path]')) {
995
+ el.oninput = () => {
996
+ const i = Number(el.dataset.capName ?? el.dataset.capPath);
997
+ d.capture[i][el.dataset.capName !== undefined ? 'name' : 'path'] = el.value;
998
+ touchDraft(d);
999
+ };
1000
+ }
1001
+ for (const el of document.querySelectorAll('[data-cap-del]')) {
1002
+ el.onclick = () => {
1003
+ d.capture.splice(Number(el.dataset.capDel), 1);
1004
+ touchDraft(d);
1005
+ renderDetail();
1006
+ };
1007
+ }
1008
+
1009
+ const acceptBtn = $('#accept-contract');
1010
+ if (acceptBtn) {
1011
+ acceptBtn.onclick = async () => {
1012
+ try {
1013
+ // the server kept the drifted shape, so nothing needs computing here
1014
+ const out = await post('/api/contracts', { id: ep.id });
1015
+ state.contracts = out.contracts;
1016
+ state.results = out.results;
1017
+ renderTree();
1018
+ renderDetail();
1019
+ toast('baseline updated');
1020
+ } catch (e) {
1021
+ toast(`${e.message} - send it once, or let the app call it again`);
1022
+ }
1023
+ };
1024
+ }
1025
+
1026
+ $('#send').onclick = () => send(ep, d);
1027
+ const resetBtn = $('#reset-sample');
1028
+ if (resetBtn) {
1029
+ resetBtn.onclick = () => {
1030
+ state.drafts.delete(ep.id);
1031
+ touchDraft();
1032
+ renderDetail();
1033
+ toast('restored the captured request');
1034
+ };
1035
+ }
1036
+ $('#copy-curl').onclick = () => copyCurl(ep, d);
1037
+ updatePreview(ep, d);
1038
+ renderResponse(ep);
1039
+ }
1040
+
1041
+ function updatePreview(ep, d) {
1042
+ const el = $('#url-preview');
1043
+ if (el) el.textContent = previewUrl(ep, d);
1044
+ }
1045
+
1046
+ /* -------------------------------------------------------------------- send */
1047
+
1048
+ function payload(ep, d) {
1049
+ const parse = (s) => {
1050
+ try {
1051
+ return JSON.parse(s || '{}');
1052
+ } catch {
1053
+ return {};
1054
+ }
1055
+ };
1056
+ return {
1057
+ id: ep.id,
1058
+ holes: d.holes,
1059
+ params: d.paramsMode !== 'raw' ? parse(d.params) : {},
1060
+ rawQuery: d.paramsMode === 'raw' ? d.rawQuery : '',
1061
+ stringifyKey: d.paramsMode === 'stringified' ? d.stringifyKey : '',
1062
+ bodyType: d.bodyType,
1063
+ headers: Object.fromEntries((d.headers ?? []).filter((h) => h.on && h.k).map((h) => [h.k, h.v])),
1064
+ capture: Object.fromEntries((d.capture ?? []).filter((c) => c.name && c.path).map((c) => [c.name, c.path])),
1065
+ data: d.bodyType === 'none' ? {} : parse(d.data),
1066
+ };
1067
+ }
1068
+
1069
+ async function send(ep, d) {
1070
+ const btn = $('#send');
1071
+ btn.disabled = true;
1072
+ btn.textContent = 'Sending';
1073
+ try {
1074
+ state.response = await post('/api/call', payload(ep, d));
1075
+ state.results[ep.id] = state.response;
1076
+ } catch (e) {
1077
+ state.response = { ok: false, status: null, error: e.message, ms: 0 };
1078
+ }
1079
+ btn.disabled = false;
1080
+ btn.textContent = 'Send';
1081
+ renderTree();
1082
+ renderDetail();
1083
+ }
1084
+
1085
+ async function runBatch(ids, label) {
1086
+ if (state.busy) return;
1087
+ state.busy = true;
1088
+ toast(`running ${label}...`);
1089
+ try {
1090
+ const out = await post('/api/run', { ids });
1091
+ state.results = out.results;
1092
+ renderTree();
1093
+ if (state.selected) renderDetail();
1094
+ else if (state.openModule) renderModule(state.openModule);
1095
+ const failed = Object.values(state.results).filter((r) => !r.ok).length;
1096
+ toast(
1097
+ `ran ${out.ran}${out.skipped ? `, skipped ${out.skipped} write endpoints` : ''} - ${failed} failing` +
1098
+ (out.unfilled ? ` - ${out.unfilled} ran with a blank path value, so treat those loosely` : ''),
1099
+ );
1100
+ } catch (e) {
1101
+ toast(e.message);
1102
+ }
1103
+ state.busy = false;
1104
+ }
1105
+
1106
+ function copyCurl(ep, d) {
1107
+ const p = payload(ep, d);
1108
+ const q = (s) => `'${String(s).replaceAll("'", `'\\''`)}'`;
1109
+ const parts = [`curl -X ${ep.method} ${q(previewUrl(ep, d))}`, ` -H 'Content-Type: application/json'`];
1110
+ if (!ep.absolute) parts.push(` -H '${state.auth.header}: <your token>'`);
1111
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(ep.method)) {
1112
+ parts.push(` -d ${q(JSON.stringify(p.data))}`);
1113
+ }
1114
+ navigator.clipboard.writeText(parts.join(' \\\n'));
1115
+ toast('cURL copied (token left as a placeholder)');
1116
+ }
1117
+
1118
+ /* ---------------------------------------------------------------- response */
1119
+
1120
+ function renderResponse(ep) {
1121
+ const el = $('#response');
1122
+ const stored = state.results[ep.id]?.failure;
1123
+
1124
+ // nothing sent from here yet, but the last run kept the failure worth reading
1125
+ if (!state.response && stored) {
1126
+ el.innerHTML = `
1127
+ <div class="status">
1128
+ <span class="code fail">${stored.status ?? 'failed'}</span>
1129
+ ${stored.innerCode ? `<span class="badge fail">body code ${stored.innerCode}</span>` : ''}
1130
+ <span class="muted">${
1131
+ stored.from === 'replay' ? 'from the last replay' : `from the last ${esc(stored.from)}`
1132
+ }, ${new Date(stored.at).toLocaleString()}</span>
1133
+ <span class="spacer"></span>
1134
+ <button class="link" id="copy-stored">copy</button>
1135
+ </div>
1136
+ <p class="muted mono" style="margin:0 0 8px;word-break:break-all">${esc(stored.url ?? '')}</p>
1137
+ <pre class="body">${
1138
+ stored.error ? esc(stored.error) : highlightJson(prettyJson(stored.body ?? ''))
1139
+ }</pre>`;
1140
+ $('#copy-stored').onclick = () => {
1141
+ navigator.clipboard.writeText(stored.body ?? stored.error ?? '');
1142
+ toast('copied');
1143
+ };
1144
+ return;
1145
+ }
1146
+
1147
+ const r = state.response;
1148
+ if (!r) {
1149
+ el.innerHTML = '';
1150
+ return;
1151
+ }
1152
+ if (r.error) {
1153
+ el.innerHTML = `<div class="status"><span class="code fail">failed</span>
1154
+ <span>${esc(r.error)}</span><span class="muted">${r.ms} ms</span></div>`;
1155
+ return;
1156
+ }
1157
+
1158
+ const pretty = prettyJson(r.body);
1159
+ const view = state.tab === 'headers' ? Object.entries(r.headers ?? {}).map(([k, v]) => `${k}: ${v}`).join('\n') : pretty;
1160
+
1161
+ const captured = Object.entries(r.captured ?? {});
1162
+ el.innerHTML = `
1163
+ ${
1164
+ captured.length
1165
+ ? `<div class="sub">${captured
1166
+ .map(([name, c]) =>
1167
+ c.error
1168
+ ? `<span class="badge fail" title="${esc(c.path)}">${esc(name)}: ${esc(c.error)}</span>`
1169
+ : `<span class="badge pass">${esc(name)} = ${esc(c.value)}</span>`,
1170
+ )
1171
+ .join('')}</div>`
1172
+ : ''
1173
+ }
1174
+ <div class="status">
1175
+ <span class="code ${r.ok ? 'pass' : 'fail'}">${r.status}</span>
1176
+ ${r.innerCode ? `<span class="badge ${r.ok ? '' : 'fail'}">body code ${r.innerCode}</span>` : ''}
1177
+ <span class="muted">${r.ms} ms</span>
1178
+ <span class="muted">${(r.size / 1024).toFixed(1)} kB</span>
1179
+ <span class="spacer"></span>
1180
+ <div class="tabs">
1181
+ ${['body', 'headers']
1182
+ .map((t) => `<button data-tab="${t}" class="${state.tab === t ? 'on' : ''}">${t}</button>`)
1183
+ .join('')}
1184
+ <button id="copy-res">copy</button>
1185
+ </div>
1186
+ </div>
1187
+ <pre class="body">${state.tab === 'headers' ? esc(view) : highlightJson(view)}</pre>`;
1188
+
1189
+ for (const b of el.querySelectorAll('[data-tab]')) {
1190
+ b.onclick = () => {
1191
+ state.tab = b.dataset.tab;
1192
+ renderResponse(ep);
1193
+ };
1194
+ }
1195
+ $('#copy-res').onclick = () => {
1196
+ navigator.clipboard.writeText(view);
1197
+ toast('copied');
1198
+ };
1199
+ }
1200
+
1201
+ /* ------------------------------------------------------------------- token */
1202
+
1203
+ function renderTokenButton() {
1204
+ const held = state.tokensHeld.includes(state.auth.header);
1205
+ const btn = $('#token-btn');
1206
+ const owner = state.tokenOwner;
1207
+ btn.textContent = held
1208
+ ? owner?.name
1209
+ ? `signed in: ${owner.name.split(' ')[0]}`
1210
+ : `${state.auth.header} set`
1211
+ : `Set ${state.auth.header}`;
1212
+ btn.title = held && owner
1213
+ ? `token belongs to ${owner.name} (${owner.email}), signed in ${new Date(owner.at).toLocaleString()} - everyone here replays as this user`
1214
+ : '';
1215
+ btn.style.borderColor = held ? 'var(--pass)' : 'var(--warn)';
1216
+ btn.style.color = held ? 'var(--pass)' : 'var(--warn)';
1217
+ }
1218
+
1219
+ /*
1220
+ * Signing in, driven entirely by what the server says the flow is.
1221
+ *
1222
+ * The console has no idea whether the API wants an email and a password, a
1223
+ * one-time code, an organisation picked from a list, or all three. It asks for
1224
+ * the current step, renders whatever fields that step declares, posts them
1225
+ * back, and repeats until the server says it holds a token. A project with no
1226
+ * sign-in flow configured gets the paste-a-token form instead.
1227
+ */
1228
+ const loginState = { stage: null, fields: [], choices: null, title: null, user: null, busy: false };
1229
+
1230
+ /** a field descriptor from the server -> one labelled input */
1231
+ function loginField(f) {
1232
+ const id = `login-f-${f.name}`;
1233
+ if (f.options || f.type === 'select') {
1234
+ return `<label>${esc(f.label ?? f.name)}
1235
+ <select id="${id}" data-field="${esc(f.name)}">
1236
+ ${(f.options ?? []).map((o) => `<option value="${esc(o.value ?? o.id)}">${esc(o.label ?? o.name)}</option>`).join('')}
1237
+ </select></label>`;
1238
+ }
1239
+ return `<label>${esc(f.label ?? f.name)}
1240
+ <input id="${id}" data-field="${esc(f.name)}" type="${esc(f.type ?? 'text')}"
1241
+ ${f.type === 'password' ? 'autocomplete="current-password"' : ''}
1242
+ ${f.inputmode ? `inputmode="${esc(f.inputmode)}"` : ''}
1243
+ placeholder="${esc(f.placeholder ?? '')}" /></label>`;
1244
+ }
1245
+
1246
+ function renderLoginStep() {
1247
+ const el = $('#login-step');
1248
+ if (!el) return;
1249
+ const held = state.tokensHeld.includes(state.auth.header);
1250
+
1251
+ if (held && !loginState.stage) {
1252
+ const who = loginState.user ?? state.tokenOwner;
1253
+ el.innerHTML = `<div class="report">
1254
+ <b class="pass">Signed in.</b>
1255
+ ${
1256
+ who?.name
1257
+ ? `${esc(who.name)} &middot; <span class="muted">${esc(who.email ?? '')}</span>`
1258
+ : `A ${esc(state.auth.header)} is held.`
1259
+ }
1260
+ <p class="muted" style="margin:6px 0 0">
1261
+ Everyone using this console replays with this session.
1262
+ </p>
1263
+ <p class="muted" style="margin:6px 0 0">
1264
+ Kept owner-only in the console's data directory so a restart does not sign you out.
1265
+ <b>Sign out</b> deletes it.
1266
+ </p>
1267
+ </div>`;
1268
+ return;
1269
+ }
1270
+
1271
+ if (!state.login?.supported) {
1272
+ el.innerHTML = `<p class="muted">This console has no sign-in flow configured.
1273
+ Paste a token below instead.</p>`;
1274
+ return;
1275
+ }
1276
+
1277
+ const fields = loginState.fields.length ? loginState.fields : state.login.fields ?? [];
1278
+ el.innerHTML = `
1279
+ <div class="login-form">
1280
+ ${loginState.title ? `<p class="muted">${esc(loginState.title)}</p>` : ''}
1281
+ ${fields.map(loginField).join('')}
1282
+ <div class="row end">
1283
+ <span class="err" id="login-err"></span>
1284
+ <span class="spacer"></span>
1285
+ <button class="primary" id="login-go">Continue</button>
1286
+ </div>
1287
+ </div>`;
1288
+
1289
+ $('#login-go').onclick = submitLoginStep;
1290
+ for (const input of el.querySelectorAll('input')) {
1291
+ // must not return the comparison: a falsy return from onkeydown cancels the key
1292
+ input.onkeydown = (e) => {
1293
+ if (e.key === 'Enter') submitLoginStep();
1294
+ };
1295
+ }
1296
+ el.querySelector('input, select')?.focus();
1297
+ }
1298
+
1299
+ const loginError = (msg) => {
1300
+ const el = $('#login-err');
1301
+ if (el) el.textContent = msg;
1302
+ };
1303
+
1304
+ async function submitLoginStep() {
1305
+ if (loginState.busy) return;
1306
+ loginState.busy = true;
1307
+ loginError('');
1308
+ try {
1309
+ const input = { stage: loginState.stage ?? state.login?.stage ?? null };
1310
+ for (const el of $('#login-step').querySelectorAll('[data-field]')) {
1311
+ input[el.dataset.field] = el.value.trim ? el.value.trim() : el.value;
1312
+ }
1313
+ const out = await post('/api/login', input);
1314
+
1315
+ if (out.done) {
1316
+ state.tokensHeld = out.tokensHeld;
1317
+ loginState.user = out.user;
1318
+ loginState.stage = null;
1319
+ loginState.fields = [];
1320
+ renderTokenButton();
1321
+ renderLoginStep();
1322
+ toast(`signed in${out.user?.name ? ` as ${out.user.name}` : ''}`);
1323
+ } else {
1324
+ loginState.stage = out.stage;
1325
+ loginState.title = out.title;
1326
+ loginState.fields = out.fields ?? [];
1327
+ renderLoginStep();
1328
+ }
1329
+ } catch (e) {
1330
+ loginError(e.message);
1331
+ }
1332
+ loginState.busy = false;
1333
+ }
1334
+
1335
+ /** a second showModal while another dialog is open traps focus in the first one */
1336
+ function closeOtherDialogs(keep) {
1337
+ for (const d of document.querySelectorAll('dialog[open]')) {
1338
+ if (d.id !== keep) d.close();
1339
+ }
1340
+ }
1341
+
1342
+ function openTokenDialog() {
1343
+ closeOtherDialogs('token-dialog');
1344
+ $('#hdr').textContent = state.auth.header;
1345
+ // only some projects can say where the live token is readable from
1346
+ const snippet = $('#snippet');
1347
+ snippet.textContent = state.auth.snippet ?? '';
1348
+ snippet.hidden = !state.auth.snippet;
1349
+ $('#copy-snippet').hidden = !state.auth.snippet;
1350
+
1351
+ loginState.stage = null;
1352
+ loginState.fields = state.login?.fields ?? [];
1353
+ loginState.title = state.login?.title ?? null;
1354
+ renderLoginStep();
1355
+ $('#token-dialog').showModal();
1356
+ }
1357
+
1358
+ /** pulls the export straight from the server so the file is built in one place */
1359
+ async function download(module, format) {
1360
+ const res = await fetchAt(`/api/export?module=${encodeURIComponent(module)}&format=${format}`);
1361
+ if (!res.ok) return toast((await res.json()).error ?? 'export failed');
1362
+
1363
+ const blob = await res.blob();
1364
+ const name = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')?.[1];
1365
+ const url = URL.createObjectURL(blob);
1366
+ const a = Object.assign(document.createElement('a'), {
1367
+ href: url,
1368
+ download: name ?? `${module || 'all'}.${format}`,
1369
+ });
1370
+ document.body.append(a);
1371
+ a.click();
1372
+ a.remove();
1373
+ URL.revokeObjectURL(url);
1374
+ toast(`downloaded ${name}`);
1375
+ }
1376
+
1377
+ /* -------------------------------------------------------------------- live */
1378
+
1379
+ let livePoll;
1380
+
1381
+ function renderLive() {
1382
+ const btn = $('#live-btn');
1383
+ btn.classList.toggle('on', state.live.on);
1384
+ btn.textContent = state.live.on ? `Live · ${state.live.count}` : 'Live';
1385
+ if (state.locks.recording) {
1386
+ // shared console: one person must not be able to stop everyone's recording
1387
+ btn.disabled = true;
1388
+ btn.title = `recording every developer's calls - ${state.live.count} captured, and it cannot be paused here`;
1389
+ return;
1390
+ }
1391
+ btn.disabled = false;
1392
+ btn.title = state.live.on
1393
+ ? `recording API calls from the running app - ${state.live.count} captured`
1394
+ : 'paused; click to record API calls from the running app';
1395
+ }
1396
+
1397
+ /**
1398
+ * Polls while recording. Only the tree and health bar are redrawn, never the open
1399
+ * editor -- refreshing that under someone mid-edit would throw their work away.
1400
+ */
1401
+ async function pollLive() {
1402
+ if (!state.live.on) return;
1403
+ try {
1404
+ const d = await api('/api/live');
1405
+ const changed = d.count !== state.live.count;
1406
+ const openBefore = state.selected ? state.samples[state.selected]?.seenAt : null;
1407
+
1408
+ state.live = { on: d.on, count: d.count, unmatched: d.unmatched ?? [] };
1409
+ state.samples = d.samples ?? {};
1410
+ state.results = d.results ?? {};
1411
+ renderLive();
1412
+ if (!changed) return;
1413
+
1414
+ for (const id of Object.keys(state.samples)) {
1415
+ if (id !== state.selected) state.drafts.delete(id);
1416
+ }
1417
+ saveDrafts();
1418
+ renderTree();
1419
+
1420
+ // the endpoint you are looking at was just called again by the app
1421
+ const openAfter = state.selected ? state.samples[state.selected]?.seenAt : null;
1422
+ if (openAfter && openAfter !== openBefore) refreshOpenEndpoint(openBefore);
1423
+ else if (!state.selected && state.openModule) renderModule(state.openModule);
1424
+ else if (!state.selected) renderEmpty();
1425
+ } catch {
1426
+ /* tracker restarting; the next tick will catch up */
1427
+ }
1428
+ }
1429
+
1430
+ /**
1431
+ * Refills the open endpoint from the call that just happened -- unless doing so
1432
+ * would throw away work: your own edits, or a field you are typing in right now.
1433
+ * In that case the new capture waits behind a button instead.
1434
+ */
1435
+ function refreshOpenEndpoint(previousSeenAt) {
1436
+ const draft = state.drafts.get(state.selected);
1437
+ const edited = Boolean(draft?.editedAt) && draft.editedAt > (previousSeenAt ?? '');
1438
+ const active = document.activeElement;
1439
+ const typing =
1440
+ active && $('#panel')?.contains(active) && /INPUT|TEXTAREA|SELECT/.test(active.tagName);
1441
+
1442
+ if (edited || typing) {
1443
+ showCaptureHint();
1444
+ return;
1445
+ }
1446
+ state.drafts.delete(state.selected);
1447
+ saveDrafts();
1448
+ renderDetail();
1449
+ toast('updated from the call your app just made');
1450
+ }
1451
+
1452
+ /** formats a response body when it is JSON, leaves it alone when it is not */
1453
+ function prettyJson(text) {
1454
+ try {
1455
+ return JSON.stringify(JSON.parse(text), null, 2);
1456
+ } catch {
1457
+ return text ?? '';
1458
+ }
1459
+ }
1460
+
1461
+ /** a non-destructive nudge when we cannot safely refill in place */
1462
+ function showCaptureHint() {
1463
+ const panel = $('#panel');
1464
+ if (!panel || $('#capture-hint')) return;
1465
+ const bar = document.createElement('div');
1466
+ bar.id = 'capture-hint';
1467
+ bar.className = 'hint';
1468
+ bar.innerHTML = `Your app just called this again.
1469
+ <button class="link" id="load-latest">load the new payload</button>
1470
+ <span class="muted">your current edits stay until you do</span>`;
1471
+ panel.prepend(bar);
1472
+ $('#load-latest').onclick = () => {
1473
+ state.drafts.delete(state.selected);
1474
+ saveDrafts();
1475
+ renderDetail();
1476
+ toast('loaded the latest call');
1477
+ };
1478
+ }
1479
+
1480
+ async function setLive(on) {
1481
+ state.live = { ...state.live, ...(await post('/api/live', { on })) };
1482
+ renderLive();
1483
+ clearInterval(livePoll);
1484
+ if (state.live.on) {
1485
+ livePoll = setInterval(pollLive, 2000);
1486
+ toast('recording - use the app at localhost:3000 and calls will appear here');
1487
+ } else {
1488
+ toast('recording paused');
1489
+ }
1490
+ }
1491
+
1492
+ /* ------------------------------------------------------------------ import */
1493
+
1494
+ function renderImportReport(out) {
1495
+ const covered = Object.keys(out.samples ?? {}).length;
1496
+ $('#import-report').innerHTML = `
1497
+ <div class="report">
1498
+ <b class="${out.matched ? 'pass' : 'fail'}">${out.matched} request${out.matched === 1 ? '' : 's'} matched</b>
1499
+ &mdash; ${covered} of ${state.endpoints.length} endpoints now have a real sample.
1500
+ ${
1501
+ out.unmatched?.length
1502
+ ? `<div class="muted" style="margin-top:6px">${out.unmatched.length} API request${
1503
+ out.unmatched.length === 1 ? '' : 's'
1504
+ } matched no known endpoint:</div>
1505
+ <ul class="muted">${out.unmatched.slice(0, 20).map((u) => `<li>${esc(u)}</li>`).join('')}</ul>
1506
+ ${out.unmatched.length > 20 ? `<div class="muted">and ${out.unmatched.length - 20} more</div>` : ''}`
1507
+ : ''
1508
+ }
1509
+ </div>`;
1510
+ }
1511
+
1512
+ async function importTraffic(kind, text) {
1513
+ toast(kind === 'har' ? 'reading HAR...' : 'parsing cURL...');
1514
+ try {
1515
+ const out = await post('/api/import', { kind, text });
1516
+ state.samples = out.samples;
1517
+ // drafts were seeded from the old samples, so drop the untouched ones
1518
+ for (const id of Object.keys(out.samples)) state.drafts.delete(id);
1519
+ saveDrafts();
1520
+ renderImportReport(out);
1521
+ renderTree();
1522
+ if (state.selected) renderDetail();
1523
+ else if (state.openModule) renderModule(state.openModule);
1524
+ else renderEmpty();
1525
+ toast(`${out.matched} matched, ${Object.keys(out.samples).length} endpoints have samples`);
1526
+ } catch (e) {
1527
+ toast(e.message);
1528
+ }
1529
+ }
1530
+
1531
+ /* ------------------------------------------------------------------ report */
1532
+
1533
+ const card = (label, value, cls = '') =>
1534
+ `<div class="card"><b class="${cls}">${value}</b><span>${label}</span></div>`;
1535
+
1536
+ const idLinks = (ids, limit = 12) =>
1537
+ ids.length
1538
+ ? ids.slice(0, limit).map((i) => `<button class="link mono" data-goto="${esc(i)}">${esc(i)}</button>`).join(', ') +
1539
+ (ids.length > limit ? ` <span class="muted">and ${ids.length - limit} more</span>` : '')
1540
+ : '<span class="muted">none</span>';
1541
+
1542
+ async function renderReport() {
1543
+ state.selected = null;
1544
+ state.openModule = null;
1545
+ renderTree();
1546
+ $('#panel').innerHTML = '<p class="muted">building the report...</p>';
1547
+
1548
+ const r = await api('/api/report');
1549
+ const c = r.coverage;
1550
+ const h = r.health;
1551
+
1552
+ $('#panel').innerHTML = `
1553
+ <nav class="crumbs"><button class="link" data-home>Overview</button><span>/</span>
1554
+ <span class="here">report</span></nav>
1555
+ <h1>API report</h1>
1556
+ <div class="sub">
1557
+ <span class="badge">${esc(r.env)}</span>
1558
+ <span class="mono">${esc(r.baseUrl ?? '')}</span>
1559
+ <span class="muted">generated ${new Date(r.generatedAt).toLocaleString()}</span>
1560
+ <span class="spacer"></span>
1561
+ <button id="report-md">Export Markdown</button>
1562
+ <button id="report-json">Export JSON</button>
1563
+ </div>
1564
+
1565
+ <div class="cards">
1566
+ ${card('endpoints', c.total)}
1567
+ ${card('exercised', `${c.seen} <small>${c.pct}%</small>`, c.pct > 50 ? 'pass' : 'warn')}
1568
+ ${card('passing', h.passed, 'pass')}
1569
+ ${card('failing', h.failed, h.failed ? 'fail' : '')}
1570
+ ${card('regressions', h.regressions.length, h.regressions.length ? 'fail' : '')}
1571
+ ${card('contract drift', r.drift.length, r.drift.length ? 'warn' : '')}
1572
+ ${card('never exercised', c.total - c.seen, 'muted')}
1573
+ ${card('unused in app', r.hygiene.unused.length, r.hygiene.unused.length ? 'warn' : '')}
1574
+ </div>
1575
+
1576
+ ${section('Failing', h.failing.length, failingTable(h))}
1577
+ ${section('Contract drift', r.drift.length, driftList(r.drift))}
1578
+ ${section('Risks', null, risksBlock(r.risks))}
1579
+ ${section('Coverage by module', null, coverageTable(c))}
1580
+ ${section('Hygiene', null, hygieneBlock(r.hygiene))}
1581
+ ${section('Auth surface', null, authBlock(r.auth))}
1582
+ ${section('Latency', null, latencyBlock(r.performance))}
1583
+ ${section('Trend', r.trends.length, trendTable(r.trends))}
1584
+ ${section('Request and response inventory', r.inventory.length, inventoryTable(r.inventory), true)}`;
1585
+
1586
+ wireCrumbs();
1587
+ wireGotoRows();
1588
+ for (const b of document.querySelectorAll('#panel [data-goto]')) {
1589
+ b.onclick = () => select(b.dataset.goto);
1590
+ }
1591
+ $('#report-md').onclick = () => downloadReport('md');
1592
+ $('#report-json').onclick = () => downloadReport('json');
1593
+ }
1594
+
1595
+ const section = (title, count, body, collapsed = false) => `
1596
+ <details class="rsec" ${collapsed ? '' : 'open'}>
1597
+ <summary>${esc(title)}${count !== null && count !== undefined ? ` <b>${count}</b>` : ''}</summary>
1598
+ <div class="rsec-body">${body}</div>
1599
+ </details>`;
1600
+
1601
+ function failingTable(h) {
1602
+ if (!h.failing.length) return '<p class="muted">Nothing is failing.</p>';
1603
+ return `<table class="list"><thead><tr>
1604
+ <th>endpoint</th><th>method</th><th>status</th><th>body</th><th>message</th></tr></thead><tbody>
1605
+ ${h.failing
1606
+ .map(
1607
+ (f) => `<tr data-goto="${esc(f.id)}">
1608
+ <td class="mono">${esc(f.id)}
1609
+ ${f.regression ? '<span class="badge fail">regression</span>' : ''}
1610
+ ${f.failedInReplay ? '<span class="badge warn">replay</span>' : ''}</td>
1611
+ <td class="method m-${esc(f.method)}">${esc(f.method)}</td>
1612
+ <td class="fail">${esc(f.status ?? '—')}</td>
1613
+ <td>${esc(f.innerCode ?? '—')}</td>
1614
+ <td class="muted msg" title="${esc(f.message ?? '')}">${esc(
1615
+ (f.message ?? '').length > 150 ? `${f.message.slice(0, 150)}…` : f.message ?? '',
1616
+ )}</td>
1617
+ </tr>`,
1618
+ )
1619
+ .join('')}</tbody></table>`;
1620
+ }
1621
+
1622
+ const driftList = (drift) =>
1623
+ drift.length
1624
+ ? `<ul class="plain">${drift
1625
+ .map(
1626
+ (d) => `<li><button class="link mono" data-goto="${esc(d.id)}">${esc(d.id)}</button>
1627
+ <span class="mono warn">${esc(d.summary)}</span>
1628
+ <span class="muted">baseline ${d.baselineAt ? new Date(d.baselineAt).toLocaleDateString() : '—'}</span></li>`,
1629
+ )
1630
+ .join('')}</ul>`
1631
+ : '<p class="muted">No response shapes have changed.</p>';
1632
+
1633
+ const risksBlock = (k) => `
1634
+ <dl class="rlist">
1635
+ <dt>Writes never exercised <b>${k.untestedWrites.length}</b></dt><dd>${idLinks(k.untestedWrites)}</dd>
1636
+ <dt>Captured DELETEs <b>${k.capturedDeletes.length}</b></dt><dd>${idLinks(k.capturedDeletes)}</dd>
1637
+ <dt>2xx carrying an error code <b>${k.envelopeErrors.length}</b></dt>
1638
+ <dd>${idLinks(k.envelopeErrors.map((e) => e.id))}</dd>
1639
+ <dt>Payloads with stripped secrets <b>${k.redactedPayloads.length}</b></dt>
1640
+ <dd>${idLinks(k.redactedPayloads)}</dd>
1641
+ </dl>`;
1642
+
1643
+ const coverageTable = (c) => `
1644
+ <table class="list"><thead><tr><th>module</th><th>exercised</th><th>total</th><th></th></tr></thead><tbody>
1645
+ ${c.modules
1646
+ .map(
1647
+ (m) => `<tr><td>${esc(m.module)}</td><td>${m.seen}</td><td>${m.total}</td>
1648
+ <td><div class="bar" style="width:120px"><i class="p" style="width:${m.pct}%"></i></div></td></tr>`,
1649
+ )
1650
+ .join('')}</tbody></table>`;
1651
+
1652
+ const hygieneBlock = (g) => `
1653
+ <dl class="rlist">
1654
+ <dt>Referenced nowhere <b>${g.unused.length}</b></dt><dd>${idLinks(g.unused.map((u) => u.id), 20)}</dd>
1655
+ <dt>Duplicate routes <b>${g.duplicates.length}</b></dt>
1656
+ <dd>${
1657
+ g.duplicates.length
1658
+ ? `<ul class="plain">${g.duplicates
1659
+ .map((d) => `<li><span class="mono">${esc(d.route)}</span> &mdash; ${idLinks(d.ids)}</li>`)
1660
+ .join('')}</ul>`
1661
+ : '<span class="muted">none</span>'
1662
+ }</dd>
1663
+ <dt>Called but not found in the source <b>${g.uncatalogued.length}</b></dt>
1664
+ <dd>${idLinks(g.uncatalogued.map((u) => u.id))}</dd>
1665
+ </dl>`;
1666
+
1667
+ const authBlock = (a) => `
1668
+ <dl class="rlist">
1669
+ <dt>Signed ACCESS_TOKEN <b>${a.accessToken.length}</b></dt><dd>${idLinks(a.accessToken)}</dd>
1670
+ <dt>SECRET_TOKEN <b>${a.secretToken.length}</b></dt><dd>${idLinks(a.secretToken)}</dd>
1671
+ <dt>CS token <b>${a.csToken.length}</b></dt><dd>${idLinks(a.csToken)}</dd>
1672
+ <dt>Third-party, no token sent <b>${a.thirdParty.length}</b></dt><dd>${idLinks(a.thirdParty)}</dd>
1673
+ <dt>Standard ${esc(state.auth.header)} <b>${a.standard}</b></dt><dd class="muted">everything else</dd>
1674
+ </dl>`;
1675
+
1676
+ const latencyBlock = (p) => `
1677
+ <p>median <b>${p.median}ms</b> &middot; p95 <b>${p.p95}ms</b>
1678
+ <span class="muted">over ${p.samples} endpoints &mdash; ${esc(p.caveat)}</span></p>
1679
+ <ul class="plain">${p.slowest
1680
+ .map((s) => `<li><b>${s.ms}ms</b> <button class="link mono" data-goto="${esc(s.id)}">${esc(s.id)}</button></li>`)
1681
+ .join('')}</ul>`;
1682
+
1683
+ const trendTable = (t) =>
1684
+ t.length
1685
+ ? `<table class="list"><thead><tr><th>when</th><th>ran</th><th>passed</th><th>failed</th>
1686
+ <th>pass rate</th><th>coverage</th></tr></thead><tbody>
1687
+ ${t
1688
+ .map(
1689
+ (x) => `<tr><td>${new Date(x.at).toLocaleString()}</td><td>${x.ran}</td>
1690
+ <td class="pass">${x.passed}</td><td class="fail">${x.failed}</td>
1691
+ <td>${x.passRate}%</td><td>${x.coverage ?? '—'}%</td></tr>`,
1692
+ )
1693
+ .join('')}</tbody></table>`
1694
+ : '<p class="muted">No runs recorded yet. Every replay from now on adds a row here.</p>';
1695
+
1696
+ const inventoryTable = (inv) => `
1697
+ <table class="list"><thead><tr><th>endpoint</th><th>params</th><th>body</th><th>response</th></tr></thead>
1698
+ <tbody>${inv
1699
+ .map(
1700
+ (i) => `<tr data-goto="${esc(i.id)}">
1701
+ <td class="mono">${esc(i.method)} ${esc(i.path)}</td>
1702
+ <td class="muted">${esc(i.params.join(', ') || '—')}</td>
1703
+ <td class="muted">${esc(i.body.join(', ') || '—')}</td>
1704
+ <td class="muted">${esc(i.response.slice(0, 6).join(', ') || '—')}${
1705
+ i.response.length > 6 ? ` +${i.response.length - 6}` : ''
1706
+ }</td>
1707
+ </tr>`,
1708
+ )
1709
+ .join('')}</tbody></table>`;
1710
+
1711
+ async function downloadReport(format) {
1712
+ const res = await fetchAt(`/api/report/export?format=${format}`);
1713
+ const blob = await res.blob();
1714
+ const name = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')?.[1];
1715
+ const url = URL.createObjectURL(blob);
1716
+ const a = Object.assign(document.createElement('a'), { href: url, download: name ?? `report.${format}` });
1717
+ document.body.append(a);
1718
+ a.click();
1719
+ a.remove();
1720
+ URL.revokeObjectURL(url);
1721
+ toast(`downloaded ${name}`);
1722
+ }
1723
+
1724
+ /* ------------------------------------------------------------------ replay */
1725
+
1726
+ const capturedIds = () => state.endpoints.filter((e) => state.samples[e.id]).map((e) => e.id);
1727
+
1728
+ const replayOptions = () => ({
1729
+ allowMutating: true,
1730
+ includeDeletes: $('#opt-deletes')?.checked ?? false,
1731
+ includeLogout: $('#opt-logout')?.checked ?? false,
1732
+ includeAccessToken: $('#opt-access')?.checked ?? false,
1733
+ });
1734
+
1735
+ /** shows exactly what is about to happen before anything is sent */
1736
+ async function renderReplayPlan() {
1737
+ const plan = await post('/api/run/preview', { ids: capturedIds(), ...replayOptions() });
1738
+ const s = plan.skippedDetail;
1739
+ const list = (label, ids) =>
1740
+ ids.length
1741
+ ? `<li>${label}: ${ids.map((i) => `<code>${esc(i)}</code>`).join(', ')}</li>`
1742
+ : '';
1743
+
1744
+ const writes = Object.entries(plan.byMethod)
1745
+ .filter(([m]) => m !== 'GET')
1746
+ .map(([m, n]) => `${n} ${m}`)
1747
+ .join(', ');
1748
+
1749
+ $('#replay-plan').innerHTML = `
1750
+ <div class="report">
1751
+ <b>${plan.total}</b> call${plan.total === 1 ? '' : 's'} against
1752
+ <b class="${plan.env === 'prod' ? 'fail' : 'warn'}">${esc(plan.env)}</b>
1753
+ <span class="muted mono">${esc(plan.baseUrl)}</span>
1754
+ <div style="margin-top:6px">
1755
+ ${Object.entries(plan.byMethod)
1756
+ .map(([m, n]) => `<span class="badge m-${esc(m)}">${n} ${esc(m)}</span>`)
1757
+ .join(' ')}
1758
+ </div>
1759
+ ${
1760
+ writes
1761
+ ? `<p class="warn" style="margin:8px 0 0">${esc(writes)} will change real data.</p>`
1762
+ : '<p class="muted" style="margin:8px 0 0">Read-only: nothing will be modified.</p>'
1763
+ }
1764
+ ${
1765
+ s.deletes.length || s.logout.length || s.redacted.length || s.accessToken.length
1766
+ ? `<ul class="muted" style="margin:8px 0 0">
1767
+ ${list('DELETE, left out', s.deletes)}
1768
+ ${list('logout, left out', s.logout)}
1769
+ ${list('needs ACCESS_TOKEN, left out', s.accessToken)}
1770
+ ${list('payload has a stripped secret, cannot be replayed', s.redacted)}
1771
+ </ul>`
1772
+ : ''
1773
+ }
1774
+ </div>`;
1775
+
1776
+ const go = $('#replay-go');
1777
+ go.disabled = plan.env === 'prod' || !plan.total;
1778
+ go.textContent = plan.env === 'prod' ? 'Not allowed on prod' : `Run ${plan.total}`;
1779
+ }
1780
+
1781
+ async function openReplayDialog() {
1782
+ closeOtherDialogs('replay-dialog');
1783
+ for (const id of ['#opt-deletes', '#opt-logout', '#opt-access']) $(id).disabled = false;
1784
+ $('#replay-close').textContent = 'Cancel';
1785
+ $('#replay-close').onclick = () => $('#replay-dialog').close();
1786
+ $('#replay-go').disabled = false;
1787
+ $('#replay-dialog').showModal();
1788
+ $('#replay-plan').innerHTML = '<p class="muted">working out what would run...</p>';
1789
+ await renderReplayPlan();
1790
+ }
1791
+
1792
+ /** the rows a run produced, clickable through to each endpoint */
1793
+ function ranList(rows) {
1794
+ return `<ul class="ran">
1795
+ ${rows
1796
+ .map(
1797
+ (r) => `<li>
1798
+ <button class="ran-row ${r.ok ? 'pass' : 'fail'}" data-goto="${esc(r.id)}"
1799
+ title="open ${esc(r.id)}">
1800
+ <span class="method m-${esc(r.method)}">${esc(r.method)}</span>
1801
+ <span class="mono">${esc(r.id)}</span>
1802
+ <span class="spacer"></span>
1803
+ <span class="outcome">${esc(
1804
+ r.error ?? `${r.status}${r.innerCode ? ` · ${r.innerCode}` : ''}`,
1805
+ )}</span>
1806
+ <span class="muted">${r.ms}ms</span>
1807
+ <span class="go">&rsaquo;</span>
1808
+ </button>
1809
+ </li>`,
1810
+ )
1811
+ .join('')}
1812
+ </ul>`;
1813
+ }
1814
+
1815
+ /** rows jump to their endpoint; the dialog closes itself if it is open */
1816
+ function wireGotoRows() {
1817
+ for (const btn of document.querySelectorAll('[data-goto]')) {
1818
+ btn.onclick = () => {
1819
+ $('#replay-dialog').close();
1820
+ select(btn.dataset.goto);
1821
+ };
1822
+ }
1823
+ }
1824
+
1825
+ /** live progress, polled while the run is in flight */
1826
+ function renderProgress(run) {
1827
+ const pct = run.total ? Math.round((run.done / run.total) * 100) : 0;
1828
+ const recent = [...run.finished].reverse().slice(0, 8);
1829
+
1830
+ $('#replay-plan').innerHTML = `
1831
+ <div class="progress">
1832
+ <div class="progress-head">
1833
+ <b>${run.done} of ${run.total}</b>
1834
+ <span class="pass">${run.passed} passed</span>
1835
+ <span class="fail">${run.failed} failed</span>
1836
+ <span class="spacer"></span>
1837
+ <span class="muted">${pct}%</span>
1838
+ </div>
1839
+ <div class="bar">
1840
+ <i class="p" style="width:${run.total ? (run.passed / run.total) * 100 : 0}%"></i>
1841
+ <i class="f" style="width:${run.total ? (run.failed / run.total) * 100 : 0}%"></i>
1842
+ </div>
1843
+ <div class="progress-now">
1844
+ ${
1845
+ run.current
1846
+ ? `<span class="spin"></span>
1847
+ <span class="method m-${esc(run.current.method)}">${esc(run.current.method)}</span>
1848
+ <span class="mono">${esc(run.current.id)}</span>`
1849
+ : `<span class="muted">${run.active ? 'starting...' : 'finished'}</span>`
1850
+ }
1851
+ </div>
1852
+ ${ranList(recent)}
1853
+ </div>`;
1854
+
1855
+ wireGotoRows();
1856
+ }
1857
+
1858
+ async function runReplay() {
1859
+ if (state.busy) return;
1860
+ const opts = replayOptions();
1861
+ state.busy = true;
1862
+
1863
+ // the dialog stays open and turns into a progress view
1864
+ for (const id of ['#opt-deletes', '#opt-logout', '#opt-access']) $(id).disabled = true;
1865
+ $('#replay-go').disabled = true;
1866
+ $('#replay-go').textContent = 'Running';
1867
+ $('#replay-close').textContent = 'Stop';
1868
+ $('#replay-close').onclick = async () => {
1869
+ $('#replay-close').textContent = 'Stopping';
1870
+ await post('/api/run/cancel', {});
1871
+ };
1872
+
1873
+ const poll = setInterval(async () => {
1874
+ try {
1875
+ const run = await api('/api/run/status');
1876
+ if (run.total) renderProgress(run);
1877
+ } catch {
1878
+ /* the next tick will catch up */
1879
+ }
1880
+ }, 350);
1881
+
1882
+ try {
1883
+ const out = await post('/api/run', { ids: capturedIds(), ...opts });
1884
+ clearInterval(poll);
1885
+ state.results = out.results;
1886
+
1887
+ const run = await api('/api/run/status');
1888
+ state.lastRun = run;
1889
+ renderProgress(run);
1890
+ renderTree();
1891
+
1892
+ // hand the controls back: the run is over, so the primary button runs it again
1893
+ for (const id of ['#opt-deletes', '#opt-logout', '#opt-access']) $(id).disabled = false;
1894
+ $('#replay-go').disabled = false;
1895
+ $('#replay-go').textContent = 'Run again';
1896
+ $('#replay-close').textContent = 'Close';
1897
+ $('#replay-close').onclick = () => {
1898
+ $('#replay-dialog').close();
1899
+ renderEmpty();
1900
+ };
1901
+ toast(
1902
+ `${out.cancelled ? 'stopped after ' : 'ran '}${out.ran}, ${out.failed} failing` +
1903
+ (out.skipped ? `, ${out.skipped} left out` : ''),
1904
+ );
1905
+ } catch (e) {
1906
+ clearInterval(poll);
1907
+ $('#replay-dialog').close();
1908
+ toast(e.message);
1909
+ }
1910
+ state.busy = false;
1911
+ }
1912
+
1913
+ /* --------------------------------------------------------------- variables */
1914
+
1915
+ function renderVars() {
1916
+ const entries = Object.entries(state.vars);
1917
+ $('#vars-list').innerHTML = entries.length
1918
+ ? entries
1919
+ .map(
1920
+ ([k, v]) => `<div class="row" data-var="${esc(k)}">
1921
+ <code style="min-width:120px">{{${esc(k)}}}</code>
1922
+ <input data-var-value="${esc(k)}" value="${esc(v)}" />
1923
+ <button class="link" data-var-del="${esc(k)}">&times;</button>
1924
+ </div>`,
1925
+ )
1926
+ .join('')
1927
+ : '<p class="muted">none yet</p>';
1928
+
1929
+ for (const el of document.querySelectorAll('[data-var-value]')) {
1930
+ el.onchange = () => setVar(el.dataset.varValue, el.value);
1931
+ }
1932
+ for (const el of document.querySelectorAll('[data-var-del]')) {
1933
+ el.onclick = () => setVar(el.dataset.varDel, '');
1934
+ }
1935
+ }
1936
+
1937
+ async function setVar(name, value) {
1938
+ state.vars = (await post('/api/variables', { name, value })).vars;
1939
+ renderVars();
1940
+ }
1941
+
1942
+ /* -------------------------------------------------------------------- boot */
1943
+
1944
+ function select(id) {
1945
+ state.selected = id;
1946
+ state.response = null;
1947
+ const ep = state.endpoints.find((e) => e.id === id);
1948
+ state.openModule = ep.module;
1949
+ renderTree();
1950
+ renderDetail();
1951
+ }
1952
+
1953
+ /** back to the overview, from anywhere */
1954
+ function goHome() {
1955
+ state.selected = null;
1956
+ state.openModule = null;
1957
+ state.response = null;
1958
+ renderTree();
1959
+ renderEmpty();
1960
+ }
1961
+
1962
+ /** where you are, and the way back */
1963
+ function crumbs(module, endpoint) {
1964
+ return `<nav class="crumbs">
1965
+ <button class="link" data-home>Overview</button>
1966
+ <span>/</span>
1967
+ ${
1968
+ endpoint
1969
+ ? `<button class="link" data-module-crumb="${esc(module)}">${esc(module)}</button>
1970
+ <span>/</span><span class="here">${esc(endpoint)}</span>`
1971
+ : `<span class="here">${esc(module)}</span>`
1972
+ }
1973
+ </nav>`;
1974
+ }
1975
+
1976
+ /** wires whichever crumbs are on screen */
1977
+ function wireCrumbs() {
1978
+ const home = $('[data-home]');
1979
+ if (home) home.onclick = goHome;
1980
+ const mod = $('[data-module-crumb]');
1981
+ if (mod) {
1982
+ mod.onclick = () => {
1983
+ state.selected = null;
1984
+ state.openModule = mod.dataset.moduleCrumb;
1985
+ renderTree();
1986
+ renderModule(state.openModule);
1987
+ };
1988
+ }
1989
+ }
1990
+
1991
+ function renderEmpty() {
1992
+ $('#panel').innerHTML = `
1993
+ <div class="empty">
1994
+ <h2>${state.endpoints.length} endpoints across ${new Set(state.endpoints.map((e) => e.module)).size} modules</h2>
1995
+ <p>Read straight out of your source. Pick a module on the left, or check
1996
+ everything at once.</p>
1997
+ <p><button class="primary" id="run-all">Run every GET endpoint</button>
1998
+ ${Object.keys(state.results).length ? '<button id="clear-results">Clear results</button>' : ''}
1999
+ ${
2000
+ Object.keys(state.samples).length
2001
+ ? '<button id="replay-all">Replay every captured call</button>'
2002
+ : ''
2003
+ }</p>
2004
+ <p>
2005
+ ${
2006
+ Object.keys(state.samples).length
2007
+ ? `<button id="export-all-postman">Export Postman collection</button>
2008
+ <button id="export-all-har">Export all captured (HAR)</button>
2009
+ <button id="clear-results-2" hidden></button>`
2010
+ : ''
2011
+ }</p>
2012
+ ${lastRunPanel()}
2013
+ ${coveragePanel()}
2014
+ ${
2015
+ state.endpoints.filter((e) => e.usedIn === 0).length
2016
+ ? `<p class="muted">${state.endpoints.filter((e) => e.usedIn === 0).length} endpoints are
2017
+ referenced nowhere outside their own service file &mdash;
2018
+ <button class="link" id="show-unused">show them</button></p>`
2019
+ : ''
2020
+ }
2021
+ <p class="muted">Set your ${esc(state.auth.header)} first, or everything will come back
2022
+ unauthorised.</p>
2023
+ </div>`;
2024
+ $('#run-all').onclick = () => runBatch(state.endpoints.map((e) => e.id), 'all GET endpoints');
2025
+ const unusedBtn = $('#show-unused');
2026
+ if (unusedBtn) {
2027
+ unusedBtn.onclick = () => {
2028
+ $('#filter-status').value = 'unused';
2029
+ renderTree();
2030
+ };
2031
+ }
2032
+ const jump = (filter, id) => {
2033
+ const btn = $(id);
2034
+ if (btn) {
2035
+ btn.onclick = () => {
2036
+ $('#filter-status').value = filter;
2037
+ renderTree();
2038
+ toast(`filtered to ${filter}`);
2039
+ };
2040
+ }
2041
+ };
2042
+ jump('uncaptured', '#show-uncaptured');
2043
+ jump('uncatalogued', '#show-uncatalogued');
2044
+ jump('drift', '#show-drift');
2045
+
2046
+ const replayBtn = $('#replay-all');
2047
+ if (replayBtn) replayBtn.onclick = openReplayDialog;
2048
+ wireGotoRows();
2049
+ const filterBtn = $('#lastrun-filter');
2050
+ if (filterBtn) {
2051
+ filterBtn.onclick = () => {
2052
+ state.lastRunOnlyFailed = !state.lastRunOnlyFailed;
2053
+ renderEmpty();
2054
+ };
2055
+ }
2056
+ const againBtn = $('#lastrun-again');
2057
+ if (againBtn) againBtn.onclick = openReplayDialog;
2058
+ const exportAll = $('#export-all-har');
2059
+ if (exportAll) exportAll.onclick = () => download('', 'har');
2060
+ const exportPostman = $('#export-all-postman');
2061
+ if (exportPostman) exportPostman.onclick = () => download('', 'postman');
2062
+ const clearBtn = $('#clear-results');
2063
+ if (clearBtn) {
2064
+ clearBtn.onclick = async () => {
2065
+ state.results = (await api('/api/results', { method: 'DELETE' })).results;
2066
+ renderTree();
2067
+ renderEmpty();
2068
+ toast('results cleared');
2069
+ };
2070
+ }
2071
+ }
2072
+
2073
+ async function load() {
2074
+ state.drafts = loadDrafts();
2075
+ const d = await api('/api/endpoints');
2076
+ Object.assign(state, {
2077
+ endpoints: d.endpoints,
2078
+ baseUrls: d.baseUrls,
2079
+ auth: d.auth,
2080
+ env: d.env,
2081
+ results: d.results ?? {},
2082
+ tokensHeld: d.tokensHeld ?? [],
2083
+ vars: d.vars ?? {},
2084
+ samples: d.samples ?? {},
2085
+ live: d.live ?? state.live,
2086
+ contracts: d.contracts ?? {},
2087
+ lastRun: d.lastRun ?? null,
2088
+ locks: d.locks ?? state.locks,
2089
+ login: d.login ?? state.login,
2090
+ name: d.name ?? state.name,
2091
+ tokenOwner: d.tokenOwner ?? null,
2092
+ });
2093
+
2094
+ // a console recording live traffic before its first scan has no scan time
2095
+ $('#scanned').textContent = d.scannedAt
2096
+ ? `scanned ${new Date(d.scannedAt).toLocaleString()}`
2097
+ : 'not scanned yet';
2098
+ document.title = `${state.name} console`;
2099
+ // the console is named after the API it is pointed at, not after itself
2100
+ $('#console-name').textContent = state.name === 'API' ? 'API console' : `${state.name} console`;
2101
+ $('#env').innerHTML = Object.entries(d.baseUrls)
2102
+ .map(([k, v]) => `<option value="${esc(k)}" ${k === d.env ? 'selected' : ''} title="${esc(v)}">${esc(k)}</option>`)
2103
+ .join('');
2104
+ $('#filter-method').innerHTML =
2105
+ '<option value="">any method</option>' +
2106
+ [...new Set(d.endpoints.map((e) => e.method))].sort().map((m) => `<option>${m}</option>`).join('');
2107
+
2108
+ if (state.locks.env) {
2109
+ const sel = $('#env');
2110
+ sel.disabled = true;
2111
+ sel.title = `this console is pinned to ${state.locks.env}`;
2112
+ }
2113
+ renderTokenButton();
2114
+ renderLive();
2115
+ if (state.live.on) livePoll = setInterval(pollLive, 2000);
2116
+ renderTree();
2117
+ if (state.selected) renderDetail();
2118
+ else if (state.openModule) renderModule(state.openModule);
2119
+ else renderEmpty();
2120
+ }
2121
+
2122
+ $('#tree').onclick = (e) => {
2123
+ const mod = e.target.closest('.module');
2124
+ if (mod) {
2125
+ const name = mod.dataset.module;
2126
+ state.openModule = state.openModule === name ? null : name;
2127
+ state.selected = null;
2128
+ renderTree();
2129
+ if (state.openModule) renderModule(state.openModule);
2130
+ else renderEmpty();
2131
+ return;
2132
+ }
2133
+ const row = e.target.closest('.endpoint');
2134
+ if (row) select(row.dataset.id);
2135
+ };
2136
+
2137
+ for (const id of ['#search', '#filter-method', '#filter-status']) $(id).oninput = renderTree;
2138
+
2139
+ $('#env').onchange = async (e) => {
2140
+ await post('/api/env', { env: e.target.value });
2141
+ state.env = e.target.value;
2142
+ // results from another environment would be misleading
2143
+ state.results = {};
2144
+ renderTree();
2145
+ if (state.selected) renderDetail();
2146
+ toast(`switched to ${state.env} (${state.baseUrls[state.env]})`);
2147
+ };
2148
+
2149
+ $('#live-btn').onclick = () => setLive(!state.live.on);
2150
+
2151
+ $('#import-btn').onclick = () => {
2152
+ closeOtherDialogs('import-dialog');
2153
+ $('#import-report').innerHTML = '';
2154
+ $('#import-dialog').showModal();
2155
+ };
2156
+ $('#import-close').onclick = () => $('#import-dialog').close();
2157
+ $('#curl-import').onclick = () => {
2158
+ const text = $('#curl-input').value.trim();
2159
+ if (!text) return toast('paste a cURL command first');
2160
+ importTraffic('curl', text);
2161
+ };
2162
+ $('#samples-clear').onclick = async () => {
2163
+ state.samples = (await api('/api/samples', { method: 'DELETE' })).samples;
2164
+ state.drafts.clear();
2165
+ saveDrafts();
2166
+ renderTree();
2167
+ renderEmpty();
2168
+ $('#import-report').innerHTML = '';
2169
+ toast('samples cleared');
2170
+ };
2171
+
2172
+ // drop a HAR anywhere on the page
2173
+ let dragDepth = 0;
2174
+ document.addEventListener('dragenter', (e) => {
2175
+ e.preventDefault();
2176
+ if (++dragDepth === 1) $('#drop').classList.add('on');
2177
+ });
2178
+ document.addEventListener('dragover', (e) => e.preventDefault());
2179
+ document.addEventListener('dragleave', () => {
2180
+ if (--dragDepth <= 0) {
2181
+ dragDepth = 0;
2182
+ $('#drop').classList.remove('on');
2183
+ }
2184
+ });
2185
+ document.addEventListener('drop', async (e) => {
2186
+ e.preventDefault();
2187
+ dragDepth = 0;
2188
+ $('#drop').classList.remove('on');
2189
+ const file = e.dataTransfer?.files?.[0];
2190
+ if (!file) return;
2191
+ $('#import-dialog').open || $('#import-dialog').showModal();
2192
+ importTraffic('har', await file.text());
2193
+ });
2194
+
2195
+ $('#replay-close').onclick = () => $('#replay-dialog').close();
2196
+ $('#replay-go').onclick = runReplay;
2197
+ for (const id of ['#opt-deletes', '#opt-logout', '#opt-access']) $(id).onchange = renderReplayPlan;
2198
+
2199
+ $('#vars-btn').onclick = () => {
2200
+ closeOtherDialogs('vars-dialog');
2201
+ renderVars();
2202
+ $('#vars-dialog').showModal();
2203
+ };
2204
+ $('#vars-close').onclick = () => $('#vars-dialog').close();
2205
+ $('#var-add').onclick = async () => {
2206
+ const name = $('#var-name').value.trim();
2207
+ if (!name) return toast('name required');
2208
+ await setVar(name, $('#var-value').value);
2209
+ $('#var-name').value = '';
2210
+ $('#var-value').value = '';
2211
+ };
2212
+
2213
+ $('#home-btn').onclick = goHome;
2214
+ $('#report-btn').onclick = renderReport;
2215
+
2216
+ // Escape backs out: endpoint -> module -> overview
2217
+ document.addEventListener('keydown', (e) => {
2218
+ if (e.key !== 'Escape' || document.querySelector('dialog[open]')) return;
2219
+ const active = document.activeElement;
2220
+ if (active && /INPUT|TEXTAREA|SELECT/.test(active.tagName)) return;
2221
+ if (state.selected) {
2222
+ state.selected = null;
2223
+ renderTree();
2224
+ renderModule(state.openModule);
2225
+ } else if (state.openModule) {
2226
+ goHome();
2227
+ }
2228
+ });
2229
+
2230
+ $('#token-btn').onclick = openTokenDialog;
2231
+ $('#copy-snippet').onclick = () => {
2232
+ navigator.clipboard.writeText(state.auth.snippet);
2233
+ toast('snippet copied - run it in the app tab DevTools console');
2234
+ };
2235
+ $('#token-save').onclick = async () => {
2236
+ const token = $('#token-input').value.trim();
2237
+ if (!token) return toast('nothing pasted');
2238
+ state.tokensHeld = (await post('/api/token', { token })).tokensHeld;
2239
+ $('#token-input').value = '';
2240
+ loginState.stage = 'done';
2241
+ renderTokenButton();
2242
+ renderLoginStep();
2243
+ toast('token stored in server memory');
2244
+ };
2245
+ $('#token-close').onclick = () => $('#token-dialog').close();
2246
+ $('#token-clear').onclick = async () => {
2247
+ state.tokensHeld = (await post('/api/token', { token: '' })).tokensHeld;
2248
+ $('#token-input').value = '';
2249
+ loginState.stage = 'credentials';
2250
+ loginState.user = null;
2251
+ renderTokenButton();
2252
+ renderLoginStep();
2253
+ toast('signed out, token cleared');
2254
+ };
2255
+
2256
+ document.addEventListener('keydown', (e) => {
2257
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') $('#send')?.click();
2258
+ });
2259
+
2260
+ load();