specforge-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specforge/__init__.py +23 -0
- specforge/adapters/fastapi.py +101 -0
- specforge/adapters/flask.py +78 -0
- specforge/assets/index.html +2995 -0
- specforge/cli/main.py +153 -0
- specforge/config.py +42 -0
- specforge/parser/models.py +131 -0
- specforge/parser/openapi3.py +285 -0
- specforge/parser/resolver.py +101 -0
- specforge/testing/exporters/pytest_export.py +89 -0
- specforge/testing/fuzzers/array_fuzzer.py +56 -0
- specforge/testing/fuzzers/boolean_fuzzer.py +38 -0
- specforge/testing/fuzzers/numeric_fuzzer.py +92 -0
- specforge/testing/fuzzers/object_fuzzer.py +99 -0
- specforge/testing/fuzzers/string_fuzzer.py +157 -0
- specforge/testing/invariants.py +170 -0
- specforge/testing/planner.py +155 -0
- specforge/utils/html_injector.py +43 -0
- specforge_api-0.1.0.dist-info/METADATA +260 -0
- specforge_api-0.1.0.dist-info/RECORD +23 -0
- specforge_api-0.1.0.dist-info/WHEEL +4 -0
- specforge_api-0.1.0.dist-info/entry_points.txt +2 -0
- specforge_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2995 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>SpecForge | Interactive API Studio & Contract Fuzzer</title>
|
|
7
|
+
<!-- Google Fonts -->
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
11
|
+
<!-- __SPECFORGE_CONFIG_INJECTION__ -->
|
|
12
|
+
<style>
|
|
13
|
+
/* Theme 1 - Indigo Slate Light (Default) */
|
|
14
|
+
:root, [data-theme="theme-1"] {
|
|
15
|
+
/* Brand & buttons */
|
|
16
|
+
--primary: #6366F1;
|
|
17
|
+
--primary-hover: #4F46E5;
|
|
18
|
+
--primary-active: #4338CA;
|
|
19
|
+
--primary-foreground: #FFFFFF;
|
|
20
|
+
|
|
21
|
+
/* Page & surfaces */
|
|
22
|
+
--background: #F8FAFC;
|
|
23
|
+
--foreground: #1E293B;
|
|
24
|
+
--card: #FFFFFF;
|
|
25
|
+
--card-foreground: #1E293B;
|
|
26
|
+
--popover: #FFFFFF;
|
|
27
|
+
--popover-foreground: #1E293B;
|
|
28
|
+
|
|
29
|
+
/* Secondary & muted */
|
|
30
|
+
--secondary: #F1F5F9;
|
|
31
|
+
--secondary-hover: #E2E8F0;
|
|
32
|
+
--secondary-foreground: #334155;
|
|
33
|
+
--muted: #F1F5F9;
|
|
34
|
+
--muted-foreground: #64748B;
|
|
35
|
+
--accent: #EEF2FF;
|
|
36
|
+
--accent-foreground: #4338CA;
|
|
37
|
+
|
|
38
|
+
/* Borders & inputs */
|
|
39
|
+
--border: #E2E8F0;
|
|
40
|
+
--input: #CBD5E1;
|
|
41
|
+
--ring: #818CF8;
|
|
42
|
+
|
|
43
|
+
/* Sidebar */
|
|
44
|
+
--sidebar: #FFFFFF;
|
|
45
|
+
--sidebar-foreground: #334155;
|
|
46
|
+
--sidebar-hover: #F8FAFC;
|
|
47
|
+
--sidebar-active: #EEF2FF;
|
|
48
|
+
--sidebar-active-foreground: #4338CA;
|
|
49
|
+
|
|
50
|
+
/* Code & links */
|
|
51
|
+
--link: #4F46E5;
|
|
52
|
+
--code-background: #F1F5F9;
|
|
53
|
+
--code-foreground: #3730A3;
|
|
54
|
+
|
|
55
|
+
/* Status */
|
|
56
|
+
--success: #16A34A;
|
|
57
|
+
--success-background: #F0FDF4;
|
|
58
|
+
--warning: #D97706;
|
|
59
|
+
--warning-background: #FFFBEB;
|
|
60
|
+
--destructive: #DC2626;
|
|
61
|
+
--destructive-background: #FEF2F2;
|
|
62
|
+
--info: #0284C7;
|
|
63
|
+
--info-background: #F0F9FF;
|
|
64
|
+
|
|
65
|
+
/* Effects */
|
|
66
|
+
--radius: 10px;
|
|
67
|
+
--shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
|
68
|
+
|
|
69
|
+
/* Aliases for SpecForge layout */
|
|
70
|
+
--bg-base: var(--background);
|
|
71
|
+
--bg-surface: var(--card);
|
|
72
|
+
--bg-card: var(--secondary);
|
|
73
|
+
--bg-input: var(--code-background);
|
|
74
|
+
--border-focus: var(--ring);
|
|
75
|
+
--text-main: var(--foreground);
|
|
76
|
+
--text-muted: var(--muted-foreground);
|
|
77
|
+
--danger: var(--destructive);
|
|
78
|
+
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
79
|
+
--font-mono: 'JetBrains Mono', monospace;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Theme 2 - Emerald Teal Light */
|
|
83
|
+
[data-theme="theme-2"] {
|
|
84
|
+
/* Brand & buttons */
|
|
85
|
+
--primary: #0F9488;
|
|
86
|
+
--primary-hover: #0D766E;
|
|
87
|
+
--primary-active: #115E59;
|
|
88
|
+
--primary-foreground: #FFFFFF;
|
|
89
|
+
|
|
90
|
+
/* Page & surfaces */
|
|
91
|
+
--background: #F7FAFA;
|
|
92
|
+
--foreground: #24343B;
|
|
93
|
+
--card: #FFFFFF;
|
|
94
|
+
--card-foreground: #24343B;
|
|
95
|
+
--popover: #FFFFFF;
|
|
96
|
+
--popover-foreground: #24343B;
|
|
97
|
+
|
|
98
|
+
/* Secondary & muted */
|
|
99
|
+
--secondary: #EDF5F4;
|
|
100
|
+
--secondary-hover: #DCEAE8;
|
|
101
|
+
--secondary-foreground: #334B4A;
|
|
102
|
+
--muted: #F0F5F5;
|
|
103
|
+
--muted-foreground: #647776;
|
|
104
|
+
--accent: #E0F2F1;
|
|
105
|
+
--accent-foreground: #115E59;
|
|
106
|
+
|
|
107
|
+
/* Borders & inputs */
|
|
108
|
+
--border: #DCE7E7;
|
|
109
|
+
--input: #C7D8D7;
|
|
110
|
+
--ring: #5EEAD4;
|
|
111
|
+
|
|
112
|
+
/* Sidebar */
|
|
113
|
+
--sidebar: #FFFFFF;
|
|
114
|
+
--sidebar-foreground: #334B4A;
|
|
115
|
+
--sidebar-hover: #F0F8F7;
|
|
116
|
+
--sidebar-active: #E0F2F1;
|
|
117
|
+
--sidebar-active-foreground: #115E59;
|
|
118
|
+
|
|
119
|
+
/* Code & links */
|
|
120
|
+
--link: #0D766E;
|
|
121
|
+
--code-background: #EDF5F4;
|
|
122
|
+
--code-foreground: #0E7490;
|
|
123
|
+
|
|
124
|
+
/* Status */
|
|
125
|
+
--success: #15803D;
|
|
126
|
+
--success-background: #F0FDF4;
|
|
127
|
+
--warning: #B45309;
|
|
128
|
+
--warning-background: #FFFBEB;
|
|
129
|
+
--destructive: #DC2626;
|
|
130
|
+
--destructive-background: #FEF2F2;
|
|
131
|
+
--info: #0369A1;
|
|
132
|
+
--info-background: #F0F9FF;
|
|
133
|
+
|
|
134
|
+
/* Effects */
|
|
135
|
+
--radius: 10px;
|
|
136
|
+
--shadow: 0 1px 3px rgba(36, 52, 59, 0.06);
|
|
137
|
+
|
|
138
|
+
--bg-base: var(--background);
|
|
139
|
+
--bg-surface: var(--card);
|
|
140
|
+
--bg-card: var(--secondary);
|
|
141
|
+
--bg-input: var(--code-background);
|
|
142
|
+
--border-focus: var(--ring);
|
|
143
|
+
--text-main: var(--foreground);
|
|
144
|
+
--text-muted: var(--muted-foreground);
|
|
145
|
+
--danger: var(--destructive);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Theme 3 - Midnight Dark */
|
|
149
|
+
.dark, [data-theme="theme-3"], [data-theme="dark"] {
|
|
150
|
+
/* Brand & buttons */
|
|
151
|
+
--primary: #38BDF8;
|
|
152
|
+
--primary-hover: #0EA5E9;
|
|
153
|
+
--primary-active: #0284C7;
|
|
154
|
+
--primary-foreground: #082F49;
|
|
155
|
+
|
|
156
|
+
/* Page & surfaces */
|
|
157
|
+
--background: #0F172A;
|
|
158
|
+
--foreground: #E2E8F0;
|
|
159
|
+
--card: #1E293B;
|
|
160
|
+
--card-foreground: #E2E8F0;
|
|
161
|
+
--popover: #1E293B;
|
|
162
|
+
--popover-foreground: #E2E8F0;
|
|
163
|
+
|
|
164
|
+
/* Secondary & muted */
|
|
165
|
+
--secondary: #273449;
|
|
166
|
+
--secondary-hover: #334155;
|
|
167
|
+
--secondary-foreground: #E2E8F0;
|
|
168
|
+
--muted: #1E293B;
|
|
169
|
+
--muted-foreground: #94A3B8;
|
|
170
|
+
--accent: #164E63;
|
|
171
|
+
--accent-foreground: #BAE6FD;
|
|
172
|
+
|
|
173
|
+
/* Borders & inputs */
|
|
174
|
+
--border: #334155;
|
|
175
|
+
--input: #475569;
|
|
176
|
+
--ring: #38BDF8;
|
|
177
|
+
|
|
178
|
+
/* Sidebar */
|
|
179
|
+
--sidebar: #111C30;
|
|
180
|
+
--sidebar-foreground: #CBD5E1;
|
|
181
|
+
--sidebar-hover: #1E293B;
|
|
182
|
+
--sidebar-active: #164E63;
|
|
183
|
+
--sidebar-active-foreground: #BAE6FD;
|
|
184
|
+
|
|
185
|
+
/* Code & links */
|
|
186
|
+
--link: #7DD3FC;
|
|
187
|
+
--code-background: #111C30;
|
|
188
|
+
--code-foreground: #7DD3FC;
|
|
189
|
+
|
|
190
|
+
/* Status */
|
|
191
|
+
--success: #4ADE80;
|
|
192
|
+
--success-background: #143526;
|
|
193
|
+
--warning: #FBBF24;
|
|
194
|
+
--warning-background: #3A2D14;
|
|
195
|
+
--destructive: #F87171;
|
|
196
|
+
--destructive-background: #3B1D25;
|
|
197
|
+
--info: #38BDF8;
|
|
198
|
+
--info-background: #142D43;
|
|
199
|
+
|
|
200
|
+
/* Effects */
|
|
201
|
+
--radius: 10px;
|
|
202
|
+
--shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
203
|
+
|
|
204
|
+
--bg-base: var(--background);
|
|
205
|
+
--bg-surface: var(--card);
|
|
206
|
+
--bg-card: var(--secondary);
|
|
207
|
+
--bg-input: var(--code-background);
|
|
208
|
+
--border-focus: var(--ring);
|
|
209
|
+
--text-main: var(--foreground);
|
|
210
|
+
--text-muted: var(--muted-foreground);
|
|
211
|
+
--danger: var(--destructive);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* Theme 4 - Cobalt Blue Light */
|
|
215
|
+
[data-theme="theme-4"] {
|
|
216
|
+
/* Brand & buttons */
|
|
217
|
+
--primary: #3B82F6;
|
|
218
|
+
--primary-hover: #2563EB;
|
|
219
|
+
--primary-active: #1D4ED8;
|
|
220
|
+
--primary-foreground: #FFFFFF;
|
|
221
|
+
|
|
222
|
+
/* Page & surfaces */
|
|
223
|
+
--background: #F6F8FB;
|
|
224
|
+
--foreground: #263449;
|
|
225
|
+
--card: #FFFFFF;
|
|
226
|
+
--card-foreground: #263449;
|
|
227
|
+
--popover: #FFFFFF;
|
|
228
|
+
--popover-foreground: #263449;
|
|
229
|
+
|
|
230
|
+
/* Secondary & muted */
|
|
231
|
+
--secondary: #EEF2F7;
|
|
232
|
+
--secondary-hover: #E1E7EF;
|
|
233
|
+
--secondary-foreground: #35445A;
|
|
234
|
+
--muted: #F0F3F8;
|
|
235
|
+
--muted-foreground: #66758A;
|
|
236
|
+
--accent: #E8F0FE;
|
|
237
|
+
--accent-foreground: #1D4ED8;
|
|
238
|
+
|
|
239
|
+
/* Borders & inputs */
|
|
240
|
+
--border: #E1E7EF;
|
|
241
|
+
--input: #CBD5E1;
|
|
242
|
+
--ring: #93C5FD;
|
|
243
|
+
|
|
244
|
+
/* Sidebar */
|
|
245
|
+
--sidebar: #FFFFFF;
|
|
246
|
+
--sidebar-foreground: #35445A;
|
|
247
|
+
--sidebar-hover: #F6F8FB;
|
|
248
|
+
--sidebar-active: #E8F0FE;
|
|
249
|
+
--sidebar-active-foreground: #1D4ED8;
|
|
250
|
+
|
|
251
|
+
/* Code & links */
|
|
252
|
+
--link: #2563EB;
|
|
253
|
+
--code-background: #EEF2F7;
|
|
254
|
+
--code-foreground: #1D4ED8;
|
|
255
|
+
|
|
256
|
+
/* Status */
|
|
257
|
+
--success: #15803D;
|
|
258
|
+
--success-background: #F0FDF4;
|
|
259
|
+
--warning: #B45309;
|
|
260
|
+
--warning-background: #FFFBEB;
|
|
261
|
+
--destructive: #DC2626;
|
|
262
|
+
--destructive-background: #FEF2F2;
|
|
263
|
+
--info: #0369A1;
|
|
264
|
+
--info-background: #EFF6FF;
|
|
265
|
+
|
|
266
|
+
/* Effects */
|
|
267
|
+
--radius: 10px;
|
|
268
|
+
--shadow: 0 1px 3px rgba(38, 52, 73, 0.06);
|
|
269
|
+
|
|
270
|
+
--bg-base: var(--background);
|
|
271
|
+
--bg-surface: var(--card);
|
|
272
|
+
--bg-card: var(--secondary);
|
|
273
|
+
--bg-input: var(--code-background);
|
|
274
|
+
--border-focus: var(--ring);
|
|
275
|
+
--text-main: var(--foreground);
|
|
276
|
+
--text-muted: var(--muted-foreground);
|
|
277
|
+
--danger: var(--destructive);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Dropdown Theme Picker Menu */
|
|
281
|
+
.theme-dropdown-container {
|
|
282
|
+
position: relative;
|
|
283
|
+
}
|
|
284
|
+
.theme-dropdown-menu {
|
|
285
|
+
position: absolute;
|
|
286
|
+
top: calc(100% + 8px);
|
|
287
|
+
right: 0;
|
|
288
|
+
background: var(--card);
|
|
289
|
+
border: 1px solid var(--border);
|
|
290
|
+
border-radius: var(--radius);
|
|
291
|
+
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.15);
|
|
292
|
+
width: 260px;
|
|
293
|
+
padding: 0.4rem;
|
|
294
|
+
z-index: 1000;
|
|
295
|
+
display: flex;
|
|
296
|
+
flex-direction: column;
|
|
297
|
+
gap: 0.25rem;
|
|
298
|
+
}
|
|
299
|
+
.theme-menu-item {
|
|
300
|
+
display: flex;
|
|
301
|
+
align-items: center;
|
|
302
|
+
gap: 0.75rem;
|
|
303
|
+
padding: 0.55rem 0.75rem;
|
|
304
|
+
border-radius: calc(var(--radius) - 3px);
|
|
305
|
+
cursor: pointer;
|
|
306
|
+
user-select: none;
|
|
307
|
+
transition: all 0.15s ease;
|
|
308
|
+
color: var(--card-foreground);
|
|
309
|
+
}
|
|
310
|
+
.theme-menu-item:hover {
|
|
311
|
+
background: var(--secondary);
|
|
312
|
+
color: var(--secondary-foreground);
|
|
313
|
+
}
|
|
314
|
+
.theme-swatch {
|
|
315
|
+
width: 1.15rem;
|
|
316
|
+
height: 1.15rem;
|
|
317
|
+
border-radius: 9999px;
|
|
318
|
+
flex-shrink: 0;
|
|
319
|
+
border: 2px solid var(--card);
|
|
320
|
+
box-shadow: 0 0 0 1px var(--border);
|
|
321
|
+
}
|
|
322
|
+
.theme-check {
|
|
323
|
+
font-size: 0.95rem;
|
|
324
|
+
font-weight: 700;
|
|
325
|
+
color: var(--primary);
|
|
326
|
+
width: 1.2rem;
|
|
327
|
+
text-align: right;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
331
|
+
body {
|
|
332
|
+
background: var(--background);
|
|
333
|
+
color: var(--foreground);
|
|
334
|
+
font-family: var(--font-sans);
|
|
335
|
+
display: flex;
|
|
336
|
+
flex-direction: column;
|
|
337
|
+
height: 100vh;
|
|
338
|
+
overflow: hidden;
|
|
339
|
+
-webkit-font-smoothing: antialiased;
|
|
340
|
+
transition: background-color 0.2s ease, color 0.2s ease;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* Header */
|
|
344
|
+
header {
|
|
345
|
+
background: var(--sidebar);
|
|
346
|
+
border-bottom: 1px solid var(--border);
|
|
347
|
+
box-shadow: var(--shadow);
|
|
348
|
+
padding: 0.75rem 1.5rem;
|
|
349
|
+
display: flex;
|
|
350
|
+
align-items: center;
|
|
351
|
+
justify-content: space-between;
|
|
352
|
+
z-index: 50;
|
|
353
|
+
}
|
|
354
|
+
.brand {
|
|
355
|
+
display: flex;
|
|
356
|
+
align-items: center;
|
|
357
|
+
gap: 0.75rem;
|
|
358
|
+
font-weight: 700;
|
|
359
|
+
font-size: 1.15rem;
|
|
360
|
+
letter-spacing: -0.025em;
|
|
361
|
+
}
|
|
362
|
+
.brand-icon {
|
|
363
|
+
width: 2rem;
|
|
364
|
+
height: 2rem;
|
|
365
|
+
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
|
366
|
+
border-radius: 0.5rem;
|
|
367
|
+
display: flex;
|
|
368
|
+
align-items: center;
|
|
369
|
+
justify-content: center;
|
|
370
|
+
color: var(--primary-foreground);
|
|
371
|
+
font-size: 1.1rem;
|
|
372
|
+
box-shadow: 0 0 14px var(--ring);
|
|
373
|
+
transition: all 0.2s ease;
|
|
374
|
+
}
|
|
375
|
+
.version-tag {
|
|
376
|
+
font-size: 0.75rem;
|
|
377
|
+
background: var(--secondary);
|
|
378
|
+
padding: 0.15rem 0.5rem;
|
|
379
|
+
border-radius: 9999px;
|
|
380
|
+
color: var(--muted-foreground);
|
|
381
|
+
border: 1px solid var(--border);
|
|
382
|
+
}
|
|
383
|
+
.nav-tabs {
|
|
384
|
+
display: flex;
|
|
385
|
+
background: var(--code-background);
|
|
386
|
+
padding: 0.25rem;
|
|
387
|
+
border-radius: 0.5rem;
|
|
388
|
+
border: 1px solid var(--border);
|
|
389
|
+
gap: 0.25rem;
|
|
390
|
+
}
|
|
391
|
+
.nav-tab {
|
|
392
|
+
padding: 0.4rem 1rem;
|
|
393
|
+
border-radius: 0.35rem;
|
|
394
|
+
font-size: 0.85rem;
|
|
395
|
+
font-weight: 600;
|
|
396
|
+
cursor: pointer;
|
|
397
|
+
border: none;
|
|
398
|
+
background: transparent;
|
|
399
|
+
color: var(--muted-foreground);
|
|
400
|
+
transition: all 0.15s ease;
|
|
401
|
+
display: flex;
|
|
402
|
+
align-items: center;
|
|
403
|
+
gap: 0.4rem;
|
|
404
|
+
}
|
|
405
|
+
.nav-tab:hover { color: var(--foreground); }
|
|
406
|
+
.nav-tab.active {
|
|
407
|
+
background: var(--primary);
|
|
408
|
+
color: var(--primary-foreground);
|
|
409
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/* Main Container */
|
|
413
|
+
.app-body {
|
|
414
|
+
display: flex;
|
|
415
|
+
flex: 1;
|
|
416
|
+
height: calc(100vh - 57px);
|
|
417
|
+
overflow: hidden;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* Sidebar */
|
|
421
|
+
aside {
|
|
422
|
+
width: 320px;
|
|
423
|
+
background: var(--sidebar);
|
|
424
|
+
border-right: 1px solid var(--border);
|
|
425
|
+
display: flex;
|
|
426
|
+
flex-direction: column;
|
|
427
|
+
}
|
|
428
|
+
.sidebar-search {
|
|
429
|
+
padding: 1rem;
|
|
430
|
+
border-bottom: 1px solid var(--border);
|
|
431
|
+
}
|
|
432
|
+
.search-input {
|
|
433
|
+
width: 100%;
|
|
434
|
+
background: var(--code-background);
|
|
435
|
+
border: 1px solid var(--border);
|
|
436
|
+
border-radius: 0.5rem;
|
|
437
|
+
padding: 0.5rem 0.75rem;
|
|
438
|
+
color: var(--foreground);
|
|
439
|
+
font-size: 0.85rem;
|
|
440
|
+
outline: none;
|
|
441
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
442
|
+
}
|
|
443
|
+
.search-input:focus {
|
|
444
|
+
border-color: var(--primary);
|
|
445
|
+
box-shadow: 0 0 0 2px var(--accent);
|
|
446
|
+
}
|
|
447
|
+
.endpoint-list {
|
|
448
|
+
flex: 1;
|
|
449
|
+
overflow-y: auto;
|
|
450
|
+
padding: 0.5rem;
|
|
451
|
+
}
|
|
452
|
+
.endpoint-item {
|
|
453
|
+
padding: 0.6rem 0.75rem;
|
|
454
|
+
border-radius: calc(var(--radius) - 4px);
|
|
455
|
+
cursor: pointer;
|
|
456
|
+
display: flex;
|
|
457
|
+
align-items: center;
|
|
458
|
+
gap: 0.6rem;
|
|
459
|
+
font-size: 0.82rem;
|
|
460
|
+
margin-bottom: 0.25rem;
|
|
461
|
+
transition: all 0.15s ease;
|
|
462
|
+
color: var(--sidebar-foreground);
|
|
463
|
+
border-left: 3px solid transparent;
|
|
464
|
+
}
|
|
465
|
+
.endpoint-item:hover {
|
|
466
|
+
background: var(--sidebar-hover);
|
|
467
|
+
color: var(--sidebar-foreground);
|
|
468
|
+
}
|
|
469
|
+
.endpoint-item.active {
|
|
470
|
+
background: var(--sidebar-active);
|
|
471
|
+
color: var(--sidebar-active-foreground);
|
|
472
|
+
border-left: 3px solid var(--primary);
|
|
473
|
+
font-weight: 600;
|
|
474
|
+
box-shadow: var(--shadow);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* Method Badges */
|
|
478
|
+
.badge-method {
|
|
479
|
+
font-size: 0.68rem;
|
|
480
|
+
font-weight: 700;
|
|
481
|
+
padding: 0.2rem 0.45rem;
|
|
482
|
+
border-radius: 0.25rem;
|
|
483
|
+
text-transform: uppercase;
|
|
484
|
+
font-family: var(--font-mono);
|
|
485
|
+
letter-spacing: 0.05em;
|
|
486
|
+
display: inline-flex;
|
|
487
|
+
align-items: center;
|
|
488
|
+
justify-content: center;
|
|
489
|
+
}
|
|
490
|
+
.badge-get { background: #ECFDF5; color: #059669; border: 1px solid #A7F3D0; }
|
|
491
|
+
.badge-post { background: var(--accent); color: var(--accent-foreground); border: 1px solid var(--border); }
|
|
492
|
+
.badge-put { background: #FFFBEB; color: #D97706; border: 1px solid #FDE68A; }
|
|
493
|
+
.badge-delete { background: #FEF2F2; color: #DC2626; border: 1px solid #FECACA; }
|
|
494
|
+
.badge-patch { background: #ECFEFF; color: #0891B2; border: 1px solid #A5F3FC; }
|
|
495
|
+
|
|
496
|
+
.dark .badge-get { background: rgba(16, 185, 129, 0.2); color: #34D399; border-color: rgba(16, 185, 129, 0.35); }
|
|
497
|
+
.dark .badge-post { background: var(--accent); color: var(--accent-foreground); border-color: var(--ring); }
|
|
498
|
+
.dark .badge-put { background: rgba(245, 158, 11, 0.2); color: #FBBF24; border-color: rgba(245, 158, 11, 0.35); }
|
|
499
|
+
.dark .badge-delete { background: rgba(244, 63, 94, 0.2); color: #F87171; border-color: rgba(244, 63, 94, 0.35); }
|
|
500
|
+
.dark .badge-patch { background: rgba(6, 182, 212, 0.2); color: #22D3EE; border-color: rgba(6, 182, 212, 0.35); }
|
|
501
|
+
|
|
502
|
+
/* Content Area */
|
|
503
|
+
main {
|
|
504
|
+
flex: 1;
|
|
505
|
+
overflow-y: auto;
|
|
506
|
+
padding: 1.5rem 2rem;
|
|
507
|
+
display: flex;
|
|
508
|
+
flex-direction: column;
|
|
509
|
+
gap: 1.5rem;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* Views */
|
|
513
|
+
.view-panel { display: none; }
|
|
514
|
+
.view-panel.active { display: flex; flex-direction: column; gap: 1.5rem; }
|
|
515
|
+
|
|
516
|
+
/* Cards */
|
|
517
|
+
.card {
|
|
518
|
+
background: var(--card);
|
|
519
|
+
border: 1px solid var(--border);
|
|
520
|
+
border-radius: var(--radius);
|
|
521
|
+
padding: 1.25rem;
|
|
522
|
+
box-shadow: var(--shadow);
|
|
523
|
+
color: var(--card-foreground);
|
|
524
|
+
}
|
|
525
|
+
.card-title {
|
|
526
|
+
font-size: 1rem;
|
|
527
|
+
font-weight: 600;
|
|
528
|
+
margin-bottom: 1rem;
|
|
529
|
+
display: flex;
|
|
530
|
+
align-items: center;
|
|
531
|
+
justify-content: space-between;
|
|
532
|
+
color: var(--card-foreground);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* Try It Out Panel */
|
|
536
|
+
.try-layout {
|
|
537
|
+
display: grid;
|
|
538
|
+
grid-template-columns: 1fr 1fr;
|
|
539
|
+
gap: 1.5rem;
|
|
540
|
+
}
|
|
541
|
+
.editor-box {
|
|
542
|
+
width: 100%;
|
|
543
|
+
height: 220px;
|
|
544
|
+
background: var(--code-background);
|
|
545
|
+
border: 1px solid var(--border);
|
|
546
|
+
border-radius: calc(var(--radius) - 3px);
|
|
547
|
+
font-family: var(--font-mono);
|
|
548
|
+
font-size: 0.85rem;
|
|
549
|
+
color: var(--code-foreground);
|
|
550
|
+
padding: 0.75rem;
|
|
551
|
+
resize: vertical;
|
|
552
|
+
outline: none;
|
|
553
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
554
|
+
}
|
|
555
|
+
.editor-box:focus {
|
|
556
|
+
border-color: var(--primary);
|
|
557
|
+
box-shadow: 0 0 0 2px var(--accent);
|
|
558
|
+
}
|
|
559
|
+
.btn {
|
|
560
|
+
padding: 0.55rem 1.25rem;
|
|
561
|
+
border-radius: calc(var(--radius) - 3px);
|
|
562
|
+
font-size: 0.85rem;
|
|
563
|
+
font-weight: 600;
|
|
564
|
+
cursor: pointer;
|
|
565
|
+
border: 1px solid transparent;
|
|
566
|
+
display: inline-flex;
|
|
567
|
+
align-items: center;
|
|
568
|
+
justify-content: center;
|
|
569
|
+
gap: 0.5rem;
|
|
570
|
+
transition: all 0.15s ease;
|
|
571
|
+
font-family: var(--font-sans);
|
|
572
|
+
user-select: none;
|
|
573
|
+
}
|
|
574
|
+
.btn-primary {
|
|
575
|
+
background: var(--primary);
|
|
576
|
+
color: var(--primary-foreground);
|
|
577
|
+
border-color: var(--primary);
|
|
578
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
579
|
+
}
|
|
580
|
+
.btn-primary:hover {
|
|
581
|
+
background: var(--primary-hover);
|
|
582
|
+
border-color: var(--primary-hover);
|
|
583
|
+
}
|
|
584
|
+
.btn-primary:active {
|
|
585
|
+
background: var(--primary-active);
|
|
586
|
+
border-color: var(--primary-active);
|
|
587
|
+
}
|
|
588
|
+
.btn-outline {
|
|
589
|
+
background: var(--card);
|
|
590
|
+
border: 1px solid var(--border);
|
|
591
|
+
color: var(--foreground);
|
|
592
|
+
}
|
|
593
|
+
.btn-outline:hover {
|
|
594
|
+
background: var(--secondary);
|
|
595
|
+
color: var(--secondary-foreground);
|
|
596
|
+
border-color: var(--input);
|
|
597
|
+
}
|
|
598
|
+
.btn-outline:active {
|
|
599
|
+
background: var(--secondary-hover);
|
|
600
|
+
}
|
|
601
|
+
.btn-outline-primary {
|
|
602
|
+
background: var(--accent);
|
|
603
|
+
border: 1px solid var(--primary);
|
|
604
|
+
color: var(--accent-foreground);
|
|
605
|
+
}
|
|
606
|
+
.btn-outline-primary:hover {
|
|
607
|
+
background: var(--primary);
|
|
608
|
+
border-color: var(--primary);
|
|
609
|
+
color: var(--primary-foreground);
|
|
610
|
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
|
611
|
+
}
|
|
612
|
+
.btn-outline-primary:active {
|
|
613
|
+
background: var(--primary-hover);
|
|
614
|
+
border-color: var(--primary-hover);
|
|
615
|
+
}
|
|
616
|
+
.btn-success {
|
|
617
|
+
background: var(--success);
|
|
618
|
+
color: #FFFFFF;
|
|
619
|
+
border-color: var(--success);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/* URL Bar */
|
|
623
|
+
.url-bar {
|
|
624
|
+
display: flex;
|
|
625
|
+
align-items: center;
|
|
626
|
+
gap: 0.5rem;
|
|
627
|
+
background: var(--code-background);
|
|
628
|
+
border: 1px solid var(--border);
|
|
629
|
+
border-radius: calc(var(--radius) - 3px);
|
|
630
|
+
padding: 0.45rem 0.75rem;
|
|
631
|
+
font-family: var(--font-mono);
|
|
632
|
+
font-size: 0.85rem;
|
|
633
|
+
margin-top: 0.75rem;
|
|
634
|
+
word-break: break-all;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/* Sub Tabs (Params / Headers / Auth / Body) */
|
|
638
|
+
.sub-tabs {
|
|
639
|
+
display: flex;
|
|
640
|
+
gap: 0.25rem;
|
|
641
|
+
border-bottom: 1px solid var(--border);
|
|
642
|
+
margin-bottom: 0.85rem;
|
|
643
|
+
}
|
|
644
|
+
.sub-tab-btn {
|
|
645
|
+
background: transparent;
|
|
646
|
+
border: none;
|
|
647
|
+
border-bottom: 2px solid transparent;
|
|
648
|
+
color: var(--muted-foreground);
|
|
649
|
+
font-size: 0.82rem;
|
|
650
|
+
font-weight: 600;
|
|
651
|
+
padding: 0.4rem 0.75rem;
|
|
652
|
+
cursor: pointer;
|
|
653
|
+
display: flex;
|
|
654
|
+
align-items: center;
|
|
655
|
+
gap: 0.35rem;
|
|
656
|
+
transition: all 0.15s ease;
|
|
657
|
+
margin-bottom: -1px;
|
|
658
|
+
}
|
|
659
|
+
.sub-tab-btn:hover { color: var(--foreground); }
|
|
660
|
+
.sub-tab-btn.active {
|
|
661
|
+
color: var(--primary);
|
|
662
|
+
border-bottom-color: var(--primary);
|
|
663
|
+
}
|
|
664
|
+
.sub-tab-badge {
|
|
665
|
+
font-size: 0.68rem;
|
|
666
|
+
background: var(--secondary);
|
|
667
|
+
padding: 0.12rem 0.45rem;
|
|
668
|
+
border-radius: 9999px;
|
|
669
|
+
border: 1px solid var(--border);
|
|
670
|
+
color: var(--muted-foreground);
|
|
671
|
+
font-weight: 600;
|
|
672
|
+
}
|
|
673
|
+
.sub-panel { display: none; }
|
|
674
|
+
.sub-panel.active { display: block; }
|
|
675
|
+
|
|
676
|
+
/* Key-Value Tables (Params & Headers) */
|
|
677
|
+
.kv-table {
|
|
678
|
+
width: 100%;
|
|
679
|
+
border-collapse: collapse;
|
|
680
|
+
margin-bottom: 0.5rem;
|
|
681
|
+
font-size: 0.82rem;
|
|
682
|
+
}
|
|
683
|
+
.kv-table th {
|
|
684
|
+
text-align: left;
|
|
685
|
+
padding: 0.35rem 0.5rem;
|
|
686
|
+
color: var(--muted-foreground);
|
|
687
|
+
font-weight: 600;
|
|
688
|
+
border-bottom: 1px solid var(--border);
|
|
689
|
+
}
|
|
690
|
+
.kv-table td {
|
|
691
|
+
padding: 0.35rem 0.5rem;
|
|
692
|
+
border-bottom: 1px solid var(--border);
|
|
693
|
+
}
|
|
694
|
+
.kv-input {
|
|
695
|
+
width: 100%;
|
|
696
|
+
background: var(--code-background);
|
|
697
|
+
border: 1px solid var(--border);
|
|
698
|
+
border-radius: calc(var(--radius) - 4px);
|
|
699
|
+
padding: 0.4rem 0.6rem;
|
|
700
|
+
color: var(--foreground);
|
|
701
|
+
font-family: var(--font-mono);
|
|
702
|
+
font-size: 0.82rem;
|
|
703
|
+
outline: none;
|
|
704
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
705
|
+
}
|
|
706
|
+
.kv-input:focus {
|
|
707
|
+
border-color: var(--primary);
|
|
708
|
+
box-shadow: 0 0 0 2px var(--accent);
|
|
709
|
+
}
|
|
710
|
+
.btn-icon {
|
|
711
|
+
background: transparent;
|
|
712
|
+
border: none;
|
|
713
|
+
color: var(--muted-foreground);
|
|
714
|
+
cursor: pointer;
|
|
715
|
+
padding: 0.2rem 0.4rem;
|
|
716
|
+
border-radius: 0.25rem;
|
|
717
|
+
font-size: 0.9rem;
|
|
718
|
+
line-height: 1;
|
|
719
|
+
transition: all 0.15s ease;
|
|
720
|
+
}
|
|
721
|
+
.btn-icon:hover {
|
|
722
|
+
color: var(--destructive);
|
|
723
|
+
background: var(--destructive-background);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/* Test Dashboard */
|
|
727
|
+
.stats-grid {
|
|
728
|
+
display: grid;
|
|
729
|
+
grid-template-columns: repeat(4, 1fr);
|
|
730
|
+
gap: 1rem;
|
|
731
|
+
}
|
|
732
|
+
.stat-card {
|
|
733
|
+
background: var(--card);
|
|
734
|
+
border: 1px solid var(--border);
|
|
735
|
+
padding: 1rem;
|
|
736
|
+
border-radius: var(--radius);
|
|
737
|
+
box-shadow: var(--shadow);
|
|
738
|
+
}
|
|
739
|
+
.stat-number { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem; }
|
|
740
|
+
.stat-green { color: var(--success); }
|
|
741
|
+
.stat-yellow { color: var(--warning); }
|
|
742
|
+
.stat-red { color: var(--destructive); }
|
|
743
|
+
|
|
744
|
+
.test-table {
|
|
745
|
+
width: 100%;
|
|
746
|
+
border-collapse: collapse;
|
|
747
|
+
font-size: 0.85rem;
|
|
748
|
+
}
|
|
749
|
+
.test-table th {
|
|
750
|
+
text-align: left;
|
|
751
|
+
padding: 0.75rem 1rem;
|
|
752
|
+
background: var(--secondary);
|
|
753
|
+
border-bottom: 1px solid var(--border);
|
|
754
|
+
color: var(--muted-foreground);
|
|
755
|
+
font-weight: 600;
|
|
756
|
+
}
|
|
757
|
+
.test-table td {
|
|
758
|
+
padding: 0.75rem 1rem;
|
|
759
|
+
border-bottom: 1px solid var(--border);
|
|
760
|
+
color: var(--foreground);
|
|
761
|
+
}
|
|
762
|
+
.pill {
|
|
763
|
+
display: inline-block;
|
|
764
|
+
padding: 0.2rem 0.55rem;
|
|
765
|
+
border-radius: 9999px;
|
|
766
|
+
font-size: 0.72rem;
|
|
767
|
+
font-weight: 700;
|
|
768
|
+
letter-spacing: 0.02em;
|
|
769
|
+
}
|
|
770
|
+
.pill-pass {
|
|
771
|
+
background: var(--success-background);
|
|
772
|
+
color: var(--success);
|
|
773
|
+
border: 1px solid var(--success);
|
|
774
|
+
}
|
|
775
|
+
.pill-leak {
|
|
776
|
+
background: var(--warning-background);
|
|
777
|
+
color: var(--warning);
|
|
778
|
+
border: 1px solid var(--warning);
|
|
779
|
+
}
|
|
780
|
+
.pill-crash {
|
|
781
|
+
background: var(--destructive-background);
|
|
782
|
+
color: var(--destructive);
|
|
783
|
+
border: 1px solid var(--destructive);
|
|
784
|
+
}
|
|
785
|
+
.pill-planned {
|
|
786
|
+
background: var(--secondary);
|
|
787
|
+
color: var(--muted-foreground);
|
|
788
|
+
border: 1px solid var(--border);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/* Modal */
|
|
792
|
+
.modal-overlay {
|
|
793
|
+
position: fixed;
|
|
794
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
795
|
+
background: rgba(0, 0, 0, 0.6);
|
|
796
|
+
backdrop-filter: blur(4px);
|
|
797
|
+
display: none;
|
|
798
|
+
align-items: center;
|
|
799
|
+
justify-content: center;
|
|
800
|
+
z-index: 100;
|
|
801
|
+
}
|
|
802
|
+
.modal-box {
|
|
803
|
+
background: var(--card);
|
|
804
|
+
border: 1px solid var(--border);
|
|
805
|
+
border-radius: var(--radius);
|
|
806
|
+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.25);
|
|
807
|
+
width: 800px;
|
|
808
|
+
max-width: 90vw;
|
|
809
|
+
max-height: 85vh;
|
|
810
|
+
display: flex;
|
|
811
|
+
flex-direction: column;
|
|
812
|
+
overflow: hidden;
|
|
813
|
+
}
|
|
814
|
+
.modal-header {
|
|
815
|
+
padding: 1rem 1.5rem;
|
|
816
|
+
border-bottom: 1px solid var(--border);
|
|
817
|
+
display: flex;
|
|
818
|
+
justify-content: space-between;
|
|
819
|
+
align-items: center;
|
|
820
|
+
color: var(--card-foreground);
|
|
821
|
+
}
|
|
822
|
+
.modal-body {
|
|
823
|
+
padding: 1.5rem;
|
|
824
|
+
overflow-y: auto;
|
|
825
|
+
flex: 1;
|
|
826
|
+
}
|
|
827
|
+
.code-box {
|
|
828
|
+
background: var(--code-background);
|
|
829
|
+
border: 1px solid var(--border);
|
|
830
|
+
padding: 1rem;
|
|
831
|
+
border-radius: calc(var(--radius) - 3px);
|
|
832
|
+
font-family: var(--font-mono);
|
|
833
|
+
font-size: 0.85rem;
|
|
834
|
+
color: var(--code-foreground);
|
|
835
|
+
white-space: pre-wrap;
|
|
836
|
+
overflow-x: auto;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/* Collapsible Planned Tests Accordion */
|
|
840
|
+
.accordion-header {
|
|
841
|
+
display: flex;
|
|
842
|
+
align-items: center;
|
|
843
|
+
justify-content: space-between;
|
|
844
|
+
cursor: pointer;
|
|
845
|
+
user-select: none;
|
|
846
|
+
padding: 0.25rem 0;
|
|
847
|
+
}
|
|
848
|
+
.accordion-header:hover h3 {
|
|
849
|
+
color: var(--primary);
|
|
850
|
+
}
|
|
851
|
+
.accordion-chevron {
|
|
852
|
+
transition: transform 0.2s ease;
|
|
853
|
+
font-size: 0.8rem;
|
|
854
|
+
color: var(--muted-foreground);
|
|
855
|
+
display: inline-block;
|
|
856
|
+
}
|
|
857
|
+
.accordion-chevron.collapsed {
|
|
858
|
+
transform: rotate(-90deg);
|
|
859
|
+
}
|
|
860
|
+
.field-tag {
|
|
861
|
+
font-family: var(--font-mono);
|
|
862
|
+
font-size: 0.78rem;
|
|
863
|
+
background: var(--accent);
|
|
864
|
+
color: var(--accent-foreground);
|
|
865
|
+
border: 1px solid var(--border);
|
|
866
|
+
padding: 0.18rem 0.5rem;
|
|
867
|
+
border-radius: calc(var(--radius) - 4px);
|
|
868
|
+
font-weight: 600;
|
|
869
|
+
display: inline-block;
|
|
870
|
+
}
|
|
871
|
+
.planned-test-row {
|
|
872
|
+
display: grid;
|
|
873
|
+
grid-template-columns: 140px 1fr 140px auto;
|
|
874
|
+
gap: 0.75rem;
|
|
875
|
+
align-items: center;
|
|
876
|
+
background: var(--card);
|
|
877
|
+
border: 1px solid var(--border);
|
|
878
|
+
border-radius: calc(var(--radius) - 4px);
|
|
879
|
+
padding: 0.65rem 0.85rem;
|
|
880
|
+
font-size: 0.82rem;
|
|
881
|
+
transition: border-color 0.15s ease;
|
|
882
|
+
}
|
|
883
|
+
.planned-test-row:hover {
|
|
884
|
+
border-color: var(--primary);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/* Grouped Field Collapsible Cards */
|
|
888
|
+
.field-group-card {
|
|
889
|
+
background: var(--card);
|
|
890
|
+
border: 1px solid var(--border);
|
|
891
|
+
border-radius: var(--radius);
|
|
892
|
+
overflow: hidden;
|
|
893
|
+
margin-bottom: 0.6rem;
|
|
894
|
+
transition: border-color 0.15s ease;
|
|
895
|
+
}
|
|
896
|
+
.field-group-card:hover {
|
|
897
|
+
border-color: var(--primary);
|
|
898
|
+
}
|
|
899
|
+
.field-group-header {
|
|
900
|
+
padding: 0.65rem 0.95rem;
|
|
901
|
+
background: var(--secondary);
|
|
902
|
+
border-bottom: 1px solid var(--border);
|
|
903
|
+
display: flex;
|
|
904
|
+
align-items: center;
|
|
905
|
+
justify-content: space-between;
|
|
906
|
+
cursor: pointer;
|
|
907
|
+
user-select: none;
|
|
908
|
+
transition: background 0.15s ease;
|
|
909
|
+
color: var(--secondary-foreground);
|
|
910
|
+
}
|
|
911
|
+
.field-group-header:hover {
|
|
912
|
+
background: var(--secondary-hover);
|
|
913
|
+
}
|
|
914
|
+
.field-group-body {
|
|
915
|
+
padding: 0.65rem 0.95rem;
|
|
916
|
+
display: flex;
|
|
917
|
+
flex-direction: column;
|
|
918
|
+
gap: 0.45rem;
|
|
919
|
+
}
|
|
920
|
+
.field-probe-item {
|
|
921
|
+
display: grid;
|
|
922
|
+
grid-template-columns: 1fr 140px auto;
|
|
923
|
+
gap: 0.75rem;
|
|
924
|
+
align-items: center;
|
|
925
|
+
background: var(--card);
|
|
926
|
+
border: 1px solid var(--border);
|
|
927
|
+
border-radius: calc(var(--radius) - 4px);
|
|
928
|
+
padding: 0.6rem 0.85rem;
|
|
929
|
+
font-size: 0.82rem;
|
|
930
|
+
transition: border-color 0.15s ease;
|
|
931
|
+
}
|
|
932
|
+
.field-probe-item:hover {
|
|
933
|
+
border-color: var(--primary);
|
|
934
|
+
}
|
|
935
|
+
</style>
|
|
936
|
+
</head>
|
|
937
|
+
<body>
|
|
938
|
+
|
|
939
|
+
<!-- Top Header Navigation -->
|
|
940
|
+
<header>
|
|
941
|
+
<div class="brand">
|
|
942
|
+
<div class="brand-icon">⚡</div>
|
|
943
|
+
<span id="app-title">SpecForge</span>
|
|
944
|
+
<span class="version-tag">v0.1.0</span>
|
|
945
|
+
</div>
|
|
946
|
+
|
|
947
|
+
<div class="nav-tabs">
|
|
948
|
+
<button class="nav-tab active" id="tab-client-btn" onclick="switchTab('client')">
|
|
949
|
+
📖 API Explorer
|
|
950
|
+
</button>
|
|
951
|
+
<button class="nav-tab" id="tab-testing-btn" onclick="switchTab('testing')">
|
|
952
|
+
🧪 Boundary Test Suite
|
|
953
|
+
</button>
|
|
954
|
+
</div>
|
|
955
|
+
|
|
956
|
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
957
|
+
<!-- Color Theme Selector Dropdown -->
|
|
958
|
+
<div class="theme-dropdown-container">
|
|
959
|
+
<button class="btn btn-outline" id="theme-btn" onclick="toggleThemeDropdown(event)" style="font-size: 0.82rem; padding: 0.35rem 0.75rem; display: inline-flex; align-items: center; gap: 0.45rem;" title="Change Color Theme">
|
|
960
|
+
<span id="theme-icon">🟣</span>
|
|
961
|
+
<span id="theme-name-label">Theme 1 (Indigo)</span>
|
|
962
|
+
<span style="font-size: 0.65rem; color: var(--muted-foreground); margin-left: 0.15rem;">▼</span>
|
|
963
|
+
</button>
|
|
964
|
+
|
|
965
|
+
<div id="theme-menu" class="theme-dropdown-menu" style="display: none;">
|
|
966
|
+
<div class="theme-menu-item" onclick="selectTheme('theme-1')">
|
|
967
|
+
<span class="theme-swatch" style="background: #6366F1;"></span>
|
|
968
|
+
<div style="flex: 1;">
|
|
969
|
+
<div style="font-weight: 600; font-size: 0.82rem;">Theme 1: Indigo Slate</div>
|
|
970
|
+
<div style="font-size: 0.72rem; color: var(--muted-foreground);">Indigo & clean slate light</div>
|
|
971
|
+
</div>
|
|
972
|
+
<span id="check-theme-1" class="theme-check">✓</span>
|
|
973
|
+
</div>
|
|
974
|
+
|
|
975
|
+
<div class="theme-menu-item" onclick="selectTheme('theme-2')">
|
|
976
|
+
<span class="theme-swatch" style="background: #0F9488;"></span>
|
|
977
|
+
<div style="flex: 1;">
|
|
978
|
+
<div style="font-weight: 600; font-size: 0.82rem;">Theme 2: Emerald Teal</div>
|
|
979
|
+
<div style="font-size: 0.72rem; color: var(--muted-foreground);">Fresh mint & teal light</div>
|
|
980
|
+
</div>
|
|
981
|
+
<span id="check-theme-2" class="theme-check"></span>
|
|
982
|
+
</div>
|
|
983
|
+
|
|
984
|
+
<div class="theme-menu-item" onclick="selectTheme('theme-3')">
|
|
985
|
+
<span class="theme-swatch" style="background: #38BDF8; box-shadow: 0 0 6px rgba(56, 189, 248, 0.6);"></span>
|
|
986
|
+
<div style="flex: 1;">
|
|
987
|
+
<div style="font-weight: 600; font-size: 0.82rem;">Theme 3: Midnight Dark</div>
|
|
988
|
+
<div style="font-size: 0.72rem; color: var(--muted-foreground);">Deep navy slate & sky blue</div>
|
|
989
|
+
</div>
|
|
990
|
+
<span id="check-theme-3" class="theme-check"></span>
|
|
991
|
+
</div>
|
|
992
|
+
|
|
993
|
+
<div class="theme-menu-item" onclick="selectTheme('theme-4')">
|
|
994
|
+
<span class="theme-swatch" style="background: #3B82F6;"></span>
|
|
995
|
+
<div style="flex: 1;">
|
|
996
|
+
<div style="font-weight: 600; font-size: 0.82rem;">Theme 4: Cobalt Blue</div>
|
|
997
|
+
<div style="font-size: 0.72rem; color: var(--muted-foreground);">Modern cobalt & azure light</div>
|
|
998
|
+
</div>
|
|
999
|
+
<span id="check-theme-4" class="theme-check"></span>
|
|
1000
|
+
</div>
|
|
1001
|
+
</div>
|
|
1002
|
+
</div>
|
|
1003
|
+
|
|
1004
|
+
<button class="btn btn-outline" style="font-size: 0.8rem; padding: 0.35rem 0.8rem;" onclick="openPytestModal()">
|
|
1005
|
+
🐍 Export to Pytest
|
|
1006
|
+
</button>
|
|
1007
|
+
</div>
|
|
1008
|
+
</header>
|
|
1009
|
+
|
|
1010
|
+
<!-- Application Body -->
|
|
1011
|
+
<div class="app-body">
|
|
1012
|
+
<!-- Left Navigation Sidebar -->
|
|
1013
|
+
<aside>
|
|
1014
|
+
<div class="sidebar-search">
|
|
1015
|
+
<input type="text" id="search-input" class="search-input" placeholder="Filter endpoints (e.g. POST /users)..." oninput="filterEndpoints()">
|
|
1016
|
+
</div>
|
|
1017
|
+
<div class="endpoint-list" id="endpoint-list">
|
|
1018
|
+
<!-- Rendered via JS -->
|
|
1019
|
+
</div>
|
|
1020
|
+
</aside>
|
|
1021
|
+
|
|
1022
|
+
<!-- Main Workspace -->
|
|
1023
|
+
<main>
|
|
1024
|
+
<!-- View 1: API Explorer / Client -->
|
|
1025
|
+
<div id="view-client" class="view-panel active">
|
|
1026
|
+
<div class="card">
|
|
1027
|
+
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;">
|
|
1028
|
+
<div>
|
|
1029
|
+
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.35rem;">
|
|
1030
|
+
<span id="active-method-badge" class="badge-method badge-post">POST</span>
|
|
1031
|
+
<h2 id="active-path" style="font-size: 1.25rem; font-weight: 700;">/api/v1/users</h2>
|
|
1032
|
+
</div>
|
|
1033
|
+
<p id="active-summary" style="color: var(--text-muted); font-size: 0.9rem;">Endpoint summary</p>
|
|
1034
|
+
</div>
|
|
1035
|
+
<div>
|
|
1036
|
+
<button class="btn btn-outline-primary" style="font-size: 0.85rem; padding: 0.45rem 0.95rem; display: inline-flex; align-items: center; gap: 0.4rem;" onclick="runBoundaryTestsForCurrentRoute()">
|
|
1037
|
+
🧪 Test Boundaries for this Route
|
|
1038
|
+
</button>
|
|
1039
|
+
</div>
|
|
1040
|
+
</div>
|
|
1041
|
+
|
|
1042
|
+
<!-- Live Computed URL Preview -->
|
|
1043
|
+
<div class="url-bar">
|
|
1044
|
+
<span id="url-method" class="badge-method badge-post" style="padding: 0.15rem 0.4rem;">POST</span>
|
|
1045
|
+
<span id="resolved-url" style="color: var(--link); flex: 1; font-weight: 500;">http://localhost:8000/api/v1/users</span>
|
|
1046
|
+
</div>
|
|
1047
|
+
</div>
|
|
1048
|
+
|
|
1049
|
+
<div class="try-layout">
|
|
1050
|
+
<!-- Request Builder Card -->
|
|
1051
|
+
<div class="card">
|
|
1052
|
+
<div class="sub-tabs">
|
|
1053
|
+
<button class="sub-tab-btn active" id="subtab-params-btn" onclick="switchSubTab('params')">
|
|
1054
|
+
Params <span id="params-count-badge" class="sub-tab-badge">0</span>
|
|
1055
|
+
</button>
|
|
1056
|
+
<button class="sub-tab-btn" id="subtab-headers-btn" onclick="switchSubTab('headers')">
|
|
1057
|
+
Headers <span id="headers-count-badge" class="sub-tab-badge">1</span>
|
|
1058
|
+
</button>
|
|
1059
|
+
<button class="sub-tab-btn" id="subtab-auth-btn" onclick="switchSubTab('auth')">
|
|
1060
|
+
Auth <span id="auth-status-badge" class="sub-tab-badge">None</span>
|
|
1061
|
+
</button>
|
|
1062
|
+
<button class="sub-tab-btn" id="subtab-body-btn" onclick="switchSubTab('body')">
|
|
1063
|
+
Body
|
|
1064
|
+
</button>
|
|
1065
|
+
</div>
|
|
1066
|
+
|
|
1067
|
+
<!-- Subpanel 1: Params (Path & Query) -->
|
|
1068
|
+
<div id="subpanel-params" class="sub-panel active">
|
|
1069
|
+
<div id="path-params-section" style="margin-bottom: 1rem; display: none;">
|
|
1070
|
+
<div style="font-size: 0.78rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 0.4rem; letter-spacing: 0.05em;">
|
|
1071
|
+
Path Variables
|
|
1072
|
+
</div>
|
|
1073
|
+
<table class="kv-table">
|
|
1074
|
+
<thead><tr><th>Variable</th><th>Value</th></tr></thead>
|
|
1075
|
+
<tbody id="path-params-table-body"></tbody>
|
|
1076
|
+
</table>
|
|
1077
|
+
</div>
|
|
1078
|
+
|
|
1079
|
+
<div>
|
|
1080
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem;">
|
|
1081
|
+
<span style="font-size: 0.78rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
|
1082
|
+
Query Parameters
|
|
1083
|
+
</span>
|
|
1084
|
+
<button class="btn btn-outline" style="font-size: 0.72rem; padding: 0.2rem 0.5rem;" onclick="addQueryParamRow()">
|
|
1085
|
+
+ Add Parameter
|
|
1086
|
+
</button>
|
|
1087
|
+
</div>
|
|
1088
|
+
<table class="kv-table">
|
|
1089
|
+
<thead><tr><th style="width: 28px;"></th><th>Key</th><th>Value</th><th style="width: 28px;"></th></tr></thead>
|
|
1090
|
+
<tbody id="query-params-table-body"></tbody>
|
|
1091
|
+
</table>
|
|
1092
|
+
</div>
|
|
1093
|
+
</div>
|
|
1094
|
+
|
|
1095
|
+
<!-- Subpanel 2: Headers -->
|
|
1096
|
+
<div id="subpanel-headers" class="sub-panel">
|
|
1097
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem;">
|
|
1098
|
+
<span style="font-size: 0.78rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
|
1099
|
+
Request Headers
|
|
1100
|
+
</span>
|
|
1101
|
+
<button class="btn btn-outline" style="font-size: 0.72rem; padding: 0.2rem 0.5rem;" onclick="addHeaderRow()">
|
|
1102
|
+
+ Add Header
|
|
1103
|
+
</button>
|
|
1104
|
+
</div>
|
|
1105
|
+
<table class="kv-table">
|
|
1106
|
+
<thead><tr><th style="width: 28px;"></th><th>Header Name</th><th>Value</th><th style="width: 28px;"></th></tr></thead>
|
|
1107
|
+
<tbody id="headers-table-body"></tbody>
|
|
1108
|
+
</table>
|
|
1109
|
+
</div>
|
|
1110
|
+
|
|
1111
|
+
<!-- Subpanel 3: Auth -->
|
|
1112
|
+
<div id="subpanel-auth" class="sub-panel">
|
|
1113
|
+
<div style="margin-bottom: 0.85rem;">
|
|
1114
|
+
<label style="font-size: 0.8rem; font-weight: 600; display: block; margin-bottom: 0.35rem;">Authentication Scheme</label>
|
|
1115
|
+
<select id="auth-type-select" class="kv-input" style="width: 180px;" onchange="onAuthTypeChange()">
|
|
1116
|
+
<option value="none">No Auth</option>
|
|
1117
|
+
<option value="bearer">Bearer Token</option>
|
|
1118
|
+
<option value="apikey">API Key</option>
|
|
1119
|
+
</select>
|
|
1120
|
+
</div>
|
|
1121
|
+
|
|
1122
|
+
<div id="auth-bearer-group" style="display: none; margin-bottom: 0.75rem;">
|
|
1123
|
+
<label style="font-size: 0.78rem; font-weight: 600; display: block; margin-bottom: 0.3rem;">Bearer Token</label>
|
|
1124
|
+
<input type="text" id="auth-bearer-token" class="kv-input" placeholder="eyJhbGciOi..." oninput="onAuthTokenInput()">
|
|
1125
|
+
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 0.25rem;">
|
|
1126
|
+
Automatically adds <code>Authorization: Bearer <token></code> to headers.
|
|
1127
|
+
</p>
|
|
1128
|
+
</div>
|
|
1129
|
+
|
|
1130
|
+
<div id="auth-apikey-group" style="display: none; margin-bottom: 0.75rem;">
|
|
1131
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
|
|
1132
|
+
<div>
|
|
1133
|
+
<label style="font-size: 0.78rem; font-weight: 600; display: block; margin-bottom: 0.3rem;">Header Name</label>
|
|
1134
|
+
<input type="text" id="auth-apikey-name" class="kv-input" value="X-API-Key" oninput="onApiKeyInput()">
|
|
1135
|
+
</div>
|
|
1136
|
+
<div>
|
|
1137
|
+
<label style="font-size: 0.78rem; font-weight: 600; display: block; margin-bottom: 0.3rem;">Key Value</label>
|
|
1138
|
+
<input type="text" id="auth-apikey-val" class="kv-input" placeholder="secret_..." oninput="onApiKeyInput()">
|
|
1139
|
+
</div>
|
|
1140
|
+
</div>
|
|
1141
|
+
</div>
|
|
1142
|
+
</div>
|
|
1143
|
+
|
|
1144
|
+
<!-- Subpanel 4: Body -->
|
|
1145
|
+
<div id="subpanel-body" class="sub-panel">
|
|
1146
|
+
<div class="card-title" style="margin-bottom: 0.5rem;">
|
|
1147
|
+
<span style="font-size: 0.82rem; font-weight: 600;">Request Body (JSON)</span>
|
|
1148
|
+
<div style="display: flex; gap: 0.4rem;">
|
|
1149
|
+
<button class="btn btn-outline" style="font-size: 0.72rem; padding: 0.2rem 0.5rem;" onclick="formatRequestBody()">Prettify JSON</button>
|
|
1150
|
+
<button class="btn btn-outline" style="font-size: 0.72rem; padding: 0.2rem 0.5rem;" onclick="resetRequestBody()">Reset Example</button>
|
|
1151
|
+
</div>
|
|
1152
|
+
</div>
|
|
1153
|
+
<textarea id="request-body-input" class="editor-box" style="height: 180px;" oninput="updateCodeSnippet()"></textarea>
|
|
1154
|
+
</div>
|
|
1155
|
+
|
|
1156
|
+
<div style="margin-top: 1rem; display: flex; justify-content: flex-end;">
|
|
1157
|
+
<button class="btn btn-primary" onclick="executeManualRequest()">
|
|
1158
|
+
▶ Send Request
|
|
1159
|
+
</button>
|
|
1160
|
+
</div>
|
|
1161
|
+
</div>
|
|
1162
|
+
|
|
1163
|
+
<!-- Response Inspector Card -->
|
|
1164
|
+
<div class="card">
|
|
1165
|
+
<div class="card-title">
|
|
1166
|
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
1167
|
+
<span>Response</span>
|
|
1168
|
+
<span id="response-status-badge" class="pill pill-pass" style="display:none;">200 OK</span>
|
|
1169
|
+
</div>
|
|
1170
|
+
<div class="sub-tabs" style="margin-bottom: 0; border-bottom: none;">
|
|
1171
|
+
<button class="sub-tab-btn active" id="res-tab-body-btn" style="padding: 0.2rem 0.5rem; font-size: 0.78rem;" onclick="switchResponseTab('body')">Body</button>
|
|
1172
|
+
<button class="sub-tab-btn" id="res-tab-headers-btn" style="padding: 0.2rem 0.5rem; font-size: 0.78rem;" onclick="switchResponseTab('headers')">Headers</button>
|
|
1173
|
+
</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
|
|
1176
|
+
<div id="res-view-body">
|
|
1177
|
+
<textarea id="response-viewer" class="editor-box" style="height: 220px;" readonly placeholder="Send a request to inspect output..."></textarea>
|
|
1178
|
+
</div>
|
|
1179
|
+
<div id="res-view-headers" style="display: none;">
|
|
1180
|
+
<textarea id="response-headers-viewer" class="editor-box" style="height: 220px;" readonly placeholder="Response headers will appear here..."></textarea>
|
|
1181
|
+
</div>
|
|
1182
|
+
|
|
1183
|
+
<div style="margin-top: 0.75rem; font-size: 0.8rem; color: var(--text-muted); display: flex; justify-content: space-between; align-items: center;">
|
|
1184
|
+
<span id="response-latency">Latency: -- ms</span>
|
|
1185
|
+
<button class="btn btn-outline" style="font-size: 0.75rem; padding: 0.2rem 0.5rem;" onclick="copyResponse()">Copy Response</button>
|
|
1186
|
+
</div>
|
|
1187
|
+
</div>
|
|
1188
|
+
</div>
|
|
1189
|
+
|
|
1190
|
+
<!-- Collapsible Planned Boundary & Contract Tests for this Endpoint -->
|
|
1191
|
+
<div class="card" id="endpoint-tests-accordion">
|
|
1192
|
+
<div class="accordion-header" onclick="toggleEndpointAccordion()">
|
|
1193
|
+
<div style="display: flex; align-items: center; gap: 0.6rem;">
|
|
1194
|
+
<span style="font-size: 1.1rem;">🧪</span>
|
|
1195
|
+
<h3 style="font-size: 0.95rem; font-weight: 700; color: var(--foreground); margin: 0;">
|
|
1196
|
+
Planned Boundary & Contract Tests for this Endpoint
|
|
1197
|
+
</h3>
|
|
1198
|
+
<span id="endpoint-planned-count-badge" class="sub-tab-badge" style="font-size: 0.72rem; padding: 0.15rem 0.5rem; background: var(--accent); color: var(--accent-foreground); border: 1px solid var(--border); font-weight: 600;">
|
|
1199
|
+
0 Tests Planned
|
|
1200
|
+
</span>
|
|
1201
|
+
</div>
|
|
1202
|
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
1203
|
+
<button class="btn btn-outline" style="font-size: 0.72rem; padding: 0.2rem 0.55rem;" onclick="event.stopPropagation(); openAddCustomTestModal();" title="Add a custom test case for this route">
|
|
1204
|
+
➕ Add Custom Test
|
|
1205
|
+
</button>
|
|
1206
|
+
<button class="btn btn-outline" style="font-size: 0.72rem; padding: 0.2rem 0.55rem;" onclick="event.stopPropagation(); toggleAllFieldGroups();" title="Collapse or expand all field groups">
|
|
1207
|
+
📂 Toggle All Fields
|
|
1208
|
+
</button>
|
|
1209
|
+
<button class="btn btn-outline-primary" style="font-size: 0.75rem; padding: 0.22rem 0.65rem;" onclick="event.stopPropagation(); runBoundaryTestsForCurrentRoute();">
|
|
1210
|
+
⚡ Run All For This Route
|
|
1211
|
+
</button>
|
|
1212
|
+
<span id="accordion-chevron" class="accordion-chevron collapsed">▼</span>
|
|
1213
|
+
</div>
|
|
1214
|
+
</div>
|
|
1215
|
+
|
|
1216
|
+
<div id="endpoint-tests-body" style="margin-top: 1rem; display: none;">
|
|
1217
|
+
<p style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.85rem;">
|
|
1218
|
+
SpecForge synthesizes these edge-case contract probes directly from this endpoint's OpenAPI schema constraints. You can inspect fields and payloads, run individual probes, or execute the full route test:
|
|
1219
|
+
</p>
|
|
1220
|
+
<div id="endpoint-planned-tests-container" style="display: flex; flex-direction: column; gap: 0.5rem;">
|
|
1221
|
+
<!-- Dynamically populated per endpoint -->
|
|
1222
|
+
</div>
|
|
1223
|
+
</div>
|
|
1224
|
+
</div>
|
|
1225
|
+
|
|
1226
|
+
<!-- Code Snippet Generator -->
|
|
1227
|
+
<div class="card">
|
|
1228
|
+
<div class="card-title">
|
|
1229
|
+
<span>Generated Code Snippet</span>
|
|
1230
|
+
<div style="display: flex; gap: 0.5rem;">
|
|
1231
|
+
<button class="btn btn-outline" style="font-size: 0.75rem; padding: 0.2rem 0.5rem;" onclick="setSnippetLang('curl')">cURL</button>
|
|
1232
|
+
<button class="btn btn-outline" style="font-size: 0.75rem; padding: 0.2rem 0.5rem;" onclick="setSnippetLang('httpx')">Python (httpx)</button>
|
|
1233
|
+
<button class="btn btn-outline" style="font-size: 0.75rem; padding: 0.2rem 0.5rem;" onclick="setSnippetLang('requests')">Python (requests)</button>
|
|
1234
|
+
</div>
|
|
1235
|
+
</div>
|
|
1236
|
+
<pre id="code-snippet-box" class="code-box"></pre>
|
|
1237
|
+
</div>
|
|
1238
|
+
</div>
|
|
1239
|
+
|
|
1240
|
+
<!-- View 2: Automated Boundary & Contract Testing Tab -->
|
|
1241
|
+
<div id="view-testing" class="view-panel">
|
|
1242
|
+
<div class="card" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem;">
|
|
1243
|
+
<div>
|
|
1244
|
+
<h2 style="font-size: 1.2rem; font-weight: 700; margin-bottom: 0.25rem;">Automated Boundary & Contract Fuzzer</h2>
|
|
1245
|
+
<p style="color: var(--text-muted); font-size: 0.85rem;">
|
|
1246
|
+
Probes string whitespace, empty values, numerical bounds, and detects unhandled 500 server crashes.
|
|
1247
|
+
</p>
|
|
1248
|
+
</div>
|
|
1249
|
+
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
|
|
1250
|
+
<div style="display: flex; align-items: center; gap: 0.4rem;">
|
|
1251
|
+
<label for="test-scope-select" style="font-size: 0.8rem; font-weight: 600; color: var(--text-muted);">Scope:</label>
|
|
1252
|
+
<select id="test-scope-select" class="kv-input" style="width: auto; min-width: 250px; font-size: 0.82rem;" onchange="onTestScopeChange()">
|
|
1253
|
+
<option value="all">🌐 All Endpoints</option>
|
|
1254
|
+
</select>
|
|
1255
|
+
</div>
|
|
1256
|
+
<button class="btn btn-primary" id="btn-run-tests" onclick="executeBoundaryTests()">
|
|
1257
|
+
⚡ Run Selected Scope
|
|
1258
|
+
</button>
|
|
1259
|
+
<button class="btn btn-outline" onclick="openPytestModal()">
|
|
1260
|
+
🐍 Export to Pytest
|
|
1261
|
+
</button>
|
|
1262
|
+
</div>
|
|
1263
|
+
</div>
|
|
1264
|
+
|
|
1265
|
+
<!-- Statistics Badges -->
|
|
1266
|
+
<div class="stats-grid">
|
|
1267
|
+
<div class="stat-card">
|
|
1268
|
+
<div style="font-size: 0.8rem; color: var(--text-muted);">Total Tests Planned</div>
|
|
1269
|
+
<div id="stat-total" class="stat-number">0</div>
|
|
1270
|
+
</div>
|
|
1271
|
+
<div class="stat-card">
|
|
1272
|
+
<div style="font-size: 0.8rem; color: var(--text-muted);">Cleanly Rejected (422/400)</div>
|
|
1273
|
+
<div id="stat-pass" class="stat-number stat-green">0</div>
|
|
1274
|
+
</div>
|
|
1275
|
+
<div class="stat-card">
|
|
1276
|
+
<div style="font-size: 0.8rem; color: var(--text-muted);">Schema Leaks (Accepted 200)</div>
|
|
1277
|
+
<div id="stat-leak" class="stat-number stat-yellow">0</div>
|
|
1278
|
+
</div>
|
|
1279
|
+
<div class="stat-card">
|
|
1280
|
+
<div style="font-size: 0.8rem; color: var(--text-muted);">Server Crashes (HTTP 500)</div>
|
|
1281
|
+
<div id="stat-crash" class="stat-number stat-red">0</div>
|
|
1282
|
+
</div>
|
|
1283
|
+
</div>
|
|
1284
|
+
|
|
1285
|
+
<!-- Test Suite Table -->
|
|
1286
|
+
<div class="card" style="padding: 0; overflow: hidden;">
|
|
1287
|
+
<table class="test-table">
|
|
1288
|
+
<thead>
|
|
1289
|
+
<tr>
|
|
1290
|
+
<th>Status</th>
|
|
1291
|
+
<th>Endpoint</th>
|
|
1292
|
+
<th>Target Field</th>
|
|
1293
|
+
<th>Mutation Strategy</th>
|
|
1294
|
+
<th>Expected vs Actual</th>
|
|
1295
|
+
<th>Latency</th>
|
|
1296
|
+
<th>Action</th>
|
|
1297
|
+
</tr>
|
|
1298
|
+
</thead>
|
|
1299
|
+
<tbody id="test-table-body">
|
|
1300
|
+
<tr>
|
|
1301
|
+
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 2rem;">
|
|
1302
|
+
Click <strong>Run Selected Scope</strong> to execute edge-case contract tests.
|
|
1303
|
+
</td>
|
|
1304
|
+
</tr>
|
|
1305
|
+
</tbody>
|
|
1306
|
+
</table>
|
|
1307
|
+
</div>
|
|
1308
|
+
</div>
|
|
1309
|
+
</main>
|
|
1310
|
+
</div>
|
|
1311
|
+
|
|
1312
|
+
<!-- Pytest Code Export Modal -->
|
|
1313
|
+
<div id="pytest-modal" class="modal-overlay">
|
|
1314
|
+
<div class="modal-box">
|
|
1315
|
+
<div class="modal-header">
|
|
1316
|
+
<h3 style="font-size: 1.1rem; font-weight: 700;">Generated Pytest Contract Test Suite</h3>
|
|
1317
|
+
<button onclick="closePytestModal()" style="background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.2rem;">✕</button>
|
|
1318
|
+
</div>
|
|
1319
|
+
<div class="modal-body">
|
|
1320
|
+
<p style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1rem;">
|
|
1321
|
+
Add this file to your <code>tests/test_api_contracts.py</code> to ensure these discovered boundary edge cases become permanent regression guards in CI.
|
|
1322
|
+
</p>
|
|
1323
|
+
<pre id="pytest-code-output" class="code-box"></pre>
|
|
1324
|
+
</div>
|
|
1325
|
+
<div style="padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 0.75rem;">
|
|
1326
|
+
<button class="btn btn-outline" onclick="closePytestModal()">Close</button>
|
|
1327
|
+
<button class="btn btn-primary" onclick="copyPytestCode()">Copy Pytest Code</button>
|
|
1328
|
+
</div>
|
|
1329
|
+
</div>
|
|
1330
|
+
</div>
|
|
1331
|
+
|
|
1332
|
+
<!-- Custom Field Test Modal -->
|
|
1333
|
+
<div id="custom-test-modal" class="modal-overlay">
|
|
1334
|
+
<div class="modal-box" style="width: 600px;">
|
|
1335
|
+
<div class="modal-header">
|
|
1336
|
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
1337
|
+
<span style="font-size: 1.1rem;">🧪</span>
|
|
1338
|
+
<h3 style="font-size: 1.05rem; font-weight: 700;">Add Custom Field Test</h3>
|
|
1339
|
+
</div>
|
|
1340
|
+
<button onclick="closeAddCustomTestModal()" class="btn-icon" style="font-size: 1.2rem;">✕</button>
|
|
1341
|
+
</div>
|
|
1342
|
+
<div class="modal-body" style="display: flex; flex-direction: column; gap: 0.9rem;">
|
|
1343
|
+
<!-- Active Route Context Banner -->
|
|
1344
|
+
<div style="display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.75rem; background: var(--secondary); border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px); font-size: 0.8rem;">
|
|
1345
|
+
<span id="custom-modal-route-method" class="badge-method badge-post">POST</span>
|
|
1346
|
+
<span id="custom-modal-route-path" style="font-family: var(--font-mono); font-weight: 600;">/api/v1/users</span>
|
|
1347
|
+
</div>
|
|
1348
|
+
|
|
1349
|
+
<!-- Target Field Selection -->
|
|
1350
|
+
<div style="display: grid; grid-template-columns: 1.5fr 1fr; gap: 0.75rem;">
|
|
1351
|
+
<div>
|
|
1352
|
+
<label style="font-size: 0.78rem; font-weight: 600; display: block; margin-bottom: 0.3rem;">Target Field</label>
|
|
1353
|
+
<select id="custom-field-select" class="kv-input" onchange="onCustomFieldSelectChange()">
|
|
1354
|
+
<!-- Populated dynamically -->
|
|
1355
|
+
</select>
|
|
1356
|
+
<input type="text" id="custom-field-name-input" class="kv-input" placeholder="Type custom field name..." style="margin-top: 0.4rem; display: none;">
|
|
1357
|
+
</div>
|
|
1358
|
+
<div>
|
|
1359
|
+
<label style="font-size: 0.78rem; font-weight: 600; display: block; margin-bottom: 0.3rem;">Location</label>
|
|
1360
|
+
<select id="custom-field-location" class="kv-input">
|
|
1361
|
+
<option value="body">Request Body (JSON)</option>
|
|
1362
|
+
<option value="query">Query Parameter</option>
|
|
1363
|
+
<option value="path">Path Variable</option>
|
|
1364
|
+
<option value="header">Request Header</option>
|
|
1365
|
+
</select>
|
|
1366
|
+
</div>
|
|
1367
|
+
</div>
|
|
1368
|
+
|
|
1369
|
+
<!-- Category & Expected Status -->
|
|
1370
|
+
<div style="display: grid; grid-template-columns: 1.5fr 1fr; gap: 0.75rem;">
|
|
1371
|
+
<div>
|
|
1372
|
+
<label style="font-size: 0.78rem; font-weight: 600; display: block; margin-bottom: 0.3rem;">Test Category / Strategy</label>
|
|
1373
|
+
<input type="text" id="custom-category-input" class="kv-input" placeholder="e.g. Custom Boundary, Security: SQLi" list="custom-category-suggestions">
|
|
1374
|
+
<datalist id="custom-category-suggestions">
|
|
1375
|
+
<option value="Custom Boundary">
|
|
1376
|
+
<option value="Business Logic: Limit">
|
|
1377
|
+
<option value="Security: SQL Injection">
|
|
1378
|
+
<option value="Security: XSS / Scripting">
|
|
1379
|
+
<option value="Special Characters / Unicode">
|
|
1380
|
+
<option value="Formatting / RegEx">
|
|
1381
|
+
<option value="Temporal / Expiration">
|
|
1382
|
+
</datalist>
|
|
1383
|
+
</div>
|
|
1384
|
+
<div>
|
|
1385
|
+
<label style="font-size: 0.78rem; font-weight: 600; display: block; margin-bottom: 0.3rem;">Expected Status</label>
|
|
1386
|
+
<select id="custom-expected-status" class="kv-input">
|
|
1387
|
+
<option value="422" selected>422 Unprocessable</option>
|
|
1388
|
+
<option value="400">400 Bad Request</option>
|
|
1389
|
+
<option value="401">401 Unauthorized</option>
|
|
1390
|
+
<option value="403">403 Forbidden</option>
|
|
1391
|
+
<option value="404">404 Not Found</option>
|
|
1392
|
+
<option value="200">200 OK (Allowed)</option>
|
|
1393
|
+
</select>
|
|
1394
|
+
</div>
|
|
1395
|
+
</div>
|
|
1396
|
+
|
|
1397
|
+
<!-- Description -->
|
|
1398
|
+
<div>
|
|
1399
|
+
<label style="font-size: 0.78rem; font-weight: 600; display: block; margin-bottom: 0.3rem;">Description / Rationale (Optional)</label>
|
|
1400
|
+
<input type="text" id="custom-desc-input" class="kv-input" placeholder="e.g. Reject usernames containing SQL wildcards or comments">
|
|
1401
|
+
</div>
|
|
1402
|
+
|
|
1403
|
+
<!-- Value Type & Input -->
|
|
1404
|
+
<div>
|
|
1405
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem;">
|
|
1406
|
+
<label style="font-size: 0.78rem; font-weight: 600;">Test Value to Inject</label>
|
|
1407
|
+
<div style="display: flex; gap: 0.5rem; font-size: 0.74rem;">
|
|
1408
|
+
<label style="display: inline-flex; align-items: center; gap: 0.2rem; cursor: pointer;">
|
|
1409
|
+
<input type="radio" name="custom-val-type" value="string" checked onchange="onCustomValTypeChange()"> String
|
|
1410
|
+
</label>
|
|
1411
|
+
<label style="display: inline-flex; align-items: center; gap: 0.2rem; cursor: pointer;">
|
|
1412
|
+
<input type="radio" name="custom-val-type" value="number" onchange="onCustomValTypeChange()"> Number
|
|
1413
|
+
</label>
|
|
1414
|
+
<label style="display: inline-flex; align-items: center; gap: 0.2rem; cursor: pointer;">
|
|
1415
|
+
<input type="radio" name="custom-val-type" value="boolean" onchange="onCustomValTypeChange()"> Boolean
|
|
1416
|
+
</label>
|
|
1417
|
+
<label style="display: inline-flex; align-items: center; gap: 0.2rem; cursor: pointer;">
|
|
1418
|
+
<input type="radio" name="custom-val-type" value="json" onchange="onCustomValTypeChange()"> Raw JSON
|
|
1419
|
+
</label>
|
|
1420
|
+
</div>
|
|
1421
|
+
</div>
|
|
1422
|
+
<textarea id="custom-val-input" class="editor-box" style="height: 100px;" placeholder="Type test probe value (e.g. admin' OR '1'='1 or 9999)..."></textarea>
|
|
1423
|
+
<div id="custom-val-error" style="color: var(--destructive); font-size: 0.74rem; margin-top: 0.25rem; display: none;"></div>
|
|
1424
|
+
</div>
|
|
1425
|
+
</div>
|
|
1426
|
+
|
|
1427
|
+
<div style="padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 0.75rem;">
|
|
1428
|
+
<button class="btn btn-outline" onclick="closeAddCustomTestModal()">Cancel</button>
|
|
1429
|
+
<button class="btn btn-primary" onclick="saveCustomFieldTest()">Save Test Case</button>
|
|
1430
|
+
</div>
|
|
1431
|
+
</div>
|
|
1432
|
+
</div>
|
|
1433
|
+
|
|
1434
|
+
<script>
|
|
1435
|
+
// Runtime State
|
|
1436
|
+
let activeSpec = null;
|
|
1437
|
+
let operations = [];
|
|
1438
|
+
let selectedOpIndex = 0;
|
|
1439
|
+
let testCases = [];
|
|
1440
|
+
let snippetLang = 'curl';
|
|
1441
|
+
|
|
1442
|
+
// Interactive Parameters & Headers State
|
|
1443
|
+
let pathParams = [];
|
|
1444
|
+
let queryParams = [];
|
|
1445
|
+
let requestHeaders = [];
|
|
1446
|
+
let authConfig = { type: 'none', token: '', headerName: 'X-API-Key', headerVal: '' };
|
|
1447
|
+
|
|
1448
|
+
const config = window.__SPECFORGE_CONFIG__ || {
|
|
1449
|
+
title: "SpecForge API Studio",
|
|
1450
|
+
spec_url: "/openapi.json",
|
|
1451
|
+
enable_tests: true
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
const THEMES = {
|
|
1455
|
+
'theme-1': { label: 'Theme 1 (Indigo)', icon: '🟣' },
|
|
1456
|
+
'theme-2': { label: 'Theme 2 (Teal)', icon: '🟢' },
|
|
1457
|
+
'theme-3': { label: 'Theme 3 (Dark)', icon: '🌌' },
|
|
1458
|
+
'theme-4': { label: 'Theme 4 (Cobalt)', icon: '🔵' }
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
let currentTheme = localStorage.getItem("specforge_theme") || "theme-1";
|
|
1462
|
+
if (currentTheme === "dark") currentTheme = "theme-3";
|
|
1463
|
+
if (currentTheme === "light") currentTheme = "theme-1";
|
|
1464
|
+
|
|
1465
|
+
function selectTheme(themeKey) {
|
|
1466
|
+
if (!THEMES[themeKey]) themeKey = "theme-1";
|
|
1467
|
+
currentTheme = themeKey;
|
|
1468
|
+
document.documentElement.setAttribute("data-theme", themeKey);
|
|
1469
|
+
if (themeKey === "theme-3") {
|
|
1470
|
+
document.documentElement.classList.add("dark");
|
|
1471
|
+
} else {
|
|
1472
|
+
document.documentElement.classList.remove("dark");
|
|
1473
|
+
}
|
|
1474
|
+
localStorage.setItem("specforge_theme", themeKey);
|
|
1475
|
+
|
|
1476
|
+
// Update button label and icon
|
|
1477
|
+
const info = THEMES[themeKey];
|
|
1478
|
+
const lbl = document.getElementById("theme-name-label");
|
|
1479
|
+
const icon = document.getElementById("theme-icon");
|
|
1480
|
+
if (lbl) lbl.textContent = info.label;
|
|
1481
|
+
if (icon) icon.textContent = info.icon;
|
|
1482
|
+
|
|
1483
|
+
// Update checkmark indicators
|
|
1484
|
+
for (let i = 1; i <= 4; i++) {
|
|
1485
|
+
const chk = document.getElementById(`check-theme-${i}`);
|
|
1486
|
+
if (chk) chk.textContent = (themeKey === `theme-${i}`) ? '✓' : '';
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
closeThemeDropdown();
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function toggleThemeDropdown(e) {
|
|
1493
|
+
if (e) e.stopPropagation();
|
|
1494
|
+
const menu = document.getElementById("theme-menu");
|
|
1495
|
+
if (menu) {
|
|
1496
|
+
menu.style.display = (menu.style.display === "none" || !menu.style.display) ? "flex" : "none";
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function closeThemeDropdown() {
|
|
1501
|
+
const menu = document.getElementById("theme-menu");
|
|
1502
|
+
if (menu) menu.style.display = "none";
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
document.addEventListener("click", () => {
|
|
1506
|
+
closeThemeDropdown();
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
document.addEventListener("DOMContentLoaded", async () => {
|
|
1510
|
+
selectTheme(currentTheme);
|
|
1511
|
+
if (config.title) document.getElementById("app-title").textContent = config.title;
|
|
1512
|
+
await loadSpecification();
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
async function loadSpecification() {
|
|
1516
|
+
try {
|
|
1517
|
+
if (config.embedded_spec) {
|
|
1518
|
+
activeSpec = config.embedded_spec;
|
|
1519
|
+
} else {
|
|
1520
|
+
const res = await fetch(config.spec_url || "/openapi.json");
|
|
1521
|
+
activeSpec = await res.json();
|
|
1522
|
+
}
|
|
1523
|
+
parseOperations();
|
|
1524
|
+
renderEndpointList();
|
|
1525
|
+
selectOperation(0);
|
|
1526
|
+
synthesizeTestPlan();
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
console.error("Failed to load OpenAPI spec:", err);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function parseOperations() {
|
|
1533
|
+
operations = [];
|
|
1534
|
+
const paths = activeSpec.paths || {};
|
|
1535
|
+
for (const [path, methods] of Object.entries(paths)) {
|
|
1536
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
1537
|
+
if (!['get', 'post', 'put', 'delete', 'patch'].includes(method.toLowerCase())) continue;
|
|
1538
|
+
operations.push({
|
|
1539
|
+
method: method.toLowerCase(),
|
|
1540
|
+
path: path,
|
|
1541
|
+
summary: op.summary || path,
|
|
1542
|
+
description: op.description || "",
|
|
1543
|
+
raw: op
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function renderEndpointList() {
|
|
1550
|
+
const container = document.getElementById("endpoint-list");
|
|
1551
|
+
container.innerHTML = "";
|
|
1552
|
+
operations.forEach((op, index) => {
|
|
1553
|
+
const item = document.createElement("div");
|
|
1554
|
+
item.className = `endpoint-item ${index === selectedOpIndex ? 'active' : ''}`;
|
|
1555
|
+
item.onclick = () => selectOperation(index);
|
|
1556
|
+
item.innerHTML = `
|
|
1557
|
+
<span class="badge-method badge-${op.method}">${op.method}</span>
|
|
1558
|
+
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${op.path}</span>
|
|
1559
|
+
`;
|
|
1560
|
+
container.appendChild(item);
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function selectOperation(index) {
|
|
1565
|
+
selectedOpIndex = index;
|
|
1566
|
+
renderEndpointList();
|
|
1567
|
+
const op = operations[index];
|
|
1568
|
+
if (!op) return;
|
|
1569
|
+
|
|
1570
|
+
document.getElementById("active-method-badge").className = `badge-method badge-${op.method}`;
|
|
1571
|
+
document.getElementById("active-method-badge").textContent = op.method.toUpperCase();
|
|
1572
|
+
document.getElementById("active-path").textContent = op.path;
|
|
1573
|
+
document.getElementById("active-summary").textContent = op.summary || op.description || "";
|
|
1574
|
+
document.getElementById("url-method").className = `badge-method badge-${op.method}`;
|
|
1575
|
+
document.getElementById("url-method").textContent = op.method.toUpperCase();
|
|
1576
|
+
|
|
1577
|
+
// 1. Parse Path Parameters from URL pattern {param_name}
|
|
1578
|
+
pathParams = [];
|
|
1579
|
+
const pathParamMatches = op.path.match(/\{([^}]+)\}/g) || [];
|
|
1580
|
+
const rawParams = op.raw?.parameters || [];
|
|
1581
|
+
|
|
1582
|
+
pathParamMatches.forEach(match => {
|
|
1583
|
+
const pName = match.replace(/[{}]/g, '');
|
|
1584
|
+
const declared = rawParams.find(p => p.in === 'path' && p.name === pName);
|
|
1585
|
+
let defaultVal = "usr_101";
|
|
1586
|
+
if (declared && (declared.example || declared.schema?.default)) {
|
|
1587
|
+
defaultVal = declared.example || declared.schema?.default;
|
|
1588
|
+
}
|
|
1589
|
+
pathParams.push({ name: pName, value: defaultVal });
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
// 2. Parse Query Parameters from spec
|
|
1593
|
+
queryParams = [];
|
|
1594
|
+
rawParams.filter(p => p.in === 'query').forEach(p => {
|
|
1595
|
+
let defaultVal = "";
|
|
1596
|
+
if (p.example !== undefined) defaultVal = p.example;
|
|
1597
|
+
else if (p.schema?.default !== undefined) defaultVal = p.schema.default;
|
|
1598
|
+
else if (p.schema?.enum) defaultVal = p.schema.enum[0];
|
|
1599
|
+
else if (p.schema?.type === 'integer' || p.schema?.type === 'number') defaultVal = "10";
|
|
1600
|
+
else if (p.schema?.type === 'string') defaultVal = "active";
|
|
1601
|
+
|
|
1602
|
+
queryParams.push({
|
|
1603
|
+
enabled: true,
|
|
1604
|
+
key: p.name,
|
|
1605
|
+
value: String(defaultVal),
|
|
1606
|
+
description: p.description || ''
|
|
1607
|
+
});
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
// 3. Initialize Headers
|
|
1611
|
+
requestHeaders = [];
|
|
1612
|
+
if (['post', 'put', 'patch'].includes(op.method)) {
|
|
1613
|
+
requestHeaders.push({ enabled: true, key: 'Content-Type', value: 'application/json', isAuth: false });
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// Add declared header params
|
|
1617
|
+
rawParams.filter(p => p.in === 'header').forEach(p => {
|
|
1618
|
+
const isAuthHeader = p.name.toLowerCase() === 'authorization';
|
|
1619
|
+
requestHeaders.push({
|
|
1620
|
+
enabled: true,
|
|
1621
|
+
key: p.name,
|
|
1622
|
+
value: p.example || p.schema?.default || (isAuthHeader ? 'Bearer secret_token_123' : 'trace_001'),
|
|
1623
|
+
isAuth: isAuthHeader
|
|
1624
|
+
});
|
|
1625
|
+
if (isAuthHeader) {
|
|
1626
|
+
authConfig.type = 'bearer';
|
|
1627
|
+
authConfig.token = 'secret_token_123';
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
// Render all controls
|
|
1632
|
+
renderPathParamsTable();
|
|
1633
|
+
renderQueryParamsTable();
|
|
1634
|
+
renderHeadersTable();
|
|
1635
|
+
updateAuthUI();
|
|
1636
|
+
|
|
1637
|
+
// Reset Body & active sub-tab
|
|
1638
|
+
resetRequestBody();
|
|
1639
|
+
if (op.method === 'get') {
|
|
1640
|
+
switchSubTab('params');
|
|
1641
|
+
} else {
|
|
1642
|
+
switchSubTab('body');
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
updateResolvedUrl();
|
|
1646
|
+
updateCodeSnippet();
|
|
1647
|
+
renderEndpointPlannedTests();
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function switchSubTab(subTabName) {
|
|
1651
|
+
document.querySelectorAll(".sub-tab-btn").forEach(b => b.classList.remove("active"));
|
|
1652
|
+
document.querySelectorAll(".sub-panel").forEach(p => p.classList.remove("active"));
|
|
1653
|
+
const btn = document.getElementById(`subtab-${subTabName}-btn`);
|
|
1654
|
+
const panel = document.getElementById(`subpanel-${subTabName}`);
|
|
1655
|
+
if (btn) btn.classList.add("active");
|
|
1656
|
+
if (panel) panel.classList.add("active");
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function switchResponseTab(resTabName) {
|
|
1660
|
+
document.querySelectorAll("#res-tab-body-btn, #res-tab-headers-btn").forEach(b => b.classList.remove("active"));
|
|
1661
|
+
const btn = document.getElementById(`res-tab-${resTabName}-btn`);
|
|
1662
|
+
if (btn) btn.classList.add("active");
|
|
1663
|
+
|
|
1664
|
+
if (resTabName === 'body') {
|
|
1665
|
+
document.getElementById("res-view-body").style.display = "block";
|
|
1666
|
+
document.getElementById("res-view-headers").style.display = "none";
|
|
1667
|
+
} else {
|
|
1668
|
+
document.getElementById("res-view-body").style.display = "none";
|
|
1669
|
+
document.getElementById("res-view-headers").style.display = "block";
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function renderPathParamsTable() {
|
|
1674
|
+
const section = document.getElementById("path-params-section");
|
|
1675
|
+
const tbody = document.getElementById("path-params-table-body");
|
|
1676
|
+
tbody.innerHTML = "";
|
|
1677
|
+
|
|
1678
|
+
if (pathParams.length === 0) {
|
|
1679
|
+
section.style.display = "none";
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
section.style.display = "block";
|
|
1684
|
+
pathParams.forEach((p, idx) => {
|
|
1685
|
+
const tr = document.createElement("tr");
|
|
1686
|
+
tr.innerHTML = `
|
|
1687
|
+
<td style="width: 140px; font-weight: 600; font-family: var(--font-mono); font-size: 0.82rem; color: var(--primary);">
|
|
1688
|
+
{${p.name}}
|
|
1689
|
+
</td>
|
|
1690
|
+
<td>
|
|
1691
|
+
<input type="text" class="kv-input" value="${p.value}" placeholder="Value..." oninput="onPathParamInput(${idx}, this.value)">
|
|
1692
|
+
</td>
|
|
1693
|
+
`;
|
|
1694
|
+
tbody.appendChild(tr);
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function onPathParamInput(idx, val) {
|
|
1699
|
+
pathParams[idx].value = val;
|
|
1700
|
+
updateResolvedUrl();
|
|
1701
|
+
updateCodeSnippet();
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function renderQueryParamsTable() {
|
|
1705
|
+
const tbody = document.getElementById("query-params-table-body");
|
|
1706
|
+
tbody.innerHTML = "";
|
|
1707
|
+
document.getElementById("params-count-badge").textContent = pathParams.length + queryParams.filter(q => q.enabled).length;
|
|
1708
|
+
|
|
1709
|
+
if (queryParams.length === 0) {
|
|
1710
|
+
tbody.innerHTML = `<tr><td colspan="4" style="color: var(--text-muted); text-align: center; font-size: 0.8rem; padding: 0.75rem;">No query parameters defined. Click <strong>+ Add Parameter</strong> to add one.</td></tr>`;
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
queryParams.forEach((q, idx) => {
|
|
1715
|
+
const tr = document.createElement("tr");
|
|
1716
|
+
tr.innerHTML = `
|
|
1717
|
+
<td style="text-align: center;">
|
|
1718
|
+
<input type="checkbox" ${q.enabled ? 'checked' : ''} onchange="toggleQueryParam(${idx})">
|
|
1719
|
+
</td>
|
|
1720
|
+
<td>
|
|
1721
|
+
<input type="text" class="kv-input" value="${q.key}" placeholder="Key" oninput="onQueryParamChange(${idx}, 'key', this.value)">
|
|
1722
|
+
</td>
|
|
1723
|
+
<td>
|
|
1724
|
+
<input type="text" class="kv-input" value="${q.value}" placeholder="Value" oninput="onQueryParamChange(${idx}, 'value', this.value)">
|
|
1725
|
+
</td>
|
|
1726
|
+
<td style="text-align: center;">
|
|
1727
|
+
<button class="btn-icon" onclick="removeQueryParamRow(${idx})" title="Remove">✕</button>
|
|
1728
|
+
</td>
|
|
1729
|
+
`;
|
|
1730
|
+
tbody.appendChild(tr);
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function addQueryParamRow() {
|
|
1735
|
+
queryParams.push({ enabled: true, key: '', value: '', description: '' });
|
|
1736
|
+
renderQueryParamsTable();
|
|
1737
|
+
updateResolvedUrl();
|
|
1738
|
+
updateCodeSnippet();
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function removeQueryParamRow(idx) {
|
|
1742
|
+
queryParams.splice(idx, 1);
|
|
1743
|
+
renderQueryParamsTable();
|
|
1744
|
+
updateResolvedUrl();
|
|
1745
|
+
updateCodeSnippet();
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function toggleQueryParam(idx) {
|
|
1749
|
+
queryParams[idx].enabled = !queryParams[idx].enabled;
|
|
1750
|
+
document.getElementById("params-count-badge").textContent = pathParams.length + queryParams.filter(q => q.enabled).length;
|
|
1751
|
+
updateResolvedUrl();
|
|
1752
|
+
updateCodeSnippet();
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function onQueryParamChange(idx, field, val) {
|
|
1756
|
+
queryParams[idx][field] = val;
|
|
1757
|
+
updateResolvedUrl();
|
|
1758
|
+
updateCodeSnippet();
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function renderHeadersTable() {
|
|
1762
|
+
const tbody = document.getElementById("headers-table-body");
|
|
1763
|
+
tbody.innerHTML = "";
|
|
1764
|
+
document.getElementById("headers-count-badge").textContent = requestHeaders.filter(h => h.enabled).length;
|
|
1765
|
+
|
|
1766
|
+
requestHeaders.forEach((h, idx) => {
|
|
1767
|
+
const tr = document.createElement("tr");
|
|
1768
|
+
tr.innerHTML = `
|
|
1769
|
+
<td style="text-align: center;">
|
|
1770
|
+
<input type="checkbox" ${h.enabled ? 'checked' : ''} onchange="toggleHeader(${idx})">
|
|
1771
|
+
</td>
|
|
1772
|
+
<td>
|
|
1773
|
+
<input type="text" class="kv-input" value="${h.key}" placeholder="Header Name" oninput="onHeaderChange(${idx}, 'key', this.value)">
|
|
1774
|
+
</td>
|
|
1775
|
+
<td>
|
|
1776
|
+
<input type="text" class="kv-input" value="${h.value}" placeholder="Value" oninput="onHeaderChange(${idx}, 'value', this.value)">
|
|
1777
|
+
</td>
|
|
1778
|
+
<td style="text-align: center;">
|
|
1779
|
+
<button class="btn-icon" onclick="removeHeaderRow(${idx})" title="Remove">✕</button>
|
|
1780
|
+
</td>
|
|
1781
|
+
`;
|
|
1782
|
+
tbody.appendChild(tr);
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function addHeaderRow() {
|
|
1787
|
+
requestHeaders.push({ enabled: true, key: '', value: '', isAuth: false });
|
|
1788
|
+
renderHeadersTable();
|
|
1789
|
+
updateCodeSnippet();
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function removeHeaderRow(idx) {
|
|
1793
|
+
requestHeaders.splice(idx, 1);
|
|
1794
|
+
renderHeadersTable();
|
|
1795
|
+
updateCodeSnippet();
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function toggleHeader(idx) {
|
|
1799
|
+
requestHeaders[idx].enabled = !requestHeaders[idx].enabled;
|
|
1800
|
+
document.getElementById("headers-count-badge").textContent = requestHeaders.filter(h => h.enabled).length;
|
|
1801
|
+
updateCodeSnippet();
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function onHeaderChange(idx, field, val) {
|
|
1805
|
+
requestHeaders[idx][field] = val;
|
|
1806
|
+
updateCodeSnippet();
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function onAuthTypeChange() {
|
|
1810
|
+
const type = document.getElementById("auth-type-select").value;
|
|
1811
|
+
authConfig.type = type;
|
|
1812
|
+
updateAuthUI();
|
|
1813
|
+
syncAuthHeader();
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function onAuthTokenInput() {
|
|
1817
|
+
authConfig.token = document.getElementById("auth-bearer-token").value;
|
|
1818
|
+
syncAuthHeader();
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function onApiKeyInput() {
|
|
1822
|
+
authConfig.headerName = document.getElementById("auth-apikey-name").value || 'X-API-Key';
|
|
1823
|
+
authConfig.headerVal = document.getElementById("auth-apikey-val").value;
|
|
1824
|
+
syncAuthHeader();
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function updateAuthUI() {
|
|
1828
|
+
document.getElementById("auth-type-select").value = authConfig.type;
|
|
1829
|
+
const bearerGroup = document.getElementById("auth-bearer-group");
|
|
1830
|
+
const apiKeyGroup = document.getElementById("auth-apikey-group");
|
|
1831
|
+
const statusBadge = document.getElementById("auth-status-badge");
|
|
1832
|
+
|
|
1833
|
+
if (authConfig.type === 'bearer') {
|
|
1834
|
+
bearerGroup.style.display = "block";
|
|
1835
|
+
apiKeyGroup.style.display = "none";
|
|
1836
|
+
statusBadge.textContent = "Bearer";
|
|
1837
|
+
document.getElementById("auth-bearer-token").value = authConfig.token;
|
|
1838
|
+
} else if (authConfig.type === 'apikey') {
|
|
1839
|
+
bearerGroup.style.display = "none";
|
|
1840
|
+
apiKeyGroup.style.display = "block";
|
|
1841
|
+
statusBadge.textContent = "API Key";
|
|
1842
|
+
document.getElementById("auth-apikey-name").value = authConfig.headerName;
|
|
1843
|
+
document.getElementById("auth-apikey-val").value = authConfig.headerVal;
|
|
1844
|
+
} else {
|
|
1845
|
+
bearerGroup.style.display = "none";
|
|
1846
|
+
apiKeyGroup.style.display = "none";
|
|
1847
|
+
statusBadge.textContent = "None";
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function syncAuthHeader() {
|
|
1852
|
+
// Remove any existing auth headers
|
|
1853
|
+
requestHeaders = requestHeaders.filter(h => !h.isAuth);
|
|
1854
|
+
|
|
1855
|
+
if (authConfig.type === 'bearer' && authConfig.token.trim()) {
|
|
1856
|
+
requestHeaders.unshift({
|
|
1857
|
+
enabled: true,
|
|
1858
|
+
key: 'Authorization',
|
|
1859
|
+
value: `Bearer ${authConfig.token.trim()}`,
|
|
1860
|
+
isAuth: true
|
|
1861
|
+
});
|
|
1862
|
+
} else if (authConfig.type === 'apikey' && authConfig.headerVal.trim()) {
|
|
1863
|
+
requestHeaders.unshift({
|
|
1864
|
+
enabled: true,
|
|
1865
|
+
key: authConfig.headerName.trim(),
|
|
1866
|
+
value: authConfig.headerVal.trim(),
|
|
1867
|
+
isAuth: true
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
renderHeadersTable();
|
|
1872
|
+
updateCodeSnippet();
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function getResolvedUrl() {
|
|
1876
|
+
const op = operations[selectedOpIndex];
|
|
1877
|
+
if (!op) return "";
|
|
1878
|
+
let path = op.path;
|
|
1879
|
+
|
|
1880
|
+
// Replace path parameters
|
|
1881
|
+
pathParams.forEach(p => {
|
|
1882
|
+
path = path.replace(`{${p.name}}`, encodeURIComponent(p.value || p.name));
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
// Append query parameters
|
|
1886
|
+
const activeQ = queryParams.filter(q => q.enabled && q.key.trim());
|
|
1887
|
+
if (activeQ.length > 0) {
|
|
1888
|
+
const searchParams = new URLSearchParams();
|
|
1889
|
+
activeQ.forEach(q => searchParams.append(q.key.trim(), q.value));
|
|
1890
|
+
path += '?' + searchParams.toString();
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
return path;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function updateResolvedUrl() {
|
|
1897
|
+
const path = getResolvedUrl();
|
|
1898
|
+
const host = window.location.origin || "http://localhost:8000";
|
|
1899
|
+
document.getElementById("resolved-url").textContent = `${host}${path}`;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function formatRequestBody() {
|
|
1903
|
+
const box = document.getElementById("request-body-input");
|
|
1904
|
+
try {
|
|
1905
|
+
const parsed = JSON.parse(box.value);
|
|
1906
|
+
box.value = JSON.stringify(parsed, null, 2);
|
|
1907
|
+
updateCodeSnippet();
|
|
1908
|
+
} catch (e) {
|
|
1909
|
+
alert("Invalid JSON: " + e.message);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function resetRequestBody() {
|
|
1914
|
+
const op = operations[selectedOpIndex];
|
|
1915
|
+
const baseline = generateMockFromOp(op);
|
|
1916
|
+
document.getElementById("request-body-input").value = JSON.stringify(baseline, null, 2);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function generateMockFromOp(op) {
|
|
1920
|
+
const schema = op.raw?.requestBody?.content?.['application/json']?.schema;
|
|
1921
|
+
if (!schema) return { message: "sample_payload" };
|
|
1922
|
+
return generateMockFromSchema(schema);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function generateMockFromSchema(schema) {
|
|
1926
|
+
if (schema.example) return schema.example;
|
|
1927
|
+
if (schema.type === 'object' || schema.properties) {
|
|
1928
|
+
const obj = {};
|
|
1929
|
+
for (const [k, v] of Object.entries(schema.properties || {})) {
|
|
1930
|
+
obj[k] = generateMockFromSchema(v);
|
|
1931
|
+
}
|
|
1932
|
+
return obj;
|
|
1933
|
+
}
|
|
1934
|
+
if (schema.type === 'string') {
|
|
1935
|
+
if (schema.format === 'email') return "user@example.com";
|
|
1936
|
+
return "sample_string";
|
|
1937
|
+
}
|
|
1938
|
+
if (schema.type === 'integer' || schema.type === 'number') return schema.minimum || 25;
|
|
1939
|
+
if (schema.type === 'boolean') return true;
|
|
1940
|
+
if (schema.type === 'array') return [];
|
|
1941
|
+
return "value";
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
async function executeManualRequest() {
|
|
1945
|
+
const op = operations[selectedOpIndex];
|
|
1946
|
+
const resolvedPath = getResolvedUrl();
|
|
1947
|
+
const bodyText = document.getElementById("request-body-input").value;
|
|
1948
|
+
const statusBadge = document.getElementById("response-status-badge");
|
|
1949
|
+
const viewer = document.getElementById("response-viewer");
|
|
1950
|
+
const headersViewer = document.getElementById("response-headers-viewer");
|
|
1951
|
+
const latencyLabel = document.getElementById("response-latency");
|
|
1952
|
+
|
|
1953
|
+
// Build Headers Dictionary
|
|
1954
|
+
const headersObj = {};
|
|
1955
|
+
requestHeaders.filter(h => h.enabled && h.key.trim()).forEach(h => {
|
|
1956
|
+
headersObj[h.key.trim()] = h.value;
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
let payload = null;
|
|
1960
|
+
if (['post', 'put', 'patch', 'delete'].includes(op.method) && bodyText.trim()) {
|
|
1961
|
+
try {
|
|
1962
|
+
payload = JSON.parse(bodyText);
|
|
1963
|
+
} catch(e) {
|
|
1964
|
+
alert("Invalid JSON body");
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
const startTime = performance.now();
|
|
1970
|
+
try {
|
|
1971
|
+
const opts = {
|
|
1972
|
+
method: op.method.toUpperCase(),
|
|
1973
|
+
headers: headersObj
|
|
1974
|
+
};
|
|
1975
|
+
if (payload) opts.body = JSON.stringify(payload);
|
|
1976
|
+
|
|
1977
|
+
const res = await fetch(resolvedPath, opts);
|
|
1978
|
+
const elapsed = Math.round(performance.now() - startTime);
|
|
1979
|
+
const text = await res.text();
|
|
1980
|
+
|
|
1981
|
+
statusBadge.style.display = "inline-block";
|
|
1982
|
+
statusBadge.textContent = `${res.status} ${res.statusText}`;
|
|
1983
|
+
statusBadge.className = `pill ${res.ok ? 'pill-pass' : (res.status >= 500 ? 'pill-crash' : 'pill-leak')}`;
|
|
1984
|
+
latencyLabel.textContent = `Latency: ${elapsed} ms`;
|
|
1985
|
+
|
|
1986
|
+
try {
|
|
1987
|
+
viewer.value = JSON.stringify(JSON.parse(text), null, 2);
|
|
1988
|
+
} catch {
|
|
1989
|
+
viewer.value = text;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// Render response headers
|
|
1993
|
+
let headerStr = "";
|
|
1994
|
+
res.headers.forEach((val, key) => {
|
|
1995
|
+
headerStr += `${key}: ${val}\n`;
|
|
1996
|
+
});
|
|
1997
|
+
headersViewer.value = headerStr || "(No response headers accessible due to CORS or empty)";
|
|
1998
|
+
} catch (err) {
|
|
1999
|
+
viewer.value = `Network or CORS error: ${err.message}`;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
function switchTab(tab) {
|
|
2004
|
+
document.querySelectorAll(".nav-tab").forEach(t => t.classList.remove("active"));
|
|
2005
|
+
document.querySelectorAll(".view-panel").forEach(p => p.classList.remove("active"));
|
|
2006
|
+
if (tab === 'client') {
|
|
2007
|
+
document.getElementById("tab-client-btn").classList.add("active");
|
|
2008
|
+
document.getElementById("view-client").classList.add("active");
|
|
2009
|
+
} else {
|
|
2010
|
+
document.getElementById("tab-testing-btn").classList.add("active");
|
|
2011
|
+
document.getElementById("view-testing").classList.add("active");
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// Fuzzing & Testing Generator
|
|
2016
|
+
function synthesizeTestPlan() {
|
|
2017
|
+
testCases = [];
|
|
2018
|
+
operations.forEach(op => {
|
|
2019
|
+
const schema = op.raw?.requestBody?.content?.['application/json']?.schema;
|
|
2020
|
+
const rawParams = op.raw?.parameters || [];
|
|
2021
|
+
const opMethod = op.method.toLowerCase();
|
|
2022
|
+
const opPath = op.path;
|
|
2023
|
+
|
|
2024
|
+
// 1. Request Body Fuzzing
|
|
2025
|
+
if (schema && schema.properties) {
|
|
2026
|
+
const baseline = generateMockFromSchema(schema);
|
|
2027
|
+
|
|
2028
|
+
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
2029
|
+
if (propSchema.type === 'string') {
|
|
2030
|
+
testCases.push({
|
|
2031
|
+
opMethod,
|
|
2032
|
+
opPath,
|
|
2033
|
+
endpoint: op.path,
|
|
2034
|
+
method: op.method,
|
|
2035
|
+
field: propName,
|
|
2036
|
+
category: "Whitespace: Multi-Space",
|
|
2037
|
+
desc: `Rejects multi-space whitespace string for '${propName}'`,
|
|
2038
|
+
payload: { ...baseline, [propName]: " " },
|
|
2039
|
+
expected: 422
|
|
2040
|
+
});
|
|
2041
|
+
testCases.push({
|
|
2042
|
+
opMethod,
|
|
2043
|
+
opPath,
|
|
2044
|
+
endpoint: op.path,
|
|
2045
|
+
method: op.method,
|
|
2046
|
+
field: propName,
|
|
2047
|
+
category: "Boundary: Empty String",
|
|
2048
|
+
desc: `Rejects empty string for '${propName}'`,
|
|
2049
|
+
payload: { ...baseline, [propName]: "" },
|
|
2050
|
+
expected: 422
|
|
2051
|
+
});
|
|
2052
|
+
} else if (propSchema.type === 'integer' || propSchema.type === 'number') {
|
|
2053
|
+
testCases.push({
|
|
2054
|
+
opMethod,
|
|
2055
|
+
opPath,
|
|
2056
|
+
endpoint: op.path,
|
|
2057
|
+
method: op.method,
|
|
2058
|
+
field: propName,
|
|
2059
|
+
category: "Boundary: Negative Number",
|
|
2060
|
+
desc: `Rejects negative number for unsigned '${propName}'`,
|
|
2061
|
+
payload: { ...baseline, [propName]: -1 },
|
|
2062
|
+
expected: 422
|
|
2063
|
+
});
|
|
2064
|
+
testCases.push({
|
|
2065
|
+
opMethod,
|
|
2066
|
+
opPath,
|
|
2067
|
+
endpoint: op.path,
|
|
2068
|
+
method: op.method,
|
|
2069
|
+
field: propName,
|
|
2070
|
+
category: "Type: Float into Integer",
|
|
2071
|
+
desc: `Rejects non-integer float (42.5) for '${propName}'`,
|
|
2072
|
+
payload: { ...baseline, [propName]: 42.5 },
|
|
2073
|
+
expected: 422
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
if (schema.required && schema.required.length > 0) {
|
|
2079
|
+
schema.required.forEach(reqKey => {
|
|
2080
|
+
const omitted = { ...baseline };
|
|
2081
|
+
delete omitted[reqKey];
|
|
2082
|
+
testCases.push({
|
|
2083
|
+
opMethod,
|
|
2084
|
+
opPath,
|
|
2085
|
+
endpoint: op.path,
|
|
2086
|
+
method: op.method,
|
|
2087
|
+
field: reqKey,
|
|
2088
|
+
category: "Structure: Omit Required Key",
|
|
2089
|
+
desc: `Rejects missing required field '${reqKey}'`,
|
|
2090
|
+
payload: omitted,
|
|
2091
|
+
expected: 422
|
|
2092
|
+
});
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
// 2. Query Parameters Fuzzing
|
|
2098
|
+
rawParams.filter(p => p.in === 'query').forEach(p => {
|
|
2099
|
+
const pName = p.name;
|
|
2100
|
+
if (p.schema?.type === 'string') {
|
|
2101
|
+
testCases.push({
|
|
2102
|
+
opMethod,
|
|
2103
|
+
opPath,
|
|
2104
|
+
endpoint: `${op.path}?${pName}=%20%20%20`,
|
|
2105
|
+
method: op.method,
|
|
2106
|
+
field: `?${pName}`,
|
|
2107
|
+
category: "QueryParam: Whitespace",
|
|
2108
|
+
desc: `Rejects whitespace in query parameter '${pName}'`,
|
|
2109
|
+
payload: null,
|
|
2110
|
+
expected: 422
|
|
2111
|
+
});
|
|
2112
|
+
} else if (p.schema?.type === 'integer') {
|
|
2113
|
+
testCases.push({
|
|
2114
|
+
opMethod,
|
|
2115
|
+
opPath,
|
|
2116
|
+
endpoint: `${op.path}?${pName}=-1`,
|
|
2117
|
+
method: op.method,
|
|
2118
|
+
field: `?${pName}`,
|
|
2119
|
+
category: "QueryParam: Below Minimum",
|
|
2120
|
+
desc: `Rejects negative value (-1) for query parameter '${pName}'`,
|
|
2121
|
+
payload: null,
|
|
2122
|
+
expected: 422
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
// 3. Header & Auth Fuzzing
|
|
2128
|
+
const authHeader = rawParams.find(p => p.in === 'header' && p.name.toLowerCase() === 'authorization');
|
|
2129
|
+
if (authHeader && authHeader.required) {
|
|
2130
|
+
testCases.push({
|
|
2131
|
+
opMethod,
|
|
2132
|
+
opPath,
|
|
2133
|
+
endpoint: op.path.replace(/\{([^}]+)\}/g, 'usr_101'),
|
|
2134
|
+
method: op.method,
|
|
2135
|
+
field: 'Header: Authorization',
|
|
2136
|
+
category: "Security: Missing Token",
|
|
2137
|
+
desc: "Rejects request without required Bearer token with HTTP 401",
|
|
2138
|
+
payload: null,
|
|
2139
|
+
expected: 401
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// 4. Path Variable Fuzzing
|
|
2144
|
+
if (op.path.includes('{')) {
|
|
2145
|
+
const shortPath = op.path.replace(/\{([^}]+)\}/g, 'x');
|
|
2146
|
+
testCases.push({
|
|
2147
|
+
opMethod,
|
|
2148
|
+
opPath,
|
|
2149
|
+
endpoint: shortPath,
|
|
2150
|
+
method: op.method,
|
|
2151
|
+
field: 'Path Variable: Length',
|
|
2152
|
+
category: "Boundary: Min Length Violation",
|
|
2153
|
+
desc: "Rejects path parameter below minimum required length ('x')",
|
|
2154
|
+
payload: null,
|
|
2155
|
+
expected: 422
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
// Merge client-side custom probes from localStorage
|
|
2161
|
+
mergeCustomProbesFromStorage();
|
|
2162
|
+
|
|
2163
|
+
populateTestScopeDropdown();
|
|
2164
|
+
renderEndpointPlannedTests();
|
|
2165
|
+
renderPlannedTestTable();
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// --- Custom Field Test Probes (Pure Client-Side / LocalStorage) ---
|
|
2169
|
+
function escapeHtml(str) {
|
|
2170
|
+
if (str === null || str === undefined) return "";
|
|
2171
|
+
return String(str)
|
|
2172
|
+
.replace(/&/g, "&")
|
|
2173
|
+
.replace(/</g, "<")
|
|
2174
|
+
.replace(/>/g, ">")
|
|
2175
|
+
.replace(/"/g, """)
|
|
2176
|
+
.replace(/'/g, "'");
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function escapeJs(str) {
|
|
2180
|
+
if (!str) return "";
|
|
2181
|
+
return String(str).replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
function getCustomProbesFromStorage() {
|
|
2185
|
+
try {
|
|
2186
|
+
const raw = localStorage.getItem("specforge_custom_probes");
|
|
2187
|
+
return raw ? JSON.parse(raw) : [];
|
|
2188
|
+
} catch (e) {
|
|
2189
|
+
console.error("Failed to load custom probes:", e);
|
|
2190
|
+
return [];
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
function saveCustomProbesToStorage(probes) {
|
|
2195
|
+
try {
|
|
2196
|
+
localStorage.setItem("specforge_custom_probes", JSON.stringify(probes));
|
|
2197
|
+
} catch (e) {
|
|
2198
|
+
console.error("Failed to save custom probes:", e);
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function mergeCustomProbesFromStorage() {
|
|
2203
|
+
const customProbes = getCustomProbesFromStorage();
|
|
2204
|
+
if (!Array.isArray(customProbes)) return;
|
|
2205
|
+
|
|
2206
|
+
customProbes.forEach(probe => {
|
|
2207
|
+
const op = operations.find(o => o.method.toLowerCase() === probe.opMethod.toLowerCase() && o.path === probe.opPath);
|
|
2208
|
+
if (!op) return;
|
|
2209
|
+
|
|
2210
|
+
let payload = null;
|
|
2211
|
+
let endpoint = op.path;
|
|
2212
|
+
|
|
2213
|
+
let parsedVal = probe.value;
|
|
2214
|
+
if (probe.valType === 'number') {
|
|
2215
|
+
parsedVal = Number(probe.value);
|
|
2216
|
+
if (isNaN(parsedVal)) parsedVal = probe.value;
|
|
2217
|
+
} else if (probe.valType === 'boolean') {
|
|
2218
|
+
parsedVal = (probe.value === true || String(probe.value).toLowerCase() === 'true');
|
|
2219
|
+
} else if (probe.valType === 'json') {
|
|
2220
|
+
try {
|
|
2221
|
+
parsedVal = JSON.parse(probe.value);
|
|
2222
|
+
} catch(e) {
|
|
2223
|
+
parsedVal = probe.value;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
const schema = op.raw?.requestBody?.content?.['application/json']?.schema;
|
|
2228
|
+
const baseline = schema ? generateMockFromSchema(schema) : {};
|
|
2229
|
+
|
|
2230
|
+
if (probe.location === 'body') {
|
|
2231
|
+
payload = { ...baseline, [probe.field]: parsedVal };
|
|
2232
|
+
} else if (probe.location === 'query') {
|
|
2233
|
+
const qKey = probe.field.replace(/^\?/, '');
|
|
2234
|
+
endpoint = `${op.path}?${encodeURIComponent(qKey)}=${encodeURIComponent(probe.value)}`;
|
|
2235
|
+
} else if (probe.location === 'path') {
|
|
2236
|
+
endpoint = op.path.replace(/\{([^}]+)\}/g, encodeURIComponent(probe.value));
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
testCases.push({
|
|
2240
|
+
id: probe.id,
|
|
2241
|
+
isCustom: true,
|
|
2242
|
+
opMethod: probe.opMethod.toLowerCase(),
|
|
2243
|
+
opPath: probe.opPath,
|
|
2244
|
+
endpoint: endpoint,
|
|
2245
|
+
method: probe.opMethod.toLowerCase(),
|
|
2246
|
+
field: probe.field,
|
|
2247
|
+
location: probe.location,
|
|
2248
|
+
category: probe.category || "Custom Boundary",
|
|
2249
|
+
desc: probe.desc || `Custom test probe for '${probe.field}'`,
|
|
2250
|
+
payload: payload,
|
|
2251
|
+
expected: Number(probe.expected) || 422
|
|
2252
|
+
});
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
function openAddCustomTestModal(prefilledField) {
|
|
2257
|
+
const op = operations[selectedOpIndex];
|
|
2258
|
+
if (!op) return;
|
|
2259
|
+
|
|
2260
|
+
document.getElementById("custom-modal-route-method").className = `badge-method badge-${op.method}`;
|
|
2261
|
+
document.getElementById("custom-modal-route-method").textContent = op.method.toUpperCase();
|
|
2262
|
+
document.getElementById("custom-modal-route-path").textContent = op.path;
|
|
2263
|
+
|
|
2264
|
+
// Populate field dropdown
|
|
2265
|
+
const sel = document.getElementById("custom-field-select");
|
|
2266
|
+
sel.innerHTML = "";
|
|
2267
|
+
|
|
2268
|
+
const schema = op.raw?.requestBody?.content?.['application/json']?.schema;
|
|
2269
|
+
const rawParams = op.raw?.parameters || [];
|
|
2270
|
+
|
|
2271
|
+
// Add body fields
|
|
2272
|
+
if (schema && schema.properties) {
|
|
2273
|
+
const bodyOptGroup = document.createElement("optgroup");
|
|
2274
|
+
bodyOptGroup.label = "Body Properties";
|
|
2275
|
+
for (const propName of Object.keys(schema.properties)) {
|
|
2276
|
+
const opt = document.createElement("option");
|
|
2277
|
+
opt.value = propName;
|
|
2278
|
+
opt.textContent = `${propName} (body)`;
|
|
2279
|
+
opt.dataset.location = "body";
|
|
2280
|
+
bodyOptGroup.appendChild(opt);
|
|
2281
|
+
}
|
|
2282
|
+
sel.appendChild(bodyOptGroup);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
// Add query params
|
|
2286
|
+
const queryParams = rawParams.filter(p => p.in === 'query');
|
|
2287
|
+
if (queryParams.length > 0) {
|
|
2288
|
+
const qOptGroup = document.createElement("optgroup");
|
|
2289
|
+
qOptGroup.label = "Query Parameters";
|
|
2290
|
+
queryParams.forEach(p => {
|
|
2291
|
+
const opt = document.createElement("option");
|
|
2292
|
+
opt.value = `?${p.name}`;
|
|
2293
|
+
opt.textContent = `?${p.name} (query)`;
|
|
2294
|
+
opt.dataset.location = "query";
|
|
2295
|
+
qOptGroup.appendChild(opt);
|
|
2296
|
+
});
|
|
2297
|
+
sel.appendChild(qOptGroup);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// Add path params
|
|
2301
|
+
const pathMatches = op.path.match(/\{([^}]+)\}/g) || [];
|
|
2302
|
+
if (pathMatches.length > 0) {
|
|
2303
|
+
const pOptGroup = document.createElement("optgroup");
|
|
2304
|
+
pOptGroup.label = "Path Variables";
|
|
2305
|
+
pathMatches.forEach(m => {
|
|
2306
|
+
const pName = m.replace(/[{}]/g, '');
|
|
2307
|
+
const opt = document.createElement("option");
|
|
2308
|
+
opt.value = pName;
|
|
2309
|
+
opt.textContent = `{${pName}} (path)`;
|
|
2310
|
+
opt.dataset.location = "path";
|
|
2311
|
+
pOptGroup.appendChild(opt);
|
|
2312
|
+
});
|
|
2313
|
+
sel.appendChild(pOptGroup);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// Add custom manual field option
|
|
2317
|
+
const customOpt = document.createElement("option");
|
|
2318
|
+
customOpt.value = "__custom__";
|
|
2319
|
+
customOpt.textContent = "➕ Enter Custom Field Name...";
|
|
2320
|
+
sel.appendChild(customOpt);
|
|
2321
|
+
|
|
2322
|
+
// Pre-select field if passed
|
|
2323
|
+
if (prefilledField) {
|
|
2324
|
+
const existing = [...sel.options].find(o => o.value === prefilledField);
|
|
2325
|
+
if (existing) {
|
|
2326
|
+
sel.value = prefilledField;
|
|
2327
|
+
} else {
|
|
2328
|
+
sel.value = "__custom__";
|
|
2329
|
+
document.getElementById("custom-field-name-input").value = prefilledField;
|
|
2330
|
+
}
|
|
2331
|
+
} else {
|
|
2332
|
+
if (sel.options.length > 1) {
|
|
2333
|
+
sel.selectedIndex = 0;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
onCustomFieldSelectChange();
|
|
2338
|
+
|
|
2339
|
+
// Reset form fields
|
|
2340
|
+
document.getElementById("custom-category-input").value = "Custom Boundary";
|
|
2341
|
+
document.getElementById("custom-expected-status").value = "422";
|
|
2342
|
+
document.getElementById("custom-desc-input").value = "";
|
|
2343
|
+
document.getElementById("custom-val-input").value = "";
|
|
2344
|
+
document.getElementById("custom-val-error").style.display = "none";
|
|
2345
|
+
document.querySelector('input[name="custom-val-type"][value="string"]').checked = true;
|
|
2346
|
+
onCustomValTypeChange();
|
|
2347
|
+
|
|
2348
|
+
document.getElementById("custom-test-modal").style.display = "flex";
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
function closeAddCustomTestModal() {
|
|
2352
|
+
document.getElementById("custom-test-modal").style.display = "none";
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
function onCustomFieldSelectChange() {
|
|
2356
|
+
const sel = document.getElementById("custom-field-select");
|
|
2357
|
+
const customInput = document.getElementById("custom-field-name-input");
|
|
2358
|
+
const locSelect = document.getElementById("custom-field-location");
|
|
2359
|
+
|
|
2360
|
+
if (sel.value === "__custom__") {
|
|
2361
|
+
customInput.style.display = "block";
|
|
2362
|
+
customInput.focus();
|
|
2363
|
+
} else {
|
|
2364
|
+
customInput.style.display = "none";
|
|
2365
|
+
const selectedOpt = sel.options[sel.selectedIndex];
|
|
2366
|
+
if (selectedOpt && selectedOpt.dataset.location) {
|
|
2367
|
+
locSelect.value = selectedOpt.dataset.location;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function onCustomValTypeChange() {
|
|
2373
|
+
const type = document.querySelector('input[name="custom-val-type"]:checked')?.value || 'string';
|
|
2374
|
+
const input = document.getElementById("custom-val-input");
|
|
2375
|
+
if (type === 'string') {
|
|
2376
|
+
input.placeholder = "Enter custom string value (e.g. admin' OR '1'='1 or <script>)...";
|
|
2377
|
+
} else if (type === 'number') {
|
|
2378
|
+
input.placeholder = "Enter numeric value (e.g. 99999 or -99)...";
|
|
2379
|
+
} else if (type === 'boolean') {
|
|
2380
|
+
input.placeholder = "true or false";
|
|
2381
|
+
} else if (type === 'json') {
|
|
2382
|
+
input.placeholder = '{\n "custom_nested": "value"\n}';
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function saveCustomFieldTest() {
|
|
2387
|
+
const op = operations[selectedOpIndex];
|
|
2388
|
+
if (!op) return;
|
|
2389
|
+
|
|
2390
|
+
const sel = document.getElementById("custom-field-select");
|
|
2391
|
+
const customNameInput = document.getElementById("custom-field-name-input");
|
|
2392
|
+
let fieldName = sel.value === "__custom__" ? customNameInput.value.trim() : sel.value;
|
|
2393
|
+
|
|
2394
|
+
const errEl = document.getElementById("custom-val-error");
|
|
2395
|
+
errEl.style.display = "none";
|
|
2396
|
+
|
|
2397
|
+
if (!fieldName) {
|
|
2398
|
+
errEl.textContent = "Please specify a target field name.";
|
|
2399
|
+
errEl.style.display = "block";
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const location = document.getElementById("custom-field-location").value;
|
|
2404
|
+
const category = document.getElementById("custom-category-input").value.trim() || "Custom Boundary";
|
|
2405
|
+
const expected = parseInt(document.getElementById("custom-expected-status").value, 10) || 422;
|
|
2406
|
+
const desc = document.getElementById("custom-desc-input").value.trim() || `Custom probe for '${fieldName}'`;
|
|
2407
|
+
const valType = document.querySelector('input[name="custom-val-type"]:checked')?.value || 'string';
|
|
2408
|
+
const rawVal = document.getElementById("custom-val-input").value;
|
|
2409
|
+
|
|
2410
|
+
if (valType === 'json') {
|
|
2411
|
+
try {
|
|
2412
|
+
JSON.parse(rawVal);
|
|
2413
|
+
} catch (e) {
|
|
2414
|
+
errEl.textContent = "Invalid JSON format: " + e.message;
|
|
2415
|
+
errEl.style.display = "block";
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
} else if (valType === 'number') {
|
|
2419
|
+
if (rawVal.trim() === "" || isNaN(Number(rawVal))) {
|
|
2420
|
+
errEl.textContent = "Please enter a valid numeric value.";
|
|
2421
|
+
errEl.style.display = "block";
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
const customProbe = {
|
|
2427
|
+
id: "cust_" + Date.now() + "_" + Math.random().toString(36).substr(2, 5),
|
|
2428
|
+
opMethod: op.method.toLowerCase(),
|
|
2429
|
+
opPath: op.path,
|
|
2430
|
+
field: fieldName,
|
|
2431
|
+
location: location,
|
|
2432
|
+
category: category,
|
|
2433
|
+
desc: desc,
|
|
2434
|
+
valType: valType,
|
|
2435
|
+
value: rawVal,
|
|
2436
|
+
expected: expected,
|
|
2437
|
+
createdAt: Date.now()
|
|
2438
|
+
};
|
|
2439
|
+
|
|
2440
|
+
const probes = getCustomProbesFromStorage();
|
|
2441
|
+
probes.unshift(customProbe);
|
|
2442
|
+
saveCustomProbesToStorage(probes);
|
|
2443
|
+
|
|
2444
|
+
// Refresh synthesized test plan & views
|
|
2445
|
+
synthesizeTestPlan();
|
|
2446
|
+
|
|
2447
|
+
// Ensure planned tests section is expanded
|
|
2448
|
+
isEndpointAccordionOpen = true;
|
|
2449
|
+
const body = document.getElementById("endpoint-tests-body");
|
|
2450
|
+
const chevron = document.getElementById("accordion-chevron");
|
|
2451
|
+
if (body) body.style.display = "block";
|
|
2452
|
+
if (chevron) chevron.classList.remove("collapsed");
|
|
2453
|
+
|
|
2454
|
+
closeAddCustomTestModal();
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
function deleteCustomProbe(probeId) {
|
|
2458
|
+
if (!confirm("Are you sure you want to remove this custom test?")) return;
|
|
2459
|
+
const probes = getCustomProbesFromStorage();
|
|
2460
|
+
const updated = probes.filter(p => p.id !== probeId);
|
|
2461
|
+
saveCustomProbesToStorage(updated);
|
|
2462
|
+
synthesizeTestPlan();
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
let isEndpointAccordionOpen = false;
|
|
2466
|
+
let allFieldGroupsExpanded = true;
|
|
2467
|
+
|
|
2468
|
+
function toggleEndpointAccordion() {
|
|
2469
|
+
isEndpointAccordionOpen = !isEndpointAccordionOpen;
|
|
2470
|
+
const body = document.getElementById("endpoint-tests-body");
|
|
2471
|
+
const chevron = document.getElementById("accordion-chevron");
|
|
2472
|
+
if (body) {
|
|
2473
|
+
body.style.display = isEndpointAccordionOpen ? "block" : "none";
|
|
2474
|
+
}
|
|
2475
|
+
if (chevron) {
|
|
2476
|
+
chevron.classList.toggle("collapsed", !isEndpointAccordionOpen);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function toggleAllFieldGroups() {
|
|
2481
|
+
allFieldGroupsExpanded = !allFieldGroupsExpanded;
|
|
2482
|
+
document.querySelectorAll(".field-group-body").forEach(b => {
|
|
2483
|
+
b.style.display = allFieldGroupsExpanded ? "flex" : "none";
|
|
2484
|
+
});
|
|
2485
|
+
document.querySelectorAll(".field-group-header .accordion-chevron").forEach(c => {
|
|
2486
|
+
c.classList.toggle("collapsed", !allFieldGroupsExpanded);
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
function toggleFieldGroup(safeId) {
|
|
2491
|
+
const body = document.getElementById(`body-${safeId}`);
|
|
2492
|
+
const chevron = document.getElementById(`chevron-${safeId}`);
|
|
2493
|
+
if (!body) return;
|
|
2494
|
+
const isClosed = body.style.display === "none";
|
|
2495
|
+
body.style.display = isClosed ? "flex" : "none";
|
|
2496
|
+
if (chevron) {
|
|
2497
|
+
chevron.classList.toggle("collapsed", !isClosed);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
async function runFieldGroupProbes(safeId) {
|
|
2502
|
+
const body = document.getElementById(`body-${safeId}`);
|
|
2503
|
+
if (!body) return;
|
|
2504
|
+
body.style.display = "flex";
|
|
2505
|
+
const chevron = document.getElementById(`chevron-${safeId}`);
|
|
2506
|
+
if (chevron) chevron.classList.remove("collapsed");
|
|
2507
|
+
|
|
2508
|
+
const testBtns = body.querySelectorAll("button[onclick*='runSingleRouteProbe']");
|
|
2509
|
+
for (const btn of testBtns) {
|
|
2510
|
+
btn.click();
|
|
2511
|
+
await new Promise(r => setTimeout(r, 60));
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function renderEndpointPlannedTests() {
|
|
2516
|
+
const container = document.getElementById("endpoint-planned-tests-container");
|
|
2517
|
+
const badge = document.getElementById("endpoint-planned-count-badge");
|
|
2518
|
+
if (!container || !badge) return;
|
|
2519
|
+
|
|
2520
|
+
const op = operations[selectedOpIndex];
|
|
2521
|
+
if (!op) return;
|
|
2522
|
+
|
|
2523
|
+
const routeCases = testCases.filter(tc => tc.opMethod === op.method.toLowerCase() && tc.opPath === op.path);
|
|
2524
|
+
badge.textContent = `${routeCases.length} Tests Planned`;
|
|
2525
|
+
|
|
2526
|
+
if (routeCases.length === 0) {
|
|
2527
|
+
container.innerHTML = `<div style="text-align: center; color: var(--text-muted); font-size: 0.85rem; padding: 1.25rem;">No schema-constrained boundary tests synthesized for this route.</div>`;
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// Group test cases by field
|
|
2532
|
+
const fieldGroups = {};
|
|
2533
|
+
routeCases.forEach((tc, i) => {
|
|
2534
|
+
const key = tc.field;
|
|
2535
|
+
if (!fieldGroups[key]) {
|
|
2536
|
+
fieldGroups[key] = [];
|
|
2537
|
+
}
|
|
2538
|
+
fieldGroups[key].push({ tc, globalIndex: i });
|
|
2539
|
+
});
|
|
2540
|
+
|
|
2541
|
+
container.innerHTML = "";
|
|
2542
|
+
Object.entries(fieldGroups).forEach(([fieldName, items], groupIndex) => {
|
|
2543
|
+
const safeId = `field-grp-${groupIndex}`;
|
|
2544
|
+
|
|
2545
|
+
// Determine location tag
|
|
2546
|
+
let locBadge = `<span style="font-size:0.68rem; font-weight:700; text-transform:uppercase; color:var(--muted-foreground); background:var(--secondary); border:1px solid var(--border); padding:0.1rem 0.35rem; border-radius:3px;">`;
|
|
2547
|
+
if (fieldName.startsWith('?')) locBadge += `Query`;
|
|
2548
|
+
else if (fieldName.startsWith('Header:')) locBadge += `Header`;
|
|
2549
|
+
else if (fieldName.startsWith('Path')) locBadge += `Path`;
|
|
2550
|
+
else locBadge += `Body`;
|
|
2551
|
+
locBadge += `</span>`;
|
|
2552
|
+
|
|
2553
|
+
const countText = items.length === 1 ? "1 test" : `${items.length} tests`;
|
|
2554
|
+
|
|
2555
|
+
const groupCard = document.createElement("div");
|
|
2556
|
+
groupCard.className = "field-group-card";
|
|
2557
|
+
groupCard.innerHTML = `
|
|
2558
|
+
<div class="field-group-header" onclick="toggleFieldGroup('${safeId}')">
|
|
2559
|
+
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
|
|
2560
|
+
${locBadge}
|
|
2561
|
+
<span class="field-tag">${fieldName}</span>
|
|
2562
|
+
<span class="sub-tab-badge" style="background: var(--accent); color: var(--accent-foreground); border: 1px solid var(--border); font-size: 0.7rem; font-weight: 600;">
|
|
2563
|
+
${countText}
|
|
2564
|
+
</span>
|
|
2565
|
+
</div>
|
|
2566
|
+
<div style="display: flex; align-items: center; gap: 0.4rem;">
|
|
2567
|
+
<button class="btn btn-outline" style="font-size: 0.68rem; padding: 0.16rem 0.45rem;" onclick="event.stopPropagation(); openAddCustomTestModal('${escapeJs(fieldName)}')" title="Add custom probe for this field">
|
|
2568
|
+
+ Add Test
|
|
2569
|
+
</button>
|
|
2570
|
+
<button class="btn btn-outline-primary" style="font-size: 0.7rem; padding: 0.18rem 0.5rem;" onclick="event.stopPropagation(); runFieldGroupProbes('${safeId}')" title="Run all tests for this field">
|
|
2571
|
+
▶ Test Field Probes (${items.length})
|
|
2572
|
+
</button>
|
|
2573
|
+
<span id="chevron-${safeId}" class="accordion-chevron">▼</span>
|
|
2574
|
+
</div>
|
|
2575
|
+
</div>
|
|
2576
|
+
<div id="body-${safeId}" class="field-group-body" style="display: flex;">
|
|
2577
|
+
</div>
|
|
2578
|
+
`;
|
|
2579
|
+
|
|
2580
|
+
const bodyEl = groupCard.querySelector(`#body-${safeId}`);
|
|
2581
|
+
|
|
2582
|
+
items.forEach(({ tc, globalIndex }) => {
|
|
2583
|
+
let valPreview = "";
|
|
2584
|
+
if (tc.payload && typeof tc.payload === 'object') {
|
|
2585
|
+
const targetVal = tc.payload[tc.field];
|
|
2586
|
+
if (targetVal !== undefined) {
|
|
2587
|
+
valPreview = JSON.stringify(targetVal);
|
|
2588
|
+
} else {
|
|
2589
|
+
valPreview = `(Field omitted)`;
|
|
2590
|
+
}
|
|
2591
|
+
} else if (tc.field.startsWith('?')) {
|
|
2592
|
+
const qVal = tc.endpoint.split('?')[1] || '';
|
|
2593
|
+
valPreview = decodeURIComponent(qVal);
|
|
2594
|
+
} else if (tc.field.startsWith('Header:')) {
|
|
2595
|
+
valPreview = `Missing auth header`;
|
|
2596
|
+
} else {
|
|
2597
|
+
valPreview = `Below min length ('x')`;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
const itemEl = document.createElement("div");
|
|
2601
|
+
itemEl.className = "field-probe-item";
|
|
2602
|
+
itemEl.innerHTML = `
|
|
2603
|
+
<div>
|
|
2604
|
+
<div style="display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;">
|
|
2605
|
+
<span style="font-weight: 600; color: var(--foreground); font-size: 0.83rem;">${escapeHtml(tc.category)}</span>
|
|
2606
|
+
${tc.isCustom ? `<span style="font-size:0.62rem; font-weight:700; color:var(--primary); background:var(--accent); border:1px solid var(--border); padding:0.06rem 0.35rem; border-radius:3px; letter-spacing:0.03em;">CUSTOM</span>` : ''}
|
|
2607
|
+
</div>
|
|
2608
|
+
<div style="color: var(--muted-foreground); font-size: 0.75rem; margin-top: 0.1rem;">${escapeHtml(tc.desc)}</div>
|
|
2609
|
+
<div style="margin-top: 0.25rem; font-family: var(--font-mono); font-size: 0.72rem; color: var(--link); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 420px;">
|
|
2610
|
+
Probe Value: <code>${escapeHtml(valPreview)}</code>
|
|
2611
|
+
</div>
|
|
2612
|
+
</div>
|
|
2613
|
+
<div style="display: flex; flex-direction: column; align-items: flex-start; gap: 0.2rem;">
|
|
2614
|
+
<span style="font-size: 0.74rem; color: var(--muted-foreground);">Expected: <strong style="color: var(--foreground);">${tc.expected}</strong></span>
|
|
2615
|
+
<div id="route-probe-res-${globalIndex}"><span class="pill pill-planned">READY</span></div>
|
|
2616
|
+
</div>
|
|
2617
|
+
<div style="display: flex; gap: 0.35rem; justify-content: flex-end; align-items: center;">
|
|
2618
|
+
<button class="btn btn-outline-primary" style="font-size: 0.72rem; padding: 0.2rem 0.55rem;" onclick="runSingleRouteProbe(${globalIndex}, 'route-probe-res-${globalIndex}')">
|
|
2619
|
+
▶ Test
|
|
2620
|
+
</button>
|
|
2621
|
+
<button class="btn-icon" onclick="inspectSingleRouteProbe(${globalIndex})" title="Inspect payload">
|
|
2622
|
+
🔍
|
|
2623
|
+
</button>
|
|
2624
|
+
${tc.isCustom ? `<button class="btn-icon" onclick="deleteCustomProbe('${escapeJs(tc.id)}')" title="Delete custom probe" style="color:var(--destructive); font-size: 0.8rem; padding: 0.2rem 0.35rem;">🗑️</button>` : ''}
|
|
2625
|
+
</div>
|
|
2626
|
+
`;
|
|
2627
|
+
bodyEl.appendChild(itemEl);
|
|
2628
|
+
});
|
|
2629
|
+
|
|
2630
|
+
container.appendChild(groupCard);
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
async function runSingleRouteProbe(idx, resultElementId) {
|
|
2635
|
+
const op = operations[selectedOpIndex];
|
|
2636
|
+
if (!op) return;
|
|
2637
|
+
const routeCases = testCases.filter(tc => tc.opMethod === op.method.toLowerCase() && tc.opPath === op.path);
|
|
2638
|
+
const tc = routeCases[idx];
|
|
2639
|
+
if (!tc) return;
|
|
2640
|
+
|
|
2641
|
+
const resEl = document.getElementById(resultElementId);
|
|
2642
|
+
if (resEl) resEl.innerHTML = `<span style="font-size:0.75rem; color: var(--text-muted);">Testing...</span>`;
|
|
2643
|
+
|
|
2644
|
+
const start = performance.now();
|
|
2645
|
+
let status = 0;
|
|
2646
|
+
let respText = "";
|
|
2647
|
+
try {
|
|
2648
|
+
const opts = {
|
|
2649
|
+
method: tc.method.toUpperCase(),
|
|
2650
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2651
|
+
};
|
|
2652
|
+
if (tc.payload) opts.body = JSON.stringify(tc.payload);
|
|
2653
|
+
|
|
2654
|
+
const res = await fetch(tc.endpoint, opts);
|
|
2655
|
+
status = res.status;
|
|
2656
|
+
respText = await res.text();
|
|
2657
|
+
} catch (e) {
|
|
2658
|
+
status = 0;
|
|
2659
|
+
respText = e.message;
|
|
2660
|
+
}
|
|
2661
|
+
const latency = Math.round(performance.now() - start);
|
|
2662
|
+
|
|
2663
|
+
let pillClass = "pill-pass";
|
|
2664
|
+
let statusLabel = "PASS";
|
|
2665
|
+
if (status >= 500) {
|
|
2666
|
+
pillClass = "pill-crash";
|
|
2667
|
+
statusLabel = "CRASH (500)";
|
|
2668
|
+
} else if (status >= 200 && status < 300) {
|
|
2669
|
+
pillClass = "pill-leak";
|
|
2670
|
+
statusLabel = "LEAK (200)";
|
|
2671
|
+
} else if (status === 400 || status === 422 || status === 401) {
|
|
2672
|
+
pillClass = "pill-pass";
|
|
2673
|
+
statusLabel = `PASS (${status})`;
|
|
2674
|
+
} else {
|
|
2675
|
+
pillClass = "pill-leak";
|
|
2676
|
+
statusLabel = `${status}`;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
if (resEl) {
|
|
2680
|
+
resEl.innerHTML = `<span class="pill ${pillClass}">${statusLabel}</span> <span style="font-size:0.72rem; color:var(--text-muted); margin-left:0.25rem;">${latency}ms</span>`;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
function inspectSingleRouteProbe(idx) {
|
|
2685
|
+
const op = operations[selectedOpIndex];
|
|
2686
|
+
if (!op) return;
|
|
2687
|
+
const routeCases = testCases.filter(tc => tc.opMethod === op.method.toLowerCase() && tc.opPath === op.path);
|
|
2688
|
+
const tc = routeCases[idx];
|
|
2689
|
+
if (!tc) return;
|
|
2690
|
+
alert(`Planned Boundary Test Probe Details:\n\nTarget Field: ${tc.field}\nCategory: ${tc.category}\nDescription: ${tc.desc}\nExpected Status: HTTP ${tc.expected}\nEndpoint Target: ${tc.endpoint}\n\nPayload:\n` + JSON.stringify(tc.payload, null, 2));
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function populateTestScopeDropdown() {
|
|
2694
|
+
const sel = document.getElementById("test-scope-select");
|
|
2695
|
+
if (!sel) return;
|
|
2696
|
+
const currentVal = sel.value || "all";
|
|
2697
|
+
sel.innerHTML = `<option value="all">🌐 All Endpoints (${testCases.length} tests)</option>`;
|
|
2698
|
+
|
|
2699
|
+
operations.forEach(op => {
|
|
2700
|
+
const key = `${op.method.toLowerCase()}:${op.path}`;
|
|
2701
|
+
const count = testCases.filter(tc => tc.opMethod === op.method.toLowerCase() && tc.opPath === op.path).length;
|
|
2702
|
+
const opt = document.createElement("option");
|
|
2703
|
+
opt.value = key;
|
|
2704
|
+
opt.textContent = `${op.method.toUpperCase()} ${op.path} (${count} tests)`;
|
|
2705
|
+
sel.appendChild(opt);
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
if ([...sel.options].some(o => o.value === currentVal)) {
|
|
2709
|
+
sel.value = currentVal;
|
|
2710
|
+
}
|
|
2711
|
+
updateScopeStats();
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
function onTestScopeChange() {
|
|
2715
|
+
updateScopeStats();
|
|
2716
|
+
renderPlannedTestTable();
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
function renderPlannedTestTable() {
|
|
2720
|
+
const tbody = document.getElementById("test-table-body");
|
|
2721
|
+
const activeCases = getActiveTestCases();
|
|
2722
|
+
window.__activeTestCases = activeCases;
|
|
2723
|
+
tbody.innerHTML = "";
|
|
2724
|
+
|
|
2725
|
+
if (activeCases.length === 0) {
|
|
2726
|
+
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-muted); padding: 2rem;">No tests planned for this scope.</td></tr>`;
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
activeCases.forEach((tc, i) => {
|
|
2731
|
+
const tr = document.createElement("tr");
|
|
2732
|
+
tr.id = `test-row-${i}`;
|
|
2733
|
+
tr.innerHTML = `
|
|
2734
|
+
<td>
|
|
2735
|
+
<span class="pill pill-planned">PLANNED</span>
|
|
2736
|
+
${tc.isCustom ? `<span style="font-size:0.62rem; font-weight:700; color:var(--primary); background:var(--accent); border:1px solid var(--border); padding:0.06rem 0.35rem; border-radius:3px; margin-left:0.3rem;">CUSTOM</span>` : ''}
|
|
2737
|
+
</td>
|
|
2738
|
+
<td><span class="badge-method badge-${tc.opMethod}">${tc.opMethod.toUpperCase()}</span> <code style="font-size:0.8rem; color: var(--foreground); margin-left: 0.35rem;">${escapeHtml(tc.opPath)}</code></td>
|
|
2739
|
+
<td><span class="field-tag">${escapeHtml(tc.field)}</span></td>
|
|
2740
|
+
<td><strong>${escapeHtml(tc.category)}</strong><br><span style="color:var(--muted-foreground);font-size:0.75rem;">${escapeHtml(tc.desc)}</span></td>
|
|
2741
|
+
<td>Expected ${tc.expected}</td>
|
|
2742
|
+
<td>--</td>
|
|
2743
|
+
<td style="white-space: nowrap;">
|
|
2744
|
+
<button class="btn btn-outline" style="font-size:0.72rem;padding:0.2rem 0.5rem;" onclick="runSingleBoundaryTest(${i})">▶ Run</button>
|
|
2745
|
+
<button class="btn btn-outline" style="font-size:0.72rem;padding:0.2rem 0.5rem;" onclick="inspectTestPayload(${i})">Inspect</button>
|
|
2746
|
+
${tc.isCustom ? `<button class="btn-icon" style="color:var(--destructive);" onclick="deleteCustomProbe('${escapeJs(tc.id)}')" title="Delete custom probe">🗑️</button>` : ''}
|
|
2747
|
+
</td>
|
|
2748
|
+
`;
|
|
2749
|
+
tbody.appendChild(tr);
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function getActiveTestCases() {
|
|
2754
|
+
const sel = document.getElementById("test-scope-select");
|
|
2755
|
+
const scope = sel ? sel.value : "all";
|
|
2756
|
+
if (!scope || scope === "all") return testCases;
|
|
2757
|
+
const [method, path] = scope.split(":");
|
|
2758
|
+
return testCases.filter(tc => tc.opMethod === method && tc.opPath === path);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
function updateScopeStats() {
|
|
2762
|
+
const activeCases = getActiveTestCases();
|
|
2763
|
+
document.getElementById("stat-total").textContent = activeCases.length;
|
|
2764
|
+
document.getElementById("stat-pass").textContent = "0";
|
|
2765
|
+
document.getElementById("stat-leak").textContent = "0";
|
|
2766
|
+
document.getElementById("stat-crash").textContent = "0";
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
function runBoundaryTestsForCurrentRoute() {
|
|
2770
|
+
const op = operations[selectedOpIndex];
|
|
2771
|
+
if (!op) return;
|
|
2772
|
+
switchTab('testing');
|
|
2773
|
+
const sel = document.getElementById("test-scope-select");
|
|
2774
|
+
if (sel) {
|
|
2775
|
+
sel.value = `${op.method.toLowerCase()}:${op.path}`;
|
|
2776
|
+
onTestScopeChange();
|
|
2777
|
+
}
|
|
2778
|
+
executeBoundaryTests();
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
async function runSingleBoundaryTest(idx) {
|
|
2782
|
+
const activeCases = getActiveTestCases();
|
|
2783
|
+
const tc = activeCases[idx];
|
|
2784
|
+
if (!tc) return;
|
|
2785
|
+
|
|
2786
|
+
const row = document.getElementById(`test-row-${idx}`);
|
|
2787
|
+
if (row) {
|
|
2788
|
+
row.cells[0].innerHTML = `<span style="font-size:0.75rem; color:var(--text-muted);">Testing...</span>`;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
const start = performance.now();
|
|
2792
|
+
let status = 0;
|
|
2793
|
+
let respText = "";
|
|
2794
|
+
try {
|
|
2795
|
+
const opts = {
|
|
2796
|
+
method: tc.method.toUpperCase(),
|
|
2797
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2798
|
+
};
|
|
2799
|
+
if (tc.payload) opts.body = JSON.stringify(tc.payload);
|
|
2800
|
+
|
|
2801
|
+
const res = await fetch(tc.endpoint, opts);
|
|
2802
|
+
status = res.status;
|
|
2803
|
+
respText = await res.text();
|
|
2804
|
+
} catch (e) {
|
|
2805
|
+
status = 0;
|
|
2806
|
+
respText = e.message;
|
|
2807
|
+
}
|
|
2808
|
+
const latency = Math.round(performance.now() - start);
|
|
2809
|
+
|
|
2810
|
+
let pillClass = "pill-pass";
|
|
2811
|
+
let statusLabel = "PASS";
|
|
2812
|
+
if (status >= 500) {
|
|
2813
|
+
pillClass = "pill-crash";
|
|
2814
|
+
statusLabel = "CRASH (500)";
|
|
2815
|
+
} else if (status >= 200 && status < 300) {
|
|
2816
|
+
pillClass = "pill-leak";
|
|
2817
|
+
statusLabel = "LEAK (200)";
|
|
2818
|
+
} else if (status === 400 || status === 422 || status === 401) {
|
|
2819
|
+
pillClass = "pill-pass";
|
|
2820
|
+
statusLabel = `PASS (${status})`;
|
|
2821
|
+
} else {
|
|
2822
|
+
pillClass = "pill-leak";
|
|
2823
|
+
statusLabel = `${status}`;
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
if (row) {
|
|
2827
|
+
row.cells[0].innerHTML = `<span class="pill ${pillClass}">${statusLabel}</span>`;
|
|
2828
|
+
row.cells[4].textContent = `Expected ${tc.expected} | Got ${status}`;
|
|
2829
|
+
row.cells[5].textContent = `${latency} ms`;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
async function executeBoundaryTests() {
|
|
2834
|
+
const activeCases = getActiveTestCases();
|
|
2835
|
+
window.__activeTestCases = activeCases;
|
|
2836
|
+
renderPlannedTestTable();
|
|
2837
|
+
let passCount = 0;
|
|
2838
|
+
let leakCount = 0;
|
|
2839
|
+
let crashCount = 0;
|
|
2840
|
+
|
|
2841
|
+
for (let i = 0; i < activeCases.length; i++) {
|
|
2842
|
+
const tc = activeCases[i];
|
|
2843
|
+
const row = document.getElementById(`test-row-${i}`);
|
|
2844
|
+
if (row) {
|
|
2845
|
+
row.cells[0].innerHTML = `<span style="font-size:0.75rem; color:var(--text-muted);">Testing...</span>`;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
const start = performance.now();
|
|
2849
|
+
let status = 0;
|
|
2850
|
+
let respText = "";
|
|
2851
|
+
|
|
2852
|
+
try {
|
|
2853
|
+
const opts = {
|
|
2854
|
+
method: tc.method.toUpperCase(),
|
|
2855
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2856
|
+
};
|
|
2857
|
+
if (tc.payload) opts.body = JSON.stringify(tc.payload);
|
|
2858
|
+
|
|
2859
|
+
const res = await fetch(tc.endpoint, opts);
|
|
2860
|
+
status = res.status;
|
|
2861
|
+
respText = await res.text();
|
|
2862
|
+
} catch (e) {
|
|
2863
|
+
status = 0;
|
|
2864
|
+
respText = e.message;
|
|
2865
|
+
}
|
|
2866
|
+
const latency = Math.round(performance.now() - start);
|
|
2867
|
+
|
|
2868
|
+
let pillClass = "pill-pass";
|
|
2869
|
+
let statusLabel = "PASS";
|
|
2870
|
+
if (status >= 500) {
|
|
2871
|
+
pillClass = "pill-crash";
|
|
2872
|
+
statusLabel = "CRASH (500)";
|
|
2873
|
+
crashCount++;
|
|
2874
|
+
} else if (status >= 200 && status < 300) {
|
|
2875
|
+
pillClass = "pill-leak";
|
|
2876
|
+
statusLabel = "LEAK (200)";
|
|
2877
|
+
leakCount++;
|
|
2878
|
+
} else if (status === 400 || status === 422 || status === 401) {
|
|
2879
|
+
pillClass = "pill-pass";
|
|
2880
|
+
statusLabel = `PASS (${status})`;
|
|
2881
|
+
passCount++;
|
|
2882
|
+
} else {
|
|
2883
|
+
pillClass = "pill-leak";
|
|
2884
|
+
statusLabel = `${status}`;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
if (row) {
|
|
2888
|
+
row.cells[0].innerHTML = `<span class="pill ${pillClass}">${statusLabel}</span>`;
|
|
2889
|
+
row.cells[4].textContent = `Expected ${tc.expected} | Got ${status}`;
|
|
2890
|
+
row.cells[5].textContent = `${latency} ms`;
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
document.getElementById("stat-pass").textContent = passCount;
|
|
2894
|
+
document.getElementById("stat-leak").textContent = leakCount;
|
|
2895
|
+
document.getElementById("stat-crash").textContent = crashCount;
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
function runAllBoundaryTests() {
|
|
2900
|
+
const sel = document.getElementById("test-scope-select");
|
|
2901
|
+
if (sel) sel.value = "all";
|
|
2902
|
+
onTestScopeChange();
|
|
2903
|
+
executeBoundaryTests();
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
function inspectTestPayload(idx) {
|
|
2907
|
+
const tc = (window.__activeTestCases && window.__activeTestCases[idx]) || testCases[idx];
|
|
2908
|
+
alert(`Mutated Payload / Target for '${tc.field}':\n\nEndpoint: ${tc.endpoint}\nPayload: ` + JSON.stringify(tc.payload, null, 2));
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function openPytestModal() {
|
|
2912
|
+
const activeCases = getActiveTestCases();
|
|
2913
|
+
const sel = document.getElementById("test-scope-select");
|
|
2914
|
+
const scopeLabel = sel && sel.value !== "all" ? `Scope: ${sel.value.toUpperCase()}` : "Scope: All Endpoints";
|
|
2915
|
+
let code = `# Auto-generated by SpecForge (${scopeLabel})\nimport pytest\nimport httpx\n\nBASE_URL = "http://localhost:8000"\n\n@pytest.mark.parametrize("endpoint,method,payload,expected", [\n`;
|
|
2916
|
+
activeCases.forEach(tc => {
|
|
2917
|
+
code += ` ("${tc.endpoint}", "${tc.method.toUpperCase()}", ${JSON.stringify(tc.payload)}, ${tc.expected}),\n`;
|
|
2918
|
+
});
|
|
2919
|
+
code += `])\ndef test_contract_boundaries(endpoint, method, payload, expected):\n with httpx.Client(base_url=BASE_URL) as client:\n res = client.request(method, endpoint, json=payload)\n assert res.status_code != 500, "Server crashed with 500!"\n assert res.status_code == expected\n`;
|
|
2920
|
+
|
|
2921
|
+
document.getElementById("pytest-code-output").textContent = code;
|
|
2922
|
+
document.getElementById("pytest-modal").style.display = "flex";
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
function closePytestModal() {
|
|
2926
|
+
document.getElementById("pytest-modal").style.display = "none";
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
function copyPytestCode() {
|
|
2930
|
+
const text = document.getElementById("pytest-code-output").textContent;
|
|
2931
|
+
navigator.clipboard.writeText(text);
|
|
2932
|
+
alert("Pytest code copied to clipboard!");
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
function updateCodeSnippet() {
|
|
2936
|
+
const op = operations[selectedOpIndex];
|
|
2937
|
+
if (!op) return;
|
|
2938
|
+
const host = window.location.origin || "http://localhost:8000";
|
|
2939
|
+
const resolvedPath = getResolvedUrl();
|
|
2940
|
+
const fullUrl = `${host}${resolvedPath}`;
|
|
2941
|
+
|
|
2942
|
+
const activeHeaders = requestHeaders.filter(h => h.enabled && h.key.trim());
|
|
2943
|
+
const activeQueries = queryParams.filter(q => q.enabled && q.key.trim());
|
|
2944
|
+
const bodyText = document.getElementById("request-body-input").value;
|
|
2945
|
+
const hasBody = ['post', 'put', 'patch', 'delete'].includes(op.method) && bodyText.trim();
|
|
2946
|
+
|
|
2947
|
+
let snippet = "";
|
|
2948
|
+
if (snippetLang === 'curl') {
|
|
2949
|
+
const parts = [`curl -X ${op.method.toUpperCase()} '${fullUrl}'`];
|
|
2950
|
+
activeHeaders.forEach(h => {
|
|
2951
|
+
parts.push(` -H '${h.key.trim()}: ${h.value.replace(/'/g, "'\\''")}'`);
|
|
2952
|
+
});
|
|
2953
|
+
if (hasBody) {
|
|
2954
|
+
parts.push(` -d '${bodyText.replace(/'/g, "'\\''")}'`);
|
|
2955
|
+
}
|
|
2956
|
+
snippet = parts.join(" \\\n");
|
|
2957
|
+
} else if (snippetLang === 'httpx') {
|
|
2958
|
+
const headerDict = {};
|
|
2959
|
+
activeHeaders.forEach(h => { headerDict[h.key.trim()] = h.value; });
|
|
2960
|
+
const paramDict = {};
|
|
2961
|
+
activeQueries.forEach(q => { paramDict[q.key.trim()] = q.value; });
|
|
2962
|
+
|
|
2963
|
+
snippet = `import httpx\n\n`;
|
|
2964
|
+
if (Object.keys(headerDict).length > 0) {
|
|
2965
|
+
snippet += `headers = ${JSON.stringify(headerDict, null, 4)}\n`;
|
|
2966
|
+
}
|
|
2967
|
+
snippet += `with httpx.Client() as client:\n`;
|
|
2968
|
+
snippet += ` response = client.${op.method}(\n "${fullUrl}",\n`;
|
|
2969
|
+
if (Object.keys(headerDict).length > 0) snippet += ` headers=headers,\n`;
|
|
2970
|
+
if (hasBody) snippet += ` json=${bodyText},\n`;
|
|
2971
|
+
snippet += ` )\n print(response.status_code, response.json())`;
|
|
2972
|
+
} else if (snippetLang === 'requests') {
|
|
2973
|
+
const headerDict = {};
|
|
2974
|
+
activeHeaders.forEach(h => { headerDict[h.key.trim()] = h.value; });
|
|
2975
|
+
|
|
2976
|
+
snippet = `import requests\n\n`;
|
|
2977
|
+
if (Object.keys(headerDict).length > 0) {
|
|
2978
|
+
snippet += `headers = ${JSON.stringify(headerDict, null, 4)}\n`;
|
|
2979
|
+
}
|
|
2980
|
+
snippet += `response = requests.${op.method}(\n "${fullUrl}",\n`;
|
|
2981
|
+
if (Object.keys(headerDict).length > 0) snippet += ` headers=headers,\n`;
|
|
2982
|
+
if (hasBody) snippet += ` json=${bodyText},\n`;
|
|
2983
|
+
snippet += `)\nprint(response.status_code, response.json())`;
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
document.getElementById("code-snippet-box").textContent = snippet;
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function setSnippetLang(lang) {
|
|
2990
|
+
snippetLang = lang;
|
|
2991
|
+
updateCodeSnippet();
|
|
2992
|
+
}
|
|
2993
|
+
</script>
|
|
2994
|
+
</body>
|
|
2995
|
+
</html>
|