@juliendu11/japa-ui-reporter 0.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.
package/README.MD ADDED
@@ -0,0 +1,50 @@
1
+ # Japa UI reporter
2
+
3
+ Frustrated by the various default reporting tools, I created my own.
4
+
5
+ This one allows you to open a dashboard in your browser with live reports of your tests, similar to `vitest --ui`
6
+
7
+ When the tests start, a page will open in your browser.
8
+
9
+ <img src="./resources/screenshot.png" alt="screenshot" width="600px">
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install -D @juliendu11/japa-ui-reporter
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ :warning: Note: I use it with Adonis JS; it's designed to be used with **version 6**.
20
+
21
+ ```ts
22
+ // tests/bootstrap.ts
23
+ import UIReporter from '@juliendu11/japa-ui-reporter'
24
+
25
+ import type {Config} from '@japa/runner/types'
26
+
27
+ export const reporters: Config['reporters'] = {
28
+ activated: ['ui'],
29
+ list: [UIReporter.ui()],
30
+ }
31
+ ```
32
+
33
+ ### Options
34
+
35
+ ```ts
36
+ UIReporter.ui({
37
+ ui: { // Optional, default: {port: 3000}
38
+ port: number
39
+ },
40
+ reporter: { // Optional, default: {port: 9999}
41
+ port: number
42
+ },
43
+ ...BaseReporterOptions // Optional
44
+ })
45
+ ```
46
+
47
+ ## How it works
48
+
49
+ The reporter starts a server that serves the dashboard and listens for test events. When a test event occurs, it sends
50
+ the data to the dashboard via WebSocket, which updates the UI accordingly.
@@ -0,0 +1,3 @@
1
+ import { NamedReporterContract } from "@japa/runner/types";
2
+ import type { UIReporterOptions } from "./types.js";
3
+ export declare const ui: (options?: UIReporterOptions) => NamedReporterContract;
@@ -0,0 +1,7 @@
1
+ import UIReporter from "./ui_reporter.js";
2
+ export const ui = (options) => {
3
+ return {
4
+ name: 'ui',
5
+ handler: (...args) => new UIReporter(options).boot(...args),
6
+ };
7
+ };
@@ -0,0 +1,4 @@
1
+ declare const _default: {
2
+ ui: (options?: import("./types.ts").UIReporterOptions) => import("@japa/core/types").NamedReporterContract;
3
+ };
4
+ export default _default;
package/build/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { ui } from './handler.js';
2
+ export default { ui };
@@ -0,0 +1,17 @@
1
+ import { BaseReporterOptions } from "@japa/runner/types";
2
+ export type UIReporterOptions = BaseReporterOptions & {
3
+ ui?: {
4
+ port: number;
5
+ };
6
+ reporter?: {
7
+ port: number;
8
+ };
9
+ };
10
+ export type CreateServerOptions = {
11
+ ui: {
12
+ port: number;
13
+ };
14
+ reporter: {
15
+ port: number;
16
+ };
17
+ };
package/build/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,538 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Test Dashboard</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ body {
11
+ font-family: 'Segoe UI', system-ui, sans-serif;
12
+ background: #0f1117;
13
+ color: #e2e8f0;
14
+ min-height: 100vh;
15
+ padding: 2rem;
16
+ }
17
+
18
+ header {
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: space-between;
22
+ margin-bottom: 2rem;
23
+ }
24
+
25
+ h1 {
26
+ font-size: 1.5rem;
27
+ font-weight: 600;
28
+ color: #f8fafc;
29
+ }
30
+
31
+ #status {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 0.5rem;
35
+ font-size: 0.85rem;
36
+ color: #94a3b8;
37
+ }
38
+
39
+ #status-dot {
40
+ width: 8px;
41
+ height: 8px;
42
+ border-radius: 50%;
43
+ background: #ef4444;
44
+ transition: background 0.3s;
45
+ }
46
+
47
+ #status-dot.connected { background: #22c55e; }
48
+ #status-dot.running { background: #f59e0b; animation: pulse 1s infinite; }
49
+
50
+ @keyframes pulse {
51
+ 0%, 100% { opacity: 1; }
52
+ 50% { opacity: 0.4; }
53
+ }
54
+
55
+ #summary {
56
+ display: flex;
57
+ gap: 1rem;
58
+ margin-bottom: 2rem;
59
+ }
60
+
61
+ .stat {
62
+ background: #1e2130;
63
+ border-radius: 8px;
64
+ padding: 0.75rem 1.25rem;
65
+ min-width: 90px;
66
+ text-align: center;
67
+ }
68
+
69
+ .stat-value {
70
+ font-size: 1.75rem;
71
+ font-weight: 700;
72
+ line-height: 1;
73
+ }
74
+
75
+ .stat-label {
76
+ font-size: 0.75rem;
77
+ color: #64748b;
78
+ margin-top: 0.25rem;
79
+ text-transform: uppercase;
80
+ letter-spacing: 0.05em;
81
+ }
82
+
83
+ .stat.pass .stat-value { color: #22c55e; }
84
+ .stat.fail .stat-value { color: #ef4444; }
85
+ .stat.total .stat-value { color: #94a3b8; }
86
+
87
+ #groups {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 1.5rem;
91
+ }
92
+
93
+ .group {
94
+ background: #1e2130;
95
+ border-radius: 10px;
96
+ overflow: hidden;
97
+ border: 1px solid #2d3348;
98
+ }
99
+
100
+ .group-header {
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: space-between;
104
+ padding: 0.85rem 1.25rem;
105
+ background: #252840;
106
+ cursor: pointer;
107
+ user-select: none;
108
+ }
109
+
110
+ .group-header:hover { background: #2c304d; }
111
+
112
+ .group-title {
113
+ font-weight: 600;
114
+ font-size: 0.95rem;
115
+ color: #c7d2fe;
116
+ }
117
+
118
+ .group-badges {
119
+ display: flex;
120
+ gap: 0.5rem;
121
+ align-items: center;
122
+ font-size: 0.8rem;
123
+ }
124
+
125
+ .badge {
126
+ padding: 0.2rem 0.55rem;
127
+ border-radius: 999px;
128
+ font-weight: 600;
129
+ }
130
+
131
+ .badge.pass { background: #14532d; color: #86efac; }
132
+ .badge.fail { background: #450a0a; color: #fca5a5; }
133
+
134
+ .group-tests {
135
+ list-style: none;
136
+ }
137
+
138
+ .test {
139
+ display: flex;
140
+ align-items: flex-start;
141
+ gap: 0.75rem;
142
+ padding: 0.75rem 1.25rem;
143
+ border-top: 1px solid #2d3348;
144
+ transition: background 0.15s;
145
+ }
146
+
147
+ .test:hover { background: #242840; }
148
+
149
+ .test-icon {
150
+ flex-shrink: 0;
151
+ margin-top: 2px;
152
+ font-size: 0.9rem;
153
+ }
154
+
155
+ .test-body {
156
+ flex: 1;
157
+ min-width: 0;
158
+ }
159
+
160
+ .test-title {
161
+ font-size: 0.875rem;
162
+ color: #e2e8f0;
163
+ word-break: break-word;
164
+ margin-bottom: 10px;
165
+ }
166
+
167
+ .test-filename {
168
+ font-size: 0.80rem;
169
+ color: #e2e8f0;
170
+ word-break: break-word;
171
+ }
172
+
173
+ .test-duration {
174
+ margin-top: 10px;
175
+ font-size: 0.75rem;
176
+ color: #475569;
177
+ }
178
+
179
+ .test-errors {
180
+ margin-top: 0.5rem;
181
+ display: flex;
182
+ flex-direction: column;
183
+ gap: 0.4rem;
184
+ }
185
+
186
+ .test-error {
187
+ background: #1a0a0a;
188
+ border-left: 3px solid #ef4444;
189
+ border-radius: 4px;
190
+ padding: 0.5rem 0.75rem;
191
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
192
+ font-size: 0.78rem;
193
+ color: #fca5a5;
194
+ white-space: pre-wrap;
195
+ word-break: break-word;
196
+ }
197
+
198
+ .test-error-phase {
199
+ font-size: 0.7rem;
200
+ color: #ef4444;
201
+ font-family: inherit;
202
+ margin-bottom: 0.25rem;
203
+ text-transform: uppercase;
204
+ letter-spacing: 0.05em;
205
+ }
206
+
207
+ #empty {
208
+ text-align: center;
209
+ color: #475569;
210
+ padding: 4rem 0;
211
+ font-size: 0.95rem;
212
+ }
213
+
214
+ #empty p:first-child {
215
+ font-size: 2rem;
216
+ margin-bottom: 0.5rem;
217
+ }
218
+
219
+ .diff-block {
220
+ margin-top: 0.5rem;
221
+ border-radius: 4px;
222
+ overflow: hidden;
223
+ border: 1px solid #2d3348;
224
+ }
225
+
226
+ .diff-legend {
227
+ display: flex;
228
+ gap: 1.5rem;
229
+ padding: 0.3rem 0.5rem;
230
+ background: #1e2130;
231
+ font-size: 0.7rem;
232
+ border-bottom: 1px solid #2d3348;
233
+ }
234
+
235
+ .diff-legend-removed { color: #fca5a5; }
236
+ .diff-legend-added { color: #86efac; }
237
+
238
+ .diff-line {
239
+ padding: 1px 0.5rem;
240
+ white-space: pre-wrap;
241
+ word-break: break-all;
242
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
243
+ font-size: 0.78rem;
244
+ line-height: 1.5;
245
+ }
246
+
247
+ .diff-line.removed { background: #3b0a0a; color: #fca5a5; }
248
+ .diff-line.added { background: #052e16; color: #86efac; }
249
+ .diff-line.unchanged { color: #475569; }
250
+ </style>
251
+ </head>
252
+ <body>
253
+ <header>
254
+ <h1>Test Dashboard</h1>
255
+ <div id="status">
256
+ <div id="status-dot"></div>
257
+ <span id="status-text">Connecting...</span>
258
+ </div>
259
+ </header>
260
+
261
+ <div id="summary">
262
+ <div class="stat total">
263
+ <div class="stat-value" id="count-total">0</div>
264
+ <div class="stat-label">Total</div>
265
+ </div>
266
+ <div class="stat pass">
267
+ <div class="stat-value" id="count-pass">0</div>
268
+ <div class="stat-label">Passed</div>
269
+ </div>
270
+ <div class="stat fail">
271
+ <div class="stat-value" id="count-fail">0</div>
272
+ <div class="stat-label">Failed</div>
273
+ </div>
274
+ </div>
275
+
276
+ <div id="groups">
277
+ <div id="empty">
278
+ <p>&#9634;</p>
279
+ <p>Waiting for test results...</p>
280
+ </div>
281
+ </div>
282
+
283
+ <script>
284
+ const statusDot = document.getElementById('status-dot');
285
+ const statusText = document.getElementById('status-text');
286
+ const groupsContainer = document.getElementById('groups');
287
+ const emptyState = document.getElementById('empty');
288
+ const countTotal = document.getElementById('count-total');
289
+ const countPass = document.getElementById('count-pass');
290
+ const countFail = document.getElementById('count-fail');
291
+
292
+ // groupTitle -> { element, tests: [] }
293
+ const groups = new Map();
294
+ let stats = { total: 0, pass: 0, fail: 0 };
295
+
296
+ function setStatus(state, text) {
297
+ statusDot.className = state;
298
+ statusText.textContent = text;
299
+ }
300
+
301
+ function updateStats() {
302
+ countTotal.textContent = stats.total;
303
+ countPass.textContent = stats.pass;
304
+ countFail.textContent = stats.fail;
305
+ }
306
+
307
+ function getOrCreateGroup(groupTitle) {
308
+ if (groups.has(groupTitle)) return groups.get(groupTitle);
309
+
310
+ emptyState.style.display = 'none';
311
+
312
+ const groupEl = document.createElement('div');
313
+ groupEl.className = 'group';
314
+
315
+ const header = document.createElement('div');
316
+ header.className = 'group-header';
317
+
318
+ const title = document.createElement('span');
319
+ title.className = 'group-title';
320
+ title.textContent = groupTitle;
321
+
322
+ const badges = document.createElement('div');
323
+ badges.className = 'group-badges';
324
+
325
+ header.appendChild(title);
326
+ header.appendChild(badges);
327
+
328
+ const testList = document.createElement('ul');
329
+ testList.className = 'group-tests';
330
+
331
+ header.addEventListener('click', () => {
332
+ testList.style.display = testList.style.display === 'none' ? '' : 'none';
333
+ });
334
+
335
+ groupEl.appendChild(header);
336
+ groupEl.appendChild(testList);
337
+ groupsContainer.appendChild(groupEl);
338
+
339
+ const entry = { element: groupEl, header, badges, testList, pass: 0, fail: 0 };
340
+ groups.set(groupTitle, entry);
341
+ return entry;
342
+ }
343
+
344
+ function updateGroupBadges(group) {
345
+ group.badges.innerHTML = '';
346
+ if (group.pass > 0) {
347
+ const b = document.createElement('span');
348
+ b.className = 'badge pass';
349
+ b.textContent = `${group.pass} passed`;
350
+ group.badges.appendChild(b);
351
+ }
352
+ if (group.fail > 0) {
353
+ const b = document.createElement('span');
354
+ b.className = 'badge fail';
355
+ b.textContent = `${group.fail} failed`;
356
+ group.badges.appendChild(b);
357
+ }
358
+ }
359
+
360
+ function formatError(err) {
361
+ if (typeof err === 'string') return err;
362
+ if (err && typeof err === 'object') return JSON.stringify(err, null, 2);
363
+ return String(err);
364
+ }
365
+
366
+ function buildDiffElement(actual, expected) {
367
+ const allKeys = Array.from(new Set([...Object.keys(actual), ...Object.keys(expected)]));
368
+ const lines = [];
369
+
370
+ for (const key of allKeys) {
371
+ const hasA = Object.prototype.hasOwnProperty.call(actual, key);
372
+ const hasE = Object.prototype.hasOwnProperty.call(expected, key);
373
+ if (hasA && hasE) {
374
+ const av = JSON.stringify(actual[key], null, 2);
375
+ const ev = JSON.stringify(expected[key], null, 2);
376
+ if (av === ev) {
377
+ lines.push({ type: 'unchanged', text: ` ${key}: ${av}` });
378
+ } else {
379
+ lines.push({ type: 'removed', text: `- ${key}: ${av}` });
380
+ lines.push({ type: 'added', text: `+ ${key}: ${ev}` });
381
+ }
382
+ } else if (hasA) {
383
+ lines.push({ type: 'removed', text: `- ${key}: ${JSON.stringify(actual[key], null, 2)}` });
384
+ } else {
385
+ lines.push({ type: 'added', text: `+ ${key}: ${JSON.stringify(expected[key], null, 2)}` });
386
+ }
387
+ }
388
+
389
+ const block = document.createElement('div');
390
+ block.className = 'diff-block';
391
+
392
+ const legend = document.createElement('div');
393
+ legend.className = 'diff-legend';
394
+ legend.innerHTML = '<span class="diff-legend-removed">- actual</span><span class="diff-legend-added">+ expected</span>';
395
+ block.appendChild(legend);
396
+
397
+ for (const { type, text } of lines) {
398
+ const row = document.createElement('div');
399
+ row.className = `diff-line ${type}`;
400
+ row.textContent = text;
401
+ block.appendChild(row);
402
+ }
403
+
404
+ return block;
405
+ }
406
+
407
+ function addTestResult(data) {
408
+ const groupTitle = data.group?.title || 'Ungrouped';
409
+ const group = getOrCreateGroup(groupTitle);
410
+
411
+ const passed = !data.hasError;
412
+
413
+ stats.total++;
414
+ if (passed) { stats.pass++; group.pass++; }
415
+ else { stats.fail++; group.fail++; }
416
+
417
+ updateStats();
418
+ updateGroupBadges(group);
419
+
420
+ const li = document.createElement('li');
421
+ li.className = 'test';
422
+ li.dataset.failed = passed ? '0' : '1';
423
+
424
+ const icon = document.createElement('span');
425
+ icon.className = 'test-icon';
426
+ icon.textContent = passed ? '✓' : '✗';
427
+ icon.style.color = passed ? '#22c55e' : '#ef4444';
428
+
429
+ const body = document.createElement('div');
430
+ body.className = 'test-body';
431
+
432
+ const titleEl = document.createElement('div');
433
+ titleEl.className = 'test-title';
434
+ titleEl.textContent = data.title;
435
+
436
+ const filename = document.createElement('code');
437
+ filename.className = 'test-filename';
438
+ filename.textContent = data.file.name
439
+ filename.addEventListener('click', () => {
440
+ navigator.clipboard.writeText(data.file.name)
441
+ })
442
+
443
+ const duration = document.createElement('div');
444
+ duration.className = 'test-duration';
445
+ duration.textContent = `${(data.duration || 0).toFixed(2)} ms`;
446
+
447
+ body.appendChild(titleEl);
448
+ body.appendChild(filename);
449
+ body.appendChild(duration);
450
+
451
+ if (!passed && data.errors?.length) {
452
+ const errorsEl = document.createElement('div');
453
+ errorsEl.className = 'test-errors';
454
+ for (const e of data.errors) {
455
+ const errEl = document.createElement('div');
456
+ errEl.className = 'test-error';
457
+ const phase = document.createElement('div');
458
+ phase.className = 'test-error-phase';
459
+ phase.textContent = e.phase || 'error';
460
+ errEl.appendChild(phase);
461
+ if (e.error?.operator === 'deepStrictEqual') {
462
+ errEl.appendChild(buildDiffElement(e.error.actual, e.error.expected));
463
+ } else {
464
+ errEl.appendChild(document.createTextNode(formatError(e.error)));
465
+ }
466
+ errorsEl.appendChild(errEl);
467
+ }
468
+ body.appendChild(errorsEl);
469
+ }
470
+
471
+ li.appendChild(icon);
472
+ li.appendChild(body);
473
+ group.testList.appendChild(li);
474
+
475
+ // Scroll to keep latest visible
476
+ li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
477
+ }
478
+
479
+ function sortResults() {
480
+ // Sort tests within each group: failed first
481
+ for (const group of groups.values()) {
482
+ const items = [...group.testList.children];
483
+ items.sort((a, b) => b.dataset.failed - a.dataset.failed);
484
+ for (const item of items) group.testList.appendChild(item);
485
+ }
486
+ // Sort groups: groups with failures first
487
+ const groupEls = [...groupsContainer.children].filter(el => el !== emptyState);
488
+ groupEls.sort((a, b) => {
489
+ const aEntry = [...groups.values()].find(g => g.element === a);
490
+ const bEntry = [...groups.values()].find(g => g.element === b);
491
+ return (bEntry?.fail ?? 0) - (aEntry?.fail ?? 0);
492
+ });
493
+ for (const el of groupEls) groupsContainer.appendChild(el);
494
+ }
495
+
496
+ function clearResults() {
497
+ groups.clear();
498
+ stats = { total: 0, pass: 0, fail: 0 };
499
+ updateStats();
500
+ groupsContainer.innerHTML = '';
501
+ groupsContainer.appendChild(emptyState);
502
+ emptyState.style.display = '';
503
+ }
504
+
505
+ function connect() {
506
+ const ws = new WebSocket(`ws://${location.host}`);
507
+
508
+ ws.onopen = () => setStatus('connected', 'Connected');
509
+
510
+ ws.onmessage = (event) => {
511
+ let msg;
512
+ try { msg = JSON.parse(event.data); } catch { return; }
513
+
514
+ if (msg.type === 'run:start') {
515
+ clearResults();
516
+ setStatus('running', 'Running...');
517
+ } else if (msg.type === 'test:result') {
518
+ addTestResult(msg.data);
519
+ } else if (msg.type === 'run:sort') {
520
+ sortResults();
521
+ } else if (msg.type === 'run:end') {
522
+ const failText = stats.fail > 0 ? ` — ${stats.fail} failed` : '';
523
+ setStatus('connected', `Done: ${stats.pass}/${stats.total} passed${failText}`);
524
+ }
525
+ };
526
+
527
+ ws.onclose = () => {
528
+ setStatus('', 'Disconnected — retrying...');
529
+ setTimeout(connect, 2000);
530
+ };
531
+
532
+ ws.onerror = () => ws.close();
533
+ }
534
+
535
+ connect();
536
+ </script>
537
+ </body>
538
+ </html>