playwright-reborn 0.2.2 → 0.2.4

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.
package/dist/ui/report.js CHANGED
@@ -17,20 +17,22 @@
17
17
  var TABS = ['overview', 'tests', 'screenshots'];
18
18
  if (data.showLogs !== false) TABS.push('logs');
19
19
  if (data.showFiles !== false) TABS.push('files');
20
+ var testIndex = new Map(data.tests.map(function (test) { return [test.id, test]; }));
20
21
  var route = readRoute();
21
22
  var state = {
22
23
  tab: route.tab,
23
24
  filter: 'all',
24
25
  query: '',
25
- selectedId: route.id || initialFailure(),
26
+ selectedId: testIndex.has(route.id) ? route.id : initialFailure(),
26
27
  attempt: 0,
27
28
  showApi: false,
28
29
  testPane: 'steps',
29
30
  listLimit: 200,
31
+ timelineLimit: 200,
30
32
  shotFilter: 'all',
31
33
  logQuery: '',
32
- tag: 'all',
33
- projectFilter: 'all',
34
+ tag: null,
35
+ projectFilter: null,
34
36
  overview: data.overview === 'timeline' ? 'timeline' : 'chart',
35
37
  order: 'grouped',
36
38
  band: 'all',
@@ -45,18 +47,22 @@
45
47
  var findTimer = 0;
46
48
 
47
49
  app.innerHTML = [
48
- '<div class="atmosphere" aria-hidden="true"><canvas class="bands"></canvas><div class="grain"></div><div class="veil"></div></div>',
49
50
  '<div class="shell">',
50
51
  '<aside class="rail">',
51
- '<div class="tabbar" role="tablist" aria-label="Report">',
52
- '<span class="tab-glow" id="tab-glow"></span>',
52
+ '<div class="sidebar-brand">' + icon('brand') + '<span>Reborn<span class="brand-caption">Test reports</span></span></div>',
53
+ '<div class="project-card">' + icon('folder') + '<div><span class="project-caption">Workspace</span><strong id="workspace-name"></strong></div></div>',
54
+ '<p class="nav-caption">Run workspace</p>',
55
+ '<div class="tabbar" role="tablist" aria-label="Report" aria-orientation="vertical">',
56
+
53
57
  TABS.map(function (id) {
54
- return tabButton(id, id.charAt(0).toUpperCase() + id.slice(1));
58
+ return tabButton(id, id === 'overview' ? 'Dashboard' : id.charAt(0).toUpperCase() + id.slice(1));
55
59
  }).join(''),
56
60
  '</div>',
57
- '<p class="meta" id="meta"></p>',
61
+ '<div class="sidebar-bottom"><p class="nav-caption">Current run</p><p class="sidebar-state run-state" id="sidebar-state"></p><p class="sidebar-note">Completed <span id="sidebar-time"></span></p><div class="keyboard-hint"><kbd>/</kbd><span>Find a test</span></div></div>',
58
62
  '</aside>',
59
63
  '<div class="stage">',
64
+ '<header class="workspace-bar"><div class="breadcrumb"><span id="breadcrumb-company"></span><span class="breadcrumb-separator">/</span><strong id="page-name">Dashboard</strong></div><button class="theme-toggle" id="theme-toggle" type="button" role="switch" aria-checked="false" aria-label="Dark mode">' + icon('moon') + '<span>Dark mode</span><span class="theme-switch" aria-hidden="true"></span></button></header>',
65
+ '<div class="report-content">',
60
66
  '<div id="panel-overview" role="tabpanel"></div>',
61
67
  '<div id="panel-tests" role="tabpanel" hidden></div>',
62
68
  '<div id="panel-screenshots" role="tabpanel" hidden></div>',
@@ -65,11 +71,16 @@
65
71
  '<p class="foot" id="foot"></p>',
66
72
  '</div>',
67
73
  '</div>',
74
+ '</div>',
68
75
  ].join('');
69
76
 
70
77
  document.body.dataset.accent = accentName();
78
+ applyTheme(readTheme());
71
79
  document.title = companyName() + (data.summary ? ' · ' + data.summary : '');
72
- fill(document.getElementById('meta'), metaBits().map(function (bit) { return el('span', null, [bit]); }));
80
+ document.getElementById('workspace-name').textContent = companyName();
81
+ document.getElementById('breadcrumb-company').textContent = companyName();
82
+ document.getElementById('sidebar-time').textContent = formatWhen(data.generatedAt);
83
+ setRunState(document.getElementById('sidebar-state'));
73
84
  var footBits = [];
74
85
  if (data.showCredit === true) footBits.push(el('span', null, ['Open source by World of Z']));
75
86
  footBits.push(el('span', null, [formatWhen(data.generatedAt)]));
@@ -81,87 +92,41 @@
81
92
  ['3to5', '3 to 5 minutes'],
82
93
  ['over5', 'Over 5 minutes'],
83
94
  ];
84
- var CHART_COLORS = ['var(--accent)', 'rgba(255,255,255,0.72)', 'rgba(255,255,255,0.42)', 'rgba(255,255,255,0.18)'];
95
+ var CHART_COLORS = ['var(--heading)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)'];
85
96
 
97
+ syncTabOrientation();
98
+ window.addEventListener('resize', syncTabOrientation);
86
99
  paintOverview();
87
100
  showTab(state.tab, true);
88
101
  bind();
89
- watchHeader();
90
- startCurtain();
91
-
92
- function startCurtain() {
93
- var canvas = document.querySelector('.atmosphere .bands');
94
- if (!canvas || !canvas.getContext) return;
95
- var ctx = canvas.getContext('2d');
96
- var ratio = 1;
97
- var columns = [];
98
- var count = 22;
99
- for (var i = 0; i < count; i += 1) {
100
- var n = Math.sin(210 + i * 2.17);
101
- columns.push({
102
- at: (i + 0.35 + n * 0.15) / count,
103
- width: 0.012 + (0.5 + n * 0.5) * 0.018,
104
- speed: 0.22 + (i % 6) * 0.05,
105
- phase: i * 1.37,
106
- depth: i % 3,
107
- });
108
- }
109
- function resize() {
110
- ratio = Math.min(window.devicePixelRatio || 1, 2);
111
- var rect = canvas.getBoundingClientRect();
112
- canvas.width = Math.max(1, Math.floor(rect.width * ratio));
113
- canvas.height = Math.max(1, Math.floor(rect.height * ratio));
114
- }
115
- var rgb = accentParts();
116
- function draw(now) {
117
- var w = canvas.width;
118
- var h = canvas.height;
119
- var t = reduce ? 8 : now / 1000;
120
- ctx.clearRect(0, 0, w, h);
121
- var wash = ctx.createLinearGradient(0, 0, 0, h);
122
- wash.addColorStop(0, '#123d24');
123
- wash.addColorStop(0.42, '#0a2416');
124
- wash.addColorStop(1, '#050806');
125
- ctx.fillStyle = wash;
126
- ctx.fillRect(0, 0, w, h);
127
- columns.forEach(function (column) {
128
- var sway = Math.sin(t * column.speed + column.phase) * 0.02;
129
- var breath = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * column.speed * 1.4 + column.phase));
130
- var x = (column.at + sway) * w;
131
- var spread = Math.max(10, column.width * w * (0.55 + breath * 0.35));
132
- var gradient = ctx.createLinearGradient(x - spread, 0, x + spread, 0);
133
- var alpha = 0.28 + 0.62 * breath;
134
- gradient.addColorStop(0, 'rgba(' + rgb + ', 0)');
135
- gradient.addColorStop(0.42, 'rgba(' + rgb + ', ' + (alpha * 0.45).toFixed(3) + ')');
136
- gradient.addColorStop(0.5, 'rgba(' + rgb + ', ' + alpha.toFixed(3) + ')');
137
- gradient.addColorStop(0.58, 'rgba(' + rgb + ', ' + (alpha * 0.45).toFixed(3) + ')');
138
- gradient.addColorStop(1, 'rgba(' + rgb + ', 0)');
139
- ctx.fillStyle = gradient;
140
- ctx.fillRect(x - spread, 0, spread * 2, h * 0.92);
141
- });
142
- if (!reduce) requestAnimationFrame(draw);
143
- }
144
- resize();
145
- window.addEventListener('resize', resize);
146
- requestAnimationFrame(function (now) {
147
- if (!canvas.width) resize();
148
- draw(now);
149
- });
102
+
103
+
104
+ function icon(name) {
105
+ var paths = {
106
+ brand: '<path d="m8 5-5 7 5 7m8-14 5 7-5 7m-8-7 3 3 5-6"/>',
107
+ overview: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
108
+ tests: '<path d="m9 11 2 2 4-4"/><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V2h6v2"/>',
109
+ screenshots: '<rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8" cy="8" r="1.5"/><path d="m3 17 6-6 4 4 3-3 5 5"/>',
110
+ logs: '<path d="m5 6 4 4-4 4m7 3h7"/><rect x="2" y="2" width="20" height="20" rx="3"/>',
111
+ files: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zm0 0v6h6M8 13h8m-8 4h5"/>',
112
+ folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
113
+ search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
114
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
115
+ check: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
116
+ alert: '<path d="m12 3 10 18H2Z M12 9v4m0 3v1"/>',
117
+ moon: '<path d="M21 13a9 9 0 0 1-10-10 9 9 0 1 0 10 10Z"/>',
118
+ arrow: '<path d="M5 12h14m-5-5 5 5-5 5"/>',
119
+ };
120
+ return '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (paths[name] || paths.tests) + '</svg>';
121
+ }
122
+
123
+ function syncTabOrientation() {
124
+ document.querySelector('.tabbar').setAttribute('aria-orientation', window.innerWidth <= 800 ? 'horizontal' : 'vertical');
150
125
  }
151
126
 
152
- function watchHeader() {
153
- var rail = document.querySelector('.rail');
154
- if (!rail) return;
155
- var lastY = window.scrollY;
156
- window.addEventListener('scroll', function () {
157
- var y = window.scrollY;
158
- var down = y > lastY + 6;
159
- var up = y < lastY - 6;
160
- if (y < 24) rail.classList.remove('is-hidden');
161
- else if (down) rail.classList.add('is-hidden');
162
- else if (up) rail.classList.remove('is-hidden');
163
- lastY = y;
164
- }, { passive: true });
127
+ function setRunState(node) {
128
+ node.dataset.status = data.status;
129
+ node.textContent = { passed: 'Run passed', failed: 'Run failed', timedout: 'Run timed out', interrupted: 'Run interrupted' }[data.status] || 'Run finished';
165
130
  }
166
131
 
167
132
  function companyName() {
@@ -174,27 +139,32 @@
174
139
  return allowed[data.accent] ? data.accent : 'green';
175
140
  }
176
141
 
177
- function accentParts() {
178
- var probe = document.createElement('span');
179
- probe.style.color = 'var(--accent)';
180
- document.body.appendChild(probe);
181
- var color = getComputedStyle(probe).color;
182
- probe.remove();
183
- var parts = color.match(/\d+/g) || ['0', '255', '136'];
184
- return parts.slice(0, 3).join(', ');
142
+ function readTheme() {
143
+ try {
144
+ var saved = localStorage.getItem('reborn-theme');
145
+ if (saved === 'light' || saved === 'dark') return saved;
146
+ } catch (error) { /* Storage may be unavailable for local reports. */ }
147
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
148
+ }
149
+
150
+ function applyTheme(theme) {
151
+ document.body.dataset.theme = theme;
152
+ document.getElementById('theme-toggle').setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false');
185
153
  }
186
154
 
187
155
  function tabButton(id, label) {
188
- return '<button class="tab" role="tab" type="button" data-tab="' + id + '" aria-selected="false" aria-controls="panel-' + id + '">' + label + '</button>';
156
+ var count = id === 'tests' ? data.tests.length : null;
157
+ return '<button id="tab-' + id + '" class="tab" role="tab" type="button" data-tab="' + id + '" aria-selected="false" aria-controls="panel-' + id + '">' + icon(id) + '<span>' + label + '</span>' + (count === null ? '' : '<span class="nav-count" aria-hidden="true">' + count + '</span>') + '</button>';
189
158
  }
190
159
 
191
160
  function readRoute() {
192
- var hash = '';
193
- try { hash = decodeURIComponent(location.hash.slice(1)); } catch (error) { hash = ''; }
194
- if (!hash || hash === 'overview') return { tab: 'overview' };
161
+ var hash = location.hash.slice(1);
162
+ try {
163
+ if (hash.indexOf('tests/') === 0) return { tab: 'tests', id: decodeURIComponent(hash.slice(6)) };
164
+ hash = decodeURIComponent(hash);
165
+ } catch (error) { return { tab: 'overview' }; }
195
166
  if (TABS.indexOf(hash) !== -1) return { tab: hash };
196
- if (hash.indexOf('tests/') === 0) return { tab: 'tests', id: decodeURIComponent(hash.slice(6)) };
197
- if (data.tests.some(function (test) { return test.id === hash; })) return { tab: 'tests', id: hash };
167
+ if (testIndex.has(hash)) return { tab: 'tests', id: hash };
198
168
  return { tab: 'overview' };
199
169
  }
200
170
 
@@ -206,13 +176,16 @@
206
176
  function showTab(tab, quiet) {
207
177
  if (TABS.indexOf(tab) === -1) tab = 'overview';
208
178
  state.tab = tab;
179
+ document.getElementById('page-name').textContent = tab === 'overview' ? 'Dashboard' : tab.charAt(0).toUpperCase() + tab.slice(1);
209
180
  document.querySelectorAll('[role="tab"]').forEach(function (button) {
210
181
  button.setAttribute('aria-selected', button.dataset.tab === tab ? 'true' : 'false');
182
+ button.tabIndex = button.dataset.tab === tab ? 0 : -1;
211
183
  });
212
184
  TABS.forEach(function (name) {
213
185
  var panel = document.getElementById('panel-' + name);
214
186
  var on = name === tab;
215
187
  panel.hidden = !on;
188
+ panel.setAttribute('aria-labelledby', 'tab-' + name);
216
189
  panel.classList.toggle('panel', true);
217
190
  if (on && !quiet && !reduce) {
218
191
  panel.classList.remove('panel-enter');
@@ -224,68 +197,111 @@
224
197
  if (tab === 'screenshots') ensureShots();
225
198
  if (tab === 'logs') ensureLogs();
226
199
  if (tab === 'files') ensureFiles();
227
- writeHash();
228
- placeTabGlow();
200
+ writeHash(!quiet);
201
+
229
202
  }
230
203
 
231
- function writeHash() {
204
+ function writeHash(push) {
232
205
  var next = state.tab === 'overview' ? 'overview' : state.tab;
233
206
  if (state.tab === 'tests' && state.selectedId) next = 'tests/' + encodeURIComponent(state.selectedId);
234
- if (window.history && history.replaceState) history.replaceState(null, '', '#' + next);
235
- }
236
-
237
- function placeTabGlow() {
238
- var bar = document.querySelector('.tabbar');
239
- var glow = document.getElementById('tab-glow');
240
- var active = document.querySelector('.tab[aria-selected="true"]');
241
- if (!bar || !glow || !active) return;
242
- var barBox = bar.getBoundingClientRect();
243
- var box = active.getBoundingClientRect();
244
- glow.style.width = box.width + 'px';
245
- glow.style.transform = 'translateX(' + (box.left - barBox.left - 4) + 'px)';
207
+ if (location.hash === '#' + next) return;
208
+ if (window.history && history.replaceState) {
209
+ var url = location.protocol === 'about:' ? location.href.split('#')[0] + '#' + next : '#' + next;
210
+ try { history[push ? 'pushState' : 'replaceState'](null, '', url); } catch (error) { /* Some artifact viewers block history updates. */ }
211
+ }
246
212
  }
247
213
 
248
214
  function paintOverview() {
249
215
  var host = document.getElementById('panel-overview');
250
216
  host.innerHTML = [
251
217
  '<header class="hero">',
252
- '<div><h1 class="word" id="word"></h1><p class="product" id="product"></p><p class="summary" id="summary"></p></div>',
253
- '<p class="time" id="time"></p>',
218
+ '<div><p class="dashboard-kicker">Run overview</p><h1 class="word">Dashboard</h1><p class="product" id="product"></p><p class="summary" id="summary"></p></div>',
219
+ '<p class="run-state" id="run-state"></p>',
254
220
  '</header>',
221
+ '<p class="meta" id="meta"></p>',
255
222
  '<div id="errors"></div>',
256
- '<dl class="info" id="info"></dl>',
257
- '<section class="console">',
258
- '<div class="console-body">',
259
- '<p class="section-label">Results</p>',
260
- '<div class="legend" id="legend"></div>',
261
- '<div class="range-head">',
262
- '<p class="section-label" id="range-label">Duration</p>',
263
- '<div class="views" role="group" aria-label="Overview shape">',
264
- '<button type="button" class="view" data-overview="chart">Chart</button>',
265
- '<button type="button" class="view" data-overview="timeline">Timeline</button>',
266
- '</div>',
267
- '</div>',
268
- '<p class="section-note" id="range-note"></p>',
269
- '<div class="charts" id="duration"></div>',
223
+ '<div class="metric-grid" id="metrics"></div>',
224
+ '<div class="dashboard-grid">',
225
+ '<section class="dashboard-card result-card"><div class="card-heading"><h2>Test results</h2><span class="card-detail">This run</span></div>',
226
+ '<div class="result-body"><div class="result-ring" id="result-ring"><div><strong id="pass-percent"></strong><span>Passed</span></div></div>',
227
+ '<div class="legend" id="legend"></div></div><p class="card-note" id="result-note"></p></section>',
228
+ '<section class="dashboard-card console"><div class="console-body">',
229
+ '<div class="range-head"><h2 class="section-label" id="range-label">Duration</h2>',
230
+ '<div class="views" role="group" aria-label="Overview shape"><button type="button" class="view" data-overview="chart">Chart</button><button type="button" class="view" data-overview="timeline">Timeline</button></div></div>',
231
+ '<p class="section-note" id="range-note"></p><div class="charts" id="duration"></div>',
270
232
  '<div class="strip-window" id="strip-window"><div class="strip-scroll"><div class="strip" id="strip" role="group" aria-label="Tests by time"></div></div></div>',
233
+ '</div></section>',
234
+ '<section class="dashboard-card attention-card"><div class="card-heading"><h2>Needs attention</h2><button type="button" class="dashboard-link" data-go-tests>View all tests ' + icon('arrow') + '</button></div><div id="attention-list"></div></section>',
235
+ '<section class="dashboard-card environment-card"><div class="card-heading"><h2>Run details</h2>' + icon('folder') + '</div><dl class="run-details" id="run-details"></dl><dl class="info" id="info"></dl></section>',
271
236
  '</div>',
272
- '</section>',
273
237
  ].join('');
274
- var word = document.getElementById('word');
275
- word.textContent = companyName();
276
- var product = document.getElementById('product');
277
238
  var subtitle = typeof data.productSubtitle === 'string' ? data.productSubtitle.trim() : 'Test Automation';
278
- if (subtitle) product.textContent = subtitle;
279
- else product.hidden = true;
239
+ document.getElementById('product').textContent = subtitle;
240
+ document.getElementById('product').hidden = !subtitle;
280
241
  document.getElementById('summary').textContent = data.summary || '';
281
- countUp(document.getElementById('time'), data.duration || 0, formatDuration);
242
+ fill(document.getElementById('meta'), metaBits().map(function (bit) { return el('span', null, [bit]); }));
243
+ setRunState(document.getElementById('run-state'));
244
+ paintDashboard();
282
245
  paintErrors();
283
246
  paintInfo();
284
247
  paintCounts(document.getElementById('legend'), false);
285
248
  paintDuration();
286
249
  paintStrip();
287
250
  applyOverview();
288
- showNow(state.selectedId);
251
+ }
252
+
253
+ function paintDashboard() {
254
+ var counts = data.counts || {};
255
+ var total = data.tests.length;
256
+ var passed = counts.passed || 0;
257
+ var percent = total ? Math.round(passed / total * 100) : 0;
258
+ var problems = data.tests.filter(isProblem);
259
+ var metrics = [
260
+ ['tests', 'Total tests', String(total), 'Across ' + uniqueValues(function (test) { return [test.file]; }).length + ' test files', ''],
261
+ ['check', 'Pass rate', total ? percent + '%' : '—', passed + ' of ' + total + ' tests passed', 'positive'],
262
+ ['clock', 'Run duration', formatDuration(data.duration || 0), 'Wall time including retries', ''],
263
+ ['alert', 'Needs attention', String(problems.length), 'Failed, flaky or interrupted', problems.length ? 'warning' : 'positive'],
264
+ ];
265
+ fill(document.getElementById('metrics'), metrics.map(function (metric) {
266
+ var node = el('div', { class: 'metric ' + metric[4] }, [
267
+ el('div', { class: 'metric-label' }, [metric[1]]),
268
+ el('strong', { class: 'metric-value' }, [metric[2]]),
269
+ el('span', { class: 'metric-note' }, [metric[3]]),
270
+ ]);
271
+ node.querySelector('.metric-label').insertAdjacentHTML('beforeend', icon(metric[0]));
272
+ return node;
273
+ }));
274
+ document.getElementById('pass-percent').textContent = total ? percent + '%' : '—';
275
+ var colors = { passed: 'var(--pass)', failed: 'var(--fail)', timedOut: 'var(--fail)', flaky: 'var(--flaky)', skipped: 'var(--skip)', interrupted: 'var(--timeout)' };
276
+ var offset = 0;
277
+ var stops = [];
278
+ Object.keys(colors).forEach(function (status) {
279
+ var amount = counts[status] || 0;
280
+ if (!amount || !total) return;
281
+ var next = offset + amount / total * 100;
282
+ stops.push(colors[status] + ' ' + offset + '% ' + next + '%');
283
+ offset = next;
284
+ });
285
+ document.getElementById('result-ring').style.background = stops.length ? 'conic-gradient(' + stops.join(',') + ')' : 'var(--hairline)';
286
+ document.getElementById('result-note').textContent = 'Select a result to explore its tests. Pass rate includes skipped tests in the total.';
287
+ var attention = problems.slice(0, 5).map(function (test) {
288
+ return el('button', { type: 'button', class: 'attention-row open-test', 'data-id': test.id, 'data-attempt': String(failedAttempt(test)) }, [
289
+ el('span', { class: 'attention-status sw', 'data-status': test.status }, [statusLabel(test)]),
290
+ el('span', { class: 'attention-test' }, [el('strong', null, [test.title]), el('span', null, [test.file])]),
291
+ el('span', { class: 'attention-time' }, [formatDuration(test.duration)]),
292
+ ]);
293
+ });
294
+ if (!attention.length) attention.push(el('p', { class: 'empty' }, [total ? 'No tests need attention. Your run is looking good.' : 'No tests ran. Run your test suite to see results here.']));
295
+ if (problems.length > 5) attention.push(el('p', { class: 'card-note' }, ['Showing 5 of ' + problems.length + ' tests. View all tests to explore the rest.']));
296
+ fill(document.getElementById('attention-list'), attention);
297
+ var rows = [['Started', formatWhen(data.startTime)], ['Workers', String(data.workers || 0)], ['Screenshots', { off: 'Off', failure: 'On failure', last: 'Last step', steps: 'Every step' }[data.screenshots] || 'Off']];
298
+ if (data.showProjects === true && (data.projects || []).length) rows.push(['Projects', data.projects.join(', ')]);
299
+ if (data.shard) rows.push(['Shard', data.shard]);
300
+ var customLabels = (data.info || []).map(function (row) { return String(row.label).toLowerCase(); });
301
+ rows = rows.filter(function (row) { return customLabels.indexOf(row[0].toLowerCase()) === -1; });
302
+ fill(document.getElementById('run-details'), rows.map(function (row) {
303
+ return el('div', null, [el('dt', null, [row[0]]), el('dd', null, [row[1]])]);
304
+ }));
289
305
  }
290
306
 
291
307
  function paintInfo() {
@@ -361,7 +377,7 @@
361
377
  empty.setAttribute('cx', '110');
362
378
  empty.setAttribute('cy', '110');
363
379
  empty.setAttribute('r', '96');
364
- empty.setAttribute('fill', 'rgba(255,255,255,0.08)');
380
+ empty.setAttribute('fill', 'var(--hairline)');
365
381
  svg.appendChild(empty);
366
382
  return svg;
367
383
  }
@@ -479,7 +495,7 @@
479
495
  function paintCounts(host, withFind) {
480
496
  var counts = data.counts || {};
481
497
  var filters = [
482
- ['all', 'ran', counts.total || 0],
498
+ ['all', 'tests', counts.total || 0],
483
499
  ['passed', 'passed', counts.passed || 0],
484
500
  ['failed', 'failed', counts.failed || 0],
485
501
  ['flaky', 'flaky', counts.flaky || 0],
@@ -490,22 +506,9 @@
490
506
  var buttons = filters.map(function (filter) {
491
507
  return el('button', { class: 'count', type: 'button', 'data-status': filter[0], 'aria-pressed': 'false' }, [
492
508
  el('span', { class: 'n' }, [String(filter[2])]),
493
- filter[1],
509
+ ' ' + filter[1],
494
510
  ]);
495
511
  });
496
- if (host.id === 'legend') {
497
- buttons.unshift(el('div', { class: 'count count-static' }, [
498
- el('span', { class: 'n' }, [formatDuration(data.duration || 0)]),
499
- 'total',
500
- ]));
501
- buttons.forEach(function (button) {
502
- if (button.classList.contains('count-static')) return;
503
- var node = button.querySelector('.n');
504
- var value = Number(node.textContent);
505
- node.textContent = '0';
506
- countUp(node, value, function (next) { return String(Math.round(next)); });
507
- });
508
- }
509
512
  var children = buttons;
510
513
  if (withFind) {
511
514
  children = buttons.concat([el('div', { class: 'find-wrap' }, [
@@ -517,7 +520,7 @@
517
520
  }
518
521
 
519
522
  function syncPressed() {
520
- document.querySelectorAll('.count').forEach(function (button) {
523
+ document.querySelectorAll('.count[data-status]').forEach(function (button) {
521
524
  button.setAttribute('aria-pressed', button.dataset.status === state.filter ? 'true' : 'false');
522
525
  });
523
526
  }
@@ -538,7 +541,7 @@
538
541
  function paintStrip() {
539
542
  var strip = document.getElementById('strip');
540
543
  if (!data.tests.length) {
541
- fill(strip, []);
544
+ fill(strip, [el('p', { class: 'empty' }, ['No tests ran.'])]);
542
545
  return;
543
546
  }
544
547
  var max = data.tests.reduce(function (hi, test) { return Math.max(hi, test.duration || 0); }, 1);
@@ -547,7 +550,7 @@
547
550
  });
548
551
  strip.className = 'run-list';
549
552
  strip.style.height = '';
550
- fill(strip, ordered.map(function (test) {
553
+ var nodes = ordered.slice(0, state.timelineLimit).map(function (test) {
551
554
  var fillBar = el('span', { class: 'run-fill', 'data-status': test.status });
552
555
  fillBar.style.width = Math.max(2, Math.round(((test.duration || 0) / max) * 100)) + '%';
553
556
  return el('button', {
@@ -562,51 +565,10 @@
562
565
  el('span', { class: 'run-track' }, [fillBar]),
563
566
  el('span', { class: 'run-time' }, [formatDuration(test.duration)]),
564
567
  ]);
565
- }));
566
- markStrip();
567
- }
568
-
569
- function paintCanvasStrip(host) {
570
- var canvas = el('canvas', { class: 'strip-canvas', 'aria-label': 'Tests by duration' });
571
- fill(host, [canvas]);
572
- var hits = [];
573
- function draw() {
574
- var width = host.clientWidth || 600;
575
- var height = 52;
576
- var ratio = window.devicePixelRatio || 1;
577
- canvas.width = Math.floor(width * ratio);
578
- canvas.height = Math.floor(height * ratio);
579
- canvas.style.width = width + 'px';
580
- canvas.style.height = height + 'px';
581
- var ctx = canvas.getContext('2d');
582
- ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
583
- ctx.clearRect(0, 0, width, height);
584
- var total = data.tests.reduce(function (sum, test) { return sum + Math.max(test.duration || 0, 1); }, 0);
585
- var x = 0;
586
- hits = [];
587
- data.tests.forEach(function (test) {
588
- var w = Math.max(2, (Math.max(test.duration || 0, 1) / total) * width);
589
- ctx.fillStyle = canvasColor(test.status);
590
- ctx.fillRect(x, 8, Math.max(1, w - 2), 36);
591
- hits.push({ x: x, w: w, id: test.id });
592
- x += w;
593
- });
594
- }
595
- draw();
596
- window.addEventListener('resize', draw);
597
- canvas.addEventListener('mousemove', function (event) {
598
- var rect = canvas.getBoundingClientRect();
599
- var x = event.clientX - rect.left;
600
- var hit = hits.find(function (item) { return x >= item.x && x < item.x + item.w; });
601
- if (hit) showNow(hit.id);
602
- });
603
- canvas.addEventListener('mouseleave', function () { showNow(state.selectedId); });
604
- canvas.addEventListener('click', function (event) {
605
- var rect = canvas.getBoundingClientRect();
606
- var x = event.clientX - rect.left;
607
- var hit = hits.find(function (item) { return x >= item.x && x < item.x + item.w; });
608
- if (hit) choose(hit.id, true);
609
568
  });
569
+ if (ordered.length > state.timelineLimit) nodes.push(el('button', { class: 'more', type: 'button', id: 'show-more-timeline' }, ['Show more tests']));
570
+ fill(strip, nodes);
571
+ markStrip();
610
572
  }
611
573
 
612
574
  function canvasColor(status) {
@@ -617,7 +579,7 @@
617
579
  }
618
580
 
619
581
  function markStrip() {
620
- var visible = {};
582
+ var visible = Object.create(null);
621
583
  filteredTests().forEach(function (test) { visible[test.id] = true; });
622
584
  document.querySelectorAll('.run-row').forEach(function (segment) {
623
585
  segment.classList.toggle('is-dim', !visible[segment.dataset.id]);
@@ -637,7 +599,7 @@
637
599
  }
638
600
 
639
601
  function uniqueValues(pick) {
640
- var seen = {};
602
+ var seen = Object.create(null);
641
603
  data.tests.forEach(function (test) {
642
604
  pick(test).forEach(function (value) {
643
605
  if (value) seen[value] = true;
@@ -646,33 +608,25 @@
646
608
  return Object.keys(seen).sort();
647
609
  }
648
610
 
611
+ function selectControl(id, label, choices, current) {
612
+ var select = el('select', { id: id, class: 'filter-select' }, choices.map(function (choice) {
613
+ return el('option', { value: choice[0] }, [choice[1]]);
614
+ }));
615
+ select.value = current || '';
616
+ return el('label', { class: 'filter-control', for: id }, [el('span', null, [label]), select]);
617
+ }
618
+
649
619
  function paintTags() {
650
620
  var host = document.getElementById('tags');
651
621
  if (!host) return;
652
622
  var tags = uniqueValues(function (test) { return (test.tags || []).filter(keptTag); });
653
623
  var projects = uniqueValues(function (test) { return test.project ? [test.project] : []; });
654
- var nodes = [];
655
- if (tags.length) {
656
- nodes.push(el('span', { class: 'order-label' }, ['Tags']));
657
- nodes.push(chip('tag-btn', 'all', 'All', state.tag));
658
- tags.forEach(function (tag) { nodes.push(chip('tag-btn', tag, tag, state.tag)); });
659
- }
624
+ var nodes = [selectControl('tag-select', 'Tags', [['', 'All tags']].concat(tags.map(function (tag) { return [tag, tag]; })), state.tag)];
660
625
  if (data.showProjectFilter === true && projects.length > 1) {
661
- nodes.push(el('span', { class: 'order-label' }, ['Project']));
662
- nodes.push(chip('project-btn', 'all', 'All', state.projectFilter));
663
- projects.forEach(function (project) { nodes.push(chip('project-btn', project, project, state.projectFilter)); });
626
+ nodes.push(selectControl('project-select', 'Project', [['', 'All projects']].concat(projects.map(function (project) { return [project, project]; })), state.projectFilter));
664
627
  }
665
- host.hidden = nodes.length === 0;
666
628
  fill(host, nodes);
667
- }
668
-
669
- function chip(className, value, label, current) {
670
- return el('button', {
671
- class: className,
672
- type: 'button',
673
- 'data-value': value,
674
- 'aria-pressed': current === value ? 'true' : 'false',
675
- }, [label]);
629
+ if (!tags.length) document.getElementById('tag-select').disabled = true;
676
630
  }
677
631
 
678
632
  function sortTests(tests) {
@@ -692,17 +646,10 @@
692
646
  function paintOrder() {
693
647
  var host = document.getElementById('order');
694
648
  if (!host) return;
695
- var orders = [['grouped', 'Grouped'], ['slowest', 'Slowest'], ['fastest', 'Fastest'], ['started', 'Started']];
696
- var nodes = [el('span', { class: 'order-label' }, ['Order'])].concat(orders.map(function (order) {
697
- return el('button', {
698
- class: 'order-btn',
699
- type: 'button',
700
- 'data-order': order[0],
701
- 'aria-pressed': state.order === order[0] ? 'true' : 'false',
702
- }, [order[1]]);
703
- }));
649
+ var orders = [['grouped', 'Grouped'], ['slowest', 'Slowest first'], ['fastest', 'Fastest first'], ['started', 'Start time']];
650
+ var nodes = [selectControl('sort-select', 'Sort', orders, state.order)];
704
651
  if (state.band !== 'all') {
705
- nodes.push(el('button', { class: 'band-clear', type: 'button', 'aria-pressed': 'true' }, [bandLabel(state.band)]));
652
+ nodes.push(el('button', { class: 'band-clear', type: 'button', 'aria-label': 'Clear duration filter: ' + bandLabel(state.band) }, [bandLabel(state.band), el('span', { 'aria-hidden': 'true' }, [' ×'])]));
706
653
  }
707
654
  fill(host, nodes);
708
655
  }
@@ -711,7 +658,7 @@
711
658
  if (built.tests) return;
712
659
  built.tests = true;
713
660
  var host = document.getElementById('panel-tests');
714
- host.innerHTML = '<div class="legend" id="test-tools"></div><div class="tags" id="tags"></div><div class="order" id="order"></div><div class="split"><div id="tests"></div><section class="detail" id="detail"></section></div>';
661
+ host.innerHTML = '<div class="page-heading"><h2>Tests</h2><p>Explore results, retries and the steps behind every test.</p></div><div class="legend" id="test-tools"></div><div class="filter-toolbar"><div class="tags" id="tags"></div><div class="order" id="order"></div></div><div class="list-summary" id="list-summary"></div><div class="split"><div id="tests"></div><section class="detail" id="detail"></section></div>';
715
662
  paintCounts(document.getElementById('test-tools'), true);
716
663
  paintTags();
717
664
  paintOrder();
@@ -724,8 +671,13 @@
724
671
  var host = document.getElementById('tests');
725
672
  if (!host) return;
726
673
  var tests = filteredTests();
674
+ var summary = document.getElementById('list-summary');
675
+ if (summary) fill(summary, [
676
+ el('span', { role: 'status', 'aria-live': 'polite' }, [tests.length + ' of ' + data.tests.length + ' tests']),
677
+ hasFilters() ? el('button', { type: 'button', class: 'reset-filters' }, ['Clear filters']) : null,
678
+ ]);
727
679
  if (!tests.length) {
728
- fill(host, [el('p', { class: 'empty' }, [data.tests.length ? 'No tests match.' : 'No tests ran.'])]);
680
+ fill(host, [el('p', { class: 'empty' }, [data.tests.length ? 'No tests match. Clear filters or try another search.' : 'No tests ran.'])]);
729
681
  return;
730
682
  }
731
683
  if (state.order !== 'grouped') {
@@ -733,20 +685,20 @@
733
685
  var shown = sorted.slice(0, state.listLimit);
734
686
  var flat = shown.map(function (test) { return rowButton(test, true); });
735
687
  if (shown.length < sorted.length) {
736
- flat.push(el('button', { class: 'more', type: 'button', id: 'show-rest' }, ['Show the rest']));
688
+ flat.push(el('button', { class: 'more', type: 'button', id: 'show-rest' }, ['Show more tests']));
737
689
  }
738
690
  fill(host, flat);
739
691
  return;
740
692
  }
741
693
  var groups = [];
742
- var problemIds = {};
694
+ var problemIds = Object.create(null);
743
695
  var problems = tests.filter(isProblem);
744
696
  if (state.filter === 'all' && !state.query && problems.length && problems.length !== tests.length) {
745
697
  problems.forEach(function (test) { problemIds[test.id] = true; });
746
698
  groups.push({ name: 'Problems', tests: problems });
747
699
  }
748
700
  var byFile = [];
749
- var index = {};
701
+ var index = Object.create(null);
750
702
  tests.forEach(function (test) {
751
703
  if (problemIds[test.id]) return;
752
704
  if (index[test.file] == null) {
@@ -771,7 +723,7 @@
771
723
  return el('div', { class: 'group' }, [el('p', { class: 'file-name' }, [group.name])].concat(rows));
772
724
  }).filter(Boolean);
773
725
  if (truncated) {
774
- nodes.push(el('button', { class: 'more', type: 'button', id: 'show-rest' }, ['Show the rest']));
726
+ nodes.push(el('button', { class: 'more', type: 'button', id: 'show-rest' }, ['Show more tests']));
775
727
  }
776
728
  fill(host, nodes);
777
729
  }
@@ -805,7 +757,7 @@
805
757
  ['logs', 'Logs'],
806
758
  ['shots', 'Screenshots'],
807
759
  ];
808
- var tabs = el('div', { class: 'subtabs', role: 'tablist' }, panes.map(function (pane) {
760
+ var tabs = el('div', { class: 'subtabs', role: 'group', 'aria-label': 'Test details' }, panes.map(function (pane) {
809
761
  return el('button', {
810
762
  class: 'subtab',
811
763
  type: 'button',
@@ -816,6 +768,7 @@
816
768
  var body = [headerBlock(test), tabs];
817
769
  if (test.attempts.length > 1) body.push(attemptTabs(test));
818
770
  if (attempt) body.push(paneBody(test, attempt));
771
+ else body.push(el('p', { class: 'empty block' }, ['This test did not run.']));
819
772
  fill(host, [el('div', { class: 'detail-body' }, body)]);
820
773
  }
821
774
 
@@ -831,7 +784,7 @@
831
784
  if (test.group && test.group.length) bits.push(el('p', { class: 'kicker' }, [test.group.join(' / ')]));
832
785
  bits.push(el('h2', null, [test.title]));
833
786
  var where = [el('span', null, [test.file + ':' + test.line])];
834
- if (test.project) where.push(el('span', null, [test.project]));
787
+ if (data.showProjects === true && test.project) where.push(el('span', null, [test.project]));
835
788
  (test.tags || []).forEach(function (tag) { where.push(el('span', null, [tag])); });
836
789
  bits.push(el('p', { class: 'where' }, where));
837
790
  bits.push(el('p', { class: 'status-line' }, [
@@ -859,7 +812,8 @@
859
812
 
860
813
  function errorBlock(attempt) {
861
814
  if (!attempt.errors || !attempt.errors.length) return null;
862
- var image = (attempt.attachments || []).find(function (item) { return item.kind === 'image' && safeSrc(item.path); });
815
+ var images = (attempt.attachments || []).filter(function (item) { return item.kind === 'image' && safeSrc(item.path); });
816
+ var image = images.find(function (item) { return item.role === 'failure'; }) || images[images.length - 1];
863
817
  var messages = attempt.errors.map(function (error) {
864
818
  var nodes = [el('pre', null, [error.message || 'The test failed.'])];
865
819
  if (error.location) nodes.push(el('p', { class: 'where-error' }, [error.location]));
@@ -949,8 +903,9 @@
949
903
  var src = safeSrc(file.path);
950
904
  var copy = el('div', { class: 'tile-copy' }, [
951
905
  el('p', { class: 'tile-title' }, [test.title]),
906
+ el('p', { class: 'tile-meta' }, ['Attempt ' + (index + 1) + ' · ' + attemptLabel(attempt.status)]),
952
907
  el('p', { class: 'tile-meta' }, [file.stepTitle || file.name || 'Screenshot']),
953
- el('button', { class: 'open-test', type: 'button', 'data-id': test.id, 'data-attempt': String(index) }, ['Open test']),
908
+ el('button', { class: 'open-test', type: 'button', 'data-id': test.id, 'data-attempt': String(index), 'data-open-pane': 'shots' }, ['Open test']),
954
909
  ]);
955
910
  var tile = el('div', { class: 'tile', 'data-role': file.role || '', 'data-status': test.status }, [
956
911
  el('button', { class: 'shot', type: 'button', 'data-src': src, 'data-alt': file.name || test.title }, [
@@ -1014,7 +969,7 @@
1014
969
  ];
1015
970
  if (attempt.stdout) nodes.push(el('div', { class: 'well' }, [el('p', null, ['Output']), el('pre', null, [attempt.stdout])]));
1016
971
  if (attempt.stderr) nodes.push(el('div', { class: 'well' }, [el('p', null, ['Error output']), el('pre', null, [attempt.stderr])]));
1017
- nodes.push(el('button', { class: 'open-test', type: 'button', 'data-id': test.id, 'data-attempt': String(index) }, ['Open test']));
972
+ nodes.push(el('button', { class: 'open-test', type: 'button', 'data-id': test.id, 'data-attempt': String(index), 'data-open-pane': 'logs' }, ['Open test']));
1018
973
  var card = el('article', { class: 'log-card', 'data-blob': blob }, nodes);
1019
974
  cards.push(card);
1020
975
  });
@@ -1022,15 +977,21 @@
1022
977
  fill(host, [
1023
978
  el('h2', null, ['Logs']),
1024
979
  el('input', { id: 'log-find', class: 'find', type: 'search', placeholder: 'Find in logs', 'aria-label': 'Find in logs' }),
1025
- el('div', { id: 'log-list' }, cards.length ? cards : [el('p', { class: 'empty' }, ['No logs in this run.'])]),
980
+ el('p', { class: 'empty', id: 'log-empty', hidden: cards.length > 0 }, ['No logs in this run.']),
981
+ el('div', { id: 'log-list' }, cards),
1026
982
  ]);
1027
983
  }
1028
984
 
1029
985
  function applyLogFilter() {
1030
986
  var query = state.logQuery.trim().toLowerCase();
987
+ var visible = 0;
1031
988
  document.querySelectorAll('.log-card').forEach(function (card) {
1032
989
  card.hidden = Boolean(query) && card.dataset.blob.indexOf(query) === -1;
990
+ if (!card.hidden) visible += 1;
1033
991
  });
992
+ var empty = document.getElementById('log-empty');
993
+ empty.hidden = visible > 0;
994
+ empty.textContent = query ? 'No logs match. Try another search.' : 'No logs in this run.';
1034
995
  }
1035
996
 
1036
997
  function ensureFiles() {
@@ -1067,10 +1028,41 @@
1067
1028
  }
1068
1029
 
1069
1030
  function bind() {
1070
- window.addEventListener('resize', placeTabGlow);
1031
+ window.addEventListener('hashchange', function () {
1032
+ var next = readRoute();
1033
+ if (next.tab === 'tests' && next.id && testById(next.id)) {
1034
+ resetFilters();
1035
+ state.selectedId = next.id;
1036
+ state.attempt = failedAttempt(testById(next.id));
1037
+ state.testPane = defaultPane(testById(next.id));
1038
+ if (built.tests) { paintTags(); paintOrder(); paintList(); paintDetail(); syncPressed(); }
1039
+ }
1040
+ showTab(next.tab, true);
1041
+ });
1042
+ document.querySelector('.skip').addEventListener('click', function (event) {
1043
+ event.preventDefault();
1044
+ showTab('tests');
1045
+ document.getElementById('find').focus();
1046
+ });
1071
1047
  app.addEventListener('click', function (event) {
1072
1048
  var target = event.target instanceof Element ? event.target : null;
1073
1049
  if (!target) return;
1050
+ if (target.closest('.reset-filters')) {
1051
+ resetFilters(); keepSelectionInFilter(); paintTags(); paintOrder(); paintDuration(); paintList(); paintDetail(); syncPressed();
1052
+ document.getElementById('find').focus();
1053
+ return;
1054
+ }
1055
+ if (target.closest('#theme-toggle')) {
1056
+ var theme = document.body.dataset.theme === 'dark' ? 'light' : 'dark';
1057
+ applyTheme(theme);
1058
+ try { localStorage.setItem('reborn-theme', theme); } catch (error) { /* Theme still works without storage. */ }
1059
+ return;
1060
+ }
1061
+ if (target.closest('[data-go-tests]')) {
1062
+ showTab('tests');
1063
+ document.getElementById('find').focus();
1064
+ return;
1065
+ }
1074
1066
  var tab = target.closest('.tab');
1075
1067
  if (tab) {
1076
1068
  showTab(tab.dataset.tab);
@@ -1084,7 +1076,7 @@
1084
1076
  }
1085
1077
  var opener = target.closest('.open-test');
1086
1078
  if (opener) {
1087
- openTest(opener.dataset.id, Number(opener.dataset.attempt || 0));
1079
+ openTest(opener.dataset.id, Number(opener.dataset.attempt || 0), opener.dataset.openPane);
1088
1080
  return;
1089
1081
  }
1090
1082
  var view = target.closest('.view');
@@ -1119,35 +1111,6 @@
1119
1111
  paintDetail();
1120
1112
  return;
1121
1113
  }
1122
- var tag = target.closest('.tag-btn');
1123
- if (tag) {
1124
- state.tag = tag.dataset.value || 'all';
1125
- state.listLimit = 200;
1126
- keepSelectionInFilter();
1127
- paintTags();
1128
- paintList();
1129
- paintDetail();
1130
- return;
1131
- }
1132
- var projectButton = target.closest('.project-btn');
1133
- if (projectButton) {
1134
- state.projectFilter = projectButton.dataset.value || 'all';
1135
- state.listLimit = 200;
1136
- keepSelectionInFilter();
1137
- paintTags();
1138
- paintList();
1139
- paintDetail();
1140
- return;
1141
- }
1142
- var order = target.closest('.order-btn');
1143
- if (order) {
1144
- state.order = order.dataset.order || 'grouped';
1145
- keepSelectionInFilter();
1146
- paintOrder();
1147
- paintList();
1148
- paintDetail();
1149
- return;
1150
- }
1151
1114
  var count = target.closest('.count');
1152
1115
  if (count && !count.classList.contains('shot-filter') && !count.classList.contains('count-static')) {
1153
1116
  state.filter = count.dataset.status || 'all';
@@ -1159,7 +1122,6 @@
1159
1122
  paintList();
1160
1123
  paintDetail();
1161
1124
  markStrip();
1162
- showNow(state.selectedId);
1163
1125
  }
1164
1126
  return;
1165
1127
  }
@@ -1177,12 +1139,14 @@
1177
1139
  if (pane) {
1178
1140
  state.testPane = pane.dataset.pane || 'steps';
1179
1141
  paintDetail();
1142
+ document.querySelector('.subtab[data-pane="' + state.testPane + '"]').focus();
1180
1143
  return;
1181
1144
  }
1182
1145
  var attempt = target.closest('.attempt');
1183
1146
  if (attempt) {
1184
1147
  state.attempt = Number(attempt.dataset.index || 0);
1185
1148
  paintDetail();
1149
+ document.querySelector('.attempt[data-index="' + state.attempt + '"]').focus();
1186
1150
  return;
1187
1151
  }
1188
1152
  if (target.closest('#api-toggle')) {
@@ -1192,15 +1156,36 @@
1192
1156
  if (toggle) toggle.focus();
1193
1157
  return;
1194
1158
  }
1159
+ if (target.closest('#show-more-timeline')) {
1160
+ state.timelineLimit += 200; paintStrip();
1161
+ var moreTimeline = document.getElementById('show-more-timeline');
1162
+ if (moreTimeline) moreTimeline.focus();
1163
+ return;
1164
+ }
1195
1165
  if (target.closest('#show-rest')) {
1196
- state.listLimit = 100000;
1166
+ state.listLimit += 200;
1197
1167
  paintList();
1168
+ var more = document.getElementById('show-rest');
1169
+ if (more) more.focus();
1198
1170
  return;
1199
1171
  }
1200
1172
  var shot = target.closest('.shot');
1201
1173
  if (shot) openShot(shot.dataset.src, shot.dataset.alt || 'Screenshot');
1202
1174
  });
1203
1175
 
1176
+ app.addEventListener('change', function (event) {
1177
+ var control = event.target;
1178
+ if (!control || ['tag-select', 'project-select', 'sort-select'].indexOf(control.id) === -1) return;
1179
+ if (control.id === 'tag-select') state.tag = control.value || null;
1180
+ if (control.id === 'project-select') state.projectFilter = control.value || null;
1181
+ if (control.id === 'sort-select') state.order = control.value || 'grouped';
1182
+ state.listLimit = 200;
1183
+ keepSelectionInFilter();
1184
+ paintList();
1185
+ paintDetail();
1186
+ markStrip();
1187
+ });
1188
+
1204
1189
  app.addEventListener('input', function (event) {
1205
1190
  if (!event.target) return;
1206
1191
  if (event.target.id === 'find') {
@@ -1213,7 +1198,6 @@
1213
1198
  paintList();
1214
1199
  paintDetail();
1215
1200
  markStrip();
1216
- showNow(state.selectedId);
1217
1201
  }, 80);
1218
1202
  }
1219
1203
  if (event.target.id === 'log-find') {
@@ -1222,21 +1206,13 @@
1222
1206
  }
1223
1207
  });
1224
1208
 
1225
- app.addEventListener('mouseover', function (event) {
1226
- var segment = event.target && event.target.closest ? event.target.closest('.run-row') : null;
1227
- if (segment) showNow(segment.dataset.id);
1228
- });
1229
- app.addEventListener('mouseout', function (event) {
1230
- var segment = event.target && event.target.closest ? event.target.closest('.run-row') : null;
1231
- if (!segment) return;
1232
- var next = event.relatedTarget && event.relatedTarget.closest ? event.relatedTarget.closest('.run-row') : null;
1233
- if (!next) showNow(state.selectedId);
1234
- });
1235
-
1236
1209
  document.addEventListener('keydown', function (event) {
1237
- var typing = event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement;
1210
+ if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey || event.altKey || document.querySelector('dialog[open]')) return;
1211
+ if (event.target instanceof HTMLSelectElement) return;
1212
+ var typing = event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || event.target.isContentEditable;
1238
1213
  if (typing) {
1239
1214
  if (event.key === 'Escape') {
1215
+ window.clearTimeout(findTimer);
1240
1216
  event.target.value = '';
1241
1217
  if (event.target.id === 'find') {
1242
1218
  state.query = '';
@@ -1244,7 +1220,6 @@
1244
1220
  paintList();
1245
1221
  paintDetail();
1246
1222
  markStrip();
1247
- showNow(state.selectedId);
1248
1223
  }
1249
1224
  if (event.target.id === 'log-find') {
1250
1225
  state.logQuery = '';
@@ -1254,7 +1229,8 @@
1254
1229
  return;
1255
1230
  }
1256
1231
  if (event.key >= '1' && event.key <= '5') {
1257
- showTab(TABS[Number(event.key) - 1]);
1232
+ var destination = TABS[Number(event.key) - 1];
1233
+ if (destination) showTab(destination);
1258
1234
  return;
1259
1235
  }
1260
1236
  if (event.key === '/') {
@@ -1264,19 +1240,19 @@
1264
1240
  if (find) find.focus();
1265
1241
  return;
1266
1242
  }
1267
- if ((event.key === 'ArrowRight' || event.key === 'ArrowLeft' || event.key === 'Home' || event.key === 'End') && event.target.closest && event.target.closest('.tabbar')) {
1243
+ if ((event.key === 'ArrowRight' || event.key === 'ArrowLeft' || event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') && event.target.closest && event.target.closest('.tabbar')) {
1268
1244
  event.preventDefault();
1269
1245
  var index = TABS.indexOf(state.tab);
1270
1246
  if (event.key === 'Home') index = 0;
1271
1247
  else if (event.key === 'End') index = TABS.length - 1;
1272
- else if (event.key === 'ArrowRight') index = (index + 1) % TABS.length;
1248
+ else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') index = (index + 1) % TABS.length;
1273
1249
  else index = (index - 1 + TABS.length) % TABS.length;
1274
1250
  showTab(TABS[index]);
1275
1251
  var next = document.querySelector('.tab[data-tab="' + TABS[index] + '"]');
1276
1252
  if (next) next.focus();
1277
1253
  return;
1278
1254
  }
1279
- if (state.tab !== 'tests' || (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')) return;
1255
+ if (state.tab !== 'tests' || !event.target.closest || !event.target.closest('#tests') || (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')) return;
1280
1256
  var rows = Array.prototype.slice.call(document.querySelectorAll('#tests .row'));
1281
1257
  if (!rows.length) return;
1282
1258
  event.preventDefault();
@@ -1291,21 +1267,15 @@
1291
1267
 
1292
1268
  function choose(id, resetFilter) {
1293
1269
  if (!id || !testById(id)) return;
1294
- if (resetFilter) {
1295
- state.filter = 'all';
1296
- state.query = '';
1297
- state.band = 'all';
1298
- state.tag = 'all';
1299
- state.projectFilter = 'all';
1300
- var find = document.getElementById('find');
1301
- if (find) find.value = '';
1302
- }
1270
+ if (resetFilter) resetFilters();
1303
1271
  if (state.selectedId !== id) {
1304
1272
  var test = testById(id);
1305
1273
  state.selectedId = id;
1306
1274
  state.attempt = failedAttempt(test);
1307
1275
  state.testPane = defaultPane(test);
1308
1276
  }
1277
+ var position = filteredTests().findIndex(function (test) { return test.id === id; });
1278
+ state.listLimit = Math.max(state.listLimit, position + 1);
1309
1279
  showTab('tests');
1310
1280
  if (built.tests) {
1311
1281
  syncPressed();
@@ -1316,37 +1286,46 @@
1316
1286
  markStrip();
1317
1287
  }
1318
1288
  paintDuration();
1319
- showNow(state.selectedId);
1320
1289
  var row = document.querySelector('#tests .row[data-id="' + cssEscape(id) + '"]');
1290
+ if (row) row.focus({ preventScroll: true });
1321
1291
  if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
1322
1292
  }
1323
1293
 
1294
+ function hasFilters() {
1295
+ return state.filter !== 'all' || state.query.trim() || state.band !== 'all' || state.tag !== null || state.projectFilter !== null;
1296
+ }
1297
+
1298
+ function resetFilters() {
1299
+ window.clearTimeout(findTimer);
1300
+ state.filter = 'all'; state.query = ''; state.band = 'all'; state.tag = null; state.projectFilter = null;
1301
+ state.listLimit = 200;
1302
+ var find = document.getElementById('find');
1303
+ if (find) find.value = '';
1304
+ }
1305
+
1324
1306
  function keepSelectionInFilter() {
1325
1307
  var tests = filteredTests();
1308
+ markStrip();
1326
1309
  if (tests.some(function (test) { return test.id === state.selectedId; })) return;
1327
1310
  var next = tests[0];
1328
1311
  if (!next) {
1329
1312
  state.selectedId = null;
1313
+ writeHash();
1330
1314
  return;
1331
1315
  }
1332
1316
  state.selectedId = next.id;
1333
1317
  state.attempt = failedAttempt(next);
1334
1318
  state.testPane = defaultPane(next);
1319
+ writeHash();
1335
1320
  }
1336
1321
 
1337
- function openTest(id, attempt) {
1322
+ function openTest(id, attempt, pane) {
1338
1323
  var test = testById(id);
1339
1324
  if (!test) return;
1340
- state.filter = 'all';
1341
- state.query = '';
1342
- state.band = 'all';
1343
- state.tag = 'all';
1344
- state.projectFilter = 'all';
1345
- var find = document.getElementById('find');
1346
- if (find) find.value = '';
1325
+ resetFilters();
1347
1326
  state.selectedId = id;
1348
1327
  state.attempt = attempt;
1349
- state.testPane = 'shots';
1328
+ state.testPane = pane === 'logs' ? 'logs' : 'shots';
1350
1329
  showTab('tests');
1351
1330
  if (built.tests) {
1352
1331
  paintTags();
@@ -1355,43 +1334,30 @@
1355
1334
  paintDetail();
1356
1335
  markStrip();
1357
1336
  }
1337
+ syncPressed();
1358
1338
  paintDuration();
1339
+ document.getElementById('detail').scrollIntoView({ block: 'nearest' });
1359
1340
  }
1360
1341
 
1361
1342
  function openShot(src, alt) {
1362
1343
  var safe = safeSrc(src);
1363
1344
  if (!safe) return;
1364
1345
  var close = el('button', { class: 'close-shot', type: 'button' }, ['Close']);
1365
- var dialog = el('dialog', null, [close, el('img', { src: safe, alt: alt })]);
1346
+ var returnFocus = document.activeElement;
1347
+ var dialog = el('dialog', { 'aria-label': alt || 'Screenshot' }, [close, el('img', { src: safe, alt: alt })]);
1366
1348
  close.addEventListener('click', function () { dialog.close(); });
1367
1349
  dialog.addEventListener('click', function (event) { if (event.target === dialog) dialog.close(); });
1368
- dialog.addEventListener('close', function () { dialog.remove(); });
1350
+ dialog.addEventListener('close', function () { dialog.remove(); if (returnFocus && returnFocus.isConnected) returnFocus.focus(); });
1369
1351
  document.body.appendChild(dialog);
1370
1352
  dialog.showModal();
1371
1353
  }
1372
1354
 
1373
- function showNow(id) {
1374
- var host = document.getElementById('now');
1375
- if (!host) return;
1376
- var test = id ? testById(id) : null;
1377
- if (!test) {
1378
- fill(host, [data.tests.length ? 'Hover a test, or click one.' : 'No tests ran.']);
1379
- return;
1380
- }
1381
- fill(host, [
1382
- el('span', { class: 'sw', 'data-status': test.status }, [statusLabel(test)]),
1383
- el('span', { class: 'name' }, [test.title]),
1384
- el('span', null, [formatDuration(test.duration)]),
1385
- el('span', { class: 'now-file' }, [test.file]),
1386
- ]);
1387
- }
1388
-
1389
1355
  function filteredTests() {
1390
1356
  var query = state.query.trim().toLowerCase();
1391
1357
  return data.tests.filter(function (test) {
1392
1358
  if (state.filter !== 'all' && test.status !== state.filter) return false;
1393
- if (state.tag !== 'all' && (test.tags || []).indexOf(state.tag) === -1) return false;
1394
- if (state.projectFilter !== 'all' && test.project !== state.projectFilter) return false;
1359
+ if (state.tag !== null && (test.tags || []).indexOf(state.tag) === -1) return false;
1360
+ if (state.projectFilter !== null && test.project !== state.projectFilter) return false;
1395
1361
  if (state.band !== 'all' && (test.status === 'skipped' || durationBand(test.duration) !== state.band)) return false;
1396
1362
  if (!query) return true;
1397
1363
  var blob = [test.title, test.file, test.project].concat(test.group || [], test.tags || []).join(' ').toLowerCase();
@@ -1405,7 +1371,7 @@
1405
1371
  }
1406
1372
 
1407
1373
  function testById(id) {
1408
- return data.tests.find(function (test) { return test.id === id; }) || null;
1374
+ return testIndex.get(id) || null;
1409
1375
  }
1410
1376
 
1411
1377
  function failedAttempt(test) {
@@ -1419,7 +1385,7 @@
1419
1385
 
1420
1386
  function defaultPane(test) {
1421
1387
  if (!test) return 'steps';
1422
- if (test.status === 'failed' || test.status === 'timedOut' || test.status === 'flaky') return 'error';
1388
+ if (isProblem(test)) return 'error';
1423
1389
  return 'steps';
1424
1390
  }
1425
1391