qa-deck-backend 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1222 @@
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>QA Deck — Local Engine</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
8
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500&display=swap" rel="stylesheet"/>
9
+ <style>
10
+ :root {
11
+ --green: #1D9E75;
12
+ --green-dim: #0F6E56;
13
+ --green-glow: rgba(29,158,117,0.12);
14
+ --green-line: rgba(29,158,117,0.3);
15
+ --bg: #0c0d0e;
16
+ --bg-1: #111213;
17
+ --bg-2: #161718;
18
+ --bg-3: #1c1d1f;
19
+ --border: rgba(255,255,255,0.07);
20
+ --border-md: rgba(255,255,255,0.12);
21
+ --text-1: #f0f0ee;
22
+ --text-2: #9a9a96;
23
+ --text-3: #5a5a58;
24
+ --red: #E24B4A;
25
+ --amber: #d97706;
26
+ --blue: #378ADD;
27
+ --mono: 'IBM Plex Mono', monospace;
28
+ --sans: 'IBM Plex Sans', sans-serif;
29
+ --radius: 6px;
30
+ }
31
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
32
+ html { font-size: 14px; }
33
+ body {
34
+ background: var(--bg);
35
+ color: var(--text-1);
36
+ font-family: var(--sans);
37
+ font-weight: 300;
38
+ min-height: 100vh;
39
+ line-height: 1.6;
40
+ }
41
+
42
+ /* ── Scanline texture overlay ── */
43
+ body::before {
44
+ content: '';
45
+ position: fixed; inset: 0;
46
+ background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px);
47
+ pointer-events: none;
48
+ z-index: 0;
49
+ }
50
+
51
+ /* ── Layout ── */
52
+ .shell { position: relative; z-index: 1; display: flex; flex-direction: column; min-height: 100vh; }
53
+
54
+ /* ── Topbar ── */
55
+ .topbar {
56
+ display: flex; align-items: center; justify-content: space-between;
57
+ padding: 0 32px; height: 56px;
58
+ border-bottom: 1px solid var(--border);
59
+ background: var(--bg-1);
60
+ position: sticky; top: 0; z-index: 100;
61
+ }
62
+ .logo { display: flex; align-items: center; gap: 10px; }
63
+ .logo-mark {
64
+ width: 28px; height: 28px; border-radius: 7px;
65
+ background: var(--green); display: flex; align-items: center; justify-content: center;
66
+ }
67
+ .logo-name { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--text-1); letter-spacing: -0.3px; }
68
+ .logo-name span { color: var(--green); }
69
+ .topbar-center { display: flex; align-items: center; gap: 6px; }
70
+ .status-pill {
71
+ display: flex; align-items: center; gap: 6px;
72
+ padding: 4px 10px; border-radius: 99px;
73
+ border: 1px solid var(--border); background: var(--bg-2);
74
+ font-family: var(--mono); font-size: 11px; color: var(--text-2);
75
+ }
76
+ .status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); transition: background 0.3s; }
77
+ .status-dot.live { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse-dot 2s ease-in-out infinite; }
78
+ .status-dot.dead { background: var(--red); }
79
+ @keyframes pulse-dot { 0%,100% { opacity:1; } 50% { opacity:0.5; } }
80
+ .topbar-right { display: flex; align-items: center; gap: 10px; }
81
+ .btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: var(--radius); font-size: 12px; font-family: var(--sans); font-weight: 400; cursor: pointer; transition: all 0.15s; border: 1px solid var(--border-md); background: var(--bg-2); color: var(--text-2); text-decoration: none; }
82
+ .btn:hover { background: var(--bg-3); color: var(--text-1); border-color: rgba(255,255,255,0.18); }
83
+ .btn-green { background: var(--green); color: #fff; border-color: var(--green); }
84
+ .btn-green:hover { background: var(--green-dim); border-color: var(--green-dim); color: #fff; }
85
+
86
+ /* ── Stats bar ── */
87
+ .statsbar {
88
+ display: grid; grid-template-columns: repeat(4, 1fr);
89
+ border-bottom: 1px solid var(--border);
90
+ background: var(--bg-1);
91
+ }
92
+ .stat-cell {
93
+ padding: 20px 32px;
94
+ border-right: 1px solid var(--border);
95
+ position: relative; overflow: hidden;
96
+ }
97
+ .stat-cell:last-child { border-right: none; }
98
+ .stat-cell::after {
99
+ content: ''; position: absolute; bottom: 0; left: 32px; right: 32px;
100
+ height: 1px; background: var(--green); transform: scaleX(0); transform-origin: left;
101
+ transition: transform 0.4s ease; transition-delay: var(--d, 0s);
102
+ }
103
+ .stat-cell.loaded::after { transform: scaleX(1); }
104
+ .stat-label { font-family: var(--mono); font-size: 10px; color: var(--text-3); letter-spacing: 0.8px; text-transform: uppercase; margin-bottom: 6px; }
105
+ .stat-value { font-family: var(--mono); font-size: 28px; font-weight: 500; color: var(--text-1); line-height: 1; }
106
+ .stat-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; }
107
+
108
+ /* ── Main content ── */
109
+ .main { flex: 1; padding: 32px; max-width: 1400px; width: 100%; margin: 0 auto; }
110
+
111
+ /* ── Section header ── */
112
+ .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
113
+ .section-title { font-family: var(--mono); font-size: 11px; color: var(--text-3); letter-spacing: 0.8px; text-transform: uppercase; display: flex; align-items: center; gap: 8px; }
114
+ .section-title::before { content: ''; display: inline-block; width: 3px; height: 12px; background: var(--green); border-radius: 99px; }
115
+ .toolbar { display: flex; align-items: center; gap: 8px; }
116
+ .search-wrap { position: relative; }
117
+ .search-input {
118
+ background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius);
119
+ padding: 7px 12px 7px 32px; font-size: 12px; font-family: var(--mono);
120
+ color: var(--text-1); outline: none; width: 220px; transition: border-color 0.15s;
121
+ }
122
+ .search-input::placeholder { color: var(--text-3); }
123
+ .search-input:focus { border-color: var(--green-line); }
124
+ .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-3); pointer-events: none; }
125
+ .filter-select {
126
+ background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius);
127
+ padding: 7px 10px; font-size: 12px; font-family: var(--mono); color: var(--text-2);
128
+ outline: none; cursor: pointer;
129
+ }
130
+ .filter-select:focus { border-color: var(--green-line); }
131
+
132
+ /* ── Projects grid ── */
133
+ .projects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
134
+
135
+ /* ── Project card ── */
136
+ .project-card {
137
+ background: var(--bg-1); border: 1px solid var(--border); border-radius: 10px;
138
+ overflow: hidden; cursor: pointer;
139
+ transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
140
+ animation: card-in 0.3s ease both;
141
+ }
142
+ .project-card:hover { border-color: var(--green-line); transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px var(--green-line); }
143
+ @keyframes card-in { from { opacity:0; transform: translateY(8px); } to { opacity:1; transform: translateY(0); } }
144
+
145
+ .card-top { padding: 18px 20px 14px; }
146
+ .card-type-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
147
+ .page-type-badge {
148
+ font-family: var(--mono); font-size: 10px; padding: 2px 8px; border-radius: 3px;
149
+ background: var(--green-glow); color: var(--green); border: 1px solid var(--green-line);
150
+ text-transform: uppercase; letter-spacing: 0.5px;
151
+ }
152
+ .card-time { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
153
+ .card-name { font-size: 15px; font-weight: 500; color: var(--text-1); margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
154
+ .card-url { font-family: var(--mono); font-size: 11px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
155
+
156
+ .card-divider { height: 1px; background: var(--border); margin: 0 20px; }
157
+
158
+ .card-stats { display: grid; grid-template-columns: repeat(3, 1fr); padding: 14px 20px; gap: 8px; }
159
+ .card-stat-item { text-align: center; }
160
+ .card-stat-num { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--text-1); line-height: 1; }
161
+ .card-stat-lbl { font-size: 10px; color: var(--text-3); margin-top: 3px; }
162
+
163
+ .card-divider2 { height: 1px; background: var(--border); }
164
+
165
+ .card-footer { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; }
166
+ .framework-tags { display: flex; gap: 5px; flex-wrap: wrap; }
167
+ .fw-tag {
168
+ font-family: var(--mono); font-size: 9px; padding: 2px 6px; border-radius: 3px;
169
+ background: var(--bg-3); color: var(--text-2); border: 1px solid var(--border);
170
+ }
171
+ .card-actions { display: flex; gap: 6px; }
172
+ .icon-btn {
173
+ width: 28px; height: 28px; border-radius: var(--radius); border: 1px solid var(--border);
174
+ background: var(--bg-2); color: var(--text-2); display: flex; align-items: center; justify-content: center;
175
+ cursor: pointer; transition: all 0.15s;
176
+ }
177
+ .icon-btn:hover { border-color: var(--border-md); color: var(--text-1); background: var(--bg-3); }
178
+ .icon-btn.danger:hover { border-color: rgba(226,75,74,0.4); color: var(--red); background: rgba(226,75,74,0.1); }
179
+ .icon-btn.export:hover { border-color: var(--green-line); color: var(--green); background: var(--green-glow); }
180
+
181
+ /* ── Priority bars ── */
182
+ .priority-bar { display: flex; gap: 2px; padding: 0 20px 14px; }
183
+ .pri-seg { height: 3px; border-radius: 99px; flex: 1; }
184
+ .pri-high { background: var(--red); }
185
+ .pri-med { background: var(--amber); }
186
+ .pri-low { background: var(--green); }
187
+ .pri-none { background: var(--bg-3); }
188
+
189
+ /* ── Empty state ── */
190
+ .empty-state {
191
+ grid-column: 1/-1; text-align: center; padding: 80px 40px;
192
+ border: 1px dashed var(--border-md); border-radius: 10px; background: var(--bg-1);
193
+ }
194
+ .empty-icon { font-size: 40px; margin-bottom: 16px; filter: grayscale(1) opacity(0.3); }
195
+ .empty-title { font-size: 16px; font-weight: 500; color: var(--text-2); margin-bottom: 8px; }
196
+ .empty-sub { font-size: 13px; color: var(--text-3); max-width: 340px; margin: 0 auto 20px; }
197
+
198
+ /* ── Loading shimmer ── */
199
+ .shimmer-card { background: var(--bg-1); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; height: 220px; position: relative; }
200
+ .shimmer-card::after {
201
+ content: ''; position: absolute; inset: 0;
202
+ background: linear-gradient(90deg, transparent 30%, rgba(255,255,255,0.03) 50%, transparent 70%);
203
+ animation: shimmer 1.5s infinite;
204
+ }
205
+ @keyframes shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
206
+
207
+ /* ── Detail drawer ── */
208
+ .drawer-overlay {
209
+ position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 200;
210
+ opacity: 0; pointer-events: none; transition: opacity 0.25s;
211
+ backdrop-filter: blur(4px);
212
+ }
213
+ .drawer-overlay.open { opacity: 1; pointer-events: all; }
214
+ .drawer {
215
+ position: fixed; right: 0; top: 0; bottom: 0; width: 680px; max-width: 95vw;
216
+ background: var(--bg-1); border-left: 1px solid var(--border);
217
+ z-index: 201; transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
218
+ display: flex; flex-direction: column;
219
+ }
220
+ .drawer.open { transform: translateX(0); }
221
+
222
+ .drawer-header {
223
+ display: flex; align-items: flex-start; justify-content: space-between;
224
+ padding: 24px 28px 20px; border-bottom: 1px solid var(--border); flex-shrink: 0;
225
+ }
226
+ .drawer-title { font-size: 17px; font-weight: 500; color: var(--text-1); margin-bottom: 4px; }
227
+ .drawer-url { font-family: var(--mono); font-size: 11px; color: var(--text-3); }
228
+ .close-btn {
229
+ width: 32px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border);
230
+ background: var(--bg-2); color: var(--text-2); display: flex; align-items: center; justify-content: center;
231
+ cursor: pointer; flex-shrink: 0; margin-left: 12px; transition: all 0.15s; font-size: 16px;
232
+ }
233
+ .close-btn:hover { background: var(--bg-3); color: var(--text-1); }
234
+
235
+ .drawer-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 28px; flex-shrink: 0; }
236
+ .drawer-tab {
237
+ padding: 12px 16px; font-family: var(--mono); font-size: 11px; color: var(--text-3);
238
+ cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
239
+ letter-spacing: 0.5px; text-transform: uppercase; transition: all 0.15s;
240
+ }
241
+ .drawer-tab:hover { color: var(--text-2); }
242
+ .drawer-tab.active { color: var(--green); border-bottom-color: var(--green); }
243
+
244
+ .drawer-body { flex: 1; overflow-y: auto; padding: 24px 28px; }
245
+
246
+ /* ── Test case rows ── */
247
+ .tc-row {
248
+ display: flex; align-items: flex-start; gap: 12px;
249
+ padding: 12px 0; border-bottom: 1px solid var(--border);
250
+ }
251
+ .tc-row:last-child { border-bottom: none; }
252
+ .tc-row-id { font-family: var(--mono); font-size: 10px; color: var(--text-3); min-width: 48px; padding-top: 2px; }
253
+ .tc-row-body { flex: 1; min-width: 0; }
254
+ .tc-row-title { font-size: 13px; color: var(--text-1); margin-bottom: 3px; }
255
+ .tc-row-meta { display: flex; gap: 6px; }
256
+ .tc-pri { font-family: var(--mono); font-size: 9px; padding: 1px 6px; border-radius: 3px; }
257
+ .tc-pri-high { background: rgba(226,75,74,0.1); color: #f87171; border: 1px solid rgba(226,75,74,0.2); }
258
+ .tc-pri-medium { background: rgba(217,119,6,0.1); color: #fbbf24; border: 1px solid rgba(217,119,6,0.2); }
259
+ .tc-pri-low { background: var(--green-glow); color: var(--green); border: 1px solid var(--green-line); }
260
+ .tc-cat { font-family: var(--mono); font-size: 9px; padding: 1px 6px; border-radius: 3px; background: var(--bg-3); color: var(--text-3); border: 1px solid var(--border); }
261
+
262
+ /* ── Script viewer ── */
263
+ .script-file-tabs { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
264
+ .script-file-tab {
265
+ font-family: var(--mono); font-size: 10px; padding: 4px 10px; border-radius: 4px;
266
+ border: 1px solid var(--border); background: var(--bg-2); color: var(--text-3);
267
+ cursor: pointer; transition: all 0.15s;
268
+ }
269
+ .script-file-tab:hover { color: var(--text-2); border-color: var(--border-md); }
270
+ .script-file-tab.active { background: var(--green-glow); color: var(--green); border-color: var(--green-line); }
271
+
272
+ .code-block {
273
+ background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
274
+ overflow: hidden;
275
+ }
276
+ .code-block-header {
277
+ display: flex; align-items: center; justify-content: space-between;
278
+ padding: 8px 14px; border-bottom: 1px solid var(--border); background: var(--bg-2);
279
+ }
280
+ .code-block-filename { font-family: var(--mono); font-size: 11px; color: var(--text-2); }
281
+ .copy-code-btn {
282
+ font-family: var(--mono); font-size: 10px; padding: 3px 9px; border-radius: 3px;
283
+ border: 1px solid var(--border); background: var(--bg-3); color: var(--text-3);
284
+ cursor: pointer; transition: all 0.15s;
285
+ }
286
+ .copy-code-btn:hover { color: var(--green); border-color: var(--green-line); }
287
+ .code-pre {
288
+ padding: 16px; font-family: var(--mono); font-size: 11px; color: var(--text-2);
289
+ line-height: 1.7; overflow-x: auto; white-space: pre; max-height: 420px; overflow-y: auto;
290
+ }
291
+
292
+ /* ── Re-export panel ── */
293
+ .export-section { margin-bottom: 24px; }
294
+ .export-label { font-family: var(--mono); font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 10px; }
295
+ .fw-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 16px; }
296
+ .fw-option {
297
+ padding: 10px 8px; border-radius: var(--radius); border: 1px solid var(--border);
298
+ background: var(--bg-2); color: var(--text-2); text-align: center; cursor: pointer;
299
+ font-size: 11px; transition: all 0.15s; line-height: 1.4;
300
+ }
301
+ .fw-option:hover { border-color: var(--border-md); color: var(--text-1); }
302
+ .fw-option.selected { border-color: var(--green-line); background: var(--green-glow); color: var(--green); }
303
+ .fw-option strong { display: block; font-size: 12px; font-family: var(--mono); margin-bottom: 1px; }
304
+
305
+ .regen-notice { font-size: 12px; color: var(--text-3); padding: 10px 14px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-2); margin-bottom: 16px; line-height: 1.6; }
306
+ .regen-notice strong { color: var(--text-2); }
307
+
308
+ .drawer-footer {
309
+ padding: 16px 28px; border-top: 1px solid var(--border); flex-shrink: 0;
310
+ display: flex; gap: 10px; background: var(--bg-1);
311
+ }
312
+ .drawer-footer .btn { flex: 1; justify-content: center; }
313
+
314
+ /* ── Confirm dialog ── */
315
+ .confirm-dialog {
316
+ position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center;
317
+ background: rgba(0,0,0,0.8); opacity: 0; pointer-events: none; transition: opacity 0.2s;
318
+ }
319
+ .confirm-dialog.open { opacity: 1; pointer-events: all; }
320
+ .confirm-box {
321
+ background: var(--bg-2); border: 1px solid var(--border-md); border-radius: 10px;
322
+ padding: 28px; width: 360px; text-align: center;
323
+ }
324
+ .confirm-icon { font-size: 32px; margin-bottom: 12px; }
325
+ .confirm-title { font-size: 16px; font-weight: 500; margin-bottom: 6px; }
326
+ .confirm-sub { font-size: 13px; color: var(--text-2); margin-bottom: 24px; line-height: 1.5; }
327
+ .confirm-btns { display: flex; gap: 10px; }
328
+ .btn-danger { background: rgba(226,75,74,0.15); color: var(--red); border: 1px solid rgba(226,75,74,0.3); }
329
+ .btn-danger:hover { background: rgba(226,75,74,0.25); color: #f87171; }
330
+
331
+ /* ── Toast ── */
332
+ .toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 400; display: flex; flex-direction: column; gap: 8px; }
333
+ .toast {
334
+ display: flex; align-items: center; gap: 10px;
335
+ padding: 12px 16px; border-radius: var(--radius);
336
+ font-size: 13px; min-width: 260px; max-width: 380px;
337
+ border: 1px solid; animation: toast-in 0.25s ease;
338
+ font-family: var(--sans);
339
+ }
340
+ @keyframes toast-in { from { opacity:0; transform: translateX(20px); } to { opacity:1; transform: translateX(0); } }
341
+ .toast-success { background: rgba(29,158,117,0.12); border-color: var(--green-line); color: var(--green); }
342
+ .toast-error { background: rgba(226,75,74,0.12); border-color: rgba(226,75,74,0.3); color: #f87171; }
343
+ .toast-info { background: rgba(55,138,221,0.12); border-color: rgba(55,138,221,0.3); color: var(--blue); }
344
+
345
+ /* ── Scrollbar ── */
346
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
347
+ ::-webkit-scrollbar-track { background: transparent; }
348
+ ::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 99px; }
349
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
350
+
351
+ /* ── Responsive ── */
352
+ @media (max-width: 700px) {
353
+ .statsbar { grid-template-columns: repeat(2,1fr); }
354
+ .main { padding: 20px 16px; }
355
+ .topbar { padding: 0 16px; }
356
+ .topbar-center { display: none; }
357
+ .drawer { width: 100%; }
358
+ }
359
+ </style>
360
+ </head>
361
+ <body>
362
+ <div class="shell">
363
+
364
+ <!-- Topbar -->
365
+ <header class="topbar">
366
+ <div class="logo">
367
+ <div class="logo-mark">
368
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
369
+ <path d="M3 8h10M3 5h6M3 11h8" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
370
+ </svg>
371
+ </div>
372
+ <span class="logo-name">QA <span>Deck</span> / local engine</span>
373
+ </div>
374
+ <div class="topbar-center">
375
+ <div class="status-pill">
376
+ <div class="status-dot" id="status-dot"></div>
377
+ <span id="status-text">connecting...</span>
378
+ </div>
379
+ </div>
380
+ <div class="topbar-right">
381
+ <a href="/recorder.html" class="btn btn-green">
382
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="6" cy="6" r="4.5" fill="currentColor" opacity=".3"/><circle cx="6" cy="6" r="3" fill="currentColor"/></svg>
383
+ Open Capture Workspace
384
+ </a>
385
+ <a href="http://localhost:3747/api/health" target="_blank" class="btn">
386
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.2"/><path d="M6 4v2l1.5 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
387
+ API Health
388
+ </a>
389
+ <button class="btn btn-green" onclick="refreshProjects()">
390
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6A4 4 0 116 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10 2v4h-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
391
+ Refresh
392
+ </button>
393
+ </div>
394
+ </header>
395
+
396
+ <!-- Stats bar -->
397
+ <div class="statsbar" id="statsbar">
398
+ <div class="stat-cell" style="--d:0.1s">
399
+ <div class="stat-label">Total projects</div>
400
+ <div class="stat-value" id="stat-projects">—</div>
401
+ <div class="stat-sub">saved to disk</div>
402
+ </div>
403
+ <div class="stat-cell" style="--d:0.2s">
404
+ <div class="stat-label">Test cases</div>
405
+ <div class="stat-value" id="stat-tcs">—</div>
406
+ <div class="stat-sub">across all projects</div>
407
+ </div>
408
+ <div class="stat-cell" style="--d:0.3s">
409
+ <div class="stat-label">Pages scanned</div>
410
+ <div class="stat-value" id="stat-pages">—</div>
411
+ <div class="stat-sub">unique URLs</div>
412
+ </div>
413
+ <div class="stat-cell" style="--d:0.4s">
414
+ <div class="stat-label">Last activity</div>
415
+ <div class="stat-value" id="stat-last" style="font-size:16px;padding-top:6px">—</div>
416
+ <div class="stat-sub" id="stat-last-sub">—</div>
417
+ </div>
418
+ </div>
419
+
420
+ <!-- Main -->
421
+ <main class="main">
422
+ <div class="section-header">
423
+ <div class="section-title">Local project cache</div>
424
+ <div class="toolbar">
425
+ <div class="search-wrap">
426
+ <svg class="search-icon" width="13" height="13" viewBox="0 0 13 13" fill="none">
427
+ <circle cx="5.5" cy="5.5" r="4.5" stroke="currentColor" stroke-width="1.2"/>
428
+ <path d="M9.5 9.5L12 12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
429
+ </svg>
430
+ <input class="search-input" id="search-input" type="text" placeholder="search projects..." oninput="filterProjects()"/>
431
+ </div>
432
+ <select class="filter-select" id="type-filter" onchange="filterProjects()">
433
+ <option value="">all types</option>
434
+ <option value="login">login</option>
435
+ <option value="dashboard">dashboard</option>
436
+ <option value="registration">registration</option>
437
+ <option value="checkout">checkout</option>
438
+ <option value="search">search</option>
439
+ <option value="settings">settings</option>
440
+ <option value="form">form</option>
441
+ <option value="general">general</option>
442
+ </select>
443
+ <select class="filter-select" id="sort-filter" onchange="filterProjects()">
444
+ <option value="newest">newest first</option>
445
+ <option value="oldest">oldest first</option>
446
+ <option value="name">by name</option>
447
+ <option value="tcs">most test cases</option>
448
+ </select>
449
+ </div>
450
+ </div>
451
+
452
+ <div class="projects-grid" id="projects-grid">
453
+ <!-- shimmer placeholders -->
454
+ <div class="shimmer-card"></div>
455
+ <div class="shimmer-card"></div>
456
+ <div class="shimmer-card"></div>
457
+ </div>
458
+ </main>
459
+ </div>
460
+
461
+ <!-- Detail drawer -->
462
+ <div class="drawer-overlay" id="drawer-overlay" onclick="closeDrawer()"></div>
463
+ <div class="drawer" id="drawer">
464
+ <div class="drawer-header">
465
+ <div>
466
+ <div class="drawer-title" id="drawer-title">—</div>
467
+ <div class="drawer-url" id="drawer-url">—</div>
468
+ </div>
469
+ <button class="close-btn" onclick="closeDrawer()">×</button>
470
+ </div>
471
+ <div class="drawer-tabs">
472
+ <div class="drawer-tab active" data-tab="testcases" onclick="switchDrawerTab('testcases',this)">Test cases</div>
473
+ <div class="drawer-tab" data-tab="scripts" onclick="switchDrawerTab('scripts',this)">Scripts</div>
474
+ <div class="drawer-tab" data-tab="cicd" onclick="switchDrawerTab('cicd',this)">CI/CD</div>
475
+ <div class="drawer-tab" data-tab="export" onclick="switchDrawerTab('export',this)">Re-export</div>
476
+ </div>
477
+ <div class="drawer-body" id="drawer-body"><!-- populated --></div>
478
+ <div class="drawer-footer">
479
+ <button class="btn" onclick="closeDrawer()">Close</button>
480
+ <button class="btn btn-green" id="drawer-dl-btn" onclick="downloadProject()">
481
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M6 1v7M3 5.5l3 3 3-3M2 10h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
482
+ Download ZIP
483
+ </button>
484
+ </div>
485
+ </div>
486
+
487
+ <!-- Confirm dialog -->
488
+ <div class="confirm-dialog" id="confirm-dialog">
489
+ <div class="confirm-box">
490
+ <div class="confirm-icon">🗑</div>
491
+ <div class="confirm-title">Delete project?</div>
492
+ <div class="confirm-sub" id="confirm-sub">This will permanently remove this project and all its test cases.</div>
493
+ <div class="confirm-btns">
494
+ <button class="btn" style="flex:1;justify-content:center" onclick="closeConfirm()">Cancel</button>
495
+ <button class="btn btn-danger" style="flex:1;justify-content:center" id="confirm-ok-btn">Delete</button>
496
+ </div>
497
+ </div>
498
+ </div>
499
+
500
+ <!-- Toast container -->
501
+ <div class="toast-container" id="toast-container"></div>
502
+
503
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
504
+ <script>
505
+ const API = 'http://localhost:3747';
506
+ let allProjects = [];
507
+ let currentProject = null;
508
+ let currentDrawerTab = 'testcases';
509
+ let currentScriptFile = 'base';
510
+ let selectedFramework = 'selenium-python';
511
+ let pendingDeleteId = null;
512
+ let apiKey = localStorage.getItem('qa_api_key') || '';
513
+
514
+ // ── Boot ──────────────────────────────────────────────────────────────
515
+ window.addEventListener('DOMContentLoaded', async () => {
516
+ await checkServer();
517
+ await loadProjects();
518
+ });
519
+
520
+ async function checkServer() {
521
+ try {
522
+ const res = await fetch(`${API}/api/health`);
523
+ const data = await res.json();
524
+ dot('live');
525
+ document.getElementById('status-text').textContent = `v${data.version} · ${data.projects} projects`;
526
+ } catch {
527
+ dot('dead');
528
+ document.getElementById('status-text').textContent = 'server offline';
529
+ showToast('Backend not running. Start with: node server.js', 'error');
530
+ }
531
+ }
532
+
533
+ function dot(state) {
534
+ const d = document.getElementById('status-dot');
535
+ d.className = 'status-dot ' + state;
536
+ }
537
+
538
+ // ── Load projects ─────────────────────────────────────────────────────
539
+ async function loadProjects() {
540
+ try {
541
+ const res = await fetch(`${API}/api/projects`);
542
+ const data = await res.json();
543
+ if (!data.success) throw new Error(data.error);
544
+ allProjects = data.projects || [];
545
+ updateStats();
546
+ renderGrid(allProjects);
547
+ } catch (err) {
548
+ document.getElementById('projects-grid').innerHTML = `
549
+ <div class="empty-state" style="grid-column:1/-1">
550
+ <div class="empty-icon">⚠</div>
551
+ <div class="empty-title">Could not load projects</div>
552
+ <div class="empty-sub">${err.message}</div>
553
+ <button class="btn btn-green" onclick="loadProjects()">Try again</button>
554
+ </div>`;
555
+ }
556
+ }
557
+
558
+ async function refreshProjects() {
559
+ document.getElementById('projects-grid').innerHTML = '<div class="shimmer-card"></div>'.repeat(3);
560
+ await checkServer();
561
+ await loadProjects();
562
+ }
563
+
564
+ // ── Stats ─────────────────────────────────────────────────────────────
565
+ function updateStats() {
566
+ const totalTCs = allProjects.reduce((n, p) => n + (p.testCaseCount || 0), 0);
567
+ const totalPages = allProjects.reduce((n, p) => n + (p.pageCount || 1), 0);
568
+ const lastProject = allProjects.sort((a,b) => new Date(b.savedAt) - new Date(a.savedAt))[0];
569
+
570
+ document.getElementById('stat-projects').textContent = allProjects.length;
571
+ document.getElementById('stat-tcs').textContent = totalTCs;
572
+ document.getElementById('stat-pages').textContent = totalPages;
573
+
574
+ if (lastProject) {
575
+ const d = new Date(lastProject.savedAt);
576
+ document.getElementById('stat-last').textContent = d.toLocaleDateString('en-GB', { day:'numeric', month:'short' });
577
+ document.getElementById('stat-last-sub').textContent = timeAgo(lastProject.savedAt);
578
+ }
579
+
580
+ // Animate bottom lines
581
+ setTimeout(() => document.querySelectorAll('.stat-cell').forEach(c => c.classList.add('loaded')), 100);
582
+ }
583
+
584
+ // ── Render grid ───────────────────────────────────────────────────────
585
+ function renderGrid(projects) {
586
+ const grid = document.getElementById('projects-grid');
587
+
588
+ if (!projects.length) {
589
+ grid.innerHTML = `
590
+ <div class="empty-state">
591
+ <div class="empty-icon">📂</div>
592
+ <div class="empty-title">No projects yet</div>
593
+ <div class="empty-sub">This local engine stores backend artifacts only. Use qadeck.com for your main project dashboard and open the extension when you need to capture new work.</div>
594
+ </div>`;
595
+ return;
596
+ }
597
+
598
+ grid.innerHTML = projects.map((p, i) => buildCard(p, i)).join('');
599
+ }
600
+
601
+ function buildCard(p, i) {
602
+ const pageType = p.pageType || p.pages?.[0]?.url?.split('/').filter(Boolean).pop() || 'page';
603
+ const tcs = p.testCaseCount || p.pages?.reduce((n, pg) => n + (pg.testCases?.length || 0), 0) || 0;
604
+ const highCount = p.highPriority || 0;
605
+ const medCount = p.medPriority || 0;
606
+ const lowCount = p.lowPriority || tcs;
607
+ const frameworks = p.frameworks || (p.pages?.[0]?.scripts ? Object.keys(p.pages[0].scripts).slice(0,2) : ['selenium-python']);
608
+ const url = p.url || p.pages?.[0]?.url || '—';
609
+ const saved = timeAgo(p.savedAt);
610
+ const delay = (i * 0.05).toFixed(2);
611
+
612
+ // Priority bar widths
613
+ const total = highCount + medCount + lowCount || 1;
614
+ const highW = Math.round(highCount/total*100);
615
+ const medW = Math.round(medCount/total*100);
616
+ const lowW = Math.round(lowCount/total*100);
617
+ const noneW = 100 - highW - medW - lowW;
618
+
619
+ return `
620
+ <div class="project-card" style="animation-delay:${delay}s" onclick="openProject('${p.id}')">
621
+ <div class="card-top">
622
+ <div class="card-type-row">
623
+ <span class="page-type-badge">${pageType}</span>
624
+ <span class="card-time">${saved}</span>
625
+ </div>
626
+ <div class="card-name">${p.name || url}</div>
627
+ <div class="card-url">${url}</div>
628
+ </div>
629
+ <div class="card-divider"></div>
630
+ <div class="card-stats">
631
+ <div class="card-stat-item">
632
+ <div class="card-stat-num">${tcs}</div>
633
+ <div class="card-stat-lbl">test cases</div>
634
+ </div>
635
+ <div class="card-stat-item">
636
+ <div class="card-stat-num">${p.pageCount || 1}</div>
637
+ <div class="card-stat-lbl">pages</div>
638
+ </div>
639
+ <div class="card-stat-item">
640
+ <div class="card-stat-num">${highCount}</div>
641
+ <div class="card-stat-lbl">high pri</div>
642
+ </div>
643
+ </div>
644
+ <div class="priority-bar">
645
+ <div class="pri-seg pri-high" style="flex:${highW||0.5}"></div>
646
+ <div class="pri-seg pri-med" style="flex:${medW||0.5}"></div>
647
+ <div class="pri-seg pri-low" style="flex:${lowW||0.5}"></div>
648
+ ${noneW > 0 ? `<div class="pri-seg pri-none" style="flex:${noneW}"></div>` : ''}
649
+ </div>
650
+ <div class="card-divider2"></div>
651
+ <div class="card-footer">
652
+ <div class="framework-tags">
653
+ ${(frameworks||['selenium-python']).map(fw => `<span class="fw-tag">${fwShort(fw)}</span>`).join('')}
654
+ </div>
655
+ <div class="card-actions" onclick="event.stopPropagation()">
656
+ <button class="icon-btn export" title="Download ZIP" onclick="quickDownload('${p.id}')">
657
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M6.5 1v8M4 6.5l2.5 2.5 2.5-2.5M2 11h9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
658
+ </button>
659
+ <button class="icon-btn danger" title="Delete" onclick="confirmDelete('${p.id}', '${(p.name||'this project').replace(/'/g,"\\'")}')">
660
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M2 3.5h9M5 3.5V2h3v1.5M5.5 6v4M7.5 6v4M3 3.5l.5 7h6l.5-7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
661
+ </button>
662
+ </div>
663
+ </div>
664
+ </div>`;
665
+ }
666
+
667
+ // ── Filter / sort ─────────────────────────────────────────────────────
668
+ function filterProjects() {
669
+ const query = document.getElementById('search-input').value.toLowerCase();
670
+ const typeFilter = document.getElementById('type-filter').value;
671
+ const sort = document.getElementById('sort-filter').value;
672
+
673
+ let filtered = allProjects.filter(p => {
674
+ const matchQ = !query || (p.name||'').toLowerCase().includes(query) || (p.url||'').toLowerCase().includes(query);
675
+ const matchT = !typeFilter || (p.pageType||'').includes(typeFilter);
676
+ return matchQ && matchT;
677
+ });
678
+
679
+ if (sort === 'newest') filtered.sort((a,b) => new Date(b.savedAt) - new Date(a.savedAt));
680
+ else if (sort === 'oldest') filtered.sort((a,b) => new Date(a.savedAt) - new Date(b.savedAt));
681
+ else if (sort === 'name') filtered.sort((a,b) => (a.name||'').localeCompare(b.name||''));
682
+ else if (sort === 'tcs') filtered.sort((a,b) => (b.testCaseCount||0) - (a.testCaseCount||0));
683
+
684
+ renderGrid(filtered);
685
+ }
686
+
687
+ // ── Open project drawer ───────────────────────────────────────────────
688
+ async function openProject(id) {
689
+ try {
690
+ const res = await fetch(`${API}/api/projects/${id}`);
691
+ const data = await res.json();
692
+ if (!data.success) throw new Error(data.error);
693
+ currentProject = data.project;
694
+
695
+ document.getElementById('drawer-title').textContent = currentProject.name || currentProject.url;
696
+ document.getElementById('drawer-url').textContent = currentProject.url || '—';
697
+ document.getElementById('drawer-overlay').classList.add('open');
698
+ document.getElementById('drawer').classList.add('open');
699
+
700
+ switchDrawerTab('testcases', document.querySelector('.drawer-tab[data-tab="testcases"]'));
701
+ } catch (err) {
702
+ showToast('Failed to load project: ' + err.message, 'error');
703
+ }
704
+ }
705
+
706
+ function closeDrawer() {
707
+ document.getElementById('drawer-overlay').classList.remove('open');
708
+ document.getElementById('drawer').classList.remove('open');
709
+ currentProject = null;
710
+ }
711
+
712
+ // ── Drawer tabs ───────────────────────────────────────────────────────
713
+ function switchDrawerTab(tab, el) {
714
+ currentDrawerTab = tab;
715
+ document.querySelectorAll('.drawer-tab').forEach(t => t.classList.remove('active'));
716
+ if (el) el.classList.add('active');
717
+ renderDrawerBody();
718
+ }
719
+
720
+ function renderDrawerBody() {
721
+ const body = document.getElementById('drawer-body');
722
+ if (!currentProject) return;
723
+
724
+ // Flatten all test cases across pages
725
+ const allTCs = (currentProject.pages || []).flatMap(pg => pg.testCases || []);
726
+ // Get scripts from first page that has them
727
+ const scriptsPage = (currentProject.pages || []).find(pg => pg.scripts);
728
+ const scripts = scriptsPage?.scripts || null;
729
+
730
+ if (currentDrawerTab === 'testcases') {
731
+ if (!allTCs.length) {
732
+ body.innerHTML = `<div style="text-align:center;color:var(--text-3);padding:40px;font-size:13px">No test cases found in this project.</div>`;
733
+ return;
734
+ }
735
+ body.innerHTML = `
736
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
737
+ <span style="font-family:var(--mono);font-size:11px;color:var(--text-3)">${allTCs.length} test cases</span>
738
+ <span style="font-family:var(--mono);font-size:11px;color:var(--green)">${allTCs.filter(t=>t.approved!==false).length} approved</span>
739
+ </div>
740
+ ${allTCs.map(tc => `
741
+ <div class="tc-row">
742
+ <div class="tc-row-id">${tc.id}</div>
743
+ <div class="tc-row-body">
744
+ <div class="tc-row-title">${tc.title}</div>
745
+ <div class="tc-row-meta">
746
+ <span class="tc-pri tc-pri-${tc.priority}">${tc.priority}</span>
747
+ <span class="tc-cat">${tc.category||'functional'}</span>
748
+ </div>
749
+ </div>
750
+ </div>`).join('')}`;
751
+ }
752
+
753
+ else if (currentDrawerTab === 'scripts') {
754
+ if (!scripts) {
755
+ body.innerHTML = `<div style="text-align:center;color:var(--text-3);padding:40px;font-size:13px">No scripts generated for this project yet.<br><br>Use the extension to generate scripts, or use the Re-export tab.</div>`;
756
+ return;
757
+ }
758
+ const fileKeys = Object.keys(scripts);
759
+ currentScriptFile = currentScriptFile || fileKeys[0];
760
+
761
+ body.innerHTML = `
762
+ <div class="script-file-tabs">
763
+ ${fileKeys.map(k => `<div class="script-file-tab ${k===currentScriptFile?'active':''}" onclick="selectScriptFile('${k}',this)">${scripts[k].filename}</div>`).join('')}
764
+ </div>
765
+ <div class="code-block">
766
+ <div class="code-block-header">
767
+ <span class="code-block-filename">${scripts[currentScriptFile]?.filename||'—'}</span>
768
+ <button class="copy-code-btn" onclick="copyCode()">Copy</button>
769
+ </div>
770
+ <pre class="code-pre" id="code-pre-content">${escHtml(scripts[currentScriptFile]?.content||'')}</pre>
771
+ </div>`;
772
+ }
773
+
774
+ else if (currentDrawerTab === 'cicd') {
775
+ renderCICDTab(body);
776
+ }
777
+
778
+ else if (currentDrawerTab === 'export') {
779
+ body.innerHTML = `
780
+ <div class="export-section">
781
+ <div class="export-label">Target framework</div>
782
+ <div class="fw-grid">
783
+ ${[['selenium-python','Selenium','Python'],['selenium-java','Selenium','Java'],['playwright-python','Playwright','Python'],['playwright-typescript','Playwright','TypeScript']].map(([val,fw,lang]) => `
784
+ <div class="fw-option ${val===selectedFramework?'selected':''}" onclick="selectFw('${val}',this)">
785
+ <strong>${fw}</strong>${lang}
786
+ </div>`).join('')}
787
+ </div>
788
+ </div>
789
+
790
+ <div class="export-section">
791
+ <div class="export-label">API key</div>
792
+ <div style="display:flex;gap:8px">
793
+ <input type="password" id="api-key-field" placeholder="sk-ant-..." value="${apiKey}"
794
+ style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:8px 12px;font-family:var(--mono);font-size:12px;color:var(--text-1);outline:none;"
795
+ oninput="apiKey=this.value;localStorage.setItem('qa_api_key',this.value)"
796
+ />
797
+ </div>
798
+ <div style="font-size:11px;color:var(--text-3);margin-top:5px">Stored locally in your browser. Never sent to any server except Anthropic.</div>
799
+ </div>
800
+
801
+ <div class="regen-notice">
802
+ <strong>Re-generate scripts</strong> will call Claude with the existing test cases and produce fresh scripts in the selected framework. This uses your API key and takes 15–30 seconds.
803
+ </div>`;
804
+ }
805
+ }
806
+
807
+ function selectScriptFile(key, el) {
808
+ currentScriptFile = key;
809
+ document.querySelectorAll('.script-file-tab').forEach(t => t.classList.remove('active'));
810
+ el.classList.add('active');
811
+ const scripts = (currentProject.pages||[]).find(pg=>pg.scripts)?.scripts;
812
+ if (scripts?.[key]) {
813
+ document.querySelector('.code-block-filename').textContent = scripts[key].filename;
814
+ document.getElementById('code-pre-content').textContent = scripts[key].content;
815
+ }
816
+ }
817
+
818
+ function selectFw(val, el) {
819
+ selectedFramework = val;
820
+ document.querySelectorAll('.fw-option').forEach(o => o.classList.remove('selected'));
821
+ el.classList.add('selected');
822
+ }
823
+
824
+ async function copyCode() {
825
+ const pre = document.getElementById('code-pre-content');
826
+ if (!pre) return;
827
+ try {
828
+ await navigator.clipboard.writeText(pre.textContent);
829
+ const btn = document.querySelector('.copy-code-btn');
830
+ btn.textContent = 'Copied!';
831
+ btn.style.color = 'var(--green)';
832
+ setTimeout(() => { btn.textContent = 'Copy'; btn.style.color = ''; }, 1500);
833
+ } catch { showToast('Copy failed', 'error'); }
834
+ }
835
+
836
+ // ── Download ZIP ──────────────────────────────────────────────────────
837
+ async function downloadProject() {
838
+ if (!currentProject) return;
839
+
840
+ if (currentDrawerTab === 'export') {
841
+ await regenAndDownload();
842
+ } else {
843
+ await buildAndDownloadZip(currentProject);
844
+ }
845
+ }
846
+
847
+ async function quickDownload(id) {
848
+ try {
849
+ const res = await fetch(`${API}/api/projects/${id}`);
850
+ const data = await res.json();
851
+ if (!data.success) throw new Error(data.error);
852
+ await buildAndDownloadZip(data.project);
853
+ } catch (err) {
854
+ showToast('Download failed: ' + err.message, 'error');
855
+ }
856
+ }
857
+
858
+ async function buildAndDownloadZip(project) {
859
+ const scriptsPage = (project.pages||[]).find(pg => pg.scripts);
860
+ if (!scriptsPage?.scripts) {
861
+ showToast('No scripts found — use Re-export tab to generate them', 'info');
862
+ return;
863
+ }
864
+
865
+ showToast('Building ZIP...', 'info');
866
+
867
+ try {
868
+ const zip = new JSZip();
869
+ const scripts = scriptsPage.scripts;
870
+ const pageType = project.pageType || 'page';
871
+ const fileKeys = ['base','pageObject','testData','tests','config'];
872
+
873
+ fileKeys.forEach(key => {
874
+ const f = scripts[key];
875
+ if (!f) return;
876
+ if (key === 'pageObject') zip.file(`pages/${f.filename}`, f.content);
877
+ else if (key === 'tests') zip.file(`tests/${f.filename}`, f.content);
878
+ else zip.file(f.filename, f.content);
879
+ });
880
+
881
+ // Add test cases as JSON reference
882
+ const allTCs = (project.pages||[]).flatMap(pg => pg.testCases||[]);
883
+ if (allTCs.length) zip.file('test_cases.json', JSON.stringify(allTCs, null, 2));
884
+
885
+ // Add metadata
886
+ zip.file('README.md', buildReadme(project, scriptsPage.framework||selectedFramework));
887
+
888
+ const ext = selectedFramework.includes('java') ? 'java' : selectedFramework.includes('typescript') ? 'ts' : 'py';
889
+ if (ext === 'ts') zip.file('package.json', JSON.stringify({ name: `qa-${pageType}`, scripts: { test: 'playwright test' }, devDependencies: { '@playwright/test': '^1.40.0' } }, null, 2));
890
+ else if (!selectedFramework.includes('java')) zip.file('requirements.txt', selectedFramework.includes('playwright') ? 'pytest==7.4.3\npytest-playwright==0.4.3\nplaywright==1.40.0\n' : 'selenium==4.15.2\npytest==7.4.3\nwebdriver-manager==4.0.1\n');
891
+
892
+ const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
893
+ triggerDownload(blob, `qa_deck_${pageType}_${Date.now()}.zip`);
894
+ showToast('ZIP downloaded!', 'success');
895
+ } catch (err) {
896
+ showToast('ZIP error: ' + err.message, 'error');
897
+ }
898
+ }
899
+
900
+ async function regenAndDownload() {
901
+ if (!apiKey) { showToast('Enter your Anthropic API key first', 'error'); return; }
902
+ if (!currentProject) return;
903
+
904
+ const allTCs = (currentProject.pages||[]).flatMap(pg => pg.testCases||[]).filter(t => t.approved !== false);
905
+ if (!allTCs.length) { showToast('No approved test cases to generate scripts from', 'error'); return; }
906
+
907
+ const btn = document.getElementById('drawer-dl-btn');
908
+ btn.disabled = true;
909
+ btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="animation:spin 0.8s linear infinite"><path d="M10 6A4 4 0 116 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> Generating...';
910
+ showToast('Calling Claude API — this takes 15–30s...', 'info');
911
+
912
+ try {
913
+ const res = await fetch(`${API}/api/generate-script`, {
914
+ method: 'POST',
915
+ headers: { 'Content-Type': 'application/json' },
916
+ body: JSON.stringify({ testCases: allTCs, pageData: currentProject.pages?.[0], framework: selectedFramework, apiKey }),
917
+ });
918
+ const data = await res.json();
919
+ if (!data.success) throw new Error(data.error);
920
+
921
+ // Patch project with new scripts
922
+ if (currentProject.pages?.[0]) {
923
+ currentProject.pages[0].scripts = data.scripts;
924
+ currentProject.pages[0].framework = selectedFramework;
925
+ }
926
+
927
+ // Save updated project
928
+ await fetch(`${API}/api/save-project`, {
929
+ method: 'POST',
930
+ headers: { 'Content-Type': 'application/json' },
931
+ body: JSON.stringify({ project: currentProject }),
932
+ });
933
+
934
+ await buildAndDownloadZip(currentProject);
935
+ showToast('Scripts regenerated and downloaded!', 'success');
936
+ } catch (err) {
937
+ showToast('Regen failed: ' + err.message, 'error');
938
+ } finally {
939
+ btn.disabled = false;
940
+ btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M6 1v7M3 5.5l3 3 3-3M2 10h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> Download ZIP';
941
+ }
942
+ }
943
+
944
+ // ── Delete ────────────────────────────────────────────────────────────
945
+ function confirmDelete(id, name) {
946
+ pendingDeleteId = id;
947
+ document.getElementById('confirm-sub').textContent = `"${name}" and all its test cases will be permanently deleted.`;
948
+ document.getElementById('confirm-dialog').classList.add('open');
949
+ document.getElementById('confirm-ok-btn').onclick = () => deleteProject(id);
950
+ }
951
+
952
+ function closeConfirm() {
953
+ document.getElementById('confirm-dialog').classList.remove('open');
954
+ pendingDeleteId = null;
955
+ }
956
+
957
+ async function deleteProject(id) {
958
+ closeConfirm();
959
+ try {
960
+ const res = await fetch(`${API}/api/projects/${id}`, { method: 'DELETE' });
961
+ const data = await res.json();
962
+ if (!data.success) throw new Error(data.error || 'Delete failed');
963
+ allProjects = allProjects.filter(p => p.id !== id);
964
+ updateStats();
965
+ filterProjects();
966
+ showToast('Project deleted', 'success');
967
+ } catch (err) {
968
+ showToast('Delete failed: ' + err.message, 'error');
969
+ }
970
+ }
971
+
972
+ // ── Helpers ───────────────────────────────────────────────────────────
973
+ function buildReadme(project, framework) {
974
+ const allTCs = (project.pages||[]).flatMap(pg => pg.testCases||[]);
975
+ const hasCICD = !!cicdConfigs;
976
+ return `# QA Deck — ${project.name || project.url}
977
+
978
+ **URL:** ${project.url}
979
+ **Framework:** ${framework}
980
+ **Test cases:** ${allTCs.length}
981
+ **Exported:** ${new Date().toISOString()}
982
+
983
+ ## Project Structure
984
+ \`\`\`
985
+ pages/ ← Page Object Models
986
+ tests/ ← Test files
987
+ test_cases.json ← All test cases (reference)
988
+ ${hasCICD ? '.github/workflows/qa-tests.yml ← GitHub Actions\nJenkinsfile ← Jenkins pipeline\ndocker-compose.ci.yml ← Docker CI simulation\nMakefile ← Convenience targets\n' : ''}requirements.txt / package.json
989
+ \`\`\`
990
+
991
+ ## Running locally
992
+ ${framework === 'selenium-python' ? '```bash\npip install -r requirements.txt\npytest tests/ -v\n```' :
993
+ framework === 'playwright-python' ? '```bash\npip install -r requirements.txt\nplaywright install\npytest tests/ -v\n```' :
994
+ framework === 'playwright-typescript' ? '```bash\nnpm install\nnpx playwright install\nnpx playwright test\n```' :
995
+ '```bash\nmvn test\n```'}
996
+
997
+ ${hasCICD ? `## CI/CD\n\n**GitHub Actions** — push to \`main\` or \`develop\` triggers the pipeline automatically.\n\n**Jenkins** — import the \`Jenkinsfile\` into a new Pipeline job. Set the \`BASE_URL\` credential.\n\n**Docker** — simulate CI locally:\n\`\`\`bash\nmake docker-test\n\`\`\`\n` : ''}
998
+
999
+ *Generated by QA Deck*
1000
+ `;
1001
+ }
1002
+
1003
+ function triggerDownload(blob, filename) {
1004
+ const url = URL.createObjectURL(blob);
1005
+ const a = document.createElement('a');
1006
+ a.href = url; a.download = filename;
1007
+ document.body.appendChild(a); a.click();
1008
+ document.body.removeChild(a);
1009
+ URL.revokeObjectURL(url);
1010
+ }
1011
+
1012
+ function fwShort(fw) {
1013
+ return { 'selenium-python':'Sel/Py', 'selenium-java':'Sel/Java', 'playwright-python':'PW/Py', 'playwright-typescript':'PW/TS' }[fw] || fw;
1014
+ }
1015
+
1016
+ function timeAgo(iso) {
1017
+ if (!iso) return '—';
1018
+ const secs = Math.floor((Date.now() - new Date(iso)) / 1000);
1019
+ if (secs < 60) return 'just now';
1020
+ if (secs < 3600) return `${Math.floor(secs/60)}m ago`;
1021
+ if (secs < 86400) return `${Math.floor(secs/3600)}h ago`;
1022
+ if (secs < 604800) return `${Math.floor(secs/86400)}d ago`;
1023
+ return new Date(iso).toLocaleDateString('en-GB', { day:'numeric', month:'short' });
1024
+ }
1025
+
1026
+ function escHtml(s) {
1027
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1028
+ }
1029
+
1030
+ function showToast(msg, type='info') {
1031
+ const c = document.getElementById('toast-container');
1032
+ const t = document.createElement('div');
1033
+ t.className = `toast toast-${type}`;
1034
+ t.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">${type==='success'?'<path d="M2 7l3.5 3.5L12 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>':type==='error'?'<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.2"/><path d="M7 4v4M7 9.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>':'<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.2"/><path d="M7 6v4M7 4.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>'}</svg>${msg}`;
1035
+ c.appendChild(t);
1036
+ setTimeout(() => { t.style.opacity='0'; t.style.transform='translateX(20px)'; t.style.transition='all 0.3s'; setTimeout(()=>t.remove(),300); }, 3500);
1037
+ }
1038
+
1039
+
1040
+ // ── CI/CD state ───────────────────────────────────────────────────────────────
1041
+ let cicdOptions = {
1042
+ browsers: ['chromium'],
1043
+ parallel: false,
1044
+ reporters: ['html', 'junit'],
1045
+ slackWebhook: false,
1046
+ emailNotify: false,
1047
+ branches: ['main', 'develop'],
1048
+ prTrigger: true,
1049
+ useAllure: false,
1050
+ };
1051
+ let cicdConfigs = null;
1052
+ let cicdFile = 'githubActions';
1053
+
1054
+ // ── Render CI/CD tab ──────────────────────────────────────────────────────────
1055
+ function renderCICDTab(body) {
1056
+ if (!currentProject) return;
1057
+
1058
+ const fw = selectedFramework;
1059
+ const isPlaywright = fw.startsWith('playwright');
1060
+ const browserOptions = isPlaywright
1061
+ ? [['chromium','Chromium'],['firefox','Firefox'],['webkit','WebKit']]
1062
+ : [['chrome','Chrome'],['firefox','Firefox']];
1063
+
1064
+ body.innerHTML = `
1065
+ <div style="display:flex;flex-direction:column;gap:14px">
1066
+
1067
+ <!-- Framework (read from re-export selection) -->
1068
+ <div>
1069
+ <div class="export-label">Framework</div>
1070
+ <div style="font-family:var(--mono);font-size:12px;color:var(--green);padding:7px 10px;background:var(--green-glow);border:1px solid var(--green-line);border-radius:var(--radius);display:inline-block">${fw}</div>
1071
+ <span style="font-size:11px;color:var(--text-3);margin-left:8px">Change in Re-export tab</span>
1072
+ </div>
1073
+
1074
+ <!-- Browsers -->
1075
+ <div>
1076
+ <div class="export-label">Browsers</div>
1077
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
1078
+ ${browserOptions.map(([val, lbl]) => `
1079
+ <label style="display:flex;align-items:center;gap:5px;cursor:pointer;font-size:12px;color:var(--text-2);padding:5px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-2)">
1080
+ <input type="checkbox" value="${val}" ${cicdOptions.browsers.includes(val)?'checked':''} onchange="toggleBrowser('${val}',this.checked)" style="accent-color:var(--green)"/>
1081
+ ${lbl}
1082
+ </label>`).join('')}
1083
+ </div>
1084
+ </div>
1085
+
1086
+ <!-- Options grid -->
1087
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
1088
+ <label style="display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2);cursor:pointer">
1089
+ <input type="checkbox" ${cicdOptions.parallel?'checked':''} onchange="cicdOptions.parallel=this.checked" style="accent-color:var(--green)"/>
1090
+ Parallel browsers (matrix)
1091
+ </label>
1092
+ <label style="display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2);cursor:pointer">
1093
+ <input type="checkbox" ${cicdOptions.prTrigger?'checked':''} onchange="cicdOptions.prTrigger=this.checked" style="accent-color:var(--green)"/>
1094
+ Trigger on pull requests
1095
+ </label>
1096
+ <label style="display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2);cursor:pointer">
1097
+ <input type="checkbox" ${cicdOptions.slackWebhook?'checked':''} onchange="cicdOptions.slackWebhook=this.checked" style="accent-color:var(--green)"/>
1098
+ Slack notifications
1099
+ </label>
1100
+ <label style="display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2);cursor:pointer">
1101
+ <input type="checkbox" ${cicdOptions.emailNotify?'checked':''} onchange="cicdOptions.emailNotify=this.checked" style="accent-color:var(--green)"/>
1102
+ Email on failure
1103
+ </label>
1104
+ <label style="display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2);cursor:pointer">
1105
+ <input type="checkbox" ${cicdOptions.useAllure?'checked':''} onchange="cicdOptions.useAllure=this.checked" style="accent-color:var(--green)"/>
1106
+ Allure reporting
1107
+ </label>
1108
+ <label style="display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2);cursor:pointer">
1109
+ <input type="checkbox" ${cicdOptions.reporters.includes('junit')?'checked':''} onchange="toggleReporter('junit',this.checked)" style="accent-color:var(--green)"/>
1110
+ JUnit XML output
1111
+ </label>
1112
+ </div>
1113
+
1114
+ <!-- Branches -->
1115
+ <div>
1116
+ <div class="export-label">Trigger branches</div>
1117
+ <input type="text" value="${cicdOptions.branches.join(', ')}"
1118
+ onchange="cicdOptions.branches=this.value.split(',').map(s=>s.trim()).filter(Boolean)"
1119
+ style="width:100%;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:7px 10px;font-family:var(--mono);font-size:12px;color:var(--text-1);outline:none"/>
1120
+ <div style="font-size:10px;color:var(--text-3);margin-top:4px">Comma-separated branch names</div>
1121
+ </div>
1122
+
1123
+ <!-- Generate button -->
1124
+ <button class="btn btn-green" onclick="generateAndShowCICD()" style="width:100%;justify-content:center">
1125
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M5 3l3 3-3 3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
1126
+ Generate CI/CD Configs
1127
+ </button>
1128
+
1129
+ <!-- Results area -->
1130
+ <div id="cicd-results" style="display:${cicdConfigs?'block':'none'}">
1131
+ <div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap" id="cicd-file-tabs">
1132
+ ${['githubActions','jenkins','dockerCompose','makefileTargets'].map((k,i) => `
1133
+ <div class="script-file-tab ${k===cicdFile?'active':''}" onclick="selectCICDFile('${k}',this)">
1134
+ ${{ githubActions:'GitHub Actions', jenkins:'Jenkinsfile', dockerCompose:'Docker Compose', makefileTargets:'Makefile' }[k]}
1135
+ </div>`).join('')}
1136
+ </div>
1137
+
1138
+ <div class="code-block">
1139
+ <div class="code-block-header">
1140
+ <span class="code-block-filename" id="cicd-filename">${cicdConfigs?.[cicdFile]?.filename||''}</span>
1141
+ <button class="copy-code-btn" onclick="copyCICDCode()">Copy</button>
1142
+ </div>
1143
+ <pre class="code-pre" id="cicd-code-pre" style="max-height:340px">${cicdConfigs ? escHtml(cicdConfigs[cicdFile]?.content||'') : ''}</pre>
1144
+ </div>
1145
+ </div>
1146
+ </div>`;
1147
+ }
1148
+
1149
+ function toggleBrowser(val, checked) {
1150
+ if (checked && !cicdOptions.browsers.includes(val)) cicdOptions.browsers.push(val);
1151
+ else if (!checked) cicdOptions.browsers = cicdOptions.browsers.filter(b => b !== val);
1152
+ if (!cicdOptions.browsers.length) cicdOptions.browsers = [val]; // keep at least one
1153
+ }
1154
+
1155
+ function toggleReporter(val, checked) {
1156
+ if (checked && !cicdOptions.reporters.includes(val)) cicdOptions.reporters.push(val);
1157
+ else cicdOptions.reporters = cicdOptions.reporters.filter(r => r !== val);
1158
+ }
1159
+
1160
+ function selectCICDFile(key, el) {
1161
+ cicdFile = key;
1162
+ document.querySelectorAll('#cicd-file-tabs .script-file-tab').forEach(t => t.classList.remove('active'));
1163
+ el.classList.add('active');
1164
+ if (cicdConfigs?.[key]) {
1165
+ document.getElementById('cicd-filename').textContent = cicdConfigs[key].filename;
1166
+ document.getElementById('cicd-code-pre').textContent = cicdConfigs[key].content;
1167
+ }
1168
+ }
1169
+
1170
+ async function generateAndShowCICD() {
1171
+ if (!currentProject) return;
1172
+ const allTCs = (currentProject.pages||[]).flatMap(pg => pg.testCases||[]);
1173
+
1174
+ try {
1175
+ const res = await fetch(`${API}/api/generate-cicd`, {
1176
+ method: 'POST',
1177
+ headers: { 'Content-Type': 'application/json' },
1178
+ body: JSON.stringify({
1179
+ framework: selectedFramework,
1180
+ projectName: currentProject.name || currentProject.url,
1181
+ pageType: currentProject.pageType || 'page',
1182
+ baseUrl: currentProject.url || 'https://staging.example.com',
1183
+ testCaseCount: allTCs.length,
1184
+ ...cicdOptions,
1185
+ }),
1186
+ });
1187
+ const data = await res.json();
1188
+ if (!data.success) throw new Error(data.error);
1189
+
1190
+ cicdConfigs = data.configs;
1191
+ cicdFile = 'githubActions';
1192
+
1193
+ // Re-render with results
1194
+ const body = document.getElementById('drawer-body');
1195
+ renderCICDTab(body);
1196
+
1197
+ showToast('CI/CD configs generated!', 'success');
1198
+ } catch (err) {
1199
+ showToast('CI/CD error: ' + err.message, 'error');
1200
+ }
1201
+ }
1202
+
1203
+ async function copyCICDCode() {
1204
+ const pre = document.getElementById('cicd-code-pre');
1205
+ if (!pre) return;
1206
+ try {
1207
+ await navigator.clipboard.writeText(pre.textContent);
1208
+ const btn = document.querySelector('#cicd-results .copy-code-btn');
1209
+ if (btn) { btn.textContent = 'Copied!'; btn.style.color = 'var(--green)'; setTimeout(() => { btn.textContent = 'Copy'; btn.style.color = ''; }, 1500); }
1210
+ } catch { showToast('Copy failed', 'error'); }
1211
+ }
1212
+
1213
+ // Keyboard shortcut
1214
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeDrawer(); closeConfirm(); } });
1215
+
1216
+ // ── Inject spin keyframe for regen button ─────────────────────────────
1217
+ const style = document.createElement('style');
1218
+ style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
1219
+ document.head.appendChild(style);
1220
+ </script>
1221
+ </body>
1222
+ </html>