pyhttp-app 2.0.1__py3-none-any.whl
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.
- pyhttp/__init__.py +9 -0
- pyhttp/__main__.py +222 -0
- pyhttp/app.py +1333 -0
- pyhttp/assertions.py +289 -0
- pyhttp/cli.py +364 -0
- pyhttp/console.py +82 -0
- pyhttp/curl.py +151 -0
- pyhttp/db.py +1483 -0
- pyhttp/jsengine.py +224 -0
- pyhttp/jsonc.py +54 -0
- pyhttp/mcp.py +316 -0
- pyhttp/mock.py +204 -0
- pyhttp/monitors.py +116 -0
- pyhttp/oauth2.py +153 -0
- pyhttp/openapi.py +365 -0
- pyhttp/pm.js +496 -0
- pyhttp/postman.py +412 -0
- pyhttp/runner.py +1089 -0
- pyhttp/scripts.py +352 -0
- pyhttp/static/app.js +2739 -0
- pyhttp/static/codegen.js +134 -0
- pyhttp/static/editor.js +421 -0
- pyhttp/static/features.css +630 -0
- pyhttp/static/features.js +2396 -0
- pyhttp/static/index.html +372 -0
- pyhttp/static/logo.svg +13 -0
- pyhttp/static/style.css +585 -0
- pyhttp/static/vendor/codemirror.js +28 -0
- pyhttp/static/vendor/fonts/Inter-OFL.txt +93 -0
- pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
- pyhttp/static/viewer.js +434 -0
- pyhttp/templating.py +155 -0
- pyhttp/updater.py +387 -0
- pyhttp_app-2.0.1.dist-info/METADATA +654 -0
- pyhttp_app-2.0.1.dist-info/RECORD +40 -0
- pyhttp_app-2.0.1.dist-info/WHEEL +5 -0
- pyhttp_app-2.0.1.dist-info/entry_points.txt +2 -0
- pyhttp_app-2.0.1.dist-info/licenses/LICENSE.md +121 -0
- pyhttp_app-2.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2396 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Postman-style features layered on app.js: auth, tests/extract/pre-request variables,
|
|
3
|
+
// body modes (form, multipart, binary, GraphQL), response panels, request tabs,
|
|
4
|
+
// history, cookies, code snippets, import/export (Postman, cURL), settings,
|
|
5
|
+
// drag-to-reorder, shortcuts, Markdown docs and the update check.
|
|
6
|
+
|
|
7
|
+
// ================================================================= auth tab
|
|
8
|
+
const AUTH_LABELS = { inherit: 'Inherit from folder', none: 'No auth', basic: 'Basic auth', bearer: 'Bearer token', apikey: 'API key', digest: 'Digest auth', oauth2: 'OAuth 2.0' };
|
|
9
|
+
// every auth field: input id is 'auth-' + key (add_to uses 'auth-addto')
|
|
10
|
+
const AUTH_KEYS = ['username', 'password', 'token', 'prefix', 'key', 'value', 'add_to', 'grant', 'token_url', 'client_id', 'client_secret', 'scope', 'audience', 'client_auth'];
|
|
11
|
+
const authInputId = (k) => 'auth-' + (k === 'add_to' ? 'addto' : k);
|
|
12
|
+
// the auth fields shown in `box`; fields of other types keep their previous values from `prev`
|
|
13
|
+
function readAuthFields(box, type, prev) {
|
|
14
|
+
const out = { type };
|
|
15
|
+
AUTH_KEYS.forEach((k) => {
|
|
16
|
+
const el = $('#' + authInputId(k), box);
|
|
17
|
+
if (el) out[k] = el.value;
|
|
18
|
+
else if (prev && prev[k] !== undefined) out[k] = prev[k];
|
|
19
|
+
});
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
let inheritedInfo = null; // db.inherited() for the current request's folder
|
|
23
|
+
|
|
24
|
+
function authFields(a, inheritedNote) {
|
|
25
|
+
const t = a.type || 'inherit';
|
|
26
|
+
const f = (label, id, value, ph = '', type = 'text') => `<label class="field"><span class="fl">${label}</span><input id="${id}" class="grow mono" type="${type}" value="${esc(value || '')}" placeholder="${esc(ph)}" spellcheck="false" autocomplete="off"></label>`;
|
|
27
|
+
if (t === 'basic' || t === 'digest') return f('Username', 'auth-username', a.username, '{{user}}') + f('Password', 'auth-password', a.password, '', 'password') + '<p class="muted small">Sent as an <code>Authorization</code> header' + (t === 'digest' ? ' after the server\'s challenge (Digest)' : ' (Basic, base64)') + '. Variables work in both fields.</p>';
|
|
28
|
+
if (t === 'bearer') return f('Token', 'auth-token', a.token, '{{token}}') + f('Prefix', 'auth-prefix', a.prefix === undefined ? 'Bearer' : a.prefix, 'Bearer') + '<p class="muted small">Sent as <code>Authorization: <prefix> <token></code>. Clear the prefix to send the bare token.</p>';
|
|
29
|
+
if (t === 'apikey') return f('Key', 'auth-key', a.key, 'X-API-Key') + f('Value', 'auth-value', a.value, '{{api_key}}') + `<label class="field"><span class="fl">Add to</span><select id="auth-addto"><option value="header" ${a.add_to !== 'query' ? 'selected' : ''}>Header</option><option value="query" ${a.add_to === 'query' ? 'selected' : ''}>Query params</option></select></label>`;
|
|
30
|
+
if (t === 'oauth2') {
|
|
31
|
+
const pw = a.grant === 'password';
|
|
32
|
+
const sel = (label, id, value, opts) => `<label class="field"><span class="fl">${label}</span><select id="${id}">${opts.map(([v, l]) => `<option value="${v}" ${value === v ? 'selected' : ''}>${l}</option>`).join('')}</select></label>`;
|
|
33
|
+
return sel('Grant type', 'auth-grant', a.grant || 'client_credentials', [['client_credentials', 'Client credentials'], ['password', 'Password (resource owner)']]) +
|
|
34
|
+
f('Token URL', 'auth-token_url', a.token_url, 'https://auth.example.com/oauth/token') +
|
|
35
|
+
f('Client ID', 'auth-client_id', a.client_id, '{{client_id}}') +
|
|
36
|
+
f('Client secret', 'auth-client_secret', a.client_secret, '{{client_secret}}', 'password') +
|
|
37
|
+
`<div class="oa-pw" ${pw ? '' : 'hidden'}>` + f('Username', 'auth-username', a.username, '{{username}}') + f('Password', 'auth-password', a.password, '', 'password') + '</div>' +
|
|
38
|
+
f('Scope', 'auth-scope', a.scope, 'read write') +
|
|
39
|
+
f('Audience', 'auth-audience', a.audience, 'optional') +
|
|
40
|
+
sel('Client auth', 'auth-client_auth', a.client_auth || 'basic', [['basic', 'Basic auth header'], ['body', 'In the request body']]) +
|
|
41
|
+
f('Header prefix', 'auth-prefix', a.prefix === undefined ? 'Bearer' : a.prefix, 'Bearer') +
|
|
42
|
+
`<div class="oa-token"><span class="oa-status muted small">Token: checking…</span><span class="grow"></span>
|
|
43
|
+
<button type="button" class="oa-get small primary">Get new token</button><button type="button" class="oa-clear small link">Clear</button></div>
|
|
44
|
+
<p class="muted small">A token is fetched on the first send and reused until it expires (a refresh token is used when the server gives one). After a <code>401</code> a new token is fetched once and the request retried. Variables work in every field; mark secrets as <em>secret</em> in the environment so they are masked.</p>`;
|
|
45
|
+
}
|
|
46
|
+
if (t === 'none') return '<p class="muted small">No authorization is added. Folder auth is not inherited.</p>';
|
|
47
|
+
return `<p class="muted small">${inheritedNote}</p>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- variable dropdown for auth fields: focus the Token (or any auth field) and pick one
|
|
51
|
+
// of the active environment's, the folder's or the global variables; it is inserted as {{name}}.
|
|
52
|
+
const varSuggest = { el: null, input: null, items: [], cur: 0, globals: null, globalsAt: 0 };
|
|
53
|
+
async function suggestVariables() {
|
|
54
|
+
if (!varSuggest.globals || Date.now() - varSuggest.globalsAt > 5000) {
|
|
55
|
+
try { varSuggest.globals = await api('/variables'); varSuggest.globalsAt = Date.now(); } catch { varSuggest.globals = varSuggest.globals || {}; }
|
|
56
|
+
}
|
|
57
|
+
const out = new Map();
|
|
58
|
+
const add = (key, value, source, secret) => { if (key && !out.has(key)) out.set(key, { key, value: value == null ? '' : String(value), source, secret: !!secret }); };
|
|
59
|
+
const env = (state.envs || []).find((e) => e.id === state.activeEnvId);
|
|
60
|
+
if (env) (env.variables || []).forEach((v) => { if (v.enabled !== false) add((v.key || '').trim(), v.value, env.name, v.secret); });
|
|
61
|
+
if (inheritedInfo && inheritedInfo.variables) Object.entries(inheritedInfo.variables).forEach(([k, v]) => add(k, v, 'folder'));
|
|
62
|
+
Object.entries(varSuggest.globals || {}).forEach(([k, v]) => add(k, v, 'global'));
|
|
63
|
+
return [...out.values()];
|
|
64
|
+
}
|
|
65
|
+
function closeVarSuggest() {
|
|
66
|
+
if (varSuggest.el) varSuggest.el.remove();
|
|
67
|
+
varSuggest.el = null; varSuggest.input = null; varSuggest.items = [];
|
|
68
|
+
}
|
|
69
|
+
// the "{{partial" being typed at the caret, if any
|
|
70
|
+
function varQueryAt(input) {
|
|
71
|
+
let caret = input.value.length;
|
|
72
|
+
try { if (input.selectionStart != null) caret = input.selectionStart; } catch { /* password inputs */ }
|
|
73
|
+
const before = input.value.slice(0, caret);
|
|
74
|
+
const m = /\{\{([\w.-]*)$/.exec(before);
|
|
75
|
+
return m ? { q: m[1], start: before.length - m[0].length, caret } : null;
|
|
76
|
+
}
|
|
77
|
+
function pickVarSuggest(item) {
|
|
78
|
+
const input = varSuggest.input;
|
|
79
|
+
if (!input || !item) return;
|
|
80
|
+
const at = varQueryAt(input);
|
|
81
|
+
const ins = `{{${item.key}}}`;
|
|
82
|
+
let next, pos;
|
|
83
|
+
if (at) { next = input.value.slice(0, at.start) + ins + input.value.slice(at.caret).replace(/^[\w.-]*\}{0,2}/, ''); pos = at.start + ins.length; }
|
|
84
|
+
else { next = ins; pos = ins.length; } // no {{ typed: the variable becomes the whole value
|
|
85
|
+
input.value = next;
|
|
86
|
+
closeVarSuggest();
|
|
87
|
+
input.focus();
|
|
88
|
+
try { input.setSelectionRange(pos, pos); } catch { /* password inputs */ }
|
|
89
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
90
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
91
|
+
}
|
|
92
|
+
async function openVarSuggest(input) {
|
|
93
|
+
const all = await suggestVariables();
|
|
94
|
+
if (document.activeElement !== input) return;
|
|
95
|
+
const at = varQueryAt(input);
|
|
96
|
+
// with a literal value typed (and no {{ in progress) stay out of the way; ArrowDown still opens it
|
|
97
|
+
const v = input.value.trim();
|
|
98
|
+
if (!at && v && !/^\{\{[\w.-]*\}\}$/.test(v) && !input._varForce) { closeVarSuggest(); return; }
|
|
99
|
+
input._varForce = false;
|
|
100
|
+
const q = (at ? at.q : '').toLowerCase();
|
|
101
|
+
const authy = (k) => /token|auth|key|secret|bearer|jwt/i.test(k) ? 1 : 0;
|
|
102
|
+
const items = all.filter((it) => !q || it.key.toLowerCase().includes(q))
|
|
103
|
+
.sort((a, b) => (b.key.toLowerCase().startsWith(q) - a.key.toLowerCase().startsWith(q)) || (authy(b.key) - authy(a.key)));
|
|
104
|
+
if (!varSuggest.el || varSuggest.input !== input) {
|
|
105
|
+
closeVarSuggest();
|
|
106
|
+
varSuggest.el = document.createElement('div');
|
|
107
|
+
varSuggest.el.className = 'menu var-suggest';
|
|
108
|
+
varSuggest.el.addEventListener('mousedown', (e) => e.preventDefault()); // keep focus in the input
|
|
109
|
+
document.body.appendChild(varSuggest.el);
|
|
110
|
+
varSuggest.input = input;
|
|
111
|
+
}
|
|
112
|
+
varSuggest.items = items.slice(0, 50);
|
|
113
|
+
varSuggest.cur = 0;
|
|
114
|
+
const preview = (it) => it.secret ? '••••••' : (it.value.length > 40 ? it.value.slice(0, 40) + '…' : it.value);
|
|
115
|
+
varSuggest.el.innerHTML = varSuggest.items.length
|
|
116
|
+
? varSuggest.items.map((it, i) => `<button type="button" data-i="${i}" class="${i === 0 ? 'cur' : ''}" title="Insert {{${esc(it.key)}}}"><span class="vs-k mono">{{${esc(it.key)}}}</span><span class="vs-v mono muted">${esc(preview(it))}</span><span class="vs-s muted small">${esc(it.source)}</span></button>`).join('')
|
|
117
|
+
: `<div class="vs-empty muted small">${all.length ? 'No variable matches' : 'No variables yet. Add them in an environment or in Global variables.'}</div>`;
|
|
118
|
+
$$('button', varSuggest.el).forEach((b) => b.addEventListener('click', () => pickVarSuggest(varSuggest.items[+b.dataset.i])));
|
|
119
|
+
const r = input.getBoundingClientRect();
|
|
120
|
+
Object.assign(varSuggest.el.style, { left: r.left + 'px', top: (r.bottom + 2) + 'px', width: Math.max(r.width, 280) + 'px' });
|
|
121
|
+
varSuggest.el.style.maxHeight = Math.max(120, Math.min(260, window.innerHeight - r.bottom - 8)) + 'px';
|
|
122
|
+
}
|
|
123
|
+
function moveVarSuggest(d) {
|
|
124
|
+
const btns = $$('button', varSuggest.el);
|
|
125
|
+
if (!btns.length) return;
|
|
126
|
+
varSuggest.cur = (varSuggest.cur + d + btns.length) % btns.length;
|
|
127
|
+
btns.forEach((b, i) => b.classList.toggle('cur', i === varSuggest.cur));
|
|
128
|
+
btns[varSuggest.cur].scrollIntoView({ block: 'nearest' });
|
|
129
|
+
}
|
|
130
|
+
const isVarSuggestInput = (el) => !!(el && el.tagName === 'INPUT' && el.id && el.id.startsWith('auth-') && el.id !== 'auth-prefix' && el.closest('#auth-fields'));
|
|
131
|
+
document.addEventListener('focusin', (e) => { if (isVarSuggestInput(e.target)) openVarSuggest(e.target); });
|
|
132
|
+
document.addEventListener('focusout', (e) => { if (e.target === varSuggest.input) setTimeout(() => { if (document.activeElement !== varSuggest.input) closeVarSuggest(); }, 0); });
|
|
133
|
+
document.addEventListener('input', (e) => { if (e.isTrusted && isVarSuggestInput(e.target)) openVarSuggest(e.target); });
|
|
134
|
+
document.addEventListener('keydown', (e) => {
|
|
135
|
+
if (!isVarSuggestInput(e.target)) return;
|
|
136
|
+
if (!varSuggest.el && e.key === 'ArrowDown') { e.preventDefault(); e.target._varForce = true; openVarSuggest(e.target); return; }
|
|
137
|
+
if (!varSuggest.el || varSuggest.input !== e.target) return;
|
|
138
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); moveVarSuggest(e.key === 'ArrowDown' ? 1 : -1); }
|
|
139
|
+
else if ((e.key === 'Enter' || e.key === 'Tab') && varSuggest.items.length) { e.preventDefault(); e.stopPropagation(); pickVarSuggest(varSuggest.items[varSuggest.cur]); }
|
|
140
|
+
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeVarSuggest(); }
|
|
141
|
+
}, true);
|
|
142
|
+
window.addEventListener('resize', closeVarSuggest);
|
|
143
|
+
document.addEventListener('scroll', (e) => { if (varSuggest.el && !varSuggest.el.contains(e.target)) closeVarSuggest(); }, true);
|
|
144
|
+
|
|
145
|
+
// ---- OAuth 2.0 token status / buttons (request Auth tab and folder settings)
|
|
146
|
+
function oauthContext(el) {
|
|
147
|
+
const fs = el.closest('#fs-auth');
|
|
148
|
+
if (fs) return { name: 'folder', method: 'GET', url: '', folder_id: fs.dataset.folder, auth: readAuthFields(fs, 'oauth2') };
|
|
149
|
+
if (!state.current) return null;
|
|
150
|
+
return { ...readEditor(), id: state.current.id, folder_id: state.current.folder_id || null, auth: readAuth() };
|
|
151
|
+
}
|
|
152
|
+
function fmtTokenStatus(st) {
|
|
153
|
+
if (!st || !st.has_token) return 'No token yet. One is fetched on the first send.';
|
|
154
|
+
const exp = st.expires_at ? new Date(st.expires_at * 1000) : null;
|
|
155
|
+
const left = exp ? Math.round((exp - Date.now()) / 60000) : null;
|
|
156
|
+
const when = exp ? (st.valid ? `expires in ${left < 1 ? '<1' : left} min` : 'expired') : 'no expiry given';
|
|
157
|
+
return `<span class="${st.valid ? 'ok-text' : 'err-text'}">●</span> <code>${esc(st.preview)}</code> · ${when}${st.scope ? ' · scope ' + esc(st.scope) : ''}${st.has_refresh ? ' · refreshable' : ''}`;
|
|
158
|
+
}
|
|
159
|
+
async function refreshOauthStatus(box) {
|
|
160
|
+
const status = box && $('.oa-status', box);
|
|
161
|
+
if (!status) return;
|
|
162
|
+
const ctx = oauthContext(status);
|
|
163
|
+
if (!ctx) { status.textContent = ''; return; }
|
|
164
|
+
try { status.innerHTML = fmtTokenStatus(await api('/oauth2/status', { method: 'POST', body: ctx })); }
|
|
165
|
+
catch (e) { status.textContent = e.message; }
|
|
166
|
+
}
|
|
167
|
+
document.addEventListener('change', (e) => {
|
|
168
|
+
if (e.target.id === 'auth-grant') {
|
|
169
|
+
const pw = e.target.closest('#auth-fields') && $('.oa-pw', e.target.closest('#auth-fields'));
|
|
170
|
+
if (pw) pw.hidden = e.target.value !== 'password';
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
document.addEventListener('click', async (e) => {
|
|
174
|
+
const get = e.target.closest('.oa-get');
|
|
175
|
+
const clear = e.target.closest('.oa-clear');
|
|
176
|
+
if (!get && !clear) return;
|
|
177
|
+
const box = e.target.closest('#auth-fields');
|
|
178
|
+
const ctx = oauthContext(e.target);
|
|
179
|
+
if (!ctx) return;
|
|
180
|
+
const status = $('.oa-status', box);
|
|
181
|
+
try {
|
|
182
|
+
if (get) {
|
|
183
|
+
get.disabled = true;
|
|
184
|
+
status.innerHTML = '<span class="spin"></span>Requesting a token…';
|
|
185
|
+
status.innerHTML = fmtTokenStatus(await api('/oauth2/token', { method: 'POST', body: ctx }));
|
|
186
|
+
toast('Got a new OAuth 2.0 token', 'ok');
|
|
187
|
+
} else {
|
|
188
|
+
await api('/oauth2/forget', { method: 'POST', body: ctx });
|
|
189
|
+
status.innerHTML = fmtTokenStatus(null);
|
|
190
|
+
}
|
|
191
|
+
} catch (err) { status.innerHTML = `<span class="err-text">${esc(err.message)}</span>`; }
|
|
192
|
+
finally { if (get) get.disabled = false; }
|
|
193
|
+
});
|
|
194
|
+
function renderAuthPanel(r) {
|
|
195
|
+
const box = $('#auth-panel');
|
|
196
|
+
const a = r.auth || { type: 'inherit' };
|
|
197
|
+
let note = 'This request uses the auth of its folder (or none). Set auth on a folder with its ⋯ menu → Settings.';
|
|
198
|
+
if (inheritedInfo && inheritedInfo.auth && inheritedInfo.auth.type !== 'none') note = `Inherits <strong>${esc(AUTH_LABELS[inheritedInfo.auth.type])}</strong> from folder <strong>${esc(inheritedInfo.auth_from || '')}</strong>.`;
|
|
199
|
+
else if (r.folder_id) note = 'No folder in the path sets auth, so nothing is added. Set auth on a folder with its ⋯ menu → Settings.';
|
|
200
|
+
box.innerHTML = `
|
|
201
|
+
<label class="field"><span class="fl">Type</span>
|
|
202
|
+
<select id="auth-type">${Object.entries(AUTH_LABELS).map(([v, l]) => `<option value="${v}" ${a.type === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
|
203
|
+
</label>
|
|
204
|
+
<div id="auth-fields">${authFields(a, note)}</div>`;
|
|
205
|
+
$('#auth-type', box).addEventListener('change', () => {
|
|
206
|
+
const cur = readAuth();
|
|
207
|
+
$('#auth-fields', box).innerHTML = authFields(cur, note);
|
|
208
|
+
updateTabCounts();
|
|
209
|
+
if (cur.type === 'oauth2') refreshOauthStatus(box);
|
|
210
|
+
});
|
|
211
|
+
if (a.type === 'oauth2') refreshOauthStatus(box);
|
|
212
|
+
}
|
|
213
|
+
function readAuth() {
|
|
214
|
+
const box = $('#auth-panel');
|
|
215
|
+
if (!box || !$('#auth-type', box)) return (state.current && state.current.auth) || { type: 'inherit' };
|
|
216
|
+
return readAuthFields(box, $('#auth-type', box).value, state.current && state.current.auth);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ================================================================ tests tab
|
|
220
|
+
const TEST_TYPES = [['status', 'Status code'], ['json', 'JSON value'], ['header', 'Header'], ['body', 'Body'], ['time', 'Response time']];
|
|
221
|
+
const TEST_OPS = {
|
|
222
|
+
status: [['equals', 'equals'], ['not_equals', 'does not equal'], ['is_2xx', 'is 2xx'], ['is_3xx', 'is 3xx'], ['is_4xx', 'is 4xx'], ['is_5xx', 'is 5xx'], ['in', 'is one of'], ['less_than', 'is less than'], ['greater_than', 'is greater than']],
|
|
223
|
+
time: [['less_than', 'is less than (ms)'], ['greater_than', 'is greater than (ms)']],
|
|
224
|
+
header: [['exists', 'exists'], ['not_exists', 'does not exist'], ['equals', 'equals'], ['not_equals', 'does not equal'], ['contains', 'contains'], ['not_contains', 'does not contain'], ['matches', 'matches regex']],
|
|
225
|
+
body: [['contains', 'contains'], ['not_contains', 'does not contain'], ['equals', 'equals'], ['not_equals', 'does not equal'], ['matches', 'matches regex'], ['is_json', 'is valid JSON'], ['is_empty', 'is empty'], ['not_empty', 'is not empty']],
|
|
226
|
+
json: [['exists', 'exists'], ['not_exists', 'does not exist'], ['equals', 'equals'], ['not_equals', 'does not equal'], ['contains', 'contains'], ['not_contains', 'does not contain'], ['matches', 'matches regex'], ['type_is', 'is of type'], ['length_equals', 'has length'], ['less_than', 'is less than'], ['greater_than', 'is greater than'], ['is_empty', 'is empty'], ['not_empty', 'is not empty'], ['is_true', 'is true'], ['is_false', 'is false'], ['is_null', 'is null']],
|
|
227
|
+
};
|
|
228
|
+
const NO_VALUE_OPS = new Set(['exists', 'not_exists', 'is_json', 'is_empty', 'not_empty', 'is_2xx', 'is_3xx', 'is_4xx', 'is_5xx', 'is_true', 'is_false', 'is_null']);
|
|
229
|
+
const TEST_PRESETS = [
|
|
230
|
+
{ label: 'Status is 200', t: { type: 'status', op: 'equals', value: '200' } },
|
|
231
|
+
{ label: 'Status is 2xx', t: { type: 'status', op: 'is_2xx' } },
|
|
232
|
+
{ label: 'Faster than 500 ms', t: { type: 'time', op: 'less_than', value: '500' } },
|
|
233
|
+
{ label: 'Body is JSON', t: { type: 'body', op: 'is_json' } },
|
|
234
|
+
{ label: 'Content-Type has json', t: { type: 'header', target: 'Content-Type', op: 'contains', value: 'json' } },
|
|
235
|
+
{ label: 'JSON field exists', t: { type: 'json', target: 'data.id', op: 'exists' } },
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
function testRow(t) {
|
|
239
|
+
const row = document.createElement('div');
|
|
240
|
+
row.className = 'test-row';
|
|
241
|
+
const type = TEST_TYPES.some(([v]) => v === t.type) ? t.type : 'status';
|
|
242
|
+
const ops = TEST_OPS[type];
|
|
243
|
+
const op = ops.some(([v]) => v === t.op) ? t.op : ops[0][0];
|
|
244
|
+
const needsTarget = type === 'json' || type === 'header';
|
|
245
|
+
row.innerHTML = `
|
|
246
|
+
<input type="checkbox" class="t-on" ${t.enabled !== false ? 'checked' : ''} title="Enabled">
|
|
247
|
+
<select class="t-type">${TEST_TYPES.map(([v, l]) => `<option value="${v}" ${v === type ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
|
248
|
+
<input class="t-target mono${needsTarget ? '' : ' t-blank'}" value="${esc(t.target || '')}" placeholder="${type === 'json' ? 'path, e.g. data.items[0].id' : 'header name'}" ${needsTarget ? '' : 'tabindex="-1"'} spellcheck="false">
|
|
249
|
+
<select class="t-op">${ops.map(([v, l]) => `<option value="${v}" ${v === op ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
|
250
|
+
<input class="t-value mono${NO_VALUE_OPS.has(op) ? ' t-blank' : ''}" value="${esc(t.value || '')}" placeholder="${type === 'json' && op === 'type_is' ? 'string | number | boolean | object | array | null' : 'expected value'}" ${NO_VALUE_OPS.has(op) ? 'tabindex="-1"' : ''} spellcheck="false">
|
|
251
|
+
<input class="t-name" value="${esc(t.name || '')}" placeholder="name (optional)" spellcheck="false">
|
|
252
|
+
<select class="t-body" title="Which body this test applies to">${bodyScopeOptions(t.body_id)}</select>
|
|
253
|
+
<button class="kv-del" title="Remove">✕</button>`;
|
|
254
|
+
$('.t-body', row).addEventListener('change', scheduleSave);
|
|
255
|
+
$('.t-type', row).addEventListener('change', () => { const cur = readTestRow(row); cur.op = ''; row.replaceWith(testRow(cur)); scheduleSave(); });
|
|
256
|
+
$('.t-op', row).addEventListener('change', () => { row.replaceWith(testRow(readTestRow(row))); scheduleSave(); });
|
|
257
|
+
$('.kv-del', row).addEventListener('click', () => { row.remove(); updateTabCounts(); scheduleSave(); });
|
|
258
|
+
return row;
|
|
259
|
+
}
|
|
260
|
+
function readTestRow(row) {
|
|
261
|
+
return { enabled: $('.t-on', row).checked, type: $('.t-type', row).value, target: $('.t-target', row).value, op: $('.t-op', row).value, value: $('.t-value', row).value, name: $('.t-name', row).value, body_id: $('.t-body', row).value };
|
|
262
|
+
}
|
|
263
|
+
// A test applies to every body, or to one named body (the select only shows with two or more bodies).
|
|
264
|
+
function bodyScopeOptions(selected) {
|
|
265
|
+
const bodies = (typeof state !== 'undefined' && state.bodies) || [];
|
|
266
|
+
const sel = bodies.some((b) => b.id === selected) ? selected : '';
|
|
267
|
+
return `<option value="">All bodies</option>` + bodies.map((b) => `<option value="${esc(b.id)}" ${b.id === sel ? 'selected' : ''}>${esc(b.name || 'Unnamed')}</option>`).join('');
|
|
268
|
+
}
|
|
269
|
+
function refreshTestBodyScopes() {
|
|
270
|
+
const list = $('#tests-list');
|
|
271
|
+
if (!list) return;
|
|
272
|
+
const bodyType = $('#f-body-type') ? $('#f-body-type').value : 'none';
|
|
273
|
+
list.classList.toggle('scoped', bodyType !== 'none' && state.bodies.length > 1);
|
|
274
|
+
$$('.t-body', list).forEach((sel) => { sel.innerHTML = bodyScopeOptions(sel.value); });
|
|
275
|
+
const hint = $('#tests-scope-hint');
|
|
276
|
+
if (hint) hint.textContent = list.classList.contains('scoped') ? 'Each test runs for all bodies or for the one body you pick.' : '';
|
|
277
|
+
refreshScriptScopes();
|
|
278
|
+
}
|
|
279
|
+
const EXTRACT_SOURCES = [['json', 'JSON path'], ['header', 'Header'], ['status', 'Status code'], ['body', 'Whole body'], ['regex', 'Regex (first group)']];
|
|
280
|
+
function extractRow(e) {
|
|
281
|
+
const row = document.createElement('div');
|
|
282
|
+
row.className = 'extract-row';
|
|
283
|
+
const src = EXTRACT_SOURCES.some(([v]) => v === e.source) ? e.source : 'json';
|
|
284
|
+
row.innerHTML = `
|
|
285
|
+
<input type="checkbox" class="x-on" ${e.enabled !== false ? 'checked' : ''} title="Enabled">
|
|
286
|
+
<span class="muted small">set</span>
|
|
287
|
+
<input class="x-name mono" value="${esc(e.name || '')}" placeholder="variable name" spellcheck="false">
|
|
288
|
+
<select class="x-scope"><option value="env" ${e.scope !== 'global' ? 'selected' : ''}>in environment</option><option value="global" ${e.scope === 'global' ? 'selected' : ''}>globally</option></select>
|
|
289
|
+
<span class="muted small">from</span>
|
|
290
|
+
<select class="x-source">${EXTRACT_SOURCES.map(([v, l]) => `<option value="${v}" ${v === src ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
|
291
|
+
<input class="x-path mono" value="${esc(e.path || '')}" placeholder="${src === 'json' ? 'data.token' : src === 'header' ? 'Set-Cookie' : src === 'regex' ? 'token=(\\w+)' : ''}" ${src === 'status' || src === 'body' ? 'hidden' : ''} spellcheck="false">
|
|
292
|
+
<button class="kv-del" title="Remove">✕</button>`;
|
|
293
|
+
$('.x-source', row).addEventListener('change', () => { row.replaceWith(extractRow(readExtractRow(row))); scheduleSave(); });
|
|
294
|
+
$('.kv-del', row).addEventListener('click', () => { row.remove(); updateTabCounts(); scheduleSave(); });
|
|
295
|
+
return row;
|
|
296
|
+
}
|
|
297
|
+
function readExtractRow(row) {
|
|
298
|
+
return { enabled: $('.x-on', row).checked, name: $('.x-name', row).value, scope: $('.x-scope', row).value, source: $('.x-source', row).value, path: $('.x-path', row).value };
|
|
299
|
+
}
|
|
300
|
+
function preVarRow(v) {
|
|
301
|
+
const row = document.createElement('div');
|
|
302
|
+
row.className = 'prevar-row';
|
|
303
|
+
row.innerHTML = `
|
|
304
|
+
<input type="checkbox" class="p-on" ${v.enabled !== false ? 'checked' : ''} title="Enabled">
|
|
305
|
+
<input class="p-key mono" value="${esc(v.key || '')}" placeholder="name" spellcheck="false">
|
|
306
|
+
<input class="p-value mono grow" value="${esc(v.value || '')}" placeholder="value, e.g. {{$guid}} or {{$timestamp}}" spellcheck="false">
|
|
307
|
+
<select class="p-scope"><option value="local" ${!v.scope || v.scope === 'local' ? 'selected' : ''}>this request only</option><option value="env" ${v.scope === 'env' ? 'selected' : ''}>save in environment</option><option value="global" ${v.scope === 'global' ? 'selected' : ''}>save globally</option></select>
|
|
308
|
+
<button class="kv-del" title="Remove">✕</button>`;
|
|
309
|
+
$('.kv-del', row).addEventListener('click', () => { row.remove(); scheduleSave(); });
|
|
310
|
+
return row;
|
|
311
|
+
}
|
|
312
|
+
function renderTestsPanel(r) {
|
|
313
|
+
const box = $('#tests-panel');
|
|
314
|
+
box.innerHTML = `
|
|
315
|
+
<div class="row tight script-lang">
|
|
316
|
+
<span class="muted small">Script language</span>
|
|
317
|
+
<select id="f-script-lang" title="Language of the pre-request and test scripts"><option value="js">JavaScript (Postman pm API)</option><option value="py">Python (py object)</option></select>
|
|
318
|
+
<span class="muted small" id="script-blurb"></span>
|
|
319
|
+
<button class="link small" id="btn-script-help">Reference</button>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="tests-section">
|
|
322
|
+
<div class="ts-head"><h4>Pre-request</h4><span class="muted small">Runs before the request is sent.</span></div>
|
|
323
|
+
<div class="ts-sub">Variables <span class="muted small">Dynamic values: <code>{{$guid}}</code> <code>{{$timestamp}}</code> <code>{{$isoTimestamp}}</code> <code>{{$randomInt 1 100}}</code> <code>{{$randomEmail}}</code>; <code>{{body_name}}</code> is the body being sent.</span></div>
|
|
324
|
+
<div id="prevars-list" class="rows"></div>
|
|
325
|
+
<button class="link" id="btn-prevar-add">+ Add variable</button>
|
|
326
|
+
<div class="ts-sub">Pre-request script ${scriptScopeHtml('pre')}</div>
|
|
327
|
+
<textarea id="f-script-pre" class="mono" rows="6" spellcheck="false"></textarea>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="tests-section">
|
|
330
|
+
<div class="ts-head"><h4>Post-response</h4><span class="muted small">Runs after the response arrives. Results show in the response's Tests tab; a failing test fails the request in a run.</span></div>
|
|
331
|
+
<div class="ts-sub">Tests <span class="muted small" id="tests-scope-hint"></span></div>
|
|
332
|
+
<div id="tests-list" class="rows"></div>
|
|
333
|
+
<div class="row tight wrap"><button class="link" id="btn-test-add">+ Add test</button><span class="muted small">Quick add:</span>${TEST_PRESETS.map((p, i) => `<span class="chip" data-preset="${i}">${esc(p.label)}</span>`).join('')}</div>
|
|
334
|
+
<div class="ts-sub">Extract variables <span class="muted small">Chain requests by saving a token or id for the next request (<code>{{token}}</code>).</span></div>
|
|
335
|
+
<div id="extract-list" class="rows"></div>
|
|
336
|
+
<button class="link" id="btn-extract-add">+ Add extraction</button>
|
|
337
|
+
<div class="ts-sub">Test script ${scriptScopeHtml('test')}</div>
|
|
338
|
+
<textarea id="f-script-test" class="mono" rows="6" spellcheck="false"></textarea>
|
|
339
|
+
</div>`;
|
|
340
|
+
const sc = (r.options && r.options.scripts) || {};
|
|
341
|
+
$('#f-script-lang').value = sc.lang === 'py' ? 'py' : 'js';
|
|
342
|
+
// one script per scope: '' is "All bodies", a body id is that body's own script (shown one at a time)
|
|
343
|
+
scriptStore = { pre: { '': sc.pre || '' }, test: { '': sc.test || '' } };
|
|
344
|
+
Object.entries(sc.bodies || {}).forEach(([id, own]) => { scriptStore.pre[id] = (own && own.pre) || ''; scriptStore.test[id] = (own && own.test) || ''; });
|
|
345
|
+
scriptScope = { pre: '', test: '' };
|
|
346
|
+
$('#f-script-pre').value = scriptStore.pre[''];
|
|
347
|
+
$('#f-script-test').value = scriptStore.test[''];
|
|
348
|
+
const scriptLang = () => ($('#f-script-lang') && $('#f-script-lang').value === 'py' ? 'python' : 'javascript');
|
|
349
|
+
enhanceTextarea($('#f-script-pre'), { lang: scriptLang });
|
|
350
|
+
enhanceTextarea($('#f-script-test'), { lang: scriptLang });
|
|
351
|
+
applyScriptLang();
|
|
352
|
+
['pre', 'test'].forEach((kind) => {
|
|
353
|
+
$('#f-script-' + kind).addEventListener('input', () => { scriptStore[kind][scriptScope[kind]] = $('#f-script-' + kind).value; refreshScriptScopes(); });
|
|
354
|
+
$(`.script-scope[data-kind="${kind}"]`, box).addEventListener('change', (e) => switchScriptScope(kind, e.target.value));
|
|
355
|
+
});
|
|
356
|
+
$('#f-script-lang').addEventListener('change', () => { applyScriptLang(); scheduleSave(); });
|
|
357
|
+
$('#btn-script-help').addEventListener('click', openScriptHelp);
|
|
358
|
+
(r.pre_vars || []).forEach((v) => $('#prevars-list').appendChild(preVarRow(v)));
|
|
359
|
+
(r.tests || []).forEach((t) => $('#tests-list').appendChild(testRow(t)));
|
|
360
|
+
(r.extract || []).forEach((e) => $('#extract-list').appendChild(extractRow(e)));
|
|
361
|
+
refreshTestBodyScopes();
|
|
362
|
+
$('#btn-prevar-add').addEventListener('click', () => { const row = preVarRow({}); $('#prevars-list').appendChild(row); $('.p-key', row).focus(); });
|
|
363
|
+
$('#btn-test-add').addEventListener('click', () => { const row = testRow({ type: 'status', op: 'equals', value: '200' }); $('#tests-list').appendChild(row); updateTabCounts(); scheduleSave(); });
|
|
364
|
+
$('#btn-extract-add').addEventListener('click', () => { const row = extractRow({ source: 'json', scope: 'env' }); $('#extract-list').appendChild(row); $('.x-name', row).focus(); updateTabCounts(); });
|
|
365
|
+
$$('[data-preset]', box).forEach((c) => c.addEventListener('click', () => { $('#tests-list').appendChild(testRow({ ...TEST_PRESETS[c.dataset.preset].t })); updateTabCounts(); scheduleSave(); }));
|
|
366
|
+
}
|
|
367
|
+
// ---- scripts per body: the "All bodies" script runs for every body, then the body's own (if written)
|
|
368
|
+
let scriptStore = { pre: { '': '' }, test: { '': '' } }; // kind -> scope ('' or body id) -> code
|
|
369
|
+
let scriptScope = { pre: '', test: '' }; // which scope each editor currently shows
|
|
370
|
+
function scriptScopeHtml(kind) {
|
|
371
|
+
return `<label class="script-scope-wrap muted small" hidden>for <select class="script-scope" data-kind="${kind}" title="Which body this script runs for: every body, or only the one you pick"></select></label><span class="muted small script-scope-hint" data-kind="${kind}"></span>`;
|
|
372
|
+
}
|
|
373
|
+
function bodyHasScript(kind, id) { return !!(scriptStore[kind][id] || '').trim(); }
|
|
374
|
+
function refreshScriptScopes() {
|
|
375
|
+
const box = $('#tests-panel');
|
|
376
|
+
if (!box || !$('.script-scope', box)) return;
|
|
377
|
+
const bodies = (typeof state !== 'undefined' && state.bodies) || [];
|
|
378
|
+
const bodyType = $('#f-body-type') ? $('#f-body-type').value : 'none';
|
|
379
|
+
['pre', 'test'].forEach((kind) => {
|
|
380
|
+
const sel = $(`.script-scope[data-kind="${kind}"]`, box);
|
|
381
|
+
const wrap = sel.closest('.script-scope-wrap');
|
|
382
|
+
const hint = $(`.script-scope-hint[data-kind="${kind}"]`, box);
|
|
383
|
+
// a body's own script is only reachable with two or more bodies, or while one body still has its own script
|
|
384
|
+
const scoped = bodyType !== 'none' && (bodies.length > 1 || bodies.some((b) => bodyHasScript(kind, b.id)));
|
|
385
|
+
if (scriptScope[kind] && (!scoped || !bodies.some((b) => b.id === scriptScope[kind]))) { switchScriptScope(kind, ''); return; }
|
|
386
|
+
wrap.hidden = !scoped;
|
|
387
|
+
sel.innerHTML = `<option value="">All bodies${bodyHasScript(kind, '') ? ' ●' : ''}</option>` +
|
|
388
|
+
bodies.map((b) => `<option value="${esc(b.id)}" ${b.id === scriptScope[kind] ? 'selected' : ''}>${esc(b.name || 'Unnamed')}${bodyHasScript(kind, b.id) ? ' ●' : ''}</option>`).join('');
|
|
389
|
+
const own = bodies.find((b) => b.id === scriptScope[kind]);
|
|
390
|
+
hint.textContent = !scoped ? '' : own ? `Runs only when “${own.name || 'Unnamed'}” is sent, after the script for all bodies.` : 'Runs for every body. Pick a body to write a script for that body only (● marks bodies that have one).';
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
function switchScriptScope(kind, id) {
|
|
394
|
+
const ta = $('#f-script-' + kind);
|
|
395
|
+
if (!ta) return;
|
|
396
|
+
scriptScope[kind] = id;
|
|
397
|
+
const code = scriptStore[kind][id] || '';
|
|
398
|
+
if (ta._enhanced) ta._enhanced.setValue(code); else ta.value = code;
|
|
399
|
+
refreshScriptScopes();
|
|
400
|
+
}
|
|
401
|
+
function readScripts() {
|
|
402
|
+
// the editors may have been set without an input event (e.g. programmatically): take what they show now
|
|
403
|
+
['pre', 'test'].forEach((kind) => { const ta = $('#f-script-' + kind); if (ta) scriptStore[kind][scriptScope[kind]] = ta.value; });
|
|
404
|
+
const ids = new Set(((typeof state !== 'undefined' && state.bodies) || []).map((b) => b.id));
|
|
405
|
+
const bodies = {};
|
|
406
|
+
ids.forEach((id) => {
|
|
407
|
+
const pre = scriptStore.pre[id] || '', test = scriptStore.test[id] || '';
|
|
408
|
+
if (pre.trim() || test.trim()) bodies[id] = { pre, test };
|
|
409
|
+
});
|
|
410
|
+
return { pre: scriptStore.pre[''] || '', test: scriptStore.test[''] || '', lang: ($('#f-script-lang') || {}).value === 'py' ? 'py' : 'js', bodies };
|
|
411
|
+
}
|
|
412
|
+
function readTests() {
|
|
413
|
+
return {
|
|
414
|
+
tests: $$('#tests-list .test-row').map(readTestRow),
|
|
415
|
+
extract: $$('#extract-list .extract-row').map(readExtractRow).filter((e) => e.name.trim() || e.path.trim()),
|
|
416
|
+
pre_vars: $$('#prevars-list .prevar-row').map((row) => ({ enabled: $('.p-on', row).checked, key: $('.p-key', row).value, value: $('.p-value', row).value, scope: $('.p-scope', row).value })).filter((v) => v.key.trim()),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ======================================================= request settings
|
|
421
|
+
function renderRequestSettings(r) {
|
|
422
|
+
const o = r.options || {};
|
|
423
|
+
$('#f-redirects').value = o.follow_redirects === true ? '1' : o.follow_redirects === false ? '0' : '';
|
|
424
|
+
$('#f-send-cookies').checked = o.send_cookies !== false;
|
|
425
|
+
$('#f-store-cookies').checked = o.store_cookies !== false;
|
|
426
|
+
$('#f-stream').checked = o.stream === true;
|
|
427
|
+
renderDependency(r);
|
|
428
|
+
}
|
|
429
|
+
// ---- "Depends on": another saved request that is sent first (a token request, typically)
|
|
430
|
+
function fmtAge(sec) {
|
|
431
|
+
if (sec == null) return '';
|
|
432
|
+
if (sec < 60) return `${Math.round(sec)} s`;
|
|
433
|
+
if (sec < 3600) return `${Math.round(sec / 60)} min`;
|
|
434
|
+
return `${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`;
|
|
435
|
+
}
|
|
436
|
+
function renderDependency(r) {
|
|
437
|
+
const dep = (r.options && r.options.depends) || null;
|
|
438
|
+
const sel = $('#f-dep');
|
|
439
|
+
const others = state.requests.filter((x) => x.id !== r.id).map((x) => ({ x, path: [...folderPath(x.folder_id), x.name].join(' › ') }))
|
|
440
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
441
|
+
sel.innerHTML = '<option value="">none</option>' + others.map(({ x, path }) => `<option value="${esc(x.id)}">${esc(x.method)} ${esc(path)}</option>`).join('');
|
|
442
|
+
sel.value = dep && others.some(({ x }) => x.id === dep.request_id) ? dep.request_id : '';
|
|
443
|
+
const ttl = dep ? Number(dep.ttl) || 0 : 1800;
|
|
444
|
+
const unit = ttl > 0 && ttl % 3600 === 0 ? 3600 : ttl > 0 && ttl % 60 === 0 ? 60 : 1;
|
|
445
|
+
$('#f-dep-unit').value = String(unit);
|
|
446
|
+
$('#f-dep-ttl').value = String(Math.round(ttl / unit));
|
|
447
|
+
$('#f-dep-retry').checked = !dep || dep.retry_on_auth !== false;
|
|
448
|
+
refreshDependencyUi();
|
|
449
|
+
}
|
|
450
|
+
async function refreshDependencyUi() {
|
|
451
|
+
const id = $('#f-dep').value;
|
|
452
|
+
$('#f-dep-opts').hidden = !id;
|
|
453
|
+
const status = $('#f-dep-status');
|
|
454
|
+
if (!id) { status.textContent = ''; return; }
|
|
455
|
+
const unit = Number($('#f-dep-unit').value) || 1;
|
|
456
|
+
const ttl = (Number($('#f-dep-ttl').value) || 0) * unit;
|
|
457
|
+
try {
|
|
458
|
+
const s = await api(`/requests/${id}/dependency`);
|
|
459
|
+
if ($('#f-dep').value !== id) return;
|
|
460
|
+
if (!s.ran_at) {
|
|
461
|
+
status.innerHTML = 'Not sent yet: it runs before the next send of this request.';
|
|
462
|
+
} else {
|
|
463
|
+
const left = ttl - s.age;
|
|
464
|
+
const when = ttl === 0 ? 'it runs before every send' : left > 0 ? `runs again in ${fmtAge(left)}` : 'runs again before the next send';
|
|
465
|
+
status.innerHTML = `Last sent ${fmtAge(s.age)} ago${s.status_code ? ` (${s.status_code})` : ''}${s.ok ? '' : ', <span class="err-text">failed</span>'} · ${when} · <button class="link small" id="f-dep-reset">send it before the next send</button>`;
|
|
466
|
+
$('#f-dep-reset').addEventListener('click', async () => { await api(`/requests/${id}/dependency`, { method: 'DELETE' }); refreshDependencyUi(); });
|
|
467
|
+
}
|
|
468
|
+
} catch (e) { status.textContent = e.message; }
|
|
469
|
+
}
|
|
470
|
+
function readDependency() {
|
|
471
|
+
const id = $('#f-dep') ? $('#f-dep').value : '';
|
|
472
|
+
if (!id) return null;
|
|
473
|
+
return { request_id: id, ttl: (Number($('#f-dep-ttl').value) || 0) * (Number($('#f-dep-unit').value) || 1), retry_on_auth: $('#f-dep-retry').checked };
|
|
474
|
+
}
|
|
475
|
+
function readRequestOptions() {
|
|
476
|
+
const v = $('#f-redirects').value;
|
|
477
|
+
return {
|
|
478
|
+
...((state.current && state.current.options) || {}),
|
|
479
|
+
follow_redirects: v === '' ? null : v === '1',
|
|
480
|
+
send_cookies: $('#f-send-cookies').checked,
|
|
481
|
+
store_cookies: $('#f-store-cookies').checked,
|
|
482
|
+
stream: $('#f-stream').checked,
|
|
483
|
+
scripts: $('#f-script-pre') ? readScripts() : ((state.current && state.current.options && state.current.options.scripts) || { pre: '', test: '', lang: 'js', bodies: {} }),
|
|
484
|
+
active_body: state.activeBodyId || '',
|
|
485
|
+
depends: readDependency(),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const SCRIPT_PLACEHOLDERS = {
|
|
489
|
+
js: {
|
|
490
|
+
pre: 'pm.request.headers.add({ key: "X-Trace", value: pm.variables.get("trace") });\npm.environment.set("ts", Date.now());',
|
|
491
|
+
test: 'pm.test("status is 200", function () {\n pm.response.to.have.status(200);\n});\nconst data = pm.response.json();\npm.test("has id", () => pm.expect(data.id).to.be.a("number"));\npm.environment.set("user_id", data.id);',
|
|
492
|
+
blurb: 'Postman scripts run here unchanged (<code>pm.test</code>, <code>pm.expect</code>, <code>pm.response</code>, <code>pm.environment</code>…). <code>console.log</code> shows in the Tests tab of the response.',
|
|
493
|
+
},
|
|
494
|
+
py: {
|
|
495
|
+
pre: 'py.request.headers["X-Trace"] = py.variables.get("trace", "none")\npy.variables.set("ts", "123")',
|
|
496
|
+
test: 'py.test("status is 200", py.response.status_code == 200)\nconst data = py.response.json();\npy.test("has id", lambda: py.expect(data.id).to_be_a(int))\npy.environment.set("user_id", data.id)',
|
|
497
|
+
blurb: 'Python with a <code>py</code> object; <code>print()</code> shows in the Tests tab of the response.',
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
function applyScriptLang() {
|
|
501
|
+
const lang = $('#f-script-lang').value === 'py' ? 'py' : 'js';
|
|
502
|
+
$('#f-script-pre').placeholder = SCRIPT_PLACEHOLDERS[lang].pre;
|
|
503
|
+
$('#f-script-test').placeholder = SCRIPT_PLACEHOLDERS[lang].test;
|
|
504
|
+
$('#script-blurb').innerHTML = SCRIPT_PLACEHOLDERS[lang].blurb;
|
|
505
|
+
['#f-script-pre', '#f-script-test'].forEach((s) => { const ta = $(s); if (ta && ta._enhanced) ta._enhanced.refresh(); });
|
|
506
|
+
}
|
|
507
|
+
function openScriptHelp() {
|
|
508
|
+
if (($('#f-script-lang') || {}).value !== 'py') { openJsScriptHelp(); return; }
|
|
509
|
+
openModal('Python scripts', `<pre class="mono snippet">${esc(`# Pre-request script: runs before the request is sent
|
|
510
|
+
py.variables.set("ts", "123") # this request only
|
|
511
|
+
py.environment.set("token", "abc") # saved in the active environment
|
|
512
|
+
py.globals.set("base", "https://x") # saved globally
|
|
513
|
+
py.variables.get("name", "default") # effective value
|
|
514
|
+
py.request.headers["X-Trace"] = "1" # dict of headers (add / change / del)
|
|
515
|
+
py.request.params["page"] = "2" # query params
|
|
516
|
+
py.request.url / py.request.method / py.request.body # editable
|
|
517
|
+
py.data # the variables-file row in a run
|
|
518
|
+
print("debug") # shown in the response's Tests tab
|
|
519
|
+
|
|
520
|
+
# Test script: runs after the response
|
|
521
|
+
r = py.response
|
|
522
|
+
r.status_code, r.ok, r.text, r.json(), r.headers["content-type"], r.time_ms, r.size
|
|
523
|
+
# JavaScript habits accepted: const/let/var, trailing ;, true/false/null, console.log(...)
|
|
524
|
+
py.test("status is 200", r.status_code == 200)
|
|
525
|
+
py.test("has token", lambda: py.expect(r.json()["token"]).to_be_a(str))
|
|
526
|
+
py.expect(x).to_equal(y) .to_not_equal(y) .to_contain(y) .to_be_true() .to_be_false()
|
|
527
|
+
.to_be_none() .to_be_above(n) .to_be_below(n) .to_match(regex)
|
|
528
|
+
.to_be_a(type) .to_have_length(n) .to_have_key(k)
|
|
529
|
+
const token = r.json().data.token; # dotted access like JavaScript; a missing field is None
|
|
530
|
+
py.environment.set("token", token) # chain into the next request
|
|
531
|
+
# json and re are imported; a failing assert inside a lambda fails that test;
|
|
532
|
+
# an exception outside a test fails the whole script. Timeout: 15 s.`)}</pre>`, [{ label: 'Close', cls: 'primary', onClick: closeModal }]);
|
|
533
|
+
}
|
|
534
|
+
function openJsScriptHelp() {
|
|
535
|
+
openModal('JavaScript scripts (Postman pm API)', `<pre class="mono snippet">${esc(`// Pre-request script: runs before the request is sent
|
|
536
|
+
pm.variables.set("ts", Date.now()); // this request only
|
|
537
|
+
pm.environment.set("token", "abc"); // saved in the active environment
|
|
538
|
+
pm.globals.set("base", "https://x"); // saved globally
|
|
539
|
+
pm.collectionVariables.set("v", "1"); // saved on the request's folder
|
|
540
|
+
pm.variables.get("name"); // local > variables file > environment > folder > global
|
|
541
|
+
pm.request.headers.add({ key: "X-Trace", value: "1" }); // .upsert / .remove / .get / .has
|
|
542
|
+
pm.request.url.query.add({ key: "page", value: "2" });
|
|
543
|
+
pm.request.method = "POST"; pm.request.url = "https://…"; pm.request.body.raw = JSON.stringify({ a: 1 });
|
|
544
|
+
pm.iterationData.get("email"); // variables-file column in a run
|
|
545
|
+
console.log("debug", pm.info.requestName); // shown in the response's Tests tab
|
|
546
|
+
|
|
547
|
+
// Test script: runs after the response
|
|
548
|
+
pm.test("status is 200", function () { pm.response.to.have.status(200); });
|
|
549
|
+
pm.response.to.be.ok / .success / .clientError / .serverError; pm.response.to.not.have.status(404);
|
|
550
|
+
pm.response.to.have.header("Content-Type", "application/json");
|
|
551
|
+
pm.response.to.have.jsonBody("data.token");
|
|
552
|
+
pm.response.code, pm.response.status, pm.response.responseTime, pm.response.responseSize
|
|
553
|
+
pm.response.json(), pm.response.text(), pm.response.headers.get("x-id"), pm.cookies.get("session")
|
|
554
|
+
const data = pm.response.json();
|
|
555
|
+
pm.test("token", () => pm.expect(data.token).to.be.a("string").and.not.empty);
|
|
556
|
+
pm.expect(x).to.equal(y) .eql(y) .include(y) .have.property("k", v) .have.lengthOf(n)
|
|
557
|
+
.be.above(n) .be.below(n) .be.within(a, b) .match(/re/) .be.oneOf([...])
|
|
558
|
+
.be.true / .false / .null / .undefined / .ok / .empty / .exist (.not and .deep work)
|
|
559
|
+
pm.expect(data).to.have.nested.property("items[0].id");
|
|
560
|
+
pm.environment.set("token", data.token); // chain into the next request
|
|
561
|
+
tests["legacy style"] = responseCode.code === 200; // old Postman globals also work
|
|
562
|
+
// async tests: pm.test("x", async () => { await …; }); setTimeout works, pm.sendRequest does not.
|
|
563
|
+
// Engine: ${esc('{{engine}}')}; timeout 15 s; no network or file access from scripts.`).replace('{{engine}}', 'QuickJS or V8')}</pre>`, [{ label: 'Close', cls: 'primary', onClick: closeModal }]);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ==================================================== params: path variables
|
|
567
|
+
function renderPathVars() {
|
|
568
|
+
const box = $('#path-vars');
|
|
569
|
+
const url = $('#f-url').value || '';
|
|
570
|
+
const names = [...new Set((url.split('?')[0].match(/(?<=\/):([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1)))];
|
|
571
|
+
if (!names.length) { box.hidden = true; box.innerHTML = ''; return; }
|
|
572
|
+
const have = new Set(readKv($('#kv-params')).map((p) => p.key));
|
|
573
|
+
box.hidden = false;
|
|
574
|
+
box.innerHTML = `<span class="muted small">Path variables in the URL:</span> ${names.map((n) => `<span class="chip ${have.has(n) ? 'ok' : ''}" data-pv="${esc(n)}" title="${have.has(n) ? 'Value comes from the Params row ' + n : 'Click to add a Params row for :' + n}">:${esc(n)}${have.has(n) ? ' ✓' : ''}</span>`).join(' ')}
|
|
575
|
+
<span class="muted small">Give each a value in the rows above (key = name without the colon); it replaces <code>:name</code> in the path instead of becoming a query parameter.</span>`;
|
|
576
|
+
$$('[data-pv]', box).forEach((c) => c.addEventListener('click', () => {
|
|
577
|
+
const n = c.dataset.pv;
|
|
578
|
+
if (have.has(n)) return;
|
|
579
|
+
const rows = readKv($('#kv-params'));
|
|
580
|
+
rows.push({ key: n, value: '', enabled: true });
|
|
581
|
+
$('#kv-params').dataset.mode = 'rows';
|
|
582
|
+
renderKv($('#kv-params'), rows);
|
|
583
|
+
const inputs = $$('#kv-params .kv-row');
|
|
584
|
+
const last = inputs[inputs.length - 1];
|
|
585
|
+
if (last) $('.kv-val', last).focus();
|
|
586
|
+
renderPathVars();
|
|
587
|
+
scheduleSave();
|
|
588
|
+
}));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ========================================================== inherited headers
|
|
592
|
+
function renderInheritedHeaders() {
|
|
593
|
+
const box = $('#inherited-headers');
|
|
594
|
+
const rows = inheritedInfo ? inheritedInfo.headers : [];
|
|
595
|
+
if (!rows || !rows.length) { box.hidden = true; box.innerHTML = ''; return; }
|
|
596
|
+
box.hidden = false;
|
|
597
|
+
box.innerHTML = `<div class="muted small">Inherited from folders (a request header with the same name wins):</div>` +
|
|
598
|
+
rows.map((h) => `<div class="inh-row mono"><span class="k">${esc(h.key)}</span><span class="v">${esc(h.value)}</span></div>`).join('');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ================================================================ tab counts
|
|
602
|
+
function updateTabCounts() {
|
|
603
|
+
const n = (id, count) => { const el = $('#' + id); if (el) el.textContent = count ? String(count) : ''; };
|
|
604
|
+
n('n-params', readKv($('#kv-params')).filter((p) => p.enabled !== false).length);
|
|
605
|
+
n('n-headers', readKv($('#kv-headers')).filter((p) => p.enabled !== false).length + ((inheritedInfo && inheritedInfo.headers) ? inheritedInfo.headers.length : 0));
|
|
606
|
+
const auth = $('#auth-type') ? $('#auth-type').value : 'inherit';
|
|
607
|
+
const authEl = $('#n-auth');
|
|
608
|
+
if (authEl) authEl.textContent = auth === 'inherit' ? ((inheritedInfo && inheritedInfo.auth && inheritedInfo.auth.type !== 'none') ? '↑' : '') : auth === 'none' ? '' : '●';
|
|
609
|
+
const t = readTests();
|
|
610
|
+
n('n-tests', t.tests.filter((x) => x.enabled).length + t.extract.filter((x) => x.enabled).length);
|
|
611
|
+
const bt = $('#f-body-type').value;
|
|
612
|
+
n('n-body', bt === 'none' ? 0 : state.bodies.filter((b) => b.enabled).length);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ============================================================== body modes
|
|
616
|
+
// Text-like bodies use the enhanced textarea; form/multipart use key/value rows
|
|
617
|
+
// (stored as "key: value" lines, "key: @path" for files); binary stores a path;
|
|
618
|
+
// GraphQL stores {"query", "variables"} as JSON.
|
|
619
|
+
const bodyEditor = { mode: 'text', api: null };
|
|
620
|
+
const bodyLang = () => { const t = $('#f-body-type').value; return ['json', 'graphql'].includes(t) ? 'json' : t; };
|
|
621
|
+
function bodyModeFor(type) {
|
|
622
|
+
if (type === 'form' || type === 'multipart') return 'form';
|
|
623
|
+
if (type === 'binary') return 'binary';
|
|
624
|
+
if (type === 'graphql') return 'graphql';
|
|
625
|
+
return 'text';
|
|
626
|
+
}
|
|
627
|
+
function initBodyEditor() {
|
|
628
|
+
bodyEditor.api = enhanceTextarea($('#f-body'), { lang: bodyLang });
|
|
629
|
+
const gq = enhanceTextarea($('#f-gql-query'), { lang: 'graphql-query' });
|
|
630
|
+
const gv = enhanceTextarea($('#f-gql-vars'), { lang: 'json' });
|
|
631
|
+
bodyEditor.gql = { q: gq, v: gv };
|
|
632
|
+
$('#f-gql-query').addEventListener('input', () => { flushBody(); renderBodies(); });
|
|
633
|
+
$('#f-gql-vars').addEventListener('input', () => { flushBody(); renderBodies(); });
|
|
634
|
+
$('#f-binary-path').addEventListener('input', () => { flushBody(); renderBodies(); checkBinaryPath(); });
|
|
635
|
+
$('#btn-binary-browse').addEventListener('click', async () => {
|
|
636
|
+
const p = await pickFilePath();
|
|
637
|
+
if (p) { $('#f-binary-path').value = p; flushBody(); renderBodies(); checkBinaryPath(); scheduleSave(); }
|
|
638
|
+
});
|
|
639
|
+
$('#kv-form').addEventListener('input', () => { flushBody(); renderBodies(); });
|
|
640
|
+
$('#kv-form').addEventListener('change', () => { flushBody(); renderBodies(); });
|
|
641
|
+
$('#kv-form').addEventListener('click', (e) => { if (e.target.closest('.kv-del')) { flushBody(); renderBodies(); } });
|
|
642
|
+
}
|
|
643
|
+
async function pickFilePath() {
|
|
644
|
+
if (native() && native().pick_file) {
|
|
645
|
+
try { return await native().pick_file(); } catch (e) { toast('Could not open the file dialog: ' + e.message, 'error'); return null; }
|
|
646
|
+
}
|
|
647
|
+
return promptDialog('File path', { label: 'Full path of the file on this computer', placeholder: 'C:\\data\\photo.jpg', okLabel: 'Use' });
|
|
648
|
+
}
|
|
649
|
+
async function checkBinaryPath() {
|
|
650
|
+
const p = $('#f-binary-path').value.trim();
|
|
651
|
+
const info = $('#binary-info');
|
|
652
|
+
if (!p) { info.textContent = 'The file is read when the request is sent. Content-Type is guessed from the extension unless you set a header.'; return; }
|
|
653
|
+
try {
|
|
654
|
+
const r = await api('/path-exists?path=' + encodeURIComponent(p));
|
|
655
|
+
info.textContent = r.exists ? `✓ ${r.name} (${fmtBytes(r.size)})` : '✕ File not found';
|
|
656
|
+
info.className = 'muted small ' + (r.exists ? 'ok' : 'err');
|
|
657
|
+
} catch { info.textContent = ''; }
|
|
658
|
+
}
|
|
659
|
+
// kv rows <-> "key: value" lines for form bodies (file rows keep the @ prefix in the value)
|
|
660
|
+
function formTextToRows(text) {
|
|
661
|
+
return (text || '').split(/\r?\n/).map((line) => {
|
|
662
|
+
let l = line.trim();
|
|
663
|
+
if (!l) return null;
|
|
664
|
+
let enabled = true;
|
|
665
|
+
if (l.startsWith('//')) { enabled = false; l = l.slice(2).trim(); }
|
|
666
|
+
const ci = l.indexOf(':'), ei = l.indexOf('=');
|
|
667
|
+
const i = ei !== -1 && (ci === -1 || ei < ci) ? ei : ci;
|
|
668
|
+
const key = (i === -1 ? l : l.slice(0, i)).trim();
|
|
669
|
+
const value = i === -1 ? '' : l.slice(i + 1).trim();
|
|
670
|
+
return (key || value) ? { key, value, enabled } : null;
|
|
671
|
+
}).filter(Boolean);
|
|
672
|
+
}
|
|
673
|
+
function formRowsToText(rows) {
|
|
674
|
+
return rows.map((r) => (r.enabled === false ? '// ' : '') + `${r.key}: ${r.value}`).join('\n');
|
|
675
|
+
}
|
|
676
|
+
function showBodyEditor(b) {
|
|
677
|
+
const type = $('#f-body-type').value;
|
|
678
|
+
const mode = bodyModeFor(type);
|
|
679
|
+
bodyEditor.mode = mode;
|
|
680
|
+
$('#body-text-wrap').hidden = mode !== 'text';
|
|
681
|
+
$('#body-form-wrap').hidden = mode !== 'form';
|
|
682
|
+
$('#body-binary-wrap').hidden = mode !== 'binary';
|
|
683
|
+
$('#body-graphql-wrap').hidden = mode !== 'graphql';
|
|
684
|
+
$('#btn-format-json').hidden = !(mode === 'text' && ['json', 'xml', 'html'].includes(type)) && mode !== 'graphql';
|
|
685
|
+
const hint = $('#body-hint');
|
|
686
|
+
if (mode === 'text') {
|
|
687
|
+
if ($('#f-body').value !== b.body) $('#f-body').value = b.body;
|
|
688
|
+
bodyEditor.api.refresh(); // also switches the editor language when the body type changed
|
|
689
|
+
hint.textContent = type === 'json' ? 'Ctrl+/ comments out a line (removed when sent) · Tab indents · Ctrl+B beautifies' : type === 'xml' || type === 'html' ? 'Ctrl+/ wraps the selection in <!-- --> · Ctrl+B formats' : 'Tab indents · Ctrl+/ toggles // on a line';
|
|
690
|
+
} else if (mode === 'form') {
|
|
691
|
+
const rows = formTextToRows(b.body);
|
|
692
|
+
const kv = $('#kv-form');
|
|
693
|
+
if (kv.dataset.bodyId !== b.id || formRowsToText(readKv(kv)) !== formRowsToText(rows)) {
|
|
694
|
+
kv.dataset.bodyId = b.id;
|
|
695
|
+
renderKv(kv, rows, true);
|
|
696
|
+
}
|
|
697
|
+
$('#form-hint').innerHTML = type === 'multipart'
|
|
698
|
+
? 'Sent as <code>multipart/form-data</code>. For a file field, set the value to <code>@C:\\path\\to\\file</code> (or use Bulk edit). Variables work in keys and values.'
|
|
699
|
+
: 'Sent as <code>application/x-www-form-urlencoded</code>. Variables work in keys and values.';
|
|
700
|
+
const browse = $('#btn-form-file');
|
|
701
|
+
if (type === 'multipart' && !browse) {
|
|
702
|
+
const btn = document.createElement('button');
|
|
703
|
+
btn.id = 'btn-form-file';
|
|
704
|
+
btn.className = 'link';
|
|
705
|
+
btn.textContent = '+ File field…';
|
|
706
|
+
btn.addEventListener('click', async () => {
|
|
707
|
+
const p = await pickFilePath();
|
|
708
|
+
if (!p) return;
|
|
709
|
+
const rows = readKv($('#kv-form'));
|
|
710
|
+
rows.push({ key: 'file', value: '@' + p, enabled: true });
|
|
711
|
+
$('#kv-form').dataset.mode = 'rows';
|
|
712
|
+
renderKv($('#kv-form'), rows, true);
|
|
713
|
+
flushBody(); renderBodies(); scheduleSave();
|
|
714
|
+
});
|
|
715
|
+
$('#form-hint').before(btn);
|
|
716
|
+
} else if (type !== 'multipart' && browse) browse.remove();
|
|
717
|
+
} else if (mode === 'binary') {
|
|
718
|
+
if ($('#f-binary-path').value !== b.body) $('#f-binary-path').value = b.body;
|
|
719
|
+
checkBinaryPath();
|
|
720
|
+
} else if (mode === 'graphql') {
|
|
721
|
+
let q = '', v = '';
|
|
722
|
+
try { const d = JSON.parse(b.body || '{}'); q = d.query || ''; v = typeof d.variables === 'string' ? d.variables : JSON.stringify(d.variables || {}, null, 2); }
|
|
723
|
+
catch { q = b.body || ''; v = '{}'; }
|
|
724
|
+
if ($('#f-gql-query').value !== q) { $('#f-gql-query').value = q; bodyEditor.gql.q.refresh(); }
|
|
725
|
+
if ($('#f-gql-vars').value !== v) { $('#f-gql-vars').value = v; bodyEditor.gql.v.refresh(); bodyEditor.gql.v.lint(); }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
function readBodyEditor() {
|
|
729
|
+
const mode = bodyEditor.mode;
|
|
730
|
+
if (mode === 'form') return formRowsToText(readKv($('#kv-form')));
|
|
731
|
+
if (mode === 'binary') return $('#f-binary-path').value.trim();
|
|
732
|
+
if (mode === 'graphql') return JSON.stringify({ query: $('#f-gql-query').value, variables: $('#f-gql-vars').value });
|
|
733
|
+
return $('#f-body').value;
|
|
734
|
+
}
|
|
735
|
+
function beautifyBody() {
|
|
736
|
+
if (bodyEditor.mode === 'graphql') { bodyEditor.gql.v.format(); return; }
|
|
737
|
+
if (bodyEditor.mode === 'text' && bodyEditor.api.format()) { flushBody(); renderBodies(); scheduleSave(); }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ========================================================== response panels
|
|
741
|
+
let lastResult = null;
|
|
742
|
+
function renderResponsePanels(result) {
|
|
743
|
+
lastResult = result;
|
|
744
|
+
const resp = result.response;
|
|
745
|
+
const bodyBox = $('#r-body');
|
|
746
|
+
let view;
|
|
747
|
+
try { view = localStorage.getItem('pyhttp.rv.view') || undefined; } catch { view = undefined; }
|
|
748
|
+
let wrap = true;
|
|
749
|
+
try { wrap = localStorage.getItem('pyhttp.rv.wrap') !== '0'; } catch { /* ignore */ }
|
|
750
|
+
if (resp) mountViewer(bodyBox, resp, { view, wrap });
|
|
751
|
+
else { bodyBox.classList.remove('rv'); bodyBox.innerHTML = `<pre class="mono rv-pre err-text">${esc(result.error || 'Unknown error')}</pre>`; }
|
|
752
|
+
// headers
|
|
753
|
+
const hs = resp ? resp.headers : [];
|
|
754
|
+
$('#n-r-headers').textContent = hs.length ? String(hs.length) : '';
|
|
755
|
+
$('#r-headers').innerHTML = hs.length ? `<table class="htable"><tbody>${hs.map(([k, v]) => `<tr><td class="k">${esc(k)}</td><td class="v">${esc(v)}</td></tr>`).join('')}</tbody></table>` +
|
|
756
|
+
(resp && resp.hops && resp.hops.length ? `<div class="hops"><div class="muted small">Redirects (${resp.hops.length})</div>${resp.hops.map((h) => `<div class="hop mono"><span class="status ${codeClass(h.status_code)}">${h.status_code}</span> ${esc(h.url)} → ${esc(h.location)}</div>`).join('')}</div>` : '') +
|
|
757
|
+
(resp ? `<div class="muted small" style="margin-top:8px">${esc(resp.http_version || '')}${resp.content_type ? ' · ' + esc(resp.content_type) : ''}</div>` : '')
|
|
758
|
+
: '<div class="muted small">No headers</div>';
|
|
759
|
+
// cookies
|
|
760
|
+
const cs = resp ? (resp.cookies || []) : [];
|
|
761
|
+
$('#n-r-cookies').textContent = cs.length ? String(cs.length) : '';
|
|
762
|
+
$('#r-cookies').innerHTML = cs.length ? `<table class="htable"><thead><tr><th>Name</th><th>Value</th><th>Domain</th><th>Path</th><th>Expires</th><th></th></tr></thead><tbody>${cs.map((c) => `<tr><td class="k">${esc(c.name)}</td><td class="v">${esc(c.value)}</td><td>${esc(c.domain)}</td><td>${esc(c.path)}</td><td>${c.expires ? esc(fmtDate(c.expires)) : 'session'}</td><td>${c.secure ? '<span class="chip">secure</span>' : ''}${c.http_only ? '<span class="chip">httpOnly</span>' : ''}</td></tr>`).join('')}</tbody></table><p class="muted small">Cookies set by this response. They are stored in the cookie jar and sent with later requests to the same domain. <button class="link small" id="r-open-cookies">Manage cookies</button></p>`
|
|
763
|
+
: '<div class="muted small">The response set no cookies. <button class="link small" id="r-open-cookies">Open the cookie jar</button></div>';
|
|
764
|
+
const oc = $('#r-open-cookies'); if (oc) oc.addEventListener('click', openCookieManager);
|
|
765
|
+
// tests
|
|
766
|
+
const tests = result.tests || [];
|
|
767
|
+
const failed = tests.filter((t) => !t.passed).length;
|
|
768
|
+
const badge = $('#r-tests-badge');
|
|
769
|
+
badge.hidden = !tests.length;
|
|
770
|
+
badge.className = 'tests-badge ' + (failed ? 'fail' : 'pass');
|
|
771
|
+
badge.textContent = tests.length ? (failed ? `${failed} of ${tests.length} test${tests.length === 1 ? '' : 's'} failed` : `${tests.length} test${tests.length === 1 ? '' : 's'} passed`) : '';
|
|
772
|
+
$('#n-r-tests').textContent = tests.length ? `${tests.length - failed}/${tests.length}` : '';
|
|
773
|
+
$('#n-r-tests').className = 'tab-n ' + (tests.length ? (failed ? 'fail' : 'pass') : '');
|
|
774
|
+
const ext = result.extracted || [];
|
|
775
|
+
const pre = result.pre_vars || [];
|
|
776
|
+
const sc = result.scripts || {};
|
|
777
|
+
const scriptHtml = ['pre', 'pre_body', 'test', 'test_body'].filter((k) => sc[k]).map((k) => {
|
|
778
|
+
const rep = sc[k];
|
|
779
|
+
const logs = (rep.logs || []).map((l) => `<div class="mono small">${esc(l)}</div>`).join('');
|
|
780
|
+
const title = `${k.startsWith('pre') ? 'Pre-request' : 'Test'} script${k.endsWith('_body') ? ` · ${esc(rep.body_name || 'this body')}` : ''}`;
|
|
781
|
+
return `<h4 class="rt-h">${title}</h4>${rep.ok ? `<div class="muted small">ran OK${rep.timed_out ? ' (timed out)' : ''}</div>` : `<div class="err-text small mono">${esc(rep.error || 'error')}</div>`}${logs ? `<div class="script-log">${logs}</div>` : ''}`;
|
|
782
|
+
}).join('');
|
|
783
|
+
$('#r-tests').innerHTML = (tests.length ? `<ul class="test-results">${tests.map((t) => `<li class="${t.passed ? 'pass' : 'fail'}"><span class="mark">${t.passed ? '✓' : '✕'}</span><span class="tn">${esc(t.name)}</span><span class="note muted small">${esc(t.note || '')}</span></li>`).join('')}</ul>`
|
|
784
|
+
: '<div class="muted small">No tests on this request. Add some in the <button class="link small" id="r-goto-tests">Tests</button> tab (status, headers, JSON values, response time).</div>') + scriptHtml +
|
|
785
|
+
(ext.length ? `<h4 class="rt-h">Extracted variables</h4><ul class="test-results">${ext.map((e) => `<li class="${e.ok ? 'pass' : 'fail'}"><span class="mark">${e.ok ? '✓' : '✕'}</span><span class="tn mono">${esc(e.name)} = ${esc(e.ok ? (e.value.length > 120 ? e.value.slice(0, 117) + '…' : e.value) : '')}</span><span class="note muted small">${esc(e.ok ? `saved ${e.scope === 'env' ? 'in the environment' : 'globally'}` : e.note)}</span></li>`).join('')}</ul>` : '') +
|
|
786
|
+
(pre.length ? `<h4 class="rt-h">Pre-request variables</h4><ul class="test-results">${pre.map((p) => `<li class="pass"><span class="mark">•</span><span class="tn mono">${esc(p.name)} = ${esc(p.value)}</span><span class="note muted small">${esc(p.scope)}</span></li>`).join('')}</ul>` : '');
|
|
787
|
+
const gt = $('#r-goto-tests'); if (gt) gt.addEventListener('click', () => showTab($('#editor-tabs'), 'tests'));
|
|
788
|
+
// request as sent
|
|
789
|
+
const req = result.request || {};
|
|
790
|
+
const unresolved = req.unresolved || [];
|
|
791
|
+
$('#r-request').innerHTML = `<div class="req-sent mono">
|
|
792
|
+
<div class="line"><span class="method m-${esc(req.method)}">${esc(req.method)}</span> ${esc(req.url || '')}</div>
|
|
793
|
+
${(req.params || []).length ? `<div class="muted small">Query params: ${req.params.map(([k, v]) => esc(k) + '=' + esc(v)).join('&')}</div>` : ''}
|
|
794
|
+
<table class="htable"><tbody>${(req.headers || []).map(([k, v]) => `<tr><td class="k">${esc(k)}</td><td class="v">${esc(v)}</td></tr>`).join('')}</tbody></table>
|
|
795
|
+
${req.auth_type && req.auth_type !== 'none' ? `<div class="muted small">Auth: ${esc(AUTH_LABELS[req.auth_type] || req.auth_type)}${req.auth_from ? ` (from folder ${esc(req.auth_from)})` : ''}</div>` : ''}
|
|
796
|
+
${unresolved.length ? `<div class="warn-box">⚠ Unresolved variables: ${unresolved.map((u) => `<code>{{${esc(u)}}}</code>`).join(' ')}</div>` : ''}
|
|
797
|
+
${(req.files || []).length ? `<div class="muted small">Files: ${req.files.map((f) => `${esc(f.field)} = ${esc(f.filename)} (${fmtBytes(f.size)})`).join(', ')}</div>` : ''}
|
|
798
|
+
${req.body ? `<pre class="rv-pre mono">${esc(req.body)}</pre>` : '<div class="muted small">No body</div>'}
|
|
799
|
+
</div>`;
|
|
800
|
+
$('#r-save').disabled = !resp;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ================================================================ history
|
|
804
|
+
const hist = { q: '', status: '', loaded: false, timer: null, compare: null };
|
|
805
|
+
// reload the request history after a send, but only while it is on screen
|
|
806
|
+
function refreshHistoryList() {
|
|
807
|
+
const panel = $('#runs-panels .tab-panel[data-panel="requests"]');
|
|
808
|
+
if (panel && !panel.hidden && runsPanelShown()) loadHistoryList();
|
|
809
|
+
}
|
|
810
|
+
async function loadHistoryList() {
|
|
811
|
+
const ul = $('#history-list');
|
|
812
|
+
renderCompareBanner();
|
|
813
|
+
try {
|
|
814
|
+
const items = await api('/history?q=' + encodeURIComponent(hist.q) + '&status=' + encodeURIComponent(hist.status));
|
|
815
|
+
hist.loaded = true;
|
|
816
|
+
if (!items.length) { ul.innerHTML = `<li class="empty small">${hist.q || hist.status ? 'Nothing matches.' : 'Nothing sent yet. Every request you send or run appears here.'}</li>`; return; }
|
|
817
|
+
let lastDay = '';
|
|
818
|
+
ul.innerHTML = items.map((h) => {
|
|
819
|
+
const day = new Date(h.ts * 1000).toLocaleDateString();
|
|
820
|
+
const head = day !== lastDay ? `<li class="h-day">${esc(day)}</li>` : '';
|
|
821
|
+
lastDay = day;
|
|
822
|
+
const picked = hist.compare && hist.compare.id === h.id;
|
|
823
|
+
return head + `<li class="req-item h-item${picked ? ' compare-base' : ''}" data-hid="${h.id}" title="${esc(h.request_name)}${h.body_name ? ' · ' + esc(h.body_name) : ''} (${esc(h.source)}${h.duration_ms != null ? ', ' + esc(fmtMs(h.duration_ms)) : ''})">
|
|
824
|
+
<span class="h-time muted">${esc(new Date(h.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))}</span>
|
|
825
|
+
<span class="method m-${esc(h.method)}">${esc(h.method)}</span>
|
|
826
|
+
<div style="min-width:0"><div class="name">${esc(h.request_name || h.url)}${h.body_name ? ` <span class="bn">· ${esc(h.body_name)}</span>` : ''}</div><div class="sub">${esc(h.url)}</div></div>
|
|
827
|
+
<span class="h-code ${codeClass(h.status_code)}">${h.status_code ?? 'ERR'}</span>
|
|
828
|
+
<button class="h-resend" title="Send again">↻</button>
|
|
829
|
+
<button class="more" title="More actions">⋯</button></li>`;
|
|
830
|
+
}).join('');
|
|
831
|
+
$$('.h-item', ul).forEach((li) => {
|
|
832
|
+
li.addEventListener('click', () => {
|
|
833
|
+
if (hist.compare && hist.compare.id !== li.dataset.hid) { const a = hist.compare.id; setCompareBase(null); openCompare(a, li.dataset.hid); return; }
|
|
834
|
+
openHistoryEntry(li.dataset.hid);
|
|
835
|
+
});
|
|
836
|
+
$('.h-resend', li).addEventListener('click', (e) => { e.stopPropagation(); resendHistory(li.dataset.hid, e.currentTarget); });
|
|
837
|
+
$('.more', li).addEventListener('click', (e) => { e.stopPropagation(); openHistoryMenu(li.dataset.hid, e.currentTarget); });
|
|
838
|
+
li.addEventListener('contextmenu', (e) => { e.preventDefault(); openHistoryMenu(li.dataset.hid, li, { x: e.clientX, y: e.clientY }); });
|
|
839
|
+
});
|
|
840
|
+
} catch (e) { ul.innerHTML = `<li class="empty small">${esc(e.message)}</li>`; }
|
|
841
|
+
}
|
|
842
|
+
function openHistoryMenu(hid, anchor, at) {
|
|
843
|
+
openMenu('h:' + hid, anchor, at, [
|
|
844
|
+
{ act: 'view', icon: '👁', label: 'View response', run: () => openHistoryEntry(hid) },
|
|
845
|
+
{ act: 'resend', icon: '↻', label: 'Send again', run: () => resendHistory(hid) },
|
|
846
|
+
{ act: 'compare', icon: '⇄', label: 'Compare with…', run: () => pickCompare(hid) },
|
|
847
|
+
{ act: 'restore', icon: '↩', label: 'Open as new request', run: () => restoreHistory(hid) },
|
|
848
|
+
{ act: 'open', icon: '→', label: 'Go to the saved request', run: () => gotoHistoryRequest(hid) },
|
|
849
|
+
'-',
|
|
850
|
+
{ act: 'del', icon: '🗑', label: 'Remove from history', danger: true, run: async () => { await api('/history/' + hid, { method: 'DELETE' }); if (hist.compare && hist.compare.id === hid) setCompareBase(null); loadHistoryList(); } },
|
|
851
|
+
]);
|
|
852
|
+
}
|
|
853
|
+
async function resendHistory(hid, btn = null) {
|
|
854
|
+
if (btn) { btn.disabled = true; btn.classList.add('spinning'); }
|
|
855
|
+
try {
|
|
856
|
+
const r = await api(`/history/${hid}/resend`, { method: 'POST' });
|
|
857
|
+
toast(r.status_code != null ? `Sent again: ${r.status_code}` : `Sent again: ${r.error || r.status}`, r.status_code != null && r.status_code < 400 ? 'ok' : 'error');
|
|
858
|
+
await loadHistoryList();
|
|
859
|
+
pollConsole();
|
|
860
|
+
return r;
|
|
861
|
+
} catch (e) { toast(e.message, 'error'); return null; }
|
|
862
|
+
finally { if (btn) { btn.disabled = false; btn.classList.remove('spinning'); } }
|
|
863
|
+
}
|
|
864
|
+
async function gotoHistoryRequest(hid) {
|
|
865
|
+
const h = await api('/history/' + hid);
|
|
866
|
+
if (h.request_id && state.requests.some((r) => r.id === h.request_id)) openRequest(h.request_id);
|
|
867
|
+
else { toast('That request no longer exists; opening a copy', 'error'); restoreHistory(hid, h); }
|
|
868
|
+
}
|
|
869
|
+
async function restoreHistory(hid, h = null) {
|
|
870
|
+
h = h || await api('/history/' + hid);
|
|
871
|
+
await saveNow();
|
|
872
|
+
const snap = h.snapshot || {};
|
|
873
|
+
const created = await api('/requests', { method: 'POST', body: { ...snap, name: (snap.name || 'Request') + ' (from history)', folder_id: snap.folder_id || null } });
|
|
874
|
+
await loadRequests();
|
|
875
|
+
await openRequest(created.id);
|
|
876
|
+
toast('Opened a copy from history', 'ok');
|
|
877
|
+
}
|
|
878
|
+
async function openHistoryEntry(hid) {
|
|
879
|
+
const h = await api('/history/' + hid);
|
|
880
|
+
const d = h.detail || {};
|
|
881
|
+
openModal(`${h.method} ${h.request_name || h.url}`, `
|
|
882
|
+
<div class="row"><span class="status ${codeClass(h.status_code)}">${h.status_code == null ? 'Error' : `${h.status_code} ${esc(d.reason || '')}`}</span>
|
|
883
|
+
<span class="muted small">${fmtMs(h.duration_ms)}</span><span class="muted small">${d.response ? fmtBytes(d.response.size) : ''}</span>
|
|
884
|
+
<span class="muted small">${esc(fmtDate(h.ts))}</span><span class="muted small">${esc(h.source)}${h.environment ? ' · ' + esc(h.environment) : ''}</span>
|
|
885
|
+
<span class="grow"></span>
|
|
886
|
+
<button id="h-resend" class="small" title="Send this request again and compare the new response with this one">↻ Send again & compare</button>
|
|
887
|
+
<button id="h-compare" class="small" title="Pick another history entry to compare with">⇄ Compare with…</button>
|
|
888
|
+
<button id="h-restore" class="small">Open as new request</button></div>
|
|
889
|
+
${h.error ? `<pre class="mono">${esc(h.error)}</pre>` : ''}
|
|
890
|
+
<div class="detail-section"><h4>Response body</h4><div id="h-viewer"></div></div>
|
|
891
|
+
${(d.tests || []).length ? `<div class="detail-section"><h4>Tests</h4><ul class="test-results">${d.tests.map((t) => `<li class="${t.passed ? 'pass' : 'fail'}"><span class="mark">${t.passed ? '✓' : '✕'}</span><span class="tn">${esc(t.name)}</span><span class="note muted small">${esc(t.note || '')}</span></li>`).join('')}</ul></div>` : ''}
|
|
892
|
+
<div class="detail-section"><h4>Response headers</h4><pre class="mono">${esc(((d.response || {}).headers || []).map(([k, v]) => `${k}: ${v}`).join('\n'))}</pre></div>
|
|
893
|
+
<div class="detail-section"><h4>Request as sent</h4><pre class="mono">${esc(formatRequest(d.request))}</pre></div>
|
|
894
|
+
`, [{ label: 'Close', onClick: closeModal }]);
|
|
895
|
+
if (d.response) mountViewer($('#h-viewer'), d.response); else $('#h-viewer').innerHTML = '<div class="muted small">No response</div>';
|
|
896
|
+
$('#h-restore').addEventListener('click', () => { closeModal(); restoreHistory(hid, h); });
|
|
897
|
+
$('#h-compare').addEventListener('click', () => { closeModal(); pickCompare(hid); });
|
|
898
|
+
$('#h-resend').addEventListener('click', async (e) => {
|
|
899
|
+
const r = await resendHistory(hid, e.currentTarget);
|
|
900
|
+
if (r && r.id) { closeModal(); openCompare(hid, r.id); }
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ---- compare two history entries
|
|
905
|
+
async function pickCompare(hid) {
|
|
906
|
+
const h = await api('/history/' + hid).catch(() => null);
|
|
907
|
+
if (!h) return;
|
|
908
|
+
setCompareBase({ id: hid, label: `${h.method} ${h.request_name || h.url} · ${new Date(h.ts * 1000).toLocaleTimeString()}` });
|
|
909
|
+
showTab($('#runs-tabs'), 'requests');
|
|
910
|
+
if (!runsPanelShown()) setRunsPanel(true);
|
|
911
|
+
loadHistoryList();
|
|
912
|
+
}
|
|
913
|
+
function setCompareBase(c) {
|
|
914
|
+
hist.compare = c;
|
|
915
|
+
renderCompareBanner();
|
|
916
|
+
$$('#history-list .h-item').forEach((li) => li.classList.toggle('compare-base', !!c && li.dataset.hid === c.id));
|
|
917
|
+
}
|
|
918
|
+
function renderCompareBanner() {
|
|
919
|
+
const box = $('#history-compare');
|
|
920
|
+
if (!box) return;
|
|
921
|
+
box.hidden = !hist.compare;
|
|
922
|
+
if (!hist.compare) { box.innerHTML = ''; return; }
|
|
923
|
+
box.innerHTML = `<span>⇄ Click another entry to compare with <strong>${esc(hist.compare.label)}</strong></span><button class="link small" id="h-compare-cancel">Cancel</button>`;
|
|
924
|
+
$('#h-compare-cancel', box).addEventListener('click', () => setCompareBase(null));
|
|
925
|
+
}
|
|
926
|
+
// stable text for diffing: JSON pretty-printed with sorted keys, anything else as-is
|
|
927
|
+
function diffText(resp) {
|
|
928
|
+
if (!resp) return '';
|
|
929
|
+
const text = resp.body_text || '';
|
|
930
|
+
if (resp.is_json) {
|
|
931
|
+
const sortKeys = (v) => (Array.isArray(v) ? v.map(sortKeys) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v);
|
|
932
|
+
try { return JSON.stringify(sortKeys(JSON.parse(text)), null, 2); } catch { /* not JSON after all */ }
|
|
933
|
+
}
|
|
934
|
+
return text;
|
|
935
|
+
}
|
|
936
|
+
// line diff (LCS); falls back to a positional compare for very large inputs
|
|
937
|
+
function lineDiff(a, b) {
|
|
938
|
+
const A = a.split('\n');
|
|
939
|
+
const B = b.split('\n');
|
|
940
|
+
let pre = 0;
|
|
941
|
+
while (pre < A.length && pre < B.length && A[pre] === B[pre]) pre++;
|
|
942
|
+
let suf = 0;
|
|
943
|
+
while (suf < A.length - pre && suf < B.length - pre && A[A.length - 1 - suf] === B[B.length - 1 - suf]) suf++;
|
|
944
|
+
const a2 = A.slice(pre, A.length - suf);
|
|
945
|
+
const b2 = B.slice(pre, B.length - suf);
|
|
946
|
+
const out = A.slice(0, pre).map((s) => ({ t: ' ', s }));
|
|
947
|
+
const n = a2.length;
|
|
948
|
+
const m = b2.length;
|
|
949
|
+
if (n * m > 4e6) {
|
|
950
|
+
a2.forEach((s) => out.push({ t: '-', s }));
|
|
951
|
+
b2.forEach((s) => out.push({ t: '+', s }));
|
|
952
|
+
} else {
|
|
953
|
+
const w = m + 1;
|
|
954
|
+
const L = new Uint32Array((n + 1) * w);
|
|
955
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i * w + j] = a2[i] === b2[j] ? L[(i + 1) * w + j + 1] + 1 : Math.max(L[(i + 1) * w + j], L[i * w + j + 1]);
|
|
956
|
+
let i = 0;
|
|
957
|
+
let j = 0;
|
|
958
|
+
while (i < n && j < m) {
|
|
959
|
+
if (a2[i] === b2[j]) { out.push({ t: ' ', s: a2[i] }); i++; j++; }
|
|
960
|
+
else if (L[(i + 1) * w + j] >= L[i * w + j + 1]) out.push({ t: '-', s: a2[i++] });
|
|
961
|
+
else out.push({ t: '+', s: b2[j++] });
|
|
962
|
+
}
|
|
963
|
+
while (i < n) out.push({ t: '-', s: a2[i++] });
|
|
964
|
+
while (j < m) out.push({ t: '+', s: b2[j++] });
|
|
965
|
+
}
|
|
966
|
+
A.slice(A.length - suf).forEach((s) => out.push({ t: ' ', s }));
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
969
|
+
function renderDiff(ops) {
|
|
970
|
+
const changed = ops.some((o) => o.t !== ' ');
|
|
971
|
+
if (!changed) return '<div class="muted small">Identical.</div>';
|
|
972
|
+
const html = [];
|
|
973
|
+
const CONTEXT = 3;
|
|
974
|
+
let i = 0;
|
|
975
|
+
while (i < ops.length) {
|
|
976
|
+
if (ops[i].t !== ' ') { html.push(`<div class="d-line d-${ops[i].t === '+' ? 'add' : 'del'}"><span class="d-mark">${ops[i].t}</span>${esc(ops[i].s)}</div>`); i++; continue; }
|
|
977
|
+
let j = i;
|
|
978
|
+
while (j < ops.length && ops[j].t === ' ') j++;
|
|
979
|
+
const run = j - i;
|
|
980
|
+
const head = i === 0 ? 0 : CONTEXT;
|
|
981
|
+
const tail = j === ops.length ? 0 : CONTEXT;
|
|
982
|
+
if (run > head + tail + 2) {
|
|
983
|
+
for (let k = i; k < i + head; k++) html.push(`<div class="d-line"><span class="d-mark"> </span>${esc(ops[k].s)}</div>`);
|
|
984
|
+
html.push(`<div class="d-skip">… ${run - head - tail} unchanged line(s) …</div>`);
|
|
985
|
+
for (let k = j - tail; k < j; k++) html.push(`<div class="d-line"><span class="d-mark"> </span>${esc(ops[k].s)}</div>`);
|
|
986
|
+
} else for (let k = i; k < j; k++) html.push(`<div class="d-line"><span class="d-mark"> </span>${esc(ops[k].s)}</div>`);
|
|
987
|
+
i = j;
|
|
988
|
+
}
|
|
989
|
+
return `<div class="diff mono">${html.join('')}</div>`;
|
|
990
|
+
}
|
|
991
|
+
async function openCompare(aId, bId) {
|
|
992
|
+
let a;
|
|
993
|
+
let b;
|
|
994
|
+
try { [a, b] = await Promise.all([api('/history/' + aId), api('/history/' + bId)]); } catch (e) { toast(e.message, 'error'); return; }
|
|
995
|
+
if (a.ts > b.ts) [a, b] = [b, a]; // older on the left / as "−"
|
|
996
|
+
const ra = (a.detail || {}).response;
|
|
997
|
+
const rb = (b.detail || {}).response;
|
|
998
|
+
const hdr = (r) => ((r || {}).headers || []).filter(([k]) => !/^(date|age|expires|x-request-id|x-amzn-requestid|cf-ray|set-cookie|etag|last-modified|content-length)$/i.test(k)).map(([k, v]) => `${k.toLowerCase()}: ${v}`).sort().join('\n');
|
|
999
|
+
const reqText = (h) => formatRequest((h.detail || {}).request);
|
|
1000
|
+
const bodyOps = lineDiff(diffText(ra), diffText(rb));
|
|
1001
|
+
const adds = bodyOps.filter((o) => o.t === '+').length;
|
|
1002
|
+
const dels = bodyOps.filter((o) => o.t === '-').length;
|
|
1003
|
+
const cell = (h, r) => `<td><span class="status ${codeClass(h.status_code)}">${h.status_code ?? 'ERR'}</span></td><td>${fmtMs(h.duration_ms)}</td><td>${r ? fmtBytes(r.size) : '—'}</td><td class="muted small">${esc(fmtDate(h.ts))}<br>${esc(h.source)}${h.environment ? ' · ' + esc(h.environment) : ''}</td>`;
|
|
1004
|
+
openModal('Compare responses', `
|
|
1005
|
+
<table class="cmp-table">
|
|
1006
|
+
<tr><th></th><th>Status</th><th>Time</th><th>Size</th><th>When</th></tr>
|
|
1007
|
+
<tr class="cmp-a"><th>− ${esc(a.method)} ${esc(a.request_name || a.url)}</th>${cell(a, ra)}</tr>
|
|
1008
|
+
<tr class="cmp-b"><th>+ ${esc(b.method)} ${esc(b.request_name || b.url)}</th>${cell(b, rb)}</tr>
|
|
1009
|
+
</table>
|
|
1010
|
+
<div class="detail-section"><h4>Response body <span class="muted small">${adds || dels ? `<span class="ok-text">+${adds}</span> <span class="err-text">−${dels}</span> lines${ra && ra.is_json ? ' · JSON keys sorted' : ''}` : ''}</span></h4>${renderDiff(bodyOps)}</div>
|
|
1011
|
+
<div class="detail-section"><h4>Response headers <span class="muted small">(volatile ones such as Date are left out)</span></h4>${renderDiff(lineDiff(hdr(ra), hdr(rb)))}</div>
|
|
1012
|
+
<div class="detail-section"><h4>Request as sent</h4>${renderDiff(lineDiff(reqText(a), reqText(b)))}</div>
|
|
1013
|
+
`, [{ label: 'Close', onClick: closeModal }]);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ================================================================ cookies
|
|
1017
|
+
async function openCookieManager() {
|
|
1018
|
+
let cookies = await api('/cookies');
|
|
1019
|
+
const render = () => {
|
|
1020
|
+
const box = $('#cookie-box');
|
|
1021
|
+
if (!box) return;
|
|
1022
|
+
const domains = [...new Set(cookies.map((c) => c.domain))].sort();
|
|
1023
|
+
box.innerHTML = cookies.length ? domains.map((d) => `
|
|
1024
|
+
<div class="cookie-domain"><div class="cd-head"><strong>${esc(d)}</strong><span class="muted small">${cookies.filter((c) => c.domain === d).length} cookie(s)</span><span class="grow"></span><button class="link small danger" data-deldom="${esc(d)}">Delete all</button></div>
|
|
1025
|
+
<table class="htable"><thead><tr><th>Name</th><th>Value</th><th>Path</th><th>Expires</th><th></th><th></th></tr></thead><tbody>
|
|
1026
|
+
${cookies.filter((c) => c.domain === d).map((c) => `<tr><td class="k">${esc(c.name)}</td><td class="v"><input class="mono ck-val" data-domain="${esc(c.domain)}" data-name="${esc(c.name)}" data-path="${esc(c.path)}" value="${esc(c.value)}"></td><td>${esc(c.path)}</td><td>${c.expires ? esc(fmtDate(c.expires)) : 'session'}</td><td>${c.secure ? '<span class="chip">secure</span>' : ''}${c.http_only ? '<span class="chip">httpOnly</span>' : ''}</td><td><button class="kv-del" data-del="1" data-domain="${esc(c.domain)}" data-name="${esc(c.name)}" data-path="${esc(c.path)}" title="Delete">✕</button></td></tr>`).join('')}
|
|
1027
|
+
</tbody></table></div>`).join('') : '<div class="empty small">The cookie jar is empty. Cookies set by responses are stored here and sent with later requests to the same domain, like a browser.</div>';
|
|
1028
|
+
$$('[data-deldom]', box).forEach((b) => b.addEventListener('click', async () => {
|
|
1029
|
+
if (!await confirmDialog('Delete cookies?', `All cookies for ${strong(b.dataset.deldom)} will be removed.`, { okLabel: 'Delete', danger: true })) return;
|
|
1030
|
+
await api('/cookies?domain=' + encodeURIComponent(b.dataset.deldom), { method: 'DELETE' });
|
|
1031
|
+
cookies = await api('/cookies'); render();
|
|
1032
|
+
}));
|
|
1033
|
+
$$('[data-del]', box).forEach((b) => b.addEventListener('click', async () => {
|
|
1034
|
+
await api(`/cookies?domain=${encodeURIComponent(b.dataset.domain)}&name=${encodeURIComponent(b.dataset.name)}&path=${encodeURIComponent(b.dataset.path)}`, { method: 'DELETE' });
|
|
1035
|
+
cookies = await api('/cookies'); render();
|
|
1036
|
+
}));
|
|
1037
|
+
$$('.ck-val', box).forEach((inp) => inp.addEventListener('change', async () => {
|
|
1038
|
+
const c = cookies.find((x) => x.domain === inp.dataset.domain && x.name === inp.dataset.name && x.path === inp.dataset.path);
|
|
1039
|
+
if (!c) return;
|
|
1040
|
+
cookies = await api('/cookies', { method: 'PUT', body: { ...c, value: inp.value } });
|
|
1041
|
+
toast('Cookie updated', 'ok');
|
|
1042
|
+
}));
|
|
1043
|
+
};
|
|
1044
|
+
openModal('Cookies', `
|
|
1045
|
+
<p class="muted small">Stored cookies are sent automatically with matching requests (turn this off per request under Settings, or globally in ⚙ Settings). Edit a value and press Enter to change it.</p>
|
|
1046
|
+
<div class="row tight"><input id="ck-domain" placeholder="domain, e.g. api.example.com" class="mono"><input id="ck-name" placeholder="name" class="mono"><input id="ck-value" placeholder="value" class="mono grow"><button id="ck-add">Add cookie</button></div>
|
|
1047
|
+
<div id="cookie-box"></div>
|
|
1048
|
+
`, [{ label: 'Clear all', cls: 'danger', onClick: async () => {
|
|
1049
|
+
if (!cookies.length) return;
|
|
1050
|
+
if (!await confirmDialog('Clear the cookie jar?', 'Every stored cookie will be removed.', { okLabel: 'Clear all', danger: true })) return;
|
|
1051
|
+
await api('/cookies', { method: 'DELETE' }); cookies = []; render();
|
|
1052
|
+
} }, { label: 'Done', cls: 'primary', onClick: closeModal }]);
|
|
1053
|
+
render();
|
|
1054
|
+
$('#ck-add').addEventListener('click', async () => {
|
|
1055
|
+
const domain = $('#ck-domain').value.trim(), name = $('#ck-name').value.trim();
|
|
1056
|
+
if (!domain || !name) { toast('Domain and name are required', 'error'); return; }
|
|
1057
|
+
try {
|
|
1058
|
+
cookies = await api('/cookies', { method: 'PUT', body: { domain, name, value: $('#ck-value').value, path: '/' } });
|
|
1059
|
+
$('#ck-name').value = ''; $('#ck-value').value = '';
|
|
1060
|
+
render();
|
|
1061
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ========================================================== code snippets
|
|
1066
|
+
async function openCodeModal() {
|
|
1067
|
+
if (!state.current) return;
|
|
1068
|
+
const data = readEditor();
|
|
1069
|
+
let resolved;
|
|
1070
|
+
try { resolved = await api('/resolve?body_id=' + encodeURIComponent(state.activeBodyId || ''), { method: 'POST', body: { ...data, id: state.current.id, folder_id: state.current.folder_id || null } }); }
|
|
1071
|
+
catch (e) { toast('Could not resolve the request: ' + e.message, 'error'); return; }
|
|
1072
|
+
let lang = 'curl';
|
|
1073
|
+
try { lang = localStorage.getItem('pyhttp.snippet') || 'curl'; } catch { /* ignore */ }
|
|
1074
|
+
openModal('Code snippet', `
|
|
1075
|
+
<div class="row tight">
|
|
1076
|
+
<select id="snip-lang">${SNIPPET_LANGS.map(([v, l]) => `<option value="${v}" ${v === lang ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
|
1077
|
+
<span class="muted small grow">Variables and auth are resolved; secrets appear in clear text.${resolved.unresolved && resolved.unresolved.length ? ` ⚠ Unresolved: ${resolved.unresolved.map((u) => `{{${esc(u)}}}`).join(', ')}` : ''}</span>
|
|
1078
|
+
<button id="snip-copy">Copy</button>
|
|
1079
|
+
</div>
|
|
1080
|
+
<pre id="snip-out" class="mono snippet"></pre>`, [{ label: 'Close', onClick: closeModal }]);
|
|
1081
|
+
const render = () => { $('#snip-out').textContent = snippet($('#snip-lang').value, resolved); };
|
|
1082
|
+
$('#snip-lang').addEventListener('change', () => { render(); try { localStorage.setItem('pyhttp.snippet', $('#snip-lang').value); } catch { /* ignore */ } });
|
|
1083
|
+
$('#snip-copy').addEventListener('click', async () => { if (await copyText($('#snip-out').textContent)) toast('Snippet copied', 'ok'); });
|
|
1084
|
+
render();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ======================================================== import / export
|
|
1088
|
+
function importDialog() {
|
|
1089
|
+
openModal('Import', `
|
|
1090
|
+
<div class="import-grid">
|
|
1091
|
+
<button id="imp-file" class="import-card"><strong>From a file</strong><span class="muted small">pyhttp export, Postman collection v2.1 or environment, OpenAPI 3 / Swagger 2 spec (JSON or YAML), browser HAR file</span></button>
|
|
1092
|
+
<button id="imp-paste" class="import-card"><strong>Paste cURL, JSON or YAML</strong><span class="muted small">A <code>curl …</code> command becomes a request; anything else is treated like a file</span></button>
|
|
1093
|
+
</div>
|
|
1094
|
+
<div id="imp-paste-box" hidden>
|
|
1095
|
+
<textarea id="imp-text" class="mono" rows="8" placeholder="curl -X POST https://api.example.com/login -H 'Content-Type: application/json' -d '{"user": "a"}'" spellcheck="false"></textarea>
|
|
1096
|
+
<label class="field">Put the request in <select id="imp-folder"></select></label>
|
|
1097
|
+
</div>`, [
|
|
1098
|
+
{ label: 'Cancel', onClick: closeModal },
|
|
1099
|
+
{ label: 'Import', cls: 'primary', onClick: async () => {
|
|
1100
|
+
const text = ($('#imp-text') || {}).value || '';
|
|
1101
|
+
if (!text.trim()) { toast('Paste something first, or choose a file', 'error'); return; }
|
|
1102
|
+
const folderId = $('#imp-folder').value || null;
|
|
1103
|
+
closeModal();
|
|
1104
|
+
if (/^\s*curl(\.exe)?\s/i.test(text)) await importCurl(text, folderId); else await importText(text);
|
|
1105
|
+
} },
|
|
1106
|
+
]);
|
|
1107
|
+
const sel = $('#imp-folder');
|
|
1108
|
+
folderOptions().forEach((o) => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.label; sel.appendChild(opt); });
|
|
1109
|
+
if (state.current && state.current.folder_id) sel.value = state.current.folder_id;
|
|
1110
|
+
$('#imp-file').addEventListener('click', () => { closeModal(); importFromFile(); });
|
|
1111
|
+
$('#imp-paste').addEventListener('click', () => { $('#imp-paste-box').hidden = false; $('#imp-text').focus(); $('#imp-paste').classList.add('on'); });
|
|
1112
|
+
}
|
|
1113
|
+
async function importFromFile() {
|
|
1114
|
+
if (!native()) { $('#import-file').click(); return; }
|
|
1115
|
+
try {
|
|
1116
|
+
const file = await native().open_file();
|
|
1117
|
+
if (file) await importText(file.content);
|
|
1118
|
+
} catch (e) { toast('Import failed: ' + e.message, 'error'); }
|
|
1119
|
+
}
|
|
1120
|
+
async function importCurl(command, folderId) {
|
|
1121
|
+
try {
|
|
1122
|
+
await saveNow();
|
|
1123
|
+
const r = await api('/import/curl', { method: 'POST', body: { command, folder_id: folderId } });
|
|
1124
|
+
await loadRequests();
|
|
1125
|
+
await openRequest(r.id);
|
|
1126
|
+
toast(`Imported ${r.method} ${r.url}`, 'ok');
|
|
1127
|
+
} catch (e) { toast('cURL import failed: ' + e.message, 'error'); }
|
|
1128
|
+
}
|
|
1129
|
+
async function importPostman(data, kind, info) {
|
|
1130
|
+
if (kind === 'postman_environment') {
|
|
1131
|
+
const res = await api('/import/postman', { method: 'POST', body: { data } });
|
|
1132
|
+
toast(`Imported environment “${info.name}” (${info.variables} variables)`, 'ok');
|
|
1133
|
+
await loadEnvironments();
|
|
1134
|
+
return res;
|
|
1135
|
+
}
|
|
1136
|
+
const titles = { postman_collection: 'Import Postman collection', openapi: `Import OpenAPI ${info.version || ''} spec`, har: 'Import HAR file' };
|
|
1137
|
+
const notes = {
|
|
1138
|
+
postman_collection: 'Pre-request and test scripts run unchanged.',
|
|
1139
|
+
openapi: 'One folder per tag. The server URL becomes the <code>{{baseUrl}}</code> variable on the top folder; bodies are filled from the examples and schemas; auth placeholders such as <code>{{token}}</code> come from the security schemes.',
|
|
1140
|
+
har: `${info.entries} recorded entries; scripts, styles, images and fonts are skipped. One folder per host.`,
|
|
1141
|
+
};
|
|
1142
|
+
const choice = await dialog({
|
|
1143
|
+
title: titles[kind] || 'Import',
|
|
1144
|
+
message: `${strong(info.name)}: ${info.requests} request(s) in ${info.folders} folder(s). ${notes[kind] || ''} Add to your collection or replace everything?`,
|
|
1145
|
+
buttons: [
|
|
1146
|
+
{ label: 'Cancel', value: null, cancel: true },
|
|
1147
|
+
{ label: 'Replace everything', value: 'replace', cls: 'danger' },
|
|
1148
|
+
{ label: 'Add to collection', value: 'add', cls: 'primary', primary: true },
|
|
1149
|
+
],
|
|
1150
|
+
});
|
|
1151
|
+
if (!choice) return null;
|
|
1152
|
+
if (choice === 'replace' && !await confirmDialog('Replace everything?', 'All current requests, folders and environments are deleted and replaced by the file. This cannot be undone.', { okLabel: 'Replace everything', danger: true })) return null;
|
|
1153
|
+
const res = await api('/import/postman', { method: 'POST', body: { data, replace: choice === 'replace' } });
|
|
1154
|
+
toast(`Imported ${res.imported} request(s) in ${res.folders} folder(s)`, 'ok');
|
|
1155
|
+
state.selected = [];
|
|
1156
|
+
if (choice === 'replace' && state.current) { autosave.dirty = false; state.current = null; renderEditor(); }
|
|
1157
|
+
await Promise.all([loadRequests(), loadEnvironments()]);
|
|
1158
|
+
return res;
|
|
1159
|
+
}
|
|
1160
|
+
function exportMenu(anchor) {
|
|
1161
|
+
openMenu('export', anchor, null, [
|
|
1162
|
+
{ act: 'pyhttp', icon: '⬇', label: 'pyhttp JSON (everything)', run: exportAll },
|
|
1163
|
+
{ act: 'postman', icon: '🚀', label: 'Postman collection (v2.1)', run: () => exportPostman() },
|
|
1164
|
+
{ act: 'postman-env', icon: '🌐', label: 'Postman environments', run: () => exportPostman(true) },
|
|
1165
|
+
]);
|
|
1166
|
+
}
|
|
1167
|
+
async function exportPostman(envsOnly = false, folderId = '') {
|
|
1168
|
+
try {
|
|
1169
|
+
const data = await api('/export/postman' + (folderId ? '?folder_id=' + encodeURIComponent(folderId) : ''));
|
|
1170
|
+
if (envsOnly) {
|
|
1171
|
+
if (!data.environments.length) { toast('No environments to export', 'error'); return; }
|
|
1172
|
+
for (const env of data.environments) await downloadText(JSON.stringify(env, null, 2), `${env.name.replace(/[^\w.-]+/g, '_')}.postman_environment.json`);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const name = data.collection.info.name.replace(/[^\w.-]+/g, '_');
|
|
1176
|
+
await downloadText(JSON.stringify(data.collection, null, 2), `${name}.postman_collection.json`);
|
|
1177
|
+
} catch (e) { toast('Export failed: ' + e.message, 'error'); }
|
|
1178
|
+
}
|
|
1179
|
+
async function downloadText(text, filename) {
|
|
1180
|
+
if (native()) {
|
|
1181
|
+
try { const path = await native().save_file(text, filename); if (path) toast('Saved to ' + path, 'ok'); } catch (e) { toast('Save failed: ' + e.message, 'error'); }
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const blob = new Blob([text], { type: 'application/json' });
|
|
1185
|
+
const a = document.createElement('a');
|
|
1186
|
+
a.href = URL.createObjectURL(blob);
|
|
1187
|
+
a.download = filename;
|
|
1188
|
+
a.click();
|
|
1189
|
+
URL.revokeObjectURL(a.href);
|
|
1190
|
+
}
|
|
1191
|
+
async function saveResponseBody() {
|
|
1192
|
+
if (!lastResult || !lastResult.response) return;
|
|
1193
|
+
const resp = lastResult.response;
|
|
1194
|
+
const ct = (resp.content_type || '').split(';')[0].trim();
|
|
1195
|
+
const ext = ct.includes('json') ? '.json' : ct.includes('html') ? '.html' : ct.includes('xml') ? '.xml' : ct.startsWith('image/') ? '.' + ct.split('/')[1].split('+')[0] : ct === 'application/pdf' ? '.pdf' : ct.startsWith('text/') ? '.txt' : '.bin';
|
|
1196
|
+
const base = ((state.current && state.current.name) || 'response').replace(/[^\w.-]+/g, '_');
|
|
1197
|
+
const filename = `${base}${ext}`;
|
|
1198
|
+
if (resp.is_binary) {
|
|
1199
|
+
if (!resp.body_base64) { toast('The body is too large to save from the viewer; use a run to save it to disk', 'error'); return; }
|
|
1200
|
+
if (native() && native().save_file_b64) {
|
|
1201
|
+
try { const p = await native().save_file_b64(resp.body_base64, filename); if (p) toast('Saved to ' + p, 'ok'); } catch (e) { toast('Save failed: ' + e.message, 'error'); }
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const bytes = Uint8Array.from(atob(resp.body_base64), (c) => c.charCodeAt(0));
|
|
1205
|
+
const a = document.createElement('a');
|
|
1206
|
+
a.href = URL.createObjectURL(new Blob([bytes], { type: ct || 'application/octet-stream' }));
|
|
1207
|
+
a.download = filename; a.click(); URL.revokeObjectURL(a.href);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
await downloadText(resp.body_text || '', filename);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// ============================================================ request tabs
|
|
1214
|
+
const tabs = { list: [] }; // [{id}] ; id === 'draft' for the unsaved new request
|
|
1215
|
+
function tabKey(r) { return r ? (r.id || 'draft') : null; }
|
|
1216
|
+
function tabsAdd(r) {
|
|
1217
|
+
const key = tabKey(r);
|
|
1218
|
+
if (!key) return;
|
|
1219
|
+
if (!tabs.list.some((t) => t.id === key)) tabs.list.push({ id: key });
|
|
1220
|
+
renderReqTabs();
|
|
1221
|
+
}
|
|
1222
|
+
function tabsRename(from, to) { tabs.list.forEach((t) => { if (t.id === from) t.id = to; }); renderReqTabs(); }
|
|
1223
|
+
function tabsRemove(key) { tabs.list = tabs.list.filter((t) => t.id !== key); renderReqTabs(); }
|
|
1224
|
+
// Re-rendered only when the set of tabs changes: a blur right before a click must not
|
|
1225
|
+
// replace the element being clicked. The "unsaved" dot is updated in place.
|
|
1226
|
+
let tabsSignature = '';
|
|
1227
|
+
function renderReqTabs() {
|
|
1228
|
+
const box = $('#req-tabs');
|
|
1229
|
+
const cur = tabKey(state.current);
|
|
1230
|
+
tabs.list = tabs.list.filter((t) => t.id === 'draft' ? (cur === 'draft') : state.requests.some((r) => r.id === t.id) || t.id === cur);
|
|
1231
|
+
box.hidden = tabs.list.length === 0;
|
|
1232
|
+
const items = tabs.list.map((t) => {
|
|
1233
|
+
const r = t.id === 'draft' ? state.current : (cur === t.id ? state.current : state.requests.find((x) => x.id === t.id));
|
|
1234
|
+
return r ? { key: t.id, name: r.name || 'Untitled', method: r.method, active: t.id === cur } : null;
|
|
1235
|
+
}).filter(Boolean);
|
|
1236
|
+
const sig = JSON.stringify(items);
|
|
1237
|
+
if (sig !== tabsSignature) {
|
|
1238
|
+
tabsSignature = sig;
|
|
1239
|
+
box.innerHTML = items.map((it) => `<div class="req-tab ${it.active ? 'active' : ''}" data-key="${esc(it.key)}" title="${esc(it.name)} (drag to reorder)" draggable="true"><span class="method m-${esc(it.method)}">${esc(it.method)}</span><span class="rt-name">${esc(it.name)}</span><span class="rt-dot" title="Unsaved changes" hidden>●</span><button class="rt-close" title="Close (Ctrl+W)">✕</button></div>`).join('') +
|
|
1240
|
+
'<button class="rt-add" title="New request (Ctrl+N)">+</button>';
|
|
1241
|
+
}
|
|
1242
|
+
$$('.req-tab', box).forEach((el) => { $('.rt-dot', el).hidden = !(el.dataset.key === cur && autosave.dirty); });
|
|
1243
|
+
}
|
|
1244
|
+
// drag a tab left or right to reorder
|
|
1245
|
+
let tabDrag = null;
|
|
1246
|
+
function bindTabDrag(box) {
|
|
1247
|
+
box.addEventListener('dragstart', (e) => {
|
|
1248
|
+
const el = e.target.closest('.req-tab');
|
|
1249
|
+
if (!el) return;
|
|
1250
|
+
tabDrag = el.dataset.key;
|
|
1251
|
+
el.classList.add('drag-src');
|
|
1252
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1253
|
+
e.dataTransfer.setData('text/plain', tabDrag);
|
|
1254
|
+
});
|
|
1255
|
+
box.addEventListener('dragover', (e) => {
|
|
1256
|
+
const el = e.target.closest('.req-tab');
|
|
1257
|
+
if (!el || !tabDrag || el.dataset.key === tabDrag) return;
|
|
1258
|
+
e.preventDefault();
|
|
1259
|
+
e.dataTransfer.dropEffect = 'move';
|
|
1260
|
+
const r = el.getBoundingClientRect();
|
|
1261
|
+
const left = e.clientX < r.left + r.width / 2;
|
|
1262
|
+
$$('.req-tab', box).forEach((t) => t.classList.remove('drop-left', 'drop-right'));
|
|
1263
|
+
el.classList.add(left ? 'drop-left' : 'drop-right');
|
|
1264
|
+
});
|
|
1265
|
+
box.addEventListener('dragleave', (e) => { if (!box.contains(e.relatedTarget)) $$('.req-tab', box).forEach((t) => t.classList.remove('drop-left', 'drop-right')); });
|
|
1266
|
+
box.addEventListener('drop', (e) => {
|
|
1267
|
+
const el = e.target.closest('.req-tab');
|
|
1268
|
+
$$('.req-tab', box).forEach((t) => t.classList.remove('drop-left', 'drop-right'));
|
|
1269
|
+
if (!el || !tabDrag || el.dataset.key === tabDrag) return;
|
|
1270
|
+
e.preventDefault();
|
|
1271
|
+
const r = el.getBoundingClientRect();
|
|
1272
|
+
const before = e.clientX < r.left + r.width / 2;
|
|
1273
|
+
const from = tabs.list.findIndex((t) => t.id === tabDrag);
|
|
1274
|
+
if (from === -1) return;
|
|
1275
|
+
const [moved] = tabs.list.splice(from, 1);
|
|
1276
|
+
let to = tabs.list.findIndex((t) => t.id === el.dataset.key);
|
|
1277
|
+
if (!before) to += 1;
|
|
1278
|
+
tabs.list.splice(to, 0, moved);
|
|
1279
|
+
tabsSignature = '';
|
|
1280
|
+
renderReqTabs();
|
|
1281
|
+
});
|
|
1282
|
+
box.addEventListener('dragend', () => { tabDrag = null; $$('.req-tab', box).forEach((t) => t.classList.remove('drag-src', 'drop-left', 'drop-right')); });
|
|
1283
|
+
}
|
|
1284
|
+
function bindReqTabs() {
|
|
1285
|
+
const box = $('#req-tabs');
|
|
1286
|
+
bindTabDrag(box);
|
|
1287
|
+
box.addEventListener('click', (e) => {
|
|
1288
|
+
if (e.target.closest('.rt-add')) { newRequest(); return; }
|
|
1289
|
+
const el = e.target.closest('.req-tab');
|
|
1290
|
+
if (!el) return;
|
|
1291
|
+
if (e.target.closest('.rt-close')) { closeTab(el.dataset.key); return; }
|
|
1292
|
+
if (el.dataset.key !== tabKey(state.current)) openRequest(el.dataset.key);
|
|
1293
|
+
});
|
|
1294
|
+
box.addEventListener('auxclick', (e) => { const el = e.target.closest('.req-tab'); if (el && e.button === 1) closeTab(el.dataset.key); });
|
|
1295
|
+
}
|
|
1296
|
+
async function closeTab(key) {
|
|
1297
|
+
const cur = tabKey(state.current);
|
|
1298
|
+
const i = tabs.list.findIndex((t) => t.id === key);
|
|
1299
|
+
if (i === -1) return;
|
|
1300
|
+
tabs.list.splice(i, 1);
|
|
1301
|
+
if (key === cur) {
|
|
1302
|
+
await saveNow();
|
|
1303
|
+
const next = tabs.list[Math.min(i, tabs.list.length - 1)];
|
|
1304
|
+
if (next) await openRequest(next.id);
|
|
1305
|
+
else { state.current = null; renderEditor(); renderList(); }
|
|
1306
|
+
}
|
|
1307
|
+
renderReqTabs();
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ============================================================== shortcuts
|
|
1311
|
+
function openHelp() {
|
|
1312
|
+
const rows = [
|
|
1313
|
+
['Ctrl+Enter', 'Send the request'], ['Ctrl+S', 'Save now'], ['Ctrl+N', 'New request'], ['Ctrl+D', 'Duplicate the request'],
|
|
1314
|
+
['Ctrl+W', 'Close the current tab'], ['Ctrl+K', 'Search the collection'], ['Ctrl+\\', 'Show or hide the sidebar'], ['Ctrl+Shift+C', 'Code snippet'],
|
|
1315
|
+
['Ctrl+/', 'Comment or uncomment the line(s) in the body'], ['Ctrl+B', 'Beautify the body'], ['Tab / Shift+Tab', 'Indent / outdent in the body'],
|
|
1316
|
+
['Ctrl+F', 'Search in the response body'], ['Ctrl+Alt+C', 'Toggle the console'], ['Escape', 'Close dialogs and menus'], ['?', 'This help'],
|
|
1317
|
+
];
|
|
1318
|
+
openModal('Keyboard shortcuts', `<table class="htable keys"><tbody>${rows.map(([k, d]) => `<tr><td class="k"><kbd>${esc(k).replace(/\+/g, '</kbd>+<kbd>')}</kbd></td><td>${esc(d)}</td></tr>`).join('')}</tbody></table>
|
|
1319
|
+
<p class="muted small">On macOS use ⌘ instead of Ctrl.</p>`, [{ label: 'Close', cls: 'primary', onClick: closeModal }]);
|
|
1320
|
+
}
|
|
1321
|
+
function inEditable(e) {
|
|
1322
|
+
const t = e.target;
|
|
1323
|
+
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
|
|
1324
|
+
}
|
|
1325
|
+
function bindShortcuts() {
|
|
1326
|
+
document.addEventListener('keydown', (e) => {
|
|
1327
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
1328
|
+
if (!$('#modal').hidden || $('.dialog-backdrop')) return;
|
|
1329
|
+
if (mod && !e.shiftKey && e.key.toLowerCase() === 'n') { e.preventDefault(); newRequest(state.current ? state.current.folder_id : null); return; }
|
|
1330
|
+
if (mod && !e.shiftKey && e.key.toLowerCase() === 'd' && state.current && state.current.id) { e.preventDefault(); duplicateRequestById(state.current.id); return; }
|
|
1331
|
+
if (mod && !e.shiftKey && e.key.toLowerCase() === 'w' && state.current) { e.preventDefault(); closeTab(tabKey(state.current)); return; }
|
|
1332
|
+
if (mod && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); if (!sidebarShown()) setSidebar(true); $('#search').focus(); $('#search').select(); return; }
|
|
1333
|
+
if (mod && e.key === '\\') { e.preventDefault(); setSidebar(!sidebarShown()); return; }
|
|
1334
|
+
if (mod && e.shiftKey && e.key.toLowerCase() === 'c' && state.current) { e.preventDefault(); openCodeModal(); return; }
|
|
1335
|
+
if (mod && !e.shiftKey && e.key.toLowerCase() === 'f' && state.current && !$('#response').hidden) {
|
|
1336
|
+
const box = $('#r-body');
|
|
1337
|
+
if (box._focusSearch) { e.preventDefault(); showTab($('#resp-tabs'), 'body'); box._focusSearch(); }
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
if (e.key === '?' && !inEditable(e)) { e.preventDefault(); openHelp(); }
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ================================================================ markdown
|
|
1345
|
+
function mdToHtml(md) {
|
|
1346
|
+
const lines = (md || '').split(/\r?\n/);
|
|
1347
|
+
let html = '', inList = null, inCode = false;
|
|
1348
|
+
const inline = (s) => esc(s)
|
|
1349
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
1350
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
1351
|
+
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
|
1352
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
1353
|
+
.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
|
1354
|
+
const closeList = () => { if (inList) { html += `</${inList}>`; inList = null; } };
|
|
1355
|
+
lines.forEach((line) => {
|
|
1356
|
+
if (line.trim().startsWith('```')) { closeList(); inCode = !inCode; html += inCode ? '<pre><code>' : '</code></pre>'; return; }
|
|
1357
|
+
if (inCode) { html += esc(line) + '\n'; return; }
|
|
1358
|
+
const h = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
1359
|
+
if (h) { closeList(); html += `<h${h[1].length + 1}>${inline(h[2])}</h${h[1].length + 1}>`; return; }
|
|
1360
|
+
const ul = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
1361
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
1362
|
+
if (ul || ol) {
|
|
1363
|
+
const kind = ul ? 'ul' : 'ol';
|
|
1364
|
+
if (inList !== kind) { closeList(); html += `<${kind}>`; inList = kind; }
|
|
1365
|
+
html += `<li>${inline((ul || ol)[1])}</li>`;
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
closeList();
|
|
1369
|
+
if (!line.trim()) { html += ''; return; }
|
|
1370
|
+
html += `<p>${inline(line)}</p>`;
|
|
1371
|
+
});
|
|
1372
|
+
closeList();
|
|
1373
|
+
if (inCode) html += '</code></pre>';
|
|
1374
|
+
return html;
|
|
1375
|
+
}
|
|
1376
|
+
function bindDescriptionPreview() {
|
|
1377
|
+
const btn = $('#btn-desc-preview');
|
|
1378
|
+
const ta = $('#f-description');
|
|
1379
|
+
const box = $('#desc-preview');
|
|
1380
|
+
btn.addEventListener('click', () => {
|
|
1381
|
+
const show = box.hidden;
|
|
1382
|
+
box.hidden = !show;
|
|
1383
|
+
ta.hidden = show;
|
|
1384
|
+
btn.textContent = show ? 'Edit' : 'Preview';
|
|
1385
|
+
if (show) box.innerHTML = mdToHtml(ta.value) || '<span class="muted small">No description yet.</span>';
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
function resetDescriptionPreview() {
|
|
1389
|
+
$('#desc-preview').hidden = true;
|
|
1390
|
+
$('#f-description').hidden = false;
|
|
1391
|
+
$('#btn-desc-preview').textContent = 'Preview';
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// ========================================================= folder settings
|
|
1395
|
+
async function openFolderSettings(f) {
|
|
1396
|
+
const fresh = folderById(f.id) || f;
|
|
1397
|
+
openModal(`Folder settings: ${fresh.name}`, `
|
|
1398
|
+
<p class="muted small">Requests inside this folder inherit these settings (like a Postman collection or folder). Nested folders override their parents.</p>
|
|
1399
|
+
<div class="set-section"><h4>Description</h4><textarea id="fs-desc" rows="2" placeholder="What this folder is for">${esc(fresh.description || '')}</textarea></div>
|
|
1400
|
+
<div class="set-section"><h4>Authorization</h4><div id="fs-auth"></div></div>
|
|
1401
|
+
<div class="set-section"><h4>Headers added to every request</h4><div id="fs-headers" class="kv"></div></div>
|
|
1402
|
+
<div class="set-section"><h4>Variables (collection variables)</h4><p class="muted small">Lowest precedence: environment and global variables with the same name win, request pre-request variables win over those.</p><div id="fs-vars" class="kv"></div></div>
|
|
1403
|
+
`, [
|
|
1404
|
+
{ label: 'Cancel', onClick: closeModal },
|
|
1405
|
+
{ label: 'Save', cls: 'primary', onClick: async () => {
|
|
1406
|
+
const box = $('#fs-auth');
|
|
1407
|
+
const auth = readAuthFields(box, $('#fs-auth-type', box).value, fresh.auth);
|
|
1408
|
+
try {
|
|
1409
|
+
await api('/folders/' + f.id, { method: 'PATCH', body: { description: $('#fs-desc').value, auth, headers: readKv($('#fs-headers')), variables: readKv($('#fs-vars')) } });
|
|
1410
|
+
closeModal();
|
|
1411
|
+
toast('Folder settings saved', 'ok');
|
|
1412
|
+
await loadRequests();
|
|
1413
|
+
if (state.current) await refreshInherited();
|
|
1414
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1415
|
+
} },
|
|
1416
|
+
]);
|
|
1417
|
+
const a = fresh.auth || { type: 'inherit' };
|
|
1418
|
+
const box = $('#fs-auth');
|
|
1419
|
+
box.dataset.folder = f.id;
|
|
1420
|
+
const labels = { ...AUTH_LABELS, inherit: 'Inherit from parent folder' };
|
|
1421
|
+
const draw = (cur) => {
|
|
1422
|
+
box.innerHTML = `<label class="field"><span class="fl">Type</span><select id="fs-auth-type">${Object.entries(labels).map(([k, l]) => `<option value="${k}" ${cur.type === k ? 'selected' : ''}>${l}</option>`).join('')}</select></label><div id="auth-fields">${authFields(cur, 'Requests use the auth of the parent folder, if any.')}</div>`;
|
|
1423
|
+
$('#fs-auth-type', box).addEventListener('change', () => draw(readAuthFields(box, $('#fs-auth-type', box).value, cur)));
|
|
1424
|
+
if (cur.type === 'oauth2') refreshOauthStatus(box);
|
|
1425
|
+
};
|
|
1426
|
+
draw(a);
|
|
1427
|
+
renderKv($('#fs-headers'), fresh.headers || [], true);
|
|
1428
|
+
$('#fs-vars').dataset.secrets = '1';
|
|
1429
|
+
renderKv($('#fs-vars'), fresh.variables || [], true);
|
|
1430
|
+
}
|
|
1431
|
+
async function refreshInherited() {
|
|
1432
|
+
const r = state.current;
|
|
1433
|
+
if (!r) return;
|
|
1434
|
+
inheritedInfo = r.folder_id ? await api(`/folders/${r.folder_id}/inherited`).catch(() => null) : null;
|
|
1435
|
+
if (state.current !== r) return;
|
|
1436
|
+
renderAuthPanel({ ...r, auth: readAuth() });
|
|
1437
|
+
renderInheritedHeaders();
|
|
1438
|
+
updateTabCounts();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// ============================================================ reorder DnD
|
|
1442
|
+
// Dragging a request/folder over the top or bottom half of a sibling inserts it
|
|
1443
|
+
// before/after; dropping on a folder (middle) moves it inside (handled by app.js).
|
|
1444
|
+
let dropMark = null;
|
|
1445
|
+
function reorderTarget(e) {
|
|
1446
|
+
const li = e.target.closest('.req-item, .folder-item');
|
|
1447
|
+
if (!li || !state.drag) return null;
|
|
1448
|
+
const rect = li.getBoundingClientRect();
|
|
1449
|
+
const y = (e.clientY - rect.top) / rect.height;
|
|
1450
|
+
const isFolder = !!li.dataset.fid;
|
|
1451
|
+
if (isFolder && y > 0.25 && y < 0.75) return null; // middle of a folder: move inside
|
|
1452
|
+
const pos = y < 0.5 ? 'before' : 'after';
|
|
1453
|
+
const sameKind = (state.drag.type === 'request') === !isFolder;
|
|
1454
|
+
if (!sameKind) return null;
|
|
1455
|
+
const id = isFolder ? li.dataset.fid : li.dataset.rid;
|
|
1456
|
+
if (id === state.drag.id) return null;
|
|
1457
|
+
return { li, pos, id, isFolder };
|
|
1458
|
+
}
|
|
1459
|
+
function showDropMark(t) {
|
|
1460
|
+
clearDropMark();
|
|
1461
|
+
if (!t) return;
|
|
1462
|
+
t.li.classList.add(t.pos === 'before' ? 'drop-before' : 'drop-after');
|
|
1463
|
+
dropMark = t.li;
|
|
1464
|
+
}
|
|
1465
|
+
function clearDropMark() { if (dropMark) { dropMark.classList.remove('drop-before', 'drop-after'); dropMark = null; } }
|
|
1466
|
+
async function applyReorder(t) {
|
|
1467
|
+
const d = state.drag;
|
|
1468
|
+
if (!d || !t) return false;
|
|
1469
|
+
if (t.isFolder) {
|
|
1470
|
+
const target = folderById(t.id);
|
|
1471
|
+
const parent = target.parent_id || '';
|
|
1472
|
+
if (folderDescendants(d.id).has(target.id)) return false;
|
|
1473
|
+
const siblings = state.folders.filter((f) => (f.parent_id || '') === parent && f.id !== d.id);
|
|
1474
|
+
const idx = siblings.findIndex((f) => f.id === t.id) + (t.pos === 'after' ? 1 : 0);
|
|
1475
|
+
siblings.splice(idx, 0, folderById(d.id));
|
|
1476
|
+
const me = folderById(d.id);
|
|
1477
|
+
if ((me.parent_id || '') !== parent) await api('/folders/' + d.id, { method: 'PATCH', body: { parent_id: parent || null, move: true } });
|
|
1478
|
+
await api('/reorder', { method: 'POST', body: { folder_ids: siblings.map((f) => f.id) } });
|
|
1479
|
+
} else {
|
|
1480
|
+
const target = state.requests.find((r) => r.id === t.id);
|
|
1481
|
+
const parent = target.folder_id || '';
|
|
1482
|
+
const siblings = state.requests.filter((r) => (r.folder_id || '') === parent && r.id !== d.id);
|
|
1483
|
+
const idx = siblings.findIndex((r) => r.id === t.id) + (t.pos === 'after' ? 1 : 0);
|
|
1484
|
+
const me = state.requests.find((r) => r.id === d.id);
|
|
1485
|
+
siblings.splice(idx, 0, me);
|
|
1486
|
+
if ((me.folder_id || '') !== parent) await api(`/requests/${d.id}/move`, { method: 'POST', body: { folder_id: parent || null } });
|
|
1487
|
+
await api('/reorder', { method: 'POST', body: { request_ids: siblings.map((r) => r.id) } });
|
|
1488
|
+
if (state.current && state.current.id === d.id) state.current.folder_id = parent || null;
|
|
1489
|
+
}
|
|
1490
|
+
await loadRequests();
|
|
1491
|
+
return true;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// ========================================================== runner extras
|
|
1495
|
+
const runData = { rows: [], columns: [], name: '' };
|
|
1496
|
+
async function loadDataFile(file) {
|
|
1497
|
+
try {
|
|
1498
|
+
const text = await file.text();
|
|
1499
|
+
const r = await api('/runs/data-file', { method: 'POST', body: { text, filename: file.name } });
|
|
1500
|
+
runData.rows = r.rows; runData.columns = r.columns; runData.name = file.name;
|
|
1501
|
+
renderDataFileInfo();
|
|
1502
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1503
|
+
}
|
|
1504
|
+
async function pickDataFile() {
|
|
1505
|
+
if (native() && native().open_file) {
|
|
1506
|
+
try {
|
|
1507
|
+
const f = await native().open_file('data');
|
|
1508
|
+
if (f) {
|
|
1509
|
+
const r = await api('/runs/data-file', { method: 'POST', body: { text: f.content, filename: f.name } });
|
|
1510
|
+
runData.rows = r.rows; runData.columns = r.columns; runData.name = f.name;
|
|
1511
|
+
renderDataFileInfo();
|
|
1512
|
+
}
|
|
1513
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
$('#data-file').click();
|
|
1517
|
+
}
|
|
1518
|
+
function renderDataFileInfo() {
|
|
1519
|
+
const el = $('#run-data-info');
|
|
1520
|
+
if (!el) return;
|
|
1521
|
+
const it = $('#run-iterations');
|
|
1522
|
+
if (!runData.rows.length) {
|
|
1523
|
+
el.innerHTML = '<span class="muted">none: every iteration uses the same variable values</span>';
|
|
1524
|
+
if (it) it.disabled = false; // a removed variables file hands the iteration count back to the user
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
el.innerHTML = `<strong>${esc(runData.name)}</strong>: ${runData.rows.length} row(s) → ${runData.rows.length} iteration(s). Variables: ${runData.columns.map((c) => `<code>{{${esc(c)}}}</code>`).join(' ')} <button class="link small" id="run-data-clear">remove</button>`;
|
|
1528
|
+
$('#run-data-clear').addEventListener('click', () => { runData.rows = []; runData.columns = []; runData.name = ''; renderDataFileInfo(); });
|
|
1529
|
+
if (it) { it.value = runData.rows.length; it.disabled = true; }
|
|
1530
|
+
}
|
|
1531
|
+
function runReportHtml(run) {
|
|
1532
|
+
const results = run.results || [];
|
|
1533
|
+
const rows = results.map((r) => {
|
|
1534
|
+
const tests = r.tests || [];
|
|
1535
|
+
return `<tr class="${r.status}"><td>${r.position}</td><td>${r.iteration || 1}</td><td>${esc(r.method)}</td><td>${esc(r.request_name)}${r.body_name ? ' · ' + esc(r.body_name) : ''}</td><td>${esc((r.detail || {}).request ? r.detail.request.url : '')}</td><td>${r.status_code ?? 'ERR'}</td><td>${Math.round(r.duration_ms || 0)} ms</td><td>${esc(r.status)}</td><td>${tests.length ? tests.map((t) => `<div class="${t.passed ? 'p' : 'f'}">${t.passed ? '✓' : '✕'} ${esc(t.name)}${t.note ? ` <small>(${esc(t.note)})</small>` : ''}</div>`).join('') : ''}${r.error ? `<div class="f">${esc(r.error)}</div>` : ''}</td></tr>`;
|
|
1536
|
+
}).join('');
|
|
1537
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>pyhttp run: ${esc(run.name)}</title>
|
|
1538
|
+
<style>body{font:14px system-ui,sans-serif;margin:24px;color:#1c2330}h1{font-size:20px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid #d6dbe3;padding:6px 8px;vertical-align:top;text-align:left}th{background:#f0f2f5}tr.failed td{background:#fff1f1}.p{color:#15803d}.f{color:#b91c1c}.sum{display:flex;gap:18px;margin:10px 0 18px}.sum b{font-size:18px;display:block}</style></head>
|
|
1539
|
+
<body><h1>pyhttp run: ${esc(run.name)}</h1>
|
|
1540
|
+
<div class="sum"><div><b>${run.completed}/${run.total}</b>sent</div><div><b class="${run.failed ? 'f' : 'p'}">${run.failed}</b>failed</div><div><b>${run.tests_total - run.tests_failed}/${run.tests_total}</b>tests passed</div><div><b>${esc(run.status)}</b>status</div><div><b>${esc(fmtDate(run.started_at))}</b>started</div>${run.options && run.options.environment ? `<div><b>${esc(run.options.environment)}</b>environment</div>` : ''}</div>
|
|
1541
|
+
<table><thead><tr><th>#</th><th>Iter.</th><th>Method</th><th>Request</th><th>URL</th><th>Status</th><th>Time</th><th>Result</th><th>Tests</th></tr></thead><tbody>${rows}</tbody></table>
|
|
1542
|
+
<p style="color:#5d6877;font-size:12px">Generated by pyhttp ${esc(run.pyhttp_version || '')} · output folder: ${esc(run.output_dir)}</p></body></html>`;
|
|
1543
|
+
}
|
|
1544
|
+
async function exportRunReport(runId, kind) {
|
|
1545
|
+
try {
|
|
1546
|
+
const run = await api(`/runs/${runId}/report`);
|
|
1547
|
+
const base = `pyhttp-run-${(run.name || 'run').replace(/[^\w.-]+/g, '_')}`;
|
|
1548
|
+
if (kind === 'html') await downloadText(runReportHtml(run), base + '.html');
|
|
1549
|
+
else await downloadText(JSON.stringify(run, null, 2), base + '.json');
|
|
1550
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// ================================================================ updates
|
|
1554
|
+
async function checkForUpdate(manual = false, statusEl = null, btn = null) {
|
|
1555
|
+
if (btn) { btn.disabled = true; btn.dataset.label = btn.dataset.label || btn.textContent; btn.innerHTML = '<span class="spin"></span>Checking…'; }
|
|
1556
|
+
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'small'; }
|
|
1557
|
+
try {
|
|
1558
|
+
const u = await api('/update-check' + (manual ? '?force=1' : ''));
|
|
1559
|
+
const pill = $('#btn-update');
|
|
1560
|
+
pill.hidden = !u.available;
|
|
1561
|
+
if (u.available) {
|
|
1562
|
+
pill.textContent = `⬆ Update ${u.latest}`;
|
|
1563
|
+
pill.onclick = () => openUpdateDialog(u);
|
|
1564
|
+
}
|
|
1565
|
+
if (statusEl) {
|
|
1566
|
+
if (u.available) { statusEl.innerHTML = `⬆ Version ${esc(u.latest)} is available (you have ${esc(u.current)}). <button class="link small" id="upd-open">Details</button>`; statusEl.className = 'small ok-text'; $('#upd-open').addEventListener('click', () => openUpdateDialog(u)); }
|
|
1567
|
+
else if (u.reason) { statusEl.textContent = `Could not check: ${u.reason}`; statusEl.className = 'small err-text'; }
|
|
1568
|
+
else { statusEl.textContent = `✓ You have the latest version (${u.current}).`; statusEl.className = 'small ok-text'; }
|
|
1569
|
+
} else if (manual) {
|
|
1570
|
+
if (u.available) openUpdateDialog(u);
|
|
1571
|
+
else toast(u.reason ? `Could not check: ${u.reason}` : `You have the latest version (${u.current})`, u.reason ? 'error' : 'ok');
|
|
1572
|
+
}
|
|
1573
|
+
} catch (e) {
|
|
1574
|
+
if (statusEl) { statusEl.textContent = 'Could not check: ' + e.message; statusEl.className = 'small err-text'; }
|
|
1575
|
+
} finally {
|
|
1576
|
+
if (btn) { btn.disabled = false; btn.textContent = btn.dataset.label; }
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
function openUpdateDialog(u) {
|
|
1580
|
+
const inst = u.install || {};
|
|
1581
|
+
const a = u.asset;
|
|
1582
|
+
const head = `<p>You are on <strong>${esc(u.current)}</strong>; version <strong>${esc(u.latest)}</strong> was published${u.published_at ? ' on ' + esc(new Date(u.published_at).toLocaleDateString()) : ''}.</p>`;
|
|
1583
|
+
const notes = u.notes ? `<div class="set-section"><h4>Release notes</h4><div class="md">${mdToHtml(u.notes)}</div></div>` : '';
|
|
1584
|
+
const download = a
|
|
1585
|
+
? `<p>Download for this computer: <a href="${esc(a.url)}" target="_blank" rel="noopener" id="upd-asset">${esc(a.name)}</a> (${fmtBytes(a.size)}). Close pyhttp, run the new file, and your data stays in place.</p>`
|
|
1586
|
+
: `<p>Get it from the <a href="${esc(u.url)}" target="_blank" rel="noopener">release page</a>.</p>`;
|
|
1587
|
+
const cmds = (inst.commands || []).length
|
|
1588
|
+
? `<div class="set-section"><h4>${esc(inst.commands_title || 'Update from the terminal')}</h4>${inst.commands_note ? `<p class="small muted">${esc(inst.commands_note)}</p>` : ''}<pre class="upd-cmds" id="upd-cmds">${esc(inst.commands.join('\n'))}</pre><button class="link small" id="upd-copy">Copy commands</button></div>`
|
|
1589
|
+
: '';
|
|
1590
|
+
const progress = '<div id="upd-progress" class="upd-progress" hidden></div>';
|
|
1591
|
+
let body;
|
|
1592
|
+
const buttons = [{ label: 'Later', onClick: closeModal }];
|
|
1593
|
+
if (inst.self_update && (inst.kind === 'installer' || inst.kind === 'portable') && a) {
|
|
1594
|
+
body = head + (inst.kind === 'installer'
|
|
1595
|
+
? `<p>pyhttp downloads the installer (${fmtBytes(a.size)}), verifies it, runs it quietly, and opens again on the new version. Your requests and settings stay in place.</p>`
|
|
1596
|
+
: `<p>pyhttp downloads the new <code>${esc(a.name)}</code> (${fmtBytes(a.size)}), verifies it, replaces its own program file and starts again. Your requests and settings stay in place.</p>`)
|
|
1597
|
+
+ progress + notes;
|
|
1598
|
+
buttons.push({ label: 'Download instead', onClick: () => openExternal(a.url) });
|
|
1599
|
+
buttons.push({ label: 'Install and restart', cls: 'primary', onClick: () => startSelfUpdate() });
|
|
1600
|
+
} else if (inst.self_update && inst.kind === 'brew') {
|
|
1601
|
+
body = head + `<p>pyhttp was installed with Homebrew. The update runs <code>brew upgrade --cask pyhttp</code> here, then pyhttp opens again on the new version.</p>` + progress + cmds + notes;
|
|
1602
|
+
buttons.push({ label: 'Update with Homebrew', cls: 'primary', onClick: () => startSelfUpdate() });
|
|
1603
|
+
} else {
|
|
1604
|
+
body = head + download + cmds + notes;
|
|
1605
|
+
buttons.push({ label: a ? 'Download' : 'Open release page', cls: 'primary', onClick: () => openExternal(a ? a.url : u.url) });
|
|
1606
|
+
}
|
|
1607
|
+
openModal(`Update available: ${u.latest}`, body, buttons);
|
|
1608
|
+
const copy = $('#upd-copy');
|
|
1609
|
+
if (copy) copy.addEventListener('click', async () => { if (await copyText($('#upd-cmds').textContent)) toast('Commands copied', 'ok'); });
|
|
1610
|
+
}
|
|
1611
|
+
// Install the update found by checkForUpdate and show its progress until pyhttp restarts.
|
|
1612
|
+
async function startSelfUpdate() {
|
|
1613
|
+
const box = $('#upd-progress');
|
|
1614
|
+
if (!box) return;
|
|
1615
|
+
box.hidden = false;
|
|
1616
|
+
box.innerHTML = '<div class="progress"><div style="width:0%"></div></div><div class="small muted upd-msg" id="upd-msg"><span class="spin"></span>Starting…</div><pre class="upd-log" id="upd-log" hidden></pre>';
|
|
1617
|
+
const foot = $$('#modal-foot button');
|
|
1618
|
+
foot.forEach((b) => { b.disabled = true; });
|
|
1619
|
+
const fail = (msg) => {
|
|
1620
|
+
$('#upd-msg').innerHTML = `<span class="err-text">${esc(msg)}</span>`;
|
|
1621
|
+
$('.progress', box).hidden = true;
|
|
1622
|
+
foot.forEach((b) => { b.disabled = false; });
|
|
1623
|
+
};
|
|
1624
|
+
try { await api('/update/install', { method: 'POST', body: {} }); } catch (e) { fail(e.message); return; }
|
|
1625
|
+
let gone = 0;
|
|
1626
|
+
const poll = async () => {
|
|
1627
|
+
let s;
|
|
1628
|
+
try { s = await api('/update/status'); } catch { if (++gone < 20) setTimeout(poll, 1000); return; } // the server is gone: pyhttp is restarting
|
|
1629
|
+
const bar = $('.progress > div', box);
|
|
1630
|
+
if (s.progress != null) bar.style.width = `${Math.round(s.progress * 100)}%`;
|
|
1631
|
+
else if (s.state === 'restarting') bar.style.width = '100%';
|
|
1632
|
+
if (s.log && s.log.length) { const log = $('#upd-log'); log.hidden = false; log.textContent = s.log.join('\n'); log.scrollTop = log.scrollHeight; }
|
|
1633
|
+
if (s.state === 'error') { fail(s.error || s.message || 'Update failed'); return; }
|
|
1634
|
+
$('#upd-msg').innerHTML = `<span class="spin"></span>${esc(s.message || s.state)}`;
|
|
1635
|
+
if (s.state === 'restarting') { $('#upd-msg').innerHTML = `✓ ${esc(s.message)} You can close this window if it does not close by itself.`; return; }
|
|
1636
|
+
setTimeout(poll, 500);
|
|
1637
|
+
};
|
|
1638
|
+
poll();
|
|
1639
|
+
}
|
|
1640
|
+
function openExternal(url) {
|
|
1641
|
+
if (native() && native().open_url) { native().open_url(url).catch(() => window.open(url, '_blank')); return; }
|
|
1642
|
+
window.open(url, '_blank', 'noopener');
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// ========================================================= runs panel toggle
|
|
1646
|
+
function setRunsPanel(show) {
|
|
1647
|
+
$('.layout').classList.toggle('no-runs', !show);
|
|
1648
|
+
$('#sb-runs').classList.toggle('on', show);
|
|
1649
|
+
try { localStorage.setItem('pyhttp.runs', show ? '1' : '0'); } catch { /* ignore */ }
|
|
1650
|
+
}
|
|
1651
|
+
function runsPanelShown() { return !$('.layout').classList.contains('no-runs'); }
|
|
1652
|
+
|
|
1653
|
+
// ======================================================= resizable panels
|
|
1654
|
+
// Drag the edge of the sidebar, the runs panel or (layout "beside") the response to resize;
|
|
1655
|
+
// double-click an edge to reset. Sizes are remembered per browser.
|
|
1656
|
+
// split: response to the right (share of the width); vsplit: response below (share of the height)
|
|
1657
|
+
const PANEL_SIZES = { side: ['--side-w', 200, 640], runs: ['--runs-w', 260, 900], split: ['--req-grow', 0.2, 0.8], vsplit: ['--req-grow', 0.2, 0.85] };
|
|
1658
|
+
const isBeside = () => $('#editor-section').classList.contains('beside');
|
|
1659
|
+
const splitName = () => (isBeside() ? 'split' : 'vsplit');
|
|
1660
|
+
function applyPanelSize(name, value, remember = true) {
|
|
1661
|
+
const root = document.documentElement.style;
|
|
1662
|
+
const [prop] = PANEL_SIZES[name];
|
|
1663
|
+
if (value == null) {
|
|
1664
|
+
root.removeProperty(prop);
|
|
1665
|
+
if (name === 'split' || name === 'vsplit') root.removeProperty('--resp-grow');
|
|
1666
|
+
} else if (name === 'split' || name === 'vsplit') {
|
|
1667
|
+
root.setProperty('--req-grow', String(value));
|
|
1668
|
+
root.setProperty('--resp-grow', String(1 - value));
|
|
1669
|
+
} else root.setProperty(prop, Math.round(value) + 'px');
|
|
1670
|
+
if (!remember) return;
|
|
1671
|
+
try { if (value == null) localStorage.removeItem('pyhttp.size.' + name); else localStorage.setItem('pyhttp.size.' + name, String(value)); } catch { /* ignore */ }
|
|
1672
|
+
}
|
|
1673
|
+
function savedPanelSize(name) {
|
|
1674
|
+
let v = null;
|
|
1675
|
+
try { v = localStorage.getItem('pyhttp.size.' + name); } catch { /* ignore */ }
|
|
1676
|
+
return v != null && !Number.isNaN(Number(v)) ? Number(v) : null;
|
|
1677
|
+
}
|
|
1678
|
+
function initResizers() {
|
|
1679
|
+
const make = (parent, nameOf, measure) => {
|
|
1680
|
+
const h = document.createElement('div');
|
|
1681
|
+
h.className = 'resizer';
|
|
1682
|
+
h.dataset.resize = typeof nameOf === 'function' ? nameOf() : nameOf;
|
|
1683
|
+
h.title = 'Drag to resize, double-click to reset';
|
|
1684
|
+
parent.appendChild(h);
|
|
1685
|
+
h.addEventListener('pointerdown', (e) => {
|
|
1686
|
+
if (e.button !== 0) return;
|
|
1687
|
+
e.preventDefault();
|
|
1688
|
+
const name = typeof nameOf === 'function' ? nameOf() : nameOf;
|
|
1689
|
+
h.setPointerCapture(e.pointerId);
|
|
1690
|
+
h.classList.add('dragging');
|
|
1691
|
+
document.body.classList.add('resizing');
|
|
1692
|
+
document.body.classList.toggle('rows', name === 'vsplit');
|
|
1693
|
+
const [, min, max] = PANEL_SIZES[name];
|
|
1694
|
+
const move = (ev) => applyPanelSize(name, Math.min(max, Math.max(min, measure(ev))));
|
|
1695
|
+
const up = () => {
|
|
1696
|
+
h.classList.remove('dragging');
|
|
1697
|
+
document.body.classList.remove('resizing', 'rows');
|
|
1698
|
+
h.removeEventListener('pointermove', move);
|
|
1699
|
+
h.removeEventListener('pointerup', up);
|
|
1700
|
+
h.removeEventListener('pointercancel', up);
|
|
1701
|
+
};
|
|
1702
|
+
h.addEventListener('pointermove', move);
|
|
1703
|
+
h.addEventListener('pointerup', up);
|
|
1704
|
+
h.addEventListener('pointercancel', up);
|
|
1705
|
+
});
|
|
1706
|
+
h.addEventListener('dblclick', () => applyPanelSize(typeof nameOf === 'function' ? nameOf() : nameOf, null));
|
|
1707
|
+
};
|
|
1708
|
+
make($('.sidebar'), 'side', (e) => e.clientX - $('.sidebar').getBoundingClientRect().left);
|
|
1709
|
+
make($('#runs-panel'), 'runs', (e) => $('#runs-panel').getBoundingClientRect().right - e.clientX);
|
|
1710
|
+
make($('#response'), splitName, (e) => {
|
|
1711
|
+
const r = $('#editor-form').getBoundingClientRect();
|
|
1712
|
+
return isBeside() ? (e.clientX - r.left) / r.width : (e.clientY - r.top) / r.height;
|
|
1713
|
+
});
|
|
1714
|
+
['side', 'runs'].forEach((name) => { const v = savedPanelSize(name); if (v != null) applyPanelSize(name, v); });
|
|
1715
|
+
applyPanelSize(splitName(), savedPanelSize(splitName()), false);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// ================================================================ sidebar
|
|
1719
|
+
function setSidebar(show) {
|
|
1720
|
+
$('.layout').classList.toggle('no-side', !show);
|
|
1721
|
+
const b = $('#btn-sidebar');
|
|
1722
|
+
b.classList.toggle('on', show);
|
|
1723
|
+
b.title = show ? 'Hide the sidebar (Ctrl+\\)' : 'Show the sidebar (Ctrl+\\)';
|
|
1724
|
+
try { localStorage.setItem('pyhttp.side', show ? '1' : '0'); } catch { /* ignore */ }
|
|
1725
|
+
}
|
|
1726
|
+
function sidebarShown() { return !$('.layout').classList.contains('no-side'); }
|
|
1727
|
+
|
|
1728
|
+
// ================================================= response collapse / max
|
|
1729
|
+
function setResponseMax(on) {
|
|
1730
|
+
$('#editor-form').classList.toggle('resp-max', on);
|
|
1731
|
+
$('#r-max').textContent = on ? '⤡' : '⤢';
|
|
1732
|
+
$('#r-max').title = on ? 'Restore the request editor (Esc)' : 'Maximize the response (Esc restores)';
|
|
1733
|
+
if (on) $('#response').classList.remove('collapsed');
|
|
1734
|
+
}
|
|
1735
|
+
function setResponseCollapsed(on) {
|
|
1736
|
+
$('#response').classList.toggle('collapsed', on);
|
|
1737
|
+
$('#r-collapse').textContent = on ? '▸' : '▾';
|
|
1738
|
+
$('#r-collapse').title = on ? 'Show the response' : 'Hide the response to see the whole request';
|
|
1739
|
+
if (on) setResponseMax(false);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// ================================================================= layout
|
|
1743
|
+
const LAYOUT_ICONS = {
|
|
1744
|
+
below: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2" width="12" height="5" rx="1"/><rect x="2" y="9" width="12" height="5" rx="1" opacity=".45"/></svg>',
|
|
1745
|
+
beside: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2" width="5" height="12" rx="1"/><rect x="9" y="2" width="5" height="12" rx="1" opacity=".45"/></svg>',
|
|
1746
|
+
};
|
|
1747
|
+
function applyLayout(layout) {
|
|
1748
|
+
const beside = layout === 'beside';
|
|
1749
|
+
$('#editor-section').classList.toggle('beside', beside);
|
|
1750
|
+
$$('#sb-layout button[data-layout]').forEach((b) => b.classList.toggle('on', b.dataset.layout === (beside ? 'beside' : 'below')));
|
|
1751
|
+
const rl = $('#r-layout');
|
|
1752
|
+
rl.innerHTML = beside ? LAYOUT_ICONS.below : LAYOUT_ICONS.beside; // the icon shows where a click moves the response
|
|
1753
|
+
rl.title = beside ? 'Move the response below the request' : 'Move the response to the right of the request';
|
|
1754
|
+
const h = $('#response .resizer');
|
|
1755
|
+
if (h) h.dataset.resize = beside ? 'split' : 'vsplit';
|
|
1756
|
+
applyPanelSize(beside ? 'split' : 'vsplit', savedPanelSize(beside ? 'split' : 'vsplit'), false);
|
|
1757
|
+
}
|
|
1758
|
+
// Settings > Appearance fonts: id -> CSS stack. Only Inter is bundled; the others fall back when not installed.
|
|
1759
|
+
const SANS_TAIL = 'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
|
|
1760
|
+
const MONO_TAIL = 'ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace';
|
|
1761
|
+
const UI_FONTS = [
|
|
1762
|
+
['inter', 'Inter (default)', `"Inter Variable", Inter, ${SANS_TAIL}`],
|
|
1763
|
+
['system', 'System default', SANS_TAIL],
|
|
1764
|
+
['segoe', 'Segoe UI', `"Segoe UI", ${SANS_TAIL}`],
|
|
1765
|
+
['roboto', 'Roboto', `Roboto, ${SANS_TAIL}`],
|
|
1766
|
+
['helvetica', 'Helvetica / Arial', `"Helvetica Neue", Helvetica, Arial, ${SANS_TAIL}`],
|
|
1767
|
+
['ubuntu', 'Ubuntu', `Ubuntu, ${SANS_TAIL}`],
|
|
1768
|
+
['verdana', 'Verdana', `Verdana, ${SANS_TAIL}`],
|
|
1769
|
+
];
|
|
1770
|
+
const MONO_FONTS = [
|
|
1771
|
+
['default', 'System monospace (default)', MONO_TAIL],
|
|
1772
|
+
['cascadia', 'Cascadia Code', `"Cascadia Code", "Cascadia Mono", ${MONO_TAIL}`],
|
|
1773
|
+
['jetbrains', 'JetBrains Mono', `"JetBrains Mono", ${MONO_TAIL}`],
|
|
1774
|
+
['consolas', 'Consolas', `Consolas, ${MONO_TAIL}`],
|
|
1775
|
+
['fira', 'Fira Code', `"Fira Code", ${MONO_TAIL}`],
|
|
1776
|
+
['source', 'Source Code Pro', `"Source Code Pro", ${MONO_TAIL}`],
|
|
1777
|
+
['courier', 'Courier New', `"Courier New", ${MONO_TAIL}`],
|
|
1778
|
+
];
|
|
1779
|
+
const fontStack = (list, id) => (list.find((f) => f[0] === id) || list[0])[2];
|
|
1780
|
+
function applyFonts(ui, mono) {
|
|
1781
|
+
const s = document.documentElement.style;
|
|
1782
|
+
const u = fontStack(UI_FONTS, ui), m = fontStack(MONO_FONTS, mono);
|
|
1783
|
+
s.setProperty('--sans', u);
|
|
1784
|
+
s.setProperty('--mono', m);
|
|
1785
|
+
try { localStorage.setItem('pyhttp.uifont', u); localStorage.setItem('pyhttp.monofont', m); } catch { /* ignore */ }
|
|
1786
|
+
}
|
|
1787
|
+
function applyFontSize(px) {
|
|
1788
|
+
document.documentElement.style.setProperty('--editor-font', (px || 13) + 'px');
|
|
1789
|
+
try { localStorage.setItem('pyhttp.fontsize', String(px || 13)); } catch { /* ignore */ }
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// ================================================================== init
|
|
1793
|
+
function initFeatures() {
|
|
1794
|
+
$('button[data-tab="requests"]', $('#runs-tabs')).addEventListener('click', () => loadHistoryList());
|
|
1795
|
+
$$('#history-filters button').forEach((b) => b.addEventListener('click', () => {
|
|
1796
|
+
hist.status = b.dataset.status;
|
|
1797
|
+
$$('#history-filters button').forEach((x) => x.classList.toggle('on', x === b));
|
|
1798
|
+
loadHistoryList();
|
|
1799
|
+
}));
|
|
1800
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && hist.compare && $('#modal').hidden) setCompareBase(null); });
|
|
1801
|
+
$('#history-search').addEventListener('input', (e) => { clearTimeout(hist.timer); hist.timer = setTimeout(() => { hist.q = e.target.value.trim(); loadHistoryList(); }, 150); });
|
|
1802
|
+
$('#btn-history-clear').addEventListener('click', async () => {
|
|
1803
|
+
if (!await confirmDialog('Clear history?', 'Every entry is removed. Run outputs on disk are not touched.', { okLabel: 'Clear', danger: true })) return;
|
|
1804
|
+
await api('/history', { method: 'DELETE' });
|
|
1805
|
+
loadHistoryList();
|
|
1806
|
+
});
|
|
1807
|
+
initBodyEditor();
|
|
1808
|
+
bindReqTabs();
|
|
1809
|
+
bindDescriptionPreview();
|
|
1810
|
+
bindShortcuts();
|
|
1811
|
+
$('#btn-code').addEventListener('click', openCodeModal);
|
|
1812
|
+
$('#btn-cookies').addEventListener('click', openCookieManager);
|
|
1813
|
+
$('#btn-help').addEventListener('click', openHelp);
|
|
1814
|
+
$('#r-save').addEventListener('click', saveResponseBody);
|
|
1815
|
+
$('#f-url').addEventListener('input', renderPathVars);
|
|
1816
|
+
$('#kv-params').addEventListener('input', () => { renderPathVars(); updateTabCounts(); });
|
|
1817
|
+
$('#kv-params').addEventListener('click', (e) => { if (e.target.closest('.kv-del')) setTimeout(() => { renderPathVars(); updateTabCounts(); }, 0); });
|
|
1818
|
+
$('#kv-headers').addEventListener('input', updateTabCounts);
|
|
1819
|
+
$('#kv-headers').addEventListener('click', (e) => { if (e.target.closest('.kv-del')) setTimeout(updateTabCounts, 0); });
|
|
1820
|
+
$('#tests-panel').addEventListener('input', updateTabCounts);
|
|
1821
|
+
$('#tests-panel').addEventListener('change', updateTabCounts);
|
|
1822
|
+
$('#data-file').addEventListener('change', (e) => { if (e.target.files[0]) loadDataFile(e.target.files[0]); e.target.value = ''; });
|
|
1823
|
+
$$('#sb-layout button[data-layout]').forEach((b) => b.addEventListener('click', () => { if (appSettings.layout !== b.dataset.layout) saveSettings({ layout: b.dataset.layout }); }));
|
|
1824
|
+
$('#r-layout').addEventListener('click', () => saveSettings({ layout: appSettings.layout === 'beside' ? 'below' : 'beside' }));
|
|
1825
|
+
$('#btn-sidebar').addEventListener('click', () => setSidebar(!sidebarShown()));
|
|
1826
|
+
try { if (localStorage.getItem('pyhttp.side') === '0') setSidebar(false); } catch { /* ignore */ }
|
|
1827
|
+
initResizers();
|
|
1828
|
+
$('#sb-runs').addEventListener('click', () => setRunsPanel(!runsPanelShown()));
|
|
1829
|
+
$('#runs-close').addEventListener('click', () => setRunsPanel(false));
|
|
1830
|
+
try { setRunsPanel(localStorage.getItem('pyhttp.runs') !== '0'); } catch { setRunsPanel(true); }
|
|
1831
|
+
$('#r-max').addEventListener('click', () => setResponseMax(!$('#editor-form').classList.contains('resp-max')));
|
|
1832
|
+
$('#r-collapse').addEventListener('click', () => setResponseCollapsed(!$('#response').classList.contains('collapsed')));
|
|
1833
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('#editor-form').classList.contains('resp-max') && $('#modal').hidden && !$('.dialog-backdrop')) setResponseMax(false); });
|
|
1834
|
+
$('#sb-proxy').addEventListener('click', openSettings);
|
|
1835
|
+
// reorder: run before app.js's dragover/drop handlers (capture phase)
|
|
1836
|
+
const ul = $('#req-list');
|
|
1837
|
+
ul.addEventListener('dragover', (e) => {
|
|
1838
|
+
const t = reorderTarget(e);
|
|
1839
|
+
if (t) { e.preventDefault(); e.stopImmediatePropagation(); e.dataTransfer.dropEffect = 'move'; clearDropMarks(); showDropMark(t); }
|
|
1840
|
+
else clearDropMark();
|
|
1841
|
+
}, true);
|
|
1842
|
+
ul.addEventListener('drop', async (e) => {
|
|
1843
|
+
const t = reorderTarget(e);
|
|
1844
|
+
if (!t) { clearDropMark(); return; }
|
|
1845
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
1846
|
+
const d = state.drag;
|
|
1847
|
+
state.drag = null;
|
|
1848
|
+
clearDropMark(); clearDropMarks();
|
|
1849
|
+
$$('.dragging').forEach((x) => x.classList.remove('dragging'));
|
|
1850
|
+
try { state.drag = d; await applyReorder(t); } catch (err) { toast(err.message, 'error'); } finally { state.drag = null; }
|
|
1851
|
+
}, true);
|
|
1852
|
+
ul.addEventListener('dragend', clearDropMark);
|
|
1853
|
+
setTimeout(() => checkForUpdate(false), 2500);
|
|
1854
|
+
initMonitors();
|
|
1855
|
+
initMocks();
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// ============================================================== monitors
|
|
1859
|
+
// Scheduled checks: picked requests sent on an interval while the app is open.
|
|
1860
|
+
// The list lives in the runs panel; a failing monitor shows in the status bar.
|
|
1861
|
+
const mon = { list: [], timer: null };
|
|
1862
|
+
|
|
1863
|
+
const fmtInterval = (sec) =>
|
|
1864
|
+
sec % 3600 === 0 ? `${sec / 3600} h` : sec % 60 === 0 ? `${sec / 60} min` : `${sec} s`;
|
|
1865
|
+
|
|
1866
|
+
function agoText(ts) {
|
|
1867
|
+
const s = Math.max(0, Math.round(Date.now() / 1000 - ts));
|
|
1868
|
+
if (s < 60) return `${s} s ago`;
|
|
1869
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
|
1870
|
+
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
|
1871
|
+
return `${Math.round(s / 86400)} d ago`;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
async function loadMonitors(render = true) {
|
|
1875
|
+
let data;
|
|
1876
|
+
try { data = await api('/monitors'); } catch { return; }
|
|
1877
|
+
mon.list = data.monitors;
|
|
1878
|
+
const sb = $('#sb-monitors');
|
|
1879
|
+
sb.hidden = !data.failing;
|
|
1880
|
+
sb.textContent = data.failing === 1 ? '⚠ 1 monitor failing' : `⚠ ${data.failing} monitors failing`;
|
|
1881
|
+
const badge = $('#mon-badge');
|
|
1882
|
+
badge.hidden = !data.failing;
|
|
1883
|
+
badge.textContent = data.failing || '';
|
|
1884
|
+
if (render && $('#mon-list')) renderMonitors();
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// the Monitors dialog (topbar button, or the ⚠ chip in the status bar)
|
|
1888
|
+
function openMonitorsModal() {
|
|
1889
|
+
openModal('Monitors', `
|
|
1890
|
+
<div class="mon-bar">
|
|
1891
|
+
<span class="muted small grow">Picked requests are sent again on a schedule while the app is open. A check fails when a request errors, answers 4xx/5xx, or fails a test; failures show in the status bar and can POST to a webhook.</span>
|
|
1892
|
+
<button id="btn-mon-new" class="primary" title="Monitor requests on a schedule">+ Monitor</button>
|
|
1893
|
+
</div>
|
|
1894
|
+
<div id="mon-list" class="mon-list"></div>
|
|
1895
|
+
`, [{ label: 'Done', cls: 'primary', onClick: closeModal }]);
|
|
1896
|
+
$('#btn-mon-new').addEventListener('click', () => monitorDialog());
|
|
1897
|
+
renderMonitors();
|
|
1898
|
+
loadMonitors(); // refresh in the background; renders again when it lands
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function monSpark(recent) {
|
|
1902
|
+
if (!recent.length) return '<span class="muted small">no checks yet</span>';
|
|
1903
|
+
const max = Math.max(...recent.map((c) => c.duration_ms || 1), 1);
|
|
1904
|
+
return `<span class="mon-spark">${recent.map((c) =>
|
|
1905
|
+
`<i class="${c.ok ? '' : 'bad'}" style="height:${Math.max(18, Math.round((c.duration_ms || 1) / max * 100))}%"` +
|
|
1906
|
+
` title="${new Date(c.ts * 1000).toLocaleString()} · ${c.ok ? 'pass' : `${c.failed} of ${c.total} failed`} · ${fmtMs(c.duration_ms)}"></i>`).join('')}</span>`;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
function renderMonitors() {
|
|
1910
|
+
const box = $('#mon-list');
|
|
1911
|
+
if (!box) return;
|
|
1912
|
+
if (!mon.list.length) {
|
|
1913
|
+
box.innerHTML = '<div class="empty"><p>No monitors yet.</p><p class="muted small">A monitor sends picked requests every few minutes and flags failures (errors, 4xx/5xx, failed tests). Press “+ Monitor”.</p></div>';
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
box.innerHTML = mon.list.map((m) => {
|
|
1917
|
+
const st = !m.enabled ? '<span class="badge">off</span>'
|
|
1918
|
+
: m.last_status === 'fail' ? '<span class="badge failed">fail</span>'
|
|
1919
|
+
: m.last_status === 'pass' ? '<span class="badge ok">pass</span>' : '<span class="badge">waiting</span>';
|
|
1920
|
+
const last = m.last_run_at ? `${agoText(m.last_run_at)}` : 'not run yet';
|
|
1921
|
+
return `<div class="mon-item ${m.enabled ? '' : 'off'}" data-id="${m.id}">
|
|
1922
|
+
<div class="mon-head">
|
|
1923
|
+
<label class="switch" title="Pause or resume this monitor"><input type="checkbox" data-act="toggle" ${m.enabled ? 'checked' : ''}><span></span></label>
|
|
1924
|
+
<span class="mon-name">${esc(m.name)}</span>
|
|
1925
|
+
${st}
|
|
1926
|
+
</div>
|
|
1927
|
+
<div class="mon-meta muted small">${m.request_ids.length} request${m.request_ids.length === 1 ? '' : 's'} · every ${fmtInterval(m.interval_s)} · ${last}${m.webhook_url ? ' · webhook' : ''}</div>
|
|
1928
|
+
${monSpark(m.recent || [])}
|
|
1929
|
+
<div class="mon-actions">
|
|
1930
|
+
<button class="link small" data-act="results">Results</button>
|
|
1931
|
+
<button class="link small" data-act="run">Run now</button>
|
|
1932
|
+
<button class="link small" data-act="edit">Edit</button>
|
|
1933
|
+
<span class="grow"></span>
|
|
1934
|
+
<button class="link small danger" data-act="del">Delete</button>
|
|
1935
|
+
</div>
|
|
1936
|
+
</div>`;
|
|
1937
|
+
}).join('');
|
|
1938
|
+
$$('.mon-item', box).forEach((el) => {
|
|
1939
|
+
const m = mon.list.find((x) => x.id === el.dataset.id);
|
|
1940
|
+
$('[data-act=toggle]', el).addEventListener('change', async (e) => {
|
|
1941
|
+
try { await api('/monitors/' + m.id, { method: 'PATCH', body: { enabled: e.target.checked } }); } catch (err) { toast(err.message, 'error'); }
|
|
1942
|
+
loadMonitors();
|
|
1943
|
+
});
|
|
1944
|
+
$('[data-act=run]', el).addEventListener('click', async (e) => {
|
|
1945
|
+
const btn = e.currentTarget;
|
|
1946
|
+
btn.disabled = true;
|
|
1947
|
+
btn.innerHTML = '<span class="spin"></span>Running…';
|
|
1948
|
+
try {
|
|
1949
|
+
const chk = await api(`/monitors/${m.id}/run-now`, { method: 'POST' });
|
|
1950
|
+
toast(chk.ok ? `${m.name}: all ${chk.total} passed` : `${m.name}: ${chk.failed} of ${chk.total} failed`, chk.ok ? 'ok' : 'error');
|
|
1951
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
1952
|
+
loadMonitors();
|
|
1953
|
+
});
|
|
1954
|
+
$('[data-act=results]', el).addEventListener('click', () => monitorResults(m));
|
|
1955
|
+
$('[data-act=edit]', el).addEventListener('click', () => monitorDialog(m));
|
|
1956
|
+
$('[data-act=del]', el).addEventListener('click', async () => {
|
|
1957
|
+
if (!await confirmDialog('Delete monitor?', `${strong(m.name)} and its check history will be removed. The requests themselves stay.`, { okLabel: 'Delete', danger: true })) return;
|
|
1958
|
+
try { await api('/monitors/' + m.id, { method: 'DELETE' }); } catch (err) { toast(err.message, 'error'); }
|
|
1959
|
+
loadMonitors();
|
|
1960
|
+
});
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
async function monitorResults(m) {
|
|
1965
|
+
let checks = [];
|
|
1966
|
+
try { checks = await api(`/monitors/${m.id}/checks?limit=50`); } catch (err) { toast(err.message, 'error'); return; }
|
|
1967
|
+
const rows = checks.map((c) => {
|
|
1968
|
+
const failedNames = (c.results || []).filter((r) => !r.ok)
|
|
1969
|
+
.map((r) => `${esc(r.name)} (${r.status_code ?? esc((r.error || 'error').slice(0, 60))}${r.tests_failed ? `, ${r.tests_failed} tests` : ''})`).join(', ');
|
|
1970
|
+
return `<tr><td>${new Date(c.ts * 1000).toLocaleString()}</td>
|
|
1971
|
+
<td class="${c.ok ? 'ok-text' : 'err-text'}">${c.ok ? 'pass' : `${c.failed}/${c.total} failed`}</td>
|
|
1972
|
+
<td>${fmtMs(c.duration_ms)}</td><td class="mon-fails">${failedNames || ''}</td></tr>`;
|
|
1973
|
+
}).join('');
|
|
1974
|
+
openModal(`Monitor: ${m.name}`, `
|
|
1975
|
+
<p class="muted small">Every ${fmtInterval(m.interval_s)}, newest first. A check fails when a request errors, answers 4xx/5xx, or fails a test.</p>
|
|
1976
|
+
${checks.length ? `<table class="htable"><tr><th>When</th><th>Result</th><th>Took</th><th>Failures</th></tr>${rows}</table>` : '<p class="muted">No checks yet. “Run now” sends one at once.</p>'}
|
|
1977
|
+
`, [{ label: 'Back to monitors', cls: 'primary', onClick: openMonitorsModal }]);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function monitorDialog(existing = null) {
|
|
1981
|
+
const m = existing || { name: '', request_ids: state.selected.map((x) => x.id), interval_s: 300, webhook_url: '' };
|
|
1982
|
+
const unit = m.interval_s % 3600 === 0 ? 3600 : m.interval_s % 60 === 0 ? 60 : 1;
|
|
1983
|
+
const picked = new Set(m.request_ids);
|
|
1984
|
+
const reqRow = (r) => `<label class="mon-pick-row"><input type="checkbox" value="${r.id}" ${picked.has(r.id) ? 'checked' : ''}>
|
|
1985
|
+
<span class="method m-${esc(r.method)}">${esc(r.method)}</span><span class="mon-pick-name">${esc(r.name)}</span></label>`;
|
|
1986
|
+
openModal(existing ? 'Edit monitor' : 'New monitor', `
|
|
1987
|
+
<label class="field">Name <input id="mon-name" class="grow" value="${esc(m.name)}" placeholder="e.g. Production health"></label>
|
|
1988
|
+
<label class="field">Every <input id="mon-every" type="number" min="1" step="1" value="${m.interval_s / unit}" style="width:80px">
|
|
1989
|
+
<select id="mon-unit"><option value="60" ${unit === 60 ? 'selected' : ''}>minutes</option><option value="1" ${unit === 1 ? 'selected' : ''}>seconds</option><option value="3600" ${unit === 3600 ? 'selected' : ''}>hours</option></select>
|
|
1990
|
+
<span class="muted small">while the app is open</span></label>
|
|
1991
|
+
<label class="field">Webhook <input id="mon-hook" class="grow mono" value="${esc(m.webhook_url || '')}" placeholder="optional: a URL to POST to when a check fails"></label>
|
|
1992
|
+
<div class="muted small" style="margin-bottom:6px">Requests, sent in this order (extractions and <code>{{prev.…}}</code> work between them):</div>
|
|
1993
|
+
<input id="mon-pick-q" type="search" placeholder="Filter requests…" autocomplete="off" style="width:100%;margin-bottom:6px">
|
|
1994
|
+
<div id="mon-pick" class="mon-pick">${state.requests.map(reqRow).join('') || '<p class="muted small">No requests yet: create one first.</p>'}</div>
|
|
1995
|
+
`, [
|
|
1996
|
+
{ label: 'Cancel', onClick: openMonitorsModal },
|
|
1997
|
+
{ label: existing ? 'Save' : 'Create monitor', cls: 'primary', onClick: async () => {
|
|
1998
|
+
const ids = $$('#mon-pick input:checked').map((i) => i.value);
|
|
1999
|
+
const every = Math.max(1, parseInt($('#mon-every').value, 10) || 5) * parseInt($('#mon-unit').value, 10);
|
|
2000
|
+
const body = { name: $('#mon-name').value.trim() || 'Monitor', request_ids: ids,
|
|
2001
|
+
interval_s: Math.max(10, every), webhook_url: $('#mon-hook').value.trim() };
|
|
2002
|
+
try {
|
|
2003
|
+
if (existing) await api('/monitors/' + existing.id, { method: 'PATCH', body });
|
|
2004
|
+
else await api('/monitors', { method: 'POST', body });
|
|
2005
|
+
toast(existing ? 'Monitor saved' : 'Monitor created: it runs on its schedule while the app is open', 'ok');
|
|
2006
|
+
openMonitorsModal();
|
|
2007
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2008
|
+
} },
|
|
2009
|
+
]);
|
|
2010
|
+
$('#mon-pick-q').addEventListener('input', (e) => {
|
|
2011
|
+
const q = e.target.value.toLowerCase();
|
|
2012
|
+
$$('#mon-pick .mon-pick-row').forEach((row) => { row.hidden = q && !row.textContent.toLowerCase().includes(q); });
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function initMonitors() {
|
|
2017
|
+
$('#btn-monitors').addEventListener('click', openMonitorsModal);
|
|
2018
|
+
$('#sb-monitors').addEventListener('click', openMonitorsModal);
|
|
2019
|
+
loadMonitors(false);
|
|
2020
|
+
mon.timer = setInterval(() => loadMonitors(true), 30000); // renders only while the dialog is open
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// ========================================================== mock servers
|
|
2024
|
+
// Fake endpoints answered by pyhttp itself, on the port it already listens on:
|
|
2025
|
+
// http://127.0.0.1:<port>/mock/<slug>/<route path>. No second server to start,
|
|
2026
|
+
// no second port to remember, and `{{mock_url}}` works whatever the port is.
|
|
2027
|
+
const mocks = { list: [], baseUrl: '', callTimer: null, timer: null };
|
|
2028
|
+
const MOCK_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ANY'];
|
|
2029
|
+
const MOCK_TYPES = ['application/json', 'text/plain', 'text/html', 'application/xml', 'text/csv',
|
|
2030
|
+
'application/javascript', 'application/octet-stream'];
|
|
2031
|
+
const mockById = (id) => mocks.list.find((m) => m.id === id) || null;
|
|
2032
|
+
|
|
2033
|
+
async function loadMocks(render = true) {
|
|
2034
|
+
let data;
|
|
2035
|
+
try { data = await api('/mocks'); } catch { return; }
|
|
2036
|
+
mocks.list = data.mocks;
|
|
2037
|
+
mocks.baseUrl = data.base_url;
|
|
2038
|
+
const badge = $('#mock-badge');
|
|
2039
|
+
badge.hidden = !data.enabled;
|
|
2040
|
+
badge.textContent = data.enabled || '';
|
|
2041
|
+
$('#btn-mocks').title = data.enabled
|
|
2042
|
+
? `${data.enabled} mock server${data.enabled === 1 ? '' : 's'} answering under ${data.base_url}/`
|
|
2043
|
+
: 'Mock servers: fake endpoints answered by pyhttp itself, on the port it already runs on';
|
|
2044
|
+
if (render && $('#mock-list')) renderMocks();
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// the Mocks dialog (topbar → Mocks)
|
|
2048
|
+
function openMocksModal() {
|
|
2049
|
+
openModal('Mock servers', `
|
|
2050
|
+
<div class="mon-bar">
|
|
2051
|
+
<span class="muted small grow">Endpoints answered by pyhttp itself, on the port it already runs on — nothing else to start.
|
|
2052
|
+
Point an app or a request at <code class="mono">${esc(mocks.baseUrl || '/mock')}/<name>/…</code> and it gets your canned answer.</span>
|
|
2053
|
+
<button id="btn-mock-new" class="primary" title="Add a mock server">+ Mock server</button>
|
|
2054
|
+
</div>
|
|
2055
|
+
<div id="mock-list" class="mon-list"></div>
|
|
2056
|
+
`, [{ label: 'Done', cls: 'primary', onClick: closeModal }]);
|
|
2057
|
+
$('#btn-mock-new').addEventListener('click', () => mockDialog());
|
|
2058
|
+
renderMocks();
|
|
2059
|
+
loadMocks(); // refresh in the background; renders again when it lands
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function renderMocks() {
|
|
2063
|
+
const box = $('#mock-list');
|
|
2064
|
+
if (!box) return;
|
|
2065
|
+
if (!mocks.list.length) {
|
|
2066
|
+
box.innerHTML = '<div class="empty"><p>No mock servers yet.</p>'
|
|
2067
|
+
+ '<p class="muted small">A mock server answers a list of routes under <code>/mock/<name>/</code> on this very port, so a front end or a test '
|
|
2068
|
+
+ 'can call an endpoint that does not exist yet. Press “+ Mock server”.</p></div>';
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
box.innerHTML = mocks.list.map((m) => {
|
|
2072
|
+
const all = m.routes || [];
|
|
2073
|
+
const off = all.filter((r) => !r.enabled).length;
|
|
2074
|
+
return `<div class="mon-item mock-item ${m.enabled ? '' : 'off'}" data-id="${m.id}">
|
|
2075
|
+
<div class="mon-head">
|
|
2076
|
+
<label class="switch" title="${m.enabled ? 'Stop answering requests' : 'Start answering requests'}"><input type="checkbox" data-act="toggle" ${m.enabled ? 'checked' : ''}><span></span></label>
|
|
2077
|
+
<span class="mon-name">${esc(m.name)}</span>
|
|
2078
|
+
${m.enabled ? '<span class="badge ok">live</span>' : '<span class="badge">off</span>'}
|
|
2079
|
+
</div>
|
|
2080
|
+
<button class="mock-url mono" data-act="copy" title="Copy this base URL">${esc(m.url)}<span class="mock-copy">Copy</span></button>
|
|
2081
|
+
<div class="mon-meta muted small">${all.length} route${all.length === 1 ? '' : 's'}${off ? ` (${off} off)` : ''}
|
|
2082
|
+
· ${m.hits} call${m.hits === 1 ? '' : 's'} answered${m.delay_ms ? ` · ${fmtMs(m.delay_ms)} delay` : ''}${m.cors ? ' · CORS' : ''}${m.description ? ` · ${esc(m.description)}` : ''}</div>
|
|
2083
|
+
<div class="mon-actions">
|
|
2084
|
+
<button class="link small" data-act="routes">Routes</button>
|
|
2085
|
+
<button class="link small" data-act="calls">Calls</button>
|
|
2086
|
+
<button class="link small" data-act="edit">Edit</button>
|
|
2087
|
+
<span class="grow"></span>
|
|
2088
|
+
<button class="link small danger" data-act="del">Delete</button>
|
|
2089
|
+
</div>
|
|
2090
|
+
</div>`;
|
|
2091
|
+
}).join('');
|
|
2092
|
+
$$('.mock-item', box).forEach((el) => {
|
|
2093
|
+
const m = mockById(el.dataset.id);
|
|
2094
|
+
$('[data-act=toggle]', el).addEventListener('change', async (e) => {
|
|
2095
|
+
try { await api('/mocks/' + m.id, { method: 'PATCH', body: { enabled: e.target.checked } }); }
|
|
2096
|
+
catch (err) { toast(err.message, 'error'); }
|
|
2097
|
+
loadMocks();
|
|
2098
|
+
});
|
|
2099
|
+
$('[data-act=copy]', el).addEventListener('click', async () => {
|
|
2100
|
+
const ok = await copyText(m.url);
|
|
2101
|
+
toast(ok ? `Copied ${m.url}` : 'Could not copy', ok ? 'ok' : 'error');
|
|
2102
|
+
});
|
|
2103
|
+
$('[data-act=routes]', el).addEventListener('click', () => openMockRoutes(m.id));
|
|
2104
|
+
$('[data-act=calls]', el).addEventListener('click', () => openMockCalls(m.id));
|
|
2105
|
+
$('[data-act=edit]', el).addEventListener('click', () => mockDialog(m));
|
|
2106
|
+
$('[data-act=del]', el).addEventListener('click', async () => {
|
|
2107
|
+
if (!await confirmDialog('Delete mock server?', `${strong(m.name)}, its routes and its call log will be removed. Requests pointing at it stay, but stop getting an answer.`, { okLabel: 'Delete', danger: true })) return;
|
|
2108
|
+
try { await api('/mocks/' + m.id, { method: 'DELETE' }); } catch (err) { toast(err.message, 'error'); }
|
|
2109
|
+
loadMocks();
|
|
2110
|
+
});
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function mockDialog(existing = null) {
|
|
2115
|
+
const m = existing || { name: '', slug: '', description: '', delay_ms: 0, cors: true, log_calls: true };
|
|
2116
|
+
openModal(existing ? 'Edit mock server' : 'New mock server', `
|
|
2117
|
+
<label class="field">Name <input id="mk-name" class="grow" value="${esc(m.name)}" placeholder="e.g. Payments API"></label>
|
|
2118
|
+
<label class="field">Base URL <span class="mono muted small">${esc(mocks.baseUrl || '/mock')}/</span>
|
|
2119
|
+
<input id="mk-slug" class="mono" value="${esc(m.slug || '')}" placeholder="from the name" style="width:170px"></label>
|
|
2120
|
+
<label class="field">Note <input id="mk-desc" class="grow" value="${esc(m.description || '')}" placeholder="optional, shown in the list"></label>
|
|
2121
|
+
<label class="field">Slow every answer by <input id="mk-delay" type="number" min="0" step="50" value="${m.delay_ms || 0}" style="width:90px">
|
|
2122
|
+
<span class="muted small">ms — to see how your app behaves on a slow API</span></label>
|
|
2123
|
+
<label class="field"><input type="checkbox" id="mk-cors" ${m.cors ? 'checked' : ''}> Send CORS headers and answer preflights
|
|
2124
|
+
<span class="muted small">(so a browser app on another origin can call it)</span></label>
|
|
2125
|
+
<label class="field"><input type="checkbox" id="mk-log" ${m.log_calls ? 'checked' : ''}> Log the calls it answers
|
|
2126
|
+
<span class="muted small">(the last 200, with their headers and body)</span></label>
|
|
2127
|
+
`, [
|
|
2128
|
+
{ label: 'Cancel', onClick: openMocksModal },
|
|
2129
|
+
{ label: existing ? 'Save' : 'Create mock server', cls: 'primary', onClick: async () => {
|
|
2130
|
+
const body = { name: $('#mk-name').value.trim(), slug: $('#mk-slug').value.trim(),
|
|
2131
|
+
description: $('#mk-desc').value.trim(),
|
|
2132
|
+
delay_ms: Math.max(0, parseFloat($('#mk-delay').value) || 0),
|
|
2133
|
+
cors: $('#mk-cors').checked, log_calls: $('#mk-log').checked };
|
|
2134
|
+
if (!body.name) { toast('Give the mock server a name', 'error'); return; }
|
|
2135
|
+
if (!body.slug) delete body.slug;
|
|
2136
|
+
try {
|
|
2137
|
+
const saved = existing ? await api('/mocks/' + existing.id, { method: 'PATCH', body })
|
|
2138
|
+
: await api('/mocks', { method: 'POST', body });
|
|
2139
|
+
await loadMocks(false);
|
|
2140
|
+
if (existing) { openMocksModal(); toast('Mock server saved', 'ok'); }
|
|
2141
|
+
else { openMockRoutes(saved.id); toast(`Answering on ${saved.url}`, 'ok'); }
|
|
2142
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2143
|
+
} },
|
|
2144
|
+
]);
|
|
2145
|
+
$('#mk-name').focus();
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// ----------------------------------------------------------- routes of one mock
|
|
2149
|
+
function openMockRoutes(mockId) {
|
|
2150
|
+
const m = mockById(mockId);
|
|
2151
|
+
if (!m) { openMocksModal(); return; }
|
|
2152
|
+
const routes = m.routes || [];
|
|
2153
|
+
const rows = routes.map((r, i) => `<tr class="mock-route ${r.enabled ? '' : 'off'}" data-id="${r.id}">
|
|
2154
|
+
<td><label class="switch tiny" title="${r.enabled ? 'Turn this route off' : 'Turn this route on'}"><input type="checkbox" data-act="toggle" ${r.enabled ? 'checked' : ''}><span></span></label></td>
|
|
2155
|
+
<td><span class="method m-${esc(r.method)}">${esc(r.method)}</span></td>
|
|
2156
|
+
<td class="mock-path">${esc(r.path)}${r.match_query ? `<span class="muted">?${esc(r.match_query)}</span>` : ''}${r.name ? `<div class="muted small">${esc(r.name)}</div>` : ''}</td>
|
|
2157
|
+
<td class="${r.status_code >= 400 ? 'err-text' : 'ok-text'}">${r.status_code}</td>
|
|
2158
|
+
<td class="muted">${r.hits || 0}</td>
|
|
2159
|
+
<td class="mock-acts">
|
|
2160
|
+
<button class="link small" data-act="up" title="Move up: the first matching route wins" ${i === 0 ? 'disabled' : ''}>↑</button>
|
|
2161
|
+
<button class="link small" data-act="down" title="Move down" ${i === routes.length - 1 ? 'disabled' : ''}>↓</button>
|
|
2162
|
+
<button class="link small" data-act="edit">Edit</button>
|
|
2163
|
+
<button class="link small" data-act="try" title="Open a new request pointing at this route">Try</button>
|
|
2164
|
+
<button class="link small danger" data-act="del" title="Delete this route">✕</button>
|
|
2165
|
+
</td></tr>`).join('');
|
|
2166
|
+
openModal(`Mock: ${m.name}`, `
|
|
2167
|
+
<div class="mon-bar">
|
|
2168
|
+
<span class="muted small grow">Base URL <code class="mono">${esc(m.url)}</code>, or write <code class="mono">{{mock_url}}/${esc(m.slug)}</code> in a request so it follows the port.
|
|
2169
|
+
Exact paths beat <code>:id</code> ones, and a route with a query condition beats one without.</span>
|
|
2170
|
+
<button id="btn-mock-route-new" class="primary">+ Route</button>
|
|
2171
|
+
<button id="btn-mock-seed" title="Add a route per saved request, answering with the response it last gave">From requests…</button>
|
|
2172
|
+
</div>
|
|
2173
|
+
${routes.length ? `<table class="htable mock-routes"><tr><th></th><th>Method</th><th>Path</th><th>Status</th><th title="Calls this route has answered">Hits</th><th></th></tr>${rows}</table>`
|
|
2174
|
+
: '<div class="empty"><p>No routes yet.</p><p class="muted small">A route is a method, a path and the answer to give back. '
|
|
2175
|
+
+ 'Press “+ Route”, or build one per request you already have.</p></div>'}
|
|
2176
|
+
`, [
|
|
2177
|
+
{ label: 'Back to mocks', onClick: openMocksModal },
|
|
2178
|
+
{ label: 'Calls', onClick: () => openMockCalls(m.id) },
|
|
2179
|
+
{ label: 'Done', cls: 'primary', onClick: closeModal },
|
|
2180
|
+
]);
|
|
2181
|
+
$('#btn-mock-route-new').addEventListener('click', () => mockRouteDialog(m.id));
|
|
2182
|
+
$('#btn-mock-seed').addEventListener('click', () => mockSeedDialog(m.id));
|
|
2183
|
+
$$('.mock-route').forEach((el) => {
|
|
2184
|
+
const r = routes.find((x) => x.id === el.dataset.id);
|
|
2185
|
+
const patch = async (body) => {
|
|
2186
|
+
try { await api(`/mocks/${m.id}/routes/${r.id}`, { method: 'PATCH', body }); } catch (err) { toast(err.message, 'error'); }
|
|
2187
|
+
await loadMocks(false);
|
|
2188
|
+
openMockRoutes(m.id);
|
|
2189
|
+
};
|
|
2190
|
+
const move = async (delta) => {
|
|
2191
|
+
const ids = routes.map((x) => x.id);
|
|
2192
|
+
const at = ids.indexOf(r.id);
|
|
2193
|
+
ids.splice(at + delta, 0, ...ids.splice(at, 1));
|
|
2194
|
+
try { await api(`/mocks/${m.id}/routes/reorder`, { method: 'POST', body: { route_ids: ids } }); } catch (err) { toast(err.message, 'error'); }
|
|
2195
|
+
await loadMocks(false);
|
|
2196
|
+
openMockRoutes(m.id);
|
|
2197
|
+
};
|
|
2198
|
+
$('[data-act=toggle]', el).addEventListener('change', (e) => patch({ enabled: e.target.checked }));
|
|
2199
|
+
$('[data-act=up]', el).addEventListener('click', () => move(-1));
|
|
2200
|
+
$('[data-act=down]', el).addEventListener('click', () => move(1));
|
|
2201
|
+
$('[data-act=edit]', el).addEventListener('click', () => mockRouteDialog(m.id, r));
|
|
2202
|
+
$('[data-act=try]', el).addEventListener('click', () => mockTryRoute(m, r));
|
|
2203
|
+
$('[data-act=del]', el).addEventListener('click', async () => {
|
|
2204
|
+
if (!await confirmDialog('Delete route?', `${strong(`${r.method} ${r.path}`)} will no longer be answered.`, { okLabel: 'Delete', danger: true })) return;
|
|
2205
|
+
try { await api(`/mocks/${m.id}/routes/${r.id}`, { method: 'DELETE' }); } catch (err) { toast(err.message, 'error'); }
|
|
2206
|
+
await loadMocks(false);
|
|
2207
|
+
openMockRoutes(m.id);
|
|
2208
|
+
});
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
function mockRouteDialog(mockId, existing = null) {
|
|
2213
|
+
const m = mockById(mockId);
|
|
2214
|
+
const r = existing || { name: '', method: 'GET', path: '/', status_code: 200, content_type: 'application/json',
|
|
2215
|
+
body: '{\n "ok": true\n}', delay_ms: 0, match_query: '', headers: [], enabled: true };
|
|
2216
|
+
openModal(existing ? 'Edit route' : 'New route', `
|
|
2217
|
+
<datalist id="mock-content-types">${MOCK_TYPES.map((t) => `<option>${t}</option>`).join('')}</datalist>
|
|
2218
|
+
<label class="field">Answers
|
|
2219
|
+
<select id="mr-method" class="method-select m-${esc(r.method)}" title="ANY answers every method">${MOCK_METHODS.map((x) => `<option ${x === r.method ? 'selected' : ''}>${x}</option>`).join('')}</select>
|
|
2220
|
+
<input id="mr-path" class="grow mono" value="${esc(r.path)}" placeholder="/users/:id" spellcheck="false" autocomplete="off"></label>
|
|
2221
|
+
<p class="muted small mock-hint">→ <code class="mono" id="mr-preview"></code><br>
|
|
2222
|
+
<code>:id</code> or <code>{id}</code> captures one segment as <code>{{req.params.id}}</code>; <code>*</code> matches one segment and <code>**</code> the rest of the path.</p>
|
|
2223
|
+
<label class="field">Name <input id="mr-name" class="grow" value="${esc(r.name)}" placeholder="optional, e.g. “user not found”"></label>
|
|
2224
|
+
<div class="field mock-row">
|
|
2225
|
+
<label>Status <input id="mr-status" type="number" min="100" max="599" value="${r.status_code}" style="width:84px"></label>
|
|
2226
|
+
<label>Content-Type <input id="mr-ct" class="mono" value="${esc(r.content_type)}" list="mock-content-types" style="width:200px"></label>
|
|
2227
|
+
<label>Delay <input id="mr-delay" type="number" min="0" step="50" value="${r.delay_ms || 0}" style="width:90px"> <span class="muted small">ms</span></label>
|
|
2228
|
+
</div>
|
|
2229
|
+
<label class="field">Only when the query has
|
|
2230
|
+
<input id="mr-when" class="grow mono" value="${esc(r.match_query || '')}" placeholder="optional: fail=1&kind=card — lets one path give several answers" spellcheck="false" autocomplete="off"></label>
|
|
2231
|
+
<div class="muted small mock-label">Response headers</div>
|
|
2232
|
+
<div id="mr-headers" class="kv"></div>
|
|
2233
|
+
<div class="muted small mock-label">Response body — a template:
|
|
2234
|
+
<code>{{req.params.id}}</code>, <code>{{req.query.q}}</code>, <code>{{req.json.name}}</code>,
|
|
2235
|
+
<code>{{req.headers.x-key}}</code>, <code>{{$guid}}</code> and your own <code>{{variables}}</code></div>
|
|
2236
|
+
<textarea id="mr-body" class="mono mock-body" spellcheck="false" rows="9">${esc(r.body)}</textarea>
|
|
2237
|
+
`, [
|
|
2238
|
+
{ label: 'Cancel', onClick: () => openMockRoutes(mockId) },
|
|
2239
|
+
{ label: existing ? 'Save route' : 'Add route', cls: 'primary', onClick: async () => {
|
|
2240
|
+
const body = {
|
|
2241
|
+
name: $('#mr-name').value.trim(), method: $('#mr-method').value,
|
|
2242
|
+
path: $('#mr-path').value.trim() || '/', status_code: parseInt($('#mr-status').value, 10) || 200,
|
|
2243
|
+
content_type: $('#mr-ct').value.trim() || 'application/json', body: $('#mr-body').value,
|
|
2244
|
+
delay_ms: Math.max(0, parseFloat($('#mr-delay').value) || 0),
|
|
2245
|
+
match_query: $('#mr-when').value.trim().replace(/^\?/, ''),
|
|
2246
|
+
headers: readKv($('#mr-headers')),
|
|
2247
|
+
};
|
|
2248
|
+
try {
|
|
2249
|
+
if (existing) await api(`/mocks/${mockId}/routes/${existing.id}`, { method: 'PATCH', body });
|
|
2250
|
+
else await api(`/mocks/${mockId}/routes`, { method: 'POST', body });
|
|
2251
|
+
await loadMocks(false);
|
|
2252
|
+
openMockRoutes(mockId);
|
|
2253
|
+
toast(existing ? 'Route saved' : `Answering ${body.method} ${body.path}`, 'ok');
|
|
2254
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2255
|
+
} },
|
|
2256
|
+
]);
|
|
2257
|
+
renderKv($('#mr-headers'), r.headers || [], true);
|
|
2258
|
+
const method = $('#mr-method');
|
|
2259
|
+
const preview = () => {
|
|
2260
|
+
$('#mr-preview').textContent = `${method.value} ${m ? m.url : ''}${$('#mr-path').value.trim().replace(/^\/?/, '/').replace(/\/$/, '')}`
|
|
2261
|
+
+ ($('#mr-when').value.trim() ? `?${$('#mr-when').value.trim().replace(/^\?/, '')}` : '');
|
|
2262
|
+
method.className = 'method-select m-' + method.value;
|
|
2263
|
+
};
|
|
2264
|
+
method.addEventListener('change', preview);
|
|
2265
|
+
$('#mr-path').addEventListener('input', preview);
|
|
2266
|
+
$('#mr-when').addEventListener('input', preview);
|
|
2267
|
+
preview();
|
|
2268
|
+
$('#mr-path').focus();
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// Routes from requests already in the collection, answering with what each last returned.
|
|
2272
|
+
function mockSeedDialog(mockId) {
|
|
2273
|
+
const reqRow = (r) => `<label class="mon-pick-row"><input type="checkbox" value="${r.id}">
|
|
2274
|
+
<span class="method m-${esc(r.method)}">${esc(r.method)}</span><span class="mon-pick-name">${esc(r.name)}</span></label>`;
|
|
2275
|
+
openModal('Routes from requests', `
|
|
2276
|
+
<p class="muted small">One route per request: its method and the path part of its URL, answering with the response that request
|
|
2277
|
+
last gave (from the request history) or an empty <code>{}</code> when it has never been sent. Edit any of them afterwards.</p>
|
|
2278
|
+
<input id="mock-seed-q" type="search" placeholder="Filter requests…" autocomplete="off" style="width:100%;margin-bottom:6px">
|
|
2279
|
+
<div id="mock-seed-pick" class="mon-pick">${state.requests.map(reqRow).join('') || '<p class="muted small">No requests yet: create one first.</p>'}</div>
|
|
2280
|
+
`, [
|
|
2281
|
+
{ label: 'Cancel', onClick: () => openMockRoutes(mockId) },
|
|
2282
|
+
{ label: 'Add routes', cls: 'primary', onClick: async () => {
|
|
2283
|
+
const ids = $$('#mock-seed-pick input:checked').map((i) => i.value);
|
|
2284
|
+
if (!ids.length) { toast('Tick at least one request', 'error'); return; }
|
|
2285
|
+
try {
|
|
2286
|
+
const out = await api(`/mocks/${mockId}/from-requests`, { method: 'POST', body: { request_ids: ids } });
|
|
2287
|
+
await loadMocks(false);
|
|
2288
|
+
openMockRoutes(mockId);
|
|
2289
|
+
toast(`Added ${out.added} route${out.added === 1 ? '' : 's'}`, 'ok');
|
|
2290
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2291
|
+
} },
|
|
2292
|
+
]);
|
|
2293
|
+
$('#mock-seed-q').addEventListener('input', (e) => {
|
|
2294
|
+
const q = e.target.value.toLowerCase();
|
|
2295
|
+
$$('#mock-seed-pick .mon-pick-row').forEach((row) => { row.hidden = q && !row.textContent.toLowerCase().includes(q); });
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Open a route as a real request: {{mock_url}} keeps it working whatever port we get.
|
|
2300
|
+
async function mockTryRoute(m, r) {
|
|
2301
|
+
const method = r.method === 'ANY' ? 'GET' : r.method;
|
|
2302
|
+
// {id} is written as :id (what the Params tab fills in), and the wildcards get a sample segment
|
|
2303
|
+
const path = (r.path === '/' ? '' : r.path)
|
|
2304
|
+
.replace(/\{([A-Za-z_]\w*)\}/g, ':$1').replace(/\/\*\*/g, '/sample/path').replace(/\/\*/g, '/sample');
|
|
2305
|
+
const params = (r.match_query || '').split('&').filter(Boolean).map((pair) => {
|
|
2306
|
+
const [key, ...rest] = pair.split('=');
|
|
2307
|
+
return { key, value: rest.join('='), enabled: true };
|
|
2308
|
+
});
|
|
2309
|
+
// a value per path variable, so Send works straight away
|
|
2310
|
+
(path.match(/(?<=\/):([A-Za-z_]\w*)/g) || []).forEach((name) => {
|
|
2311
|
+
params.push({ key: name.slice(1), value: '1', enabled: true });
|
|
2312
|
+
});
|
|
2313
|
+
try {
|
|
2314
|
+
await saveNow();
|
|
2315
|
+
const created = await api('/requests', { method: 'POST', body: {
|
|
2316
|
+
name: r.name || `${method} ${r.path}`,
|
|
2317
|
+
method,
|
|
2318
|
+
url: `{{mock_url}}/${m.slug}${path}`,
|
|
2319
|
+
description: `Mock route in “${m.name}”. \`{{mock_url}}\` is this app's own /mock base, so the URL follows the port.`,
|
|
2320
|
+
params,
|
|
2321
|
+
} });
|
|
2322
|
+
closeModal();
|
|
2323
|
+
await loadRequests();
|
|
2324
|
+
await openRequest(created.id);
|
|
2325
|
+
toast(`New request pointing at ${method} ${r.path}`, 'ok');
|
|
2326
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// ------------------------------------------------------------ the call log
|
|
2330
|
+
async function openMockCalls(mockId) {
|
|
2331
|
+
const m = mockById(mockId);
|
|
2332
|
+
if (!m) { openMocksModal(); return; }
|
|
2333
|
+
let calls = [];
|
|
2334
|
+
try { calls = await api(`/mocks/${m.id}/calls?limit=200`); } catch (err) { toast(err.message, 'error'); return; }
|
|
2335
|
+
openModal(`Calls: ${m.name}`, `
|
|
2336
|
+
<div class="mon-bar">
|
|
2337
|
+
<span class="muted small grow">Every request this mock answered, newest first — click one to see the headers and body it arrived with.
|
|
2338
|
+
${m.log_calls ? 'Refreshes while this is open.' : '<b>Logging is off for this mock</b> (turn it on in Edit).'}</span>
|
|
2339
|
+
<button id="btn-mock-calls-clear" class="danger">Clear</button>
|
|
2340
|
+
</div>
|
|
2341
|
+
<div id="mock-calls-wrap"></div>
|
|
2342
|
+
`, [
|
|
2343
|
+
{ label: 'Routes', onClick: () => openMockRoutes(m.id) },
|
|
2344
|
+
{ label: 'Back to mocks', onClick: openMocksModal },
|
|
2345
|
+
{ label: 'Done', cls: 'primary', onClick: closeModal },
|
|
2346
|
+
]);
|
|
2347
|
+
$('#btn-mock-calls-clear').addEventListener('click', async () => {
|
|
2348
|
+
if (!await confirmDialog('Clear the call log?', `Every logged call of ${strong(m.name)} is removed. Its routes stay.`, { okLabel: 'Clear', danger: true })) return;
|
|
2349
|
+
try { await api(`/mocks/${m.id}/calls`, { method: 'DELETE' }); } catch (err) { toast(err.message, 'error'); }
|
|
2350
|
+
openMockCalls(m.id);
|
|
2351
|
+
});
|
|
2352
|
+
renderMockCalls(m, calls);
|
|
2353
|
+
clearInterval(mocks.callTimer);
|
|
2354
|
+
mocks.callTimer = setInterval(async () => {
|
|
2355
|
+
if (!$('#mock-calls-wrap')) { clearInterval(mocks.callTimer); return; } // dialog closed or moved on
|
|
2356
|
+
try { renderMockCalls(m, await api(`/mocks/${m.id}/calls?limit=200`)); } catch { /* keep what is on screen */ }
|
|
2357
|
+
}, 2000);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function renderMockCalls(m, calls) {
|
|
2361
|
+
const wrap = $('#mock-calls-wrap');
|
|
2362
|
+
if (!wrap) return;
|
|
2363
|
+
if (!calls.length) {
|
|
2364
|
+
wrap.innerHTML = '<div class="empty"><p>No calls yet.</p><p class="muted small">Point something at '
|
|
2365
|
+
+ `<code class="mono">${esc(m.url)}</code> and it shows up here.</p></div>`;
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
wrap.innerHTML = `<table class="htable mock-calls"><tr><th>When</th><th>Method</th><th>Path</th><th>Status</th><th>Took</th><th>Route</th></tr>
|
|
2369
|
+
${calls.map((c) => `<tr class="mock-call ${c.matched ? '' : 'unmatched'}" data-id="${c.id}" title="Click for the request headers and body">
|
|
2370
|
+
<td>${new Date(c.ts * 1000).toLocaleTimeString()}</td>
|
|
2371
|
+
<td><span class="method m-${esc(c.method)}">${esc(c.method)}</span></td>
|
|
2372
|
+
<td class="mock-path">${esc(c.path)}${c.query ? `<span class="muted">?${esc(c.query)}</span>` : ''}</td>
|
|
2373
|
+
<td class="${c.status_code >= 400 ? 'err-text' : 'ok-text'}">${c.status_code}</td>
|
|
2374
|
+
<td>${fmtMs(c.duration_ms)}</td>
|
|
2375
|
+
<td>${c.matched ? esc(c.route_name) : '<span class="err-text">no route matched</span>'}</td></tr>`).join('')}</table>`;
|
|
2376
|
+
$$('.mock-call', wrap).forEach((el) => el.addEventListener('click', () => {
|
|
2377
|
+
const c = calls.find((x) => x.id === el.dataset.id);
|
|
2378
|
+
const headers = Object.entries(c.req_headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
2379
|
+
dialog({ title: `${c.method} ${c.path}${c.query ? `?${c.query}` : ''}`, message: `
|
|
2380
|
+
<p class="muted small">${new Date(c.ts * 1000).toLocaleString()} · answered ${c.status_code} in ${fmtMs(c.duration_ms)}
|
|
2381
|
+
· ${c.matched ? `route ${esc(c.route_name)}` : 'no route matched'}</p>
|
|
2382
|
+
<div class="muted small mock-label">Request headers</div>
|
|
2383
|
+
<pre class="mono mock-pre">${esc(headers) || '<span class="muted">none</span>'}</pre>
|
|
2384
|
+
<div class="muted small mock-label">Request body</div>
|
|
2385
|
+
<pre class="mono mock-pre">${esc(c.req_body) || '<span class="muted">empty</span>'}</pre>
|
|
2386
|
+
`, buttons: [{ label: 'Close', value: null, cancel: true, cls: 'primary', primary: true }] });
|
|
2387
|
+
}));
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function initMocks() {
|
|
2391
|
+
$('#btn-mocks').addEventListener('click', openMocksModal);
|
|
2392
|
+
loadMocks(false);
|
|
2393
|
+
mocks.timer = setInterval(() => loadMocks(true), 30000); // renders only while the dialog is open
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
document.addEventListener('DOMContentLoaded', initFeatures);
|