4bnode 4.1.5 → 4.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/add-api.js +5 -3
- package/add-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +191 -57
- package/lib/codegen.test.js +87 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +110 -50
- package/skeleton/.4bnode/codegen.js +191 -57
- package/skeleton/.4bnode/dev-api.js +284 -56
- package/skeleton/.env.example +17 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +7 -2
- package/skeleton/src/middleware/apiKey.js +14 -1
- package/skeleton/src/middleware/errorHandler.js +7 -2
- package/app-1/.4bnode/.passkey +0 -1
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +0 -139
- package/app-1/.4bnode/assets/js/Root.js +0 -40
- package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
- package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
- package/app-1/.4bnode/assets/js/context.js +0 -7
- package/app-1/.4bnode/assets/js/lib.js +0 -113
- package/app-1/.4bnode/assets/js/main.js +0 -5
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
- package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -224
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
- package/app-1/.4bnode/codegen.js +0 -1440
- package/app-1/.4bnode/dev-api.js +0 -3747
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/.env.example +0 -11
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -188
- package/app-1/package.json +0 -37
- package/app-1/public/index.html +0 -292
- package/app-1/src/db.js +0 -13
- package/app-1/src/docs.js +0 -84
- package/app-1/src/middleware/apiKey.js +0 -36
- package/app-1/src/middleware/auth.js +0 -17
- package/app-1/src/middleware/errorHandler.js +0 -15
- package/app-1/src/middleware/roles.js +0 -14
- package/app-1/src/middleware/validate.js +0 -20
- package/app-1/src/models/user.js +0 -11
- package/app-1/src/openapi.json +0 -80
- package/app-1/src/routes/upload-resume.js +0 -21
- package/app-1/src/services/mailer.js +0 -52
- package/app-1/src/validators/upload-resume.js +0 -8
|
@@ -1,1879 +0,0 @@
|
|
|
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
|
-
|