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