@matware/e2e-runner 1.2.1 → 1.3.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 (88) hide show
  1. package/.claude-plugin/marketplace.json +52 -0
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/.mcp.json +2 -2
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/LICENSE +190 -0
  19. package/OPENCODE.md +166 -0
  20. package/README.md +165 -104
  21. package/agents/test-creator.md +54 -1
  22. package/agents/test-improver.md +37 -0
  23. package/bin/cli.js +409 -16
  24. package/commands/capture.md +45 -0
  25. package/commands/create-test.md +16 -1
  26. package/opencode.json +11 -0
  27. package/package.json +7 -2
  28. package/scripts/setup-opencode.sh +113 -0
  29. package/skills/e2e-testing/SKILL.md +10 -3
  30. package/skills/e2e-testing/references/action-types.md +48 -5
  31. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  32. package/skills/e2e-testing/references/graphql.md +59 -0
  33. package/skills/e2e-testing/references/issue-verification.md +59 -0
  34. package/skills/e2e-testing/references/multi-pool.md +60 -0
  35. package/skills/e2e-testing/references/network-debugging.md +62 -0
  36. package/skills/e2e-testing/references/test-json-format.md +4 -0
  37. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  38. package/skills/e2e-testing/references/variables.md +41 -0
  39. package/skills/e2e-testing/references/visual-verification.md +89 -0
  40. package/src/actions.js +475 -2
  41. package/src/ai-generate.js +139 -8
  42. package/src/app-pool.js +339 -0
  43. package/src/config.js +266 -5
  44. package/src/dashboard.js +216 -17
  45. package/src/db.js +191 -7
  46. package/src/index.js +12 -9
  47. package/src/learner-sqlite.js +458 -0
  48. package/src/learner.js +78 -6
  49. package/src/mcp-tools.js +1348 -51
  50. package/src/module-resolver.js +37 -0
  51. package/src/narrate.js +65 -0
  52. package/src/pool-manager.js +229 -0
  53. package/src/pool.js +301 -31
  54. package/src/reporter.js +86 -2
  55. package/src/runner.js +480 -71
  56. package/src/sync/auth.js +354 -0
  57. package/src/sync/client.js +572 -0
  58. package/src/sync/hub-routes.js +816 -0
  59. package/src/sync/index.js +68 -0
  60. package/src/sync/middleware.js +347 -0
  61. package/src/sync/queue.js +209 -0
  62. package/src/sync/schema.js +540 -0
  63. package/src/verify.js +10 -7
  64. package/src/visual-diff.js +446 -0
  65. package/src/watch.js +384 -0
  66. package/templates/build-dashboard.js +47 -6
  67. package/templates/dashboard/js/api.js +62 -0
  68. package/templates/dashboard/js/init.js +13 -0
  69. package/templates/dashboard/js/keyboard.js +46 -0
  70. package/templates/dashboard/js/state.js +40 -0
  71. package/templates/dashboard/js/toast.js +41 -0
  72. package/templates/dashboard/js/utils.js +216 -0
  73. package/templates/dashboard/js/view-live.js +181 -0
  74. package/templates/dashboard/js/view-runs.js +676 -0
  75. package/templates/dashboard/js/view-tests.js +294 -0
  76. package/templates/dashboard/js/view-watch.js +242 -0
  77. package/templates/dashboard/js/websocket.js +116 -0
  78. package/templates/dashboard/styles/base.css +69 -0
  79. package/templates/dashboard/styles/components.css +117 -0
  80. package/templates/dashboard/styles/view-live.css +97 -0
  81. package/templates/dashboard/styles/view-runs.css +243 -0
  82. package/templates/dashboard/styles/view-tests.css +96 -0
  83. package/templates/dashboard/styles/view-watch.css +53 -0
  84. package/templates/dashboard/template.html +181 -100
  85. package/templates/dashboard.html +1614 -547
  86. package/templates/sample-test.json +0 -8
  87. package/templates/dashboard/app.js +0 -1152
  88. package/templates/dashboard/styles.css +0 -413
@@ -7,6 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
9
9
  <style>
10
+ /* ── base.css ── */
11
+ /* ── Reset & Variables ── */
10
12
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
13
  :root{
12
14
  --bg:#090a10;--surface:#11131b;--surface2:#181b26;--surface3:#1f2333;
@@ -51,6 +53,11 @@ a{color:var(--accent);text-decoration:none}
51
53
  .pool-info{font-size:11px;color:var(--text2);line-height:1.7}
52
54
  .pool-info strong{color:var(--text)}
53
55
  .ws-dot{width:6px;height:6px;border-radius:50%;display:inline-block;margin-right:4px}
56
+ .pool-list{margin-top:6px;display:flex;flex-direction:column;gap:3px}
57
+ .pool-item{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--text2);font-family:var(--mono);padding:2px 0}
58
+ .pool-item .pool-dot{width:6px;height:6px;margin-right:0}
59
+ .pool-item strong{color:var(--text);font-weight:500}
60
+ .pool-item .pool-sessions{margin-left:auto;color:var(--text3);font-size:9px}
54
61
 
55
62
  /* ── Main ── */
56
63
  .main{margin-left:232px;flex:1;min-height:100vh;display:flex;flex-direction:column}
@@ -61,6 +68,18 @@ a{color:var(--accent);text-decoration:none}
61
68
  .view.active{display:block}
62
69
  #view-live.active{display:flex;flex-direction:column;flex:1;min-height:calc(100vh - 0px);padding:16px}
63
70
 
71
+ /* ── Responsive ── */
72
+ @media(max-width:768px){
73
+ .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}
74
+ .nav-item{justify-content:center;padding:12px}.nav-item .icon{width:auto}
75
+ .main{margin-left:60px}
76
+ .suite-grid,.gallery,.module-grid{grid-template-columns:1fr}
77
+ .lr-test-grid{grid-template-columns:1fr}
78
+ .toast-container{right:12px;bottom:12px}
79
+ }
80
+
81
+
82
+ /* ── components.css ── */
64
83
  /* ── Buttons ── */
65
84
  .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:var(--r);font-family:var(--mono);font-size:11px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface2);color:var(--text);transition:all .15s;white-space:nowrap}
66
85
  .btn:hover{background:var(--surface3);border-color:var(--border-hi)}
@@ -84,24 +103,6 @@ a{color:var(--accent);text-decoration:none}
84
103
  .stat-val.accent{color:var(--accent)}
85
104
  .stat-val.purple{color:var(--purple)}
86
105
 
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
-
105
106
  /* ── Tables ── */
106
107
  .tbl-wrap{overflow-x:auto}
107
108
  table{width:100%;border-collapse:collapse;font-size:12px}
@@ -110,32 +111,151 @@ td{padding:8px 12px;border-bottom:1px solid var(--border)}
110
111
  tbody tr{cursor:pointer;transition:background .1s}
111
112
  tbody tr:hover td{background:var(--surface2)}
112
113
  tbody tr.selected td{background:var(--accent-dim)}
114
+
115
+ /* ── Badges ── */
113
116
  .badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
114
117
  .badge.pass{background:var(--green-dim);color:var(--green)}
115
118
  .badge.fail{background:var(--red-dim);color:var(--red)}
116
119
  .badge.flaky{background:var(--amber-dim);color:var(--amber)}
117
120
  .badge.run{background:var(--purple-dim);color:var(--purple)}
118
121
 
119
- /* ── Trend Chart ── */
120
- .chart{display:flex;align-items:flex-end;gap:3px;height:60px}
121
- .chart-bar{flex:1;min-width:3px;max-width:16px;border-radius:2px 2px 0 0;cursor:pointer;position:relative;transition:opacity .15s}
122
- .chart-bar:hover{opacity:.75}
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}
124
- .chart-bar:hover .tip{display:block}
122
+ /* ── Empty ── */
123
+ .empty{text-align:center;padding:48px 24px;color:var(--text3)}
124
+ .empty-icon{font-size:36px;margin-bottom:8px;opacity:.5}
125
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}
126
+ /* ── Modal ── */
127
+ .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}
128
+ .modal.open{display:flex}
129
+ .modal img{max-width:100%;max-height:90vh;border-radius:var(--r);cursor:default}
130
+
131
+ /* ── Toast Notifications ── */
132
+ .toast-container{position:fixed;bottom:24px;right:24px;z-index:300;display:flex;flex-direction:column-reverse;gap:8px;pointer-events:none}
133
+ .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}
134
+ .toast.success{background:var(--green);border:1px solid rgba(255,255,255,.15)}
135
+ .toast.error{background:var(--red);border:1px solid rgba(255,255,255,.15)}
136
+ .toast.info{background:var(--accent);border:1px solid rgba(255,255,255,.15)}
137
+ .toast.fade-out{animation:toastOut .3s ease forwards}
138
+ @keyframes toastIn{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}
139
+ @keyframes toastOut{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(24px)}}
140
+ .toast.clickable{cursor:pointer}
141
+ .toast.clickable:hover{filter:brightness(1.1)}
142
+
143
+ /* ── Copy Button ── */
144
+ .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}
145
+ .copy-btn:hover{color:var(--accent);border-color:var(--accent);background:var(--accent-dim)}
146
+ .copy-btn.copied{color:var(--green);border-color:var(--green);background:var(--green-dim)}
147
+
148
+ /* ── Screenshot Hash Badge ── */
149
+ .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}
150
+ .ss-hash:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
151
+ .ss-hash.copied{border-color:var(--green);color:var(--green);background:var(--green-dim)}
152
+ .ss-hash .ss-icon{font-size:10px;line-height:1}
138
153
 
154
+ /* ── Trigger Source Badges ── */
155
+ .trigger-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;font-family:var(--mono);white-space:nowrap}
156
+ .trigger-badge.src-dashboard{background:rgba(127,140,162,.10);color:var(--text2)}
157
+ .trigger-badge.src-mcp{background:var(--purple-dim);color:var(--purple)}
158
+ .trigger-badge.src-cli{background:var(--accent-dim);color:var(--accent)}
159
+ .trigger-badge.src-unknown{background:rgba(70,75,98,.15);color:var(--text3)}
160
+ .trigger-badge .trig-icon{font-size:11px;line-height:1}
161
+
162
+ /* ── Driver Badges ── */
163
+ .driver-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;font-family:var(--mono);white-space:nowrap}
164
+ .driver-badge.drv-browserless{background:var(--accent-dim)}
165
+ .driver-badge.drv-cdp{background:var(--purple-dim)}
166
+ .driver-badge.drv-steel{background:var(--amber-dim)}
167
+ .driver-badge .drv-icon{font-size:11px;line-height:1}
168
+
169
+ /* ── Filter Bar ── */
170
+ .filter-bar{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}
171
+ .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}
172
+ .filter-btn:hover{background:var(--surface3);border-color:var(--border-hi)}
173
+ .filter-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
174
+ .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}
175
+ .filter-bar input:focus{outline:none;border-color:var(--accent)}
176
+ .filter-bar input::placeholder{color:var(--text3)}
177
+
178
+ /* ── Inner Tabs ── */
179
+ .tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:20px}
180
+ .tab-btn{padding:8px 16px;font-family:var(--mono);font-size:11px;font-weight:500;color:var(--text3);cursor:pointer;border:none;background:transparent;border-bottom:2px solid transparent;transition:all .15s}
181
+ .tab-btn:hover{color:var(--text2);background:var(--surface2)}
182
+ .tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
183
+ .tab-pane{display:none}
184
+ .tab-pane.active{display:block}
185
+
186
+ /* ── Keyboard Shortcuts Modal ── */
187
+ .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}
188
+ .kb-modal.open{display:flex}
189
+ .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}
190
+ .kb-modal-content h2{font-family:var(--sans);font-size:16px;font-weight:700;margin-bottom:16px;color:var(--text)}
191
+ .kb-row{display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border)}
192
+ .kb-row:last-child{border-bottom:none}
193
+ .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)}
194
+ .kb-desc{font-size:12px;color:var(--text2)}
195
+
196
+ /* ── Serial / Pool Badges ── */
197
+ .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}
198
+ .pool-badge{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:600;background:rgba(99,102,241,.15);color:#818cf8;vertical-align:middle;margin-left:4px;font-family:var(--mono);letter-spacing:-.3px}
199
+ .pool-badge::before{content:'\1F517';font-size:8px}
200
+
201
+
202
+ /* ── view-watch.css ── */
203
+ /* ── Watch View: Project Cards ── */
204
+ .watch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-bottom:24px}
205
+ .watch-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;transition:border-color .2s,box-shadow .2s}
206
+ .watch-card:hover{border-color:var(--border-hi);box-shadow:0 2px 12px rgba(0,0,0,.25)}
207
+ .watch-card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
208
+ .watch-card-name{font-family:var(--sans);font-size:14px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
209
+ .watch-card-icons{display:flex;gap:6px;flex-shrink:0;margin-left:8px}
210
+ .watch-card-icons .btn{padding:3px 8px;font-size:10px}
211
+
212
+ /* ── Sparkline ── */
213
+ .watch-sparkline{height:40px;margin-bottom:10px}
214
+ .watch-sparkline svg{width:100%;height:100%;display:block}
215
+
216
+ /* ── Card Footer ── */
217
+ .watch-card-footer{display:flex;align-items:center;justify-content:space-between;font-size:11px}
218
+ .watch-card-status{display:flex;align-items:center;gap:6px}
219
+ .watch-card-status .status-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
220
+ .watch-card-status .status-dot.green{background:var(--green);box-shadow:0 0 6px var(--green)}
221
+ .watch-card-status .status-dot.red{background:var(--red);box-shadow:0 0 6px var(--red)}
222
+ .watch-card-status .status-dot.amber{background:var(--amber);box-shadow:0 0 6px var(--amber)}
223
+ .watch-card-status .status-dot.dim{background:var(--text3)}
224
+ .watch-card-rate{font-weight:600}
225
+ .watch-card-rate.green{color:var(--green)}
226
+ .watch-card-rate.amber{color:var(--amber)}
227
+ .watch-card-rate.red{color:var(--red)}
228
+
229
+ .watch-card-meta{display:flex;flex-direction:column;gap:4px;margin-top:10px;font-size:10px;color:var(--text3)}
230
+ .watch-card-meta span{display:flex;align-items:center;gap:6px}
231
+ .watch-card-countdown{color:var(--accent);font-weight:500;font-variant-numeric:tabular-nums}
232
+ .watch-card-commit{font-family:var(--mono);color:var(--text3)}
233
+
234
+ /* ── Event Log ── */
235
+ .watch-event-log{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
236
+ .watch-event-log-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);background:var(--surface2)}
237
+ .watch-event-log-header .title{font-family:var(--sans);font-size:13px;font-weight:600}
238
+ .watch-event-log-body{max-height:400px;overflow-y:auto}
239
+ .watch-event-row{display:grid;grid-template-columns:120px 130px minmax(80px,1fr) 46px 74px 40px 52px 60px;align-items:center;gap:0;padding:7px 16px;border-bottom:1px solid var(--border);font-size:11px;transition:background .1s}
240
+ .watch-event-row:last-child{border-bottom:none}
241
+ .watch-event-row:hover{background:var(--surface2)}
242
+ .watch-event-row.we-header{font-size:9px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;padding:6px 16px;background:var(--surface2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:1}
243
+ .watch-event-row.we-header:hover{background:var(--surface2)}
244
+ .watch-event-time{color:var(--text3);font-variant-numeric:tabular-nums;font-family:var(--mono);font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
245
+ .watch-event-project{color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:8px}
246
+ .watch-event-suite{color:var(--accent);font-family:var(--mono);font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:8px}
247
+ .watch-event-result{justify-self:center}
248
+ .watch-event-counts{font-family:var(--mono);font-size:10px;color:var(--text2);white-space:nowrap;text-align:center}
249
+ .watch-event-counts .we-counts-ok{color:var(--green)}
250
+ .watch-event-rate{font-weight:600;color:var(--text2);font-variant-numeric:tabular-nums;text-align:right}
251
+ .watch-event-duration{color:var(--text3);font-family:var(--mono);font-size:10px;text-align:right}
252
+ .we-trigger{color:var(--text3);font-size:9px;padding:1px 6px;border-radius:8px;background:var(--surface3);white-space:nowrap;text-align:center;justify-self:end}
253
+
254
+ /* ── Watch Table (legacy) ── */
255
+ .watch-jobs-table{width:100%;border-collapse:collapse;font-size:11px}
256
+
257
+
258
+ /* ── view-tests.css ── */
139
259
  /* ── Suite Cards ── */
140
260
  .suite-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px;padding:16px}
141
261
  .suite-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;transition:border-color .2s,box-shadow .2s}
@@ -198,94 +318,49 @@ tbody tr.selected td{background:var(--accent-dim)}
198
318
  .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
319
  .suite-modal-expect-label{font-weight:600;flex-shrink:0}
200
320
 
201
- /* ── Live Execution ── */
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}
203
- .live-panel.active{display:flex;flex:1;min-height:0}
204
- @keyframes fadeSlide{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
205
- .live-header{padding:14px 16px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--border);background:var(--purple-dim)}
206
- .live-header .label{font-weight:600;color:var(--purple);font-size:12px;display:flex;align-items:center;gap:8px}
207
- .live-header .label .dot{width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
208
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
209
- .live-project{display:flex;align-items:center;padding:2px 10px;background:rgba(255,255,255,.05);border-radius:4px;border:1px solid var(--border)}
210
- .live-stats{display:flex;gap:16px;font-size:11px;color:var(--text2);margin-left:auto}
211
- .live-stats span strong{color:var(--text)}
212
- .live-progress{height:3px;background:var(--surface3)}
213
- .live-progress-fill{height:100%;background:var(--purple);transition:width .4s;border-radius:0 2px 2px 0}
214
- .live-tests{padding:12px 16px;display:flex;flex-direction:column;gap:2px;flex:1;overflow-y:auto;min-height:0}
215
- .live-test{padding:10px 12px;border-radius:var(--r);border-left:3px solid var(--text3);background:var(--surface2);font-size:11px;transition:border-color .2s,padding .25s,max-height .35s cubic-bezier(.4,0,.2,1)}
216
- .live-test.running{border-left-color:var(--purple)}
217
- .live-test.passed{border-left-color:var(--green)}
218
- .live-test.failed{border-left-color:var(--red)}
219
- .live-test.collapsed{cursor:pointer;padding:6px 12px}
220
- .live-test.collapsed:hover{background:var(--surface3)}
221
- .live-test.collapsed .lt-meta,.live-test.collapsed .lt-actions,.live-test.collapsed .lt-screenshots{display:none}
222
- .live-test.collapsed .lt-name{margin-bottom:0}
223
- .live-test.collapsed .lt-summary{display:flex}
224
- .live-test .lt-name{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:6px}
225
- .live-test .lt-summary{display:none;align-items:center;gap:8px;margin-left:auto;font-size:10px;color:var(--text3);font-family:var(--mono)}
226
- .live-test .lt-summary .lt-dur{color:var(--text2)}
227
- .live-test .lt-summary .lt-expand{color:var(--purple);font-size:9px;opacity:.6}
228
- .live-test .lt-meta{color:var(--text2);font-size:10px;margin-bottom:6px}
229
- .live-test .lt-icon{font-size:12px}
230
- .lt-actions{overflow-y:auto;border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
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}
232
- .lt-step .step-icon{flex-shrink:0;width:14px;text-align:center}
233
- .lt-step .step-icon.ok{color:var(--green)}
234
- .lt-step .step-icon.fail{color:var(--red)}
235
- .lt-step .step-icon.run{color:var(--purple)}
236
- .lt-step .step-type{color:var(--purple);font-weight:600;flex-shrink:0}
237
- .lt-step .step-detail{color:var(--text2);flex:1;min-width:0;white-space:pre-wrap;word-break:break-word}
238
- .lt-step .step-dur{color:var(--text3);flex-shrink:0;margin-left:auto}
239
- .lt-screenshots{border-top:1px solid var(--border);margin-top:6px;padding-top:6px}
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}
241
- .lt-screenshots-toggle:hover{color:var(--text)}
242
- .lt-screenshots-toggle .ss-arrow{transition:transform .2s;font-size:8px}
243
- .lt-screenshots-toggle.open .ss-arrow{transform:rotate(90deg)}
244
- .lt-screenshots-grid{display:none;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;padding-top:6px}
245
- .lt-screenshots-toggle.open+.lt-screenshots-grid{display:grid}
246
- .lt-ss-thumb{position:relative;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;aspect-ratio:16/10;background:var(--surface2)}
247
- .lt-ss-thumb img{width:100%;height:100%;object-fit:cover;display:block}
248
- .lt-ss-thumb:hover{border-color:var(--purple);box-shadow:0 0 0 1px var(--purple)}
249
- .lt-ss-label{font-size:8px;color:var(--text3);font-family:var(--mono);text-align:center;padding:2px 2px 0;display:flex;align-items:center;justify-content:center;gap:3px;flex-wrap:wrap}
250
- .lr-section-header{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;margin:6px 0 2px;border-radius:6px;font-family:var(--mono);font-size:12px;font-weight:700;border-left:3px solid var(--purple)}
251
- .lr-section-header.running{border-color:var(--purple);background:rgba(139,92,246,.08);color:var(--purple)}
252
- .lr-section-header.pass{border-color:var(--green);background:rgba(52,211,153,.08);color:var(--green)}
253
- .lr-section-header.fail{border-color:var(--red);background:rgba(248,113,113,.08);color:var(--red)}
254
- .lr-section-stats{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text2);font-weight:400}
255
- .lr-project-name{letter-spacing:.5px}
256
- .lr-test-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px;padding:4px 0 8px}
257
- .live-done{background:var(--green-dim);color:var(--green);text-align:center;padding:10px;font-weight:600;font-size:12px}
258
- .live-done.has-failures{background:var(--red-dim);color:var(--red)}
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}
260
- .live-close:hover{color:var(--text);border-color:var(--border-hi)}
261
- .live-clear-btn{padding:5px 12px;font-size:10px;font-family:var(--mono);font-weight:500;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer;display:none;transition:all .15s}
262
- .live-clear-btn:hover{color:var(--text);border-color:var(--border-hi);background:var(--surface3)}
263
- .lr-dismiss{padding:2px 6px;font-size:9px;font-family:var(--mono);background:transparent;border:1px solid transparent;border-radius:4px;color:var(--text3);cursor:pointer;transition:all .15s;margin-left:auto}
264
- .lr-dismiss:hover{color:var(--red);border-color:rgba(239,68,68,.3);background:var(--red-dim)}
321
+ /* ── Project Accordion ── */
322
+ .project-accordion{margin-bottom:2px}
323
+ .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}
324
+ .project-accordion-header:hover{background:var(--surface2);border-color:var(--border-hi)}
325
+ .project-accordion.open>.project-accordion-header{border-radius:var(--r) var(--r) 0 0;border-bottom-color:transparent;background:var(--surface2)}
326
+ .project-accordion-chevron{font-size:10px;color:var(--text3);transition:transform .2s ease;flex-shrink:0;width:16px;text-align:center}
327
+ .project-accordion.open>.project-accordion-header .project-accordion-chevron{transform:rotate(90deg);color:var(--accent)}
328
+ .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}
329
+ .project-accordion-meta{display:flex;align-items:center;gap:10px;flex-shrink:0}
330
+ .project-accordion-badge{font-size:10px;font-weight:600;padding:2px 8px;border-radius:10px;background:var(--surface3);color:var(--text2)}
331
+ .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)}
332
+ .project-accordion.open>.project-accordion-body{max-height:5000px}
265
333
 
266
- .live-nav-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
267
- .spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
268
- .spinner-small{display:inline-block;width:8px;height:8px;border:1.5px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
269
- @keyframes spin{to{transform:rotate(360deg)}}
334
+ /* ── Module Cards ── */
335
+ .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}
336
+ .module-section-title .mod-icon{color:var(--purple)}
337
+ .module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
338
+ .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}
339
+ .module-card:hover{border-color:var(--border-hi);border-left-color:var(--purple)}
340
+ .module-card-name{font-weight:600;font-size:13px;color:var(--purple);margin-bottom:4px}
341
+ .module-card-desc{font-size:11px;color:var(--text2);margin-bottom:8px}
342
+ .module-card-meta{font-size:10px;color:var(--text3);display:flex;gap:12px}
343
+ .module-card-params{list-style:none;font-size:10px;color:var(--text2);margin-top:6px}
344
+ .module-card-params li{padding:2px 0}
345
+ .module-card-params li::before{content:'$';color:var(--purple);margin-right:4px}
346
+
347
+ /* ── Variables ── */
348
+ .var-table{width:100%;border-collapse:collapse;font-size:12px}
349
+ .var-table th{text-align:left;font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;padding:8px 12px;border-bottom:1px solid var(--border)}
350
+ .var-table td{padding:8px 12px;border-bottom:1px solid var(--border)}
351
+ .var-table td code{background:var(--surface3);padding:1px 5px;border-radius:3px;font-size:11px}
352
+ .var-add-form{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;margin-bottom:16px}
353
+ .var-add-form input,.var-add-form select{padding:6px 10px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px}
354
+ .var-add-form input:focus,.var-add-form select:focus{outline:none;border-color:var(--accent)}
270
355
 
271
- /* ── Screenshots ── */
272
- .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
273
- .gallery-item{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;cursor:pointer;transition:border-color .15s}
274
- .gallery-item:hover{border-color:var(--accent)}
275
- .gallery-item img{width:100%;height:150px;object-fit:cover;display:block}
276
- .gallery-item .cap{padding:6px 10px;font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px}
277
- .gallery-item .cap .cap-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
278
- .gallery-item .cap .ss-hash{flex-shrink:0}
279
- .ss-search{display:flex;gap:8px;margin-bottom:16px;align-items:center}
280
- .ss-search input{flex:1;max-width:320px;padding:8px 12px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px}
281
- .ss-search input:focus{outline:none;border-color:var(--accent)}
282
- .ss-search input::placeholder{color:var(--text3)}
283
- .ss-search button{padding:8px 16px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px;cursor:pointer;transition:background .15s,border-color .15s}
284
- .ss-search button:hover{background:var(--surface3);border-color:var(--accent)}
285
- .ss-search-result{margin-bottom:20px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r)}
286
- .ss-search-result img{max-width:100%;max-height:500px;border-radius:var(--r);cursor:pointer;display:block;margin-top:8px}
287
- .ss-search-result .ss-result-label{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px}
288
- .ss-search-error{font-size:11px;color:var(--red);margin-bottom:12px}
356
+
357
+ /* ── view-runs.css ── */
358
+ /* ── Trend Chart ── */
359
+ .chart{display:flex;align-items:flex-end;gap:3px;height:60px}
360
+ .chart-bar{flex:1;min-width:3px;max-width:16px;border-radius:2px 2px 0 0;cursor:pointer;position:relative;transition:opacity .15s}
361
+ .chart-bar:hover{opacity:.75}
362
+ .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}
363
+ .chart-bar:hover .tip{display:block}
289
364
 
290
365
  /* ── Inline Run Detail ── */
291
366
  .run-detail-row td{padding:0!important;border-bottom:2px solid var(--purple)}
@@ -312,6 +387,8 @@ tbody tr.selected td{background:var(--accent-dim)}
312
387
  .rd-test-body{padding:16px}
313
388
  .rd-retries{font-size:11px;color:var(--amber);margin-bottom:10px;display:flex;align-items:center;gap:6px}
314
389
  .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}
390
+ .rd-error-msg .copy-btn{position:absolute;top:8px;right:8px;opacity:0}
391
+ .rd-error-msg:hover .copy-btn{opacity:1}
315
392
  .rd-shots{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px}
316
393
  .rd-shot{width:140px;border-radius:6px;overflow:hidden;border:1px solid var(--border);cursor:pointer;transition:all .2s;background:var(--surface2)}
317
394
  .rd-shot:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 6px 16px rgba(0,0,0,.35)}
@@ -326,6 +403,8 @@ tbody tr.selected td{background:var(--accent-dim)}
326
403
  .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}
327
404
  .rd-log-item.error{border-left-color:var(--red);color:var(--red)}
328
405
  .rd-log-item.warning,.rd-log-item.warn{border-left-color:var(--amber);color:var(--amber)}
406
+
407
+ /* ── Network Panel ── */
329
408
  .rd-net-panel{margin-top:4px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--surface)}
330
409
  .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
410
  .rd-net-head:hover{background:var(--surface3)}
@@ -359,10 +438,19 @@ tbody tr.selected td{background:var(--accent-dim)}
359
438
  .rd-net-status.s2xx{color:var(--green)}
360
439
  .rd-net-status.s3xx{color:var(--amber)}
361
440
  .rd-net-status.s4xx,.rd-net-status.s5xx{color:var(--red)}
441
+ .rd-net-op{flex-shrink:0;padding:1px 7px;border-radius:3px;font-size:9px;font-weight:600;background:var(--purple-dim,rgba(168,85,247,.12));color:var(--purple,#a855f7);font-family:var(--sans);letter-spacing:.01em;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
362
442
  .rd-net-url{flex:1;min-width:0;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
363
443
  .rd-net-dur{width:60px;flex-shrink:0;text-align:right;color:var(--text3);font-variant-numeric:tabular-nums}
364
444
  .rd-net-detail{display:none;border-bottom:1px solid var(--border);background:var(--bg);overflow:hidden}
365
445
  .rd-net-row.open+.rd-net-detail{display:block}
446
+ .rd-net-row .copy-btn{opacity:0;padding:1px 6px}
447
+ .rd-net-row:hover .copy-btn{opacity:1}
448
+ .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}
449
+ .rd-net-body .rd-log-item:last-child{border-bottom:none}
450
+ .rd-net-body .rd-log-item.error{border-left-color:var(--red)}
451
+ .rd-net-body .rd-log-item.warn,.rd-net-body .rd-log-item.warning{border-left-color:var(--amber)}
452
+
453
+ /* ── Network Detail Sections ── */
366
454
  .rd-nd-section{border-bottom:1px solid var(--border)}
367
455
  .rd-nd-section:last-child{border-bottom:none}
368
456
  .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}
@@ -379,107 +467,237 @@ tbody tr.selected td{background:var(--accent-dim)}
379
467
  .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
468
  .rd-hdr-val{padding:3px 0;color:var(--text2);border-bottom:1px solid var(--border);word-break:break-all}
381
469
  .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)}
470
+
393
471
  tr.expanded td{background:var(--surface2)!important}
394
472
  tr.expanded td:first-child{position:relative}
395
473
  tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--purple)}
396
474
 
397
- /* ── Screenshot Hash Badge ── */
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}
399
- .ss-hash:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
400
- .ss-hash.copied{border-color:var(--green);color:var(--green);background:var(--green-dim)}
401
- .ss-hash .ss-icon{font-size:10px;line-height:1}
402
-
403
- /* ── Trigger Source Badges ── */
404
- .trigger-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;font-family:var(--mono);white-space:nowrap}
405
- .trigger-badge.src-dashboard{background:rgba(127,140,162,.10);color:var(--text2)}
406
- .trigger-badge.src-mcp{background:var(--purple-dim);color:var(--purple)}
407
- .trigger-badge.src-cli{background:var(--accent-dim);color:var(--accent)}
408
- .trigger-badge.src-unknown{background:rgba(70,75,98,.15);color:var(--text3)}
409
- .trigger-badge .trig-icon{font-size:11px;line-height:1}
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}
475
+ /* ── Actions Panel in Run Detail ── */
476
+ .rd-actions-panel{margin-top:4px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--surface)}
477
+ .rd-actions-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--surface2);cursor:pointer;user-select:none;transition:background .15s}
478
+ .rd-actions-head:hover{background:var(--surface3)}
479
+ .rd-actions-head .act-arrow{font-size:9px;color:var(--text3);transition:transform .2s}
480
+ .rd-actions-head.open .act-arrow{transform:rotate(90deg)}
481
+ .rd-actions-body{display:none;max-height:500px;overflow-y:auto;padding:8px 14px}
482
+ .rd-actions-head.open~.rd-actions-body{display:block}
419
483
 
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)}}
484
+ /* ── Run Detail Insights ── */
485
+ .rd-insights{margin-bottom:16px;display:flex;flex-direction:column;gap:6px}
486
+ .rd-insights:empty{display:none}
487
+ .rd-ins-health{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);font-size:12px}
488
+ .rd-ins-rate{font-size:18px;font-weight:700}
489
+ .rd-ins-rate.green{color:var(--green)}
490
+ .rd-ins-rate.amber{color:var(--amber)}
491
+ .rd-ins-rate.red{color:var(--red)}
492
+ .rd-ins-trend{font-size:11px;color:var(--text2)}
493
+ .rd-ins-trend.green{color:var(--green)}
494
+ .rd-ins-trend.red{color:var(--red)}
495
+ .rd-ins-tag{padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
496
+ .rd-ins-tag.amber{background:var(--amber-dim);color:var(--amber)}
497
+ .rd-ins-tag.red{background:var(--red-dim);color:var(--red)}
498
+ .rd-ins-item{display:flex;align-items:flex-start;gap:8px;padding:6px 12px;border-radius:var(--r);font-size:11px;color:var(--text2);border-left:3px solid var(--border)}
499
+ .rd-ins-item.red{border-left-color:var(--red);background:var(--red-dim)}
500
+ .rd-ins-item.green{border-left-color:var(--green);background:var(--green-dim)}
501
+ .rd-ins-item.amber{border-left-color:var(--amber);background:var(--amber-dim)}
502
+ .rd-ins-icon{font-size:12px;flex-shrink:0;width:14px;text-align:center}
503
+
504
+ /* ── Health Banner ── */
505
+ .health-banner{display:flex;align-items:center;gap:1px;background:var(--border);border-radius:var(--r);overflow:hidden;margin-bottom:20px}
506
+ .health-banner:empty{display:none}
507
+ .hb-item{flex:1;background:var(--surface);padding:12px 16px;text-align:center;min-width:0;transition:background .15s}
508
+ .hb-item:hover{background:var(--surface2)}
509
+ .hb-val{font-size:20px;font-weight:700;margin-bottom:2px}
510
+ .hb-val.green{color:var(--green)}
511
+ .hb-val.amber{color:var(--amber)}
512
+ .hb-val.red{color:var(--red)}
513
+ .hb-val.accent{color:var(--accent)}
514
+ .hb-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em}
515
+ .hb-trend{font-size:10px;margin-top:2px}
516
+ .hb-trend.green{color:var(--green)}
517
+ .hb-trend.red{color:var(--red)}
518
+ .hb-trend.dim{color:var(--text3)}
519
+ .hb-link{background:var(--surface);padding:12px 16px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s;min-width:100px}
520
+ .hb-link:hover{background:var(--accent-dim)}
521
+ .hb-link span{font-size:11px;color:var(--accent);font-weight:600}
429
522
 
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)}
523
+ /* ── Screenshots ── */
524
+ .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
525
+ .gallery-item{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;cursor:pointer;transition:border-color .15s}
526
+ .gallery-item:hover{border-color:var(--accent)}
527
+ .gallery-item img{width:100%;height:150px;object-fit:cover;display:block}
528
+ .gallery-item .cap{padding:6px 10px;font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px}
529
+ .gallery-item .cap .cap-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
530
+ .gallery-item .cap .ss-hash{flex-shrink:0}
531
+ .ss-search{display:flex;gap:8px;margin-bottom:16px;align-items:center}
532
+ .ss-search input{flex:1;max-width:320px;padding:8px 12px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px}
533
+ .ss-search input:focus{outline:none;border-color:var(--accent)}
534
+ .ss-search input::placeholder{color:var(--text3)}
535
+ .ss-search button{padding:8px 16px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px;cursor:pointer;transition:background .15s,border-color .15s}
536
+ .ss-search button:hover{background:var(--surface3);border-color:var(--accent)}
537
+ .ss-search-result{margin-bottom:20px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r)}
538
+ .ss-search-result img{max-width:100%;max-height:500px;border-radius:var(--r);cursor:pointer;display:block;margin-top:8px}
539
+ .ss-search-result .ss-result-label{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px}
540
+ .ss-search-error{font-size:11px;color:var(--red);margin-bottom:12px}
438
541
 
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}
542
+ /* ── Learnings ── */
543
+ .learn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:20px}
544
+ .learn-stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px;text-align:center}
545
+ .learn-stat-val{font-size:24px;font-weight:700;margin-bottom:2px}
546
+ .learn-stat-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em}
547
+ .learn-section{margin-bottom:20px}
548
+ .learn-section-title{font-family:var(--sans);font-size:13px;font-weight:600;margin-bottom:10px;color:var(--text)}
549
+ .learn-table{width:100%;border-collapse:collapse;font-size:11px}
550
+ .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}
551
+ .learn-table th:hover{color:var(--text2)}
552
+ .learn-table th.sorted::after{content:' \25B2';font-size:8px}
553
+ .learn-table th.sorted.desc::after{content:' \25BC'}
554
+ .learn-table td{padding:6px 10px;border-bottom:1px solid var(--border);color:var(--text2)}
555
+ .learn-table td code{background:var(--surface3);padding:1px 5px;border-radius:3px;font-size:10px;color:var(--text)}
556
+ .learn-table tbody tr:hover td{background:var(--surface2);color:var(--text)}
557
+ .learn-trend-chart{width:100%;height:100px;margin-bottom:20px}
558
+ .learn-trend-chart svg{width:100%;height:100%}
451
559
 
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)}
560
+ /* ── Learnings Dashboard (visual cards) ── */
561
+ .learn-hero{display:flex;align-items:center;gap:24px;margin-bottom:20px;padding:20px 24px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r)}
562
+ .learn-hero-ring{position:relative;width:100px;height:100px;flex-shrink:0}
563
+ .learn-hero-ring svg{width:100%;height:100%;transform:rotate(-90deg)}
564
+ .learn-hero-ring-bg{fill:none;stroke:var(--surface3);stroke-width:8}
565
+ .learn-hero-ring-fg{fill:none;stroke-width:8;stroke-linecap:round;transition:stroke-dashoffset .6s ease}
566
+ .learn-hero-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;font-family:var(--mono)}
567
+ .learn-hero-stats{flex:1;display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
568
+ .learn-hero-stat{text-align:center}
569
+ .learn-hero-stat-val{font-size:18px;font-weight:700;font-family:var(--mono)}
570
+ .learn-hero-stat-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
571
+
572
+ .learn-cols{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
573
+ @media(max-width:900px){.learn-cols{grid-template-columns:1fr}}
574
+
575
+ .learn-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px 16px}
576
+ .learn-card-title{font-size:11px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px;display:flex;align-items:center;gap:6px}
577
+ .learn-card-title .lc-icon{font-size:13px}
578
+ .learn-card-empty{font-size:11px;color:var(--text3);font-style:italic}
579
+
580
+ .learn-item{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--border)}
581
+ .learn-item:last-child{border-bottom:none}
582
+ .learn-item-bar{flex:1;min-width:0}
583
+ .learn-item-label{font-size:11px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:3px}
584
+ .learn-item-label code{background:var(--surface3);padding:1px 4px;border-radius:3px;font-size:10px}
585
+ .learn-item-sub{font-size:9px;color:var(--text3)}
586
+ .learn-item-val{font-size:13px;font-weight:700;font-family:var(--mono);flex-shrink:0;min-width:44px;text-align:right}
587
+
588
+ .learn-bar{height:4px;border-radius:2px;background:var(--surface3);overflow:hidden;margin-top:3px}
589
+ .learn-bar-fill{height:100%;border-radius:2px;transition:width .4s ease}
590
+
591
+ .learn-verdict{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:10px;font-size:10px;font-weight:600}
592
+ .learn-verdict.good{background:var(--green-dim);color:var(--green)}
593
+ .learn-verdict.warn{background:var(--amber-dim);color:var(--amber)}
594
+ .learn-verdict.bad{background:var(--red-dim);color:var(--red)}
595
+
596
+ /* ── Pool Distribution ── */
597
+ .pool-dist{display:flex;align-items:stretch;gap:0;border-radius:6px;overflow:hidden;height:22px;margin:8px 0;font-size:10px;font-weight:600;font-family:var(--mono)}
598
+ .pool-dist-seg{display:flex;align-items:center;justify-content:center;gap:4px;padding:0 8px;color:#fff;white-space:nowrap;min-width:40px;transition:flex .3s}
599
+ .pool-dist-legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin:4px 0 8px;font-size:10px;font-family:var(--mono);color:var(--text2)}
600
+ .pool-dist-legend span::before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px;vertical-align:middle}
601
+
602
+
603
+ /* ── view-live.css ── */
604
+ /* ── Live Execution ── */
605
+ .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}
606
+ .live-panel.active{display:flex;flex:1;min-height:0}
607
+ @keyframes fadeSlide{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
608
+ .live-header{padding:14px 16px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--border);background:var(--purple-dim)}
609
+ .live-header .label{font-weight:600;color:var(--purple);font-size:12px;display:flex;align-items:center;gap:8px}
610
+ .live-header .label .dot{width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
611
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
612
+ .live-project{display:flex;align-items:center;padding:2px 10px;background:rgba(255,255,255,.05);border-radius:4px;border:1px solid var(--border)}
613
+ .live-stats{display:flex;gap:16px;font-size:11px;color:var(--text2);margin-left:auto}
614
+ .live-stats span strong{color:var(--text)}
615
+ .live-progress{height:3px;background:var(--surface3)}
616
+ .live-progress-fill{height:100%;background:var(--purple);transition:width .4s;border-radius:0 2px 2px 0}
617
+ .live-tests{padding:12px 16px;display:flex;flex-direction:column;gap:2px;overflow-y:auto;min-height:0;flex:1}
618
+ .live-test{padding:10px 12px;border-radius:var(--r);border-left:3px solid var(--text3);background:var(--surface2);font-size:11px;transition:border-color .2s,padding .25s,max-height .35s cubic-bezier(.4,0,.2,1)}
619
+ .live-test.running{border-left-color:var(--purple)}
620
+ .live-test.passed{border-left-color:var(--green)}
621
+ .live-test.failed{border-left-color:var(--red)}
622
+ .live-test.collapsed{cursor:pointer;padding:6px 12px}
623
+ .live-test.collapsed:hover{background:var(--surface3)}
624
+ .live-test.collapsed .lt-meta,.live-test.collapsed .lt-actions,.live-test.collapsed .lt-screenshots{display:none}
625
+ .live-test.collapsed .lt-name{margin-bottom:0}
626
+ .live-test.collapsed .lt-summary{display:flex}
627
+ .live-test .lt-name{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:6px}
628
+ .live-test .lt-summary{display:none;align-items:center;gap:8px;margin-left:auto;font-size:10px;color:var(--text3);font-family:var(--mono)}
629
+ .live-test .lt-summary .lt-dur{color:var(--text2)}
630
+ .live-test .lt-summary .lt-expand{color:var(--purple);font-size:9px;opacity:.6}
631
+ .live-test .lt-meta{color:var(--text2);font-size:10px;margin-bottom:6px}
632
+ .live-test .lt-icon{font-size:12px}
633
+ .lt-actions{overflow-y:auto;border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
634
+ .lt-step{display:flex;align-items:flex-start;gap:6px;padding:2px 0;font-size:10px;font-family:var(--mono);line-height:1.4}
635
+ .lt-step .step-icon{flex-shrink:0;width:14px;text-align:center}
636
+ .lt-step .step-icon.ok{color:var(--green)}
637
+ .lt-step .step-icon.fail{color:var(--red)}
638
+ .lt-step .step-icon.run{color:var(--purple)}
639
+ .lt-step .step-type{color:var(--purple);font-weight:600;flex-shrink:0}
640
+ .lt-step .step-detail{color:var(--text2);flex:1;min-width:0;white-space:pre-wrap;word-break:break-word}
641
+ .lt-step .step-dur{color:var(--text3);flex-shrink:0;margin-left:auto}
642
+ .lt-step.pool-log{background:rgba(99,102,241,.06);border-left:2px solid rgba(99,102,241,.3);padding-left:8px}
643
+ .lt-step.pool-log .step-icon{color:#818cf8}
644
+ .lt-step.pool-log .step-type{color:#818cf8;font-weight:600}
645
+ .lt-screenshots{border-top:1px solid var(--border);margin-top:6px;padding-top:6px}
646
+ .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}
647
+ .lt-screenshots-toggle:hover{color:var(--text)}
648
+ .lt-screenshots-toggle .ss-arrow{transition:transform .2s;font-size:8px}
649
+ .lt-screenshots-toggle.open .ss-arrow{transform:rotate(90deg)}
650
+ .lt-screenshots-grid{display:none;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;padding-top:6px}
651
+ .lt-screenshots-toggle.open+.lt-screenshots-grid{display:grid}
652
+ .lt-ss-thumb{position:relative;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;aspect-ratio:16/10;background:var(--surface2)}
653
+ .lt-ss-thumb img{width:100%;height:100%;object-fit:cover;display:block}
654
+ .lt-ss-thumb:hover{border-color:var(--purple);box-shadow:0 0 0 1px var(--purple)}
655
+ .lt-ss-label{font-size:8px;color:var(--text3);font-family:var(--mono);text-align:center;padding:2px 2px 0;display:flex;align-items:center;justify-content:center;gap:3px;flex-wrap:wrap}
461
656
 
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}
657
+ /* ── Live Run Sections ── */
658
+ .lr-section-header{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;margin:6px 0 2px;border-radius:6px;font-family:var(--mono);font-size:12px;font-weight:700;border-left:3px solid var(--purple)}
659
+ .lr-section-header.running{border-color:var(--purple);background:rgba(139,92,246,.08);color:var(--purple)}
660
+ .lr-section-header.pass{border-color:var(--green);background:rgba(52,211,153,.08);color:var(--green)}
661
+ .lr-section-header.fail{border-color:var(--red);background:rgba(248,113,113,.08);color:var(--red)}
662
+ .lr-section-stats{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text2);font-weight:400}
663
+ .lr-project-name{letter-spacing:.5px}
664
+ .lr-test-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px;padding:4px 0 8px}
665
+ .live-done{background:var(--green-dim);color:var(--green);text-align:center;padding:10px;font-weight:600;font-size:12px}
666
+ .live-done.has-failures{background:var(--red-dim);color:var(--red)}
667
+ .live-close{padding:4px 10px;font-size:10px;background:transparent;border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer}
668
+ .live-close:hover{color:var(--text);border-color:var(--border-hi)}
669
+ .live-clear-btn{padding:5px 12px;font-size:10px;font-family:var(--mono);font-weight:500;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer;display:none;transition:all .15s}
670
+ .live-clear-btn:hover{color:var(--text);border-color:var(--border-hi);background:var(--surface3)}
671
+ .lr-dismiss{padding:2px 6px;font-size:9px;font-family:var(--mono);background:transparent;border:1px solid transparent;border-radius:4px;color:var(--text3);cursor:pointer;transition:all .15s;margin-left:auto}
672
+ .lr-dismiss:hover{color:var(--red);border-color:rgba(239,68,68,.3);background:var(--red-dim)}
470
673
 
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}
674
+ /* ── Screencast Panel ── */
675
+ .live-body{display:flex;flex:1;min-height:0;overflow:hidden}
676
+ .live-body .live-tests{flex:1;min-width:0}
677
+ .screencast-panel{width:420px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--border);background:var(--surface2)}
678
+ .screencast-header{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--surface3)}
679
+ .screencast-label{font-size:11px;font-weight:600;color:var(--purple);white-space:nowrap}
680
+ .screencast-select{flex:1;padding:4px 8px;font-size:10px;font-family:var(--mono);background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none;cursor:pointer}
681
+ .screencast-select:focus{border-color:var(--purple)}
682
+ .screencast-viewport{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#000;position:relative}
683
+ .screencast-viewport img{max-width:100%;max-height:100%;object-fit:contain;display:none}
684
+ .screencast-placeholder{display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--text3);font-size:12px;font-family:var(--mono)}
685
+
686
+ /* ── Screencast focus badge on test cards ── */
687
+ .sc-focus-badge{cursor:pointer;font-size:10px;padding:1px 4px;border-radius:3px;opacity:.4;transition:all .15s}
688
+ .sc-focus-badge:hover{opacity:.8}
689
+ .sc-focus-badge.active{opacity:1;background:var(--purple-dim);border-radius:3px}
690
+
691
+ /* ── Screencast toggle in Tests view ── */
692
+ .screencast-toggle-label{display:flex;align-items:center;gap:4px;cursor:pointer;font-size:14px;padding:4px 8px;border-radius:4px;border:1px solid var(--border);background:var(--surface2);transition:all .15s;user-select:none}
693
+ .screencast-toggle-label:hover{border-color:var(--purple);background:var(--surface3)}
694
+ .screencast-toggle-label input{display:none}
695
+ .screencast-toggle-label:has(input:checked){border-color:var(--purple);background:var(--purple-dim);color:var(--purple)}
473
696
 
474
- /* ── Responsive ── */
475
- @media(max-width:768px){
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}
477
- .nav-item{justify-content:center;padding:12px}.nav-item .icon{width:auto}
478
- .main{margin-left:60px}
479
- .suite-grid,.gallery,.module-grid{grid-template-columns:1fr}
480
- .lr-test-grid{grid-template-columns:1fr}
481
- .toast-container{right:12px;bottom:12px}
482
- }
697
+ .live-nav-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
698
+ .spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
699
+ .spinner-small{display:inline-block;width:8px;height:8px;border:1.5px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
700
+ @keyframes spin{to{transform:rotate(360deg)}}
483
701
 
484
702
  </style>
485
703
  </head>
@@ -501,20 +719,17 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
501
719
  <div class="sidebar-section">
502
720
  <div class="sidebar-section-label">Navigation</div>
503
721
  </div>
504
- <div class="nav-item" data-view="live" id="navLive" style="display:none">
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>
722
+ <div class="nav-item active" data-view="watch">
723
+ <i class="icon">&#9202;</i><span>Watch</span>
506
724
  </div>
507
- <div class="nav-item active" data-view="suites">
508
- <i class="icon">&#9655;</i><span>Suites</span><span class="badge" id="badgeSuites">-</span>
725
+ <div class="nav-item" data-view="tests">
726
+ <i class="icon">&#9655;</i><span>Tests</span><span class="badge" id="badgeSuites">-</span>
509
727
  </div>
510
728
  <div class="nav-item" data-view="runs">
511
729
  <i class="icon">&#9776;</i><span>Runs</span><span class="badge" id="badgeRuns">-</span>
512
730
  </div>
513
- <div class="nav-item" data-view="screenshots">
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>
731
+ <div class="nav-item" data-view="live" id="navLive" style="display:none">
732
+ <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>
518
733
  </div>
519
734
 
520
735
  <div class="pool-status" id="poolStatus">
@@ -523,6 +738,7 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
523
738
  <strong>Pool</strong> <span id="poolLabel">--</span>
524
739
  </div>
525
740
  <div class="pool-info">Sessions: <strong id="poolSessions">-/-</strong></div>
741
+ <div class="pool-list" id="poolList" style="display:none"></div>
526
742
  <div class="pool-info" style="margin-top:6px">
527
743
  <span class="ws-dot" id="wsDot" style="background:var(--red)"></span>
528
744
  <span id="wsLabel" style="font-size:10px;color:var(--text3)">ws: connecting</span>
@@ -532,7 +748,148 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
532
748
 
533
749
  <div class="main">
534
750
 
535
- <!-- Live View -->
751
+ <!-- ════════════════ Watch View (default) ════════════════ -->
752
+ <div class="view active" id="view-watch">
753
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
754
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Watch</div>
755
+ </div>
756
+ <div class="watch-grid" id="watchCards"></div>
757
+ <div class="watch-event-log">
758
+ <div class="watch-event-log-header">
759
+ <span class="title">Recent Runs</span>
760
+ </div>
761
+ <div class="watch-event-log-body" id="watchEventLog"></div>
762
+ </div>
763
+ <div class="empty" id="watchEmpty" style="display:none">
764
+ <div class="empty-icon">&#9202;</div>
765
+ <p>No projects registered yet. Run some tests to see project cards here.</p>
766
+ </div>
767
+ </div>
768
+
769
+ <!-- ════════════════ Tests View (inner tabs: Suites / Modules / Variables) ════════════════ -->
770
+ <div class="view" id="view-tests">
771
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
772
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Tests</div>
773
+ <div style="display:flex;gap:8px;align-items:center">
774
+ <label class="screencast-toggle-label" title="Enable live browser screencast during test runs">
775
+ <input type="checkbox" id="screencastToggle" />
776
+ <span>&#128249;</span>
777
+ </label>
778
+ <button class="btn sm primary" id="btnRunAll">&#9655; Run All</button>
779
+ </div>
780
+ </div>
781
+ <div class="tab-bar">
782
+ <button class="tab-btn active" data-tab="testsTabSuites">Suites</button>
783
+ <button class="tab-btn" data-tab="testsTabModules">Modules</button>
784
+ <button class="tab-btn" data-tab="testsTabVariables">Variables</button>
785
+ </div>
786
+ <!-- Suites tab -->
787
+ <div class="tab-pane active" id="testsTabSuites">
788
+ <div id="suiteAccordionContainer"></div>
789
+ <div class="suite-grid" id="suiteGrid"></div>
790
+ <div class="empty" id="suitesEmpty" style="display:none">
791
+ <div class="empty-icon">&#9655;</div>
792
+ <p>No test suites found.</p>
793
+ </div>
794
+ </div>
795
+ <!-- Modules tab -->
796
+ <div class="tab-pane" id="testsTabModules">
797
+ <div id="moduleSection"></div>
798
+ </div>
799
+ <!-- Variables tab -->
800
+ <div class="tab-pane" id="testsTabVariables">
801
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
802
+ <div style="font-family:var(--sans);font-size:14px;font-weight:600;color:var(--text2)">Variables</div>
803
+ <button class="btn sm primary" id="btnAddVar">+ Add Variable</button>
804
+ </div>
805
+ <div id="varAddForm" style="display:none"></div>
806
+ <div id="variablesContainer"></div>
807
+ <div class="empty" id="variablesEmpty" style="display:none">
808
+ <div class="empty-icon">&#9881;</div>
809
+ <p>No variables set. Add variables to use <code>{{var.KEY}}</code> in your tests.</p>
810
+ </div>
811
+ </div>
812
+ </div>
813
+
814
+ <!-- ════════════════ Runs View (inner tabs: History / Screenshots / Learnings) ════════════════ -->
815
+ <div class="view" id="view-runs">
816
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
817
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Runs</div>
818
+ </div>
819
+ <div class="tab-bar">
820
+ <button class="tab-btn active" data-tab="runsTabHistory">History</button>
821
+ <button class="tab-btn" data-tab="runsTabScreenshots">Screenshots<span class="badge" id="badgeScreenshots" style="margin-left:6px">-</span></button>
822
+ <button class="tab-btn" data-tab="runsTabLearnings" id="runsTabLearnings">Learnings<span class="badge" id="badgeLearnings" style="margin-left:6px">-</span></button>
823
+ </div>
824
+ <!-- History tab -->
825
+ <div class="tab-pane active" id="runsTabHistory">
826
+ <div class="health-banner" id="runsHealthBanner"></div>
827
+ <div class="card">
828
+ <div class="card-label">Pass Rate Trend</div>
829
+ <div class="chart" id="trendChart"></div>
830
+ </div>
831
+ <div class="filter-bar" id="filterBar">
832
+ <button class="filter-btn active" data-filter="all">All</button>
833
+ <button class="filter-btn" data-filter="pass">Pass</button>
834
+ <button class="filter-btn" data-filter="fail">Fail</button>
835
+ <button class="filter-btn" data-filter="mixed">Mixed</button>
836
+ <input type="text" id="runSearchInput" placeholder="Search suite..." spellcheck="false">
837
+ </div>
838
+ <div class="card" style="padding:0">
839
+ <div class="tbl-wrap">
840
+ <table>
841
+ <thead id="runsHead"><tr></tr></thead>
842
+ <tbody id="runsBody"></tbody>
843
+ </table>
844
+ </div>
845
+ </div>
846
+ <div class="empty" id="runsEmpty" style="display:none">
847
+ <div class="empty-icon">&#9776;</div>
848
+ <p>No runs recorded yet.</p>
849
+ </div>
850
+ </div>
851
+ <!-- Screenshots tab -->
852
+ <div class="tab-pane" id="runsTabScreenshots">
853
+ <div class="ss-search">
854
+ <input type="text" id="ssHashInput" placeholder="Search by hash (e.g. ss:a3f2b1c9)" spellcheck="false">
855
+ <button id="ssHashBtn">Search</button>
856
+ </div>
857
+ <div id="ssSearchResult"></div>
858
+ <div class="gallery" id="screenshotGallery"></div>
859
+ <div class="empty" id="screenshotsEmpty" style="display:none">
860
+ <div class="empty-icon">&#9635;</div>
861
+ <p>Select a project to view screenshots.</p>
862
+ </div>
863
+ </div>
864
+ <!-- Learnings tab -->
865
+ <div class="tab-pane" id="runsTabLearnings">
866
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
867
+ <div style="font-family:var(--sans);font-size:14px;font-weight:600;color:var(--text2)">Learnings</div>
868
+ <div style="display:flex;gap:8px;align-items:center">
869
+ <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">
870
+ <option value="7">7 days</option>
871
+ <option value="14">14 days</option>
872
+ <option value="30" selected>30 days</option>
873
+ <option value="90">90 days</option>
874
+ </select>
875
+ <button class="btn sm" id="btnExportLearnings">Export MD</button>
876
+ <button class="btn sm" id="btnRefreshLearnings">Refresh</button>
877
+ </div>
878
+ </div>
879
+ <div id="learnDash">
880
+ <div id="learnHero"></div>
881
+ <div id="learnCards" class="learn-cols"></div>
882
+ <div id="learnTrend"></div>
883
+ <div id="learnBottom" class="learn-cols"></div>
884
+ </div>
885
+ <div class="empty" id="learningsEmpty" style="display:none">
886
+ <div class="empty-icon">&#9733;</div>
887
+ <p>No learnings data yet. Run some tests to start building knowledge.</p>
888
+ </div>
889
+ </div>
890
+ </div>
891
+
892
+ <!-- ════════════════ Live View ════════════════ -->
536
893
  <div class="view" id="view-live">
537
894
  <div class="live-panel active" id="livePanel">
538
895
  <div class="live-header">
@@ -551,114 +908,56 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
551
908
  <button class="live-clear-btn" id="liveClearBtn">Clear All</button>
552
909
  </div>
553
910
  <div class="live-progress"><div class="live-progress-fill" id="liveProgressFill" style="width:0"></div></div>
554
- <div class="live-tests" id="liveTests"></div>
911
+ <div class="live-body">
912
+ <div class="live-tests" id="liveTests"></div>
913
+ <div class="screencast-panel" id="screencastPanel" style="display:none">
914
+ <div class="screencast-header">
915
+ <span class="screencast-label">&#128249; Screencast</span>
916
+ <select id="screencastSelect" class="screencast-select"><option value="">Select test...</option></select>
917
+ </div>
918
+ <div class="screencast-viewport">
919
+ <img id="screencastImg" alt="Browser screencast" />
920
+ <div class="screencast-placeholder" id="screencastPlaceholder">Select a running test to watch</div>
921
+ </div>
922
+ </div>
923
+ </div>
555
924
  </div>
556
925
  <div class="empty" id="liveEmpty">
557
926
  <div class="empty-icon" style="font-size:48px;opacity:.3">&#9679;</div>
558
- <p>No tests running. Start a test from the Suites view or another console.</p>
927
+ <p>No tests running. Start a test from the Tests view or another console.</p>
559
928
  <p style="margin-top:8px;font-size:11px;color:var(--text3)">This view activates automatically when tests are detected.</p>
560
929
  </div>
561
930
  </div>
562
931
 
563
- <!-- Suites View -->
564
- <div class="view active" id="view-suites">
565
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
566
- <div style="font-family:var(--sans);font-size:16px;font-weight:600">Test Suites</div>
567
- </div>
568
- <div id="suiteAccordionContainer"></div>
569
- <div class="suite-grid" id="suiteGrid"></div>
570
- <div id="moduleSection"></div>
571
- <div class="empty" id="suitesEmpty" style="display:none">
572
- <div class="empty-icon">&#9655;</div>
573
- <p>No test suites found.</p>
574
- </div>
575
- </div>
932
+ </div>
576
933
 
577
- <!-- Runs View -->
578
- <div class="view" id="view-runs">
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>
582
- <div class="card">
583
- <div class="card-label">Pass Rate Trend</div>
584
- <div class="chart" id="trendChart"></div>
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>
593
- <div class="card" style="padding:0">
594
- <div class="tbl-wrap">
595
- <table>
596
- <thead id="runsHead"><tr></tr></thead>
597
- <tbody id="runsBody"></tbody>
598
- </table>
934
+ <div class="suite-modal-overlay" id="suiteModalOverlay">
935
+ <div class="suite-modal" id="suiteModal">
936
+ <div class="suite-modal-header">
937
+ <div class="suite-card-icon">&#9655;</div>
938
+ <div class="suite-modal-title">
939
+ <h2 id="suiteModalName"></h2>
940
+ <span id="suiteModalFile"></span>
599
941
  </div>
600
- </div>
601
- <div class="empty" id="runsEmpty" style="display:none">
602
- <div class="empty-icon">&#9776;</div>
603
- <p>No runs recorded yet.</p>
604
- </div>
605
- </div>
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>
942
+ <div class="suite-modal-actions">
943
+ <button class="btn sm primary" id="suiteModalRun">&#9655; Run</button>
944
+ <button class="suite-modal-close" id="suiteModalClose">&times;</button>
620
945
  </div>
621
946
  </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
-
635
- <!-- Screenshots View -->
636
- <div class="view" id="view-screenshots">
637
- <div style="font-family:var(--sans);font-size:16px;font-weight:600;margin-bottom:20px">Screenshots</div>
638
- <div class="ss-search">
639
- <input type="text" id="ssHashInput" placeholder="Search by hash (e.g. ss:a3f2b1c9)" spellcheck="false">
640
- <button id="ssHashBtn">Search</button>
641
- </div>
642
- <div id="ssSearchResult"></div>
643
- <div class="gallery" id="screenshotGallery"></div>
644
- <div class="empty" id="screenshotsEmpty" style="display:none">
645
- <div class="empty-icon">&#9635;</div>
646
- <p>Select a project to view screenshots.</p>
947
+ <div class="suite-modal-body" id="suiteModalBody">
948
+ <div class="suite-modal-loading">Loading...</div>
647
949
  </div>
648
950
  </div>
649
-
650
951
  </div>
651
-
652
952
  <div class="modal" id="modal"><img id="modalImg" src="" alt=""></div>
653
953
  <div class="toast-container" id="toastContainer"></div>
654
954
  <div class="kb-modal" id="kbModal">
655
955
  <div class="kb-modal-content">
656
956
  <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>
957
+ <div class="kb-row"><span class="kb-key">1</span><span class="kb-desc">Watch view</span></div>
958
+ <div class="kb-row"><span class="kb-key">2</span><span class="kb-desc">Tests view</span></div>
959
+ <div class="kb-row"><span class="kb-key">3</span><span class="kb-desc">Runs view</span></div>
960
+ <div class="kb-row"><span class="kb-key">4</span><span class="kb-desc">Live view</span></div>
662
961
  <div class="kb-row"><span class="kb-key">j / k</span><span class="kb-desc">Navigate runs (next / previous)</span></div>
663
962
  <div class="kb-row"><span class="kb-key">Enter</span><span class="kb-desc">Expand / collapse selected run</span></div>
664
963
  <div class="kb-row"><span class="kb-key">Esc</span><span class="kb-desc">Close modal / collapse run</span></div>
@@ -670,6 +969,8 @@ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bot
670
969
  <script>
671
970
  (function(){
672
971
  'use strict';
972
+ /* ── utils.js ── */
973
+ /* ── DOM Helpers ── */
673
974
  var $=function(s){return document.querySelector(s)};
674
975
  var $$=function(s){return document.querySelectorAll(s)};
675
976
 
@@ -740,20 +1041,37 @@ function buildNdSection(title,contentEl,count,copyText){
740
1041
  return el('div',{className:'rd-nd-section'},[toggle,contentWrap]);
741
1042
  }
742
1043
 
1044
+ function gqlOp(n){
1045
+ if(n.requestBody){
1046
+ try{
1047
+ var b=JSON.parse(n.requestBody);
1048
+ if(b.operationName)return b.operationName;
1049
+ if(b.query){var m=b.query.match(/^(?:query|mutation|subscription)\s+([A-Za-z_]\w*)/);if(m)return m[1]}
1050
+ }catch(e){}
1051
+ }
1052
+ if(n.url){
1053
+ try{var u=new URL(n.url,location.href);var op=u.searchParams.get('operationName');if(op)return op}catch(e){}
1054
+ }
1055
+ return null;
1056
+ }
1057
+
743
1058
  function buildNetRow(n){
744
1059
  var mCls='rd-net-method '+(n.method||'GET').toLowerCase();
745
1060
  var sCode=n.status||0;
746
1061
  var sCls='rd-net-status '+(sCode<300?'s2xx':sCode<400?'s3xx':sCode<500?'s4xx':'s5xx');
747
1062
  var hasDetail=n.requestBody||n.responseBody||n.requestHeaders||n.responseHeaders;
748
1063
  var rowCls='rd-net-row'+(sCode>=400?' has-error':'');
749
- var row=el('div',{className:rowCls},[
1064
+ var opName=gqlOp(n);
1065
+ var children=[
750
1066
  el('span',{className:'rd-net-expand'},hasDetail?'\u25B6':''),
751
1067
  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||''),
755
- el('span',{className:'rd-net-dur'},dur(n.duration))
756
- ]);
1068
+ el('span',{className:sCls},String(sCode))
1069
+ ];
1070
+ if(opName)children.push(el('span',{className:'rd-net-op'},opName));
1071
+ children.push(el('span',{className:'rd-net-url'},n.url||''));
1072
+ children.push(makeCopyBtn(n.url||''));
1073
+ children.push(el('span',{className:'rd-net-dur'},dur(n.duration)));
1074
+ var row=el('div',{className:rowCls},children);
757
1075
  var detail=null;
758
1076
  if(hasDetail){
759
1077
  var sections=[];
@@ -816,11 +1134,108 @@ function createTriggerBadge(source){
816
1134
  return badge;
817
1135
  }
818
1136
 
819
- /* ══════════════════════════════════════════════════════════════════
820
- Toast Notifications (Improvement 4)
821
- ══════════════════════════════════════════════════════════════════ */
822
- function showToast(message,type){
1137
+ function createDriverBadge(driver){
1138
+ if(!driver)return document.createTextNode('--');
1139
+ var labels={browserless:'Browserless',cdp:'CDP',steel:'Steel',auto:'Auto'};
1140
+ var colors={browserless:'var(--accent)',cdp:'var(--purple)',steel:'var(--amber)'};
1141
+ var icons={browserless:'\u{1F310}',cdp:'\u{1F50C}',steel:'\u{1F6E1}'};
1142
+ // Handle multi-driver (e.g. "browserless,steel")
1143
+ var parts=driver.split(',');
1144
+ if(parts.length>1){
1145
+ var wrap=el('span',{style:'display:inline-flex;gap:4px'});
1146
+ parts.forEach(function(d){wrap.appendChild(createDriverBadge(d.trim()))});
1147
+ return wrap;
1148
+ }
1149
+ var d=driver.trim();
1150
+ var badge=el('span',{className:'driver-badge drv-'+d,style:'color:'+(colors[d]||'var(--text3)')},[
1151
+ el('span',{className:'drv-icon'},icons[d]||'\u2699'),
1152
+ document.createTextNode(labels[d]||d)
1153
+ ]);
1154
+ return badge;
1155
+ }
1156
+
1157
+ /* ── Pool Distribution Summary ── */
1158
+ var POOL_COLORS=['#6366f1','#22d3ee','#f59e0b','#10b981','#ef4444','#8b5cf6','#ec4899','#14b8a6'];
1159
+ function buildPoolDistribution(tests){
1160
+ var pools={};var total=0;
1161
+ Object.keys(tests).forEach(function(n){
1162
+ if(n==='__error')return;var t=tests[n];
1163
+ if(!t.poolUrl)return;
1164
+ var label=t.poolUrl.replace('ws://','').replace('wss://','');
1165
+ if(!pools[label])pools[label]={count:0,passed:0,failed:0};
1166
+ pools[label].count++;total++;
1167
+ if(t.status==='passed'||t.success)pools[label].passed++;
1168
+ if(t.status==='failed'||t.success===false)pools[label].failed++;
1169
+ });
1170
+ var keys=Object.keys(pools);
1171
+ if(keys.length<2)return null;
1172
+ var bar=el('div',{className:'pool-dist'});
1173
+ var legend=el('div',{className:'pool-dist-legend'});
1174
+ keys.forEach(function(k,i){
1175
+ var pct=Math.round(pools[k].count/total*100);
1176
+ var color=POOL_COLORS[i%POOL_COLORS.length];
1177
+ var seg=el('div',{className:'pool-dist-seg'});
1178
+ seg.style.flex=pools[k].count;seg.style.background=color;
1179
+ seg.textContent=k+' ('+pools[k].count+')';
1180
+ bar.appendChild(seg);
1181
+ var lg=el('span',{},k+': '+pools[k].count+' tests ('+pct+'%)');
1182
+ lg.style.cssText='display:inline-flex;align-items:center;gap:4px';
1183
+ var dot=el('span',{});dot.style.cssText='width:8px;height:8px;border-radius:2px;background:'+color+';flex-shrink:0';
1184
+ lg.insertBefore(dot,lg.firstChild);
1185
+ legend.appendChild(lg);
1186
+ });
1187
+ return el('div',{style:'padding:4px 12px'},[bar,legend]);
1188
+ }
1189
+
1190
+
1191
+ /* ── state.js ── */
1192
+ /* ── Global State ── */
1193
+ var S={
1194
+ ws:null,project:null,view:'watch',selectedRun:null,
1195
+ liveRuns:{},liveCollapsed:new Set(),liveSSOpen:new Set(),
1196
+ runFilter:{status:'all',search:''},
1197
+ lastLearningsData:null,
1198
+ highlightedRunIdx:-1
1199
+ };
1200
+
1201
+ /* ── Navigation ── */
1202
+ $$('.nav-item').forEach(function(n){
1203
+ n.addEventListener('click',function(){
1204
+ showView(n.dataset.view);
1205
+ });
1206
+ });
1207
+ function showView(v){
1208
+ S.view=v;
1209
+ $$('.nav-item').forEach(function(n){n.classList.toggle('active',n.dataset.view===v)});
1210
+ $$('.view').forEach(function(x){x.classList.remove('active')});
1211
+ var viewEl=$('#view-'+v);
1212
+ if(viewEl)viewEl.classList.add('active');
1213
+ if(v==='watch'&&typeof startWatchPolling==='function')startWatchPolling();
1214
+ else if(typeof stopWatchPolling==='function')stopWatchPolling();
1215
+ }
1216
+
1217
+ /* ── Inner Tabs ── */
1218
+ function initTabs(){
1219
+ $$('.tab-bar').forEach(function(bar){
1220
+ var container=bar.parentElement;
1221
+ bar.querySelectorAll('.tab-btn').forEach(function(btn){
1222
+ btn.addEventListener('click',function(){
1223
+ bar.querySelectorAll('.tab-btn').forEach(function(b){b.classList.remove('active')});
1224
+ btn.classList.add('active');
1225
+ container.querySelectorAll('.tab-pane').forEach(function(p){p.classList.remove('active')});
1226
+ var pane=container.querySelector('#'+btn.dataset.tab);
1227
+ if(pane)pane.classList.add('active');
1228
+ });
1229
+ });
1230
+ });
1231
+ }
1232
+
1233
+
1234
+ /* ── toast.js ── */
1235
+ /* ── Toast Notifications ── */
1236
+ function showToast(message,type,timeout){
823
1237
  type=type||'info';
1238
+ timeout=timeout||5000;
824
1239
  var container=$('#toastContainer');
825
1240
  var icons={success:'\u2714',error:'\u2718',info:'\u2139'};
826
1241
  var t=el('div',{className:'toast '+type},[
@@ -831,12 +1246,24 @@ function showToast(message,type){
831
1246
  setTimeout(function(){
832
1247
  t.classList.add('fade-out');
833
1248
  setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t)},300);
834
- },5000);
1249
+ },timeout);
835
1250
  }
836
1251
 
837
- /* ══════════════════════════════════════════════════════════════════
838
- Download helper (Improvement 8)
839
- ══════════════════════════════════════════════════════════════════ */
1252
+ function showEnrichedToast(message,type){
1253
+ var container=$('#toastContainer');
1254
+ var icons={success:'\u2714',error:'\u2718',info:'\u2139'};
1255
+ var t=el('div',{className:'toast clickable '+type,onclick:function(){showView('runs');var lb=$('#runsTabLearnings');if(lb)lb.click()}},[
1256
+ el('span',null,icons[type]||''),
1257
+ el('span',null,message)
1258
+ ]);
1259
+ container.appendChild(t);
1260
+ setTimeout(function(){
1261
+ t.classList.add('fade-out');
1262
+ setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t)},300);
1263
+ },7000);
1264
+ }
1265
+
1266
+ /* ── Download helper ── */
840
1267
  function downloadFile(filename,content,mimeType){
841
1268
  var blob=new Blob([content],{type:mimeType||'text/plain'});
842
1269
  var url=URL.createObjectURL(blob);
@@ -847,35 +1274,74 @@ function downloadFile(filename,content,mimeType){
847
1274
  URL.revokeObjectURL(url);
848
1275
  }
849
1276
 
850
- /* ── State ── */
851
- var S={
852
- ws:null,project:null,view:'suites',selectedRun:null,
853
- liveRuns:{},liveCollapsed:new Set(),liveSSOpen:new Set(),
854
- runFilter:{status:'all',search:''},
855
- lastLearningsData:null,
856
- highlightedRunIdx:-1
857
- };
858
1277
 
859
- /* ── Navigation ── */
860
- $$('.nav-item').forEach(function(n){
861
- n.addEventListener('click',function(){
862
- $$('.nav-item').forEach(function(x){x.classList.remove('active')});
863
- n.classList.add('active');
864
- S.view=n.dataset.view;
865
- $$('.view').forEach(function(v){v.classList.remove('active')});
866
- $('#view-'+S.view).classList.add('active');
867
- });
868
- });
869
- function showView(v){
870
- S.view=v;
871
- $$('.nav-item').forEach(function(n){n.classList.toggle('active',n.dataset.view===v)});
872
- $$('.view').forEach(function(x){x.classList.remove('active')});
873
- $('#view-'+v).classList.add('active');
1278
+ /* ── api.js ── */
1279
+ /* ── API & Pool ── */
1280
+ function api(p){return fetch(p).then(function(r){return r.json()})}
1281
+ function triggerRun(suite,projectId){
1282
+ if(anyLiveRunning())return;
1283
+ var body={};
1284
+ if(suite)body.suite=suite;
1285
+ if(projectId)body.projectId=projectId;
1286
+ else if(S.project)body.projectId=S.project;
1287
+ var scToggle=$('#screencastToggle');
1288
+ if(scToggle&&scToggle.checked)body.screencast=true;
1289
+ fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
874
1290
  }
875
1291
 
876
- /* ══════════════════════════════════════════════════════════════════
877
- WebSocket
878
- ══════════════════════════════════════════════════════════════════ */
1292
+ function renderPool(d){
1293
+ if(!d)return;
1294
+ var poolList=$('#poolList');
1295
+ if(d.pools&&d.pools.length>1){
1296
+ var anyAvail=d.availableCount>0;
1297
+ $('#poolDot').className='pool-dot '+(anyAvail?'on':'off');
1298
+ $('#poolLabel').textContent=anyAvail?d.availableCount+'/'+d.totalPools+' ready':'all busy';
1299
+ $('#poolSessions').textContent=(d.totalRunning||0)+'/'+(d.totalMaxConcurrent||0);
1300
+ poolList.textContent='';poolList.style.display='';
1301
+ d.pools.forEach(function(p){
1302
+ var label=(p.url||'').replace('ws://','').replace('wss://','');
1303
+ var ok=!p.error&&p.available;
1304
+ var dot=el('span',{className:'pool-dot '+(ok?'on':'off')});
1305
+ var name=el('strong',{},label);
1306
+ var status=el('span',{},p.error?'offline':p.available?'ready':'busy');
1307
+ var sess=el('span',{className:'pool-sessions'},(p.running||0)+'/'+(p.maxConcurrent||0));
1308
+ poolList.appendChild(el('div',{className:'pool-item'},[dot,name,status,sess]));
1309
+ });
1310
+ }else if(d.pools&&d.pools.length===1){
1311
+ var p=d.pools[0];
1312
+ $('#poolDot').className='pool-dot '+(p.error||!p.available?'off':'on');
1313
+ $('#poolLabel').textContent=p.error?'offline':p.available?'ready':'busy';
1314
+ $('#poolSessions').textContent=(p.running||0)+'/'+(p.maxConcurrent||0);
1315
+ poolList.style.display='none';
1316
+ }else{
1317
+ $('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
1318
+ $('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
1319
+ $('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
1320
+ poolList.style.display='none';
1321
+ }
1322
+ }
1323
+ function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
1324
+
1325
+ /* ── Projects ── */
1326
+ function refreshProjects(){
1327
+ api('/api/db/projects').then(function(projects){
1328
+ var sel=$('#projectSelect'),prev=sel.value;
1329
+ while(sel.options.length>1)sel.remove(1);
1330
+ if(Array.isArray(projects))projects.forEach(function(p){
1331
+ var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
1332
+ });
1333
+ sel.value=prev||'';
1334
+ }).catch(function(){});
1335
+ }
1336
+ $('#projectSelect').addEventListener('change',function(){
1337
+ S.project=this.value?parseInt(this.value,10):null;
1338
+ S.selectedRun=null;
1339
+ refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();refreshWatch();
1340
+ });
1341
+
1342
+
1343
+ /* ── websocket.js ── */
1344
+ /* ── WebSocket ── */
879
1345
  function connectWS(){
880
1346
  var proto=location.protocol==='https:'?'wss:':'ws:';
881
1347
  S.ws=new WebSocket(proto+'//'+location.host);
@@ -929,6 +1395,11 @@ function handleWS(m){
929
1395
  r2.active=m.activeCount;
930
1396
  r2.tests[m.name]={status:'running',actions:0,totalActions:0,error:null,actionLog:[],screenshots:[],serial:m.serial||false};
931
1397
  renderLive();break;
1398
+ case 'test:pool':
1399
+ var rp=getLiveRun(m);if(!rp||!rp.tests[m.name])break;
1400
+ rp.tests[m.name].poolUrl=m.poolUrl||null;
1401
+ rp.tests[m.name].actionLog.unshift({type:'pool',narrative:'\uD83D\uDD17 '+m.name+' \u2192 '+(m.poolUrl||'').replace('ws://','').replace('wss://',''),success:true,duration:null,isPoolLog:true});
1402
+ renderLive();break;
932
1403
  case 'test:action':
933
1404
  var r3=getLiveRun(m);if(!r3||!r3.tests[m.name])break;
934
1405
  var t=r3.tests[m.name];
@@ -950,66 +1421,292 @@ function handleWS(m){
950
1421
  if(m.screenshots&&m.screenshots.length)r5.tests[m.name].screenshots=m.screenshots;
951
1422
  if(m.errorScreenshot)r5.tests[m.name].errorScreenshot=m.errorScreenshot;
952
1423
  if(m.networkLogs&&m.networkLogs.length)r5.tests[m.name].networkLogs=m.networkLogs;
1424
+ if(m.poolUrl)r5.tests[m.name].poolUrl=m.poolUrl;
953
1425
  }
954
1426
  r5.active=Math.max(0,r5.active-1);
955
1427
  renderLive();break;
956
1428
  case 'run:complete':
957
1429
  var r6=getLiveRun(m);if(r6){r6.on=false;r6.done=true;r6.active=0}
958
1430
  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')}
961
- renderLive();refreshRuns();refreshProjects();break;
1431
+ var baseMsg='Run complete: '+(summary.failed>0?summary.failed+' failed':'all '+(summary.total||0)+' passed');
1432
+ var baseType=summary.failed>0?'error':'success';
1433
+ var healthUrl=S.project?'/api/db/projects/'+S.project+'/health':'/api/db/health';
1434
+ fetch(healthUrl).then(function(r){return r.json()}).then(function(h){
1435
+ if(h&&h.passRate!==undefined){
1436
+ var extra='. Pass rate: '+h.passRate+'%';
1437
+ if(h.passRateTrend==='declining')extra+=' (declining, '+h.trendDelta+'%)';
1438
+ else if(h.passRateTrend==='improving')extra+=' (improving, +'+h.trendDelta+'%)';
1439
+ if(h.flakyCount>0)extra+='. '+h.flakyCount+' flaky test(s)';
1440
+ showEnrichedToast(baseMsg+extra,baseType);
1441
+ } else {
1442
+ showToast(baseMsg,baseType);
1443
+ }
1444
+ }).catch(function(){showToast(baseMsg,baseType)});
1445
+ renderLive();refreshRuns();refreshProjects();refreshWatch();break;
962
1446
  case 'run:error':
963
1447
  var r7=getLiveRun(m);if(r7){r7.on=false;r7.done=true;r7.tests.__error={status:'failed',error:m.error}}
964
1448
  showToast('Run error: '+m.error,'error');
965
1449
  renderLive();break;
1450
+ case 'test:frame':
1451
+ if(S.screencastTest===m.name&&m.data){
1452
+ var img=$('#screencastImg');
1453
+ if(img)img.src='data:image/jpeg;base64,'+m.data;
1454
+ }
1455
+ break;
966
1456
  case 'db:updated':
967
- refreshRuns();refreshProjects();refreshScreenshots();refreshLearnings();break;
1457
+ refreshRuns();refreshProjects();refreshScreenshots();refreshLearnings();refreshWatch();break;
968
1458
  }
969
1459
  }
970
1460
 
1461
+
1462
+ /* ── view-watch.js ── */
971
1463
  /* ══════════════════════════════════════════════════════════════════
972
- API & Pool
1464
+ Watch View — Project Cards + Sparklines + Event Log
973
1465
  ══════════════════════════════════════════════════════════════════ */
974
- function api(p){return fetch(p).then(function(r){return r.json()})}
975
- function triggerRun(suite,projectId){
976
- if(anyLiveRunning())return;
977
- var body={};
978
- if(suite)body.suite=suite;
979
- if(projectId)body.projectId=projectId;
980
- else if(S.project)body.projectId=S.project;
981
- fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1466
+ var _watchInterval=null;
1467
+ var _countdownInterval=null;
1468
+ var _watchData=null;
1469
+
1470
+ function refreshWatch(){
1471
+ // Fetch projects overview (sparklines)
1472
+ api('/api/db/projects/overview').then(function(projects){
1473
+ if(!Array.isArray(projects)||!projects.length){
1474
+ $('#watchCards').textContent='';
1475
+ $('#watchEmpty').style.display='block';
1476
+ return;
1477
+ }
1478
+ $('#watchEmpty').style.display='none';
1479
+ _watchData=projects;
1480
+ renderWatchCards(projects);
1481
+ }).catch(function(){
1482
+ // Fallback: use regular projects list
1483
+ api('/api/db/projects').then(function(projects){
1484
+ if(!Array.isArray(projects)||!projects.length){$('#watchEmpty').style.display='block';return}
1485
+ $('#watchEmpty').style.display='none';
1486
+ _watchData=projects.map(function(p){return Object.assign({},p,{sparkline:[]})});
1487
+ renderWatchCards(_watchData);
1488
+ }).catch(function(){});
1489
+ });
1490
+
1491
+ // Fetch event log (recent runs)
1492
+ var runsUrl=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
1493
+ api(runsUrl).then(function(runs){
1494
+ renderEventLog(runs);
1495
+ }).catch(function(){});
1496
+
1497
+ // Fetch watch jobs status for countdown
1498
+ fetch('/api/watch/status').then(function(r){
1499
+ if(!r.ok)throw new Error('not running');
1500
+ return r.json();
1501
+ }).then(function(jobs){
1502
+ applyWatchJobData(jobs);
1503
+ }).catch(function(){
1504
+ // Watch engine not running — that's fine, cards still show
1505
+ });
1506
+ }
1507
+
1508
+ function renderWatchCards(projects){
1509
+ var container=$('#watchCards');
1510
+ container.textContent='';
1511
+
1512
+ projects.forEach(function(p){
1513
+ var sparkline=p.sparkline||[];
1514
+ var lastRate=sparkline.length?sparkline[sparkline.length-1]:null;
1515
+ var rateColor=lastRate===null?'dim':lastRate>=90?'green':lastRate>=70?'amber':'red';
1516
+ var dotColor=rateColor;
1517
+
1518
+ var sparkEl=el('div',{className:'watch-sparkline'});
1519
+ if(sparkline.length>=2){
1520
+ sparkEl.appendChild(buildSparkline(sparkline));
1521
+ } else {
1522
+ sparkEl.style.cssText='height:40px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:10px';
1523
+ sparkEl.textContent=sparkline.length?'1 run':'No runs yet';
1524
+ }
1525
+
1526
+ var triggerBtn=el('button',{className:'btn sm',onclick:function(e){e.stopPropagation();triggerRun(null,p.id)}},'\u25B6');
1527
+ var detailBtn=el('button',{className:'btn sm',onclick:function(e){
1528
+ e.stopPropagation();
1529
+ S.project=p.id;$('#projectSelect').value=p.id;
1530
+ showView('runs');
1531
+ refreshRuns();refreshSuites();
1532
+ }},'\uD83D\uDD0D');
1533
+
1534
+ var card=el('div',{className:'watch-card',id:'watch-card-'+p.id},[
1535
+ el('div',{className:'watch-card-header'},[
1536
+ el('div',{className:'watch-card-name'},p.name),
1537
+ el('div',{className:'watch-card-icons'},[triggerBtn,detailBtn])
1538
+ ]),
1539
+ sparkEl,
1540
+ el('div',{className:'watch-card-footer'},[
1541
+ el('div',{className:'watch-card-status'},[
1542
+ el('span',{className:'status-dot '+dotColor}),
1543
+ el('span',{className:'watch-card-rate '+rateColor},lastRate!==null?lastRate+'%':'—')
1544
+ ]),
1545
+ el('span',{style:'color:var(--text3);font-size:10px'},p.runCount?p.runCount+' runs':'')
1546
+ ]),
1547
+ el('div',{className:'watch-card-meta'},[
1548
+ el('span',{className:'watch-card-countdown',id:'watch-countdown-'+p.id},''),
1549
+ p.lastCommit?el('span',{className:'watch-card-commit'},'\u{1F4CB} '+p.lastCommit.slice(0,8)):null
1550
+ ])
1551
+ ]);
1552
+
1553
+ container.appendChild(card);
1554
+ });
1555
+ }
1556
+
1557
+ function buildSparkline(data){
1558
+ var ns='http://www.w3.org/2000/svg';
1559
+ var svg=document.createElementNS(ns,'svg');
1560
+ svg.setAttribute('viewBox','0 0 200 40');
1561
+ svg.setAttribute('preserveAspectRatio','none');
1562
+
1563
+ var n=data.length;
1564
+ var w=200/(n-1||1);
1565
+ var pts=data.map(function(v,i){return (i*w)+','+(40-v*0.4)}).join(' ');
1566
+
1567
+ // Gradient fill
1568
+ var poly=document.createElementNS(ns,'polygon');
1569
+ poly.setAttribute('points','0,40 '+pts+' '+((n-1)*w)+',40');
1570
+ poly.setAttribute('fill','var(--accent-dim)');
1571
+ svg.appendChild(poly);
1572
+
1573
+ // Line
1574
+ var pl=document.createElementNS(ns,'polyline');
1575
+ pl.setAttribute('points',pts);
1576
+ pl.setAttribute('fill','none');
1577
+ pl.setAttribute('stroke','var(--accent)');
1578
+ pl.setAttribute('stroke-width','1.5');
1579
+ svg.appendChild(pl);
1580
+
1581
+ // End dot
1582
+ if(n>0){
1583
+ var lastVal=data[n-1];
1584
+ var dotColor=lastVal>=90?'var(--green)':lastVal>=70?'var(--amber)':'var(--red)';
1585
+ var circle=document.createElementNS(ns,'circle');
1586
+ circle.setAttribute('cx',''+(n-1)*w);
1587
+ circle.setAttribute('cy',''+(40-lastVal*0.4));
1588
+ circle.setAttribute('r','3');
1589
+ circle.setAttribute('fill',dotColor);
1590
+ svg.appendChild(circle);
1591
+ }
1592
+
1593
+ return svg;
1594
+ }
1595
+
1596
+ function applyWatchJobData(jobs){
1597
+ if(!jobs||!jobs.length)return;
1598
+ jobs.forEach(function(j){
1599
+ // Find matching card by project name
1600
+ if(!_watchData)return;
1601
+ var match=_watchData.find(function(p){return p.name===j.name||p.cwd===j.cwd});
1602
+ if(!match)return;
1603
+ var cdEl=$('#watch-countdown-'+match.id);
1604
+ if(cdEl&&j.nextRunAt){
1605
+ cdEl.dataset.nextRunAt=j.nextRunAt;
1606
+ updateCountdown(cdEl);
1607
+ }
1608
+ });
1609
+ startCountdownTimer();
1610
+ }
1611
+
1612
+ function startCountdownTimer(){
1613
+ if(_countdownInterval)return;
1614
+ _countdownInterval=setInterval(function(){
1615
+ $$('.watch-card-countdown[data-next-run-at]').forEach(updateCountdown);
1616
+ },1000);
982
1617
  }
983
1618
 
984
- function renderPool(d){
985
- if(!d)return;
986
- $('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
987
- $('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
988
- $('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
1619
+ function updateCountdown(cdEl){
1620
+ var next=cdEl.dataset.nextRunAt;
1621
+ if(!next){cdEl.textContent='';return}
1622
+ var diff=new Date(next)-Date.now();
1623
+ if(diff<=0){cdEl.textContent='\u23F1 Running...';return}
1624
+ var m=Math.floor(diff/60000);
1625
+ var s=Math.floor((diff%60000)/1000);
1626
+ cdEl.textContent='\u23F1 Next: '+m+'m '+String(s).padStart(2,'0')+'s';
989
1627
  }
990
- function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
991
1628
 
992
- /* ══════════════════════════════════════════════════════════════════
993
- Projects
994
- ══════════════════════════════════════════════════════════════════ */
995
- function refreshProjects(){
996
- api('/api/db/projects').then(function(projects){
997
- var sel=$('#projectSelect'),prev=sel.value;
998
- while(sel.options.length>1)sel.remove(1);
999
- if(Array.isArray(projects))projects.forEach(function(p){
1000
- var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
1001
- });
1002
- sel.value=prev||'';
1003
- }).catch(function(){});
1629
+ function renderEventLog(runs){
1630
+ var container=$('#watchEventLog');
1631
+ if(!container)return;
1632
+ container.textContent='';
1633
+
1634
+ if(!Array.isArray(runs)||!runs.length){
1635
+ container.appendChild(el('div',{style:'padding:16px;text-align:center;color:var(--text3);font-size:11px'},'No runs recorded yet.'));
1636
+ return;
1637
+ }
1638
+
1639
+ // Column header row
1640
+ container.appendChild(el('div',{className:'watch-event-row we-header'},[
1641
+ el('span',null,'Time'),
1642
+ el('span',null,'Project'),
1643
+ el('span',null,'Suite'),
1644
+ el('span',{style:'justify-self:center'},'Status'),
1645
+ el('span',{style:'text-align:center'},'Tests'),
1646
+ el('span',{style:'text-align:right'},'Rate'),
1647
+ el('span',{style:'text-align:right'},'Duration'),
1648
+ el('span',{style:'text-align:right'},'Source')
1649
+ ]));
1650
+
1651
+ var recent=runs.slice(0,30);
1652
+ recent.forEach(function(r){
1653
+ var rate=parseFloat(r.pass_rate)||0;
1654
+ var badgeCls=r.failed>0?'fail':'pass';
1655
+ var badgeText=r.failed>0?'FAIL':'PASS';
1656
+
1657
+ // Test counts: "5/5" or "3/5 (2 fail)"
1658
+ var countsText=r.passed+'/'+r.total;
1659
+ var countsParts=[el('span',{className:'we-counts-ok'},String(r.passed))];
1660
+ countsParts.push(document.createTextNode('/'+r.total));
1661
+ if(r.failed>0){
1662
+ countsParts.push(document.createTextNode(' ('));
1663
+ countsParts.push(el('span',{style:'color:var(--red)'},r.failed+' fail'));
1664
+ countsParts.push(document.createTextNode(')'));
1665
+ }
1666
+
1667
+ // Trigger badge
1668
+ var triggerIcon={'cli':'\u2318','dashboard':'\uD83D\uDCBB','mcp':'\u2699','watch':'\u23F1','api':'\u26A1'};
1669
+ var trigSrc=r.triggered_by||'cli';
1670
+ var trigEl=el('span',{className:'we-trigger',title:'Triggered by: '+trigSrc},(triggerIcon[trigSrc]||'\u2318')+' '+trigSrc);
1671
+
1672
+ var row=el('div',{className:'watch-event-row',style:'cursor:pointer'},[
1673
+ el('span',{className:'watch-event-time'},fdate(r.generated_at)),
1674
+ el('span',{className:'watch-event-project'},r.project_name||'—'),
1675
+ el('span',{className:'watch-event-suite'},r.suite_name||'all'),
1676
+ el('span',{className:'watch-event-result'},[el('span',{className:'badge '+badgeCls},badgeText)]),
1677
+ el('span',{className:'watch-event-counts'},countsParts),
1678
+ el('span',{className:'watch-event-rate'},rate>0?rate.toFixed(0)+'%':'—'),
1679
+ el('span',{className:'watch-event-duration'},r.duration?dur(r.duration):'—'),
1680
+ trigEl
1681
+ ]);
1682
+
1683
+ // Click to navigate to run detail
1684
+ (function(run){
1685
+ row.addEventListener('click',function(){
1686
+ S.project=run.project_id;$('#projectSelect').value=run.project_id;
1687
+ showView('runs');
1688
+ refreshRuns();
1689
+ });
1690
+ })(r);
1691
+
1692
+ container.appendChild(row);
1693
+ });
1004
1694
  }
1005
- $('#projectSelect').addEventListener('change',function(){
1006
- S.project=this.value?parseInt(this.value,10):null;
1007
- S.selectedRun=null;
1008
- refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();
1009
- });
1010
1695
 
1696
+ function startWatchPolling(){
1697
+ if(_watchInterval)return;
1698
+ refreshWatch();
1699
+ _watchInterval=setInterval(refreshWatch,10000);
1700
+ }
1701
+ function stopWatchPolling(){
1702
+ if(_watchInterval){clearInterval(_watchInterval);_watchInterval=null}
1703
+ if(_countdownInterval){clearInterval(_countdownInterval);_countdownInterval=null}
1704
+ }
1705
+
1706
+
1707
+ /* ── view-tests.js ── */
1011
1708
  /* ══════════════════════════════════════════════════════════════════
1012
- Suites (+ Serial badges, Modules)
1709
+ Tests View — Suites + Modules + Variables (inner tabs)
1013
1710
  ══════════════════════════════════════════════════════════════════ */
1014
1711
  function refreshSuites(){
1015
1712
  var grid=$('#suiteGrid'),empty=$('#suitesEmpty'),accordion=$('#suiteAccordionContainer');
@@ -1067,15 +1764,75 @@ function renderProjectAccordion(container,project,suites){
1067
1764
  ]);
1068
1765
 
1069
1766
  var wrapper=el('div',{className:'project-accordion'},[header,body]);
1070
-
1071
- header.addEventListener('click',function(){
1072
- wrapper.classList.toggle('open');
1073
- });
1074
-
1767
+ header.addEventListener('click',function(){wrapper.classList.toggle('open')});
1075
1768
  container.appendChild(wrapper);
1076
1769
  }
1077
1770
 
1771
+ /* ── Suite Modal ── */
1078
1772
  var _suiteCache={};
1773
+
1774
+ function openSuiteModal(suiteName,projectId){
1775
+ var overlay=$('#suiteModalOverlay');
1776
+ var body=$('#suiteModalBody');
1777
+ $('#suiteModalName').textContent=suiteName;
1778
+ $('#suiteModalFile').textContent=suiteName+'.json';
1779
+ body.textContent='';
1780
+ body.appendChild(el('div',{className:'suite-modal-loading'},'Loading\u2026'));
1781
+ overlay.classList.add('open');
1782
+
1783
+ $('#suiteModalRun').onclick=function(){triggerRun(suiteName,projectId)};
1784
+ $('#suiteModalClose').onclick=function(){overlay.classList.remove('open')};
1785
+ overlay.addEventListener('click',function(e){if(e.target===overlay)overlay.classList.remove('open')});
1786
+
1787
+ var cacheKey=projectId+'::'+suiteName;
1788
+ var p=_suiteCache[cacheKey]||api('/api/db/projects/'+projectId+'/suites/'+encodeURIComponent(suiteName));
1789
+ _suiteCache[cacheKey]=p;
1790
+ p.then(function(data){
1791
+ body.textContent='';
1792
+ if(!data||!data.tests||!data.tests.length){
1793
+ body.appendChild(el('div',{className:'suite-modal-loading'},'No tests found'));
1794
+ return;
1795
+ }
1796
+ data.tests.forEach(function(test){
1797
+ var actionsDiv=el('div',{className:'suite-modal-test-actions'});
1798
+ (test.actions||[]).forEach(function(a,i){
1799
+ var detailContent;
1800
+ if(a.selector&&(a.value||a.text)){
1801
+ detailContent=[el('span',{className:'step-sel'},a.selector),el('span',{className:'step-arrow'},'\u2192'),el('span',{className:'step-val'},a.text||a.value)];
1802
+ } else {
1803
+ detailContent=a.selector||a.value||a.text||'';
1804
+ }
1805
+ actionsDiv.appendChild(el('div',{className:'suite-modal-step'},[
1806
+ el('span',{className:'suite-modal-step-num'},String(i+1)),
1807
+ el('span',{className:'suite-modal-step-type'},a.type),
1808
+ el('span',{className:'suite-modal-step-detail'},detailContent)
1809
+ ]));
1810
+ });
1811
+
1812
+ var header=el('div',{className:'suite-modal-test-header'},[
1813
+ el('span',{className:'suite-modal-test-chevron'},'\u25B6'),
1814
+ el('span',{className:'suite-modal-test-name'},test.name),
1815
+ el('span',{className:'suite-modal-test-badge'},(test.actions||[]).length+' actions')
1816
+ ]);
1817
+
1818
+ var testEl=el('div',{className:'suite-modal-test'},[header,actionsDiv]);
1819
+ if(test.expect){
1820
+ var expectText=Array.isArray(test.expect)?test.expect.join(', '):test.expect;
1821
+ var expectEl=el('div',{className:'suite-modal-expect'},[
1822
+ el('span',{className:'suite-modal-expect-label'},'Expect:'),
1823
+ document.createTextNode(expectText)
1824
+ ]);
1825
+ testEl.insertBefore(expectEl,actionsDiv);
1826
+ }
1827
+ header.addEventListener('click',function(){testEl.classList.toggle('open')});
1828
+ body.appendChild(testEl);
1829
+ });
1830
+ }).catch(function(){
1831
+ body.textContent='';
1832
+ body.appendChild(el('div',{className:'suite-modal-loading',style:'color:var(--red)'},'Failed to load suite'));
1833
+ });
1834
+ }
1835
+
1079
1836
  function renderSuiteCards(container,suites,projectId){
1080
1837
  suites.forEach(function(s){
1081
1838
  var tests=el('ul',{className:'suite-card-tests'});
@@ -1177,9 +1934,79 @@ function renderModules(container,modules){
1177
1934
  container.appendChild(grid);
1178
1935
  }
1179
1936
 
1937
+ /* ── Variables ── */
1938
+ function refreshVariables(){
1939
+ var container=$('#variablesContainer'),empty=$('#variablesEmpty');
1940
+ container.textContent='';
1941
+ if(!S.project){empty.style.display='block';empty.querySelector('p').textContent='Select a project to manage variables.';return}
1942
+ api('/api/db/projects/'+S.project+'/variables').then(function(vars){
1943
+ if(!Array.isArray(vars)||!vars.length){empty.style.display='block';empty.querySelector('p').textContent='No variables set. Add variables to use {{var.KEY}} in your tests.';return}
1944
+ empty.style.display='none';
1945
+ renderVariables(vars);
1946
+ }).catch(function(){empty.style.display='block'});
1947
+ }
1948
+
1949
+ function renderVariables(vars){
1950
+ var container=$('#variablesContainer');
1951
+ var tbl=el('table',{className:'var-table'});
1952
+ var thead=document.createElement('thead');
1953
+ var hr=document.createElement('tr');
1954
+ ['Key','Value','Scope','Actions'].forEach(function(h){hr.appendChild(el('th',null,h))});
1955
+ thead.appendChild(hr);tbl.appendChild(thead);
1956
+ var tbody=document.createElement('tbody');
1957
+ vars.forEach(function(v){
1958
+ var tr=document.createElement('tr');
1959
+ tr.appendChild(el('td',null,[el('code',null,v.key)]));
1960
+ tr.appendChild(el('td',{style:'max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'},v.is_secret?'\u2022\u2022\u2022\u2022\u2022\u2022':v.value));
1961
+ tr.appendChild(el('td',{style:'color:var(--text3)'},v.scope||'project'));
1962
+ var delBtn=el('button',{className:'btn sm danger',onclick:function(){
1963
+ if(!confirm('Delete variable "'+v.key+'"?'))return;
1964
+ fetch('/api/db/projects/'+S.project+'/variables/'+encodeURIComponent(v.key),{method:'DELETE'}).then(function(){refreshVariables();showToast('Variable deleted','success')}).catch(function(){showToast('Delete failed','error')});
1965
+ }},'\u2715');
1966
+ tr.appendChild(el('td',null,[delBtn]));
1967
+ tbody.appendChild(tr);
1968
+ });
1969
+ tbl.appendChild(tbody);
1970
+ container.appendChild(tbl);
1971
+ }
1972
+
1973
+ /* ── Variable Add Form ── */
1974
+ $('#btnAddVar').addEventListener('click',function(){
1975
+ var form=$('#varAddForm');
1976
+ if(form.style.display==='none'){
1977
+ form.style.display='';
1978
+ form.textContent='';
1979
+ var keyInput=el('input',{type:'text',placeholder:'KEY',style:'margin-right:8px;width:120px'});
1980
+ var valInput=el('input',{type:'text',placeholder:'Value',style:'margin-right:8px;width:200px'});
1981
+ var secretCheck=el('input',{type:'checkbox',style:'margin-right:4px'});
1982
+ var saveBtn=el('button',{className:'btn sm primary',onclick:function(){
1983
+ var k=keyInput.value.trim(),v=valInput.value;
1984
+ if(!k){showToast('Key is required','error');return}
1985
+ if(!S.project){showToast('Select a project first','error');return}
1986
+ fetch('/api/db/projects/'+S.project+'/variables',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:k,value:v,is_secret:secretCheck.checked})}).then(function(r){return r.json()}).then(function(){
1987
+ form.style.display='none';refreshVariables();showToast('Variable saved','success');
1988
+ }).catch(function(){showToast('Save failed','error')});
1989
+ }},'Save');
1990
+ var cancelBtn=el('button',{className:'btn sm',onclick:function(){form.style.display='none'}},'Cancel');
1991
+ form.appendChild(el('div',{className:'var-add-form',style:'display:flex;align-items:center;gap:8px;flex-wrap:wrap'},[
1992
+ keyInput,valInput,
1993
+ el('label',{style:'font-size:11px;color:var(--text2);display:flex;align-items:center;gap:4px'},[secretCheck,document.createTextNode('Secret')]),
1994
+ saveBtn,cancelBtn
1995
+ ]));
1996
+ } else {
1997
+ form.style.display='none';
1998
+ }
1999
+ });
2000
+
2001
+ $('#btnRunAll').addEventListener('click',function(){triggerRun()});
2002
+
2003
+
2004
+ /* ── view-runs.js ── */
1180
2005
  /* ══════════════════════════════════════════════════════════════════
1181
- Runs (+ Filters)
2006
+ Runs View — History + Screenshots + Learnings (inner tabs)
1182
2007
  ══════════════════════════════════════════════════════════════════ */
2008
+
2009
+ /* ── Filters ── */
1183
2010
  $$('.filter-btn').forEach(function(btn){
1184
2011
  btn.addEventListener('click',function(){
1185
2012
  $$('.filter-btn').forEach(function(b){b.classList.remove('active')});
@@ -1214,7 +2041,44 @@ function applyRunFilters(){
1214
2041
  });
1215
2042
  }
1216
2043
 
2044
+ function renderRunsHealthBanner(){
2045
+ var banner=$('#runsHealthBanner');
2046
+ banner.textContent='';
2047
+ var url=S.project?'/api/db/projects/'+S.project+'/health':'/api/db/health';
2048
+ fetch(url).then(function(r){return r.json()}).then(function(h){
2049
+ if(!h||!h.passRate)return;
2050
+ var rateColor=h.passRate>=90?'green':h.passRate>=70?'amber':'red';
2051
+ var trendIcon=h.passRateTrend==='improving'?'\u25B2':h.passRateTrend==='declining'?'\u25BC':'=';
2052
+ var trendCls=h.passRateTrend==='improving'?'green':h.passRateTrend==='declining'?'red':'dim';
2053
+ var deltaStr=h.trendDelta!==0?(h.trendDelta>0?'+':'')+h.trendDelta+'%':'';
2054
+
2055
+ banner.appendChild(el('div',{className:'hb-item'},[
2056
+ el('div',{className:'hb-val '+rateColor},h.passRate+'%'),
2057
+ el('div',{className:'hb-lbl'},'Pass Rate'),
2058
+ el('div',{className:'hb-trend '+trendCls},trendIcon+' '+h.passRateTrend+(deltaStr?' ('+deltaStr+')':''))
2059
+ ]));
2060
+ if(h.flakyCount>0){
2061
+ banner.appendChild(el('div',{className:'hb-item'},[
2062
+ el('div',{className:'hb-val amber'},String(h.flakyCount)),
2063
+ el('div',{className:'hb-lbl'},'Flaky Tests')
2064
+ ]));
2065
+ }
2066
+ if(h.topErrorPattern){
2067
+ var cat=h.topErrorPattern.category||h.topErrorPattern.pattern||'unknown';
2068
+ var pat=cat.replace(/-/g,' ').replace(/\b\w/g,function(c){return c.toUpperCase()});
2069
+ banner.appendChild(el('div',{className:'hb-item'},[
2070
+ el('div',{className:'hb-val red',style:'font-size:13px'},pat),
2071
+ el('div',{className:'hb-lbl'},'Top Error ('+h.topErrorPattern.count+'x)')
2072
+ ]));
2073
+ }
2074
+ banner.appendChild(el('div',{className:'hb-link',onclick:function(){var lb=$('#runsTabLearnings');if(lb)lb.click()}},[
2075
+ el('span',null,'\u2192 View Learnings')
2076
+ ]));
2077
+ }).catch(function(){});
2078
+ }
2079
+
1217
2080
  function refreshRuns(){
2081
+ renderRunsHealthBanner();
1218
2082
  var url=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
1219
2083
  api(url).then(function(rows){
1220
2084
  var chart=$('#trendChart'),body=$('#runsBody'),empty=$('#runsEmpty'),head=$('#runsHead');
@@ -1228,7 +2092,7 @@ function refreshRuns(){
1228
2092
  var htr=document.createElement('tr');
1229
2093
  var cols=[];
1230
2094
  if(!S.project)cols.push('Project');
1231
- cols=cols.concat(['Suite','Source','Date','Total','Pass','Fail','Rate','Time']);
2095
+ cols=cols.concat(['Suite','Driver','Source','Date','Total','Pass','Fail','Rate','Time']);
1232
2096
  cols.forEach(function(c){htr.appendChild(el('th',null,c))});
1233
2097
  head.textContent='';head.appendChild(htr);
1234
2098
  var colSpan=cols.length;
@@ -1247,6 +2111,7 @@ function refreshRuns(){
1247
2111
  if(r.id===S.selectedRun)tr.classList.add('expanded');
1248
2112
  if(!S.project)tr.appendChild(el('td',{style:'font-weight:600'},r.project_name||'-'));
1249
2113
  tr.appendChild(el('td',{style:'color:var(--accent)'},r.suite_name||'all'));
2114
+ var driverTd=document.createElement('td');driverTd.appendChild(createDriverBadge(r.pool_driver));tr.appendChild(driverTd);
1250
2115
  var srcTd=document.createElement('td');srcTd.appendChild(createTriggerBadge(r.triggered_by));tr.appendChild(srcTd);
1251
2116
  tr.appendChild(el('td',null,fdate(r.generated_at)));
1252
2117
  tr.appendChild(el('td',null,String(r.total||0)));
@@ -1325,9 +2190,7 @@ function toggleDetail(id,clickedTr,colSpan){
1325
2190
  loadDetailInline(id,detailTr);
1326
2191
  }
1327
2192
 
1328
- /* ══════════════════════════════════════════════════════════════════
1329
- Run Detail (+ Action Narratives, Retry badges, Export)
1330
- ══════════════════════════════════════════════════════════════════ */
2193
+ /* ── Run Detail ── */
1331
2194
  function loadDetailInline(id,detailTr){
1332
2195
  api('/api/db/runs/'+id).then(function(d){
1333
2196
  if(d.error)return;
@@ -1345,8 +2208,10 @@ function loadDetailInline(id,detailTr){
1345
2208
  ])
1346
2209
  ]);
1347
2210
  var srcBlock=el('div',null,[el('div',{className:'rd-s-label'},'Source'),el('div',{style:'margin-top:4px'},[createTriggerBadge(d.triggeredBy)])]);
2211
+ var drvBlock=el('div',null,[el('div',{className:'rd-s-label'},'Driver'),el('div',{style:'margin-top:4px'},[createDriverBadge(d.poolDriver)])]);
1348
2212
  var summ=el('div',{className:'rd-summary'},[
1349
2213
  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')]),
2214
+ drvBlock,
1350
2215
  srcBlock,
1351
2216
  el('div',null,[el('div',{className:'rd-s-label'},'Total'),el('div',{className:'rd-s-val'},String(d.summary.total))]),
1352
2217
  el('div',null,[el('div',{className:'rd-s-label'},'Passed'),el('div',{className:'rd-s-val',style:'color:var(--green)'},String(d.summary.passed))]),
@@ -1356,6 +2221,43 @@ function loadDetailInline(id,detailTr){
1356
2221
  ]);
1357
2222
  inner.appendChild(summ);
1358
2223
 
2224
+ // Insights
2225
+ var insightsContainer=el('div',{className:'rd-insights'});
2226
+ inner.appendChild(insightsContainer);
2227
+ fetch('/api/db/runs/'+id+'/insights').then(function(r){return r.json()}).then(function(ins){
2228
+ if(!ins||ins.error)return;
2229
+ var items=[];
2230
+ var h=ins.health;
2231
+ if(h){
2232
+ var rateColor=h.passRate>=90?'green':h.passRate>=70?'amber':'red';
2233
+ var trendIcon=h.passRateTrend==='improving'?'\u25B2':h.passRateTrend==='declining'?'\u25BC':'=';
2234
+ var trendCls=h.passRateTrend==='improving'?'green':h.passRateTrend==='declining'?'red':'';
2235
+ items.push(el('div',{className:'rd-ins-health'},[
2236
+ el('span',{className:'rd-ins-rate '+rateColor},h.passRate+'%'),
2237
+ el('span',{className:'rd-ins-trend '+trendCls},trendIcon+' '+h.passRateTrend),
2238
+ h.flakyCount>0?el('span',{className:'rd-ins-tag amber'},h.flakyCount+' flaky'):null,
2239
+ h.unstableSelectorCount>0?el('span',{className:'rd-ins-tag red'},h.unstableSelectorCount+' unstable sel.'):null
2240
+ ]));
2241
+ }
2242
+ var insights=ins.insights||[];
2243
+ insights.forEach(function(i){
2244
+ var icon=i.type==='new-failure'?'\u2718':i.type==='recovered'?'\u2714':i.type==='flaky'?'\u223C':'!';
2245
+ var cls=i.type==='new-failure'?'red':i.type==='recovered'?'green':i.type==='flaky'?'amber':'';
2246
+ items.push(el('div',{className:'rd-ins-item '+cls},[
2247
+ el('span',{className:'rd-ins-icon'},icon),
2248
+ el('span',null,i.message)
2249
+ ]));
2250
+ });
2251
+ if(items.length>0){items.forEach(function(it){insightsContainer.appendChild(it)})}
2252
+ else{insightsContainer.style.display='none'}
2253
+ }).catch(function(){insightsContainer.style.display='none'});
2254
+
2255
+ // Pool distribution bar
2256
+ var histPoolTests={};
2257
+ results.forEach(function(r){if(!r.poolUrl)return;histPoolTests[r.name]={poolUrl:r.poolUrl,success:r.success}});
2258
+ var histPoolDist=buildPoolDistribution(histPoolTests);
2259
+ if(histPoolDist)inner.appendChild(histPoolDist);
2260
+
1359
2261
  results.forEach(function(r){
1360
2262
  var d2=r.durationMs?dur(r.durationMs):r.endTime&&r.startTime?dur(new Date(r.endTime)-new Date(r.startTime)):'-';
1361
2263
  var flaky=r.success&&r.attempt>1;
@@ -1365,7 +2267,8 @@ function loadDetailInline(id,detailTr){
1365
2267
  badges.appendChild(el('span',{className:'badge '+(r.success?'pass':'fail')},r.success?'PASS':'FAIL'));
1366
2268
  if(flaky)badges.appendChild(el('span',{className:'badge flaky'},'FLAKY'));
1367
2269
 
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)]);
2270
+ var poolEl=r.poolUrl?el('span',{className:'pool-badge'},r.poolUrl.replace('ws://','').replace('wss://','')) :null;
2271
+ var head=el('div',{className:'rd-test-head'},[badges,el('div',{className:'rd-test-name'},[document.createTextNode(r.name),poolEl]),el('div',{className:'rd-test-dur'},d2)]);
1369
2272
  var body=el('div',{className:'rd-test-body'});
1370
2273
 
1371
2274
  if(r.maxAttempts>1){body.appendChild(el('div',{className:'rd-retries'},'Attempt '+r.attempt+' of '+r.maxAttempts))}
@@ -1488,9 +2391,7 @@ function loadDetailInline(id,detailTr){
1488
2391
  });
1489
2392
  }
1490
2393
 
1491
- /* ══════════════════════════════════════════════════════════════════
1492
- Screenshots
1493
- ══════════════════════════════════════════════════════════════════ */
2394
+ /* ── Screenshots ── */
1494
2395
  function refreshScreenshots(){
1495
2396
  var gal=$('#screenshotGallery'),empty=$('#screenshotsEmpty');
1496
2397
  gal.textContent='';
@@ -1535,13 +2436,289 @@ function searchByHash(){
1535
2436
  $('#ssHashBtn').addEventListener('click',searchByHash);
1536
2437
  $('#ssHashInput').addEventListener('keydown',function(e){if(e.key==='Enter')searchByHash()});
1537
2438
 
2439
+ /* ── Learnings ── */
2440
+ function refreshLearnings(){
2441
+ var days=$('#learningsDays').value||30;
2442
+ var url=S.project?'/api/db/projects/'+S.project+'/learnings?days='+days:'/api/db/learnings?days='+days;
2443
+ fetch(url).then(function(r){return r.json()}).then(function(data){
2444
+ if(!data||data.totalRuns===0){
2445
+ $('#learningsEmpty').style.display='block';
2446
+ $('#learnHero').textContent='';$('#learnCards').textContent='';
2447
+ $('#learnTrend').textContent='';$('#learnBottom').textContent='';
2448
+ $('#badgeLearnings').textContent='-';
2449
+ return;
2450
+ }
2451
+ $('#learningsEmpty').style.display='none';
2452
+ S.lastLearningsData=data;
2453
+ var flakyCount=data.flakyTests?data.flakyTests.length:0;
2454
+ var passRate=data.overallPassRate||0;
2455
+ var declining=data.recentTrend&&Array.isArray(data.recentTrend.data||data.recentTrend)&&(function(){
2456
+ var td=data.recentTrend.data||data.recentTrend;
2457
+ if(td.length<2)return false;
2458
+ var last=td[td.length-1].pass_rate;
2459
+ var prior=td.slice(0,-1).reduce(function(s,t){return s+t.pass_rate},0)/(td.length-1);
2460
+ return last-prior<-2;
2461
+ })();
2462
+ if(passRate<70){
2463
+ $('#badgeLearnings').textContent='\u26A0';
2464
+ $('#badgeLearnings').style.background='var(--red-dim)';$('#badgeLearnings').style.color='var(--red)';
2465
+ } else if(flakyCount>0||declining){
2466
+ $('#badgeLearnings').textContent=flakyCount>0?flakyCount:(declining?'\u25BC':'\u2714');
2467
+ $('#badgeLearnings').style.background='var(--amber-dim)';$('#badgeLearnings').style.color='var(--amber)';
2468
+ } else {
2469
+ $('#badgeLearnings').textContent='\u2714';
2470
+ $('#badgeLearnings').style.background='var(--green-dim)';$('#badgeLearnings').style.color='var(--green)';
2471
+ }
2472
+ renderLearnHero(data);
2473
+ renderLearnCards(data);
2474
+ renderLearnTrend(data.recentTrend||[]);
2475
+ renderLearnBottomRow(data);
2476
+ }).catch(function(){$('#learningsEmpty').style.display='block'});
2477
+ }
2478
+
2479
+ function rateColor(v){return v>=90?'var(--green)':v>=70?'var(--amber)':'var(--red)'}
2480
+ function rateClass(v){return v>=90?'good':v>=70?'warn':'bad'}
2481
+ function durFmt(ms){return ms<1000?Math.round(ms)+'ms':(ms/1000).toFixed(1)+'s'}
2482
+
2483
+ function renderLearnHero(d){
2484
+ var c=$('#learnHero');c.textContent='';
2485
+ var wrap=document.createElement('div');wrap.className='learn-hero';
2486
+ var passRate=d.overallPassRate||0;
2487
+ var ns='http://www.w3.org/2000/svg';
2488
+ var ringWrap=document.createElement('div');ringWrap.className='learn-hero-ring';
2489
+ var svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 36 36');
2490
+ var bgCircle=document.createElementNS(ns,'circle');bgCircle.setAttribute('cx','18');bgCircle.setAttribute('cy','18');bgCircle.setAttribute('r','15.9');bgCircle.className.baseVal='learn-hero-ring-bg';svg.appendChild(bgCircle);
2491
+ var fgCircle=document.createElementNS(ns,'circle');fgCircle.setAttribute('cx','18');fgCircle.setAttribute('cy','18');fgCircle.setAttribute('r','15.9');fgCircle.className.baseVal='learn-hero-ring-fg';
2492
+ var circ=2*Math.PI*15.9;fgCircle.setAttribute('stroke-dasharray',circ.toFixed(1));fgCircle.setAttribute('stroke-dashoffset',(circ*(1-passRate/100)).toFixed(1));fgCircle.setAttribute('stroke',rateColor(passRate));
2493
+ svg.appendChild(fgCircle);ringWrap.appendChild(svg);
2494
+ var pctEl=document.createElement('div');pctEl.className='learn-hero-pct';pctEl.style.color=rateColor(passRate);pctEl.textContent=passRate+'%';
2495
+ ringWrap.appendChild(pctEl);wrap.appendChild(ringWrap);
2496
+
2497
+ var stats=document.createElement('div');stats.className='learn-hero-stats';
2498
+ var badSels=d.unstableSelectors?d.unstableSelectors.length:0;
2499
+ var slowTests=d.failingPages?d.failingPages.length:0;
2500
+ var apiIssues=d.apiIssues?d.apiIssues.length:0;
2501
+ var topErr=d.topErrors&&d.topErrors.length>0?d.topErrors[0].occurrence_count:0;
2502
+ var flakyCount=d.flakyTests?d.flakyTests.length:0;
2503
+ var items=[
2504
+ {val:String(d.totalRuns),lbl:'Runs',color:'var(--accent)'},
2505
+ {val:String(d.totalTests),lbl:'Tests',color:'var(--accent)'},
2506
+ {val:durFmt(d.avgDurationMs||0),lbl:'Avg Duration',color:'var(--purple)'},
2507
+ {val:String(flakyCount),lbl:'Flaky',color:flakyCount>0?'var(--amber)':'var(--green)'},
2508
+ {val:String(badSels),lbl:'Bad Selectors',color:badSels>0?'var(--red)':'var(--green)'},
2509
+ {val:String(slowTests),lbl:'Slow Pages',color:slowTests>0?'var(--amber)':'var(--green)'},
2510
+ {val:String(apiIssues),lbl:'API Issues',color:apiIssues>0?'var(--red)':'var(--green)'},
2511
+ {val:String(topErr),lbl:'Top Error Hits',color:topErr>0?'var(--red)':'var(--green)'}
2512
+ ];
2513
+ items.forEach(function(it){
2514
+ var statEl=document.createElement('div');statEl.className='learn-hero-stat';
2515
+ var valEl=document.createElement('div');valEl.className='learn-hero-stat-val';valEl.style.color=it.color;valEl.textContent=it.val;
2516
+ var lblEl=document.createElement('div');lblEl.className='learn-hero-stat-lbl';lblEl.textContent=it.lbl;
2517
+ statEl.appendChild(valEl);statEl.appendChild(lblEl);stats.appendChild(statEl);
2518
+ });
2519
+ wrap.appendChild(stats);c.appendChild(wrap);
2520
+ }
2521
+
2522
+ function makeLearnItem(label,sub,pct,valText,color){
2523
+ var item=document.createElement('div');item.className='learn-item';
2524
+ var barWrap=document.createElement('div');barWrap.className='learn-item-bar';
2525
+ var lblEl=document.createElement('div');lblEl.className='learn-item-label';
2526
+ var codeEl=document.createElement('code');codeEl.textContent=label;lblEl.appendChild(codeEl);
2527
+ barWrap.appendChild(lblEl);
2528
+ if(sub){var subEl=document.createElement('div');subEl.className='learn-item-sub';subEl.textContent=sub;barWrap.appendChild(subEl)}
2529
+ var bar=document.createElement('div');bar.className='learn-bar';
2530
+ var fill=document.createElement('div');fill.className='learn-bar-fill';fill.style.width=Math.min(pct,100)+'%';fill.style.background=color;
2531
+ bar.appendChild(fill);barWrap.appendChild(bar);
2532
+ item.appendChild(barWrap);
2533
+ var valEl=document.createElement('div');valEl.className='learn-item-val';valEl.style.color=color;valEl.textContent=valText;
2534
+ item.appendChild(valEl);
2535
+ return item;
2536
+ }
2537
+
2538
+ function makeLearnCard(icon,title,emptyMsg){
2539
+ var card=document.createElement('div');card.className='learn-card';
2540
+ var titleEl=document.createElement('div');titleEl.className='learn-card-title';
2541
+ var iconEl=document.createElement('span');iconEl.className='lc-icon';iconEl.textContent=icon;
2542
+ titleEl.appendChild(iconEl);titleEl.appendChild(document.createTextNode(title));
2543
+ card.appendChild(titleEl);
2544
+ card._empty=emptyMsg;
2545
+ return card;
2546
+ }
2547
+
2548
+ function renderLearnCards(d){
2549
+ var c=$('#learnCards');c.textContent='';
2550
+
2551
+ var selCard=makeLearnCard('\u26A0','Risky Selectors','No unstable selectors');
2552
+ var sels=d.unstableSelectors||[];
2553
+ if(!sels.length){var e1=document.createElement('div');e1.className='learn-card-empty';e1.textContent=selCard._empty;selCard.appendChild(e1)}
2554
+ else{sels.slice(0,5).forEach(function(s){
2555
+ var sel=s.selector.length>40?s.selector.slice(0,37)+'...':s.selector;
2556
+ selCard.appendChild(makeLearnItem(sel,s.action_type+' \u00B7 '+s.total_uses+' uses',parseFloat(s.fail_rate),s.fail_rate+'%',parseFloat(s.fail_rate)>30?'var(--red)':'var(--amber)'));
2557
+ })}
2558
+ c.appendChild(selCard);
2559
+
2560
+ var pageCard=makeLearnCard('\u23F1','Problem Pages','No failing pages');
2561
+ var pages=d.failingPages||[];
2562
+ if(!pages.length){var e2=document.createElement('div');e2.className='learn-card-empty';e2.textContent=pageCard._empty;pageCard.appendChild(e2)}
2563
+ else{pages.slice(0,5).forEach(function(p){
2564
+ pageCard.appendChild(makeLearnItem(p.url_path,p.total_visits+' visits \u00B7 '+p.console_errors+' console errs',parseFloat(p.fail_rate),p.fail_rate+'%',parseFloat(p.fail_rate)>30?'var(--red)':'var(--amber)'));
2565
+ })}
2566
+ c.appendChild(pageCard);
2567
+
2568
+ var flakyCard=makeLearnCard('\u223C','Flaky Tests','No flaky tests detected');
2569
+ var flaky=d.flakyTests||[];
2570
+ if(!flaky.length){var e3=document.createElement('div');e3.className='learn-card-empty';e3.textContent=flakyCard._empty;flakyCard.appendChild(e3)}
2571
+ else{flaky.slice(0,5).forEach(function(f){
2572
+ flakyCard.appendChild(makeLearnItem(f.test_name,'Attempt avg '+f.avg_attempts+' \u00B7 '+f.total_runs+' runs',parseFloat(f.flaky_rate),f.flaky_rate+'%',parseFloat(f.flaky_rate)>30?'var(--red)':'var(--amber)'));
2573
+ })}
2574
+ c.appendChild(flakyCard);
2575
+
2576
+ var apiCard=makeLearnCard('\u21C4','API Issues','No API issues');
2577
+ var apis=d.apiIssues||[];
2578
+ if(!apis.length){var e4=document.createElement('div');e4.className='learn-card-empty';e4.textContent=apiCard._empty;apiCard.appendChild(e4)}
2579
+ else{apis.slice(0,5).forEach(function(a){
2580
+ var ep=a.endpoint.length>40?a.endpoint.slice(0,37)+'...':a.endpoint;
2581
+ apiCard.appendChild(makeLearnItem(ep,a.total_calls+' calls \u00B7 '+durFmt(a.avg_duration_ms),parseFloat(a.error_rate),a.error_rate+'%',parseFloat(a.error_rate)>20?'var(--red)':'var(--amber)'));
2582
+ })}
2583
+ c.appendChild(apiCard);
2584
+ }
2585
+
2586
+ function renderLearnTrend(trend){
2587
+ var container=$('#learnTrend');container.textContent='';
2588
+ if(!trend.length)return;
2589
+ var card=document.createElement('div');card.className='learn-card';
2590
+ var titleEl=document.createElement('div');titleEl.className='learn-card-title';
2591
+ var iconEl=document.createElement('span');iconEl.className='lc-icon';iconEl.textContent='\u2197';
2592
+ titleEl.appendChild(iconEl);titleEl.appendChild(document.createTextNode('Pass Rate Trend'));
2593
+ card.appendChild(titleEl);
2594
+ var chartDiv=document.createElement('div');chartDiv.style.cssText='height:80px;width:100%';
2595
+ var w=100/trend.length;var ns='http://www.w3.org/2000/svg';
2596
+ var svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 100 100');svg.setAttribute('preserveAspectRatio','none');svg.style.cssText='width:100%;height:100%';
2597
+ 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');svg.appendChild(bg);
2598
+ 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);
2599
+ var pts=trend.map(function(t,i){return(i*w+w/2)+','+(100-t.pass_rate)}).join(' ');
2600
+ 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);
2601
+ 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);
2602
+ trend.forEach(function(t,i){
2603
+ var color=rateColor(t.pass_rate);
2604
+ var circle=document.createElementNS(ns,'circle');circle.setAttribute('cx',''+(i*w+w/2));circle.setAttribute('cy',''+(100-t.pass_rate));circle.setAttribute('r','2.5');circle.setAttribute('fill',color);
2605
+ var title=document.createElementNS(ns,'title');title.textContent=t.date+': '+t.pass_rate+'% ('+t.total_tests+' tests)';circle.appendChild(title);svg.appendChild(circle);
2606
+ });
2607
+ chartDiv.appendChild(svg);card.appendChild(chartDiv);
2608
+ var dates=document.createElement('div');dates.style.cssText='display:flex;justify-content:space-between;font-size:10px;color:var(--text3);margin-top:4px';
2609
+ dates.appendChild(el('span',null,trend[0].date));dates.appendChild(el('span',null,trend[trend.length-1].date));
2610
+ card.appendChild(dates);container.appendChild(card);
2611
+ }
2612
+
2613
+ function renderLearnBottomRow(d){
2614
+ var c=$('#learnBottom');c.textContent='';
2615
+
2616
+ var errCard=makeLearnCard('\u2718','Most Common Errors','No errors recorded');
2617
+ var errors=d.topErrors||[];
2618
+ if(!errors.length){var e1=document.createElement('div');e1.className='learn-card-empty';e1.textContent=errCard._empty;errCard.appendChild(e1)}
2619
+ else{errors.slice(0,5).forEach(function(e){
2620
+ var pat=e.pattern.length>45?e.pattern.slice(0,42)+'...':e.pattern;
2621
+ var maxCount=errors[0].occurrence_count||1;
2622
+ var pct=(e.occurrence_count/maxCount)*100;
2623
+ var verdictEl=document.createElement('div');verdictEl.className='learn-verdict '+rateClass(100-(pct));verdictEl.textContent=e.category.replace(/-/g,' ');
2624
+ var item=makeLearnItem(pat,(e.last_seen||'').split('T')[0]+' \u00B7 '+e.occurrence_count+'x',pct,e.occurrence_count+'x','var(--red)');
2625
+ item.insertBefore(verdictEl,item.lastChild);
2626
+ errCard.appendChild(item);
2627
+ })}
2628
+ c.appendChild(errCard);
2629
+
2630
+ var slowCard=makeLearnCard('\u23F3','Slowest Tests','No slow test data');
2631
+ var trend=d.recentTrend||[];
2632
+ var slowTests=[];
2633
+ if(d.flakyTests){
2634
+ d.flakyTests.forEach(function(f){
2635
+ if(f.avg_duration_ms&&f.avg_duration_ms>2000){slowTests.push({name:f.test_name,dur:f.avg_duration_ms})}
2636
+ });
2637
+ }
2638
+ if(d.failingPages){
2639
+ d.failingPages.forEach(function(p){
2640
+ if(p.avg_load_time_ms&&p.avg_load_time_ms>3000){slowTests.push({name:p.url_path,dur:p.avg_load_time_ms})}
2641
+ });
2642
+ }
2643
+ slowTests.sort(function(a,b){return b.dur-a.dur});
2644
+ if(!slowTests.length){var e2=document.createElement('div');e2.className='learn-card-empty';e2.textContent=slowCard._empty;slowCard.appendChild(e2)}
2645
+ else{
2646
+ var maxDur=slowTests[0].dur;
2647
+ slowTests.slice(0,5).forEach(function(t){
2648
+ slowCard.appendChild(makeLearnItem(t.name,'','',durFmt(t.dur),(t.dur/maxDur)*100,t.dur>5000?'var(--red)':'var(--amber)'));
2649
+ });
2650
+ }
2651
+ c.appendChild(slowCard);
2652
+ }
2653
+
2654
+ $('#btnRefreshLearnings').addEventListener('click',refreshLearnings);
2655
+ $('#learningsDays').addEventListener('change',refreshLearnings);
2656
+
2657
+ $('#btnExportLearnings').addEventListener('click',function(){
2658
+ var data=S.lastLearningsData;
2659
+ if(!data){showToast('No learnings data to export','error');return}
2660
+ var md='# E2E Learnings Report\n\n';
2661
+ md+='| Metric | Value |\n|--------|-------|\n';
2662
+ md+='| Total Runs | '+data.totalRuns+' |\n';
2663
+ md+='| Total Tests | '+data.totalTests+' |\n';
2664
+ md+='| Pass Rate | '+data.overallPassRate+'% |\n';
2665
+ md+='| Avg Duration | '+dur(data.avgDurationMs)+' |\n\n';
2666
+ if(data.flakyTests&&data.flakyTests.length){
2667
+ md+='## Flaky Tests\n\n| Test | Flaky Rate | Occurrences |\n|------|-----------|-------------|\n';
2668
+ data.flakyTests.forEach(function(f){md+='| '+f.test_name+' | '+f.flaky_rate+'% | '+f.flaky_count+' |\n'});md+='\n';
2669
+ }
2670
+ if(data.unstableSelectors&&data.unstableSelectors.length){
2671
+ md+='## Unstable Selectors\n\n| Selector | Action | Fail Rate |\n|----------|--------|-----------|\n';
2672
+ data.unstableSelectors.forEach(function(s){md+='| `'+s.selector+'` | '+s.action_type+' | '+s.fail_rate+'% |\n'});md+='\n';
2673
+ }
2674
+ downloadFile('learnings-report.md',md,'text/markdown');
2675
+ showToast('Learnings exported','success');
2676
+ });
2677
+
2678
+ /* ── Modal ── */
2679
+ function openModal(src){$('#modalImg').src=src;$('#modal').classList.add('open')}
2680
+ $('#modal').addEventListener('click',function(){$('#modal').classList.remove('open')});
2681
+
2682
+
2683
+ /* ── view-live.js ── */
1538
2684
  /* ══════════════════════════════════════════════════════════════════
1539
- Live Execution (+ Retry badges, Serial badges)
2685
+ Live Execution View
1540
2686
  ══════════════════════════════════════════════════════════════════ */
1541
- function clearFinishedLiveRuns(){for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}renderLive()}
2687
+ function clearFinishedLiveRuns(){for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}S.screencastTest=null;renderLive()}
1542
2688
  function dismissLiveRun(rid){delete S.liveRuns[rid];renderLive()}
1543
2689
  $('#liveClearBtn').addEventListener('click',clearFinishedLiveRuns);
1544
2690
 
2691
+ // Screencast state
2692
+ S.screencastTest=null;
2693
+
2694
+ $('#screencastSelect').addEventListener('change',function(){
2695
+ S.screencastTest=this.value||null;
2696
+ var img=$('#screencastImg'),ph=$('#screencastPlaceholder');
2697
+ if(S.screencastTest){img.style.display='block';ph.style.display='none';img.src=''}
2698
+ else{img.style.display='none';ph.style.display='flex'}
2699
+ });
2700
+
2701
+ function updateScreencastSelect(){
2702
+ var sel=$('#screencastSelect'),panel=$('#screencastPanel');
2703
+ var runningTests=[];
2704
+ for(var k in S.liveRuns){var r=S.liveRuns[k];for(var n in r.tests){if(n!=='__error'&&r.tests[n].status==='running')runningTests.push(n)}}
2705
+ // Show panel if any run is active
2706
+ var anyActive=false;for(var k2 in S.liveRuns)if(S.liveRuns[k2].on)anyActive=true;
2707
+ panel.style.display=anyActive?'':'none';
2708
+ // Rebuild options
2709
+ var prev=sel.value;
2710
+ while(sel.options.length>1)sel.remove(1);
2711
+ runningTests.forEach(function(n){var o=document.createElement('option');o.value=n;o.textContent=n;sel.appendChild(o)});
2712
+ // Auto-select first running test if nothing selected
2713
+ if(!S.screencastTest&&runningTests.length>0){S.screencastTest=runningTests[0];sel.value=S.screencastTest;$('#screencastImg').style.display='block';$('#screencastPlaceholder').style.display='none'}
2714
+ else if(S.screencastTest&&runningTests.indexOf(S.screencastTest)===-1){
2715
+ // Current test finished — pick next running or clear
2716
+ if(runningTests.length>0){S.screencastTest=runningTests[0];sel.value=S.screencastTest}
2717
+ else{S.screencastTest=null;sel.value='';$('#screencastImg').style.display='none';$('#screencastPlaceholder').style.display='flex';$('#screencastPlaceholder').textContent='No running tests'}
2718
+ }
2719
+ else{sel.value=S.screencastTest||''}
2720
+ }
2721
+
1545
2722
  function renderLive(){
1546
2723
  var panel=$('#livePanel'),grid=$('#liveTests'),navLive=$('#navLive'),liveEmpty=$('#liveEmpty');
1547
2724
  var runs=S.liveRuns;var runIds=Object.keys(runs);
@@ -1591,6 +2768,9 @@ function renderLive(){
1591
2768
  dismissBtn
1592
2769
  ]));
1593
2770
 
2771
+ var poolDist=buildPoolDistribution(L.tests);
2772
+ if(poolDist)grid.appendChild(poolDist);
2773
+
1594
2774
  var testGrid=el('div',{className:'lr-test-grid'});
1595
2775
  Object.keys(L.tests).forEach(function(name){
1596
2776
  if(name==='__error')return;
@@ -1609,12 +2789,13 @@ function renderLive(){
1609
2789
  var durText=a.duration!=null?(a.duration<1000?a.duration+'ms':(a.duration/1000).toFixed(1)+'s'):'';
1610
2790
  var retryBadge=null;
1611
2791
  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)}
1612
- stepsEl.appendChild(el('div',{className:'lt-step'},[
1613
- el('span',{className:'step-icon '+(a.success?'ok':'fail')},a.success?'\u2714':'\u2718'),
1614
- el('span',{className:'step-type'},a.type),
1615
- el('span',{className:'step-detail'},detail),
2792
+ var stepCls='lt-step'+(a.isPoolLog?' pool-log':'');
2793
+ stepsEl.appendChild(el('div',{className:stepCls},[
2794
+ el('span',{className:'step-icon '+(a.isPoolLog?'':a.success?'ok':'fail')},a.isPoolLog?'\uD83D\uDD17':a.success?'\u2714':'\u2718'),
2795
+ el('span',{className:'step-type'},a.isPoolLog?'pool':a.type),
2796
+ el('span',{className:'step-detail'},a.isPoolLog?a.narrative:detail),
1616
2797
  retryBadge,
1617
- el('span',{className:'step-dur'},durText)
2798
+ a.isPoolLog?null:el('span',{className:'step-dur'},durText)
1618
2799
  ]));
1619
2800
  });
1620
2801
  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...')]))}
@@ -1648,11 +2829,18 @@ function renderLive(){
1648
2829
  ssEl=el('div',{className:'lt-screenshots'},[toggle,ssGridEl]);
1649
2830
  }
1650
2831
 
2832
+ // Screencast focus indicator
2833
+ var scFocusBadge=null;
2834
+ if(t.status==='running'){
2835
+ var isFocused=S.screencastTest===name;
2836
+ scFocusBadge=el('span',{className:'sc-focus-badge'+(isFocused?' active':''),title:'Watch this test',onclick:function(e){e.stopPropagation();S.screencastTest=name;$('#screencastSelect').value=name;$('#screencastImg').style.display='block';$('#screencastPlaceholder').style.display='none';renderLive()}},'\uD83C\uDFA5');
2837
+ }
1651
2838
  var serialBadge=t.serial?el('span',{className:'serial-badge'},'Serial'):null;
2839
+ var poolBadge=t.poolUrl?el('span',{className:'pool-badge'},t.poolUrl.replace('ws://','').replace('wss://','')):null;
1652
2840
  var card=el('div',{className:'live-test '+t.status+(isCollapsed?' collapsed':'')},[
1653
2841
  el('div',{className:'lt-name'},[
1654
2842
  t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
1655
- document.createTextNode(' '+name),serialBadge,summaryEl
2843
+ document.createTextNode(' '+name),scFocusBadge,serialBadge,poolBadge,summaryEl
1656
2844
  ]),
1657
2845
  el('div',{className:'lt-meta'},meta),stepsEl
1658
2846
  ]);
@@ -1672,142 +2860,13 @@ function renderLive(){
1672
2860
  });
1673
2861
  grid.appendChild(testGrid);
1674
2862
  });
2863
+ updateScreencastSelect();
1675
2864
  }
1676
2865
 
1677
- $('#btnRunAll').addEventListener('click',function(){triggerRun()});
1678
-
1679
- /* ── Modal ── */
1680
- function openModal(src){$('#modalImg').src=src;$('#modal').classList.add('open')}
1681
- $('#modal').addEventListener('click',function(){$('#modal').classList.remove('open')});
1682
-
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
2866
 
2867
+ /* ── keyboard.js ── */
1809
2868
  /* ══════════════════════════════════════════════════════════════════
1810
- Keyboard Shortcuts
2869
+ Keyboard Shortcuts (Updated: 1=Watch, 2=Tests, 3=Runs, 4=Live)
1811
2870
  ══════════════════════════════════════════════════════════════════ */
1812
2871
  document.addEventListener('keydown',function(e){
1813
2872
  var tag=document.activeElement.tagName;
@@ -1815,6 +2874,7 @@ document.addEventListener('keydown',function(e){
1815
2874
  if(e.key==='Escape'){
1816
2875
  if($('#kbModal').classList.contains('open')){$('#kbModal').classList.remove('open');return}
1817
2876
  if($('#modal').classList.contains('open')){$('#modal').classList.remove('open');return}
2877
+ if($('#suiteModalOverlay').classList.contains('open')){$('#suiteModalOverlay').classList.remove('open');return}
1818
2878
  if(S.selectedRun!==null){
1819
2879
  var expanded=document.querySelector('#runsBody tr.expanded');
1820
2880
  if(expanded){
@@ -1827,11 +2887,13 @@ document.addEventListener('keydown',function(e){
1827
2887
  return;
1828
2888
  }
1829
2889
  if(e.key==='?'){$('#kbModal').classList.toggle('open');return}
1830
- var viewMap={'1':'suites','2':'runs','3':'screenshots','4':'learnings','5':'live'};
2890
+ var viewMap={'1':'watch','2':'tests','3':'runs','4':'live'};
1831
2891
  if(viewMap[e.key]){showView(viewMap[e.key]);return}
1832
2892
  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();
2893
+ if(S.view==='watch')refreshWatch();
2894
+ else if(S.view==='tests'){refreshSuites();refreshVariables()}
2895
+ else if(S.view==='runs'){refreshRuns();refreshScreenshots();refreshLearnings()}
2896
+ else if(S.view==='live')renderLive();
1835
2897
  return;
1836
2898
  }
1837
2899
  if(S.view==='runs'&&(e.key==='j'||e.key==='k')){
@@ -1850,9 +2912,12 @@ document.addEventListener('keydown',function(e){
1850
2912
  });
1851
2913
  $('#kbModal').addEventListener('click',function(e){if(e.target===$('#kbModal'))$('#kbModal').classList.remove('open')});
1852
2914
 
2915
+
2916
+ /* ── init.js ── */
1853
2917
  /* ══════════════════════════════════════════════════════════════════
1854
- Init
2918
+ Init — startup sequence
1855
2919
  ══════════════════════════════════════════════════════════════════ */
2920
+ initTabs();
1856
2921
  connectWS();
1857
2922
  refreshStatus();
1858
2923
  refreshProjects();
@@ -1860,8 +2925,10 @@ refreshSuites();
1860
2925
  refreshRuns();
1861
2926
  refreshScreenshots();
1862
2927
  refreshLearnings();
1863
- })();
2928
+ refreshVariables();
2929
+ startWatchPolling();
1864
2930
 
2931
+ })();
1865
2932
  </script>
1866
2933
  </body>
1867
2934
  </html>