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.
@@ -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 &lt;token&gt;</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, "&amp;")
2173
+ .replace(/</g, "&lt;")
2174
+ .replace(/>/g, "&gt;")
2175
+ .replace(/"/g, "&quot;")
2176
+ .replace(/'/g, "&#039;");
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>