@aimeloic/monkey-tester 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/htmlTemplate.js +295 -0
  2. package/index.js +83 -0
  3. package/package.json +13 -0
@@ -0,0 +1,295 @@
1
+ export function getHtmlTemplate(endpoints) {
2
+ return `
3
+ <!DOCTYPE html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="UTF-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <title>Endtester — API Environment</title>
9
+ <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: #0e0c09;
13
+ --surface: #181510;
14
+ --surface2: #221d14;
15
+ --border: #3a3020;
16
+ --accent: #e8a838;
17
+ --accent2: #c47a1e;
18
+ --text: #f0e8d8;
19
+ --text-dim: #9a8c78;
20
+ --red: #d45c3c;
21
+ --green: #6ba05a;
22
+ --blue: #5a86c0;
23
+ --radius: 8px;
24
+ }
25
+ * { box-sizing: border-box; margin: 0; padding: 0; }
26
+ body {
27
+ background: var(--bg); color: var(--text); font-family: 'DM Sans', sans-serif; font-size: 14px; min-height: 100vh;
28
+ background-image: radial-gradient(ellipse 80% 60% at 50% -20%, #3a2a0a22 0%, transparent 70%);
29
+ }
30
+ header {
31
+ border-bottom: 1px solid var(--border); padding: 20px 32px; display: flex; align-items: center; gap: 20px;
32
+ background: #0e0c09ee; backdrop-filter: blur(8px); position: sticky; top: 0; z-index: 100;
33
+ }
34
+ .logo { font-family: 'Playfair Display', serif; font-size: 22px; color: var(--accent); letter-spacing: 0.02em; }
35
+ .logo span { color: var(--text-dim); font-size: 13px; font-family: 'DM Mono', monospace; display: block; font-weight: 400; }
36
+ .header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
37
+ .jwt-wrap, .base-url-wrap { display: flex; align-items: center; gap: 8px; }
38
+ .jwt-wrap label, .base-url-wrap label { color: var(--text-dim); font-size: 12px; font-family: 'DM Mono', monospace; }
39
+ #jwt-input, #base-url {
40
+ background: var(--surface2); border: 1px solid var(--border); color: var(--text);
41
+ font-family: 'DM Mono', monospace; font-size: 11px; padding: 6px 10px; border-radius: var(--radius); width: 220px; outline: none;
42
+ }
43
+ .layout { display: grid; grid-template-columns: 260px 1fr 400px; height: calc(100vh - 65px); }
44
+ aside { border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 0; }
45
+ .section-label { font-size: 10px; font-family: 'DM Mono', monospace; color: var(--text-dim); letter-spacing: 0.12em; text-transform: uppercase; padding: 12px 18px 6px; }
46
+ .nav-item { display: flex; align-items: center; gap: 10px; padding: 8px 18px; cursor: pointer; border-left: 2px solid transparent; color: var(--text-dim); }
47
+ .nav-item:hover { background: var(--surface); color: var(--text); }
48
+ .nav-item.active { border-left-color: var(--accent); background: var(--surface); color: var(--accent); }
49
+ .method-badge { font-family: 'DM Mono', monospace; font-size: 9px; font-weight: 500; padding: 2px 5px; border-radius: 3px; min-width: 45px; text-align: center; }
50
+ .GET { background: #1a3a22; color: #6ba05a; }
51
+ .POST { background: #1a2e3a; color: #5a86c0; }
52
+ .PUT, .PATCH { background: #3a2e10; color: #e8a838; }
53
+ .DELETE { background: #3a1a14; color: #d45c3c; }
54
+ .nav-label { font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
55
+ main { overflow-y: auto; padding: 24px; }
56
+ .endpoint-title { font-family: 'Playfair Display', serif; font-size: 20px; color: var(--accent); margin-bottom: 6px; }
57
+ .endpoint-path { font-family: 'DM Mono', monospace; font-size: 12px; color: var(--text-dim); margin-bottom: 20px; display: flex; align-items: center; gap: 8px; }
58
+ .endpoint-desc { color: var(--text-dim); font-size: 13px; line-height: 1.6; margin-bottom: 24px; border-left: 2px solid var(--border); padding-left: 12px; }
59
+ .form-section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; margin-bottom: 16px; }
60
+ .form-section-title { font-size: 11px; font-family: 'DM Mono', monospace; color: var(--text-dim); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 14px; }
61
+ .field-row { display: grid; grid-template-columns: 140px 1fr; align-items: center; gap: 10px; margin-bottom: 10px; }
62
+ .field-label { font-family: 'DM Mono', monospace; font-size: 11px; color: var(--text-dim); text-align: right; }
63
+ input[type=text], input[type=number], select { background: var(--surface2); border: 1px solid var(--border); color: var(--text); font-family: 'DM Mono', monospace; font-size: 12px; padding: 7px 10px; border-radius: var(--radius); width: 100%; outline: none; }
64
+ .btn { background: var(--accent); color: #0e0c09; border: none; padding: 10px 22px; border-radius: var(--radius); font-size: 13px; font-weight: 500; cursor: pointer; }
65
+ .btn-secondary { background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); margin-left: 8px;}
66
+ .response-panel { border-left: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
67
+ .response-header { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; background: var(--surface); }
68
+ .response-header-title { font-size: 11px; font-family: 'DM Mono', monospace; color: var(--text-dim); text-transform: uppercase; }
69
+ .status-badge { font-family: 'DM Mono', monospace; font-size: 12px; margin-left: auto; padding: 2px 8px; border-radius: 4px; }
70
+ .status-ok { background: #1a3a22; color: #6ba05a; }
71
+ .status-err { background: #3a1a14; color: #d45c3c; }
72
+ .status-idle { background: var(--surface2); color: var(--text-dim); }
73
+ .response-body { flex: 1; overflow-y: auto; padding: 16px; font-family: 'DM Mono', monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; }
74
+ .response-body.empty { color: var(--text-dim); display: flex; align-items: center; justify-content: center; }
75
+ .json-key { color: #e8a838; } .json-str { color: #9ab878; } .json-num { color: #5a86c0; }
76
+ #toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface2); border: 1px solid var(--border); padding: 10px 18px; border-radius: var(--radius); opacity: 0; transition: all .25s; }
77
+ #toast.show { opacity: 1; }
78
+ </style>
79
+ </head>
80
+ <body>
81
+
82
+ <header>
83
+ <div class="logo">Endtester <span>Application Runtime Sandbox</span></div>
84
+ <div class="header-right">
85
+ <div class="base-url-wrap">
86
+ <label>TARGET HOST</label>
87
+ <input id="base-url" type="text" value="">
88
+ </div>
89
+ <div class="jwt-wrap">
90
+ <label>BEARER AUTH</label>
91
+ <input id="jwt-input" type="text" placeholder="Token value...">
92
+ </div>
93
+ </div>
94
+ </header>
95
+
96
+ <div class="layout">
97
+ <aside id="sidebar-nav">
98
+ <div class="section-label">Discovered Endpoints</div>
99
+ </aside>
100
+
101
+ <main id="main-panel"></main>
102
+
103
+ <div class="response-panel">
104
+ <div class="response-header">
105
+ <span class="response-header-title">Response Output</span>
106
+ <span id="status-badge" class="status-badge status-idle">—</span>
107
+ </div>
108
+ <div class="response-body empty" id="response-body">Execute a request row to generate feedback data</div>
109
+ </div>
110
+ </div>
111
+
112
+ <div id="toast"></div>
113
+
114
+ <script>
115
+ // The backend server automatically stringifies the runtime mapping straight into here!
116
+ const ENDPOINTS = ${JSON.stringify(endpoints, null, 2)};
117
+
118
+ let currentEp = '';
119
+
120
+ // Setup host variables contextually on layout initialization
121
+ document.getElementById('base-url').value = window.location.origin;
122
+
123
+ function getBase() { return document.getElementById('base-url').value.replace(/\/$/, ''); }
124
+ function getJwt() { return document.getElementById('jwt-input').value.trim(); }
125
+
126
+ function buildSidebar() {
127
+ const sidebar = document.getElementById('sidebar-nav');
128
+ const keys = Object.keys(ENDPOINTS);
129
+
130
+ if(keys.length === 0) {
131
+ sidebar.innerHTML += '<div style="padding:15px; color:var(--text-dim)">No active application endpoints discovered.</div>';
132
+ return;
133
+ }
134
+
135
+ keys.forEach((key, index) => {
136
+ const ep = ENDPOINTS[key];
137
+ const div = document.createElement('div');
138
+ div.className = \`nav-item \${index === 0 ? 'active' : ''}\`;
139
+ div.setAttribute('data-ep', key);
140
+ div.innerHTML = \`<span class="method-badge \${ep.method}">\${ep.method}</span><span class="nav-label">\${ep.path}</span>\`;
141
+
142
+ div.addEventListener('click', () => {
143
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
144
+ div.classList.add('active');
145
+ clearResponse();
146
+ renderPanel(key);
147
+ });
148
+
149
+ sidebar.appendChild(div);
150
+ });
151
+
152
+ // Render first item initially
153
+ if(keys.length > 0) renderPanel(keys[0]);
154
+ }
155
+
156
+ function renderPanel(epKey) {
157
+ currentEp = epKey;
158
+ const ep = ENDPOINTS[epKey];
159
+ const main = document.getElementById('main-panel');
160
+ if(!ep) return;
161
+
162
+ let html = \`
163
+ <div class="endpoint-title">\${ep.title}</div>
164
+ <div class="endpoint-path">
165
+ <span class="method-badge \${ep.method}">\${ep.method}</span>
166
+ <span>\${ep.path}</span>
167
+ </div>
168
+ <div class="endpoint-desc">\${ep.desc}</div>
169
+ \`;
170
+
171
+ if (ep.params?.length) {
172
+ html += \`<div class="form-section"><div class="form-section-title">Path Parameters</div>\`;
173
+ ep.params.forEach(p => {
174
+ html += \`<div class="field-row">
175
+ <label class="field-label">\${p.label}</label>
176
+ <input type="text" id="param-\${p.name}" placeholder="\${p.placeholder}" />
177
+ </div>\`;
178
+ });
179
+ html += \`</div>\`;
180
+ }
181
+
182
+ if (ep.fields?.length) {
183
+ html += \`<div class="form-section"><div class="form-section-title">JSON Request Body Raw Payload</div>\`;
184
+ ep.fields.forEach(f => {
185
+ html += \`<div class="field-row">
186
+ <label class="field-label">\${f.label}</label>
187
+ <input type="text" id="field-\${f.name}" value='{"key": "value"}' />
188
+ </div>\`;
189
+ });
190
+ html += \`</div>\`;
191
+ }
192
+
193
+ html += \`<div class="btn-row">
194
+ <button class="btn" onclick="sendRequest()">Execute Route</button>
195
+ <button class="btn btn-secondary" onclick="clearResponse()">Clear Context</button>
196
+ </div>\`;
197
+
198
+ main.innerHTML = html;
199
+ }
200
+
201
+ async function sendRequest() {
202
+ const ep = ENDPOINTS[currentEp];
203
+ let path = ep.path;
204
+
205
+ if (ep.params) {
206
+ for (const p of ep.params) {
207
+ const val = document.getElementById(\`param-\${p.name}\`)?.value.trim();
208
+ if (!val) { showToast(\`⚠ Parameter \${p.label} required\`); return; }
209
+ path = path.replace(\`:\${p.name}\`, encodeURIComponent(val));
210
+ }
211
+ }
212
+
213
+ const url = getBase() + path;
214
+ const headers = { 'Content-Type': 'application/json' };
215
+
216
+ const jwt = getJwt();
217
+ if (jwt) headers['Authorization'] = \`Bearer \${jwt}\`;
218
+
219
+ let body = undefined;
220
+ if (ep.fields?.length && ['POST','PUT','PATCH'].includes(ep.method)) {
221
+ const rawVal = document.getElementById(\`field-\${ep.fields[0].name}\`).value.trim();
222
+ try {
223
+ JSON.parse(rawVal);
224
+ body = rawVal;
225
+ } catch(e) {
226
+ showToast('❌ Malformed JSON Body structure provided.');
227
+ return;
228
+ }
229
+ }
230
+
231
+ setResponse(null, 'loading');
232
+ const start = Date.now();
233
+
234
+ try {
235
+ const res = await fetch(url, { method: ep.method, headers, body });
236
+ const ms = Date.now() - start;
237
+ const text = await res.text();
238
+ let json;
239
+ try { json = JSON.parse(text); } catch { json = text; }
240
+ setResponse(json, res.ok ? 'ok' : 'err', res.status, ms);
241
+ } catch (err) {
242
+ setResponse({ error: err.message }, 'err', 'FAIL', 0);
243
+ }
244
+ }
245
+
246
+ function setResponse(data, state, status, ms) {
247
+ const badge = document.getElementById('status-badge');
248
+ const body = document.getElementById('response-body');
249
+
250
+ if (state === 'loading') {
251
+ badge.className = 'status-badge status-idle';
252
+ badge.textContent = '…';
253
+ body.innerHTML = 'Executing transmission...';
254
+ return;
255
+ }
256
+
257
+ badge.className = \`status-badge \${state === 'ok' ? 'status-ok' : 'status-err'}\`;
258
+ badge.textContent = \`\${status} · \${ms}ms\`;
259
+ body.innerHTML = highlightJson(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
260
+ }
261
+
262
+ function clearResponse() {
263
+ const badge = document.getElementById('status-badge');
264
+ const body = document.getElementById('response-body');
265
+ badge.className = 'status-badge status-idle';
266
+ badge.textContent = '—';
267
+ body.className = 'response-body empty';
268
+ body.textContent = 'Execute a request row to generate feedback data';
269
+ }
270
+
271
+ function highlightJson(str) {
272
+ return str
273
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
274
+ .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, match => {
275
+ if (/^"/.test(match)) {
276
+ if (/:$/.test(match)) return \`<span class="json-key">\${match}</span>\`;
277
+ return \`<span class="json-str">\${match}</span>\`;
278
+ }
279
+ return \`<span class="json-num">\${match}</span>\`;
280
+ });
281
+ }
282
+
283
+ function showToast(msg) {
284
+ const t = document.getElementById('toast');
285
+ t.textContent = msg; t.classList.add('show');
286
+ setTimeout(() => t.classList.remove('show'), 2500);
287
+ }
288
+
289
+ // Initial fire
290
+ buildSidebar();
291
+ </script>
292
+ </body>
293
+ </html>
294
+ `;
295
+ }
package/index.js ADDED
@@ -0,0 +1,83 @@
1
+ import { getHtmlTemplate } from './htmlTemplate.js';
2
+
3
+ /**
4
+ * Express middleware to automatically map routes and serve the endtester UI
5
+ */
6
+ export function endtesterExpress() {
7
+ return (req, res, next) => {
8
+ // We only intercept requests hitting the tester path
9
+ if (req.path !== '/api/tester' && req.path !== '/api/tester/') {
10
+ return next();
11
+ }
12
+
13
+ // 1. Inspect the Express app to extract all registered routes
14
+ const expressApp = req.app;
15
+ const routesStack = expressApp._router.stack;
16
+ const detectedEndpoints = {};
17
+
18
+ routesStack.forEach((middleware) => {
19
+ if (middleware.route) {
20
+ // Simple top-level routes
21
+ processRoute(middleware.route, detectedEndpoints);
22
+ } else if (middleware.name === 'router') {
23
+ // Nested router stacks (e.g., app.use('/api', apiRouter))
24
+ const baseUrl = middleware.regexp.source
25
+ .replace('^\\', '')
26
+ .replace('\\/?(?=\\/|$)', '')
27
+ .replace('\\', '');
28
+
29
+ middleware.handle.stack.forEach((nestedHandler) => {
30
+ if (nestedHandler.route) {
31
+ processRoute(nestedHandler.route, detectedEndpoints, baseUrl);
32
+ }
33
+ });
34
+ }
35
+ });
36
+
37
+ // 2. Generate the HTML template injecting our found endpoints
38
+ const fullHtml = getHtmlTemplate(detectedEndpoints);
39
+
40
+ // 3. Serve the interactive tester page
41
+ res.setHeader('Content-Type', 'text/html');
42
+ return res.send(fullHtml);
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Formats Express route data into the shape your UI script expects
48
+ */
49
+ function processRoute(route, targetObject, prefix = '') {
50
+ const fullPath = `${prefix}${route.path}`.replace(/\/+/g, '/');
51
+
52
+ // Skip the tester endpoint itself to avoid recursion confusion
53
+ if (fullPath.includes('/api/tester')) return;
54
+
55
+ // Extract path parameters (e.g., :id)
56
+ const pathParams = route.keys ? route.keys.map(k => ({
57
+ name: k.name,
58
+ label: k.name.toUpperCase(),
59
+ placeholder: 'value'
60
+ })) : [];
61
+
62
+ Object.keys(route.methods).forEach((method) => {
63
+ if (!route.methods[method]) return;
64
+
65
+ const httpMethod = method.toUpperCase();
66
+ // Unique key for your UI state
67
+ const key = `${httpMethod.toLowerCase()}-${fullPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
68
+
69
+ // Infer basic structure based on request types
70
+ const needsBody = ['POST', 'PUT', 'PATCH'].includes(httpMethod);
71
+
72
+ targetObject[key] = {
73
+ method: httpMethod,
74
+ path: fullPath,
75
+ auth: true, // Default to true, users can toggle or override
76
+ title: `${httpMethod} ${fullPath}`,
77
+ desc: `Auto-generated tester for ${fullPath}`,
78
+ params: pathParams,
79
+ queryParams: httpMethod === 'GET' ? [{ name: 'limit', label: 'Limit', placeholder: '10' }] : [],
80
+ fields: needsBody ? [{ name: 'payload', label: 'JSON Body Field', type: 'text', placeholder: 'Data string' }] : []
81
+ };
82
+ });
83
+ }
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@aimeloic/monkey-tester",
3
+ "version": "1.0.0",
4
+ "description": "Embedded interactive API testing UI for Node.js backends",
5
+ "main": "index.js",
6
+ "type":"module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "keywords": [],
11
+ "author": "",
12
+ "license": "ISC"
13
+ }