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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +466 -0
- package/cli/bin/api-tracer.mjs +266 -0
- package/cli/config.mjs +224 -0
- package/cli/import.mjs +231 -0
- package/cli/index.mjs +10 -0
- package/cli/presets.mjs +212 -0
- package/cli/report.mjs +346 -0
- package/cli/scan.mjs +142 -0
- package/cli/server.mjs +1576 -0
- package/cli/shape.mjs +90 -0
- package/cli/test.mjs +342 -0
- package/cli/web/app.css +1424 -0
- package/cli/web/app.js +2260 -0
- package/cli/web/favicon.svg +5 -0
- package/cli/web/index.html +159 -0
- package/cli/web/logo.svg +7 -0
- package/dist/axios.cjs +856 -0
- package/dist/axios.cjs.map +1 -0
- package/dist/axios.d.cts +27 -0
- package/dist/axios.d.ts +27 -0
- package/dist/axios.js +853 -0
- package/dist/axios.js.map +1 -0
- package/dist/index.cjs +872 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +74 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +857 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +896 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +22 -0
- package/dist/react.d.ts +22 -0
- package/dist/react.js +893 -0
- package/dist/react.js.map +1 -0
- package/dist/tracer-BUWdU2lG.d.ts +76 -0
- package/dist/tracer-DG2YUqK0.d.cts +76 -0
- package/dist/types-Bl2-K6_g.d.cts +111 -0
- package/dist/types-Bl2-K6_g.d.ts +111 -0
- package/dist/ui.cjs +1162 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.d.cts +16 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +1157 -0
- package/dist/ui.js.map +1 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}">×</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">●</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 ? '▾' : '▸'} ${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 · <b class="fail">${fail}</b> fail ·
|
|
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 ? ` · <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 —
|
|
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 — 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 — 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`) : '—'}
|
|
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
|
+
} — <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 — 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 — 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}">×</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)} · <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
|
+
— ${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> — ${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> · p95 <b>${p.p95}ms</b>
|
|
1678
|
+
<span class="muted">over ${p.samples} endpoints — ${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">›</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)}">×</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 —
|
|
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();
|