4bnode 4.1.2 → 4.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/add-security.js +0 -18
  2. package/app-1/.4bnode/.passkey +1 -0
  3. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  4. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  5. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  6. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  7. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  8. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  9. package/app-1/.4bnode/assets/js/App.js +139 -0
  10. package/app-1/.4bnode/assets/js/Root.js +40 -0
  11. package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
  12. package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
  13. package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
  14. package/app-1/.4bnode/assets/js/context.js +7 -0
  15. package/app-1/.4bnode/assets/js/lib.js +113 -0
  16. package/app-1/.4bnode/assets/js/main.js +5 -0
  17. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  18. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
  19. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
  20. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
  21. package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
  22. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
  23. package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
  24. package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
  25. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
  26. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
  27. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
  28. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  29. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
  30. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
  31. package/app-1/.4bnode/codegen.js +1249 -0
  32. package/app-1/.4bnode/dev-api.js +3621 -0
  33. package/app-1/.4bnode/ui.html +1578 -0
  34. package/app-1/README.md +47 -0
  35. package/app-1/ecosystem.config.cjs +25 -0
  36. package/app-1/index.js +145 -0
  37. package/app-1/package.json +35 -0
  38. package/app-1/public/index.html +292 -0
  39. package/app-1/src/db.js +13 -0
  40. package/app-1/src/docs.js +84 -0
  41. package/app-1/src/middleware/errorHandler.js +15 -0
  42. package/app-1/src/middleware/roles.js +14 -0
  43. package/app-1/src/middleware/validate.js +20 -0
  44. package/app-1/src/models/login.js +9 -0
  45. package/app-1/src/openapi.json +62 -0
  46. package/app-1/src/routes/products.js +26 -0
  47. package/app-1/src/validators/products.js +7 -0
  48. package/app-1/src/validators/user.js +7 -0
  49. package/index.js +2 -17
  50. package/lib/codegen.js +91 -44
  51. package/lib/codegen.test.js +23 -3
  52. package/package.json +1 -1
  53. package/skeleton/.4bnode/assets/js/App.js +0 -2
  54. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
  55. package/skeleton/.4bnode/assets/js/lib.js +0 -3
  56. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
  57. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
  58. package/skeleton/.4bnode/codegen.js +91 -44
  59. package/skeleton/.4bnode/dev-api.js +173 -87
  60. package/skeleton/_gitignore +0 -1
  61. package/skeleton/index.js +4 -8
  62. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +0 -164
@@ -0,0 +1,1879 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ import { io as socketIoConnect } from "socket.io-client";
4
+ import { LuChevronLeft, LuCircleDot, LuCopy, LuImport, LuLoader, LuPencil, LuPlugZap, LuPlus, LuRadio, LuSave, LuSend, LuShare2, LuTrash2, LuWifi, LuWifiOff } from "react-icons/lu";
5
+ import { html, api, Icons, copyToClipboard } from "../lib.js";
6
+ import { useApp } from "../context.js";
7
+ import { Modal } from "../components/Modal.js";
8
+
9
+ function SocketIOTester({ selectedSaved, isSaved, loadedTest, onSaveTest, onSaveLog }) {
10
+ const { toast } = useApp();
11
+ const [sioUrl, setSioUrl] = useState('http://localhost:3000');
12
+ const [sioConnected, setSioConnected] = useState(false);
13
+ const [sioSocket, setSioSocket] = useState(null);
14
+ const [sioEvent, setSioEvent] = useState('');
15
+ const [sioData, setSioData] = useState('');
16
+ const [sioListen, setSioListen] = useState('');
17
+ const [sioListeners, setSioListeners] = useState([]);
18
+ const [sioLog, setSioLog] = useState([]);
19
+ const logRef = useRef(null);
20
+
21
+ function addLog(dir, msg) {
22
+ const ts = new Date().toLocaleTimeString();
23
+ setSioLog(prev => [...prev, { dir, msg, ts, id: Date.now() + Math.random() }]);
24
+ }
25
+
26
+ useEffect(() => {
27
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
28
+ }, [sioLog]);
29
+
30
+ useEffect(() => {
31
+ return () => { if (sioSocket) sioSocket.disconnect(); };
32
+ }, []);
33
+
34
+ function connect() {
35
+ if (sioSocket) { sioSocket.disconnect(); setSioSocket(null); }
36
+ try {
37
+ const socket = socketIoConnect(sioUrl, { transports: ['websocket', 'polling'] });
38
+ socket.on('connect', () => {
39
+ setSioConnected(true);
40
+ addLog('system', 'Connected (id: ' + socket.id + ')');
41
+ });
42
+ socket.on('disconnect', (reason) => {
43
+ setSioConnected(false);
44
+ addLog('system', 'Disconnected: ' + reason);
45
+ });
46
+ socket.on('connect_error', (err) => {
47
+ addLog('system', 'Connection error: ' + err.message);
48
+ });
49
+ setSioSocket(socket);
50
+ } catch (err) {
51
+ addLog('system', 'Error: ' + err.message);
52
+ }
53
+ }
54
+
55
+ function disconnect() {
56
+ if (sioSocket) {
57
+ sioSocket.disconnect();
58
+ setSioSocket(null);
59
+ setSioConnected(false);
60
+ }
61
+ }
62
+
63
+ function emitEvent() {
64
+ if (!sioSocket || !sioEvent.trim()) return;
65
+ let parsed = sioData.trim();
66
+ try { parsed = JSON.parse(parsed); } catch {}
67
+ sioSocket.emit(sioEvent, parsed);
68
+ addLog('sent', sioEvent + ': ' + (typeof parsed === 'object' ? JSON.stringify(parsed) : parsed));
69
+ }
70
+
71
+ function addListener() {
72
+ if (!sioSocket || !sioListen.trim() || sioListeners.includes(sioListen.trim())) return;
73
+ const ev = sioListen.trim();
74
+ sioSocket.on(ev, (data) => {
75
+ const display = typeof data === 'object' ? JSON.stringify(data, null, 2) : String(data);
76
+ addLog('received', ev + ': ' + display);
77
+ });
78
+ setSioListeners(prev => [...prev, ev]);
79
+ addLog('system', 'Listening on "' + ev + '"');
80
+ setSioListen('');
81
+ }
82
+
83
+ function removeListener(ev) {
84
+ if (sioSocket) sioSocket.off(ev);
85
+ setSioListeners(prev => prev.filter(e => e !== ev));
86
+ }
87
+
88
+ useEffect(() => {
89
+ if (loadedTest && loadedTest.type === 'socketio') {
90
+ setSioUrl(loadedTest.url || 'http://localhost:3000');
91
+ if (loadedTest.event) setSioEvent(loadedTest.event);
92
+ if (loadedTest.data) setSioData(loadedTest.data);
93
+ }
94
+ }, [loadedTest?.id]);
95
+
96
+ function saveSession() {
97
+ const name = prompt('Name this Socket.IO test:');
98
+ if (!name?.trim()) return;
99
+ onSaveTest({
100
+ name: name.trim(),
101
+ type: 'socketio',
102
+ url: sioUrl,
103
+ event: sioEvent,
104
+ data: sioData,
105
+ listeners: [...sioListeners],
106
+ });
107
+ }
108
+
109
+ function saveLogEntry(entry) {
110
+ if (!selectedSaved || !onSaveLog) return;
111
+ onSaveLog({
112
+ dir: entry.dir,
113
+ label: (entry.dir === 'sent' ? '↑ EMIT ' : entry.dir === 'received' ? '↓ EVENT ' : '● SYS ') + entry.msg.substring(0, 80),
114
+ body: entry.msg,
115
+ time: 0,
116
+ });
117
+ }
118
+
119
+ return html`<div>
120
+ <!-- Connection bar -->
121
+ <div className="api-tester-bar">
122
+ <input className="api-tester-url" placeholder="http://localhost:3000" value=${sioUrl}
123
+ onInput=${(e) => setSioUrl(e.target.value)}
124
+ onKeyDown=${(e) => { if (e.key === 'Enter') connect(); }} />
125
+ ${!sioConnected
126
+ ? html`<button className="api-tester-send" onClick=${connect}>
127
+ <${LuPlugZap} size=${14} /> Connect
128
+ </button>`
129
+ : html`<button className="api-tester-send" onClick=${disconnect} style=${{ background: 'var(--danger)' }}>
130
+ <${LuWifiOff} size=${14} /> Disconnect
131
+ </button>`}
132
+ ${!isSaved && onSaveTest && html`<button className="api-tester-send" onClick=${saveSession}
133
+ style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
134
+ <${LuSave} size=${14} /> Save
135
+ </button>`}
136
+ </div>
137
+
138
+ <div style=${{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
139
+ <!-- Emit -->
140
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
141
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
142
+ Emit Event
143
+ </div>
144
+ <div style=${{ padding: '12px' }}>
145
+ <div className="flex gap-2 mb-3">
146
+ <input className="form-input" style=${{ flex: 1, fontSize: '12px' }} placeholder="Event name"
147
+ value=${sioEvent} onInput=${(e) => setSioEvent(e.target.value)}
148
+ onKeyDown=${(e) => { if (e.key === 'Enter') emitEvent(); }} />
149
+ <button className="btn btn-sm btn-primary" onClick=${emitEvent}
150
+ disabled=${!sioConnected || !sioEvent.trim()}>
151
+ <${LuSend} size=${12} /> Emit
152
+ </button>
153
+ </div>
154
+ <textarea className="api-tester-body-editor" style=${{ minHeight: '80px', fontSize: '11px' }}
155
+ placeholder='{"key": "value"} or plain text'
156
+ value=${sioData} onInput=${(e) => setSioData(e.target.value)} spellcheck=${false} />
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Listeners -->
161
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
162
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
163
+ Event Listeners
164
+ </div>
165
+ <div style=${{ padding: '12px' }}>
166
+ <div className="flex gap-2 mb-3">
167
+ <input className="form-input" style=${{ flex: 1, fontSize: '12px' }} placeholder="Event name to listen"
168
+ value=${sioListen} onInput=${(e) => setSioListen(e.target.value)}
169
+ onKeyDown=${(e) => { if (e.key === 'Enter') addListener(); }} />
170
+ <button className="btn btn-sm btn-ghost" onClick=${addListener}
171
+ disabled=${!sioConnected || !sioListen.trim()}>
172
+ <${LuPlus} size=${12} /> Listen
173
+ </button>
174
+ </div>
175
+ ${sioListeners.length === 0 && html`<div style=${{ fontSize: '12px', color: 'var(--text-muted)', padding: '8px 0' }}>No listeners yet</div>`}
176
+ <div className="flex gap-1" style=${{ flexWrap: 'wrap' }}>
177
+ ${sioListeners.map(ev => html`<span key=${ev} style=${{ display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '3px 8px', borderRadius: '4px', background: 'rgba(52,211,153,0.1)', fontSize: '11px', color: 'var(--success)' }}>
178
+ <${LuCircleDot} size=${10} /> ${ev}
179
+ <button style=${{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '0 2px', fontSize: '12px' }}
180
+ onClick=${() => removeListener(ev)}>×</button>
181
+ </span>`)}
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ <!-- Log -->
188
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
189
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
190
+ <div className="flex items-center gap-3">
191
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
192
+ Event Log
193
+ </span>
194
+ <span className="ws-connected">
195
+ <span className=${`dot ${sioConnected ? 'on' : 'off'}`} />
196
+ ${sioConnected ? 'Connected' : 'Disconnected'}
197
+ </span>
198
+ </div>
199
+ <button className="btn btn-ghost btn-sm" onClick=${() => setSioLog([])} style=${{ fontSize: '11px' }}>Clear</button>
200
+ </div>
201
+ <div className="ws-log" ref=${logRef}>
202
+ ${sioLog.length === 0 && html`<div style=${{ padding: '30px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px' }}>Connect to start logging events</div>`}
203
+ ${sioLog.map(e => html`<div key=${e.id} className="ws-log-entry">
204
+ <span className=${`dir ${e.dir}`}>${e.dir === 'sent' ? '↑ EMIT' : e.dir === 'received' ? '↓ EVENT' : '● SYS'}</span>
205
+ <span className="msg">${e.msg}</span>
206
+ <span className="ts">${e.ts}</span>
207
+ ${selectedSaved && onSaveLog && html`<button className="btn btn-ghost" style=${{ padding: '2px 4px', opacity: 0.4, marginLeft: '4px', flexShrink: 0 }}
208
+ onClick=${() => saveLogEntry(e)} title="Save log entry">
209
+ <${LuSave} size=${10} />
210
+ </button>`}
211
+ </div>`)}
212
+ </div>
213
+ </div>
214
+ </div>`;
215
+ }
216
+
217
+ // ── WebSocket Tester Component ──
218
+ function WebSocketTester({ selectedSaved, isSaved, loadedTest, onSaveTest, onSaveLog }) {
219
+ const { toast } = useApp();
220
+ const [wsUrl, setWsUrl] = useState('ws://localhost:3000');
221
+ const [wsConnected, setWsConnected] = useState(false);
222
+ const [wsRef, setWsRef] = useState(null);
223
+ const [wsMessage, setWsMessage] = useState('');
224
+ const [wsLog, setWsLog] = useState([]);
225
+ const logRef = useRef(null);
226
+
227
+ function addLog(dir, msg) {
228
+ const ts = new Date().toLocaleTimeString();
229
+ setWsLog(prev => [...prev, { dir, msg, ts, id: Date.now() + Math.random() }]);
230
+ }
231
+
232
+ useEffect(() => {
233
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
234
+ }, [wsLog]);
235
+
236
+ useEffect(() => {
237
+ return () => { if (wsRef) wsRef.close(); };
238
+ }, []);
239
+
240
+ function connect() {
241
+ if (wsRef) { wsRef.close(); setWsRef(null); }
242
+ try {
243
+ const ws = new WebSocket(wsUrl);
244
+ ws.onopen = () => {
245
+ setWsConnected(true);
246
+ addLog('system', 'Connected to ' + wsUrl);
247
+ };
248
+ ws.onclose = (e) => {
249
+ setWsConnected(false);
250
+ addLog('system', 'Disconnected (code: ' + e.code + ', reason: ' + (e.reason || 'none') + ')');
251
+ };
252
+ ws.onerror = () => {
253
+ addLog('system', 'Connection error');
254
+ };
255
+ ws.onmessage = (e) => {
256
+ let display = e.data;
257
+ try {
258
+ const parsed = JSON.parse(e.data);
259
+ display = JSON.stringify(parsed, null, 2);
260
+ } catch {}
261
+ addLog('received', display);
262
+ };
263
+ setWsRef(ws);
264
+ } catch (err) {
265
+ addLog('system', 'Error: ' + err.message);
266
+ }
267
+ }
268
+
269
+ function disconnect() {
270
+ if (wsRef) {
271
+ wsRef.close();
272
+ setWsRef(null);
273
+ setWsConnected(false);
274
+ }
275
+ }
276
+
277
+ function sendMessage() {
278
+ if (!wsRef || !wsMessage.trim()) return;
279
+ wsRef.send(wsMessage);
280
+ addLog('sent', wsMessage);
281
+ setWsMessage('');
282
+ }
283
+
284
+ useEffect(() => {
285
+ if (loadedTest && loadedTest.type === 'websocket') {
286
+ setWsUrl(loadedTest.url || 'ws://localhost:3000');
287
+ if (loadedTest.message) setWsMessage(loadedTest.message);
288
+ }
289
+ }, [loadedTest?.id]);
290
+
291
+ function saveSession() {
292
+ const name = prompt('Name this WebSocket test:');
293
+ if (!name?.trim()) return;
294
+ onSaveTest({
295
+ name: name.trim(),
296
+ type: 'websocket',
297
+ url: wsUrl,
298
+ message: wsMessage,
299
+ });
300
+ }
301
+
302
+ function saveLogEntry(entry) {
303
+ if (!selectedSaved || !onSaveLog) return;
304
+ onSaveLog({
305
+ dir: entry.dir,
306
+ label: (entry.dir === 'sent' ? '↑ SENT ' : entry.dir === 'received' ? '↓ RECV ' : '● SYS ') + entry.msg.substring(0, 80),
307
+ body: entry.msg,
308
+ time: 0,
309
+ });
310
+ }
311
+
312
+ return html`<div>
313
+ <!-- Connection bar -->
314
+ <div className="api-tester-bar">
315
+ <input className="api-tester-url" placeholder="ws://localhost:3000" value=${wsUrl}
316
+ onInput=${(e) => setWsUrl(e.target.value)}
317
+ onKeyDown=${(e) => { if (e.key === 'Enter') connect(); }} />
318
+ ${!wsConnected
319
+ ? html`<button className="api-tester-send" onClick=${connect}>
320
+ <${LuPlugZap} size=${14} /> Connect
321
+ </button>`
322
+ : html`<button className="api-tester-send" onClick=${disconnect} style=${{ background: 'var(--danger)' }}>
323
+ <${LuWifiOff} size=${14} /> Disconnect
324
+ </button>`}
325
+ ${!isSaved && onSaveTest && html`<button className="api-tester-send" onClick=${saveSession}
326
+ style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
327
+ <${LuSave} size=${14} /> Save
328
+ </button>`}
329
+ </div>
330
+
331
+ <!-- Send message -->
332
+ <div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
333
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
334
+ Send Message
335
+ </div>
336
+ <div style=${{ padding: '12px', display: 'flex', gap: '8px' }}>
337
+ <textarea className="api-tester-body-editor" style=${{ minHeight: '60px', fontSize: '12px', flex: 1 }}
338
+ placeholder='{"type": "ping"} or plain text'
339
+ value=${wsMessage} onInput=${(e) => setWsMessage(e.target.value)}
340
+ onKeyDown=${(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
341
+ spellcheck=${false} />
342
+ </div>
343
+ <div style=${{ padding: '0 12px 12px', display: 'flex', justifyContent: 'flex-end' }}>
344
+ <button className="btn btn-sm btn-primary" onClick=${sendMessage}
345
+ disabled=${!wsConnected || !wsMessage.trim()}>
346
+ <${LuSend} size=${12} /> Send
347
+ </button>
348
+ </div>
349
+ </div>
350
+
351
+ <!-- Log -->
352
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
353
+ <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
354
+ <div className="flex items-center gap-3">
355
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
356
+ Message Log
357
+ </span>
358
+ <span className="ws-connected">
359
+ <span className=${`dot ${wsConnected ? 'on' : 'off'}`} />
360
+ ${wsConnected ? 'Connected' : 'Disconnected'}
361
+ </span>
362
+ </div>
363
+ <button className="btn btn-ghost btn-sm" onClick=${() => setWsLog([])} style=${{ fontSize: '11px' }}>Clear</button>
364
+ </div>
365
+ <div className="ws-log" ref=${logRef}>
366
+ ${wsLog.length === 0 && html`<div style=${{ padding: '30px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px' }}>Connect to start logging messages</div>`}
367
+ ${wsLog.map(e => html`<div key=${e.id} className="ws-log-entry">
368
+ <span className=${`dir ${e.dir}`}>${e.dir === 'sent' ? '↑ SENT' : e.dir === 'received' ? '↓ RECV' : '● SYS'}</span>
369
+ <span className="msg">${e.msg}</span>
370
+ <span className="ts">${e.ts}</span>
371
+ ${selectedSaved && onSaveLog && html`<button className="btn btn-ghost" style=${{ padding: '2px 4px', opacity: 0.4, marginLeft: '4px', flexShrink: 0 }}
372
+ onClick=${() => saveLogEntry(e)} title="Save log entry">
373
+ <${LuSave} size=${10} />
374
+ </button>`}
375
+ </div>`)}
376
+ </div>
377
+ </div>
378
+ </div>`;
379
+ }
380
+
381
+ export function SavedResponseDetail({ resp, parentTest, onBack, onUpdate }) {
382
+ const isSocketResp = resp.dir;
383
+ const [editLabel, setEditLabel] = useState(resp.label);
384
+ const [editReqBody, setEditReqBody] = useState(() => {
385
+ if (!resp.requestBody?.trim()) return '';
386
+ try { return JSON.stringify(JSON.parse(resp.requestBody), null, 2); } catch { return resp.requestBody; }
387
+ });
388
+ const [editRespBody, setEditRespBody] = useState(() => {
389
+ if (!resp.body) return '';
390
+ try { return JSON.stringify(JSON.parse(resp.body), null, 2); } catch { return resp.body; }
391
+ });
392
+ const [editing, setEditing] = useState(false);
393
+
394
+ const rStatusClass = 's' + Math.floor((resp.status || 0) / 100) + 'xx';
395
+ const hasChanges = editLabel !== resp.label ||
396
+ editReqBody !== ((() => { try { return JSON.stringify(JSON.parse(resp.requestBody || ''), null, 2); } catch { return resp.requestBody || ''; } })()) ||
397
+ editRespBody !== ((() => { try { return JSON.stringify(JSON.parse(resp.body || ''), null, 2); } catch { return resp.body || ''; } })());
398
+
399
+ function save() {
400
+ onUpdate({
401
+ label: editLabel,
402
+ requestBody: editReqBody,
403
+ body: editRespBody,
404
+ });
405
+ setEditing(false);
406
+ }
407
+
408
+ return html`<div>
409
+ <div className="flex items-center gap-2 mb-3">
410
+ <button className="btn btn-ghost btn-sm" onClick=${onBack} style=${{ fontSize: '11px' }}>
411
+ <${LuChevronLeft} size=${14} /> Back
412
+ </button>
413
+ ${!editing
414
+ ? html`<span style=${{ fontSize: '13px', fontWeight: 600, flex: 1 }}>${resp.label}</span>`
415
+ : html`<input className="form-input" value=${editLabel} onInput=${(e) => setEditLabel(e.target.value)}
416
+ style=${{ fontSize: '13px', fontWeight: 600, flex: 1, padding: '4px 8px' }} />`}
417
+ ${!editing && html`<button className="btn btn-ghost btn-sm" onClick=${() => setEditing(true)} style=${{ fontSize: '11px' }}>
418
+ <${LuPencil} size=${12} /> Edit
419
+ </button>`}
420
+ ${editing && html`<div className="flex gap-2">
421
+ <button className="btn btn-ghost btn-sm" onClick=${() => setEditing(false)} style=${{ fontSize: '11px' }}>Cancel</button>
422
+ <button className="btn btn-sm btn-primary" onClick=${save} disabled=${!hasChanges} style=${{ fontSize: '11px' }}>
423
+ <${LuSave} size=${12} /> Update
424
+ </button>
425
+ </div>`}
426
+ </div>
427
+
428
+ ${!isSocketResp && html`<div>
429
+ <div className="card mb-4" style=${{ padding: 0, overflow: 'hidden' }}>
430
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
431
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Request</span>
432
+ <span className=${`route-method ${(parentTest.method || 'GET').toLowerCase()}`} style=${{ fontSize: '10px', padding: '1px 6px' }}>${parentTest.method || 'GET'}</span>
433
+ <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', color: 'var(--text-primary)' }}>${parentTest.url}</span>
434
+ </div>
435
+ ${(editReqBody?.trim() || editing) && html`<div style=${{ padding: editing ? '0' : '10px 16px' }}>
436
+ ${!editing && html`<div>
437
+ <div style=${{ padding: '0 0 6px', fontSize: '10px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.3px', color: 'var(--text-muted)' }}>Body Sent</div>
438
+ <pre style=${{ margin: 0, fontFamily: "'SF Mono', monospace", fontSize: '12px', lineHeight: '1.5', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>${editReqBody}</pre>
439
+ </div>`}
440
+ ${editing && html`<textarea className="api-tester-body-editor" value=${editReqBody}
441
+ onInput=${(e) => setEditReqBody(e.target.value)} spellcheck=${false}
442
+ style=${{ borderRadius: 0, border: 'none', borderTop: '1px solid var(--border)', minHeight: '100px' }} />`}
443
+ </div>`}
444
+ </div>
445
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
446
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
447
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Response</span>
448
+ <span className=${`api-tester-status ${rStatusClass}`}>${resp.status} ${resp.statusText}</span>
449
+ <span style=${{ fontSize: '11px', color: 'var(--text-muted)' }}>${resp.time}ms</span>
450
+ </div>
451
+ ${!editing && html`<div className="api-tester-response-body">${editRespBody || 'No response body'}</div>`}
452
+ ${editing && html`<textarea className="api-tester-body-editor" value=${editRespBody}
453
+ onInput=${(e) => setEditRespBody(e.target.value)} spellcheck=${false}
454
+ style=${{ borderRadius: 0, border: 'none', minHeight: '140px' }} />`}
455
+ </div>
456
+ </div>`}
457
+
458
+ ${isSocketResp && html`<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
459
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
460
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
461
+ ${parentTest.type === 'socketio' ? 'Socket.IO' : 'WebSocket'} Log Entry
462
+ </span>
463
+ <span style=${{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', fontWeight: 700,
464
+ background: resp.dir === 'received' ? 'rgba(52,211,153,0.15)' : resp.dir === 'sent' ? 'rgba(245,158,11,0.15)' : 'rgba(148,163,184,0.15)',
465
+ color: resp.dir === 'received' ? '#34d399' : resp.dir === 'sent' ? '#f59e0b' : '#94a3b8' }}>
466
+ ${resp.dir === 'received' ? '↓ Received' : resp.dir === 'sent' ? '↑ Sent' : '● System'}
467
+ </span>
468
+ </div>
469
+ ${!editing && html`<div className="api-tester-response-body">${editRespBody}</div>`}
470
+ ${editing && html`<textarea className="api-tester-body-editor" value=${editRespBody}
471
+ onInput=${(e) => setEditRespBody(e.target.value)} spellcheck=${false}
472
+ style=${{ borderRadius: 0, border: 'none', minHeight: '140px' }} />`}
473
+ </div>`}
474
+ </div>`;
475
+ }
476
+
477
+ export function ApiTesterPage() {
478
+ const { data, toast, showConfirm } = useApp();
479
+ const [testerMode, setTesterMode] = useState('rest');
480
+ const [method, setMethod] = useState('GET');
481
+ const [url, setUrl] = useState('');
482
+ const [headers, setHeaders] = useState([
483
+ { key: 'Content-Type', value: 'application/json', id: 1 },
484
+ ]);
485
+ const [body, setBody] = useState('');
486
+ const bodyRef = useRef(null);
487
+ const [response, setResponse] = useState(null);
488
+ const [loading, setLoading] = useState(false);
489
+ const [activeTab, setActiveTab] = useState('body');
490
+ const [params, setParams] = useState([]);
491
+ const [savedTests, setSavedTests] = useState([]);
492
+ const [selectedSaved, setSelectedSaved] = useState(null);
493
+ const [showImport, setShowImport] = useState(false);
494
+ const [importRoutes, setImportRoutes] = useState([]);
495
+ const [importLoading, setImportLoading] = useState(false);
496
+ const [bodyError, setBodyError] = useState(null);
497
+ const [bodyType, setBodyType] = useState('json');
498
+ const [formFields, setFormFields] = useState([]);
499
+ const [responseTab, setResponseTab] = useState('body');
500
+ const [urlParams, setUrlParams] = useState([]);
501
+ const [savedResponses, setSavedResponses] = useState({}); // { testId: [{status, label, body, requestBody}] }
502
+ const [selectedResponse, setSelectedResponse] = useState(null); // { testId, respId }
503
+ const [expandedTests, setExpandedTests] = useState({});
504
+ const [routeEndpoints, setRouteEndpoints] = useState([]);
505
+ const [lastSharedAt, setLastSharedAt] = useState(null);
506
+ const [sharedSnapshot, setSharedSnapshot] = useState(null);
507
+
508
+ // Auto-grow the request-body textarea to fit its content (capped, then scrolls).
509
+ useEffect(() => {
510
+ const ta = bodyRef.current;
511
+ if (ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight + 2, 600) + 'px'; }
512
+ }, [body, bodyType, activeTab]);
513
+
514
+ // ── Environments / variables + auth (persisted per-browser in localStorage) ──
515
+ const [variables, setVariables] = useState([]);
516
+ const [showVars, setShowVars] = useState(false);
517
+ const [autoToken, setAutoToken] = useState(true);
518
+ const [auth, setAuth] = useState({ type: 'none', token: '', username: '', password: '', apiKeyKey: 'x-api-key', apiKeyValue: '' });
519
+
520
+ useEffect(() => {
521
+ try {
522
+ const v = JSON.parse(localStorage.getItem('_4bnode_vars') || '[]');
523
+ if (Array.isArray(v)) setVariables(v);
524
+ const a = JSON.parse(localStorage.getItem('_4bnode_auth') || 'null');
525
+ if (a && typeof a === 'object') setAuth(prev => ({ ...prev, ...a }));
526
+ const at = localStorage.getItem('_4bnode_autotoken');
527
+ if (at !== null) setAutoToken(at === '1');
528
+ } catch {}
529
+ }, []);
530
+ useEffect(() => { try { localStorage.setItem('_4bnode_vars', JSON.stringify(variables)); } catch {} }, [variables]);
531
+ useEffect(() => { try { localStorage.setItem('_4bnode_auth', JSON.stringify(auth)); } catch {} }, [auth]);
532
+ useEffect(() => { try { localStorage.setItem('_4bnode_autotoken', autoToken ? '1' : '0'); } catch {} }, [autoToken]);
533
+
534
+ // Replace {{name}} tokens with variable values.
535
+ function subst(str) {
536
+ if (typeof str !== 'string' || str.indexOf('{{') === -1) return str;
537
+ return str.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (m, k) => {
538
+ const v = variables.find(x => (x.key || '').trim() === k);
539
+ return v ? v.value : m;
540
+ });
541
+ }
542
+ function setVar(key, value) {
543
+ setVariables(prev => {
544
+ const i = prev.findIndex(x => (x.key || '').trim() === key);
545
+ if (i === -1) return [...prev, { key, value, id: Date.now() }];
546
+ const next = prev.slice(); next[i] = { ...next[i], value }; return next;
547
+ });
548
+ }
549
+
550
+ // ── Request tabs (each tab holds a snapshot; live state = active tab's buffer) ──
551
+ const [tabs, setTabs] = useState([{ id: 1, snap: null }]);
552
+ const [activeTabId, setActiveTabId] = useState(1);
553
+
554
+ function captureSnap() {
555
+ return { method, url, headers, body, bodyType, params, urlParams, formFields, response, activeTab };
556
+ }
557
+ function loadSnap(s) {
558
+ // Only protect a restored request that actually has data; blank tabs should
559
+ // still auto-fill from a matching route when the user types a URL.
560
+ suppressAutofillRef.current = !!(s && s.url);
561
+ setMethod((s && s.method) || 'GET');
562
+ setUrl((s && s.url) || '');
563
+ setHeaders((s && s.headers) || [{ key: 'Content-Type', value: 'application/json', id: 1 }]);
564
+ setBody((s && s.body) || '');
565
+ setBodyType((s && s.bodyType) || 'json');
566
+ setParams((s && s.params) || []);
567
+ setUrlParams((s && s.urlParams) || []);
568
+ setFormFields((s && s.formFields) || []);
569
+ setResponse((s && s.response) || null);
570
+ setActiveTab((s && s.activeTab) || 'body');
571
+ setBodyError(null);
572
+ setSelectedSaved(null);
573
+ setSelectedResponse(null);
574
+ }
575
+ function switchTab(id) {
576
+ if (id === activeTabId) return;
577
+ const snap = captureSnap();
578
+ const target = tabs.find(t => t.id === id);
579
+ setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, snap } : t));
580
+ loadSnap(target && target.snap);
581
+ setActiveTabId(id);
582
+ }
583
+ function newTab() {
584
+ const snap = captureSnap();
585
+ const id = Math.max(0, ...tabs.map(t => t.id)) + 1;
586
+ setTabs(prev => [...prev.map(t => t.id === activeTabId ? { ...t, snap } : t), { id, snap: null }]);
587
+ loadSnap(null);
588
+ setActiveTabId(id);
589
+ }
590
+ function closeTab(id) {
591
+ const remaining = tabs.filter(t => t.id !== id);
592
+ if (remaining.length === 0) {
593
+ const nid = Math.max(0, ...tabs.map(t => t.id)) + 1;
594
+ setTabs([{ id: nid, snap: null }]);
595
+ loadSnap(null);
596
+ setActiveTabId(nid);
597
+ return;
598
+ }
599
+ setTabs(remaining);
600
+ if (id === activeTabId) {
601
+ const next = remaining[remaining.length - 1];
602
+ loadSnap(next.snap);
603
+ setActiveTabId(next.id);
604
+ }
605
+ }
606
+ function tabLabel(t) {
607
+ const s = t.id === activeTabId ? { method, url } : (t.snap || {});
608
+ if (!s.url) return 'New Request';
609
+ let path = s.url;
610
+ try { path = new URL(s.url).pathname || '/'; } catch {}
611
+ return (s.method || 'GET') + ' ' + path;
612
+ }
613
+
614
+ // ── Request history (persisted per-browser) ──
615
+ const [history, setHistory] = useState([]);
616
+ const [showHistory, setShowHistory] = useState(false);
617
+ useEffect(() => {
618
+ try { const h = JSON.parse(localStorage.getItem('_4bnode_history') || '[]'); if (Array.isArray(h)) setHistory(h); } catch {}
619
+ }, []);
620
+ function pushHistory(entry) {
621
+ setHistory(prev => {
622
+ const next = [entry, ...prev].slice(0, 50);
623
+ try { localStorage.setItem('_4bnode_history', JSON.stringify(next)); } catch {}
624
+ return next;
625
+ });
626
+ }
627
+ function clearHistory() {
628
+ setHistory([]);
629
+ try { localStorage.removeItem('_4bnode_history'); } catch {}
630
+ }
631
+ function loadHistory(h) {
632
+ setMethod(h.method);
633
+ setUrl(h.url);
634
+ setResponse(null);
635
+ setSelectedSaved(null);
636
+ setSelectedResponse(null);
637
+ setShowHistory(false);
638
+ }
639
+
640
+ // Latest-committed mirrors of tests/responses. Persistence reads these so a PUT
641
+ // always sends the LATEST tests AND responses — never a stale half from an older
642
+ // closure (which previously could wipe saved API data when saving from sockets).
643
+ const savedTestsRef = useRef(savedTests);
644
+ const savedResponsesRef = useRef(savedResponses);
645
+ useEffect(() => { savedTestsRef.current = savedTests; }, [savedTests]);
646
+ useEffect(() => { savedResponsesRef.current = savedResponses; }, [savedResponses]);
647
+
648
+ // Load saved tests and responses from server
649
+ useEffect(() => {
650
+ api('/api-tester/data').then(data => {
651
+ if (data.tests) { setSavedTests(data.tests); savedTestsRef.current = data.tests; }
652
+ if (data.responses) { setSavedResponses(data.responses); savedResponsesRef.current = data.responses; }
653
+ if (data.lastSharedAt) { setLastSharedAt(data.lastSharedAt); setSharedSnapshot(data.sharedSnapshot || null); }
654
+ }).catch(() => {});
655
+ }, []);
656
+
657
+ // Load all route endpoints on mount for auto-matching
658
+ useEffect(() => {
659
+ api('/api-tester/routes').then(routes => {
660
+ const eps = [];
661
+ (routes || []).forEach(r => {
662
+ (r.endpoints || []).forEach(ep => {
663
+ eps.push({ ...ep, routeName: r.name, hasAuth: r.hasAuth });
664
+ });
665
+ });
666
+ setRouteEndpoints(eps);
667
+ }).catch(() => {});
668
+ }, []);
669
+
670
+ // Find matching endpoint based on current URL and method
671
+ function findMatchingEndpoint() {
672
+ if (!url.trim() || routeEndpoints.length === 0) return null;
673
+ try {
674
+ const urlObj = new URL(url.trim());
675
+ const pathname = urlObj.pathname;
676
+ return routeEndpoints.find(ep => {
677
+ // Match exact path or path with :id param
678
+ const epPath = ep.fullPath;
679
+ if (pathname === epPath) return ep.method === method;
680
+ // Check :id style params - /api/users/:id matches /api/users/123
681
+ const epParts = epPath.split('/');
682
+ const urlParts = pathname.split('/');
683
+ if (epParts.length !== urlParts.length) return false;
684
+ const matches = epParts.every((p, i) => p.startsWith(':') || p === urlParts[i]);
685
+ return matches && ep.method === method;
686
+ });
687
+ } catch { return null; }
688
+ }
689
+
690
+ const matchedEndpoint = findMatchingEndpoint();
691
+
692
+ // Load fields from server when route matches
693
+ const lastMatchRef = useRef(null);
694
+ const fieldsLoadedRef = useRef(false);
695
+ const suppressAutofillRef = useRef(false);
696
+ useEffect(() => {
697
+ if (!matchedEndpoint) { lastMatchRef.current = null; return; }
698
+ const matchKey = matchedEndpoint.fullPath + ':' + matchedEndpoint.method;
699
+ // When a tab/history/saved request is restored, don't let route auto-fill clobber it.
700
+ if (suppressAutofillRef.current) { suppressAutofillRef.current = false; lastMatchRef.current = matchKey; return; }
701
+ if (lastMatchRef.current === matchKey) return;
702
+ lastMatchRef.current = matchKey;
703
+ fieldsLoadedRef.current = false;
704
+
705
+ // Set URL params
706
+ if (matchedEndpoint.params?.length > 0) {
707
+ setUrlParams(matchedEndpoint.params.map((p, i) => ({ key: p, value: '', id: Date.now() + 200 + i })));
708
+ } else {
709
+ setUrlParams([]);
710
+ }
711
+
712
+ // Set query params
713
+ if (matchedEndpoint.queryFields?.length > 0) {
714
+ setParams(matchedEndpoint.queryFields.map((f, i) => ({ key: f.name, value: '', id: Date.now() + i })));
715
+ }
716
+
717
+ // Build fields: start from route-detected, then merge any saved custom fields
718
+ const routeKey = encodeURIComponent(matchKey);
719
+ const hasFiles = matchedEndpoint.fileFields?.length > 0;
720
+ const hasBodyFields = matchedEndpoint.bodyFields?.length > 0;
721
+
722
+ // Build route-detected fields
723
+ const routeFields = [
724
+ ...(matchedEndpoint.bodyFields || []).map((f, i) => ({ key: f.name, value: '', type: 'text', id: Date.now() + i, fromRoute: true })),
725
+ ...(matchedEndpoint.fileFields || []).map((f, i) => ({ key: f.name, value: '', type: 'file', id: Date.now() + 100 + i, fromRoute: true })),
726
+ ];
727
+
728
+ if (hasFiles) setBodyType('form-data');
729
+
730
+ // Load saved custom fields from server and merge
731
+ api('/api-tester/fields/' + routeKey).then(res => {
732
+ if (fieldsLoadedRef.current) return;
733
+ fieldsLoadedRef.current = true;
734
+
735
+ if (res.fields && res.fields.length > 0) {
736
+ // Get custom fields (not from route) that user added
737
+ const routeFieldKeys = routeFields.map(f => f.key);
738
+ const customFields = res.fields.filter(f => !routeFieldKeys.includes(f.key));
739
+ const merged = [...routeFields, ...customFields];
740
+ setFormFields(merged);
741
+ if (merged.some(f => f.type === 'file')) setBodyType('form-data');
742
+ } else if (hasFiles || hasBodyFields) {
743
+ setFormFields(hasFiles ? routeFields : []);
744
+ if (!hasFiles && hasBodyFields) {
745
+ const obj = {};
746
+ matchedEndpoint.bodyFields.forEach(f => {
747
+ obj[f.name] = f.type === 'Number' ? 0 : f.type === 'Boolean' ? false : f.type === 'Date' ? '2024-01-01' : '';
748
+ });
749
+ setBody(JSON.stringify(obj, null, 2));
750
+ setBodyError(null);
751
+ }
752
+ }
753
+ if (matchedEndpoint.bodyFields?.length > 0 || matchedEndpoint.fileFields?.length > 0) setActiveTab('body');
754
+ }).catch(() => {});
755
+ }, [url, method]);
756
+
757
+ // Check if collection changed since last share
758
+ const shareNeeded = lastSharedAt && sharedSnapshot !== null && sharedSnapshot !== JSON.stringify({ t: savedTests.length, r: Object.keys(savedResponses).length, ids: savedTests.map(t => t.id).join(',') });
759
+
760
+ function persistTests(tests) {
761
+ savedTestsRef.current = tests;
762
+ setSavedTests(tests);
763
+ api('/api-tester/data', { method: 'PUT', body: { tests, responses: savedResponsesRef.current } }).catch(() => {});
764
+ }
765
+
766
+ function persistResponses(resp) {
767
+ savedResponsesRef.current = resp;
768
+ setSavedResponses(resp);
769
+ api('/api-tester/data', { method: 'PUT', body: { tests: savedTestsRef.current, responses: resp } }).catch(() => {});
770
+ }
771
+
772
+ function saveResponse() {
773
+ if (!response || !selectedSaved) {
774
+ toast('Save the request first, then save responses', 'error');
775
+ return;
776
+ }
777
+ const label = prompt('Label this response (e.g., "Success", "Missing email"):', response.status + ' ' + response.statusText);
778
+ if (!label?.trim()) return;
779
+ const entry = {
780
+ id: Date.now(),
781
+ label: label.trim(),
782
+ status: response.status,
783
+ statusText: response.statusText,
784
+ body: response.body,
785
+ requestBody: body || '',
786
+ time: response.time,
787
+ };
788
+ const updated = { ...savedResponses };
789
+ if (!updated[selectedSaved]) updated[selectedSaved] = [];
790
+ updated[selectedSaved].push(entry);
791
+ persistResponses(updated);
792
+ setExpandedTests(prev => ({ ...prev, [selectedSaved]: true }));
793
+ toast('Response saved', 'success');
794
+ }
795
+
796
+ function deleteResponse(testId, respId) {
797
+ const updated = { ...savedResponses };
798
+ updated[testId] = (updated[testId] || []).filter(r => r.id !== respId);
799
+ if (updated[testId].length === 0) delete updated[testId];
800
+ persistResponses(updated);
801
+ }
802
+
803
+ function updateResponse(testId, respId, changes) {
804
+ const updated = { ...savedResponses };
805
+ updated[testId] = (updated[testId] || []).map(r =>
806
+ r.id === respId ? { ...r, ...changes } : r
807
+ );
808
+ persistResponses(updated);
809
+ toast('Response updated', 'success');
810
+ }
811
+
812
+ async function shareCollection() {
813
+ if (savedTests.length === 0) { toast('No saved requests to share', 'error'); return; }
814
+ try {
815
+ const result = await api('/api-tester/share-collection', {
816
+ method: 'POST',
817
+ body: { tests: savedTests, responses: savedResponses },
818
+ });
819
+ const shareUrl = window.location.origin + '/_dev/shared/' + result.id;
820
+ copyToClipboard(shareUrl);
821
+ const now = Date.now();
822
+ setLastSharedAt(now);
823
+ const snap = JSON.stringify({ t: savedTests.length, r: Object.keys(savedResponses).length, ids: savedTests.map(t => t.id).join(',') });
824
+ setSharedSnapshot(snap);
825
+ api('/api-tester/data', { method: 'PUT', body: { tests: savedTests, responses: savedResponses, lastSharedAt: now, sharedSnapshot: snap } }).catch(() => {});
826
+ toast('Share URL copied: ' + shareUrl, 'success');
827
+ } catch (err) {
828
+ toast(err.message, 'error');
829
+ }
830
+ }
831
+
832
+ async function sendRequest() {
833
+ if (!url.trim()) { toast('Enter a URL', 'error'); return; }
834
+
835
+ // Validate file fields before sending
836
+ if (['POST','PUT','PATCH'].includes(method) && (bodyType === 'form-data' || bodyType === 'x-www-form-urlencoded')) {
837
+ const missingFiles = formFields.filter(f => f.key?.trim() && f.type === 'file' && !f.fileData && !f.value);
838
+ if (missingFiles.length > 0) {
839
+ const names = missingFiles.map(f => f.key).join(', ');
840
+ const ok = await showConfirm('File field' + (missingFiles.length > 1 ? 's' : '') + ' "' + names + '" ' + (missingFiles.length > 1 ? 'are' : 'is') + ' empty. Send without ' + (missingFiles.length > 1 ? 'them' : 'it') + '?', { confirmLabel: 'Send Anyway' });
841
+ if (!ok) return;
842
+ }
843
+ }
844
+
845
+ // Validate URL params
846
+ const emptyParams = urlParams.filter(p => p.key && !p.value);
847
+ if (emptyParams.length > 0) {
848
+ const names = emptyParams.map(p => p.key).join(', ');
849
+ toast('Missing value for path variable: ' + names, 'error');
850
+ return;
851
+ }
852
+
853
+ setLoading(true);
854
+ setResponse(null);
855
+ setSelectedResponse(null);
856
+ try {
857
+ const hdrs = {};
858
+ headers.forEach(h => { if (h.key?.trim() && h.value?.trim()) hdrs[h.key] = subst(h.value); });
859
+ // Apply the Auth tab (variables supported via {{...}})
860
+ if (auth.type === 'bearer' && auth.token.trim()) {
861
+ hdrs['Authorization'] = 'Bearer ' + subst(auth.token).trim();
862
+ } else if (auth.type === 'basic' && (auth.username || auth.password)) {
863
+ try { hdrs['Authorization'] = 'Basic ' + btoa(subst(auth.username) + ':' + subst(auth.password)); } catch {}
864
+ } else if (auth.type === 'apikey' && auth.apiKeyKey.trim()) {
865
+ hdrs[auth.apiKeyKey.trim()] = subst(auth.apiKeyValue);
866
+ }
867
+ const finalUrl = subst(buildDisplayUrl());
868
+ // For base64 send-as, convert form field value into body JSON
869
+ let sendBody = undefined;
870
+ let sendBodyType = bodyType;
871
+ let sendFormFields = undefined;
872
+
873
+ if (['POST','PUT','PATCH'].includes(method)) {
874
+ if (bodyType === 'form-data' || bodyType === 'x-www-form-urlencoded') {
875
+ sendFormFields = formFields.filter(f => f.key?.trim()).map(f => {
876
+ if (f.sendAs === 'base64') {
877
+ return { key: f.key, value: f.value }; // base64 string as text value
878
+ }
879
+ if (f.sendAs === 'file' && f.fileData) {
880
+ return { key: f.key, fileData: f.fileData, fileName: f.fileName, fileType: f.fileType };
881
+ }
882
+ if (f.type === 'file' && !f.fileData && !f.value) {
883
+ return null; // Skip empty file fields
884
+ }
885
+ return { key: f.key, value: subst(f.value || '') };
886
+ }).filter(Boolean);
887
+ } else if (bodyType === 'binary') {
888
+ sendBody = body; // base64 encoded
889
+ } else {
890
+ sendBody = subst(body);
891
+ }
892
+ }
893
+
894
+ const result = await api('/api-tester/send', {
895
+ method: 'POST',
896
+ body: {
897
+ method,
898
+ url: finalUrl,
899
+ headers: hdrs,
900
+ body: sendBody,
901
+ bodyType: sendBodyType,
902
+ formFields: sendFormFields,
903
+ },
904
+ });
905
+ let parsed = null;
906
+ try { parsed = JSON.parse(result.body); } catch {}
907
+ setResponse({
908
+ status: result.status,
909
+ statusText: result.statusText,
910
+ headers: result.headers || {},
911
+ body: parsed ? JSON.stringify(parsed, null, 2) : result.body,
912
+ isJson: !!parsed,
913
+ time: result.time,
914
+ size: result.size,
915
+ });
916
+ // Auto-capture an auth token from the response into {{token}}
917
+ if (autoToken && parsed && typeof parsed === 'object') {
918
+ const tok = parsed.token || parsed.accessToken || parsed.access_token || (parsed.data && parsed.data.token);
919
+ if (tok && typeof tok === 'string') { setVar('token', tok); toast('Captured token → {{token}}', 'success'); }
920
+ }
921
+ pushHistory({ id: Date.now(), method, url, status: result.status, time: result.time, ts: Date.now() });
922
+ } catch (err) {
923
+ setResponse({
924
+ status: 0, statusText: 'Error',
925
+ body: err.message, isJson: false,
926
+ time: 0, size: 0, headers: {},
927
+ });
928
+ pushHistory({ id: Date.now(), method, url, status: 0, time: 0, ts: Date.now() });
929
+ }
930
+ setLoading(false);
931
+ }
932
+
933
+ function saveTest() {
934
+ const name = prompt('Name this request:');
935
+ if (!name?.trim()) return;
936
+ const test = {
937
+ id: Date.now(),
938
+ name: name.trim(),
939
+ type: 'rest',
940
+ method, url,
941
+ headers: headers.filter(h => h.key?.trim()),
942
+ params: params.filter(p => p.key?.trim()),
943
+ urlParams: urlParams.filter(p => p.key?.trim()),
944
+ body, bodyType,
945
+ formFields: formFields.filter(f => f.key?.trim()),
946
+ };
947
+ const updated = [...savedTests, test];
948
+ persistTests(updated);
949
+ setSelectedSaved(test.id);
950
+ toast('Request saved', 'success');
951
+ }
952
+
953
+ function handleSaveSocketTest(config) {
954
+ const test = { id: Date.now(), ...config };
955
+ const updated = [...savedTests, test];
956
+ persistTests(updated);
957
+ setSelectedSaved(test.id);
958
+ toast('Test saved', 'success');
959
+ }
960
+
961
+ function handleSaveLog(entry) {
962
+ if (!selectedSaved) return;
963
+ const respEntry = {
964
+ id: Date.now(),
965
+ label: entry.label,
966
+ status: entry.dir === 'received' ? 200 : entry.dir === 'sent' ? 201 : 100,
967
+ statusText: entry.dir === 'received' ? 'Received' : entry.dir === 'sent' ? 'Sent' : 'System',
968
+ body: entry.body,
969
+ requestBody: '',
970
+ time: entry.time || 0,
971
+ dir: entry.dir,
972
+ };
973
+ const updated = { ...savedResponses };
974
+ if (!updated[selectedSaved]) updated[selectedSaved] = [];
975
+ updated[selectedSaved].push(respEntry);
976
+ persistResponses(updated);
977
+ setExpandedTests(prev => ({ ...prev, [selectedSaved]: true }));
978
+ toast('Log entry saved', 'success');
979
+ }
980
+
981
+ function loadTest(test) {
982
+ if (test.type === 'socketio') {
983
+ setTesterMode('socketio');
984
+ } else if (test.type === 'websocket') {
985
+ setTesterMode('websocket');
986
+ } else {
987
+ setTesterMode('rest');
988
+ setMethod(test.method || 'GET');
989
+ setUrl(test.url || '');
990
+ setHeaders(test.headers?.length ? test.headers : [{ key: 'Content-Type', value: 'application/json', id: 1 }]);
991
+ setParams(test.params?.length ? test.params : []);
992
+ setUrlParams(test.urlParams?.length ? test.urlParams : []);
993
+ setBody(test.body || '');
994
+ setBodyType(test.bodyType || 'json');
995
+ setFormFields(test.formFields?.length ? test.formFields : []);
996
+ if (['POST','PUT','PATCH'].includes(test.method)) setActiveTab('body');
997
+ else setActiveTab('params');
998
+ }
999
+ setSelectedSaved(test.id);
1000
+ setSelectedResponse(null);
1001
+ setResponse(null);
1002
+ }
1003
+
1004
+ function toggleExpand(testId) {
1005
+ setExpandedTests(prev => ({ ...prev, [testId]: !prev[testId] }));
1006
+ }
1007
+
1008
+ function viewSavedResponse(testId, respId) {
1009
+ setSelectedSaved(testId);
1010
+ setSelectedResponse({ testId, respId });
1011
+ setResponse(null);
1012
+ const test = savedTests.find(t => t.id === testId);
1013
+ if (test) {
1014
+ if (test.type === 'socketio') { setTesterMode('socketio'); }
1015
+ else if (test.type === 'websocket') { setTesterMode('websocket'); }
1016
+ else {
1017
+ setTesterMode('rest');
1018
+ setMethod(test.method || 'GET');
1019
+ setUrl(test.url || '');
1020
+ setHeaders(test.headers?.length ? test.headers : [{ key: 'Content-Type', value: 'application/json', id: 1 }]);
1021
+ setParams(test.params?.length ? test.params : []);
1022
+ // Load saved response's request body into body editor
1023
+ const r = (savedResponses[testId] || []).find(x => x.id === respId);
1024
+ if (r?.requestBody?.trim()) {
1025
+ try { setBody(JSON.stringify(JSON.parse(r.requestBody), null, 2)); } catch { setBody(r.requestBody); }
1026
+ } else {
1027
+ setBody(test.body || '');
1028
+ }
1029
+ if (['POST','PUT','PATCH'].includes(test.method)) setActiveTab('body');
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ async function deleteTest(id) {
1035
+ const test = savedTests.find(t => t.id === id);
1036
+ const ok = await showConfirm('Delete "' + (test?.name || 'this request') + '"? This will also remove all saved responses.', { danger: true, confirmLabel: 'Delete' });
1037
+ if (!ok) return;
1038
+ const updated = savedTests.filter(t => t.id !== id);
1039
+ persistTests(updated);
1040
+ if (savedResponses[id]) {
1041
+ const r = { ...savedResponses };
1042
+ delete r[id];
1043
+ persistResponses(r);
1044
+ }
1045
+ // Clear saved field config for this route
1046
+ if (test?.url && test?.method) {
1047
+ try {
1048
+ const ep = matchedEndpoint || routeEndpoints.find(e => test.url.includes(e.fullPath) && e.method === test.method);
1049
+ if (ep) {
1050
+ const mk = encodeURIComponent(ep.fullPath + ':' + ep.method);
1051
+ api('/api-tester/fields/' + mk, { method: 'PUT', body: { fields: [] } }).catch(() => {});
1052
+ }
1053
+ } catch {}
1054
+ // Reset lastMatchRef so next time route is entered, it re-detects fresh
1055
+ lastMatchRef.current = null;
1056
+ fieldsLoadedRef.current = false;
1057
+ }
1058
+ if (selectedSaved === id) setSelectedSaved(null);
1059
+ }
1060
+
1061
+ async function loadImportRoutes() {
1062
+ setShowImport(true);
1063
+ setImportLoading(true);
1064
+ try {
1065
+ const routes = await api('/api-tester/routes');
1066
+ setImportRoutes(routes);
1067
+ } catch (err) { toast(err.message, 'error'); }
1068
+ setImportLoading(false);
1069
+ }
1070
+
1071
+ function importEndpoint(route, ep) {
1072
+ const port = window.location.port || '3000';
1073
+ const origin = window.location.protocol + '//' + window.location.hostname + ':' + port;
1074
+ setMethod(ep.method);
1075
+ setUrl(origin + ep.fullPath);
1076
+ setResponse(null);
1077
+ setSelectedSaved(null);
1078
+ setSelectedResponse(null);
1079
+
1080
+ // Set URL params (:id, etc.)
1081
+ if (ep.params?.length > 0) {
1082
+ setUrlParams(ep.params.map((p, i) => ({ key: p, value: '', id: Date.now() + 200 + i })));
1083
+ } else {
1084
+ setUrlParams([]);
1085
+ }
1086
+
1087
+ // Populate keys for testing — both the JSON body and the form-data rows, so
1088
+ // fields are pre-filled regardless of which body mode is used.
1089
+ const formRows = [
1090
+ ...((ep.bodyFields || []).map((f, i) => ({ key: f.name, value: '', type: 'text', id: Date.now() + i, fromRoute: true }))),
1091
+ ...((ep.fileFields || []).map((f, i) => ({ key: f.name, value: '', type: 'file', id: Date.now() + 500 + i, fromRoute: true }))),
1092
+ ];
1093
+ setFormFields(formRows);
1094
+
1095
+ const hasFiles = ep.fileFields?.length > 0;
1096
+ if (ep.bodyFields?.length > 0 && ['POST','PUT','PATCH'].includes(ep.method)) {
1097
+ const bodyObj = {};
1098
+ ep.bodyFields.forEach(f => {
1099
+ bodyObj[f.name] = f.type === 'Number' ? 0 : f.type === 'Boolean' ? false : f.type === 'Date' ? '2024-01-01' : '';
1100
+ });
1101
+ setBody(JSON.stringify(bodyObj, null, 2));
1102
+ setHeaders([{ key: 'Content-Type', value: 'application/json', id: 1 }]);
1103
+ setActiveTab('body');
1104
+ } else {
1105
+ setBody('');
1106
+ }
1107
+ if (hasFiles && ['POST','PUT','PATCH'].includes(ep.method)) {
1108
+ setBodyType('form-data');
1109
+ setActiveTab('body');
1110
+ }
1111
+
1112
+ // Set query params for GET/DELETE
1113
+ if (ep.queryFields?.length > 0 && ['GET','DELETE'].includes(ep.method)) {
1114
+ setParams(ep.queryFields.map((f, i) => ({ key: f.name, value: '', id: Date.now() + i })));
1115
+ setActiveTab('params');
1116
+ } else if (['GET','DELETE'].includes(ep.method)) {
1117
+ setParams([]);
1118
+ setActiveTab('params');
1119
+ } else {
1120
+ setParams([]);
1121
+ }
1122
+
1123
+ // Set auth header if route has auth
1124
+ if (route.hasAuth) {
1125
+ const hasAuthHeader = headers.some(h => h.key === 'Authorization');
1126
+ if (!hasAuthHeader) {
1127
+ setHeaders(prev => [...prev, { key: 'Authorization', value: 'Bearer ', id: Date.now() }]);
1128
+ }
1129
+ }
1130
+
1131
+ setShowImport(false);
1132
+ toast(ep.method + ' ' + ep.fullPath + ' loaded', 'success');
1133
+ }
1134
+
1135
+
1136
+ function updateHeader(id, field, value) {
1137
+ setHeaders(prev => prev.map(h => h.id === id ? { ...h, [field]: value } : h));
1138
+ }
1139
+
1140
+ function addHeader() {
1141
+ setHeaders(prev => [...prev, { key: '', value: '', id: Date.now() }]);
1142
+ }
1143
+
1144
+ function removeHeader(id) {
1145
+ setHeaders(prev => prev.filter(h => h.id !== id));
1146
+ }
1147
+
1148
+ function updateParam(id, field, value) {
1149
+ setParams(prev => prev.map(p => p.id === id ? { ...p, [field]: value } : p));
1150
+ }
1151
+
1152
+ function addParam() {
1153
+ setParams(prev => [...prev, { key: '', value: '', id: Date.now() }]);
1154
+ }
1155
+
1156
+ function removeParam(id) {
1157
+ setParams(prev => prev.filter(p => p.id !== id));
1158
+ }
1159
+
1160
+ function addHeaderPreset(type) {
1161
+ const presets = {
1162
+ 'bearer': { key: 'Authorization', value: 'Bearer ' },
1163
+ 'json': { key: 'Content-Type', value: 'application/json' },
1164
+ 'form': { key: 'Content-Type', value: 'multipart/form-data' },
1165
+ 'urlencoded': { key: 'Content-Type', value: 'application/x-www-form-urlencoded' },
1166
+ 'accept': { key: 'Accept', value: 'application/json' },
1167
+ };
1168
+ const p = presets[type];
1169
+ if (p) {
1170
+ const exists = headers.find(h => h.key === p.key);
1171
+ if (exists) {
1172
+ setHeaders(prev => prev.map(h => h.key === p.key ? { ...h, value: p.value } : h));
1173
+ } else {
1174
+ setHeaders(prev => [...prev, { ...p, id: Date.now() }]);
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ function saveFieldsToServer(fields) {
1180
+ if (!matchedEndpoint) return;
1181
+ const matchKey = matchedEndpoint.fullPath + ':' + matchedEndpoint.method;
1182
+ const routeKey = encodeURIComponent(matchKey);
1183
+ api('/api-tester/fields/' + routeKey, {
1184
+ method: 'PUT',
1185
+ body: { fields: fields.map(f => ({ key: f.key, value: f.value || '', type: f.type || 'text', id: f.id })) },
1186
+ }).catch(() => {});
1187
+ }
1188
+
1189
+ function addFormField() {
1190
+ setFormFields(prev => {
1191
+ const updated = [...prev, { key: '', value: '', type: 'text', id: Date.now() }];
1192
+ saveFieldsToServer(updated);
1193
+ return updated;
1194
+ });
1195
+ }
1196
+
1197
+ function updateFormField(id, field, value) {
1198
+ setFormFields(prev => {
1199
+ const updated = prev.map(f => f.id === id ? { ...f, [field]: value } : f);
1200
+ saveFieldsToServer(updated);
1201
+ return updated;
1202
+ });
1203
+ }
1204
+
1205
+ function removeFormField(id) {
1206
+ setFormFields(prev => {
1207
+ const updated = prev.filter(f => f.id !== id);
1208
+ saveFieldsToServer(updated);
1209
+ return updated;
1210
+ });
1211
+ }
1212
+
1213
+ function handleFileSelect(fieldId, file, sendAs) {
1214
+ if (!file) return;
1215
+ const reader = new FileReader();
1216
+ reader.onload = () => {
1217
+ const base64 = reader.result.split(',')[1];
1218
+ setFormFields(prev => prev.map(f => {
1219
+ if (f.id !== fieldId) return f;
1220
+ if (sendAs === 'base64') {
1221
+ return { ...f, value: base64, fileName: file.name, fileType: file.type, sendAs: 'base64', fileSize: file.size };
1222
+ }
1223
+ return { ...f, fileData: base64, fileName: file.name, fileType: file.type, sendAs: 'file', fileSize: file.size };
1224
+ }));
1225
+ };
1226
+ reader.readAsDataURL(file);
1227
+ }
1228
+
1229
+ function validateBody(val) {
1230
+ setBody(val);
1231
+ if (!val.trim()) { setBodyError(null); return; }
1232
+ try { JSON.parse(val); setBodyError(null); }
1233
+ catch (e) { setBodyError(e.message); }
1234
+ }
1235
+
1236
+ function formatBody() {
1237
+ try {
1238
+ const parsed = JSON.parse(body);
1239
+ setBody(JSON.stringify(parsed, null, 2));
1240
+ setBodyError(null);
1241
+ } catch {}
1242
+ }
1243
+
1244
+ const statusClass = response ? 's' + Math.floor(response.status / 100) + 'xx' : '';
1245
+ const hasBody = ['POST','PUT','PATCH'].includes(method);
1246
+
1247
+ // Build display URL with param values and query params filled in
1248
+ function buildDisplayUrl() {
1249
+ let u = url;
1250
+ urlParams.forEach(p => {
1251
+ if (p.key && p.value) u = u.replace(':' + p.key, p.value);
1252
+ });
1253
+ const qp = params.filter(p => p.key?.trim() && p.value?.trim());
1254
+ if (qp.length > 0) {
1255
+ const sep = u.includes('?') ? '&' : '?';
1256
+ u += sep + qp.map(p => encodeURIComponent(p.key) + '=' + encodeURIComponent(p.value)).join('&');
1257
+ }
1258
+ return u;
1259
+ }
1260
+ const displayUrl = buildDisplayUrl();
1261
+
1262
+ const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
1263
+ const methodColors = { GET: 'var(--success)', POST: '#f59e0b', PUT: '#3b82f6', PATCH: '#8b5cf6', DELETE: 'var(--danger)' };
1264
+
1265
+ const modeTabs = [
1266
+ { id: 'rest', label: 'REST API', icon: LuSend },
1267
+ { id: 'socketio', label: 'Socket.IO', icon: LuRadio },
1268
+ { id: 'websocket', label: 'WebSocket', icon: LuWifi },
1269
+ ];
1270
+
1271
+ const loadedSocketTest = savedTests.find(t => t.id === selectedSaved);
1272
+ const typeBadgeStyles = {
1273
+ socketio: { background: 'rgba(52,211,153,0.15)', color: '#34d399' },
1274
+ websocket: { background: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
1275
+ };
1276
+
1277
+ return html`<div>
1278
+ <div className="flex items-center justify-between mb-4">
1279
+ <div>
1280
+ <div className="card-title">API Tester</div>
1281
+ <div className="card-subtitle">Test REST APIs, Socket.IO, and WebSocket connections</div>
1282
+ </div>
1283
+ <div className="flex gap-2">
1284
+ ${testerMode === 'rest' && html`<button className="btn btn-ghost btn-sm" onClick=${loadImportRoutes}>
1285
+ <${LuImport} size=${14} /> Import Route
1286
+ </button>`}
1287
+ <button className=${`btn btn-sm ${shareNeeded ? 'btn-warning' : 'btn-primary'}`}
1288
+ onClick=${shareCollection} disabled=${savedTests.length === 0}
1289
+ style=${shareNeeded ? { animation: 'pulse 2s infinite', background: 'var(--warning)', color: '#000' } : {}}>
1290
+ <${LuShare2} size=${14} /> ${shareNeeded ? 'Update & Share' : lastSharedAt ? 'Re-share' : 'Share'}
1291
+ </button>
1292
+ </div>
1293
+ </div>
1294
+
1295
+ <div className="routes-layout">
1296
+ <!-- Left: Saved collection tree (always visible) -->
1297
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
1298
+ <div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
1299
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Collection</span>
1300
+ </div>
1301
+ <div style=${{ maxHeight: '600px', overflowY: 'auto' }}>
1302
+ ${savedTests.length === 0 && html`<div style=${{ padding: '30px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px' }}>
1303
+ No saved requests yet
1304
+ </div>`}
1305
+ ${savedTests.map(test => {
1306
+ const resps = savedResponses[test.id] || [];
1307
+ const isExpanded = expandedTests[test.id];
1308
+ const isParentActive = selectedSaved === test.id && !selectedResponse;
1309
+ const testType = test.type || 'rest';
1310
+ return html`<div key=${test.id}>
1311
+ <div style=${{ padding: '8px 10px', cursor: 'pointer', borderBottom: '1px solid var(--border)',
1312
+ background: isParentActive ? 'var(--accent-glow)' : 'transparent',
1313
+ borderLeft: isParentActive ? '2px solid var(--accent)' : '2px solid transparent',
1314
+ transition: 'background 0.1s' }}
1315
+ onClick=${() => { loadTest(test); setSelectedResponse(null); if (resps.length > 0) setExpandedTests(prev => ({ ...prev, [test.id]: true })); }}>
1316
+ <div className="flex items-center gap-2">
1317
+ ${resps.length > 0 && html`<span style=${{ fontSize: '10px', color: 'var(--text-muted)', width: '14px', textAlign: 'center', flexShrink: 0 }}>
1318
+ ${isExpanded ? '▼' : '▶'}
1319
+ </span>`}
1320
+ ${resps.length === 0 && html`<span style=${{ width: '14px', flexShrink: 0 }} />`}
1321
+ ${testType === 'rest' && html`<span className=${`route-method ${(test.method || 'GET').toLowerCase()}`} style=${{ fontSize: '9px', padding: '1px 5px' }}>${test.method || 'GET'}</span>`}
1322
+ ${testType === 'socketio' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace", ...typeBadgeStyles.socketio }}>SIO</span>`}
1323
+ ${testType === 'websocket' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace", ...typeBadgeStyles.websocket }}>WS</span>`}
1324
+ <span style=${{ fontSize: '12px', fontWeight: 500, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${test.name}</span>
1325
+ ${resps.length > 0 && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '8px', background: 'var(--accent-glow)', color: 'var(--accent-light)', fontWeight: 600 }}>${resps.length}</span>`}
1326
+ <button className="btn btn-ghost" style=${{ padding: '2px', opacity: 0.4 }}
1327
+ onClick=${(e) => { e.stopPropagation(); deleteTest(test.id); }}>
1328
+ <${LuTrash2} size=${11} />
1329
+ </button>
1330
+ </div>
1331
+ </div>
1332
+ ${isExpanded && resps.map(r => {
1333
+ const isRespActive = selectedResponse?.testId === test.id && selectedResponse?.respId === r.id;
1334
+ const isSocketChild = r.dir;
1335
+ return html`<div key=${r.id}
1336
+ style=${{ padding: '6px 10px 6px 40px', cursor: 'pointer', borderBottom: '1px solid var(--border)',
1337
+ background: isRespActive ? 'var(--accent-glow)' : 'transparent',
1338
+ borderLeft: isRespActive ? '2px solid var(--accent)' : '2px solid transparent',
1339
+ transition: 'background 0.1s' }}
1340
+ onClick=${() => viewSavedResponse(test.id, r.id)}>
1341
+ <div className="flex items-center gap-2">
1342
+ ${isSocketChild
1343
+ ? html`<span style=${{ fontSize: '10px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace",
1344
+ background: r.dir === 'received' ? 'rgba(52,211,153,0.15)' : r.dir === 'sent' ? 'rgba(245,158,11,0.15)' : 'rgba(148,163,184,0.15)',
1345
+ color: r.dir === 'received' ? '#34d399' : r.dir === 'sent' ? '#f59e0b' : '#94a3b8' }}>
1346
+ ${r.dir === 'received' ? '↓' : r.dir === 'sent' ? '↑' : '●'}
1347
+ </span>`
1348
+ : html`<span className=${`api-tester-status s${Math.floor(r.status / 100)}xx`} style=${{ fontSize: '10px', padding: '1px 5px' }}>
1349
+ ${r.status}
1350
+ </span>`}
1351
+ <span style=${{ fontSize: '11px', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>${r.label}</span>
1352
+ <button className="btn btn-ghost" style=${{ padding: '2px', opacity: 0.3 }}
1353
+ onClick=${(e) => { e.stopPropagation(); deleteResponse(test.id, r.id); }}>
1354
+ <${LuTrash2} size=${10} />
1355
+ </button>
1356
+ </div>
1357
+ </div>`;
1358
+ })}
1359
+ </div>`;
1360
+ })}
1361
+ </div>
1362
+ </div>
1363
+
1364
+ <!-- Right: Mode tabs + tester content -->
1365
+ <div>
1366
+ <div className="api-tester-mode-tabs">
1367
+ ${modeTabs.map(t => html`<button key=${t.id}
1368
+ className=${`api-tester-mode-tab${testerMode === t.id ? ' active' : ''}`}
1369
+ onClick=${() => { setTesterMode(t.id); setSelectedResponse(null); }}>
1370
+ <${t.icon} size=${14} /> ${t.label}
1371
+ </button>`)}
1372
+ </div>
1373
+
1374
+ ${testerMode === 'socketio' && html`<${SocketIOTester}
1375
+ selectedSaved=${selectedSaved}
1376
+ isSaved=${!!(selectedSaved && savedTests.find(t => t.id === selectedSaved)?.type === 'socketio')}
1377
+ loadedTest=${loadedSocketTest}
1378
+ onSaveTest=${handleSaveSocketTest}
1379
+ onSaveLog=${handleSaveLog} />`}
1380
+
1381
+ ${testerMode === 'websocket' && html`<${WebSocketTester}
1382
+ selectedSaved=${selectedSaved}
1383
+ isSaved=${!!(selectedSaved && savedTests.find(t => t.id === selectedSaved)?.type === 'websocket')}
1384
+ loadedTest=${loadedSocketTest}
1385
+ onSaveTest=${handleSaveSocketTest}
1386
+ onSaveLog=${handleSaveLog} />`}
1387
+
1388
+ ${testerMode === 'rest' && html`<div>
1389
+ <!-- Request tabs -->
1390
+ <div style=${{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '10px', flexWrap: 'wrap' }}>
1391
+ ${tabs.map(t => { const active = t.id === activeTabId; return html`<div key=${t.id}
1392
+ onClick=${() => switchTab(t.id)}
1393
+ style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', cursor: 'pointer',
1394
+ border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border)'),
1395
+ background: active ? 'var(--accent-glow)' : 'transparent',
1396
+ color: active ? 'var(--accent-light)' : 'var(--text-secondary)',
1397
+ borderRadius: '6px', padding: '5px 10px', maxWidth: '220px', fontSize: '12px' }}>
1398
+ <span style=${{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${tabLabel(t)}</span>
1399
+ ${tabs.length > 1 && html`<span onClick=${(e) => { e.stopPropagation(); closeTab(t.id); }}
1400
+ style=${{ cursor: 'pointer', fontSize: '15px', lineHeight: 1, opacity: 0.6 }}>×</span>`}
1401
+ </div>`; })}
1402
+ <button onClick=${newTab} title="New request tab"
1403
+ style=${{ border: '1px solid var(--border)', background: 'transparent', color: 'var(--text-secondary)', borderRadius: '6px', padding: '4px 11px', cursor: 'pointer', fontSize: '16px', lineHeight: 1 }}>+</button>
1404
+ </div>
1405
+
1406
+ <!-- Request bar -->
1407
+ <div className="api-tester-bar">
1408
+ <select className="api-tester-method"
1409
+ value=${method}
1410
+ onChange=${(e) => setMethod(e.target.value)}
1411
+ style=${{ color: methodColors[method] || 'var(--text-primary)' }}>
1412
+ ${methods.map(m => html`<option key=${m} value=${m} style=${{ color: 'var(--text-primary)' }}>${m}</option>`)}
1413
+ </select>
1414
+ <input
1415
+ className="api-tester-url"
1416
+ type="text"
1417
+ placeholder="http://localhost:3000/api/users"
1418
+ value=${url}
1419
+ onInput=${(e) => setUrl(e.target.value)}
1420
+ onKeyDown=${(e) => { if (e.key === 'Enter') sendRequest(); }}
1421
+ />
1422
+ <button className="api-tester-send" onClick=${sendRequest} disabled=${loading || !url.trim()}>
1423
+ ${loading ? html`<${LuLoader} size=${14} className="spin" />` : html`<${LuSend} size=${14} />`}
1424
+ Send
1425
+ </button>
1426
+ <button className="api-tester-send" onClick=${() => { setShowVars(v => !v); setShowHistory(false); }}
1427
+ title="Environment variables"
1428
+ style=${{ background: showVars ? 'var(--accent-glow)' : 'transparent', border: '1px solid var(--border)', color: showVars ? 'var(--accent-light)' : 'var(--text-primary)' }}>
1429
+ Variables${variables.filter(v => v.key?.trim()).length ? ` (${variables.filter(v => v.key?.trim()).length})` : ''}
1430
+ </button>
1431
+ <button className="api-tester-send" onClick=${() => { setShowHistory(h => !h); setShowVars(false); }}
1432
+ title="Request history"
1433
+ style=${{ background: showHistory ? 'var(--accent-glow)' : 'transparent', border: '1px solid var(--border)', color: showHistory ? 'var(--accent-light)' : 'var(--text-primary)' }}>
1434
+ History
1435
+ </button>
1436
+ ${!savedTests.some(t => t.type !== 'socketio' && t.type !== 'websocket' && t.method === method && t.url === url) && html`<button className="api-tester-send" onClick=${saveTest} disabled=${!url.trim()}
1437
+ style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
1438
+ <${LuSave} size=${14} /> Save
1439
+ </button>`}
1440
+ ${selectedResponse && (() => {
1441
+ const sr = (savedResponses[selectedResponse.testId] || []).find(x => x.id === selectedResponse.respId);
1442
+ if (!sr) return null;
1443
+ return html`<button className="api-tester-send" onClick=${() => {
1444
+ updateResponse(selectedResponse.testId, selectedResponse.respId, { requestBody: body });
1445
+ }} style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
1446
+ <${LuSave} size=${14} /> Update
1447
+ </button>`;
1448
+ })()}
1449
+ </div>
1450
+
1451
+ ${displayUrl !== url && html`<div style=${{
1452
+ padding: '6px 14px', fontSize: '11px', color: 'var(--text-muted)',
1453
+ fontFamily: "'SF Mono', monospace", background: 'rgba(124,58,237,0.04)',
1454
+ borderRadius: '6px', marginBottom: '8px', wordBreak: 'break-all',
1455
+ }}>${displayUrl}</div>`}
1456
+
1457
+ ${showVars && html`<div className="card" style=${{ padding: '12px 14px', marginBottom: '12px' }}>
1458
+ <div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
1459
+ <span style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Environment Variables</span>
1460
+ <label style=${{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: 'var(--text-secondary)', cursor: 'pointer' }}>
1461
+ <input type="checkbox" checked=${autoToken} onChange=${e => setAutoToken(e.target.checked)} /> Auto-capture token from response
1462
+ </label>
1463
+ </div>
1464
+ ${variables.map((v, i) => html`<div key=${v.id || i} className="api-tester-kv-row">
1465
+ <input placeholder="name" value=${v.key}
1466
+ onInput=${e => setVariables(prev => prev.map((x, j) => j === i ? { ...x, key: e.target.value } : x))} />
1467
+ <input placeholder="value" value=${v.value}
1468
+ onInput=${e => setVariables(prev => prev.map((x, j) => j === i ? { ...x, value: e.target.value } : x))} />
1469
+ <button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
1470
+ onClick=${() => setVariables(prev => prev.filter((_, j) => j !== i))}><${LuTrash2} size=${12} /></button>
1471
+ </div>`)}
1472
+ <div style=${{ padding: '8px 0 0' }}>
1473
+ <button className="btn btn-ghost btn-sm" onClick=${() => setVariables(prev => [...prev, { key: '', value: '', id: Date.now() }])}>
1474
+ <${LuPlus} size=${12} /> Add variable
1475
+ </button>
1476
+ </div>
1477
+ <div className="text-sm text-muted" style=${{ marginTop: '6px' }}>
1478
+ Reference anywhere with <span style=${{ fontFamily: "'SF Mono', monospace", color: 'var(--accent-light)' }}>{{name}}</span> — URL, headers, body, or Auth.
1479
+ </div>
1480
+ </div>`}
1481
+
1482
+ ${showHistory && html`<div className="card" style=${{ padding: 0, marginBottom: '12px', overflow: 'hidden' }}>
1483
+ <div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
1484
+ <span style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>History</span>
1485
+ ${history.length > 0 && html`<button className="btn btn-ghost btn-sm" onClick=${clearHistory} style=${{ fontSize: '11px' }}>Clear</button>`}
1486
+ </div>
1487
+ ${history.length === 0
1488
+ ? html`<div style=${{ padding: '16px', color: 'var(--text-muted)', fontSize: '12px' }}>No requests yet — sent requests show up here.</div>`
1489
+ : history.map(h => html`<div key=${h.id}
1490
+ onClick=${() => loadHistory(h)}
1491
+ style=${{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer', fontSize: '12px' }}>
1492
+ <span style=${{ fontWeight: 600, color: methodColors[h.method] || 'var(--text-secondary)', minWidth: '46px' }}>${h.method}</span>
1493
+ <span style=${{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'SF Mono', monospace" }}>${h.url}</span>
1494
+ <span style=${{ fontWeight: 600, color: h.status >= 200 && h.status < 300 ? 'var(--success)' : h.status === 0 ? 'var(--text-muted)' : 'var(--warning)' }}>${h.status || '—'}</span>
1495
+ </div>`)}
1496
+ </div>`}
1497
+
1498
+ <!-- URL Params (:id, etc.) -->
1499
+ ${urlParams.length > 0 && html`<div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1500
+ <div style=${{ padding: '8px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
1501
+ <span style=${{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Path Variables</span>
1502
+ </div>
1503
+ ${urlParams.map(p => html`<div key=${p.id} className="api-tester-kv-row">
1504
+ <input value=${p.key} disabled style=${{
1505
+ flex: '0 0 120px', opacity: 0.6, fontWeight: 600, fontFamily: "'SF Mono', monospace",
1506
+ color: 'var(--accent-light)', background: 'var(--bg-input)', fontSize: '12px',
1507
+ }} />
1508
+ <input placeholder=${'Enter ' + p.key + ' value'} value=${p.value}
1509
+ onInput=${(e) => setUrlParams(prev => prev.map(up => up.id === p.id ? { ...up, value: e.target.value } : up))}
1510
+ style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px' }} />
1511
+ </div>`)}
1512
+ </div>`}
1513
+
1514
+ <!-- Tabs: Params / Headers / Body -->
1515
+ <div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1516
+ <div className="api-tester-tabs">
1517
+ <button className=${`api-tester-tab${activeTab === 'params' ? ' active' : ''}`}
1518
+ onClick=${() => setActiveTab('params')}>
1519
+ Params <span style=${{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: '4px' }}>(${params.filter(p => p.key?.trim()).length})</span>
1520
+ </button>
1521
+ <button className=${`api-tester-tab${activeTab === 'headers' ? ' active' : ''}`}
1522
+ onClick=${() => setActiveTab('headers')}>
1523
+ Headers <span style=${{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: '4px' }}>(${headers.filter(h => h.key?.trim()).length})</span>
1524
+ </button>
1525
+ ${hasBody && html`<button className=${`api-tester-tab${activeTab === 'body' ? ' active' : ''}`}
1526
+ onClick=${() => setActiveTab('body')}>
1527
+ Body
1528
+ </button>`}
1529
+ <button className=${`api-tester-tab${activeTab === 'auth' ? ' active' : ''}`}
1530
+ onClick=${() => setActiveTab('auth')}>
1531
+ Auth${auth.type !== 'none' ? html` <span style=${{ color: 'var(--success)', marginLeft: '2px' }}>●</span>` : ''}
1532
+ </button>
1533
+ </div>
1534
+
1535
+ ${activeTab === 'auth' && html`<div style=${{ padding: '12px 14px' }}>
1536
+ <div style=${{ display: 'flex', gap: '6px', marginBottom: '12px', flexWrap: 'wrap' }}>
1537
+ ${[['none', 'No Auth'], ['bearer', 'Bearer Token'], ['basic', 'Basic'], ['apikey', 'API Key']].map(([t, label]) => html`<button key=${t}
1538
+ onClick=${() => setAuth(a => ({ ...a, type: t }))}
1539
+ className="badge"
1540
+ style=${{ cursor: 'pointer', border: '1px solid var(--border)', background: auth.type === t ? 'var(--accent-glow)' : 'var(--bg-input)', color: auth.type === t ? 'var(--accent-light)' : 'var(--text-secondary)' }}>${label}</button>`)}
1541
+ </div>
1542
+ ${auth.type === 'bearer' && html`<input className="form-input" placeholder="Token (e.g. {{token}})"
1543
+ value=${auth.token} onInput=${e => setAuth(a => ({ ...a, token: e.target.value }))} />`}
1544
+ ${auth.type === 'basic' && html`<div style=${{ display: 'flex', gap: '8px' }}>
1545
+ <input className="form-input" placeholder="Username" value=${auth.username} onInput=${e => setAuth(a => ({ ...a, username: e.target.value }))} />
1546
+ <input className="form-input" placeholder="Password" value=${auth.password} onInput=${e => setAuth(a => ({ ...a, password: e.target.value }))} />
1547
+ </div>`}
1548
+ ${auth.type === 'apikey' && html`<div style=${{ display: 'flex', gap: '8px' }}>
1549
+ <input className="form-input" placeholder="Header name" value=${auth.apiKeyKey} onInput=${e => setAuth(a => ({ ...a, apiKeyKey: e.target.value }))} style=${{ flex: '0 0 170px' }} />
1550
+ <input className="form-input" placeholder="Value (e.g. {{apiKey}})" value=${auth.apiKeyValue} onInput=${e => setAuth(a => ({ ...a, apiKeyValue: e.target.value }))} />
1551
+ </div>`}
1552
+ ${auth.type === 'none' && html`<div className="text-sm text-muted">No authentication will be sent with this request.</div>`}
1553
+ </div>`}
1554
+
1555
+ ${activeTab === 'params' && html`<div>
1556
+ ${params.map(p => html`<div key=${p.id} className="api-tester-kv-row">
1557
+ <input placeholder="Param name" value=${p.key}
1558
+ onInput=${(e) => updateParam(p.id, 'key', e.target.value)} />
1559
+ <input placeholder="Value" value=${p.value}
1560
+ onInput=${(e) => updateParam(p.id, 'value', e.target.value)} />
1561
+ <button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
1562
+ onClick=${() => removeParam(p.id)}>
1563
+ <${LuTrash2} size=${12} />
1564
+ </button>
1565
+ </div>`)}
1566
+ <div style=${{ padding: '8px 14px' }}>
1567
+ <button className="btn btn-ghost btn-sm" onClick=${addParam}>
1568
+ <${LuPlus} size=${12} /> Add Param
1569
+ </button>
1570
+ </div>
1571
+ </div>`}
1572
+
1573
+ ${activeTab === 'headers' && html`<div>
1574
+ ${headers.map(h => html`<div key=${h.id} className="api-tester-kv-row">
1575
+ <input placeholder="Header name" value=${h.key}
1576
+ onInput=${(e) => updateHeader(h.id, 'key', e.target.value)} />
1577
+ <input placeholder="Value" value=${h.value}
1578
+ onInput=${(e) => updateHeader(h.id, 'value', e.target.value)} />
1579
+ <button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
1580
+ onClick=${() => removeHeader(h.id)}>
1581
+ <${LuTrash2} size=${12} />
1582
+ </button>
1583
+ </div>`)}
1584
+ <div style=${{ padding: '8px 14px', display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' }}>
1585
+ <button className="btn btn-ghost btn-sm" onClick=${addHeader}>
1586
+ <${LuPlus} size=${12} /> Add Header
1587
+ </button>
1588
+ <span style=${{ fontSize: '10px', color: 'var(--text-muted)', margin: '0 4px' }}>|</span>
1589
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('bearer')}>+ Auth Bearer</button>
1590
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('json')}>+ JSON</button>
1591
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('form')}>+ Form Data</button>
1592
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('urlencoded')}>+ URL Encoded</button>
1593
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '10px' }} onClick=${() => addHeaderPreset('accept')}>+ Accept JSON</button>
1594
+ </div>
1595
+ </div>`}
1596
+
1597
+ ${activeTab === 'body' && hasBody && html`<div>
1598
+ <div style=${{ display: 'flex', gap: '0', borderBottom: '1px solid var(--border)' }}>
1599
+ ${[
1600
+ { id: 'json', label: 'JSON' },
1601
+ { id: 'form-data', label: 'Form Data' },
1602
+ { id: 'x-www-form-urlencoded', label: 'URL Encoded' },
1603
+ { id: 'raw', label: 'Raw' },
1604
+ { id: 'binary', label: 'Binary' },
1605
+ ].map(bt => html`<button key=${bt.id}
1606
+ style=${{
1607
+ padding: '6px 14px', fontSize: '11px', fontWeight: bodyType === bt.id ? 600 : 400,
1608
+ color: bodyType === bt.id ? 'var(--accent-light)' : 'var(--text-muted)',
1609
+ background: 'none', border: 'none', cursor: 'pointer',
1610
+ borderBottom: bodyType === bt.id ? '2px solid var(--accent)' : '2px solid transparent',
1611
+ transition: 'all 0.15s',
1612
+ }}
1613
+ onClick=${() => setBodyType(bt.id)}
1614
+ >${bt.label}</button>`)}
1615
+ </div>
1616
+
1617
+ ${(bodyType === 'json' || bodyType === 'raw') && html`<div>
1618
+ <textarea
1619
+ ref=${bodyRef}
1620
+ className="api-tester-body-editor"
1621
+ placeholder=${bodyType === 'json' ? '{\n "key": "value"\n}' : 'Enter raw body...'}
1622
+ value=${body}
1623
+ onInput=${(e) => bodyType === 'json' ? validateBody(e.target.value) : setBody(e.target.value)}
1624
+ onBlur=${bodyType === 'json' ? formatBody : undefined}
1625
+ spellcheck=${false}
1626
+ style=${{ borderTop: bodyError && bodyType === 'json' ? '2px solid var(--danger)' : 'none' }}
1627
+ />
1628
+ ${bodyError && bodyType === 'json' && html`<div style=${{ padding: '6px 14px', fontSize: '11px', color: 'var(--danger)', background: 'rgba(248,113,113,0.06)' }}>
1629
+ Invalid JSON: ${bodyError}
1630
+ </div>`}
1631
+ </div>`}
1632
+
1633
+ ${(bodyType === 'form-data' || bodyType === 'x-www-form-urlencoded') && html`<div>
1634
+ ${formFields.map(f => html`<div key=${f.id} style=${{
1635
+ display: 'flex', gap: '8px', alignItems: 'center', padding: '8px 14px',
1636
+ borderBottom: '1px solid var(--border)',
1637
+ }}>
1638
+ <input placeholder="Key" value=${f.key} style=${{
1639
+ flex: '0 0 140px', padding: '6px 10px', fontSize: '12px',
1640
+ background: 'var(--bg-input)', border: '1px solid var(--border)', borderRadius: '6px',
1641
+ color: 'var(--text-primary)', outline: 'none',
1642
+ }} onInput=${(e) => updateFormField(f.id, 'key', e.target.value)} />
1643
+
1644
+ ${bodyType === 'form-data' && html`<select style=${{
1645
+ padding: '6px 8px', fontSize: '11px', background: 'var(--bg-input)',
1646
+ border: '1px solid var(--border)', borderRadius: '6px', color: 'var(--text-secondary)',
1647
+ cursor: 'pointer', minWidth: '70px',
1648
+ }} value=${f.type || 'text'} onChange=${(e) => updateFormField(f.id, 'type', e.target.value)}>
1649
+ <option value="text">Text</option>
1650
+ <option value="file">File</option>
1651
+ </select>`}
1652
+
1653
+ ${(f.type !== 'file' || bodyType === 'x-www-form-urlencoded') && html`<input
1654
+ placeholder="Value" value=${f.value || ''} style=${{
1655
+ flex: 1, padding: '6px 10px', fontSize: '12px',
1656
+ background: 'var(--bg-input)', border: '1px solid var(--border)', borderRadius: '6px',
1657
+ color: 'var(--text-primary)', outline: 'none',
1658
+ }} onInput=${(e) => updateFormField(f.id, 'value', e.target.value)} />`}
1659
+
1660
+ ${f.type === 'file' && bodyType === 'form-data' && html`<div style=${{ flex: 1, display: 'flex', gap: '6px', alignItems: 'center' }}>
1661
+ ${f.fileName ? html`<span style=${{
1662
+ fontSize: '11px', color: 'var(--text-secondary)', padding: '4px 8px',
1663
+ background: 'var(--bg-input)', borderRadius: '4px', border: '1px solid var(--border)',
1664
+ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
1665
+ }}>${f.fileName} <span style=${{ color: 'var(--text-muted)', fontSize: '10px' }}>(${f.fileSize > 1024 ? (f.fileSize / 1024).toFixed(1) + 'KB' : f.fileSize + 'B'})</span></span>` : null}
1666
+ <label style=${{
1667
+ padding: '5px 12px', fontSize: '11px', borderRadius: '6px', cursor: 'pointer',
1668
+ background: 'var(--bg-input)', border: '1px solid var(--border)',
1669
+ color: 'var(--accent-light)', fontWeight: 500,
1670
+ }}>
1671
+ ${f.fileName ? 'Change' : 'Choose File'}
1672
+ <input type="file" style=${{ display: 'none' }} onChange=${(e) => {
1673
+ const file = e.target.files[0];
1674
+ if (file) {
1675
+ const sendAs = f.sendAs || 'file';
1676
+ handleFileSelect(f.id, file, sendAs);
1677
+ }
1678
+ }} />
1679
+ </label>
1680
+ ${f.fileName && html`<select style=${{
1681
+ padding: '5px 8px', fontSize: '10px', background: 'var(--bg-input)',
1682
+ border: '1px solid var(--border)', borderRadius: '6px', color: 'var(--text-secondary)',
1683
+ cursor: 'pointer',
1684
+ }} value=${f.sendAs || 'file'} onChange=${(e) => {
1685
+ const newSendAs = e.target.value;
1686
+ setFormFields(prev => prev.map(ff => {
1687
+ if (ff.id !== f.id) return ff;
1688
+ var updated = { ...ff, sendAs: newSendAs };
1689
+ if (ff.fileData && newSendAs === 'base64') updated.value = ff.fileData;
1690
+ else if (ff.value && newSendAs === 'file') updated.fileData = ff.value;
1691
+ return updated;
1692
+ }));
1693
+ }}>
1694
+ <option value="file">Send as File</option>
1695
+ <option value="base64">Send as Base64</option>
1696
+ </select>`}
1697
+ </div>`}
1698
+
1699
+ <button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
1700
+ onClick=${() => removeFormField(f.id)}>
1701
+ <${LuTrash2} size=${12} />
1702
+ </button>
1703
+ </div>`)}
1704
+ <div style=${{ padding: '8px 14px' }}>
1705
+ <button className="btn btn-ghost btn-sm" onClick=${addFormField}>
1706
+ <${LuPlus} size=${12} /> Add Field
1707
+ </button>
1708
+ </div>
1709
+ </div>`}
1710
+
1711
+ ${bodyType === 'binary' && html`<div style=${{ padding: '20px 14px', textAlign: 'center' }}>
1712
+ <label style=${{
1713
+ padding: '12px 24px', fontSize: '13px', borderRadius: '8px', cursor: 'pointer',
1714
+ background: 'var(--bg-input)', border: '1.5px dashed var(--border)',
1715
+ color: 'var(--accent-light)', fontWeight: 500, display: 'inline-block',
1716
+ }}>
1717
+ ${body ? 'File selected (' + (body.length * 0.75 / 1024).toFixed(1) + ' KB)' : 'Choose File'}
1718
+ <input type="file" style=${{ display: 'none' }} onChange=${(e) => {
1719
+ const file = e.target.files[0];
1720
+ if (file) {
1721
+ const reader = new FileReader();
1722
+ reader.onload = () => setBody(reader.result.split(',')[1]);
1723
+ reader.readAsDataURL(file);
1724
+ }
1725
+ }} />
1726
+ </label>
1727
+ ${body && html`<button className="btn btn-ghost btn-sm" style=${{ marginLeft: '8px' }}
1728
+ onClick=${() => setBody('')}>Clear</button>`}
1729
+ </div>`}
1730
+ </div>`}
1731
+ </div>
1732
+
1733
+ <!-- Saved response detail (inline) -->
1734
+ ${selectedResponse && (() => {
1735
+ const r = (savedResponses[selectedResponse.testId] || []).find(x => x.id === selectedResponse.respId);
1736
+ if (!r) return null;
1737
+ const rStatusClass = 's' + Math.floor((r.status || 0) / 100) + 'xx';
1738
+ return html`<div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1739
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
1740
+ <span style=${{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>Saved Response</span>
1741
+ <span className=${`api-tester-status ${rStatusClass}`}>${r.status} ${r.statusText}</span>
1742
+ <span style=${{ fontSize: '11px', color: 'var(--text-muted)' }}>${r.time}ms</span>
1743
+ <span style=${{ fontSize: '13px', fontWeight: 500, marginLeft: 'auto', color: 'var(--text-secondary)' }}>${r.label}</span>
1744
+ </div>
1745
+ <div className="api-tester-response-body">${(() => {
1746
+ try { return JSON.stringify(JSON.parse(r.body), null, 2); } catch { return r.body || 'No response body'; }
1747
+ })()}</div>
1748
+ </div>`;
1749
+ })()}
1750
+
1751
+ <!-- Response (live) -->
1752
+ ${response && !selectedResponse && html`<div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
1753
+ <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
1754
+ <span style=${{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>Response</span>
1755
+ <span className=${`api-tester-status ${statusClass}`}>
1756
+ ${response.status} ${response.statusText}
1757
+ </span>
1758
+ <span style=${{ fontSize: '11px', color: 'var(--text-muted)' }}>${response.time}ms</span>
1759
+ <span style=${{ fontSize: '11px', color: 'var(--text-muted)' }}>
1760
+ ${response.size > 1024 ? (response.size / 1024).toFixed(1) + ' KB' : response.size + ' B'}
1761
+ </span>
1762
+ <div style=${{ marginLeft: 'auto', display: 'flex', gap: '6px' }}>
1763
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '11px' }}
1764
+ onClick=${() => { copyToClipboard(response.body || ''); toast('Copied', 'success'); }}>
1765
+ <${LuCopy} size=${12} /> Copy
1766
+ </button>
1767
+ <button className="btn btn-ghost btn-sm" style=${{ fontSize: '11px' }}
1768
+ onClick=${saveResponse}>
1769
+ <${LuSave} size=${12} /> Save
1770
+ </button>
1771
+ </div>
1772
+ </div>
1773
+ <div style=${{ display: 'flex', gap: '0', borderBottom: '1px solid var(--border)' }}>
1774
+ <button style=${{
1775
+ padding: '5px 14px', fontSize: '11px', background: 'none', border: 'none', cursor: 'pointer',
1776
+ color: responseTab === 'body' ? 'var(--accent-light)' : 'var(--text-muted)',
1777
+ borderBottom: responseTab === 'body' ? '2px solid var(--accent)' : '2px solid transparent',
1778
+ fontWeight: responseTab === 'body' ? 600 : 400,
1779
+ }} onClick=${() => setResponseTab('body')}>Body</button>
1780
+ <button style=${{
1781
+ padding: '5px 14px', fontSize: '11px', background: 'none', border: 'none', cursor: 'pointer',
1782
+ color: responseTab === 'headers' ? 'var(--accent-light)' : 'var(--text-muted)',
1783
+ borderBottom: responseTab === 'headers' ? '2px solid var(--accent)' : '2px solid transparent',
1784
+ fontWeight: responseTab === 'headers' ? 600 : 400,
1785
+ }} onClick=${() => setResponseTab('headers')}>Headers <span style=${{ fontSize: '10px', color: 'var(--text-muted)' }}>(${Object.keys(response.headers || {}).length})</span></button>
1786
+ </div>
1787
+ ${responseTab === 'body' && html`<div className="api-tester-response-body">
1788
+ ${response.body || 'No response body'}
1789
+ </div>`}
1790
+ ${responseTab === 'headers' && html`<div style=${{ padding: '8px 0' }}>
1791
+ ${Object.entries(response.headers || {}).map(([k, v]) => html`<div key=${k} style=${{
1792
+ display: 'flex', padding: '4px 14px', fontSize: '12px', gap: '12px',
1793
+ borderBottom: '1px solid rgba(255,255,255,0.03)',
1794
+ }}>
1795
+ <span style=${{ fontWeight: 600, color: 'var(--accent-light)', minWidth: '180px', fontFamily: "'SF Mono', monospace", fontSize: '11px' }}>${k}</span>
1796
+ <span style=${{ color: 'var(--text-secondary)', fontFamily: "'SF Mono', monospace", fontSize: '11px', wordBreak: 'break-all' }}>${v}</span>
1797
+ </div>`)}
1798
+ </div>`}
1799
+ </div>`}
1800
+
1801
+ ${!response && !loading && !selectedResponse && html`<div className="card">
1802
+ <div className="empty-state" style=${{ padding: '40px' }}>
1803
+ <div className="empty-icon"><${LuSend} size=${32} /></div>
1804
+ <h3>Send a Request</h3>
1805
+ <p>Enter a URL and click Send, or import from your existing routes</p>
1806
+ </div>
1807
+ </div>`}
1808
+ </div>`}
1809
+
1810
+ <!-- Viewing a saved socket response detail -->
1811
+ ${selectedResponse && testerMode !== 'rest' && (() => {
1812
+ const r = (savedResponses[selectedResponse.testId] || []).find(x => x.id === selectedResponse.respId);
1813
+ const parentTest = savedTests.find(t => t.id === selectedResponse.testId);
1814
+ if (!r || !parentTest) return null;
1815
+ return html`<${SavedResponseDetail}
1816
+ key=${selectedResponse.respId}
1817
+ resp=${r}
1818
+ parentTest=${parentTest}
1819
+ onBack=${() => { setSelectedResponse(null); loadTest(parentTest); }}
1820
+ onUpdate=${(changes) => updateResponse(selectedResponse.testId, selectedResponse.respId, changes)} />`;
1821
+ })()}
1822
+ </div>
1823
+ </div>
1824
+
1825
+ <!-- Import Route Panel -->
1826
+ <${AnimatePresence}>
1827
+ ${showImport && html`<${React.Fragment}>
1828
+ <${motion.div}
1829
+ key="import-overlay"
1830
+ initial=${{ opacity: 0 }}
1831
+ animate=${{ opacity: 1 }}
1832
+ exit=${{ opacity: 0 }}
1833
+ transition=${{ duration: 0.2 }}
1834
+ onClick=${() => setShowImport(false)}
1835
+ style=${{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.3)', zIndex: 1000 }}
1836
+ />
1837
+ <${motion.div}
1838
+ key="import-panel"
1839
+ initial=${{ x: '100%' }}
1840
+ animate=${{ x: 0 }}
1841
+ exit=${{ x: '100%' }}
1842
+ transition=${{ type: 'tween', duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
1843
+ style=${{
1844
+ position: 'fixed', top: 0, right: 0, bottom: 0, width: '25%', minWidth: '300px',
1845
+ background: 'var(--bg-card)', borderLeft: '1px solid var(--border)',
1846
+ boxShadow: '-8px 0 30px rgba(0,0,0,0.3)', zIndex: 1001,
1847
+ display: 'flex', flexDirection: 'column',
1848
+ }}>
1849
+ <div style=${{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
1850
+ <div style=${{ fontWeight: 600, fontSize: '15px' }}>Import from Routes</div>
1851
+ <button onClick=${() => setShowImport(false)}
1852
+ style=${{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: '18px', cursor: 'pointer', padding: '4px 8px', borderRadius: '6px', lineHeight: 1 }}
1853
+ onMouseOver=${(e) => e.currentTarget.style.color = 'var(--text)'}
1854
+ onMouseOut=${(e) => e.currentTarget.style.color = 'var(--text-muted)'}>✕</button>
1855
+ </div>
1856
+ <div style=${{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
1857
+ ${importLoading && html`<div style=${{ padding: '40px', textAlign: 'center' }}><span className="spinner" /></div>`}
1858
+ ${!importLoading && importRoutes.length === 0 && html`<div style=${{ padding: '30px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>No routes found</div>`}
1859
+ ${!importLoading && importRoutes.map(route => html`<div key=${route.name}>
1860
+ <div style=${{ padding: '10px 20px 6px', fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', position: 'sticky', top: 0, background: 'var(--bg-card)', zIndex: 1 }}>
1861
+ ${route.name} <span style=${{ fontWeight: 400, textTransform: 'none' }}>(${route.prefix})</span>
1862
+ </div>
1863
+ ${route.endpoints.map((ep, i) => html`<div key=${i}
1864
+ style=${{ padding: '9px 20px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px', transition: 'background 0.1s', borderBottom: '1px solid rgba(255,255,255,0.02)' }}
1865
+ onClick=${() => importEndpoint(route, ep)}
1866
+ onMouseOver=${(e) => e.currentTarget.style.background = 'var(--bg-input)'}
1867
+ onMouseOut=${(e) => e.currentTarget.style.background = 'transparent'}>
1868
+ <span className=${`route-method ${ep.method.toLowerCase()}`} style=${{ fontSize: '10px', padding: '2px 7px' }}>${ep.method}</span>
1869
+ <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', color: 'var(--text-primary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${ep.fullPath}</span>
1870
+ ${ep.bodyFields?.length > 0 && html`<span style=${{ fontSize: '10px', color: 'var(--text-muted)', flexShrink: 0 }}>${ep.bodyFields.length} fields</span>`}
1871
+ </div>`)}
1872
+ </div>`)}
1873
+ </div>
1874
+ <//>
1875
+ <//>`}
1876
+ <//>
1877
+ </div>`;
1878
+ }
1879
+