ortoni-report 2.0.9 → 3.0.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.
@@ -1,600 +1,1249 @@
1
- <!DOCTYPE html>
2
- <html lang="en" data-theme="{{preferredTheme}}">
3
- {{> head}}
4
- <style>
5
- {{{inlineCss}}}
6
- </style>
7
-
8
- <body>
9
- {{> navbar }}
10
- <section class="section mt-6">
11
- <main class="container">
12
- <div class="columns">
13
- <aside class="column is-two-fifths">
14
- {{> testPanel}}
15
- {{> project}}
16
- </aside>
17
- <section class="column is-three-fifths">
18
- <div id="summary">
19
- <div class="columns is-multiline has-text-centered">
20
- {{> summaryCard bg="primary" status="all" statusHeader="All Tests" statusCount=totalCount}}
21
- {{> summaryCard bg="success" status="passed" statusHeader="Passed" statusCount=passCount}}
22
- {{> summaryCard bg="danger" status="failed" statusHeader="Failed" statusCount=failCount}}
23
- {{> summaryCard bg="info" status="skipped" statusHeader="Skipped" statusCount=skipCount}}
24
- {{> summaryCard bg="warning" status="flaky" statusHeader="Flaky" statusCount=flakyCount}}
25
- {{> summaryCard bg="retry" status="retry" statusHeader="Retry" statusCount=retryCount}}
26
- </div>
27
- <div class="box">
28
- {{> userInfo}}
29
- </div>
30
- </div>
31
- <div id="testDetails" style="display: none;"></div>
32
- </section>
33
- </div>
34
- </main>
35
- </section>
36
- <script>
37
- document.addEventListener('DOMContentLoaded', () => {
38
- const testData = {{{ json results }}};
39
- const testHistory = {{{ json testHistories }}};
40
- let testHistoriesMap = {};
41
- let testHistoryTitle = '';
42
-
43
- const elements = {
44
- testDetails: document.getElementById('testDetails'),
45
- summary: document.getElementById('summary'),
46
- themeButton: document.getElementById("toggle-theme"),
47
- themeIcon: document.getElementById("theme-icon"),
48
- htmlElement: document.documentElement,
49
- searchInput: document.querySelector('input[name="search"]'),
50
- detailsElements: document.querySelectorAll('details'),
51
- filtersDisplay: document.getElementById('selected-filters'),
52
- };
53
-
54
- const themeManager = {
55
- init() {
56
- const preferredTheme = elements.themeButton.getAttribute("data-theme-status");
57
- this.setTheme(preferredTheme);
58
- elements.themeButton.addEventListener('click', () => this.toggleTheme());
59
- },
60
- setTheme(theme) {
61
- elements.htmlElement.setAttribute('data-theme', theme);
62
- elements.themeIcon.className = `fa fa-${theme === 'dark' ? 'moon' : 'sun'}`;
63
- },
64
- toggleTheme() {
65
- const currentTheme = elements.htmlElement.getAttribute('data-theme');
66
- this.setTheme(currentTheme === 'light' ? 'dark' : 'light');
67
- }
68
- };
69
-
70
- const testDetailsManager = {
71
- show(test) {
72
- elements.summary.style.display = 'none';
73
- elements.testDetails.style.opacity = '0';
74
- elements.testDetails.style.display = 'block';
75
- setTimeout(() => {
76
- elements.testDetails.style.opacity = '1';
77
- }, 50);
78
- this.render(test);
79
- },
80
- hide() {
81
- elements.summary.style.display = 'block';
82
- elements.testDetails.style.display = 'none';
83
- },
84
- render(test) {
85
- let currentScreenshotIndex = 0;
86
- const statusClass = this.getStatusClass(test.status);
87
- const statusIcon = this.getStatusIcon(test.status);
88
- const projectIcon = this.getProjectIcon(test.projectName);
89
-
90
- elements.testDetails.innerHTML = `
91
- <div class="sticky-header">
92
- <div class="card mb-3">
93
- <button class="button is-primary mb-3" id="back-to-summary" onclick="showSummary()">
94
- <span class="icon"><i class="fa fa-chevron-left"></i></span>
95
- <span>Back to Summary</span>
96
- </button>
97
- <div class="card-content">
98
- <div class="content has-text-centered">
99
- <h1 class="title is-2">${test.title}</h1>
100
- <p class="subtitle is-5" id="filepath">${test.location}</p>
101
- </div>
102
- </div>
103
- <footer class="card-footer">
104
- <div class="card-footer-item">
105
- <div class="columns is-mobile">
106
- <div class="column is-half">
107
- <div class="is-flex is-align-items-center">
108
- <span class="icon status-icon has-text-${statusClass}">
109
- <i class="fa fa-${statusIcon}"></i>
110
- </span>
111
- <span class="has-text-weight-bold is-uppercase has-text-${statusClass}">${test.status}</span>
112
- </div>
113
- </div>
114
- </div>
115
- </div>
116
- ${test.duration ? `
117
- <div class="card-footer-item">
118
- <div class="column is-half">
119
- <div class="is-flex is-align-items-center">
120
- <span class="icon status-icon has-text-info">
121
- <i class="fa fa-clock"></i>
122
- </span>
123
- <span class="has-text-info has-text-weight-semibold">${test.duration}</span>
124
- </div>
125
- </div>
126
- </div>
127
- ` : ''}
128
- ${test.projectName ? `
129
- <div class="card-footer-item">
130
- <div class="is-flex is-align-items-center">
131
- <span class="icon status-icon has-text-link">
132
- ${projectIcon}
133
- </span>
134
- <span> ${test.projectName}</span>
135
- </div>
136
- </div>
137
- ` : ''}
138
- </footer>
139
- </div>
140
- </div>
141
- <div class="content-wrapper">
142
- ${this.renderTestContent(test)}
143
- </div>
144
- `;
145
- this.attachScreenshotListeners(test);
146
- this.attachSteps(test);
147
- },
148
- getStatusClass(status) {
149
- if (status.startsWith('passed')) return 'success';
150
- if (status === 'flaky') return 'warning';
151
- if (status === 'failed') return 'danger';
152
- return 'info';
153
- },
154
- getStatusIcon(status) {
155
- if (status.startsWith('passed')) return 'check-circle';
156
- if (status === 'flaky') return 'exclamation-triangle';
157
- if (status === 'failed') return 'times-circle';
158
- return 'question-circle';
159
- },
160
- getProjectIcon(project) {
161
- if (project === 'webkit') return `<i class="fa-brands fa-safari"></i>`;
162
- if (project === 'firefox') return `<i class="fa-brands fa-firefox"></i>`;
163
- return `<i class="fa-brands fa-chrome"></i>`;
164
- },
165
- renderTestContent(test) {
166
- let content = '';
167
- if (test.status !== "skipped") {
168
- content += this.renderScreenshotsAndVideo(test);
169
- }
170
- content += this.renderAdditionalInfo(test);
171
- content += this.renderTabs(test);
172
- return content;
173
- },
174
- renderScreenshotsAndVideo(test) {
175
- let content = '<div class="card mb-5"><div class="card-content"><div class="columns is-multiline">';
176
- if (test.screenshots && test.screenshots.length > 0) {
177
- content += `
178
- <div class="column is-half">
179
- <div id="testImage" class="modal">
180
- <div class="modal-background"></div>
181
- <div class="modal-content">
182
- <p class="image">
183
- <img id="screenshot-modal-img" src="${test.screenshots[0]}" alt="Screenshot">
184
- </p>
185
- </div>
186
- <button onclick="closeModal()" class="modal-close is-large" aria-label="close"></button>
187
- </div>
188
- <figure class="image">
189
- <img id="screenshot-main-img" onclick="openModal()" src="${test.screenshots[0]}" alt="Screenshot">
190
- </figure>
191
- <nav class="mt-4 pagination is-small is-centered ${test.screenshots.length > 1 ? '' : 'is-hidden'}" role="navigation" aria-label="pagination">
192
- <a class="pagination-previous">Previous</a>
193
- <a class="pagination-next">Next</a>
194
- <ul class="pagination-list">
195
- ${test.screenshots.map((_, index) => `
196
- <li>
197
- <a class="pagination-link ${index === 0 ? 'is-current' : ''}" aria-label="Goto screenshot ${index + 1}">${index + 1}</a>
198
- </li>`).join('')}
199
- </ul>
200
- </nav>
201
- </div>
202
- `;
203
- }
204
- if (test.videoPath) {
205
- content += `
206
- <div class="column is-half">
207
- <div class="video-preview">
208
- <video controls width="100%" height="auto" preload="metadata">
209
- <source src="${test.videoPath}" type="video/webm">
210
- Your browser does not support the video tag.
211
- </video>
212
- </div>
213
- </div>
214
- `;
215
- }
216
- content += '</div>';
217
- content += `
218
- <div class="columns">
219
- <div class="column">
220
- <button
221
- onclick="openHistory()"
222
- class="button is-primary is-fullwidth mt-3">
223
- <span class="icon"><i class="fa-solid fa-timeline"></i></span>
224
- <span class="has-text-white pl-2">Open history</span>
225
- </button>
226
- <div id="historyModal" class="modal">
227
- <div class="modal-background"></div>
228
- </div>
229
- </div>
230
- ${test.tracePath ? `
231
- <div class="column">
232
- <button
233
- data-trace="${test.tracePath}"
234
- onclick="openTraceViewer(this)"
235
- class="button is-primary is-fullwidth mt-3">
236
- <span class="icon"><i class="fa-solid fa-tv"></i></span>
237
- <span class="has-text-white pl-2">View Trace</span>
238
- </button>
239
- </div>
240
- ` : ''}
241
- </div>
242
- `;
243
-
244
- content += '</div></div>';
245
- return content;
246
- },
247
- renderAdditionalInfo(test) {
248
- if (!(test.annotations.length || test.testTags.length > 0)) return '';
249
- return `
250
- <div class="card mb-5">
251
- <header class="card-header">
252
- <p class="card-header-title">Additional Information</p>
253
- </header>
254
- <div class="card-content">
255
- <div class="content">
256
- ${test.testTags.length > 0 ? `
257
- <div class="control mb-4">
258
- <div class="tags is-rounded">
259
- ${test.testTags.map(tag => `<span class="tag is-primary is-medium">${tag}</span>`).join('')}
260
- </div>
261
- </div>` : ""}
262
- ${test.annotations
263
- .filter(annotation => annotation !== null && annotation !== undefined)
264
- .map(annotation => `
265
- <div class="mb-4">
266
- ${annotation?.type ? `<strong class="has-text-link">Type: </strong><span>${annotation.type}</span>` : ''}
267
- <br>
268
- ${annotation?.description ? `<strong class="has-text-link">Description: </strong><span>${annotation.description}</span>` : ''}
269
- </div>
270
- `).join('')}
271
- </div>
272
- </div>
273
- </div>
274
- `;
275
- },
276
-
277
- renderTabs(test) {
278
- return `
279
- <div class="card mt-5">
280
- <div class="card-content">
281
- <div class="tabs is-boxed is-fullwidth">
282
- <ul>
283
- <li class="is-active"><a data-tab="steps">Steps</a></li>
284
- <li><a data-tab="errors">Errors</a></li>
285
- <li><a data-tab="logs">Logs</a></li>
286
- </ul>
287
- </div>
288
- <div id="tabContent">
289
- <div id="stepsTab" class="tab-content">
290
- ${this.renderSteps(test)}
291
- </div>
292
- <div id="errorsTab" class="tab-content" style="display: none;">
293
- ${this.renderErrors(test)}
294
- </div>
295
- <div id="logsTab" class="tab-content" style="display: none;">
296
- ${this.renderLogs(test)}
297
- </div>
298
- </div>
299
- </div>
300
- </div>
301
- `;
302
- },
303
-
304
- renderSteps(test) {
305
- if (test.steps.length === 0) return '<p>No steps available.</p>';
306
- return `
307
- <div class="content">
308
- <span id="stepDetails" class="content"></span>
309
- </div>
310
- `;
311
- },
312
-
313
- renderErrors(test) {
314
- if (!test.errors.length) return '<p>No errors reported.</p>';
315
- return `
316
- <div class="content">
317
- <pre><code class="data-lang=js">${test.errors.join('\n')}</code></pre>
318
- </div>
319
- `;
320
- },
321
-
322
- renderLogs(test) {
323
- if (!test.logs) return '<p>No logs available.</p>';
324
- return `
325
- <div class="content">
326
- <pre>${test.logs}</pre>
327
- </div>
328
- `;
329
- },
330
- attachScreenshotListeners(test) {
331
- if (test.screenshots && test.screenshots.length > 0) {
332
- let currentScreenshotIndex = 0;
333
- const changeScreenshot = (direction) => {
334
- currentScreenshotIndex = (currentScreenshotIndex + direction + test.screenshots.length) % test.screenshots.length;
335
- this.updateScreenshot(test.screenshots, currentScreenshotIndex);
336
- };
337
- const gotoScreenshot = (index) => {
338
- currentScreenshotIndex = index;
339
- this.updateScreenshot(test.screenshots, currentScreenshotIndex);
340
- };
341
- document.querySelector('.pagination-previous').addEventListener('click', () => changeScreenshot(-1));
342
- document.querySelector('.pagination-next').addEventListener('click', () => changeScreenshot(1));
343
- document.querySelectorAll('.pagination-link').forEach((link, index) => {
344
- link.addEventListener('click', () => gotoScreenshot(index));
345
- });
346
- }
347
- },
348
- updateScreenshot(screenshots, index) {
349
- document.getElementById('screenshot-main-img').src = screenshots[index];
350
- document.getElementById('screenshot-modal-img').src = screenshots[index];
351
- document.querySelectorAll('.pagination-link').forEach((link, i) => {
352
- link.classList.toggle('is-current', i === index);
353
- });
354
- },
355
- attachSteps(test) {
356
- const stepDetailsDiv = document.getElementById('stepDetails');
357
- if (stepDetailsDiv) {
358
- const stepsList = document.createElement("ul");
359
- stepsList.setAttribute("id", "steps");
360
- test.steps.forEach(step => {
361
- const li = document.createElement('li');
362
- li.innerHTML = `<strong class="${step.snippet ? 'has-text-danger' : ''}">${step.title}</strong>`;
363
- if (step.snippet) {
364
- const pre = document.createElement('pre');
365
- const code = document.createElement('code');
366
- const locationText = step.location ? `\n\nat: ${step.location}` : '';
367
- code.innerHTML = `${step.snippet}${locationText}`;
368
- code.setAttribute('data-lang', 'js');
369
- pre.appendChild(code);
370
- li.appendChild(pre);
371
- }
372
- stepsList.appendChild(li);
373
- });
374
- stepDetailsDiv.appendChild(stepsList);
375
- }
376
- },
377
- attachEventListeners() {
378
- const testItems = document.querySelectorAll('[data-test-id]');
379
- testItems.forEach(item => {
380
- item.addEventListener('click', () => {
381
- testItems.forEach(i => i.classList.remove('listselected'));
382
- item.classList.add('listselected');
383
- const testId = item.getAttribute('data-test-id');
384
- const testHistoryId = item.getAttribute('data-test-history-id');
385
- const test = testData[testId];
386
- const historyEntry = testHistory.find(entry => entry.testId === testHistoryId);
387
- testHistoriesMap = historyEntry ? historyEntry.history : null;
388
- testHistoryTitle = historyEntry.testId ? historyEntry.testId.split(":")[2] : '';
389
- this.show(test);
390
- });
391
- });
392
- document.addEventListener('click', (e) => {
393
- const tabLink = e.target.closest('div.tabs a');
394
- if (tabLink) {
395
- e.preventDefault();
396
- const tabId = e.target.getAttribute('data-tab');
397
- const tabLinks = document.querySelectorAll('div.tabs a');
398
- const tabContents = document.querySelectorAll('div.tab-content');
399
-
400
- tabLinks.forEach(l => l.parentElement.classList.remove('is-active'));
401
- tabContents.forEach(c => c.style.display = 'none');
402
-
403
- e.target.parentElement.classList.add('is-active');
404
- document.getElementById(`${tabId}Tab`).style.display = 'block';
405
- }
406
- });
407
- }
408
- };
409
-
410
- const filterManager = {
411
- init() {
412
- this.attachEventListeners();
413
- },
414
- attachEventListeners() {
415
- const checkboxes = document.querySelectorAll('#select-filter input[type="checkbox"]');
416
- checkboxes.forEach(checkbox => {
417
- checkbox.addEventListener('change', () => this.applyFilters());
418
- });
419
-
420
- const filters = document.querySelectorAll('.filter');
421
- filters.forEach(filter => {
422
- filter.addEventListener('click', () => {
423
- filters.forEach(f => f.classList.remove('active'));
424
- filter.classList.add('active');
425
- this.applyFilters();
426
- });
427
- });
428
- },
429
- applyFilters() {
430
- const selectedProjects = this.getSelectedValues('project');
431
- const selectedTags = this.getSelectedValues('test-tags');
432
- const selectedStatus = document.querySelector('.filter.active')?.getAttribute('data-status') || 'all';
433
-
434
- elements.detailsElements.forEach(details => {
435
- const items = details.querySelectorAll('div[data-test-id]');
436
- let shouldShowDetails = false;
437
-
438
- items.forEach(item => {
439
- const isVisible = this.shouldShowItem(item, selectedProjects, selectedTags, selectedStatus);
440
- item.classList.toggle('is-hidden', !isVisible);
441
- shouldShowDetails = shouldShowDetails || isVisible;
442
- });
443
-
444
- details.open = shouldShowDetails;
445
- details.classList.toggle('is-hidden', !shouldShowDetails);
446
- });
447
-
448
- this.updateSelectedFiltersDisplay(selectedProjects, selectedTags, selectedStatus);
449
- },
450
- getSelectedValues(type) {
451
- return Array.from(document.querySelectorAll(`#select-filter input[type="checkbox"][data-filter-type="${type}"]:checked`))
452
- .map(checkbox => checkbox.value.trim());
453
- },
454
- shouldShowItem(item, projects, tags, status) {
455
- const testTags = item.getAttribute('data-test-tags').trim().split(' ').filter(Boolean);
456
- const projectName = item.getAttribute('data-project-name').trim();
457
- const testStatus = item.getAttribute('data-test-status').trim();
458
-
459
- const matchesProject = projects.length === 0 || projects.includes(projectName);
460
- const matchesTags = tags.length === 0 || tags.every(tag => testTags.includes(tag));
461
- const matchesStatus = this.matchesStatus(testStatus, status);
462
-
463
- return matchesProject && matchesTags && matchesStatus;
464
- },
465
- matchesStatus(testStatus, selectedStatus) {
466
- if (selectedStatus === 'all') return testStatus !== 'skipped';
467
- if (selectedStatus === 'failed') return testStatus === 'failed' || testStatus === 'timedOut';
468
- if (selectedStatus === 'retry') return testStatus.includes('retry');
469
- if (selectedStatus === 'flaky') return testStatus.includes('flaky');
470
- return testStatus === selectedStatus;
471
- },
472
- updateSelectedFiltersDisplay(projects, tags, status) {
473
- let displayText = [];
474
- if (projects.length > 0) displayText.push(`Projects: ${projects.join(', ')}`);
475
- if (tags.length > 0) displayText.push(`Tags: ${tags.join(', ')}`);
476
- if (status !== 'all') displayText.push(`Status: ${status}`);
477
- elements.filtersDisplay.innerHTML = displayText.length > 0 ? displayText.join(' | ') : 'All Tests';
478
- }
479
- };
480
-
481
- const searchManager = {
482
- init() {
483
- elements.searchInput.addEventListener('input', this.debounce(this.filterTests, 300));
484
- },
485
- filterTests(event) {
486
- const searchTerm = event.target.value.toLowerCase();
487
- const testItems = document.querySelectorAll('[data-test-id]');
488
-
489
- elements.detailsElements.forEach(detail => detail.open = !!searchTerm);
490
-
491
- testItems.forEach(item => {
492
- const isVisible = item.textContent.toLowerCase().includes(searchTerm);
493
- item.style.display = isVisible ? 'flex' : 'none';
494
- if (isVisible && searchTerm) searchManager.openParentDetails(item);
495
- });
496
- },
497
- openParentDetails(item) {
498
- let parent = item.parentElement;
499
- while (parent && parent.tagName !== 'ASIDE') {
500
- if (parent.tagName === 'DETAILS') parent.open = true;
501
- parent = parent.parentElement;
502
- }
503
- },
504
- debounce(func, wait) {
505
- let timeout;
506
- return function (...args) {
507
- clearTimeout(timeout);
508
- timeout = setTimeout(() => func.apply(this, args), wait);
509
- };
510
- }
511
- };
512
-
513
- // Initialize all managers
514
- themeManager.init();
515
- testDetailsManager.attachEventListeners();
516
- filterManager.init();
517
- searchManager.init();
518
-
519
- // Expose necessary functions to the global scope
520
- window.showSummary = testDetailsManager.hide;
521
- window.openModal = () => document.querySelector("#testImage").classList.add("is-active");
522
- window.closeModal = () => document.querySelector("#testImage").classList.remove("is-active");
523
- window.closeErrorModal = (modalId) => document.getElementById(modalId).classList.remove("is-active");
524
- window.openTraceViewer = (button) => {
525
- const tracePath = button.getAttribute("data-trace");
526
- if (tracePath) {
527
- const normalizedTracePath = tracePath.replace(/\\/g, '/');
528
- const baseUrl = getAdjustedBaseUrl();
529
- window.open(`${baseUrl}/trace/index.html?trace=${baseUrl}/${normalizedTracePath}`, "_blank");
530
- }
531
- };
532
- window.getAdjustedBaseUrl = () => {
533
- const origin = window.location.origin;
534
- const pathname = window.location.pathname;
535
- if (pathname.endsWith('.html')) {
536
- const directoryPath = pathname.substring(0, pathname.lastIndexOf('/') + 1);
537
- return `${origin}${directoryPath}`;
538
- }
539
- return origin;
540
- }
541
- window.openHistory = () => {
542
- const historyElement = document.getElementById("historyModal");
543
- historyElement.classList.add('is-active');
544
-
545
- let historyContent = '';
546
- if (testHistoriesMap && testHistoriesMap.length > 0) {
547
- historyContent = testHistoriesMap.map((h, index) => `
548
- <tr>
549
- <td>${h.run_date}</td>
550
- <td>${h.status}</td>
551
- <td>${h.duration}</td>
552
- ${h.error_message ? `<td><div class="modal" id="${index}">
553
- <div class="modal-background"></div>
554
- <div class="modal-content">
555
- <pre><code>${h.error_message}</code></pre>
556
- </div>
557
- <button class="button is-primary" onclick="closeErrorModal(${index})">Close</button>
558
- </div><a class="button is-link" onclick="showHistoryErrorMessage(${index})">Show error</a></td>` : '<td>No Error</td>'}
559
- </tr>
560
- `).join('');
561
- } else {
562
- historyContent = '<p class="title">No history available</p>';
563
- }
564
-
565
- historyElement.innerHTML = `
566
- <div class="modal-background"></div>
567
- <div class="modal-card">
568
- <header class="modal-card-head">
569
- <p class="modal-card-title">${testHistoryTitle}</p>
570
- <button class="button is-primary" onclick="closeHistoryModal()">Close</button>
571
- </header>
572
- <section class="modal-card-body">
573
- <table class="table is-hoverable is-fullwidth">
574
- <thead>
575
- <tr>
576
- <th title="Run Date">Run Date</th>
577
- <th title="Status">Status</th>
578
- <th title="Duration">Duration</th>
579
- <th title="Reason">Reason</th>
580
- </tr>
581
- </thead>
582
- <tbody>
583
- ${historyContent}
584
- </tbody>
585
- </table>
586
- </section>
587
- </div>
588
- `;
589
- };
590
- window.closeHistoryModal = () => {
591
- document.getElementById("historyModal").classList.remove('is-active');
592
- };
593
- window.showHistoryErrorMessage = (modalId) => {
594
- document.getElementById(modalId)?.classList.add('is-active');
595
- };
596
- });
597
- </script>
598
- </body>
599
-
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="{{preferredTheme}}">
3
+ {{> head}}
4
+ <style>
5
+ {{{inlineCss}}}
6
+
7
+ </style>
8
+
9
+ <body>
10
+ <div class="app-container">
11
+ {{> sidebar}}
12
+ <main class="main-content">
13
+ <div id="dashboard-section" class="content-section">
14
+ {{#if logo}}
15
+ <img src="{{logo}}" alt="{{projectName}}" class="logoimage" />
16
+ {{else}}
17
+ <h1 class="title is-3">Dashboard</h1>
18
+ {{/if}}
19
+ <div class="columns is-multiline has-text-centered">
20
+ {{> summaryCard bg="hsl(var(--bulma-primary-h), var(--bulma-primary-s), var(--bulma-primary-l)) !important" status="all" statusHeader="All Tests" statusCount=totalCount}}
21
+ {{> summaryCard bg="hsl(var(--bulma-success-h), var(--bulma-success-s), var(--bulma-success-l)) !important" status="passed" statusHeader="Passed" statusCount=passCount}}
22
+ {{> summaryCard bg="hsl(var(--bulma-danger-h), var(--bulma-danger-s), var(--bulma-danger-l)) !important" status="failed" statusHeader="Failed" statusCount=failCount}}
23
+ {{> summaryCard bg="hsl(var(--bulma-info-h), var(--bulma-info-s), var(--bulma-info-l)) !important" status="skipped" statusHeader="Skipped" statusCount=skipCount}}
24
+ {{> summaryCard bg="hsl(var(--bulma-warning-h), var(--bulma-warning-s), var(--bulma-warning-l)) !important" status="flaky" statusHeader="Flaky" statusCount=flakyCount}}
25
+ {{> summaryCard bg="#69748c" status="retry" statusHeader="Retry" statusCount=retryCount}}
26
+ </div>
27
+ {{> userInfo}}
28
+ </div>
29
+ <div id="tests-section" class="content-section" style="display: none;">
30
+ <div class="test-list">
31
+ <div class="columns">
32
+ <div class="column column is-two-fifths">
33
+ <h1 class="title is-3">Tests</h1>
34
+ <div class="columns is-multiline has-text-centered">
35
+ {{> summaryCard icon="fa-solid fa-vial" filter="filter" bg="hsl(var(--bulma-primary-h), var(--bulma-primary-s), var(--bulma-primary-l)) !important" status="all" statusHeader="All Tests" statusCount=totalCount}}
36
+ {{> summaryCard icon="fa fa-check-circle" filter="filter" bg="hsl(var(--bulma-success-h), var(--bulma-success-s), var(--bulma-success-l)) !important" status="passed" statusHeader="Passed" statusCount=passCount}}
37
+ {{> summaryCard icon="fa fa-times-circle" filter="filter" bg="hsl(var(--bulma-danger-h), var(--bulma-danger-s), var(--bulma-danger-l)) !important" status="failed" statusHeader="Failed" statusCount=failCount}}
38
+ {{> summaryCard icon="fa fa-question-circle" filter="filter" bg="hsl(var(--bulma-info-h), var(--bulma-info-s), var(--bulma-info-l)) !important" status="skipped" statusHeader="Skipped" statusCount=skipCount}}
39
+ {{> summaryCard icon="fa fa-exclamation-triangle" filter="filter" bg="hsl(var(--bulma-warning-h), var(--bulma-warning-s), var(--bulma-warning-l)) !important" status="flaky" statusHeader="Flaky" statusCount=flakyCount}}
40
+ {{> summaryCard icon="fa-solid fa-repeat" filter="filter" bg="#69748c" status="retry" statusHeader="Retry" statusCount=retryCount}}
41
+ </div>
42
+ {{> testPanel}}
43
+ {{> project}}
44
+ </div>
45
+ <div class="column column is-three-fifths">
46
+ <div id="testDetails" style="display: none;"></div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </main>
52
+ </div>
53
+ <script>
54
+ document.addEventListener('DOMContentLoaded', () => {
55
+ /**
56
+ * ====================================
57
+ * UTILITY FUNCTIONS
58
+ * ====================================
59
+ */
60
+ const Utils = {
61
+ /**
62
+ * Debounce function to limit how often a function can be called
63
+ * @param {Function} func - Function to debounce
64
+ * @param {number} wait - Wait time in milliseconds
65
+ * @returns {Function} Debounced function
66
+ */
67
+ debounce(func, wait) {
68
+ let timeout;
69
+ return function (...args) {
70
+ clearTimeout(timeout);
71
+ timeout = setTimeout(() => func.apply(this, args), wait);
72
+ };
73
+ },
74
+
75
+ /**
76
+ * Get status class based on test status
77
+ * @param {string} status - Test status
78
+ * @returns {string} CSS class
79
+ */
80
+ getStatusClass(status) {
81
+ if (status.startsWith('passed')) return 'success';
82
+ if (status === 'flaky') return 'warning';
83
+ if (status === 'failed') return 'danger';
84
+ return 'info';
85
+ },
86
+
87
+ /**
88
+ * Get status icon based on test status
89
+ * @param {string} status - Test status
90
+ * @returns {string} Icon class
91
+ */
92
+ getStatusIcon(status) {
93
+ if (status.startsWith('passed')) return 'check-circle';
94
+ if (status === 'flaky') return 'exclamation-triangle';
95
+ if (status === 'failed') return 'times-circle';
96
+ return 'question-circle';
97
+ },
98
+
99
+ /**
100
+ * Get project icon based on browser
101
+ * @param {string} project - Project name
102
+ * @returns {string} Icon HTML
103
+ */
104
+ getProjectIcon(project) {
105
+ if (project === 'webkit') return `<i class="fa-brands fa-safari"></i>`;
106
+ if (project === 'firefox') return `<i class="fa-brands fa-firefox"></i>`;
107
+ return `<i class="fa-brands fa-chrome"></i>`;
108
+ },
109
+
110
+ /**
111
+ * Get adjusted base URL for trace viewer
112
+ * @returns {string} Adjusted base URL
113
+ */
114
+ getAdjustedBaseUrl() {
115
+ const origin = window.location.origin;
116
+ const pathname = window.location.pathname;
117
+ if (pathname.endsWith('.html')) {
118
+ const directoryPath = pathname.substring(0, pathname.lastIndexOf('/') + 1);
119
+ return `${origin}${directoryPath}`;
120
+ }
121
+ return origin;
122
+ }
123
+ };
124
+
125
+ /**
126
+ * ====================================
127
+ * SIDEBAR MANAGER
128
+ * ====================================
129
+ */
130
+ class SidebarManager {
131
+ /**
132
+ * Initialize the sidebar manager
133
+ */
134
+ constructor() {
135
+ this.sidebarLinks = document.querySelectorAll('.sidebar-menu-link');
136
+ this.sections = document.querySelectorAll('.content-section');
137
+ this.testDetailsSection = document.getElementById('testDetails');
138
+ this.sidebar = document.getElementById('sidebar');
139
+ this.mainContent = document.querySelector('.main-content');
140
+ this.init();
141
+ }
142
+
143
+ /**
144
+ * Initialize sidebar manager
145
+ */
146
+ init() {
147
+ this.attachEventListeners();
148
+ }
149
+
150
+ /**
151
+ * Attach event listeners
152
+ */
153
+ attachEventListeners() {
154
+ this.sidebarLinks.forEach(link => {
155
+ link.addEventListener('click', (e) => {
156
+ e.preventDefault();
157
+ this.setActiveLink(link);
158
+ this.showSection(link.getAttribute('data-section'));
159
+ });
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Set active link
165
+ * @param {HTMLElement} activeLink - Active link
166
+ */
167
+ setActiveLink(activeLink) {
168
+ this.sidebarLinks.forEach(link => {
169
+ link.classList.remove('active');
170
+ });
171
+ activeLink.classList.add('active');
172
+ }
173
+
174
+ /**
175
+ * Show section
176
+ * @param {string} sectionId - Section ID
177
+ */
178
+ showSection(sectionId) {
179
+ // Hide test details if showing a main section
180
+ this.testDetailsSection.style.display = 'none';
181
+
182
+ // Show the selected section
183
+ this.sections.forEach(section => {
184
+ section.style.display = 'none';
185
+ });
186
+ document.getElementById(`${sectionId}-section`).style.display = 'block';
187
+ }
188
+
189
+ /**
190
+ * Show test details
191
+ */
192
+ showTestDetails() {
193
+ /*
194
+ this.sections.forEach(section => {
195
+ section.style.display = 'none';
196
+ });
197
+ */
198
+ this.testDetailsSection.style.display = 'block';
199
+ }
200
+ }
201
+
202
+ /* Add this new class to the JavaScript section, after the SidebarManager class */
203
+ /**
204
+ * ====================================
205
+ * SIDEBAR COLLAPSE MANAGER
206
+ * ====================================
207
+ */
208
+ class SidebarCollapseManager {
209
+ /**
210
+ * Initialize the sidebar collapse manager
211
+ */
212
+ constructor() {
213
+ this.sidebar = document.getElementById('sidebar');
214
+ this.mainContent = document.querySelector('.main-content');
215
+ this.toggleButton = document.getElementById('sidebar-toggle');
216
+ this.toggleIcon = document.getElementById('sidebar-toggle-icon');
217
+ this.isInitialLoad = true;
218
+ this.init();
219
+ }
220
+
221
+ /**
222
+ * Initialize sidebar collapse manager
223
+ */
224
+ init() {
225
+ // Only add the click event listener once
226
+ this.toggleButton.addEventListener('click', (e) => {
227
+ e.preventDefault();
228
+ e.stopPropagation();
229
+ this.toggleSidebar();
230
+ });
231
+
232
+ // Check for saved state, but only apply it on initial load
233
+ if (this.isInitialLoad) {
234
+ const sidebarCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
235
+ if (sidebarCollapsed) {
236
+ this.collapseSidebar(false);
237
+ }
238
+ this.isInitialLoad = false;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Toggle sidebar collapsed state
244
+ */
245
+ toggleSidebar() {
246
+ if (this.sidebar.classList.contains('collapsed')) {
247
+ this.expandSidebar(true);
248
+ } else {
249
+ this.collapseSidebar(true);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Collapse sidebar
255
+ * @param {boolean} saveState - Whether to save state to localStorage
256
+ */
257
+ collapseSidebar(saveState = true) {
258
+ this.sidebar.classList.add('collapsed');
259
+ this.mainContent.classList.add('expanded');
260
+ this.toggleIcon.classList.remove('fa-chevron-left');
261
+ this.toggleIcon.classList.add('fa-chevron-right');
262
+ if (saveState) {
263
+ localStorage.setItem('sidebarCollapsed', 'true');
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Expand sidebar
269
+ * @param {boolean} saveState - Whether to save state to localStorage
270
+ */
271
+ expandSidebar(saveState = true) {
272
+ this.sidebar.classList.remove('collapsed');
273
+ this.mainContent.classList.remove('expanded');
274
+ this.toggleIcon.classList.remove('fa-chevron-right');
275
+ this.toggleIcon.classList.add('fa-chevron-left');
276
+ if (saveState) {
277
+ localStorage.setItem('sidebarCollapsed', 'false');
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * ====================================
284
+ * THEME MANAGER
285
+ * ====================================
286
+ */
287
+ class ThemeManager {
288
+ /**
289
+ * Initialize the theme manager
290
+ * @param {Object} elements - DOM elements
291
+ */
292
+ constructor(elements) {
293
+ this.elements = elements;
294
+ this.init();
295
+ }
296
+
297
+ /**
298
+ * Initialize theme manager
299
+ */
300
+ init() {
301
+ const preferredTheme = this.elements.themeButton.getAttribute("data-theme-status");
302
+ this.setTheme(preferredTheme);
303
+ this.elements.themeButton.addEventListener('click', () => this.toggleTheme());
304
+ }
305
+
306
+ /**
307
+ * Set theme (light or dark)
308
+ * @param {string} theme - Theme name
309
+ */
310
+ setTheme(theme) {
311
+ this.elements.htmlElement.setAttribute('data-theme', theme);
312
+ this.elements.themeIcon.className = `fa fa-${theme === 'dark' ? 'moon' : 'sun'}`;
313
+ }
314
+
315
+ /**
316
+ * Toggle between light and dark theme
317
+ */
318
+ toggleTheme() {
319
+ const currentTheme = this.elements.htmlElement.getAttribute('data-theme');
320
+ this.setTheme(currentTheme === 'light' ? 'dark' : 'light');
321
+ }
322
+ }
323
+
324
+ /**
325
+ * ====================================
326
+ * TEST DETAILS MANAGER
327
+ * ====================================
328
+ */
329
+ class TestDetailsManager {
330
+ /**
331
+ * Initialize the test details manager
332
+ * @param {Object} elements - DOM elements
333
+ * @param {Object} testData - Test data
334
+ * @param {Array} testHistory - Test history data
335
+ * @param {SidebarManager} sidebarManager - Sidebar manager
336
+ */
337
+ constructor(elements, testData, testHistory, sidebarManager) {
338
+ this.elements = elements;
339
+ this.testData = testData;
340
+ this.testHistory = testHistory;
341
+ this.sidebarManager = sidebarManager;
342
+ this.testHistoriesMap = null;
343
+ this.testHistoryTitle = '';
344
+ this.currentScreenshotIndex = 0;
345
+ }
346
+
347
+ /**
348
+ * Show test details
349
+ * @param {Object} test - Test data
350
+ */
351
+ show(test) {
352
+ this.sidebarManager.showTestDetails();
353
+ this.elements.testDetails.style.opacity = '0';
354
+ setTimeout(() => {
355
+ this.elements.testDetails.style.opacity = '1';
356
+ }, 50);
357
+ this.render(test);
358
+ }
359
+
360
+ /**
361
+ * Hide test details and show dashboard
362
+ */
363
+ hide() {
364
+ // Find the dashboard link and click it
365
+ document.querySelector('.sidebar-menu-link[data-section="dashboard"]').click();
366
+ }
367
+
368
+ /**
369
+ * Render test details
370
+ * @param {Object} test - Test data
371
+ */
372
+ render(test) {
373
+ this.currentScreenshotIndex = 0;
374
+ const statusClass = Utils.getStatusClass(test.status);
375
+ const statusIcon = Utils.getStatusIcon(test.status);
376
+ const projectIcon = Utils.getProjectIcon(test.projectName);
377
+
378
+ this.elements.testDetails.innerHTML = `
379
+ <div class="sticky-header">
380
+ <div class="card mb-3">
381
+ <div class="card-content">
382
+ <div class="content has-text-centered">
383
+ <h1 class="title is-4">${test.title}</h1>
384
+ <p class="subtitle is-5" id="filepath">${test.location}</p>
385
+ </div>
386
+ </div>
387
+ <footer class="card-footer">
388
+ <div class="card-footer-item">
389
+ <div class="columns is-mobile">
390
+ <div class="column is-half">
391
+ <div class="is-flex is-align-items-center">
392
+ <span class="icon status-icon has-text-${statusClass}">
393
+ <i class="fa fa-${statusIcon}"></i>
394
+ </span>
395
+ <span class="has-text-weight-bold is-capitalized has-text-${statusClass}">${test.status}</span>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ ${test.duration ? `
401
+ <div class="card-footer-item">
402
+ <div class="column is-half">
403
+ <div class="is-flex is-align-items-center">
404
+ <span class="icon status-icon has-text-info">
405
+ <i class="fa fa-clock"></i>
406
+ </span>
407
+ <span class="has-text-info has-text-weight-semibold">${test.duration}</span>
408
+ </div>
409
+ </div>
410
+ </div>
411
+ ` : ''}
412
+ ${test.projectName ? `
413
+ <div class="card-footer-item">
414
+ <div class="is-flex is-align-items-center">
415
+ <span class="icon status-icon has-text-link">
416
+ ${projectIcon}
417
+ </span>
418
+ <span> ${test.projectName}</span>
419
+ </div>
420
+ </div>
421
+ ` : ''}
422
+ </footer>
423
+ </div>
424
+ </div>
425
+ <div class="content-wrapper">
426
+ ${this.renderTestContent(test)}
427
+ </div>
428
+ `;
429
+ this.attachScreenshotListeners(test);
430
+ this.attachSteps(test);
431
+ this.attachTabListeners();
432
+ }
433
+
434
+ /**
435
+ * Render test content
436
+ * @param {Object} test - Test data
437
+ * @returns {string} HTML content
438
+ */
439
+ renderTestContent(test) {
440
+ let content = '';
441
+ if (test.status !== "skipped") {
442
+ content += this.renderScreenshotsAndVideo(test);
443
+ }
444
+ content += this.renderAdditionalInfo(test);
445
+ content += this.renderTabs(test);
446
+ return content;
447
+ }
448
+
449
+ /**
450
+ * Render screenshots and video
451
+ * @param {Object} test - Test data
452
+ * @returns {string} HTML content
453
+ */
454
+ renderScreenshotsAndVideo(test) {
455
+ let content = '<div class="card mb-5"><div class="card-content"><div class="columns is-multiline">';
456
+ if (test.screenshots && test.screenshots.length > 0) {
457
+ content += `
458
+ <div class="column is-half">
459
+ <div id="testImage" class="modal">
460
+ <div class="modal-background"></div>
461
+ <div class="modal-content">
462
+ <p class="image">
463
+ <img id="screenshot-modal-img" src="${test.screenshots[0]}" alt="Screenshot">
464
+ </p>
465
+ </div>
466
+ <button onclick="closeModal()" class="modal-close is-large" aria-label="close"></button>
467
+ </div>
468
+ <figure class="image">
469
+ <img id="screenshot-main-img" onclick="openModal()" src="${test.screenshots[0]}" alt="Screenshot">
470
+ </figure>
471
+ <nav class="mt-4 pagination is-small is-centered ${test.screenshots.length > 1 ? '' : 'is-hidden'}" role="navigation" aria-label="pagination">
472
+ <a class="pagination-previous">Previous</a>
473
+ <a class="pagination-next">Next</a>
474
+ <ul class="pagination-list">
475
+ ${test.screenshots.map((_, index) => `
476
+ <li>
477
+ <a class="pagination-link ${index === 0 ? 'is-current' : ''}" aria-label="Goto screenshot ${index + 1}">${index + 1}</a>
478
+ </li>`).join('')}
479
+ </ul>
480
+ </nav>
481
+ </div>
482
+ `;
483
+ }
484
+ if (test.videoPath) {
485
+ content += `
486
+ <div class="column is-half">
487
+ <div class="video-preview">
488
+ <video controls width="100%" height="auto" preload="metadata">
489
+ <source src="${test.videoPath}" type="video/webm">
490
+ Your browser does not support the video tag.
491
+ </video>
492
+ </div>
493
+ </div>
494
+ `;
495
+ }
496
+ content += '</div>';
497
+ content += `
498
+ <div class="columns">
499
+ <div class="column">
500
+ <button
501
+ onclick="openHistory()"
502
+ class="button is-primary is-fullwidth mt-3">
503
+ <span class="icon"><i class="fa-solid fa-timeline"></i></span>
504
+ <span class="has-text-white pl-2">Open history</span>
505
+ </button>
506
+ <div id="historyModal" class="modal">
507
+ <div class="modal-background"></div>
508
+ </div>
509
+ </div>
510
+ ${test.tracePath ? `
511
+ <div class="column">
512
+ <button
513
+ data-trace="${test.tracePath}"
514
+ onclick="openTraceViewer(this)"
515
+ class="button is-primary is-fullwidth mt-3">
516
+ <span class="icon"><i class="fa-solid fa-tv"></i></span>
517
+ <span class="has-text-white pl-2">View Trace</span>
518
+ </button>
519
+ </div>
520
+ ` : ''}
521
+ </div>
522
+ `;
523
+
524
+ content += '</div></div>';
525
+ return content;
526
+ }
527
+
528
+ /**
529
+ * Render additional information
530
+ * @param {Object} test - Test data
531
+ * @returns {string} HTML content
532
+ */
533
+ renderAdditionalInfo(test) {
534
+ if (!(test.annotations.length || test.testTags.length > 0)) return '';
535
+ return `
536
+ <div class="card mb-5">
537
+ <header class="card-header">
538
+ <p class="card-header-title">Additional Information</p>
539
+ </header>
540
+ <div class="card-content">
541
+ <div class="content">
542
+ ${test.testTags.length > 0 ? `
543
+ <div class="control mb-4">
544
+ <div class="tags is-rounded">
545
+ ${test.testTags.map(tag => `<span class="tag is-primary is-medium">${tag}</span>`).join('')}
546
+ </div>
547
+ </div>` : ""}
548
+ ${test.annotations
549
+ .filter(annotation => annotation !== null && annotation !== undefined)
550
+ .map(annotation => `
551
+ <div class="mb-4">
552
+ ${annotation?.type ? `<strong class="has-text-link">Type: </strong><span>${annotation.type}</span>` : ''}
553
+ <br>
554
+ ${annotation?.description ? `<strong class="has-text-link">Description: </strong><span>${annotation.description}</span>` : ''}
555
+ </div>
556
+ `).join('')}
557
+ </div>
558
+ </div>
559
+ </div>
560
+ `;
561
+ }
562
+
563
+ /**
564
+ * Render tabs
565
+ * @param {Object} test - Test data
566
+ * @returns {string} HTML content
567
+ */
568
+ renderTabs(test) {
569
+ return `
570
+ <div class="card mt-5">
571
+ <div class="card-content">
572
+ <div class="tabs is-boxed is-fullwidth">
573
+ <ul>
574
+ <li class="is-active"><a data-tab="steps">Steps</a></li>
575
+ <li><a data-tab="errors">Errors</a></li>
576
+ <li><a data-tab="logs">Logs</a></li>
577
+ </ul>
578
+ </div>
579
+ <div id="tabContent">
580
+ <div id="stepsTab" class="tab-content">
581
+ ${this.renderSteps(test)}
582
+ </div>
583
+ <div id="errorsTab" class="tab-content" style="display: none;">
584
+ ${this.renderErrors(test)}
585
+ </div>
586
+ <div id="logsTab" class="tab-content" style="display: none;">
587
+ ${this.renderLogs(test)}
588
+ </div>
589
+ </div>
590
+ </div>
591
+ </div>
592
+ `;
593
+ }
594
+
595
+ /**
596
+ * Render steps
597
+ * @param {Object} test - Test data
598
+ * @returns {string} HTML content
599
+ */
600
+ renderSteps(test) {
601
+ if (test.steps.length === 0) return '<p>No steps available.</p>';
602
+ return `
603
+ <div class="content">
604
+ <span id="stepDetails" class="content"></span>
605
+ </div>
606
+ `;
607
+ }
608
+
609
+ /**
610
+ * Render errors
611
+ * @param {Object} test - Test data
612
+ * @returns {string} HTML content
613
+ */
614
+ renderErrors(test) {
615
+ if (!test.errors.length) return '<p>No errors reported.</p>';
616
+ return `
617
+ <div class="content">
618
+ <pre><code class="data-lang=js">${test.errors.join('\n')}</code></pre>
619
+ </div>
620
+ `;
621
+ }
622
+
623
+ /**
624
+ * Render logs
625
+ * @param {Object} test - Test data
626
+ * @returns {string} HTML content
627
+ */
628
+ renderLogs(test) {
629
+ if (!test.logs) return '<p>No logs available.</p>';
630
+ return `
631
+ <div class="content">
632
+ <pre>${test.logs}</pre>
633
+ </div>
634
+ `;
635
+ }
636
+
637
+ /**
638
+ * Attach screenshot listeners
639
+ * @param {Object} test - Test data
640
+ */
641
+ attachScreenshotListeners(test) {
642
+ if (test.screenshots && test.screenshots.length > 0) {
643
+ const changeScreenshot = (direction) => {
644
+ this.currentScreenshotIndex = (this.currentScreenshotIndex + direction + test.screenshots.length) % test.screenshots.length;
645
+ this.updateScreenshot(test.screenshots, this.currentScreenshotIndex);
646
+ };
647
+ const gotoScreenshot = (index) => {
648
+ this.currentScreenshotIndex = index;
649
+ this.updateScreenshot(test.screenshots, this.currentScreenshotIndex);
650
+ };
651
+ document.querySelector('.pagination-previous').addEventListener('click', () => changeScreenshot(-1));
652
+ document.querySelector('.pagination-next').addEventListener('click', () => changeScreenshot(1));
653
+ document.querySelectorAll('.pagination-link').forEach((link, index) => {
654
+ link.addEventListener('click', () => gotoScreenshot(index));
655
+ });
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Update screenshot
661
+ * @param {Array} screenshots - Screenshot paths
662
+ * @param {number} index - Screenshot index
663
+ */
664
+ updateScreenshot(screenshots, index) {
665
+ document.getElementById('screenshot-main-img').src = screenshots[index];
666
+ document.getElementById('screenshot-modal-img').src = screenshots[index];
667
+ document.querySelectorAll('.pagination-link').forEach((link, i) => {
668
+ link.classList.toggle('is-current', i === index);
669
+ });
670
+ }
671
+
672
+ /**
673
+ * Attach steps
674
+ * @param {Object} test - Test data
675
+ */
676
+ attachSteps(test) {
677
+ const stepDetailsDiv = document.getElementById('stepDetails');
678
+ if (stepDetailsDiv) {
679
+ const stepsList = document.createElement("ul");
680
+ stepsList.setAttribute("id", "steps");
681
+ test.steps.forEach(step => {
682
+ const li = document.createElement('li');
683
+ li.innerHTML = `<strong class="${step.snippet ? 'has-text-danger' : ''}">${step.title}</strong>`;
684
+ if (step.snippet) {
685
+ const pre = document.createElement('pre');
686
+ const code = document.createElement('code');
687
+ const locationText = step.location ? `\n\nat: ${step.location}` : '';
688
+ code.innerHTML = `${step.snippet}${locationText}`;
689
+ code.setAttribute('data-lang', 'js');
690
+ pre.appendChild(code);
691
+ li.appendChild(pre);
692
+ }
693
+ stepsList.appendChild(li);
694
+ });
695
+ stepDetailsDiv.appendChild(stepsList);
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Attach tab listeners
701
+ */
702
+ attachTabListeners() {
703
+ const tabLinks = document.querySelectorAll('div.tabs a');
704
+ tabLinks.forEach(link => {
705
+ link.addEventListener('click', (e) => {
706
+ e.preventDefault();
707
+ const tabId = link.getAttribute('data-tab');
708
+
709
+ tabLinks.forEach(l => l.parentElement.classList.remove('is-active'));
710
+ document.querySelectorAll('div.tab-content').forEach(c => c.style.display = 'none');
711
+
712
+ link.parentElement.classList.add('is-active');
713
+ document.getElementById(`${tabId}Tab`).style.display = 'block';
714
+ });
715
+ });
716
+ }
717
+
718
+ /**
719
+ * Attach event listeners
720
+ */
721
+ attachEventListeners() {
722
+ const testItems = document.querySelectorAll('[data-test-id]');
723
+ testItems.forEach(item => {
724
+ item.addEventListener('click', () => {
725
+ testItems.forEach(i => i.classList.remove('listselected'));
726
+ item.classList.add('listselected');
727
+ const testId = item.getAttribute('data-test-id');
728
+ const testHistoryId = item.getAttribute('data-test-history-id');
729
+ const test = this.testData[testId];
730
+ const historyEntry = this.testHistory.find(entry => entry.testId === testHistoryId);
731
+ this.testHistoriesMap = historyEntry ? historyEntry.history : null;
732
+ this.testHistoryTitle = historyEntry && historyEntry.testId ? historyEntry.testId.split(":")[2] : '';
733
+ this.show(test);
734
+ });
735
+ });
736
+ }
737
+ }
738
+
739
+ /**
740
+ * ====================================
741
+ * FILTER MANAGER
742
+ * ====================================
743
+ */
744
+ class FilterManager {
745
+ /**
746
+ * Initialize the filter manager
747
+ * @param {Object} elements - DOM elements
748
+ */
749
+ constructor(elements) {
750
+ this.elements = elements;
751
+ this.init();
752
+ }
753
+
754
+ /**
755
+ * Initialize filter manager
756
+ */
757
+ init() {
758
+ this.attachEventListeners();
759
+ }
760
+
761
+ /**
762
+ * Attach event listeners
763
+ */
764
+ attachEventListeners() {
765
+ const checkboxes = document.querySelectorAll('#select-filter input[type="checkbox"]');
766
+ checkboxes.forEach(checkbox => {
767
+ checkbox.addEventListener('change', () => this.applyFilters());
768
+ });
769
+
770
+ const filters = document.querySelectorAll('.filter');
771
+ filters.forEach(filter => {
772
+ filter.addEventListener('click', (event) => {
773
+ filters.forEach(f => {
774
+ f.classList.remove('active');
775
+ f.style.borderRight = ''; // Reset previous border-right
776
+ });
777
+ const clickedFilter = event.currentTarget;
778
+ clickedFilter.classList.add('active');
779
+ const borderLeftColor = window.getComputedStyle(clickedFilter).borderLeftColor;
780
+ clickedFilter.style.setProperty('border-right', `5px solid ${borderLeftColor}`, 'important');
781
+ this.applyFilters();
782
+ });
783
+ });
784
+ }
785
+
786
+ /**
787
+ * Apply filters
788
+ */
789
+ applyFilters() {
790
+ const selectedProjects = this.getSelectedValues('project');
791
+ const selectedTags = this.getSelectedValues('test-tags');
792
+ const selectedStatus = document.querySelector('.filter.active')?.getAttribute('data-status') || 'all';
793
+
794
+ this.elements.detailsElements.forEach(details => {
795
+ const items = details.querySelectorAll('div[data-test-id]');
796
+ let shouldShowDetails = false;
797
+
798
+ items.forEach(item => {
799
+ const isVisible = this.shouldShowItem(item, selectedProjects, selectedTags, selectedStatus);
800
+ item.classList.toggle('is-hidden', !isVisible);
801
+ shouldShowDetails = shouldShowDetails || isVisible;
802
+ });
803
+
804
+ details.open = shouldShowDetails;
805
+ details.classList.toggle('is-hidden', !shouldShowDetails);
806
+ });
807
+
808
+ this.updateSelectedFiltersDisplay(selectedProjects, selectedTags, selectedStatus);
809
+ }
810
+
811
+ /**
812
+ * Get selected values
813
+ * @param {string} type - Filter type
814
+ * @returns {Array} Selected values
815
+ */
816
+ getSelectedValues(type) {
817
+ return Array.from(document.querySelectorAll(`#select-filter input[type="checkbox"][data-filter-type="${type}"]:checked`))
818
+ .map(checkbox => checkbox.value.trim());
819
+ }
820
+
821
+ /**
822
+ * Check if item should be shown
823
+ * @param {HTMLElement} item - Test item
824
+ * @param {Array} projects - Selected projects
825
+ * @param {Array} tags - Selected tags
826
+ * @param {string} status - Selected status
827
+ * @returns {boolean} Whether item should be shown
828
+ */
829
+ shouldShowItem(item, projects, tags, status) {
830
+ const testTags = item.getAttribute('data-test-tags').trim().split(' ').filter(Boolean);
831
+ const projectName = item.getAttribute('data-project-name').trim();
832
+ const testStatus = item.getAttribute('data-test-status').trim();
833
+
834
+ const matchesProject = projects.length === 0 || projects.includes(projectName);
835
+ const matchesTags = tags.length === 0 || tags.every(tag => testTags.includes(tag));
836
+ const matchesStatus = this.matchesStatus(testStatus, status);
837
+
838
+ return matchesProject && matchesTags && matchesStatus;
839
+ }
840
+
841
+ /**
842
+ * Check if test status matches selected status
843
+ * @param {string} testStatus - Test status
844
+ * @param {string} selectedStatus - Selected status
845
+ * @returns {boolean} Whether status matches
846
+ */
847
+ matchesStatus(testStatus, selectedStatus) {
848
+ if (selectedStatus === 'all') return testStatus !== 'skipped';
849
+ if (selectedStatus === 'failed') return testStatus === 'failed' || testStatus === 'timedOut';
850
+ if (selectedStatus === 'retry') return testStatus.includes('retry');
851
+ if (selectedStatus === 'flaky') return testStatus.includes('flaky');
852
+ return testStatus === selectedStatus;
853
+ }
854
+
855
+ /**
856
+ * Update selected filters display
857
+ * @param {Array} projects - Selected projects
858
+ * @param {Array} tags - Selected tags
859
+ * @param {string} status - Selected status
860
+ */
861
+ updateSelectedFiltersDisplay(projects, tags, status) {
862
+ let displayText = [];
863
+ if (projects.length > 0) displayText.push(`Projects: ${projects.join(', ')}`);
864
+ if (tags.length > 0) displayText.push(`Tags: ${tags.join(', ')}`);
865
+ if (status !== 'all') displayText.push(`Status: ${status}`);
866
+ this.elements.filtersDisplay.innerHTML = displayText.length > 0 ? displayText.join(' | ') : 'All Tests';
867
+ }
868
+ }
869
+
870
+ /**
871
+ * ====================================
872
+ * SEARCH MANAGER
873
+ * ====================================
874
+ */
875
+ class SearchManager {
876
+ /**
877
+ * Initialize the search manager
878
+ * @param {Object} elements - DOM elements
879
+ */
880
+ constructor(elements) {
881
+ this.elements = elements;
882
+ this.init();
883
+ }
884
+
885
+ /**
886
+ * Initialize search manager
887
+ */
888
+ init() {
889
+ this.elements.searchInput.addEventListener('input', Utils.debounce(this.filterTests.bind(this), 300));
890
+ }
891
+
892
+ /**
893
+ * Filter tests based on search term
894
+ * @param {Event} event - Input event
895
+ */
896
+ filterTests(event) {
897
+ const searchTerm = event.target.value.toLowerCase();
898
+ const testItems = document.querySelectorAll('[data-test-id]');
899
+
900
+ this.elements.detailsElements.forEach(detail => detail.open = !!searchTerm);
901
+
902
+ testItems.forEach(item => {
903
+ const isVisible = item.textContent.toLowerCase().includes(searchTerm);
904
+ item.style.display = isVisible ? 'flex' : 'none';
905
+ if (isVisible && searchTerm) this.openParentDetails(item);
906
+ });
907
+ }
908
+
909
+ /**
910
+ * Open parent details elements
911
+ * @param {HTMLElement} item - Test item
912
+ */
913
+ openParentDetails(item) {
914
+ let parent = item.parentElement;
915
+ while (parent && parent.tagName !== 'ASIDE') {
916
+ if (parent.tagName === 'DETAILS') parent.open = true;
917
+ parent = parent.parentElement;
918
+ }
919
+ }
920
+ }
921
+
922
+ /**
923
+ * ====================================
924
+ * HISTORY MANAGER
925
+ * ====================================
926
+ */
927
+ class HistoryManager {
928
+ /**
929
+ * Initialize the history manager
930
+ * @param {Array} testHistory - Test history data
931
+ */
932
+ constructor(testHistory) {
933
+ this.testHistory = testHistory
934
+ this.testHistoriesMap = null
935
+ this.testHistoryTitle = ""
936
+ this.activeErrorModal = null
937
+ }
938
+
939
+ /**
940
+ * Set current test history
941
+ * @param {Array} historyMap - History map
942
+ * @param {string} title - Test title
943
+ */
944
+ setCurrentHistory(historyMap, title) {
945
+ this.testHistoriesMap = historyMap
946
+ this.testHistoryTitle = title
947
+ }
948
+
949
+ /**
950
+ * Open history modal
951
+ */
952
+ openHistory() {
953
+ const historyElement = document.getElementById("historyModal")
954
+ historyElement.classList.add("is-active")
955
+
956
+ let historyContent = ""
957
+ if (this.testHistoriesMap && this.testHistoriesMap.length > 0) {
958
+ historyContent = this.testHistoriesMap
959
+ .map(
960
+ (h, index) => `
961
+ <tr>
962
+ <td>${h.run_date}</td>
963
+ <td>
964
+ <span class="tag is-${this.getStatusClass(h.status)}">
965
+ ${h.status}
966
+ </span>
967
+ </td>
968
+ <td>${h.duration}</td>
969
+ <td>
970
+ ${h.error_message
971
+ ? `<div class="modal" id="error-${index}">
972
+ <div class="modal-background"></div>
973
+ <div class="modal-card">
974
+ <header class="modal-card-head">
975
+ <p class="modal-card-title">
976
+ <span class="icon-text">
977
+ <span class="icon">
978
+ <i class="fa-solid fa-exclamation-triangle"></i>
979
+ </span>
980
+ <span>Error Details</span>
981
+ </span>
982
+ </p>
983
+ <button class="delete" aria-label="close" onclick="closeErrorModal('error-${index}')"></button>
984
+ </header>
985
+ <section class="modal-card-body">
986
+ <div class="notification is-danger is-light">
987
+ <pre><code>${h.error_message}</code></pre>
988
+ </div>
989
+ </section>
990
+ <footer class="modal-card-foot">
991
+ <button class="button is-primary" onclick="closeErrorModal('error-${index}')">Close</button>
992
+ </footer>
993
+ </div>
994
+ </div>
995
+ <button class="button is-small is-link" onclick="showHistoryErrorMessage('error-${index}')">
996
+ <span class="icon">
997
+ <i class="fa-solid fa-exclamation-triangle"></i>
998
+ </span>
999
+ <span>View Error</span>
1000
+ </button>`
1001
+ : '<span class="tag is-success is-light">No Error</span>'
1002
+ }
1003
+ </td>
1004
+ </tr>
1005
+ `,
1006
+ )
1007
+ .join("")
1008
+ } else {
1009
+ historyContent = `
1010
+ <tr>
1011
+ <td colspan="4">
1012
+ <div class="notification is-info is-light">
1013
+ <p class="has-text-centered">
1014
+ <span class="icon">
1015
+ <i class="fa-solid fa-info-circle"></i>
1016
+ </span>
1017
+ <span>No history available for this test.</span>
1018
+ </p>
1019
+ </div>
1020
+ </td>
1021
+ </tr>
1022
+ `
1023
+ }
1024
+
1025
+ historyElement.innerHTML = `
1026
+ <div class="modal-background"></div>
1027
+ <div class="modal-card">
1028
+ <header class="modal-card-head">
1029
+ <p class="modal-card-title">
1030
+ <span class="icon-text">
1031
+ <span class="icon">
1032
+ <i class="fa-solid fa-history"></i>
1033
+ </span>
1034
+ <span>Test History: ${this.testHistoryTitle}</span>
1035
+ </span>
1036
+ </p>
1037
+ <button class="delete" aria-label="close" onclick="closeHistoryModal()"></button>
1038
+ </header>
1039
+ <section class="modal-card-body">
1040
+ <div class="table-container">
1041
+ <table class="table is-hoverable is-fullwidth">
1042
+ <thead>
1043
+ <tr>
1044
+ <th title="Run Date">
1045
+ <span class="icon-text">
1046
+ <span class="icon">
1047
+ <i class="fa-solid fa-calendar"></i>
1048
+ </span>
1049
+ <span>Run Date</span>
1050
+ </span>
1051
+ </th>
1052
+ <th title="Status">
1053
+ <span class="icon-text">
1054
+ <span class="icon">
1055
+ <i class="fa-solid fa-check-circle"></i>
1056
+ </span>
1057
+ <span>Status</span>
1058
+ </span>
1059
+ </th>
1060
+ <th title="Duration">
1061
+ <span class="icon-text">
1062
+ <span class="icon">
1063
+ <i class="fa-solid fa-clock"></i>
1064
+ </span>
1065
+ <span>Duration</span>
1066
+ </span>
1067
+ </th>
1068
+ <th title="Details">
1069
+ <span class="icon-text">
1070
+ <span class="icon">
1071
+ <i class="fa-solid fa-info-circle"></i>
1072
+ </span>
1073
+ <span>Details</span>
1074
+ </span>
1075
+ </th>
1076
+ </tr>
1077
+ </thead>
1078
+ <tbody>
1079
+ ${historyContent}
1080
+ </tbody>
1081
+ </table>
1082
+ </div>
1083
+ </section>
1084
+ <footer class="modal-card-foot">
1085
+ <button class="button is-primary" onclick="closeHistoryModal()">Close</button>
1086
+ </footer>
1087
+ </div>
1088
+ `
1089
+
1090
+ // Add keyboard event listener for ESC key
1091
+ document.addEventListener("keydown", this.handleEscKeyPress)
1092
+ }
1093
+
1094
+ /**
1095
+ * Get status class based on test status
1096
+ * @param {string} status - Test status
1097
+ * @returns {string} CSS class
1098
+ */
1099
+ getStatusClass(status) {
1100
+ if (status && status.startsWith("passed")) return "success"
1101
+ if (status === "flaky") return "warning"
1102
+ if (status === "failed") return "danger"
1103
+ if (status === "skipped") return "info"
1104
+ if (status && status.includes("retry")) return "link"
1105
+ return "dark"
1106
+ }
1107
+
1108
+ /**
1109
+ * Handle ESC key press to close modal
1110
+ * @param {KeyboardEvent} event - Keyboard event
1111
+ */
1112
+ handleEscKeyPress = (event) => {
1113
+ if (event.key === "Escape") {
1114
+ this.closeHistoryModal()
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * Close history modal
1120
+ */
1121
+ closeHistoryModal() {
1122
+ const historyElement = document.getElementById("historyModal")
1123
+ historyElement.classList.remove("is-active")
1124
+
1125
+ // Remove keyboard event listener
1126
+ document.removeEventListener("keydown", this.handleEscKeyPress)
1127
+ }
1128
+
1129
+ /**
1130
+ * Show history error message
1131
+ * @param {string} modalId - Modal ID
1132
+ */
1133
+ showHistoryErrorMessage(modalId) {
1134
+ document.getElementById(modalId)?.classList.add("is-active")
1135
+ }
1136
+
1137
+ /**
1138
+ * Close error modal
1139
+ * @param {string} modalId - Modal ID
1140
+ */
1141
+ closeErrorModal(modalId) {
1142
+ document.getElementById(modalId)?.classList.remove("is-active")
1143
+ }
1144
+ }
1145
+ /**
1146
+ * ====================================
1147
+ * MOBILE RESPONSIVE MANAGER
1148
+ * ====================================
1149
+ */
1150
+ class MobileManager {
1151
+ /**
1152
+ * Initialize the mobile manager
1153
+ */
1154
+ constructor() {
1155
+ this.sidebar = document.getElementById('sidebar');
1156
+ this.init();
1157
+ }
1158
+
1159
+ /**
1160
+ * Initialize mobile manager
1161
+ */
1162
+ init() {
1163
+ // Add sidebar toggle button to navbar for mobile
1164
+ const navbar = document.querySelector('.navbar-brand');
1165
+ if (navbar) {
1166
+ const toggleButton = document.createElement('a');
1167
+ toggleButton.className = 'navbar-item sidebar-toggle is-hidden-desktop';
1168
+ toggleButton.innerHTML = '<i class="fa fa-bars"></i>';
1169
+ toggleButton.addEventListener('click', () => this.toggleSidebar());
1170
+ navbar.appendChild(toggleButton);
1171
+ }
1172
+
1173
+ // Close sidebar when clicking on a link on mobile
1174
+ const sidebarLinks = document.querySelectorAll('.sidebar-menu-link');
1175
+ sidebarLinks.forEach(link => {
1176
+ link.addEventListener('click', () => {
1177
+ if (window.innerWidth < 769) {
1178
+ this.sidebar.classList.remove('is-active');
1179
+ }
1180
+ });
1181
+ });
1182
+ }
1183
+
1184
+ /**
1185
+ * Toggle sidebar on mobile
1186
+ */
1187
+ toggleSidebar() {
1188
+ this.sidebar.classList.toggle('is-active');
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * ====================================
1194
+ * INITIALIZATION
1195
+ * ====================================
1196
+ */
1197
+ // Initialize data
1198
+ const testData = {{{ json results }}};
1199
+ const testHistory = {{{ json testHistories }}};
1200
+
1201
+ // Initialize DOM elements
1202
+ const elements = {
1203
+ testDetails: document.getElementById('testDetails'),
1204
+ summary: document.getElementById('dashboard-section'),
1205
+ themeButton: document.getElementById("toggle-theme"),
1206
+ themeIcon: document.getElementById("theme-icon"),
1207
+ htmlElement: document.documentElement,
1208
+ searchInput: document.querySelector('input[name="search"]'),
1209
+ detailsElements: document.querySelectorAll('details'),
1210
+ filtersDisplay: document.getElementById('selected-filters'),
1211
+ };
1212
+
1213
+ // Initialize managers
1214
+ const sidebarManager = new SidebarManager();
1215
+ const themeManager = new ThemeManager(elements);
1216
+ const testDetailsManager = new TestDetailsManager(elements, testData, testHistory, sidebarManager);
1217
+ const filterManager = new FilterManager(elements);
1218
+ const searchManager = new SearchManager(elements);
1219
+ const historyManager = new HistoryManager(testHistory);
1220
+ const mobileManager = new MobileManager();
1221
+ const sidebarCollapseManager = new SidebarCollapseManager();
1222
+
1223
+ // Attach event listeners
1224
+ testDetailsManager.attachEventListeners();
1225
+
1226
+ // Expose necessary functions to the global scope
1227
+ window.showSummary = () => testDetailsManager.hide();
1228
+ window.openModal = () => document.querySelector("#testImage").classList.add("is-active");
1229
+ window.closeModal = () => document.querySelector("#testImage").classList.remove("is-active");
1230
+ window.closeErrorModal = (modalId) => document.getElementById(modalId).classList.remove("is-active");
1231
+ window.openTraceViewer = (button) => {
1232
+ const tracePath = button.getAttribute("data-trace");
1233
+ if (tracePath) {
1234
+ const normalizedTracePath = tracePath.replace(/\\/g, '/');
1235
+ const baseUrl = Utils.getAdjustedBaseUrl();
1236
+ window.open(`${baseUrl}/trace/index.html?trace=${baseUrl}/${normalizedTracePath}`, "_blank");
1237
+ }
1238
+ };
1239
+ window.getAdjustedBaseUrl = Utils.getAdjustedBaseUrl;
1240
+ window.openHistory = () => {
1241
+ historyManager.setCurrentHistory(testDetailsManager.testHistoriesMap, testDetailsManager.testHistoryTitle);
1242
+ historyManager.openHistory();
1243
+ };
1244
+ window.closeHistoryModal = () => historyManager.closeHistoryModal();
1245
+ window.showHistoryErrorMessage = (modalId) => historyManager.showHistoryErrorMessage(modalId);
1246
+ });
1247
+ </script>
1248
+ </body>
600
1249
  </html>