@matware/e2e-runner 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.mcp.json +9 -0
  3. package/README.md +475 -307
  4. package/agents/test-analyzer.md +81 -0
  5. package/agents/test-creator.md +102 -0
  6. package/agents/test-improver.md +140 -0
  7. package/bin/cli.js +194 -6
  8. package/commands/create-test.md +50 -0
  9. package/commands/run.md +49 -0
  10. package/commands/verify-issue.md +63 -0
  11. package/package.json +10 -2
  12. package/skills/e2e-testing/SKILL.md +166 -0
  13. package/skills/e2e-testing/references/action-types.md +100 -0
  14. package/skills/e2e-testing/references/test-json-format.md +159 -0
  15. package/skills/e2e-testing/references/troubleshooting.md +182 -0
  16. package/src/actions.js +273 -18
  17. package/src/ai-generate.js +87 -7
  18. package/src/config.js +28 -0
  19. package/src/dashboard.js +156 -6
  20. package/src/db.js +207 -13
  21. package/src/index.js +9 -3
  22. package/src/learner-markdown.js +177 -0
  23. package/src/learner-neo4j.js +255 -0
  24. package/src/learner-sqlite.js +354 -0
  25. package/src/learner.js +413 -0
  26. package/src/mcp-tools.js +448 -18
  27. package/src/module-resolver.js +273 -0
  28. package/src/narrate.js +225 -0
  29. package/src/neo4j-pool.js +124 -0
  30. package/src/reporter.js +35 -2
  31. package/src/runner.js +120 -46
  32. package/src/verify.js +5 -3
  33. package/templates/build-dashboard.js +28 -0
  34. package/templates/dashboard/app.js +1152 -0
  35. package/templates/dashboard/styles.css +413 -0
  36. package/templates/dashboard/template.html +201 -0
  37. package/templates/dashboard.html +964 -378
  38. package/templates/docker-compose-neo4j.yml +19 -0
  39. package/templates/e2e.config.js +3 -0
@@ -84,6 +84,24 @@ a{color:var(--accent);text-decoration:none}
84
84
  .stat-val.accent{color:var(--accent)}
85
85
  .stat-val.purple{color:var(--purple)}
86
86
 
87
+ /* ── Learnings ── */
88
+ .learn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:20px}
89
+ .learn-stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px;text-align:center}
90
+ .learn-stat-val{font-size:24px;font-weight:700;margin-bottom:2px}
91
+ .learn-stat-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em}
92
+ .learn-section{margin-bottom:20px}
93
+ .learn-section-title{font-family:var(--sans);font-size:13px;font-weight:600;margin-bottom:10px;color:var(--text)}
94
+ .learn-table{width:100%;border-collapse:collapse;font-size:11px}
95
+ .learn-table th{text-align:left;font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;padding:6px 10px;border-bottom:1px solid var(--border);cursor:pointer;user-select:none}
96
+ .learn-table th:hover{color:var(--text2)}
97
+ .learn-table th.sorted::after{content:' \\25B2';font-size:8px}
98
+ .learn-table th.sorted.desc::after{content:' \\25BC'}
99
+ .learn-table td{padding:6px 10px;border-bottom:1px solid var(--border);color:var(--text2)}
100
+ .learn-table td code{background:var(--surface3);padding:1px 5px;border-radius:3px;font-size:10px;color:var(--text)}
101
+ .learn-table tbody tr:hover td{background:var(--surface2);color:var(--text)}
102
+ .learn-trend-chart{width:100%;height:100px;margin-bottom:20px}
103
+ .learn-trend-chart svg{width:100%;height:100%}
104
+
87
105
  /* ── Tables ── */
88
106
  .tbl-wrap{overflow-x:auto}
89
107
  table{width:100%;border-collapse:collapse;font-size:12px}
@@ -105,16 +123,80 @@ tbody tr.selected td{background:var(--accent-dim)}
105
123
  .chart-bar .tip{display:none;position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--surface3);border:1px solid var(--border);padding:4px 8px;border-radius:4px;font-size:10px;white-space:nowrap;z-index:10;pointer-events:none}
106
124
  .chart-bar:hover .tip{display:block}
107
125
 
126
+ /* ── Project Accordion ── */
127
+ .project-accordion{margin-bottom:2px}
128
+ .project-accordion-header{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);cursor:pointer;transition:all .15s;user-select:none}
129
+ .project-accordion-header:hover{background:var(--surface2);border-color:var(--border-hi)}
130
+ .project-accordion.open>.project-accordion-header{border-radius:var(--r) var(--r) 0 0;border-bottom-color:transparent;background:var(--surface2)}
131
+ .project-accordion-chevron{font-size:10px;color:var(--text3);transition:transform .2s ease;flex-shrink:0;width:16px;text-align:center}
132
+ .project-accordion.open>.project-accordion-header .project-accordion-chevron{transform:rotate(90deg);color:var(--accent)}
133
+ .project-accordion-name{font-family:var(--sans);font-size:13px;font-weight:600;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
134
+ .project-accordion-meta{display:flex;align-items:center;gap:10px;flex-shrink:0}
135
+ .project-accordion-badge{font-size:10px;font-weight:600;padding:2px 8px;border-radius:10px;background:var(--surface3);color:var(--text2)}
136
+ .project-accordion-body{overflow:hidden;max-height:0;transition:max-height .3s ease;border:1px solid var(--border);border-top:none;border-radius:0 0 var(--r) var(--r);background:var(--bg)}
137
+ .project-accordion.open>.project-accordion-body{max-height:5000px}
138
+
108
139
  /* ── Suite Cards ── */
109
- .suite-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
110
- .suite-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;transition:border-color .15s}
111
- .suite-card:hover{border-color:var(--border-hi)}
112
- .suite-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
113
- .suite-card-name{font-weight:600;font-size:13px}
114
- .suite-card-count{font-size:10px;color:var(--text3)}
115
- .suite-card-tests{list-style:none;margin-bottom:12px}
116
- .suite-card-tests li{font-size:11px;color:var(--text2);padding:2px 0;padding-left:14px;position:relative}
117
- .suite-card-tests li::before{content:">";position:absolute;left:0;color:var(--text3)}
140
+ .suite-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px;padding:16px}
141
+ .suite-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;transition:border-color .2s,box-shadow .2s}
142
+ .suite-card:hover{border-color:var(--border-hi);box-shadow:0 2px 12px rgba(0,0,0,.25)}
143
+ .suite-card-head{display:flex;align-items:center;gap:12px;padding:14px 16px;border-bottom:1px solid var(--border);background:var(--surface2)}
144
+ .suite-card-icon{width:32px;height:32px;border-radius:6px;background:var(--accent-dim);display:flex;align-items:center;justify-content:center;font-size:14px;color:var(--accent);flex-shrink:0}
145
+ .suite-card-info{flex:1;min-width:0}
146
+ .suite-card-name{font-family:var(--sans);font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
147
+ .suite-card-file{font-size:10px;color:var(--text3);margin-top:1px;font-family:var(--mono)}
148
+ .suite-card-count{flex-shrink:0;display:flex;flex-direction:column;align-items:center;justify-content:center;min-width:40px;height:40px;border-radius:var(--r);background:var(--surface3);border:1px solid var(--border)}
149
+ .suite-card-count-num{font-size:16px;font-weight:700;color:var(--text);line-height:1}
150
+ .suite-card-count-lbl{font-size:8px;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;line-height:1;margin-top:2px}
151
+ .suite-card-body{padding:0}
152
+ .suite-card-tests{list-style:none;max-height:180px;overflow-y:auto}
153
+ .suite-card-tests::-webkit-scrollbar{width:4px}
154
+ .suite-card-tests::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
155
+ .suite-card-tests li{font-size:11px;color:var(--text2);padding:6px 16px 6px 30px;position:relative;cursor:pointer;transition:all .15s;border-bottom:1px solid rgba(35,39,56,.5)}
156
+ .suite-card-tests li:last-child{border-bottom:none}
157
+ .suite-card-tests li:hover{color:var(--text);background:var(--surface2)}
158
+ .suite-card-tests li::before{content:"\25B8";position:absolute;left:14px;color:var(--text3);font-size:9px;top:7px;transition:transform .15s}
159
+ .suite-card-tests li.expanded{color:var(--accent);background:var(--accent-dim)}
160
+ .suite-card-tests li.expanded::before{content:"\25BE";color:var(--accent)}
161
+ .suite-test-steps{padding:8px 0 8px 14px;border-left:2px solid var(--accent-dim);margin:6px 0 4px 6px}
162
+ .suite-card-footer{padding:10px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;background:var(--surface)}
163
+
164
+ /* ── Suite Detail Modal ── */
165
+ .suite-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:200;display:none;align-items:flex-start;justify-content:center;padding:32px 24px;overflow-y:auto;backdrop-filter:blur(4px)}
166
+ .suite-modal-overlay.open{display:flex}
167
+ .suite-modal{width:100%;max-width:960px;background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:hidden;animation:suiteModalIn .2s ease}
168
+ @keyframes suiteModalIn{from{opacity:0;transform:translateY(-12px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
169
+ .suite-modal-header{display:flex;align-items:center;gap:14px;padding:18px 24px;background:var(--surface2);border-bottom:1px solid var(--border)}
170
+ .suite-modal-header .suite-card-icon{width:36px;height:36px;font-size:16px}
171
+ .suite-modal-title{flex:1;min-width:0}
172
+ .suite-modal-title h2{font-family:var(--sans);font-size:16px;font-weight:700;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
173
+ .suite-modal-title span{font-size:10px;color:var(--text3);font-family:var(--mono)}
174
+ .suite-modal-actions{display:flex;gap:8px;align-items:center;flex-shrink:0}
175
+ .suite-modal-close{width:32px;height:32px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface3);color:var(--text2);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;line-height:1}
176
+ .suite-modal-close:hover{background:var(--red-dim);border-color:rgba(239,68,68,.3);color:var(--red)}
177
+ .suite-modal-body{padding:0}
178
+ .suite-modal-loading{padding:32px;text-align:center;color:var(--text3);font-size:12px}
179
+ .suite-modal-test{border-bottom:1px solid var(--border)}
180
+ .suite-modal-test:last-child{border-bottom:none}
181
+ .suite-modal-test-header{display:flex;align-items:center;gap:10px;padding:12px 24px;cursor:pointer;transition:background .15s;user-select:none}
182
+ .suite-modal-test-header:hover{background:var(--surface2)}
183
+ .suite-modal-test.open>.suite-modal-test-header{background:var(--surface2)}
184
+ .suite-modal-test-chevron{font-size:9px;color:var(--text3);transition:transform .15s;width:14px;text-align:center;flex-shrink:0}
185
+ .suite-modal-test.open>.suite-modal-test-header .suite-modal-test-chevron{transform:rotate(90deg);color:var(--accent)}
186
+ .suite-modal-test-name{font-size:12px;font-weight:500;color:var(--text);flex:1}
187
+ .suite-modal-test-badge{font-size:9px;padding:2px 7px;border-radius:8px;background:var(--surface3);color:var(--text3);flex-shrink:0}
188
+ .suite-modal-test-actions{display:none;padding:0 24px 16px 48px}
189
+ .suite-modal-test.open>.suite-modal-test-actions{display:block}
190
+ .suite-modal-step{display:flex;align-items:flex-start;gap:8px;padding:5px 0;font-size:11px;font-family:var(--mono);border-bottom:1px solid rgba(35,39,56,.3)}
191
+ .suite-modal-step:last-child{border-bottom:none}
192
+ .suite-modal-step-num{width:22px;text-align:right;color:var(--text3);flex-shrink:0;font-size:10px;padding-top:1px}
193
+ .suite-modal-step-type{color:var(--purple);font-weight:600;flex-shrink:0;min-width:120px}
194
+ .suite-modal-step-detail{color:var(--text2);flex:1;min-width:0;word-break:break-word}
195
+ .suite-modal-step-detail .step-sel{color:var(--accent)}
196
+ .suite-modal-step-detail .step-arrow{color:var(--text3);margin:0 4px}
197
+ .suite-modal-step-detail .step-val{color:var(--text)}
198
+ .suite-modal-expect{padding:10px 24px 10px 48px;background:var(--green-dim);border-bottom:1px solid var(--border);font-size:11px;color:var(--green);display:flex;align-items:flex-start;gap:8px}
199
+ .suite-modal-expect-label{font-weight:600;flex-shrink:0}
118
200
 
119
201
  /* ── Live Execution ── */
120
202
  .live-panel{display:none;background:var(--surface);border:1px solid var(--purple);border-radius:var(--r);overflow:hidden;animation:fadeSlide .3s ease;flex-direction:column}
@@ -145,14 +227,14 @@ tbody tr.selected td{background:var(--accent-dim)}
145
227
  .live-test .lt-summary .lt-expand{color:var(--purple);font-size:9px;opacity:.6}
146
228
  .live-test .lt-meta{color:var(--text2);font-size:10px;margin-bottom:6px}
147
229
  .live-test .lt-icon{font-size:12px}
148
- .lt-actions{max-height:180px;overflow-y:auto;border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
230
+ .lt-actions{overflow-y:auto;border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
149
231
  .lt-step{display:flex;align-items:flex-start;gap:6px;padding:2px 0;font-size:10px;font-family:var(--mono);line-height:1.4}
150
232
  .lt-step .step-icon{flex-shrink:0;width:14px;text-align:center}
151
233
  .lt-step .step-icon.ok{color:var(--green)}
152
234
  .lt-step .step-icon.fail{color:var(--red)}
153
235
  .lt-step .step-icon.run{color:var(--purple)}
154
236
  .lt-step .step-type{color:var(--purple);font-weight:600;flex-shrink:0}
155
- .lt-step .step-detail{color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
237
+ .lt-step .step-detail{color:var(--text2);flex:1;min-width:0;white-space:pre-wrap;word-break:break-word}
156
238
  .lt-step .step-dur{color:var(--text3);flex-shrink:0;margin-left:auto}
157
239
  .lt-screenshots{border-top:1px solid var(--border);margin-top:6px;padding-top:6px}
158
240
  .lt-screenshots-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:10px;color:var(--text3);font-family:var(--mono);padding:2px 0;user-select:none}
@@ -171,7 +253,7 @@ tbody tr.selected td{background:var(--accent-dim)}
171
253
  .lr-section-header.fail{border-color:var(--red);background:rgba(248,113,113,.08);color:var(--red)}
172
254
  .lr-section-stats{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text2);font-weight:400}
173
255
  .lr-project-name{letter-spacing:.5px}
174
- .lr-test-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:8px;padding:4px 0 8px}
256
+ .lr-test-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px;padding:4px 0 8px}
175
257
  .live-done{background:var(--green-dim);color:var(--green);text-align:center;padding:10px;font-weight:600;font-size:12px}
176
258
  .live-done.has-failures{background:var(--red-dim);color:var(--red)}
177
259
  .live-close{padding:4px 10px;font-size:10px;background:transparent;border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer}
@@ -206,77 +288,112 @@ tbody tr.selected td{background:var(--accent-dim)}
206
288
  .ss-search-error{font-size:11px;color:var(--red);margin-bottom:12px}
207
289
 
208
290
  /* ── Inline Run Detail ── */
209
- .run-detail-row td{padding:0!important;border-bottom:2px solid var(--border-hi)}
291
+ .run-detail-row td{padding:0!important;border-bottom:2px solid var(--purple)}
210
292
  .run-detail-row:hover td{background:transparent!important}
211
- .rd-wrap{overflow:hidden;transition:max-height .35s cubic-bezier(.4,0,.2,1);max-height:0}
212
- .rd-wrap.open{max-height:2000px}
213
- .rd-inner{padding:16px 20px;background:var(--bg);border-left:3px solid var(--purple);margin:0 8px 8px}
214
- .rd-test{margin-bottom:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
293
+ .rd-wrap{overflow:hidden;transition:max-height .4s cubic-bezier(.4,0,.2,1);max-height:0}
294
+ .rd-wrap.open{max-height:10000px}
295
+ .rd-inner{padding:20px 24px;background:var(--bg);position:relative}
296
+ .rd-inner::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--purple),var(--accent),transparent 80%)}
297
+ .rd-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:1px;background:var(--border);border-radius:8px;overflow:hidden;margin-bottom:20px}
298
+ .rd-summary>div{background:var(--surface);padding:14px 16px;text-align:center;transition:background .15s}
299
+ .rd-summary>div:hover{background:var(--surface2)}
300
+ .rd-s-label{font-size:9px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.1em}
301
+ .rd-s-val{font-weight:700;font-size:18px;margin-top:4px}
302
+ .rd-test{margin-bottom:12px;background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:hidden;transition:border-color .2s}
303
+ .rd-test:hover{border-color:var(--border-hi)}
215
304
  .rd-test:last-child{margin-bottom:0}
216
- .rd-test-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--surface2)}
217
- .rd-test-name{font-weight:600;font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
218
- .rd-test-dur{font-size:11px;color:var(--text2);flex-shrink:0}
219
- .rd-test-body{padding:10px 14px}
220
- .rd-error-msg{font-size:11px;color:var(--red);padding:8px 12px;background:var(--red-dim);border-radius:var(--r);margin-bottom:10px;word-break:break-all;border-left:3px solid var(--red)}
221
- .rd-shots{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
222
- .rd-shot{width:120px;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;transition:border-color .15s,transform .15s}
223
- .rd-shot:hover{border-color:var(--accent);transform:scale(1.03)}
224
- .rd-shot img{width:100%;height:72px;object-fit:cover;display:block}
225
- .rd-shot .rd-shot-cap{font-size:9px;color:var(--text3);padding:4px 6px;background:var(--surface2)}
305
+ .rd-test-head{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--surface2);border-bottom:1px solid var(--border);position:relative;padding-left:20px}
306
+ .rd-test-head::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px}
307
+ .rd-test.pass .rd-test-head::before{background:var(--green)}
308
+ .rd-test.fail .rd-test-head::before{background:var(--red)}
309
+ .rd-test.flaky .rd-test-head::before{background:var(--amber)}
310
+ .rd-test-name{font-family:var(--sans);font-weight:600;font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
311
+ .rd-test-dur{font-size:11px;color:var(--text2);flex-shrink:0;font-variant-numeric:tabular-nums}
312
+ .rd-test-body{padding:16px}
313
+ .rd-retries{font-size:11px;color:var(--amber);margin-bottom:10px;display:flex;align-items:center;gap:6px}
314
+ .rd-error-msg{font-size:12px;color:var(--red);padding:10px 14px;background:var(--red-dim);border-radius:var(--r);margin-bottom:12px;word-break:break-all;border-left:3px solid var(--red);line-height:1.5;position:relative;padding-right:60px}
315
+ .rd-shots{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px}
316
+ .rd-shot{width:140px;border-radius:6px;overflow:hidden;border:1px solid var(--border);cursor:pointer;transition:all .2s;background:var(--surface2)}
317
+ .rd-shot:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 6px 16px rgba(0,0,0,.35)}
318
+ .rd-shot img{width:100%;height:84px;object-fit:cover;display:block}
319
+ .rd-shot .rd-shot-cap{font-size:9px;color:var(--text3);padding:5px 8px;background:var(--surface)}
226
320
  .rd-shot .rd-shot-cap .cap-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
227
321
  .rd-shot .rd-shot-cap .ss-hash{margin-top:3px}
228
322
  .rd-shot.err-shot{border-color:rgba(239,68,68,.4)}
229
323
  .rd-shot.err-shot .rd-shot-cap{color:var(--red)}
230
- .rd-logs{margin-top:10px}
231
- .rd-log-label{font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;margin-bottom:4px}
232
- .rd-log-item{font-size:10px;padding:3px 8px;border-left:2px solid var(--border);margin-bottom:2px;color:var(--text2);word-break:break-all}
324
+ .rd-logs{margin-bottom:12px}
325
+ .rd-log-label{font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.1em;text-transform:uppercase;margin-bottom:6px;display:flex;align-items:center;gap:8px}
326
+ .rd-log-item{font-size:11px;padding:4px 10px;border-left:2px solid var(--border);margin-bottom:2px;color:var(--text2);word-break:break-all;line-height:1.5}
233
327
  .rd-log-item.error{border-left-color:var(--red);color:var(--red)}
234
328
  .rd-log-item.warning,.rd-log-item.warn{border-left-color:var(--amber);color:var(--amber)}
235
- .rd-net-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:10px;color:var(--text3);font-family:var(--mono);padding:2px 0;user-select:none;margin-top:10px}
236
- .rd-net-toggle:hover{color:var(--text)}
237
- .rd-net-toggle .net-arrow{transition:transform .2s;font-size:8px}
238
- .rd-net-toggle.open .net-arrow{transform:rotate(90deg)}
239
- .rd-net-list{display:none;margin-top:6px}
240
- .rd-net-toggle.open+.rd-net-list{display:block}
241
- .rd-net-row{display:flex;align-items:center;gap:8px;padding:3px 8px;font-size:10px;font-family:var(--mono);border-left:2px solid var(--border);margin-bottom:0;cursor:pointer;transition:background .15s}
329
+ .rd-net-panel{margin-top:4px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--surface)}
330
+ .rd-net-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--surface2);cursor:pointer;user-select:none;transition:background .15s}
331
+ .rd-net-head:hover{background:var(--surface3)}
332
+ .rd-net-head .net-arrow{font-size:9px;color:var(--text3);transition:transform .2s}
333
+ .rd-net-head.open .net-arrow{transform:rotate(90deg)}
334
+ .rd-net-head .net-title{font-family:var(--sans);font-size:12px;font-weight:600;color:var(--text)}
335
+ .rd-net-head .net-stats{margin-left:auto;display:flex;gap:12px;font-size:10px}
336
+ .rd-net-head .net-stat{color:var(--text3)}
337
+ .rd-net-head .net-stat strong{color:var(--text2)}
338
+ .rd-net-head .net-stat.has-err strong{color:var(--red)}
339
+ .rd-net-body{display:none;max-height:600px;overflow-y:auto}
340
+ .rd-net-head.open~.rd-net-body{display:block}
341
+ .rd-net-cols{display:flex;align-items:center;gap:8px;padding:6px 14px;font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;border-bottom:1px solid var(--border);background:var(--surface);position:sticky;top:0;z-index:1}
342
+ .rd-net-cols .col-e{width:16px;flex-shrink:0}
343
+ .rd-net-cols .col-m{width:50px;flex-shrink:0}
344
+ .rd-net-cols .col-s{width:60px;flex-shrink:0}
345
+ .rd-net-cols .col-u{flex:1;min-width:0}
346
+ .rd-net-cols .col-d{width:60px;flex-shrink:0;text-align:right}
347
+ .rd-net-row{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:11px;font-family:var(--mono);border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s}
242
348
  .rd-net-row:hover{background:var(--surface2)}
243
- .rd-net-row.has-error{border-left-color:var(--red)}
244
- .rd-net-method{display:inline-block;padding:1px 5px;border-radius:3px;font-size:9px;font-weight:700;min-width:36px;text-align:center}
349
+ .rd-net-row.has-error{background:rgba(239,68,68,.03)}
350
+ .rd-net-row.has-error:hover{background:rgba(239,68,68,.06)}
351
+ .rd-net-expand{width:16px;flex-shrink:0;font-size:8px;color:var(--text3);transition:transform .2s;text-align:center}
352
+ .rd-net-row.open .rd-net-expand{transform:rotate(90deg);color:var(--accent)}
353
+ .rd-net-method{width:50px;flex-shrink:0;display:inline-flex;justify-content:center;padding:2px 6px;border-radius:3px;font-size:9px;font-weight:700}
245
354
  .rd-net-method.get{background:var(--accent-dim);color:var(--accent)}
246
355
  .rd-net-method.post{background:var(--green-dim);color:var(--green)}
247
356
  .rd-net-method.put,.rd-net-method.patch{background:var(--amber-dim);color:var(--amber)}
248
357
  .rd-net-method.delete{background:var(--red-dim);color:var(--red)}
249
- .rd-net-status{display:inline-block;padding:1px 5px;border-radius:3px;font-size:9px;font-weight:600;min-width:28px;text-align:center}
250
- .rd-net-status.s2xx{background:var(--green-dim);color:var(--green)}
251
- .rd-net-status.s3xx{background:var(--amber-dim);color:var(--amber)}
252
- .rd-net-status.s4xx,.rd-net-status.s5xx{background:var(--red-dim);color:var(--red)}
253
- .rd-net-url{color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
254
- .rd-net-dur{color:var(--text3);flex-shrink:0}
255
- .rd-net-expand{font-size:8px;color:var(--text3);transition:transform .2s;flex-shrink:0}
256
- .rd-net-row.open .rd-net-expand{transform:rotate(90deg)}
257
- .rd-net-detail{display:none;margin:0 0 4px 0;padding:8px 12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);font-size:10px;font-family:var(--mono);overflow:hidden}
358
+ .rd-net-status{width:60px;flex-shrink:0;font-size:10px;font-weight:600}
359
+ .rd-net-status.s2xx{color:var(--green)}
360
+ .rd-net-status.s3xx{color:var(--amber)}
361
+ .rd-net-status.s4xx,.rd-net-status.s5xx{color:var(--red)}
362
+ .rd-net-url{flex:1;min-width:0;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
363
+ .rd-net-dur{width:60px;flex-shrink:0;text-align:right;color:var(--text3);font-variant-numeric:tabular-nums}
364
+ .rd-net-detail{display:none;border-bottom:1px solid var(--border);background:var(--bg);overflow:hidden}
258
365
  .rd-net-row.open+.rd-net-detail{display:block}
259
- .rd-net-detail-section{margin-bottom:8px}
260
- .rd-net-detail-section:last-child{margin-bottom:0}
261
- .rd-net-detail-title{font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}
262
- .rd-net-detail-body{white-space:pre-wrap;word-break:break-all;color:var(--text2);max-height:300px;overflow-y:auto;line-height:1.5}
263
- .rd-retries{font-size:10px;color:var(--amber);margin-bottom:6px}
264
- .rd-summary{display:flex;gap:16px;margin-bottom:14px;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);font-size:11px;align-items:center}
265
- .rd-summary .rd-s-label{color:var(--text3);font-size:9px;text-transform:uppercase;letter-spacing:.08em}
266
- .rd-summary .rd-s-val{font-weight:700;font-size:16px;margin-top:2px}
366
+ .rd-nd-section{border-bottom:1px solid var(--border)}
367
+ .rd-nd-section:last-child{border-bottom:none}
368
+ .rd-nd-toggle{display:flex;align-items:center;gap:8px;padding:8px 16px;cursor:pointer;user-select:none;transition:background .15s;font-size:10px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.06em}
369
+ .rd-nd-toggle:hover{background:var(--surface2);color:var(--text2)}
370
+ .rd-nd-toggle .nd-arrow{font-size:8px;transition:transform .2s}
371
+ .rd-nd-toggle.open .nd-arrow{transform:rotate(90deg)}
372
+ .rd-nd-toggle .nd-count{margin-left:auto;font-size:9px;font-weight:400;letter-spacing:0;text-transform:none}
373
+ .rd-nd-content{display:none;padding:0 16px 10px;max-height:300px;overflow-y:auto}
374
+ .rd-nd-toggle.open+.rd-nd-content{display:block}
375
+ .rd-nd-content pre{white-space:pre-wrap;word-break:break-all;color:var(--text2);font-size:11px;line-height:1.6;margin:0;font-family:var(--mono)}
376
+ .rd-hdr-table{display:grid;grid-template-columns:minmax(120px,auto) 1fr;gap:0;font-size:11px}
377
+ .rd-hdr-row{display:contents}
378
+ .rd-hdr-row:hover .rd-hdr-key,.rd-hdr-row:hover .rd-hdr-val{background:var(--surface2)}
379
+ .rd-hdr-key{padding:3px 12px 3px 0;color:var(--accent);font-weight:500;border-bottom:1px solid var(--border);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
380
+ .rd-hdr-val{padding:3px 0;color:var(--text2);border-bottom:1px solid var(--border);word-break:break-all}
381
+ .rd-nd-empty{color:var(--text3);font-size:11px;padding:4px 0;font-style:italic}
382
+ .copy-btn{display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:4px;font-size:9px;font-family:var(--mono);font-weight:500;color:var(--text3);background:transparent;border:1px solid transparent;cursor:pointer;transition:all .15s;user-select:none;white-space:nowrap;flex-shrink:0}
383
+ .copy-btn:hover{color:var(--accent);border-color:var(--accent);background:var(--accent-dim)}
384
+ .copy-btn.copied{color:var(--green);border-color:var(--green);background:var(--green-dim)}
385
+ .rd-error-msg .copy-btn{position:absolute;top:8px;right:8px;opacity:0}
386
+ .rd-error-msg:hover .copy-btn{opacity:1}
387
+ .rd-net-row .copy-btn{opacity:0;padding:1px 6px}
388
+ .rd-net-row:hover .copy-btn{opacity:1}
389
+ .rd-net-body .rd-log-item{padding:6px 14px;margin-bottom:0;border-left:3px solid var(--border);border-bottom:1px solid var(--border);font-size:11px}
390
+ .rd-net-body .rd-log-item:last-child{border-bottom:none}
391
+ .rd-net-body .rd-log-item.error{border-left-color:var(--red)}
392
+ .rd-net-body .rd-log-item.warn,.rd-net-body .rd-log-item.warning{border-left-color:var(--amber)}
267
393
  tr.expanded td{background:var(--surface2)!important}
268
394
  tr.expanded td:first-child{position:relative}
269
395
  tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--purple)}
270
396
 
271
- /* ── Empty ── */
272
- .empty{text-align:center;padding:48px 24px;color:var(--text3)}
273
- .empty-icon{font-size:36px;margin-bottom:8px;opacity:.5}
274
-
275
- /* ── Modal ── */
276
- .modal{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:200;display:none;align-items:center;justify-content:center;padding:24px;cursor:pointer}
277
- .modal.open{display:flex}
278
- .modal img{max-width:100%;max-height:90vh;border-radius:var(--r);cursor:default}
279
-
280
397
  /* ── Screenshot Hash Badge ── */
281
398
  .ss-hash{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:10px;font-family:var(--mono);font-size:9px;font-weight:500;background:var(--surface3);border:1px solid var(--border);color:var(--text2);cursor:pointer;transition:all .15s;user-select:none;white-space:nowrap;vertical-align:middle}
282
399
  .ss-hash:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
@@ -291,14 +408,79 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
291
408
  .trigger-badge.src-unknown{background:rgba(70,75,98,.15);color:var(--text3)}
292
409
  .trigger-badge .trig-icon{font-size:11px;line-height:1}
293
410
 
411
+ /* ── Empty ── */
412
+ .empty{text-align:center;padding:48px 24px;color:var(--text3)}
413
+ .empty-icon{font-size:36px;margin-bottom:8px;opacity:.5}
414
+
415
+ /* ── Modal ── */
416
+ .modal{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:200;display:none;align-items:center;justify-content:center;padding:24px;cursor:pointer}
417
+ .modal.open{display:flex}
418
+ .modal img{max-width:100%;max-height:90vh;border-radius:var(--r);cursor:default}
419
+
420
+ /* ── Toast Notifications (NEW) ── */
421
+ .toast-container{position:fixed;bottom:24px;right:24px;z-index:300;display:flex;flex-direction:column-reverse;gap:8px;pointer-events:none}
422
+ .toast{padding:10px 16px;border-radius:var(--r);font-family:var(--mono);font-size:11px;font-weight:500;color:#fff;pointer-events:auto;animation:toastIn .3s ease;min-width:200px;max-width:380px;box-shadow:0 8px 24px rgba(0,0,0,.4);display:flex;align-items:center;gap:8px}
423
+ .toast.success{background:var(--green);border:1px solid rgba(255,255,255,.15)}
424
+ .toast.error{background:var(--red);border:1px solid rgba(255,255,255,.15)}
425
+ .toast.info{background:var(--accent);border:1px solid rgba(255,255,255,.15)}
426
+ .toast.fade-out{animation:toastOut .3s ease forwards}
427
+ @keyframes toastIn{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}
428
+ @keyframes toastOut{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(24px)}}
429
+
430
+ /* ── Filter Bar (NEW) ── */
431
+ .filter-bar{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}
432
+ .filter-btn{padding:5px 12px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text2);font-family:var(--mono);font-size:11px;cursor:pointer;transition:all .15s}
433
+ .filter-btn:hover{background:var(--surface3);border-color:var(--border-hi)}
434
+ .filter-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
435
+ .filter-bar input{padding:5px 10px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:11px;max-width:200px}
436
+ .filter-bar input:focus{outline:none;border-color:var(--accent)}
437
+ .filter-bar input::placeholder{color:var(--text3)}
438
+
439
+ /* ── Module Cards (NEW) ── */
440
+ .module-section-title{font-family:var(--sans);font-size:14px;font-weight:600;margin:24px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--text2);display:flex;align-items:center;gap:8px}
441
+ .module-section-title .mod-icon{color:var(--purple)}
442
+ .module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
443
+ .module-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;border-left:3px solid var(--purple);transition:border-color .15s}
444
+ .module-card:hover{border-color:var(--border-hi);border-left-color:var(--purple)}
445
+ .module-card-name{font-weight:600;font-size:13px;color:var(--purple);margin-bottom:4px}
446
+ .module-card-desc{font-size:11px;color:var(--text2);margin-bottom:8px}
447
+ .module-card-meta{font-size:10px;color:var(--text3);display:flex;gap:12px}
448
+ .module-card-params{list-style:none;font-size:10px;color:var(--text2);margin-top:6px}
449
+ .module-card-params li{padding:2px 0}
450
+ .module-card-params li::before{content:'$';color:var(--purple);margin-right:4px}
451
+
452
+ /* ── Keyboard Shortcuts Modal (NEW) ── */
453
+ .kb-modal{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:250;display:none;align-items:center;justify-content:center;padding:24px}
454
+ .kb-modal.open{display:flex}
455
+ .kb-modal-content{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:24px;max-width:420px;width:100%;max-height:80vh;overflow-y:auto}
456
+ .kb-modal-content h2{font-family:var(--sans);font-size:16px;font-weight:700;margin-bottom:16px;color:var(--text)}
457
+ .kb-row{display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border)}
458
+ .kb-row:last-child{border-bottom:none}
459
+ .kb-key{display:inline-flex;align-items:center;justify-content:center;min-width:24px;padding:2px 8px;border-radius:4px;background:var(--surface3);border:1px solid var(--border);font-family:var(--mono);font-size:11px;font-weight:600;color:var(--accent)}
460
+ .kb-desc{font-size:12px;color:var(--text2)}
461
+
462
+ /* ── Actions Panel in Run Detail (NEW) ── */
463
+ .rd-actions-panel{margin-top:4px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--surface)}
464
+ .rd-actions-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--surface2);cursor:pointer;user-select:none;transition:background .15s}
465
+ .rd-actions-head:hover{background:var(--surface3)}
466
+ .rd-actions-head .act-arrow{font-size:9px;color:var(--text3);transition:transform .2s}
467
+ .rd-actions-head.open .act-arrow{transform:rotate(90deg)}
468
+ .rd-actions-body{display:none;max-height:500px;overflow-y:auto;padding:8px 14px}
469
+ .rd-actions-head.open~.rd-actions-body{display:block}
470
+
471
+ /* ── Serial Badge (NEW) ── */
472
+ .serial-badge{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:600;background:var(--amber-dim);color:var(--amber);vertical-align:middle;margin-left:4px}
473
+
294
474
  /* ── Responsive ── */
295
475
  @media(max-width:768px){
296
- .sidebar{width:60px}.sidebar-logo h1,.sidebar-section-label,.nav-item span:not(.icon),.pool-info,.sidebar select,.sidebar-logo .ver{display:none}
476
+ .sidebar{width:60px}.sidebar-logo h1,.sidebar-section-label,.nav-item span:not(.icon):not(.badge),.pool-info,.sidebar select,.sidebar-logo .ver{display:none}
297
477
  .nav-item{justify-content:center;padding:12px}.nav-item .icon{width:auto}
298
478
  .main{margin-left:60px}
299
- .suite-grid,.gallery{grid-template-columns:1fr}
479
+ .suite-grid,.gallery,.module-grid{grid-template-columns:1fr}
300
480
  .lr-test-grid{grid-template-columns:1fr}
481
+ .toast-container{right:12px;bottom:12px}
301
482
  }
483
+
302
484
  </style>
303
485
  </head>
304
486
  <body>
@@ -323,13 +505,16 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
323
505
  <i class="icon"><span class="live-nav-dot"></span></i><span>Live</span><span class="badge" id="liveBadge" style="background:var(--purple-dim);color:var(--purple)">0</span>
324
506
  </div>
325
507
  <div class="nav-item active" data-view="suites">
326
- <i class="icon">&#9655;</i><span>Suites</span>
508
+ <i class="icon">&#9655;</i><span>Suites</span><span class="badge" id="badgeSuites">-</span>
327
509
  </div>
328
510
  <div class="nav-item" data-view="runs">
329
- <i class="icon">&#9776;</i><span>Runs</span>
511
+ <i class="icon">&#9776;</i><span>Runs</span><span class="badge" id="badgeRuns">-</span>
330
512
  </div>
331
513
  <div class="nav-item" data-view="screenshots">
332
- <i class="icon">&#9635;</i><span>Screenshots</span>
514
+ <i class="icon">&#9635;</i><span>Screenshots</span><span class="badge" id="badgeScreenshots">-</span>
515
+ </div>
516
+ <div class="nav-item" data-view="learnings">
517
+ <i class="icon">&#9733;</i><span>Learnings</span><span class="badge" id="badgeLearnings">-</span>
333
518
  </div>
334
519
 
335
520
  <div class="pool-status" id="poolStatus">
@@ -379,9 +564,10 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
379
564
  <div class="view active" id="view-suites">
380
565
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
381
566
  <div style="font-family:var(--sans);font-size:16px;font-weight:600">Test Suites</div>
382
- <button class="btn primary" id="btnRunAll">Run All Tests</button>
383
567
  </div>
568
+ <div id="suiteAccordionContainer"></div>
384
569
  <div class="suite-grid" id="suiteGrid"></div>
570
+ <div id="moduleSection"></div>
385
571
  <div class="empty" id="suitesEmpty" style="display:none">
386
572
  <div class="empty-icon">&#9655;</div>
387
573
  <p>No test suites found.</p>
@@ -390,11 +576,20 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
390
576
 
391
577
  <!-- Runs View -->
392
578
  <div class="view" id="view-runs">
393
- <div style="font-family:var(--sans);font-size:16px;font-weight:600;margin-bottom:20px">Run History</div>
579
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
580
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Run History</div>
581
+ </div>
394
582
  <div class="card">
395
583
  <div class="card-label">Pass Rate Trend</div>
396
584
  <div class="chart" id="trendChart"></div>
397
585
  </div>
586
+ <div class="filter-bar" id="filterBar">
587
+ <button class="filter-btn active" data-filter="all">All</button>
588
+ <button class="filter-btn" data-filter="pass">Pass</button>
589
+ <button class="filter-btn" data-filter="fail">Fail</button>
590
+ <button class="filter-btn" data-filter="mixed">Mixed</button>
591
+ <input type="text" id="runSearchInput" placeholder="Search suite..." spellcheck="false">
592
+ </div>
398
593
  <div class="card" style="padding:0">
399
594
  <div class="tbl-wrap">
400
595
  <table>
@@ -409,6 +604,34 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
409
604
  </div>
410
605
  </div>
411
606
 
607
+ <!-- Learnings View -->
608
+ <div class="view" id="view-learnings">
609
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
610
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Learnings</div>
611
+ <div style="display:flex;gap:8px;align-items:center">
612
+ <select id="learningsDays" style="padding:5px 8px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:11px">
613
+ <option value="7">7 days</option>
614
+ <option value="14">14 days</option>
615
+ <option value="30" selected>30 days</option>
616
+ <option value="90">90 days</option>
617
+ </select>
618
+ <button class="btn sm" id="btnExportLearnings">Export MD</button>
619
+ <button class="btn sm" id="btnRefreshLearnings">Refresh</button>
620
+ </div>
621
+ </div>
622
+ <div id="learningsOverview"></div>
623
+ <div id="learningsTrend"></div>
624
+ <div id="learningsFlaky"></div>
625
+ <div id="learningsSelectors"></div>
626
+ <div id="learningsPages"></div>
627
+ <div id="learningsApis"></div>
628
+ <div id="learningsErrors"></div>
629
+ <div class="empty" id="learningsEmpty" style="display:none">
630
+ <div class="empty-icon">&#9733;</div>
631
+ <p>No learnings data yet. Run some tests to start building knowledge.</p>
632
+ </div>
633
+ </div>
634
+
412
635
  <!-- Screenshots View -->
413
636
  <div class="view" id="view-screenshots">
414
637
  <div style="font-family:var(--sans);font-size:16px;font-weight:600;margin-bottom:20px">Screenshots</div>
@@ -427,6 +650,22 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
427
650
  </div>
428
651
 
429
652
  <div class="modal" id="modal"><img id="modalImg" src="" alt=""></div>
653
+ <div class="toast-container" id="toastContainer"></div>
654
+ <div class="kb-modal" id="kbModal">
655
+ <div class="kb-modal-content">
656
+ <h2>Keyboard Shortcuts</h2>
657
+ <div class="kb-row"><span class="kb-key">1</span><span class="kb-desc">Suites view</span></div>
658
+ <div class="kb-row"><span class="kb-key">2</span><span class="kb-desc">Runs view</span></div>
659
+ <div class="kb-row"><span class="kb-key">3</span><span class="kb-desc">Screenshots view</span></div>
660
+ <div class="kb-row"><span class="kb-key">4</span><span class="kb-desc">Learnings view</span></div>
661
+ <div class="kb-row"><span class="kb-key">5</span><span class="kb-desc">Live view</span></div>
662
+ <div class="kb-row"><span class="kb-key">j / k</span><span class="kb-desc">Navigate runs (next / previous)</span></div>
663
+ <div class="kb-row"><span class="kb-key">Enter</span><span class="kb-desc">Expand / collapse selected run</span></div>
664
+ <div class="kb-row"><span class="kb-key">Esc</span><span class="kb-desc">Close modal / collapse run</span></div>
665
+ <div class="kb-row"><span class="kb-key">r</span><span class="kb-desc">Refresh current view</span></div>
666
+ <div class="kb-row"><span class="kb-key">?</span><span class="kb-desc">Show this help</span></div>
667
+ </div>
668
+ </div>
430
669
 
431
670
  <script>
432
671
  (function(){
@@ -450,53 +689,89 @@ function css(n){return n.replace(/[^a-zA-Z0-9\-_]/g,'_')}
450
689
  function dur(ms){return ms>=1000?(ms/1000).toFixed(1)+'s':ms+'ms'}
451
690
  function fdate(iso){return iso?new Date(iso).toLocaleString():'--'}
452
691
 
453
- /** Pretty-print a string if it's JSON, otherwise return as-is */
454
692
  function prettyJson(str){
455
693
  if(!str)return '';
456
694
  try{return JSON.stringify(JSON.parse(str),null,2)}catch(e){return str}
457
695
  }
458
696
 
459
- /** Format headers object as readable string */
460
697
  function fmtHeaders(h){
461
698
  if(!h||typeof h!=='object')return '';
462
699
  return Object.keys(h).map(function(k){return k+': '+h[k]}).join('\n');
463
700
  }
464
701
 
465
- /** Build a clickable network row + expandable detail panel */
702
+ function buildHeaderKV(h){
703
+ if(!h||typeof h!=='object') return el('div',{className:'rd-nd-empty'},'No data');
704
+ var table=el('div',{className:'rd-hdr-table'});
705
+ Object.keys(h).forEach(function(k){
706
+ var row=el('div',{className:'rd-hdr-row'});
707
+ row.appendChild(el('span',{className:'rd-hdr-key'},k));
708
+ row.appendChild(el('span',{className:'rd-hdr-val'},String(h[k])));
709
+ table.appendChild(row);
710
+ });
711
+ return table;
712
+ }
713
+
714
+ function makeCopyBtn(getTextFn){
715
+ var btn=el('span',{className:'copy-btn',onclick:function(e){
716
+ e.stopPropagation();
717
+ var text=typeof getTextFn==='function'?getTextFn():String(getTextFn);
718
+ navigator.clipboard.writeText(text).then(function(){
719
+ btn.textContent='\u2713 Copied';
720
+ btn.classList.add('copied');
721
+ setTimeout(function(){btn.textContent='\u2398 Copy';btn.classList.remove('copied')},1200);
722
+ });
723
+ }},'\u2398 Copy');
724
+ return btn;
725
+ }
726
+
727
+ function buildNdSection(title,contentEl,count,copyText){
728
+ var toggle=el('div',{className:'rd-nd-toggle'},[
729
+ el('span',{className:'nd-arrow'},'\u25B6'),
730
+ el('span',null,title),
731
+ count?el('span',{className:'nd-count'},count+' entries'):null,
732
+ makeCopyBtn(copyText||function(){return contentWrap.textContent})
733
+ ]);
734
+ var contentWrap=el('div',{className:'rd-nd-content'});
735
+ contentWrap.appendChild(contentEl);
736
+ toggle.addEventListener('click',function(e){
737
+ e.stopPropagation();
738
+ toggle.classList.toggle('open');
739
+ });
740
+ return el('div',{className:'rd-nd-section'},[toggle,contentWrap]);
741
+ }
742
+
466
743
  function buildNetRow(n){
467
- var mCls='rd-net-method '+n.method.toLowerCase();
468
- var sCls='rd-net-status '+(n.status<300?'s2xx':n.status<400?'s3xx':n.status<500?'s4xx':'s5xx');
744
+ var mCls='rd-net-method '+(n.method||'GET').toLowerCase();
745
+ var sCode=n.status||0;
746
+ var sCls='rd-net-status '+(sCode<300?'s2xx':sCode<400?'s3xx':sCode<500?'s4xx':'s5xx');
469
747
  var hasDetail=n.requestBody||n.responseBody||n.requestHeaders||n.responseHeaders;
470
- var rowCls='rd-net-row'+(n.status>=400?' has-error':'');
748
+ var rowCls='rd-net-row'+(sCode>=400?' has-error':'');
471
749
  var row=el('div',{className:rowCls},[
472
- hasDetail?el('span',{className:'rd-net-expand'},'\u25B6'):null,
473
- el('span',{className:mCls},n.method),
474
- el('span',{className:sCls},String(n.status)+(n.statusText?' '+n.statusText:'')),
475
- el('span',{className:'rd-net-url'},n.url),
750
+ el('span',{className:'rd-net-expand'},hasDetail?'\u25B6':''),
751
+ el('span',{className:mCls},n.method||'GET'),
752
+ el('span',{className:sCls},String(sCode)),
753
+ el('span',{className:'rd-net-url'},n.url||''),
754
+ makeCopyBtn(n.url||''),
476
755
  el('span',{className:'rd-net-dur'},dur(n.duration))
477
756
  ]);
478
757
  var detail=null;
479
758
  if(hasDetail){
480
759
  var sections=[];
481
760
  if(n.requestHeaders){
482
- var s=el('div',{className:'rd-net-detail-section'},[el('div',{className:'rd-net-detail-title'},'Request Headers')]);
483
- s.appendChild(el('div',{className:'rd-net-detail-body'},fmtHeaders(n.requestHeaders)));
484
- sections.push(s);
761
+ var hCount=Object.keys(n.requestHeaders).length;
762
+ sections.push(buildNdSection('Request Headers',buildHeaderKV(n.requestHeaders),hCount,fmtHeaders(n.requestHeaders)));
485
763
  }
486
764
  if(n.requestBody){
487
- var s2=el('div',{className:'rd-net-detail-section'},[el('div',{className:'rd-net-detail-title'},'Request Body')]);
488
- s2.appendChild(el('div',{className:'rd-net-detail-body'},prettyJson(n.requestBody)));
489
- sections.push(s2);
765
+ var rbText=prettyJson(n.requestBody);
766
+ sections.push(buildNdSection('Request Body',el('pre',null,rbText),null,rbText));
490
767
  }
491
768
  if(n.responseHeaders){
492
- var s3=el('div',{className:'rd-net-detail-section'},[el('div',{className:'rd-net-detail-title'},'Response Headers')]);
493
- s3.appendChild(el('div',{className:'rd-net-detail-body'},fmtHeaders(n.responseHeaders)));
494
- sections.push(s3);
769
+ var rhCount=Object.keys(n.responseHeaders).length;
770
+ sections.push(buildNdSection('Response Headers',buildHeaderKV(n.responseHeaders),rhCount,fmtHeaders(n.responseHeaders)));
495
771
  }
496
772
  if(n.responseBody){
497
- var s4=el('div',{className:'rd-net-detail-section'},[el('div',{className:'rd-net-detail-title'},'Response Body')]);
498
- s4.appendChild(el('div',{className:'rd-net-detail-body'},prettyJson(n.responseBody)));
499
- sections.push(s4);
773
+ var respText=prettyJson(n.responseBody);
774
+ sections.push(buildNdSection('Response Body',el('pre',null,respText),null,respText));
500
775
  }
501
776
  detail=el('div',{className:'rd-net-detail'},sections);
502
777
  row.addEventListener('click',function(e){e.stopPropagation();row.classList.toggle('open')});
@@ -530,7 +805,6 @@ function createHashBadge(hash){
530
805
  return badge;
531
806
  }
532
807
 
533
- /* ── Trigger source badge helper ── */
534
808
  function createTriggerBadge(source){
535
809
  var s=source||'unknown';
536
810
  var labels={dashboard:'Dashboard',mcp:'MCP',cli:'CLI',unknown:'--'};
@@ -542,10 +816,44 @@ function createTriggerBadge(source){
542
816
  return badge;
543
817
  }
544
818
 
819
+ /* ══════════════════════════════════════════════════════════════════
820
+ Toast Notifications (Improvement 4)
821
+ ══════════════════════════════════════════════════════════════════ */
822
+ function showToast(message,type){
823
+ type=type||'info';
824
+ var container=$('#toastContainer');
825
+ var icons={success:'\u2714',error:'\u2718',info:'\u2139'};
826
+ var t=el('div',{className:'toast '+type},[
827
+ el('span',null,icons[type]||''),
828
+ el('span',null,message)
829
+ ]);
830
+ container.appendChild(t);
831
+ setTimeout(function(){
832
+ t.classList.add('fade-out');
833
+ setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t)},300);
834
+ },5000);
835
+ }
836
+
837
+ /* ══════════════════════════════════════════════════════════════════
838
+ Download helper (Improvement 8)
839
+ ══════════════════════════════════════════════════════════════════ */
840
+ function downloadFile(filename,content,mimeType){
841
+ var blob=new Blob([content],{type:mimeType||'text/plain'});
842
+ var url=URL.createObjectURL(blob);
843
+ var a=document.createElement('a');
844
+ a.href=url;a.download=filename;
845
+ document.body.appendChild(a);a.click();
846
+ document.body.removeChild(a);
847
+ URL.revokeObjectURL(url);
848
+ }
849
+
545
850
  /* ── State ── */
546
851
  var S={
547
852
  ws:null,project:null,view:'suites',selectedRun:null,
548
- liveRuns:{},liveExpanded:new Set(),liveSSOpen:new Set()
853
+ liveRuns:{},liveCollapsed:new Set(),liveSSOpen:new Set(),
854
+ runFilter:{status:'all',search:''},
855
+ lastLearningsData:null,
856
+ highlightedRunIdx:-1
549
857
  };
550
858
 
551
859
  /* ── Navigation ── */
@@ -565,12 +873,20 @@ function showView(v){
565
873
  $('#view-'+v).classList.add('active');
566
874
  }
567
875
 
568
- /* ── WebSocket ── */
876
+ /* ══════════════════════════════════════════════════════════════════
877
+ WebSocket
878
+ ══════════════════════════════════════════════════════════════════ */
569
879
  function connectWS(){
570
880
  var proto=location.protocol==='https:'?'wss:':'ws:';
571
881
  S.ws=new WebSocket(proto+'//'+location.host);
572
- S.ws.onopen=function(){$('#wsDot').style.background='var(--green)';$('#wsLabel').textContent='ws: connected';$('#wsLabel').style.color='var(--green)'};
573
- S.ws.onclose=function(){$('#wsDot').style.background='var(--red)';$('#wsLabel').textContent='ws: disconnected';$('#wsLabel').style.color='var(--text3)';setTimeout(connectWS,3000)};
882
+ S.ws.onopen=function(){
883
+ $('#wsDot').style.background='var(--green)';$('#wsLabel').textContent='ws: connected';$('#wsLabel').style.color='var(--green)';
884
+ showToast('WebSocket connected','info');
885
+ };
886
+ S.ws.onclose=function(){
887
+ $('#wsDot').style.background='var(--red)';$('#wsLabel').textContent='ws: disconnected';$('#wsLabel').style.color='var(--text3)';
888
+ setTimeout(connectWS,3000);
889
+ };
574
890
  S.ws.onerror=function(){};
575
891
  S.ws.onmessage=function(e){try{handleWS(JSON.parse(e.data))}catch(x){}};
576
892
  }
@@ -583,22 +899,16 @@ function getLiveRun(m){
583
899
  }
584
900
  function anyLiveRunning(){for(var k in S.liveRuns)if(S.liveRuns[k].on)return true;return false}
585
901
 
586
- /* Staleness guard: auto-finish stuck runs, garbage-collect old finished runs */
587
902
  setInterval(function(){
588
903
  var changed=false;
589
904
  for(var k in S.liveRuns){
590
905
  var r=S.liveRuns[k];
591
906
  var age=Date.now()-r._lastEvent;
592
- /* Mark stuck runs as done */
593
907
  if(r.on&&!r.done){
594
- /* 0/0 runs (never received any tests) — mark stale after 10s */
595
908
  if(r.total===0&&age>10000){r.on=false;r.done=true;r.stale=true;r.active=0;changed=true}
596
- /* All tests completed but run:complete never arrived */
597
909
  else if(r.completed>=r.total&&r.total>0&&age>15000){r.on=false;r.done=true;r.active=0;changed=true}
598
- /* General staleness — no events for 30s */
599
910
  else if(age>30000){r.on=false;r.done=true;r.stale=true;r.active=0;changed=true}
600
911
  }
601
- /* Auto-remove stale 0/0 runs after 15s, finished runs after 120s */
602
912
  if(r.done&&r.stale&&r.total===0&&age>15000){delete S.liveRuns[k];changed=true}
603
913
  else if(r.done&&age>120000){delete S.liveRuns[k];changed=true}
604
914
  }
@@ -609,53 +919,58 @@ function handleWS(m){
609
919
  switch(m.event){
610
920
  case 'pool:status':renderPool(m.data);break;
611
921
  case 'run:start':
612
- /* Clear all finished/stale runs when a new one starts */
613
922
  for(var dk in S.liveRuns){if(S.liveRuns[dk].done)delete S.liveRuns[dk]}
614
923
  var r=getLiveRun(m);
615
924
  r.total=m.total;r.on=true;r.done=false;
616
- S.liveExpanded=new Set();S.liveSSOpen=new Set();
925
+ S.liveCollapsed=new Set();S.liveSSOpen=new Set();
617
926
  showView('live');renderLive();break;
618
927
  case 'test:start':
619
- var r=getLiveRun(m);if(!r)break;
620
- r.active=m.activeCount;
621
- r.tests[m.name]={status:'running',actions:0,totalActions:0,error:null,actionLog:[],screenshots:[]};
928
+ var r2=getLiveRun(m);if(!r2)break;
929
+ r2.active=m.activeCount;
930
+ r2.tests[m.name]={status:'running',actions:0,totalActions:0,error:null,actionLog:[],screenshots:[],serial:m.serial||false};
622
931
  renderLive();break;
623
932
  case 'test:action':
624
- var r=getLiveRun(m);if(!r||!r.tests[m.name])break;
625
- var t=r.tests[m.name];
933
+ var r3=getLiveRun(m);if(!r3||!r3.tests[m.name])break;
934
+ var t=r3.tests[m.name];
626
935
  t.actions=m.actionIndex+1;t.totalActions=m.totalActions;t.actionType=m.action.type;
627
- t.actionLog.push({type:m.action.type,selector:m.action.selector||null,value:m.action.value||null,text:m.action.text||null,success:m.success,duration:m.duration,error:m.error||null});
936
+ t.actionLog.push({type:m.action.type,selector:m.action.selector||null,value:m.action.value||null,text:m.action.text||null,success:m.success,duration:m.duration,error:m.error||null,narrative:m.narrative||null,actionRetries:m.action.retries||0});
628
937
  if(m.screenshotPath)t.screenshots.push(m.screenshotPath);
629
938
  renderLive();break;
630
939
  case 'test:retry':
631
- var r=getLiveRun(m);if(!r||!r.tests[m.name])break;
632
- r.tests[m.name].retry=m.attempt+'/'+m.maxAttempts;
940
+ var r4=getLiveRun(m);if(!r4||!r4.tests[m.name])break;
941
+ r4.tests[m.name].retry=m.attempt+'/'+m.maxAttempts;
633
942
  renderLive();break;
634
943
  case 'test:complete':
635
- var r=getLiveRun(m);if(!r)break;
636
- r.completed++;
637
- if(m.success){r.passed++;if(r.tests[m.name])r.tests[m.name].status='passed'}
638
- else{r.failed++;if(r.tests[m.name]){r.tests[m.name].status='failed';r.tests[m.name].error=m.error}}
639
- if(r.tests[m.name]){
640
- r.tests[m.name].duration=m.duration;
641
- if(m.screenshots&&m.screenshots.length)r.tests[m.name].screenshots=m.screenshots;
642
- if(m.errorScreenshot)r.tests[m.name].errorScreenshot=m.errorScreenshot;
643
- if(m.networkLogs&&m.networkLogs.length)r.tests[m.name].networkLogs=m.networkLogs;
944
+ var r5=getLiveRun(m);if(!r5)break;
945
+ r5.completed++;
946
+ if(m.success){r5.passed++;if(r5.tests[m.name])r5.tests[m.name].status='passed'}
947
+ else{r5.failed++;if(r5.tests[m.name]){r5.tests[m.name].status='failed';r5.tests[m.name].error=m.error}}
948
+ if(r5.tests[m.name]){
949
+ r5.tests[m.name].duration=m.duration;
950
+ if(m.screenshots&&m.screenshots.length)r5.tests[m.name].screenshots=m.screenshots;
951
+ if(m.errorScreenshot)r5.tests[m.name].errorScreenshot=m.errorScreenshot;
952
+ if(m.networkLogs&&m.networkLogs.length)r5.tests[m.name].networkLogs=m.networkLogs;
644
953
  }
645
- r.active=Math.max(0,r.active-1);
954
+ r5.active=Math.max(0,r5.active-1);
646
955
  renderLive();break;
647
956
  case 'run:complete':
648
- var r=getLiveRun(m);if(r){r.on=false;r.done=true;r.active=0}
957
+ var r6=getLiveRun(m);if(r6){r6.on=false;r6.done=true;r6.active=0}
958
+ var summary=m.summary||{};
959
+ if(summary.failed>0){showToast('Run complete: '+summary.failed+' failed','error')}
960
+ else{showToast('Run complete: all '+(summary.total||0)+' passed','success')}
649
961
  renderLive();refreshRuns();refreshProjects();break;
650
962
  case 'run:error':
651
- var r=getLiveRun(m);if(r){r.on=false;r.done=true;r.tests.__error={status:'failed',error:m.error}}
963
+ var r7=getLiveRun(m);if(r7){r7.on=false;r7.done=true;r7.tests.__error={status:'failed',error:m.error}}
964
+ showToast('Run error: '+m.error,'error');
652
965
  renderLive();break;
653
966
  case 'db:updated':
654
- refreshRuns();refreshProjects();refreshScreenshots();break;
967
+ refreshRuns();refreshProjects();refreshScreenshots();refreshLearnings();break;
655
968
  }
656
969
  }
657
970
 
658
- /* ── API ── */
971
+ /* ══════════════════════════════════════════════════════════════════
972
+ API & Pool
973
+ ══════════════════════════════════════════════════════════════════ */
659
974
  function api(p){return fetch(p).then(function(r){return r.json()})}
660
975
  function triggerRun(suite,projectId){
661
976
  if(anyLiveRunning())return;
@@ -666,7 +981,6 @@ function triggerRun(suite,projectId){
666
981
  fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
667
982
  }
668
983
 
669
- /* ── Pool ── */
670
984
  function renderPool(d){
671
985
  if(!d)return;
672
986
  $('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
@@ -675,7 +989,9 @@ function renderPool(d){
675
989
  }
676
990
  function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
677
991
 
678
- /* ── Projects ── */
992
+ /* ══════════════════════════════════════════════════════════════════
993
+ Projects
994
+ ══════════════════════════════════════════════════════════════════ */
679
995
  function refreshProjects(){
680
996
  api('/api/db/projects').then(function(projects){
681
997
  var sel=$('#projectSelect'),prev=sel.value;
@@ -689,68 +1005,226 @@ function refreshProjects(){
689
1005
  $('#projectSelect').addEventListener('change',function(){
690
1006
  S.project=this.value?parseInt(this.value,10):null;
691
1007
  S.selectedRun=null;
692
- refreshRuns();refreshSuites();refreshScreenshots();
1008
+ refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();
693
1009
  });
694
1010
 
695
- /* ── Suites ── */
1011
+ /* ══════════════════════════════════════════════════════════════════
1012
+ Suites (+ Serial badges, Modules)
1013
+ ══════════════════════════════════════════════════════════════════ */
696
1014
  function refreshSuites(){
697
- var grid=$('#suiteGrid'),empty=$('#suitesEmpty');
1015
+ var grid=$('#suiteGrid'),empty=$('#suitesEmpty'),accordion=$('#suiteAccordionContainer');
698
1016
  grid.textContent='';
1017
+ var moduleSection=$('#moduleSection');
1018
+ moduleSection.textContent='';
699
1019
 
700
1020
  if(S.project){
701
- // Single project — fetch its suites
702
1021
  api('/api/db/projects/'+S.project+'/suites').then(function(suites){
703
1022
  if(!Array.isArray(suites)||suites.length===0){empty.style.display='block';empty.querySelector('p').textContent='No test suites found for this project.';return}
704
1023
  empty.style.display='none';
1024
+ $('#badgeSuites').textContent=suites.length;
705
1025
  renderSuiteCards(grid,suites,S.project);
706
1026
  }).catch(function(){});
1027
+ api('/api/db/projects/'+S.project+'/modules').then(function(modules){
1028
+ renderModules(moduleSection,modules);
1029
+ }).catch(function(){});
707
1030
  } else {
708
- // All projects — fetch each project's suites
709
1031
  api('/api/db/projects').then(function(projects){
710
1032
  if(!Array.isArray(projects)||projects.length===0){empty.style.display='block';empty.querySelector('p').textContent='No projects registered yet.';return}
711
- var loaded=0,hasAny=false;
1033
+ var loaded=0,hasAny=false,totalSuites=0;
712
1034
  projects.forEach(function(p){
713
1035
  api('/api/db/projects/'+p.id+'/suites').then(function(suites){
714
1036
  loaded++;
715
1037
  if(Array.isArray(suites)&&suites.length>0){
716
- hasAny=true;
1038
+ hasAny=true;totalSuites+=suites.length;
717
1039
  var label=el('div',{style:'grid-column:1/-1;font-family:var(--sans);font-size:13px;font-weight:600;margin-top:'+(grid.children.length?'16':'0')+'px;padding-bottom:6px;border-bottom:1px solid var(--border);color:var(--text2)'},p.name);
718
1040
  grid.appendChild(label);
719
1041
  renderSuiteCards(grid,suites,p.id);
720
1042
  }
721
- if(loaded===projects.length&&!hasAny){empty.style.display='block';empty.querySelector('p').textContent='No test suites found.'}
1043
+ if(loaded===projects.length){
1044
+ $('#badgeSuites').textContent=totalSuites;
1045
+ if(!hasAny){empty.style.display='block';empty.querySelector('p').textContent='No test suites found.'}
1046
+ }
722
1047
  }).catch(function(){loaded++;});
723
1048
  });
724
1049
  }).catch(function(){});
725
1050
  }
726
1051
  }
1052
+
1053
+ function renderProjectAccordion(container,project,suites){
1054
+ var totalTests=suites.reduce(function(sum,s){return sum+(s.testCount||0)},0);
1055
+ var body=el('div',{className:'project-accordion-body'});
1056
+ var innerGrid=el('div',{className:'suite-grid'});
1057
+ renderSuiteCards(innerGrid,suites,project.id);
1058
+ body.appendChild(innerGrid);
1059
+
1060
+ var header=el('div',{className:'project-accordion-header'},[
1061
+ el('span',{className:'project-accordion-chevron'},'\u25B6'),
1062
+ el('span',{className:'project-accordion-name'},project.name),
1063
+ el('div',{className:'project-accordion-meta'},[
1064
+ el('span',{className:'project-accordion-badge'},suites.length+(suites.length===1?' suite':' suites')),
1065
+ el('span',{className:'project-accordion-badge'},totalTests+(totalTests===1?' test':' tests'))
1066
+ ])
1067
+ ]);
1068
+
1069
+ var wrapper=el('div',{className:'project-accordion'},[header,body]);
1070
+
1071
+ header.addEventListener('click',function(){
1072
+ wrapper.classList.toggle('open');
1073
+ });
1074
+
1075
+ container.appendChild(wrapper);
1076
+ }
1077
+
1078
+ var _suiteCache={};
727
1079
  function renderSuiteCards(container,suites,projectId){
728
1080
  suites.forEach(function(s){
729
1081
  var tests=el('ul',{className:'suite-card-tests'});
730
- (s.tests||[]).forEach(function(t){tests.appendChild(el('li',null,t))});
731
1082
  var pid=projectId;
732
- var card=el('div',{className:'suite-card'},[
733
- el('div',{className:'suite-card-head'},[
1083
+ (s.tests||[]).forEach(function(t){
1084
+ var li=el('li',null,t);
1085
+ li.addEventListener('click',function(e){
1086
+ e.stopPropagation();
1087
+ var existing=li.querySelector('.suite-test-steps');
1088
+ if(existing){existing.remove();li.classList.remove('expanded');return}
1089
+ tests.querySelectorAll('.suite-test-steps').forEach(function(d){d.remove()});
1090
+ tests.querySelectorAll('li.expanded').forEach(function(l){l.classList.remove('expanded')});
1091
+ var stepsDiv=el('div',{className:'suite-test-steps'});
1092
+ stepsDiv.appendChild(el('div',{style:'color:var(--text3);font-size:10px'},'Loading...'));
1093
+ li.appendChild(stepsDiv);
1094
+ li.classList.add('expanded');
1095
+ var cacheKey=pid+'::'+s.name;
1096
+ var p=_suiteCache[cacheKey]||api('/api/db/projects/'+pid+'/suites/'+encodeURIComponent(s.name));
1097
+ _suiteCache[cacheKey]=p;
1098
+ p.then(function(data){
1099
+ stepsDiv.textContent='';
1100
+ var test=(data.tests||[]).find(function(x){return x.name===t});
1101
+ if(!test||!test.actions||!test.actions.length){
1102
+ stepsDiv.appendChild(el('div',{style:'color:var(--text3);font-size:10px'},'No actions'));
1103
+ return;
1104
+ }
1105
+ if(test.serial){
1106
+ var sb=el('span',{className:'serial-badge'},'Serial');
1107
+ li.insertBefore(sb,li.querySelector('.suite-test-steps'));
1108
+ }
1109
+ test.actions.forEach(function(a,i){
1110
+ var detail=a.selector||a.value||a.text||'';
1111
+ if(a.selector&&(a.value||a.text))detail=a.selector+' \u2192 '+(a.text||a.value);
1112
+ stepsDiv.appendChild(el('div',{className:'lt-step'},[
1113
+ el('span',{className:'step-icon',style:'color:var(--text3)'},String(i+1)),
1114
+ el('span',{className:'step-type'},a.type),
1115
+ el('span',{className:'step-detail'},detail)
1116
+ ]));
1117
+ });
1118
+ }).catch(function(){
1119
+ stepsDiv.textContent='';
1120
+ stepsDiv.appendChild(el('div',{style:'color:var(--red);font-size:10px'},'Failed to load'));
1121
+ });
1122
+ });
1123
+ tests.appendChild(li);
1124
+ });
1125
+
1126
+ var cardHead=el('div',{className:'suite-card-head',style:'cursor:pointer'},[
1127
+ el('div',{className:'suite-card-icon'},'\u25B6'),
1128
+ el('div',{className:'suite-card-info'},[
734
1129
  el('div',{className:'suite-card-name'},s.name),
735
- el('span',{className:'suite-card-count'},s.testCount+' tests')
1130
+ el('div',{className:'suite-card-file'},s.file||s.name+'.json')
736
1131
  ]),
737
- tests,
738
- el('button',{className:'btn sm primary',onclick:function(){triggerRun(s.name,pid)}},'Run Suite')
1132
+ el('div',{className:'suite-card-count'},[
1133
+ el('div',{className:'suite-card-count-num'},String(s.testCount||0)),
1134
+ el('div',{className:'suite-card-count-lbl'},'tests')
1135
+ ])
1136
+ ]);
1137
+ (function(name,projId){
1138
+ cardHead.addEventListener('click',function(){openSuiteModal(name,projId)});
1139
+ })(s.name,pid);
1140
+
1141
+ var card=el('div',{className:'suite-card'},[
1142
+ cardHead,
1143
+ el('div',{className:'suite-card-body'},[tests]),
1144
+ el('div',{className:'suite-card-footer'},[
1145
+ el('button',{className:'btn sm primary',onclick:function(){triggerRun(s.name,pid)}},'Run Suite')
1146
+ ])
739
1147
  ]);
740
1148
  container.appendChild(card);
741
1149
  });
742
1150
  }
743
1151
 
744
- /* ── Runs ── */
1152
+ function renderModules(container,modules){
1153
+ if(!Array.isArray(modules)||modules.length===0)return;
1154
+ var title=el('div',{className:'module-section-title'},[
1155
+ el('span',{className:'mod-icon'},'\u{1F9E9}'),
1156
+ document.createTextNode(' Reusable Modules ('+modules.length+')')
1157
+ ]);
1158
+ container.appendChild(title);
1159
+ var grid=el('div',{className:'module-grid'});
1160
+ modules.forEach(function(m){
1161
+ var paramsEl=null;
1162
+ if(m.params&&m.params.length){
1163
+ var items=m.params.map(function(p){return el('li',null,typeof p==='string'?p:(p.name||String(p)))});
1164
+ paramsEl=el('ul',{className:'module-card-params'},items);
1165
+ }
1166
+ var card=el('div',{className:'module-card'},[
1167
+ el('div',{className:'module-card-name'},m.name),
1168
+ m.description?el('div',{className:'module-card-desc'},m.description):null,
1169
+ el('div',{className:'module-card-meta'},[
1170
+ el('span',null,m.actionCount+' actions'),
1171
+ m.params&&m.params.length?el('span',null,m.params.length+' params'):null
1172
+ ]),
1173
+ paramsEl
1174
+ ]);
1175
+ grid.appendChild(card);
1176
+ });
1177
+ container.appendChild(grid);
1178
+ }
1179
+
1180
+ /* ══════════════════════════════════════════════════════════════════
1181
+ Runs (+ Filters)
1182
+ ══════════════════════════════════════════════════════════════════ */
1183
+ $$('.filter-btn').forEach(function(btn){
1184
+ btn.addEventListener('click',function(){
1185
+ $$('.filter-btn').forEach(function(b){b.classList.remove('active')});
1186
+ btn.classList.add('active');
1187
+ S.runFilter.status=btn.dataset.filter;
1188
+ applyRunFilters();
1189
+ });
1190
+ });
1191
+ $('#runSearchInput').addEventListener('input',function(){
1192
+ S.runFilter.search=this.value.trim().toLowerCase();
1193
+ applyRunFilters();
1194
+ });
1195
+
1196
+ var _allRunRows=[];
1197
+ function applyRunFilters(){
1198
+ _allRunRows.forEach(function(item){
1199
+ var show=true;
1200
+ var r=item.data;
1201
+ if(S.runFilter.status!=='all'){
1202
+ var total=r.total||0;var passed=r.passed||0;var failed=r.failed||0;
1203
+ if(S.runFilter.status==='pass'&&(failed>0||total===0))show=false;
1204
+ if(S.runFilter.status==='fail'&&failed===0)show=false;
1205
+ if(S.runFilter.status==='mixed'&&(failed===0||passed===0))show=false;
1206
+ }
1207
+ if(show&&S.runFilter.search){
1208
+ var suite=(r.suite_name||'all').toLowerCase();
1209
+ var proj=(r.project_name||'').toLowerCase();
1210
+ if(suite.indexOf(S.runFilter.search)===-1&&proj.indexOf(S.runFilter.search)===-1)show=false;
1211
+ }
1212
+ item.tr.style.display=show?'':'none';
1213
+ if(item.detailTr)item.detailTr.style.display=show?'':'none';
1214
+ });
1215
+ }
1216
+
745
1217
  function refreshRuns(){
746
1218
  var url=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
747
1219
  api(url).then(function(rows){
748
1220
  var chart=$('#trendChart'),body=$('#runsBody'),empty=$('#runsEmpty'),head=$('#runsHead');
749
1221
  chart.textContent='';body.textContent='';
750
- if(!Array.isArray(rows)||rows.length===0){empty.style.display='block';head.parentNode.parentNode.style.display='none';return}
1222
+ _allRunRows=[];
1223
+ S.highlightedRunIdx=-1;
1224
+ if(!Array.isArray(rows)||rows.length===0){empty.style.display='block';head.parentNode.parentNode.style.display='none';$('#badgeRuns').textContent='0';return}
751
1225
  empty.style.display='none';head.parentNode.parentNode.style.display='';
1226
+ $('#badgeRuns').textContent=rows.length;
752
1227
 
753
- // Thead
754
1228
  var htr=document.createElement('tr');
755
1229
  var cols=[];
756
1230
  if(!S.project)cols.push('Project');
@@ -759,7 +1233,6 @@ function refreshRuns(){
759
1233
  head.textContent='';head.appendChild(htr);
760
1234
  var colSpan=cols.length;
761
1235
 
762
- // Chart
763
1236
  rows.slice(0,40).slice().reverse().forEach(function(r){
764
1237
  var rate=parseFloat(r.pass_rate)||0;
765
1238
  var color=rate>=90?'var(--green)':rate>=70?'var(--amber)':'var(--red)';
@@ -768,7 +1241,6 @@ function refreshRuns(){
768
1241
  chart.appendChild(bar);
769
1242
  });
770
1243
 
771
- // Rows
772
1244
  rows.forEach(function(r){
773
1245
  var tr=document.createElement('tr');
774
1246
  tr.dataset.runId=r.id;
@@ -786,12 +1258,14 @@ function refreshRuns(){
786
1258
  tr.addEventListener('click',function(){toggleDetail(r.id,tr,colSpan)});
787
1259
  body.appendChild(tr);
788
1260
 
789
- // If this run was already expanded, re-expand it
1261
+ var item={tr:tr,data:r,detailTr:null};
790
1262
  if(r.id===S.selectedRun){
791
1263
  var detailTr=createDetailRow(colSpan);
792
1264
  body.appendChild(detailTr);
793
1265
  loadDetailInline(r.id,detailTr);
1266
+ item.detailTr=detailTr;
794
1267
  }
1268
+ _allRunRows.push(item);
795
1269
  });
796
1270
  }).catch(function(){});
797
1271
  }
@@ -802,8 +1276,12 @@ function createDetailRow(colSpan){
802
1276
  var td=document.createElement('td');
803
1277
  td.setAttribute('colspan',colSpan);
804
1278
  var wrap=el('div',{className:'rd-wrap'});
805
- var inner=el('div',{className:'rd-inner'});
806
- inner.innerHTML='<div style="color:var(--text3);font-size:11px"><span class="spinner-small"></span> Loading...</div>';
1279
+ var inner=el('div',{className:'rd-inner'},[
1280
+ el('div',{style:'color:var(--text3);font-size:11px'},[
1281
+ el('span',{className:'spinner-small'}),
1282
+ document.createTextNode(' Loading...')
1283
+ ])
1284
+ ]);
807
1285
  wrap.appendChild(inner);
808
1286
  td.appendChild(wrap);
809
1287
  detailTr.appendChild(td);
@@ -811,7 +1289,6 @@ function createDetailRow(colSpan){
811
1289
  }
812
1290
 
813
1291
  function toggleDetail(id,clickedTr,colSpan){
814
- // If already expanded, collapse
815
1292
  if(S.selectedRun===id){
816
1293
  var existing=clickedTr.nextElementSibling;
817
1294
  if(existing&&existing.classList.contains('run-detail-row')){
@@ -824,7 +1301,6 @@ function toggleDetail(id,clickedTr,colSpan){
824
1301
  return;
825
1302
  }
826
1303
 
827
- // Collapse any other open detail
828
1304
  var prevTr=document.querySelector('#runsBody tr.expanded');
829
1305
  if(prevTr){
830
1306
  prevTr.classList.remove('expanded');
@@ -836,83 +1312,106 @@ function toggleDetail(id,clickedTr,colSpan){
836
1312
  }
837
1313
  }
838
1314
 
839
- // Expand new
840
1315
  S.selectedRun=id;
841
1316
  clickedTr.classList.add('expanded');
842
1317
  var detailTr=createDetailRow(colSpan);
843
1318
  clickedTr.parentNode.insertBefore(detailTr,clickedTr.nextSibling);
844
-
845
- // Animate open
846
1319
  requestAnimationFrame(function(){
847
1320
  requestAnimationFrame(function(){
848
1321
  var w2=detailTr.querySelector('.rd-wrap');
849
1322
  if(w2)w2.classList.add('open');
850
1323
  });
851
1324
  });
852
-
853
1325
  loadDetailInline(id,detailTr);
854
1326
  }
855
1327
 
1328
+ /* ══════════════════════════════════════════════════════════════════
1329
+ Run Detail (+ Action Narratives, Retry badges, Export)
1330
+ ══════════════════════════════════════════════════════════════════ */
856
1331
  function loadDetailInline(id,detailTr){
857
1332
  api('/api/db/runs/'+id).then(function(d){
858
1333
  if(d.error)return;
859
1334
  var inner=detailTr.querySelector('.rd-inner');
860
1335
  inner.textContent='';
861
-
862
1336
  var results=d.results||[];
863
1337
 
864
- // Summary bar
1338
+ var exportBtn=el('div',null,[
1339
+ el('div',{className:'rd-s-label'},'Export'),
1340
+ el('div',{style:'margin-top:4px'},[
1341
+ el('button',{className:'btn sm',onclick:function(e){
1342
+ e.stopPropagation();
1343
+ downloadFile('run-'+id+'.json',JSON.stringify(d,null,2),'application/json');
1344
+ }},'JSON')
1345
+ ])
1346
+ ]);
865
1347
  var srcBlock=el('div',null,[el('div',{className:'rd-s-label'},'Source'),el('div',{style:'margin-top:4px'},[createTriggerBadge(d.triggeredBy)])]);
866
1348
  var summ=el('div',{className:'rd-summary'},[
867
- el('div',null,[el('div',{className:'rd-s-label'},'Suite'),el('div',{className:'rd-s-val',style:'font-size:13px;color:var(--accent)'},d.suiteName||'all')]),
1349
+ el('div',null,[el('div',{className:'rd-s-label'},'Suite'),el('div',{className:'rd-s-val',style:'font-size:14px;color:var(--accent)'},d.suiteName||'all')]),
868
1350
  srcBlock,
869
1351
  el('div',null,[el('div',{className:'rd-s-label'},'Total'),el('div',{className:'rd-s-val'},String(d.summary.total))]),
870
1352
  el('div',null,[el('div',{className:'rd-s-label'},'Passed'),el('div',{className:'rd-s-val',style:'color:var(--green)'},String(d.summary.passed))]),
871
- el('div',null,[el('div',{className:'rd-s-label'},'Failed'),el('div',{className:'rd-s-val',style:'color:var(--red)'},String(d.summary.failed))]),
872
- el('div',null,[el('div',{className:'rd-s-label'},'Duration'),el('div',{className:'rd-s-val',style:'font-size:13px;color:var(--text2)'},d.summary.duration||'-')])
1353
+ el('div',null,[el('div',{className:'rd-s-label'},'Failed'),el('div',{className:'rd-s-val',style:'color:'+(d.summary.failed>0?'var(--red)':'var(--text3)')},String(d.summary.failed))]),
1354
+ el('div',null,[el('div',{className:'rd-s-label'},'Duration'),el('div',{className:'rd-s-val',style:'font-size:14px;color:var(--text2)'},d.summary.duration||'-')]),
1355
+ exportBtn
873
1356
  ]);
874
1357
  inner.appendChild(summ);
875
1358
 
876
- // Test result cards
877
1359
  results.forEach(function(r){
878
1360
  var d2=r.durationMs?dur(r.durationMs):r.endTime&&r.startTime?dur(new Date(r.endTime)-new Date(r.startTime)):'-';
879
1361
  var flaky=r.success&&r.attempt>1;
1362
+ var state=flaky?'flaky':(r.success?'pass':'fail');
880
1363
 
881
- // Header: badge + name + duration
882
1364
  var badges=el('div',{style:'display:flex;gap:6px;align-items:center;flex-shrink:0'});
883
1365
  badges.appendChild(el('span',{className:'badge '+(r.success?'pass':'fail')},r.success?'PASS':'FAIL'));
884
1366
  if(flaky)badges.appendChild(el('span',{className:'badge flaky'},'FLAKY'));
885
1367
 
886
- var head=el('div',{className:'rd-test-head'},[
887
- badges,
888
- el('div',{className:'rd-test-name'},r.name),
889
- el('div',{className:'rd-test-dur'},d2)
890
- ]);
891
-
892
- // Body content
1368
+ var head=el('div',{className:'rd-test-head'},[badges,el('div',{className:'rd-test-name'},r.name),el('div',{className:'rd-test-dur'},d2)]);
893
1369
  var body=el('div',{className:'rd-test-body'});
894
1370
 
895
- // Retries info
896
- if(r.maxAttempts>1){
897
- body.appendChild(el('div',{className:'rd-retries'},'Attempt '+r.attempt+' of '+r.maxAttempts));
1371
+ if(r.maxAttempts>1){body.appendChild(el('div',{className:'rd-retries'},'Attempt '+r.attempt+' of '+r.maxAttempts))}
1372
+ if(r.error){
1373
+ var errDiv=el('div',{className:'rd-error-msg'});
1374
+ errDiv.appendChild(document.createTextNode(r.error));
1375
+ errDiv.appendChild(makeCopyBtn(r.error));
1376
+ body.appendChild(errDiv);
898
1377
  }
899
1378
 
900
- // Error message
901
- if(r.error){
902
- body.appendChild(el('div',{className:'rd-error-msg'},r.error));
1379
+ // Actions panel
1380
+ if(r.actions&&r.actions.length){
1381
+ var passCount=r.actions.filter(function(a){return a.success}).length;
1382
+ var failCount=r.actions.length-passCount;
1383
+ var actHead=el('div',{className:'rd-net-head'},[
1384
+ el('span',{className:'net-arrow'},'\u25B6'),
1385
+ el('span',{className:'net-title'},'Actions'),
1386
+ el('div',{className:'net-stats'},[
1387
+ el('span',{className:'net-stat'},[document.createTextNode('Steps: '),el('strong',null,String(r.actions.length))]),
1388
+ failCount?el('span',{className:'net-stat has-err'},[document.createTextNode('Failed: '),el('strong',null,String(failCount))]):null
1389
+ ])
1390
+ ]);
1391
+ var actBody=el('div',{className:'rd-net-body',style:'padding:8px 14px'});
1392
+ r.actions.forEach(function(a){
1393
+ var label=a.narrative||a.type;
1394
+ var durText=a.duration!=null?dur(a.duration):'';
1395
+ var retryBadge=null;
1396
+ if(a.actionRetries&&a.actionRetries>0){
1397
+ retryBadge=el('span',{className:'badge flaky',style:'font-size:9px;padding:1px 5px'},'\u21BB x'+a.actionRetries);
1398
+ }
1399
+ actBody.appendChild(el('div',{className:'lt-step'},[
1400
+ el('span',{className:'step-icon '+(a.success?'ok':'fail')},a.success?'\u2714':'\u2718'),
1401
+ el('span',{className:'step-detail',style:'flex:1'},label),
1402
+ retryBadge,
1403
+ el('span',{className:'step-dur'},durText)
1404
+ ]));
1405
+ });
1406
+ actHead.addEventListener('click',function(){actHead.classList.toggle('open')});
1407
+ body.appendChild(el('div',{className:'rd-net-panel'},[actHead,actBody]));
903
1408
  }
904
1409
 
905
- // Screenshots per test
1410
+ // Screenshots
906
1411
  var shots=[];
907
1412
  var hashes=r.screenshotHashes||{};
908
- (r.screenshots||[]).forEach(function(p){
909
- var fname=p.split('/').pop();
910
- shots.push({path:p,label:fname,type:'screenshot',hash:hashes[p]||null});
911
- });
912
- if(r.errorScreenshot){
913
- var ename=r.errorScreenshot.split('/').pop();
914
- shots.push({path:r.errorScreenshot,label:ename,type:'error',hash:hashes[r.errorScreenshot]||null});
915
- }
1413
+ (r.screenshots||[]).forEach(function(p){shots.push({path:p,label:p.split('/').pop(),type:'screenshot',hash:hashes[p]||null})});
1414
+ if(r.errorScreenshot){shots.push({path:r.errorScreenshot,label:r.errorScreenshot.split('/').pop(),type:'error',hash:hashes[r.errorScreenshot]||null})}
916
1415
  if(shots.length){
917
1416
  var shotsWrap=el('div',{className:'rd-shots'});
918
1417
  shots.forEach(function(s){
@@ -921,94 +1420,95 @@ function loadDetailInline(id,detailTr){
921
1420
  var capEl=el('div',{className:'rd-shot-cap'},[el('span',{className:'cap-name'},s.label)]);
922
1421
  if(s.hash){capEl.appendChild(createHashBadge(s.hash))}
923
1422
  else{(function(c,fp){ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))})})(capEl,s.path)}
924
- var shotEl=el('div',{className:'rd-shot'+(s.type==='error'?' err-shot':''),onclick:function(e){e.stopPropagation();openModal(src)}},[
925
- img,capEl
926
- ]);
927
- shotsWrap.appendChild(shotEl);
1423
+ shotsWrap.appendChild(el('div',{className:'rd-shot'+(s.type==='error'?' err-shot':''),onclick:function(e){e.stopPropagation();openModal(src)}},[img,capEl]));
928
1424
  });
929
1425
  body.appendChild(shotsWrap);
930
1426
  }
931
1427
 
932
- // Console logs (errors and warnings only)
1428
+ // Console logs
933
1429
  var cIssues=(r.consoleLogs||[]).filter(function(l){return l.type==='error'||l.type==='warn'||l.type==='warning'});
934
1430
  if(cIssues.length){
935
- var logSec=el('div',{className:'rd-logs'});
936
- logSec.appendChild(el('div',{className:'rd-log-label'},'Console'));
937
- cIssues.forEach(function(l){
938
- logSec.appendChild(el('div',{className:'rd-log-item '+l.type},'['+l.type+'] '+l.text));
939
- });
940
- body.appendChild(logSec);
1431
+ var cErrors=cIssues.filter(function(l){return l.type==='error'}).length;
1432
+ var cWarns=cIssues.length-cErrors;
1433
+ var conHead=el('div',{className:'rd-net-head'},[
1434
+ el('span',{className:'net-arrow'},'\u25B6'),
1435
+ el('span',{className:'net-title'},'Console'),
1436
+ el('div',{className:'net-stats'},[
1437
+ cErrors?el('span',{className:'net-stat has-err'},[document.createTextNode('Errors: '),el('strong',null,String(cErrors))]):null,
1438
+ cWarns?el('span',{className:'net-stat'},[document.createTextNode('Warnings: '),el('strong',null,String(cWarns))]):null
1439
+ ]),
1440
+ makeCopyBtn(function(){return cIssues.map(function(l){return '['+l.type+'] '+l.text}).join('\n')})
1441
+ ]);
1442
+ var conBody=el('div',{className:'rd-net-body'});
1443
+ cIssues.forEach(function(l){conBody.appendChild(el('div',{className:'rd-log-item '+l.type},'['+l.type+'] '+l.text))});
1444
+ conHead.addEventListener('click',function(){conHead.classList.toggle('open')});
1445
+ body.appendChild(el('div',{className:'rd-net-panel'},[conHead,conBody]));
941
1446
  }
942
1447
 
943
1448
  // Network errors
944
1449
  if(r.networkErrors&&r.networkErrors.length){
945
- var netSec=el('div',{className:'rd-logs'});
946
- netSec.appendChild(el('div',{className:'rd-log-label'},'Network Errors'));
947
- r.networkErrors.forEach(function(ne){
948
- netSec.appendChild(el('div',{className:'rd-log-item error'},'['+ne.error+'] '+ne.url));
949
- });
950
- body.appendChild(netSec);
1450
+ var neHead=el('div',{className:'rd-net-head'},[
1451
+ el('span',{className:'net-arrow'},'\u25B6'),
1452
+ el('span',{className:'net-title'},'Network Errors'),
1453
+ el('div',{className:'net-stats'},[el('span',{className:'net-stat has-err'},[document.createTextNode('Errors: '),el('strong',null,String(r.networkErrors.length))])]),
1454
+ makeCopyBtn(function(){return r.networkErrors.map(function(ne){return '['+ne.error+'] '+ne.url}).join('\n')})
1455
+ ]);
1456
+ var neBody=el('div',{className:'rd-net-body'});
1457
+ r.networkErrors.forEach(function(ne){neBody.appendChild(el('div',{className:'rd-log-item error'},'['+ne.error+'] '+ne.url))});
1458
+ neHead.addEventListener('click',function(){neHead.classList.toggle('open')});
1459
+ body.appendChild(el('div',{className:'rd-net-panel'},[neHead,neBody]));
951
1460
  }
952
1461
 
953
- // Network API logs (collapsible, clickable rows)
1462
+ // Network panel
954
1463
  if(r.networkLogs&&r.networkLogs.length){
955
1464
  var errCount=r.networkLogs.filter(function(n){return n.status>=400}).length;
956
- var netLabel='Network ('+r.networkLogs.length+' request'+(r.networkLogs.length!==1?'s':'')+(errCount?', '+errCount+' error'+(errCount!==1?'s':''):'')+')';
957
- var netToggle=el('div',{className:'rd-net-toggle'},[
1465
+ var netHead=el('div',{className:'rd-net-head'},[
958
1466
  el('span',{className:'net-arrow'},'\u25B6'),
959
- el('span',{},netLabel)
1467
+ el('span',{className:'net-title'},'Network Requests'),
1468
+ el('div',{className:'net-stats'},[
1469
+ el('span',{className:'net-stat'},[document.createTextNode('Total: '),el('strong',null,String(r.networkLogs.length))]),
1470
+ errCount?el('span',{className:'net-stat has-err'},[document.createTextNode('Errors: '),el('strong',null,String(errCount))]):null
1471
+ ])
960
1472
  ]);
961
- var netList=el('div',{className:'rd-net-list'});
962
- r.networkLogs.forEach(function(n){
963
- var built=buildNetRow(n);
964
- netList.appendChild(built.row);
965
- if(built.detail)netList.appendChild(built.detail);
966
- });
967
- netToggle.addEventListener('click',function(){
968
- netToggle.classList.toggle('open');
969
- });
970
- body.appendChild(netToggle);
971
- body.appendChild(netList);
1473
+ var netCols=el('div',{className:'rd-net-cols'},[el('span',{className:'col-e'},''),el('span',{className:'col-m'},'Method'),el('span',{className:'col-s'},'Status'),el('span',{className:'col-u'},'URL'),el('span',{className:'col-d'},'Time')]);
1474
+ var netBody=el('div',{className:'rd-net-body'},[netCols]);
1475
+ r.networkLogs.forEach(function(n){var built=buildNetRow(n);netBody.appendChild(built.row);if(built.detail)netBody.appendChild(built.detail)});
1476
+ netHead.addEventListener('click',function(){netHead.classList.toggle('open')});
1477
+ body.appendChild(el('div',{className:'rd-net-panel'},[netHead,netBody]));
972
1478
  }
973
1479
 
974
- var testCard=el('div',{className:'rd-test'},[head,body]);
975
- inner.appendChild(testCard);
1480
+ inner.appendChild(el('div',{className:'rd-test '+state},[head,body]));
976
1481
  });
977
1482
 
978
- // Re-trigger open animation if not yet open
979
1483
  var w=detailTr.querySelector('.rd-wrap');
980
- if(w&&!w.classList.contains('open')){
981
- requestAnimationFrame(function(){w.classList.add('open')});
982
- }
983
- }).catch(function(err){
1484
+ if(w&&!w.classList.contains('open')){requestAnimationFrame(function(){w.classList.add('open')})}
1485
+ }).catch(function(){
984
1486
  var inner=detailTr.querySelector('.rd-inner');
985
1487
  if(inner)inner.textContent='Failed to load run detail';
986
1488
  });
987
1489
  }
988
1490
 
989
- /* ── Screenshots ── */
1491
+ /* ══════════════════════════════════════════════════════════════════
1492
+ Screenshots
1493
+ ══════════════════════════════════════════════════════════════════ */
990
1494
  function refreshScreenshots(){
991
1495
  var gal=$('#screenshotGallery'),empty=$('#screenshotsEmpty');
992
1496
  gal.textContent='';
993
- if(!S.project){empty.style.display='block';empty.querySelector('p').textContent='Select a project to view screenshots.';return}
1497
+ if(!S.project){empty.style.display='block';empty.querySelector('p').textContent='Select a project to view screenshots.';$('#badgeScreenshots').textContent='-';return}
994
1498
  api('/api/db/projects/'+S.project+'/screenshots').then(function(files){
995
- if(!Array.isArray(files)||!files.length){empty.style.display='block';empty.querySelector('p').textContent='No screenshots for this project.';return}
1499
+ if(!Array.isArray(files)||!files.length){empty.style.display='block';empty.querySelector('p').textContent='No screenshots for this project.';$('#badgeScreenshots').textContent='0';return}
996
1500
  empty.style.display='none';
1501
+ $('#badgeScreenshots').textContent=files.length;
997
1502
  files.forEach(function(f){
998
1503
  var src='/api/image?path='+encodeURIComponent(f.path);
999
1504
  var img=document.createElement('img');img.src=src;img.alt=f.name;img.loading='lazy';
1000
1505
  var capEl=el('div',{className:'cap'},[el('span',{className:'cap-name'},f.name)]);
1001
- (function(c,fp){
1002
- ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))});
1003
- })(capEl,f.path);
1004
- var item=el('div',{className:'gallery-item',onclick:function(){openModal(src)}},[
1005
- img,capEl
1006
- ]);gal.appendChild(item);
1506
+ (function(c,fp){ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))})})(capEl,f.path);
1507
+ gal.appendChild(el('div',{className:'gallery-item',onclick:function(){openModal(src)}},[img,capEl]));
1007
1508
  });
1008
1509
  }).catch(function(){});
1009
1510
  }
1010
1511
 
1011
- /* ── Screenshot Hash Search ── */
1012
1512
  function searchByHash(){
1013
1513
  var container=$('#ssSearchResult');
1014
1514
  container.textContent='';
@@ -1020,67 +1520,38 @@ function searchByHash(){
1020
1520
  return;
1021
1521
  }
1022
1522
  fetch('/api/screenshot-hash/'+hash).then(function(res){
1023
- if(!res.ok){
1024
- container.appendChild(el('div',{className:'ss-search-error'},'Screenshot not found for hash: ss:'+hash));
1025
- return;
1026
- }
1523
+ if(!res.ok){container.appendChild(el('div',{className:'ss-search-error'},'Screenshot not found for hash: ss:'+hash));return}
1027
1524
  return res.blob();
1028
1525
  }).then(function(blob){
1029
1526
  if(!blob)return;
1030
1527
  var url=URL.createObjectURL(blob);
1031
- var wrap=el('div',{className:'ss-search-result'},[
1032
- el('div',{className:'ss-result-label'},[
1033
- createHashBadge(hash),
1034
- el('span',{},'Found')
1035
- ])
1036
- ]);
1037
- var img=document.createElement('img');
1038
- img.src=url;
1039
- img.alt='ss:'+hash;
1528
+ var wrap=el('div',{className:'ss-search-result'},[el('div',{className:'ss-result-label'},[createHashBadge(hash),el('span',{},'Found')])]);
1529
+ var img=document.createElement('img');img.src=url;img.alt='ss:'+hash;
1040
1530
  img.addEventListener('click',function(){openModal(url)});
1041
1531
  wrap.appendChild(img);
1042
1532
  container.appendChild(wrap);
1043
- }).catch(function(){
1044
- container.appendChild(el('div',{className:'ss-search-error'},'Error searching for screenshot.'));
1045
- });
1533
+ }).catch(function(){container.appendChild(el('div',{className:'ss-search-error'},'Error searching for screenshot.'))});
1046
1534
  }
1047
1535
  $('#ssHashBtn').addEventListener('click',searchByHash);
1048
1536
  $('#ssHashInput').addEventListener('keydown',function(e){if(e.key==='Enter')searchByHash()});
1049
1537
 
1050
- /* ── Live Execution ── */
1051
- function clearFinishedLiveRuns(){
1052
- for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}
1053
- renderLive();
1054
- }
1055
- function dismissLiveRun(rid){
1056
- delete S.liveRuns[rid];
1057
- renderLive();
1058
- }
1538
+ /* ══════════════════════════════════════════════════════════════════
1539
+ Live Execution (+ Retry badges, Serial badges)
1540
+ ══════════════════════════════════════════════════════════════════ */
1541
+ function clearFinishedLiveRuns(){for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}renderLive()}
1542
+ function dismissLiveRun(rid){delete S.liveRuns[rid];renderLive()}
1059
1543
  $('#liveClearBtn').addEventListener('click',clearFinishedLiveRuns);
1060
1544
 
1061
1545
  function renderLive(){
1062
1546
  var panel=$('#livePanel'),grid=$('#liveTests'),navLive=$('#navLive'),liveEmpty=$('#liveEmpty');
1063
- var runs=S.liveRuns;
1064
- var runIds=Object.keys(runs);
1065
-
1066
- if(runIds.length===0){
1067
- panel.classList.remove('active');
1068
- navLive.style.display='none';
1069
- liveEmpty.style.display='block';
1070
- $('#liveClearBtn').style.display='none';
1071
- return;
1072
- }
1547
+ var runs=S.liveRuns;var runIds=Object.keys(runs);
1548
+
1549
+ if(runIds.length===0){panel.classList.remove('active');navLive.style.display='none';liveEmpty.style.display='block';$('#liveClearBtn').style.display='none';return}
1073
1550
 
1074
- navLive.style.display='';
1075
- liveEmpty.style.display='none';
1076
- panel.classList.add('active');
1551
+ navLive.style.display='';liveEmpty.style.display='none';panel.classList.add('active');
1077
1552
 
1078
- // Aggregate stats across all runs
1079
1553
  var gTotal=0,gCompleted=0,gPassed=0,gFailed=0,gActive=0,gRunning=false,gDone=true;
1080
- runIds.forEach(function(rid){
1081
- var r=runs[rid];gTotal+=r.total;gCompleted+=r.completed;gPassed+=r.passed;gFailed+=r.failed;gActive+=r.active;
1082
- if(r.on)gRunning=true;if(!r.done)gDone=false;
1083
- });
1554
+ runIds.forEach(function(rid){var r=runs[rid];gTotal+=r.total;gCompleted+=r.completed;gPassed+=r.passed;gFailed+=r.failed;gActive+=r.active;if(r.on)gRunning=true;if(!r.done)gDone=false});
1084
1555
 
1085
1556
  var badgeActive=0;
1086
1557
  runIds.forEach(function(rid){var r=runs[rid];Object.keys(r.tests).forEach(function(n){if(n!=='__error'&&r.tests[n].status==='running')badgeActive++})});
@@ -1088,21 +1559,13 @@ function renderLive(){
1088
1559
  $('#liveBadge').style.background=gRunning?'var(--purple-dim)':gFailed>0?'var(--red-dim)':'var(--green-dim)';
1089
1560
  $('#liveBadge').style.color=gRunning?'var(--purple)':gFailed>0?'var(--red)':'var(--green)';
1090
1561
 
1091
- $('#liveTotal').textContent=gTotal;
1092
- $('#livePass').textContent=gPassed;
1093
- $('#liveFail').textContent=gFailed;
1094
- $('#liveActive').textContent=gActive;
1095
- var pct=gTotal>0?gCompleted/gTotal*100:0;
1096
- $('#liveProgressFill').style.width=pct+'%';
1097
-
1098
- // Hide single-project info (now shown per-section)
1562
+ $('#liveTotal').textContent=gTotal;$('#livePass').textContent=gPassed;$('#liveFail').textContent=gFailed;$('#liveActive').textContent=gActive;
1563
+ $('#liveProgressFill').style.width=(gTotal>0?gCompleted/gTotal*100:0)+'%';
1099
1564
  $('#liveProject').style.display='none';
1100
1565
 
1101
- // Show "Clear All" when there are finished/stale runs
1102
1566
  var hasFinished=runIds.some(function(rid){return runs[rid].done||!runs[rid].on});
1103
1567
  $('#liveClearBtn').style.display=hasFinished?'inline-block':'none';
1104
1568
 
1105
- // Header state
1106
1569
  var lbl=panel.querySelector('.live-header .label');
1107
1570
  var anyStale=runIds.some(function(rid){return runs[rid].stale});
1108
1571
  if(!gRunning&&gDone){
@@ -1111,148 +1574,99 @@ function renderLive(){
1111
1574
  var dot=lbl.querySelector('.dot');if(dot)dot.remove();
1112
1575
  $('#liveProgressFill').style.background=anyStale?'var(--yellow)':gFailed>0?'var(--red)':'var(--green)';
1113
1576
  } else {
1114
- if(!lbl.querySelector('.dot')){
1115
- lbl.textContent='';
1116
- var d=el('span',{className:'dot'});lbl.appendChild(d);lbl.appendChild(document.createTextNode(' RUNNING'));
1117
- }
1118
- lbl.style.color='var(--purple)';
1119
- $('#liveProgressFill').style.background='var(--purple)';
1577
+ if(!lbl.querySelector('.dot')){lbl.textContent='';var d=el('span',{className:'dot'});lbl.appendChild(d);lbl.appendChild(document.createTextNode(' RUNNING'))}
1578
+ lbl.style.color='var(--purple)';$('#liveProgressFill').style.background='var(--purple)';
1120
1579
  }
1121
1580
 
1122
- // Render per-run sections
1123
1581
  grid.textContent='';
1124
1582
  runIds.forEach(function(rid){
1125
1583
  var L=runs[rid];
1126
- // Project section header
1127
1584
  var projLabel=L.project||(L.cwd?L.cwd.split('/').pop():'Run');
1128
1585
  var runStatus=L.done?(L.failed>0?'fail':'pass'):'running';
1129
1586
  var dismissBtn=null;
1130
- if(L.done||!L.on){
1131
- dismissBtn=el('button',{className:'lr-dismiss',onclick:function(e){e.stopPropagation();dismissLiveRun(rid)}},'\u2715');
1132
- }
1133
- var sectionHeader=el('div',{className:'lr-section-header '+runStatus},[
1134
- el('span',{className:'lr-project-name'},projLabel),
1135
- createTriggerBadge(L.triggeredBy),
1136
- el('span',{className:'lr-section-stats'},[
1137
- el('span',{},L.completed+'/'+L.total),
1138
- L.failed>0?el('span',{style:'color:var(--red);margin-left:6px'},L.failed+' failed'):null,
1139
- L.on?el('span',{className:'spinner-small',style:'margin-left:6px'}):null
1140
- ]),
1587
+ if(L.done||!L.on){dismissBtn=el('button',{className:'lr-dismiss',onclick:function(e){e.stopPropagation();dismissLiveRun(rid)}},'\u2715')}
1588
+ grid.appendChild(el('div',{className:'lr-section-header '+runStatus},[
1589
+ el('span',{className:'lr-project-name'},projLabel),createTriggerBadge(L.triggeredBy),
1590
+ el('span',{className:'lr-section-stats'},[el('span',{},L.completed+'/'+L.total),L.failed>0?el('span',{style:'color:var(--red);margin-left:6px'},L.failed+' failed'):null,L.on?el('span',{className:'spinner-small',style:'margin-left:6px'}):null]),
1141
1591
  dismissBtn
1142
- ]);
1143
- grid.appendChild(sectionHeader);
1592
+ ]));
1144
1593
 
1145
1594
  var testGrid=el('div',{className:'lr-test-grid'});
1146
- var names=Object.keys(L.tests);
1147
- names.forEach(function(name){
1595
+ Object.keys(L.tests).forEach(function(name){
1148
1596
  if(name==='__error')return;
1149
- var t=L.tests[name];
1150
- var testKey=rid+'::'+name;
1597
+ var t=L.tests[name];var testKey=rid+'::'+name;
1151
1598
  var iconText=t.status==='passed'?'\u2714':t.status==='failed'?'\u2718':'\u25CF';
1152
1599
  var iconColor=t.status==='passed'?'color:var(--green)':t.status==='failed'?'color:var(--red)':'color:var(--purple)';
1153
1600
  var meta='';
1154
- if(t.status==='running'){
1155
- meta=t.actionType?('Step '+(t.actions||0)+'/'+(t.totalActions||'?')):'starting...';
1156
- if(t.retry)meta='Retry '+t.retry;
1157
- } else if(t.status==='passed'){meta=t.duration||'done'}
1601
+ if(t.status==='running'){meta=t.actionType?('Step '+(t.actions||0)+'/'+(t.totalActions||'?')):'starting...';if(t.retry)meta='Retry '+t.retry}
1602
+ else if(t.status==='passed'){meta=t.duration||'done'}
1158
1603
  else if(t.status==='failed'){meta=t.error||'failed'}
1159
- // Action log
1604
+
1160
1605
  var stepsEl=el('div',{className:'lt-actions'});
1161
1606
  if(t.actionLog&&t.actionLog.length>0){
1162
1607
  t.actionLog.forEach(function(a){
1163
- var detail=a.selector||a.value||a.text||'';
1608
+ var detail=a.narrative||a.selector||a.value||a.text||'';
1164
1609
  var durText=a.duration!=null?(a.duration<1000?a.duration+'ms':(a.duration/1000).toFixed(1)+'s'):'';
1610
+ var retryBadge=null;
1611
+ if(a.actionRetries&&a.actionRetries>0){retryBadge=el('span',{className:'badge flaky',style:'font-size:9px;padding:1px 5px;margin-left:4px'},'\u21BB x'+a.actionRetries)}
1165
1612
  stepsEl.appendChild(el('div',{className:'lt-step'},[
1166
1613
  el('span',{className:'step-icon '+(a.success?'ok':'fail')},a.success?'\u2714':'\u2718'),
1167
1614
  el('span',{className:'step-type'},a.type),
1168
1615
  el('span',{className:'step-detail'},detail),
1616
+ retryBadge,
1169
1617
  el('span',{className:'step-dur'},durText)
1170
1618
  ]));
1171
1619
  });
1172
- if(t.status==='running'&&t.actions<t.totalActions){
1173
- stepsEl.appendChild(el('div',{className:'lt-step'},[el('span',{className:'step-icon run spinner-small'}),el('span',{className:'step-type',style:'opacity:.6'},'waiting...')]));
1174
- }
1620
+ if(t.status==='running'&&t.actions<t.totalActions){stepsEl.appendChild(el('div',{className:'lt-step'},[el('span',{className:'step-icon run spinner-small'}),el('span',{className:'step-type',style:'opacity:.6'},'waiting...')]))}
1175
1621
  } else if(t.status==='running'){
1176
1622
  stepsEl.appendChild(el('div',{className:'lt-step'},[el('span',{className:'step-icon run spinner-small'}),el('span',{className:'step-type',style:'opacity:.6'},'connecting...')]));
1177
1623
  }
1178
1624
  var isFinished=t.status==='passed'||t.status==='failed';
1179
- var isCollapsed=isFinished&&!S.liveExpanded.has(testKey);
1180
- var summaryEl=el('div',{className:'lt-summary'},[
1181
- el('span',{className:'lt-dur'},t.duration||''),
1182
- el('span',{className:'lt-expand'},isCollapsed?'\u25BC':'\u25B2')
1183
- ]);
1184
- // Screenshots section
1625
+ var isCollapsed=isFinished&&S.liveCollapsed.has(testKey);
1626
+ var summaryEl=el('div',{className:'lt-summary'},[el('span',{className:'lt-dur'},t.duration||''),el('span',{className:'lt-expand'},isCollapsed?'\u25BC':'\u25B2')]);
1627
+
1185
1628
  var ssEl=null;
1186
1629
  var allSS=(t.screenshots||[]).slice();
1187
1630
  if(t.errorScreenshot)allSS.push(t.errorScreenshot);
1188
1631
  if(allSS.length>0){
1189
1632
  var ssOpen=S.liveSSOpen&&S.liveSSOpen.has(testKey);
1190
- var toggle=el('div',{className:'lt-screenshots-toggle'+(ssOpen?' open':'')},[
1191
- el('span',{className:'ss-arrow'},'\u25B6'),
1192
- el('span',{},'Screenshots ('+allSS.length+')')
1193
- ]);
1633
+ var toggle=el('div',{className:'lt-screenshots-toggle'+(ssOpen?' open':'')},[el('span',{className:'ss-arrow'},'\u25B6'),el('span',{},'Screenshots ('+allSS.length+')')]);
1194
1634
  var ssGridEl=el('div',{className:'lt-screenshots-grid'});
1195
1635
  allSS.forEach(function(ssPath){
1196
- var fname=ssPath.split('/').pop();
1197
- var isErr=t.errorScreenshot&&ssPath===t.errorScreenshot;
1636
+ var fname=ssPath.split('/').pop();var isErr=t.errorScreenshot&&ssPath===t.errorScreenshot;
1198
1637
  var thumb=el('div',{className:'lt-ss-thumb'});
1199
- var img=document.createElement('img');
1200
- img.src='/api/image?path='+encodeURIComponent(ssPath);
1201
- img.alt=fname;img.loading='lazy';
1638
+ var img=document.createElement('img');img.src='/api/image?path='+encodeURIComponent(ssPath);img.alt=fname;img.loading='lazy';
1202
1639
  if(isErr)thumb.style.borderColor='var(--red)';
1203
1640
  thumb.appendChild(img);
1204
1641
  thumb.addEventListener('click',function(e){e.stopPropagation();openModal('/api/image?path='+encodeURIComponent(ssPath),fname)});
1205
1642
  var labelEl=el('div',{className:'lt-ss-label'},[el('span',{style:'overflow:hidden;text-overflow:ellipsis;white-space:nowrap'},fname)]);
1206
- // Compute hash async and append badge
1207
- (function(lbl,sp){
1208
- ssHash(sp).then(function(h){lbl.appendChild(createHashBadge(h))});
1209
- })(labelEl,ssPath);
1643
+ (function(lbl,sp){ssHash(sp).then(function(h){lbl.appendChild(createHashBadge(h))})})(labelEl,ssPath);
1210
1644
  ssGridEl.appendChild(el('div',{},[thumb,labelEl]));
1211
1645
  });
1212
- toggle.addEventListener('click',function(e){
1213
- e.stopPropagation();
1214
- if(S.liveSSOpen.has(testKey))S.liveSSOpen.delete(testKey);else S.liveSSOpen.add(testKey);
1215
- toggle.classList.toggle('open');
1216
- ssGridEl.style.display=ssGridEl.style.display==='grid'?'none':'grid';
1217
- });
1646
+ toggle.addEventListener('click',function(e){e.stopPropagation();if(S.liveSSOpen.has(testKey))S.liveSSOpen.delete(testKey);else S.liveSSOpen.add(testKey);toggle.classList.toggle('open');ssGridEl.style.display=ssGridEl.style.display==='grid'?'none':'grid'});
1218
1647
  if(ssOpen)ssGridEl.style.display='grid';
1219
1648
  ssEl=el('div',{className:'lt-screenshots'},[toggle,ssGridEl]);
1220
1649
  }
1650
+
1651
+ var serialBadge=t.serial?el('span',{className:'serial-badge'},'Serial'):null;
1221
1652
  var card=el('div',{className:'live-test '+t.status+(isCollapsed?' collapsed':'')},[
1222
1653
  el('div',{className:'lt-name'},[
1223
1654
  t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
1224
- document.createTextNode(' '+name),
1225
- summaryEl
1655
+ document.createTextNode(' '+name),serialBadge,summaryEl
1226
1656
  ]),
1227
- el('div',{className:'lt-meta'},meta),
1228
- stepsEl
1657
+ el('div',{className:'lt-meta'},meta),stepsEl
1229
1658
  ]);
1230
1659
  if(ssEl)card.appendChild(ssEl);
1231
- // Network API logs in live view (clickable rows)
1232
1660
  if(t.networkLogs&&t.networkLogs.length&&!isCollapsed){
1233
1661
  var liveErrCount=t.networkLogs.filter(function(n){return n.status>=400}).length;
1234
- var liveNetLabel='Network ('+t.networkLogs.length+(liveErrCount?', '+liveErrCount+' error'+(liveErrCount!==1?'s':'')+')':')');
1235
- var liveNetToggle=el('div',{className:'rd-net-toggle',style:'margin:6px 0 0;padding-left:0'},[
1236
- el('span',{className:'net-arrow'},'\u25B6'),
1237
- el('span',{},liveNetLabel)
1238
- ]);
1239
- var liveNetList=el('div',{className:'rd-net-list'});
1240
- t.networkLogs.forEach(function(n){
1241
- var built=buildNetRow(n);
1242
- liveNetList.appendChild(built.row);
1243
- if(built.detail)liveNetList.appendChild(built.detail);
1244
- });
1245
- liveNetToggle.addEventListener('click',function(e){e.stopPropagation();liveNetToggle.classList.toggle('open')});
1246
- card.appendChild(liveNetToggle);
1247
- card.appendChild(liveNetList);
1248
- }
1249
- if(isFinished){
1250
- card.addEventListener('click',function(){
1251
- if(S.liveExpanded.has(testKey))S.liveExpanded.delete(testKey);
1252
- else S.liveExpanded.add(testKey);
1253
- renderLive();
1254
- });
1662
+ var liveNetHead=el('div',{className:'rd-net-head'},[el('span',{className:'net-arrow'},'\u25B6'),el('span',{className:'net-title'},'Network Requests'),el('div',{className:'net-stats'},[el('span',{className:'net-stat'},[document.createTextNode('Total: '),el('strong',null,String(t.networkLogs.length))]),liveErrCount?el('span',{className:'net-stat has-err'},[document.createTextNode('Errors: '),el('strong',null,String(liveErrCount))]):null])]);
1663
+ var liveNetCols=el('div',{className:'rd-net-cols'},[el('span',{className:'col-e'},''),el('span',{className:'col-m'},'Method'),el('span',{className:'col-s'},'Status'),el('span',{className:'col-u'},'URL'),el('span',{className:'col-d'},'Time')]);
1664
+ var liveNetBody=el('div',{className:'rd-net-body'},[liveNetCols]);
1665
+ t.networkLogs.forEach(function(n){var built=buildNetRow(n);liveNetBody.appendChild(built.row);if(built.detail)liveNetBody.appendChild(built.detail)});
1666
+ liveNetHead.addEventListener('click',function(e){e.stopPropagation();liveNetHead.classList.toggle('open')});
1667
+ card.appendChild(el('div',{className:'rd-net-panel',style:'margin-top:6px'},[liveNetHead,liveNetBody]));
1255
1668
  }
1669
+ if(isFinished){card.addEventListener('click',function(e){if(window.getSelection().toString())return;if(S.liveCollapsed.has(testKey))S.liveCollapsed.delete(testKey);else S.liveCollapsed.add(testKey);renderLive()})}
1256
1670
  testGrid.appendChild(card);
1257
1671
  if(!isCollapsed)stepsEl.scrollTop=stepsEl.scrollHeight;
1258
1672
  });
@@ -1260,22 +1674,194 @@ function renderLive(){
1260
1674
  });
1261
1675
  }
1262
1676
 
1263
- /* ── Actions ── */
1264
1677
  $('#btnRunAll').addEventListener('click',function(){triggerRun()});
1265
1678
 
1266
1679
  /* ── Modal ── */
1267
1680
  function openModal(src){$('#modalImg').src=src;$('#modal').classList.add('open')}
1268
1681
  $('#modal').addEventListener('click',function(){$('#modal').classList.remove('open')});
1269
- document.addEventListener('keydown',function(e){if(e.key==='Escape')$('#modal').classList.remove('open')});
1270
1682
 
1271
- /* ── Init ── */
1683
+ /* ══════════════════════════════════════════════════════════════════
1684
+ Learnings (+ Cross-project, Export)
1685
+ ══════════════════════════════════════════════════════════════════ */
1686
+ function refreshLearnings(){
1687
+ var days=$('#learningsDays').value||30;
1688
+ var url=S.project?'/api/db/projects/'+S.project+'/learnings?days='+days:'/api/db/learnings?days='+days;
1689
+ fetch(url).then(function(r){return r.json()}).then(function(data){
1690
+ if(!data||data.totalRuns===0){
1691
+ $('#learningsEmpty').style.display='block';
1692
+ $('#learningsOverview').textContent='';$('#learningsTrend').textContent='';
1693
+ $('#learningsFlaky').textContent='';$('#learningsSelectors').textContent='';
1694
+ $('#learningsPages').textContent='';$('#learningsApis').textContent='';
1695
+ $('#learningsErrors').textContent='';
1696
+ $('#badgeLearnings').textContent='-';
1697
+ return;
1698
+ }
1699
+ $('#learningsEmpty').style.display='none';
1700
+ S.lastLearningsData=data;
1701
+ var flakyCount=data.flakyTests?data.flakyTests.length:0;
1702
+ $('#badgeLearnings').textContent=flakyCount>0?flakyCount:'\u2714';
1703
+ if(flakyCount>0){$('#badgeLearnings').style.background='var(--amber-dim)';$('#badgeLearnings').style.color='var(--amber)'}
1704
+ else{$('#badgeLearnings').style.background='';$('#badgeLearnings').style.color=''}
1705
+ renderLearnOverview(data);
1706
+ renderLearnTrend(data.recentTrend||[]);
1707
+ renderLearnFlaky(data.flakyTests||[]);
1708
+ renderLearnSelectors(data.unstableSelectors||[]);
1709
+ renderLearnPages(data.failingPages||[]);
1710
+ renderLearnApis(data.apiIssues||[]);
1711
+ renderLearnErrors(data.topErrors||[]);
1712
+ }).catch(function(){$('#learningsEmpty').style.display='block'});
1713
+ }
1714
+
1715
+ function renderLearnOverview(d){
1716
+ var container=$('#learningsOverview');container.textContent='';
1717
+ var grid=document.createElement('div');grid.className='learn-grid';
1718
+ [{val:d.totalRuns,lbl:'Runs',cls:'accent'},{val:d.totalTests,lbl:'Tests',cls:'accent'},
1719
+ {val:d.overallPassRate+'%',lbl:'Pass Rate',cls:d.overallPassRate>=90?'green':d.overallPassRate>=70?'':'red'},
1720
+ {val:d.avgDurationMs<1000?d.avgDurationMs+'ms':(d.avgDurationMs/1000).toFixed(1)+'s',lbl:'Avg Duration',cls:'purple'},
1721
+ {val:(d.flakyTests?d.flakyTests.length:0),lbl:'Flaky Tests',cls:d.flakyTests&&d.flakyTests.length>0?'red':'green'},
1722
+ {val:(d.unstableSelectors?d.unstableSelectors.length:0),lbl:'Unstable Selectors',cls:d.unstableSelectors&&d.unstableSelectors.length>0?'red':'green'}
1723
+ ].forEach(function(item){
1724
+ var stat=document.createElement('div');stat.className='learn-stat';
1725
+ var valEl=document.createElement('div');valEl.className='learn-stat-val '+item.cls;valEl.textContent=item.val;
1726
+ var lblEl=document.createElement('div');lblEl.className='learn-stat-lbl';lblEl.textContent=item.lbl;
1727
+ stat.appendChild(valEl);stat.appendChild(lblEl);grid.appendChild(stat);
1728
+ });
1729
+ container.appendChild(grid);
1730
+ }
1731
+
1732
+ function renderLearnTrend(trend){
1733
+ var container=$('#learningsTrend');container.textContent='';
1734
+ if(!trend.length)return;
1735
+ var card=document.createElement('div');card.className='card';
1736
+ var label=document.createElement('div');label.className='card-label';label.textContent='Pass Rate Trend (7 days)';card.appendChild(label);
1737
+ var chartDiv=document.createElement('div');chartDiv.className='learn-trend-chart';
1738
+ var w=100/trend.length;var ns='http://www.w3.org/2000/svg';
1739
+ var svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 100 100');svg.setAttribute('preserveAspectRatio','none');
1740
+ var bg=document.createElementNS(ns,'rect');bg.setAttribute('x','0');bg.setAttribute('y','0');bg.setAttribute('width','100');bg.setAttribute('height','100');bg.setAttribute('fill','var(--surface2)');bg.setAttribute('rx','2');
1741
+ svg.appendChild(bg);
1742
+ var gridLine=document.createElementNS(ns,'line');gridLine.setAttribute('x1','0');gridLine.setAttribute('y1','50');gridLine.setAttribute('x2','100');gridLine.setAttribute('y2','50');gridLine.setAttribute('stroke','var(--border)');gridLine.setAttribute('stroke-width','0.3');gridLine.setAttribute('stroke-dasharray','2,2');svg.appendChild(gridLine);
1743
+ var pts=trend.map(function(t,i){return(i*w+w/2)+','+(100-t.pass_rate)}).join(' ');
1744
+ var poly=document.createElementNS(ns,'polygon');poly.setAttribute('points',(0*w+w/2)+',100 '+pts+' '+((trend.length-1)*w+w/2)+',100');poly.setAttribute('fill','var(--accent-dim)');svg.appendChild(poly);
1745
+ var pl=document.createElementNS(ns,'polyline');pl.setAttribute('points',pts);pl.setAttribute('fill','none');pl.setAttribute('stroke','var(--accent)');pl.setAttribute('stroke-width','1.5');svg.appendChild(pl);
1746
+ trend.forEach(function(t,i){
1747
+ var circle=document.createElementNS(ns,'circle');circle.setAttribute('cx',''+(i*w+w/2));circle.setAttribute('cy',''+(100-t.pass_rate));circle.setAttribute('r','2');circle.setAttribute('fill','var(--accent)');
1748
+ var title=document.createElementNS(ns,'title');title.textContent=t.date+': '+t.pass_rate+'% ('+t.total_tests+' tests)';circle.appendChild(title);svg.appendChild(circle);
1749
+ });
1750
+ chartDiv.appendChild(svg);card.appendChild(chartDiv);
1751
+ var dates=document.createElement('div');dates.style.cssText='display:flex;justify-content:space-between;font-size:10px;color:var(--text3);margin-top:4px';
1752
+ dates.appendChild(el('span',null,trend[0].date));dates.appendChild(el('span',null,trend[trend.length-1].date));
1753
+ card.appendChild(dates);container.appendChild(card);
1754
+ }
1755
+
1756
+ function buildLearnTable(title,headers,rows){
1757
+ var card=document.createElement('div');card.className='card learn-section';
1758
+ var h=document.createElement('div');h.className='learn-section-title';h.textContent=title;card.appendChild(h);
1759
+ var wrap=document.createElement('div');wrap.className='tbl-wrap';
1760
+ var tbl=document.createElement('table');tbl.className='learn-table';
1761
+ var thead=document.createElement('thead');var hr=document.createElement('tr');
1762
+ headers.forEach(function(hdr){var th=document.createElement('th');th.textContent=hdr;hr.appendChild(th)});
1763
+ thead.appendChild(hr);tbl.appendChild(thead);
1764
+ var tbody=document.createElement('tbody');
1765
+ rows.forEach(function(cells){
1766
+ var tr=document.createElement('tr');
1767
+ cells.forEach(function(cell){
1768
+ var td=document.createElement('td');
1769
+ if(cell.code){var code=document.createElement('code');code.textContent=cell.code;td.appendChild(code)}
1770
+ else if(cell.badge){var span=document.createElement('span');span.className='badge '+cell.cls;span.textContent=cell.badge;td.appendChild(span)}
1771
+ else{td.textContent=cell.text!==undefined?cell.text:cell}
1772
+ tr.appendChild(td);
1773
+ });
1774
+ tbody.appendChild(tr);
1775
+ });
1776
+ tbl.appendChild(tbody);wrap.appendChild(tbl);card.appendChild(wrap);return card;
1777
+ }
1778
+
1779
+ function renderLearnFlaky(flaky){var c=$('#learningsFlaky');c.textContent='';if(!flaky.length)return;c.appendChild(buildLearnTable('Flaky Tests',['Test','Flaky Rate','Occurrences','Total Runs','Last Flaky','Avg Attempts'],flaky.map(function(f){return[{code:f.test_name},{badge:f.flaky_rate+'%',cls:f.flaky_rate>30?'fail':'flaky'},{text:f.flaky_count},{text:f.total_runs},{text:(f.last_flaky||'-').split('T')[0]},{text:f.avg_attempts}]})))}
1780
+ function renderLearnSelectors(sels){var c=$('#learningsSelectors');c.textContent='';if(!sels.length)return;c.appendChild(buildLearnTable('Unstable Selectors',['Selector','Action','Fail Rate','Uses','Tests','Page'],sels.map(function(s){var sel=s.selector.length>45?s.selector.slice(0,42)+'...':s.selector;return[{code:sel},{text:s.action_type},{badge:s.fail_rate+'%',cls:s.fail_rate>30?'fail':'flaky'},{text:s.total_uses},{text:s.used_by_tests},{text:s.page_url||'-'}]})))}
1781
+ function renderLearnPages(pages){var c=$('#learningsPages');c.textContent='';if(!pages.length)return;c.appendChild(buildLearnTable('Failing Pages',['Page','Fail Rate','Visits','Console Errors','Network Errors'],pages.map(function(p){return[{code:p.url_path},{badge:p.fail_rate+'%',cls:p.fail_rate>30?'fail':'flaky'},{text:p.total_visits},{text:p.console_errors},{text:p.network_errors}]})))}
1782
+ function renderLearnApis(apis){var c=$('#learningsApis');c.textContent='';if(!apis.length)return;c.appendChild(buildLearnTable('API Issues',['Endpoint','Error Rate','Calls','Avg Duration','Status Codes'],apis.map(function(a){var ep=a.endpoint.length>45?a.endpoint.slice(0,42)+'...':a.endpoint;var d=a.avg_duration_ms<1000?Math.round(a.avg_duration_ms)+'ms':(a.avg_duration_ms/1000).toFixed(1)+'s';return[{code:ep},{badge:a.error_rate+'%',cls:a.error_rate>20?'fail':'flaky'},{text:a.total_calls},{text:d},{text:a.status_codes||'-'}]})))}
1783
+ function renderLearnErrors(errors){var c=$('#learningsErrors');c.textContent='';if(!errors.length)return;c.appendChild(buildLearnTable('Error Patterns',['Pattern','Category','Count','First Seen','Last Seen','Example Test'],errors.map(function(e){var pat=e.pattern.length>50?e.pattern.slice(0,47)+'...':e.pattern;return[{text:pat},{badge:e.category,cls:'run'},{text:e.occurrence_count},{text:(e.first_seen||'-').split('T')[0]},{text:(e.last_seen||'-').split('T')[0]},{code:e.example_test||'-'}]})))}
1784
+
1785
+ $('#btnRefreshLearnings').addEventListener('click',refreshLearnings);
1786
+ $('#learningsDays').addEventListener('change',refreshLearnings);
1787
+
1788
+ $('#btnExportLearnings').addEventListener('click',function(){
1789
+ var data=S.lastLearningsData;
1790
+ if(!data){showToast('No learnings data to export','error');return}
1791
+ var md='# E2E Learnings Report\n\n';
1792
+ md+='| Metric | Value |\n|--------|-------|\n';
1793
+ md+='| Total Runs | '+data.totalRuns+' |\n';
1794
+ md+='| Total Tests | '+data.totalTests+' |\n';
1795
+ md+='| Pass Rate | '+data.overallPassRate+'% |\n';
1796
+ md+='| Avg Duration | '+dur(data.avgDurationMs)+' |\n\n';
1797
+ if(data.flakyTests&&data.flakyTests.length){
1798
+ md+='## Flaky Tests\n\n| Test | Flaky Rate | Occurrences |\n|------|-----------|-------------|\n';
1799
+ data.flakyTests.forEach(function(f){md+='| '+f.test_name+' | '+f.flaky_rate+'% | '+f.flaky_count+' |\n'});md+='\n';
1800
+ }
1801
+ if(data.unstableSelectors&&data.unstableSelectors.length){
1802
+ md+='## Unstable Selectors\n\n| Selector | Action | Fail Rate |\n|----------|--------|-----------|\n';
1803
+ data.unstableSelectors.forEach(function(s){md+='| `'+s.selector+'` | '+s.action_type+' | '+s.fail_rate+'% |\n'});md+='\n';
1804
+ }
1805
+ downloadFile('learnings-report.md',md,'text/markdown');
1806
+ showToast('Learnings exported','success');
1807
+ });
1808
+
1809
+ /* ══════════════════════════════════════════════════════════════════
1810
+ Keyboard Shortcuts
1811
+ ══════════════════════════════════════════════════════════════════ */
1812
+ document.addEventListener('keydown',function(e){
1813
+ var tag=document.activeElement.tagName;
1814
+ if(tag==='INPUT'||tag==='SELECT'||tag==='TEXTAREA')return;
1815
+ if(e.key==='Escape'){
1816
+ if($('#kbModal').classList.contains('open')){$('#kbModal').classList.remove('open');return}
1817
+ if($('#modal').classList.contains('open')){$('#modal').classList.remove('open');return}
1818
+ if(S.selectedRun!==null){
1819
+ var expanded=document.querySelector('#runsBody tr.expanded');
1820
+ if(expanded){
1821
+ var next=expanded.nextElementSibling;
1822
+ if(next&&next.classList.contains('run-detail-row')){var w=next.querySelector('.rd-wrap');if(w)w.classList.remove('open');expanded.classList.remove('expanded');setTimeout(function(){if(next.parentNode)next.parentNode.removeChild(next)},350)}
1823
+ S.selectedRun=null;
1824
+ }
1825
+ return;
1826
+ }
1827
+ return;
1828
+ }
1829
+ if(e.key==='?'){$('#kbModal').classList.toggle('open');return}
1830
+ var viewMap={'1':'suites','2':'runs','3':'screenshots','4':'learnings','5':'live'};
1831
+ if(viewMap[e.key]){showView(viewMap[e.key]);return}
1832
+ if(e.key==='r'){
1833
+ if(S.view==='suites')refreshSuites();else if(S.view==='runs')refreshRuns();
1834
+ else if(S.view==='screenshots')refreshScreenshots();else if(S.view==='learnings')refreshLearnings();
1835
+ return;
1836
+ }
1837
+ if(S.view==='runs'&&(e.key==='j'||e.key==='k')){
1838
+ var visible=_allRunRows.filter(function(item){return item.tr.style.display!=='none'});
1839
+ if(!visible.length)return;
1840
+ if(e.key==='j')S.highlightedRunIdx=Math.min(S.highlightedRunIdx+1,visible.length-1);
1841
+ if(e.key==='k')S.highlightedRunIdx=Math.max(S.highlightedRunIdx-1,0);
1842
+ visible.forEach(function(item,i){if(i===S.highlightedRunIdx){item.tr.classList.add('selected');item.tr.scrollIntoView({block:'nearest'})}else item.tr.classList.remove('selected')});
1843
+ return;
1844
+ }
1845
+ if(S.view==='runs'&&e.key==='Enter'){
1846
+ var visible2=_allRunRows.filter(function(item){return item.tr.style.display!=='none'});
1847
+ if(S.highlightedRunIdx>=0&&S.highlightedRunIdx<visible2.length){visible2[S.highlightedRunIdx].tr.click()}
1848
+ return;
1849
+ }
1850
+ });
1851
+ $('#kbModal').addEventListener('click',function(e){if(e.target===$('#kbModal'))$('#kbModal').classList.remove('open')});
1852
+
1853
+ /* ══════════════════════════════════════════════════════════════════
1854
+ Init
1855
+ ══════════════════════════════════════════════════════════════════ */
1272
1856
  connectWS();
1273
1857
  refreshStatus();
1274
1858
  refreshProjects();
1275
1859
  refreshSuites();
1276
1860
  refreshRuns();
1277
1861
  refreshScreenshots();
1862
+ refreshLearnings();
1278
1863
  })();
1864
+
1279
1865
  </script>
1280
1866
  </body>
1281
1867
  </html>