jestronaut 0.1.0
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 +101 -0
- package/bin/jestronaut.js +41 -0
- package/index.js +3 -0
- package/lib/constants.js +5 -0
- package/lib/reporter.js +133 -0
- package/lib/state.js +51 -0
- package/lib/ui/keys.js +139 -0
- package/lib/ui/overlays/suiteDetail.js +123 -0
- package/lib/ui/overlays/testDetail.js +85 -0
- package/lib/ui/panels/footer.js +42 -0
- package/lib/ui/panels/header.js +23 -0
- package/lib/ui/panels/progress.js +31 -0
- package/lib/ui/panels/results.js +41 -0
- package/lib/ui/panels/stats.js +27 -0
- package/lib/ui/panels/suites.js +63 -0
- package/lib/ui/screen.js +74 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Jestronaut 🚀
|
|
2
|
+
|
|
3
|
+
An interactive terminal dashboard for Jest — navigate live test results, suites, and failure stack traces without leaving your terminal.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Live dashboard updates as tests run
|
|
12
|
+
- Animated spinner showing which suites are in progress
|
|
13
|
+
- Pass / Fail / Skip counters + progress bar
|
|
14
|
+
- Navigable test results panel with keyboard controls
|
|
15
|
+
- Navigable suites panel — open any suite to see its full breakdown
|
|
16
|
+
- Failure detail overlay with expected/received diff and stack trace
|
|
17
|
+
- Navigate between failed tests inside a suite with `j/k`
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install --save-dev jestronaut
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
Add to your `jest.config.js`:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
module.exports = {
|
|
31
|
+
reporters: ['jestronaut'],
|
|
32
|
+
};
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Run
|
|
36
|
+
|
|
37
|
+
Instead of `jest`, use:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx jestronaut
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or add to your `package.json` scripts:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "jestronaut"
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
All Jest CLI flags work as normal:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx jestronaut --testPathPattern=auth
|
|
55
|
+
npx jestronaut --watch
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Keyboard Controls
|
|
59
|
+
|
|
60
|
+
| Key | Action |
|
|
61
|
+
|-----|--------|
|
|
62
|
+
| `Tab` | Switch focus between Test Results and Suites panels |
|
|
63
|
+
| `j` / `↓` | Move cursor down |
|
|
64
|
+
| `k` / `↑` | Move cursor up |
|
|
65
|
+
| `Enter` | Open failure detail (on a failed test) or suite detail (on a suite) |
|
|
66
|
+
| `Esc` | Close overlay / go back |
|
|
67
|
+
| `q` / `Ctrl+C` | Quit |
|
|
68
|
+
|
|
69
|
+
### Inside Suite Detail
|
|
70
|
+
|
|
71
|
+
| Key | Action |
|
|
72
|
+
|-----|--------|
|
|
73
|
+
| `j` / `k` | Navigate between failed tests only |
|
|
74
|
+
| `Enter` | Open failure detail for selected test |
|
|
75
|
+
| `Esc` | Back to dashboard |
|
|
76
|
+
|
|
77
|
+
### Inside Failure Detail
|
|
78
|
+
|
|
79
|
+
| Key | Action |
|
|
80
|
+
|-----|--------|
|
|
81
|
+
| `j` / `k` | Scroll |
|
|
82
|
+
| `Esc` | Back |
|
|
83
|
+
|
|
84
|
+
## Example
|
|
85
|
+
|
|
86
|
+
The `example/` directory contains a sample Jest project with multiple test suites and intentional failures to demonstrate the dashboard.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cd example
|
|
90
|
+
npm install
|
|
91
|
+
npm test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Requirements
|
|
95
|
+
|
|
96
|
+
- Node >= 16
|
|
97
|
+
- Jest >= 27
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Intercept stdout/stderr before Jest loads anything so its early
|
|
5
|
+
// output ("Determining test suites...") doesn't bleed into the TUI.
|
|
6
|
+
const realStdout = process.stdout.write.bind(process.stdout);
|
|
7
|
+
const realStderr = process.stderr.write.bind(process.stderr);
|
|
8
|
+
|
|
9
|
+
const SUPPRESS = [
|
|
10
|
+
'Determining test suites',
|
|
11
|
+
'localstorage-file',
|
|
12
|
+
'ExperimentalWarning',
|
|
13
|
+
'trace-warnings',
|
|
14
|
+
'Jest did not exit',
|
|
15
|
+
'detectOpenHandles',
|
|
16
|
+
'asynchronous operations',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function shouldSuppress(chunk) {
|
|
20
|
+
const s = chunk.toString();
|
|
21
|
+
return SUPPRESS.some(kw => s.includes(kw));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process.stdout.write = (chunk, enc, cb) => {
|
|
25
|
+
if (!shouldSuppress(chunk)) return realStdout(chunk, enc, cb);
|
|
26
|
+
if (typeof enc === 'function') enc(); else if (typeof cb === 'function') cb();
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
process.stderr.write = (chunk, enc, cb) => {
|
|
31
|
+
if (!shouldSuppress(chunk)) return realStderr(chunk, enc, cb);
|
|
32
|
+
if (typeof enc === 'function') enc(); else if (typeof cb === 'function') cb();
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Clear terminal before handing off to Jest
|
|
37
|
+
realStdout('\x1b[2J\x1b[H');
|
|
38
|
+
|
|
39
|
+
// Forward all CLI args so flags like --testPathPattern, --watch etc. still work
|
|
40
|
+
const { run } = require('jest');
|
|
41
|
+
run();
|
package/index.js
ADDED
package/lib/constants.js
ADDED
package/lib/reporter.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createState } = require('./state');
|
|
4
|
+
const { createScreen } = require('./ui/screen');
|
|
5
|
+
const { updateHeader } = require('./ui/panels/header');
|
|
6
|
+
const { updateStats } = require('./ui/panels/stats');
|
|
7
|
+
const { updateProgress } = require('./ui/panels/progress');
|
|
8
|
+
const { updateFooter } = require('./ui/panels/footer');
|
|
9
|
+
|
|
10
|
+
class DashboardReporter {
|
|
11
|
+
constructor(globalConfig) {
|
|
12
|
+
this._globalConfig = globalConfig;
|
|
13
|
+
this._state = createState();
|
|
14
|
+
const { screen, widgets, renderAll, refreshOpenSuiteDetail } = createScreen(this._state);
|
|
15
|
+
this._screen = screen;
|
|
16
|
+
this._widgets = widgets;
|
|
17
|
+
this._renderAll = renderAll;
|
|
18
|
+
this._refreshOpenSuiteDetail = refreshOpenSuiteDetail;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
onRunStart(results) {
|
|
22
|
+
const s = this._state;
|
|
23
|
+
s.stats.suites = results.numTotalTestSuites;
|
|
24
|
+
s.stats.expectedTotal = results.numTotalTests || 0;
|
|
25
|
+
s.stats.startTime = Date.now();
|
|
26
|
+
this._renderAll();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
onTestFileStart(test) {
|
|
30
|
+
this._state.suites[test.path] = {
|
|
31
|
+
passed: 0, failed: 0, done: false,
|
|
32
|
+
startTime: Date.now(), endTime: null,
|
|
33
|
+
running: new Set(), tests: [],
|
|
34
|
+
};
|
|
35
|
+
this._state.suiteOrder.push(test.path);
|
|
36
|
+
this._renderAll();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onTestCaseStart(test, info) {
|
|
40
|
+
const suite = this._state.suites[test.path];
|
|
41
|
+
if (suite && info) suite.running.add(info.fullName || info.title);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onTestCaseResult(test, r) {
|
|
45
|
+
const s = this._state;
|
|
46
|
+
const suite = s.suites[test.path] || {
|
|
47
|
+
passed: 0, failed: 0, done: false,
|
|
48
|
+
startTime: Date.now(), running: new Set(), tests: [],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
s.stats.total++;
|
|
52
|
+
if (suite.running) suite.running.delete(r.fullName || r.title);
|
|
53
|
+
|
|
54
|
+
suite.tests.push({
|
|
55
|
+
title: r.title,
|
|
56
|
+
status: r.status,
|
|
57
|
+
duration: r.duration,
|
|
58
|
+
messages: r.failureMessages || [],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let icon, textColor;
|
|
62
|
+
|
|
63
|
+
if (r.status === 'passed') {
|
|
64
|
+
s.stats.passed++;
|
|
65
|
+
suite.passed++;
|
|
66
|
+
icon = '{green-fg}PASS{/green-fg}';
|
|
67
|
+
textColor = 'white-fg';
|
|
68
|
+
s.resultMeta.push({ status: 'passed', failureIndex: -1 });
|
|
69
|
+
} else if (r.status === 'failed') {
|
|
70
|
+
s.stats.failed++;
|
|
71
|
+
suite.failed++;
|
|
72
|
+
icon = '{red-fg}FAIL{/red-fg}';
|
|
73
|
+
textColor = 'red-fg';
|
|
74
|
+
const failureIndex = s.failures.length;
|
|
75
|
+
s.failures.push({
|
|
76
|
+
title: r.title,
|
|
77
|
+
suiteName: r.ancestorTitles.join(' > '),
|
|
78
|
+
messages: r.failureMessages || [],
|
|
79
|
+
duration: r.duration,
|
|
80
|
+
});
|
|
81
|
+
s.resultMeta.push({ status: 'failed', failureIndex });
|
|
82
|
+
} else {
|
|
83
|
+
s.stats.skipped++;
|
|
84
|
+
icon = '{yellow-fg}SKIP{/yellow-fg}';
|
|
85
|
+
textColor = 'yellow-fg';
|
|
86
|
+
s.resultMeta.push({ status: r.status, failureIndex: -1 });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
s.suites[test.path] = suite;
|
|
90
|
+
|
|
91
|
+
const ms = r.duration != null ? ` {grey-fg}(${r.duration}ms){/grey-fg}` : '';
|
|
92
|
+
const ancestor = r.ancestorTitles.join(' > ');
|
|
93
|
+
const prefix = ancestor ? `{grey-fg}${ancestor} >{/grey-fg} ` : '';
|
|
94
|
+
const hint = r.status === 'failed' ? ' {cyan-fg}[Enter]{/cyan-fg}' : '';
|
|
95
|
+
s.resultLines.push(`[${icon}] ${prefix}{${textColor}}${r.title}{/${textColor}}${ms}${hint}`);
|
|
96
|
+
|
|
97
|
+
this._refreshOpenSuiteDetail();
|
|
98
|
+
this._renderAll();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
onTestFileResult(test) {
|
|
102
|
+
const suite = this._state.suites[test.path];
|
|
103
|
+
if (suite) {
|
|
104
|
+
suite.done = true;
|
|
105
|
+
suite.endTime = Date.now();
|
|
106
|
+
suite.running = new Set();
|
|
107
|
+
this._state.stats.suitesCompleted++;
|
|
108
|
+
}
|
|
109
|
+
this._refreshOpenSuiteDetail();
|
|
110
|
+
this._renderAll();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onRunComplete(_, results) {
|
|
114
|
+
const s = this._state;
|
|
115
|
+
if (s._ticker) { clearInterval(s._ticker); s._ticker = null; }
|
|
116
|
+
|
|
117
|
+
const elapsed = ((Date.now() - s.stats.startTime) / 1000).toFixed(2);
|
|
118
|
+
const ok = results.numFailedTests === 0;
|
|
119
|
+
|
|
120
|
+
updateHeader(this._widgets.header, ok);
|
|
121
|
+
this._widgets.header.setContent(
|
|
122
|
+
`{center}{bold} ${ok ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'} — ${elapsed}s {/bold}{/center}`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
s.stats.expectedTotal = s.stats.total;
|
|
126
|
+
updateStats(this._widgets.stats, s.stats);
|
|
127
|
+
updateProgress(this._widgets.progress, s.stats, this._screen.width);
|
|
128
|
+
updateFooter(this._widgets.footer, s);
|
|
129
|
+
this._renderAll();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = DashboardReporter;
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Central mutable state shared across all UI modules.
|
|
4
|
+
// All modules receive a reference to this object and read/write it directly.
|
|
5
|
+
|
|
6
|
+
function createState() {
|
|
7
|
+
return {
|
|
8
|
+
// run-level stats
|
|
9
|
+
stats: {
|
|
10
|
+
passed: 0,
|
|
11
|
+
failed: 0,
|
|
12
|
+
skipped: 0,
|
|
13
|
+
total: 0,
|
|
14
|
+
expectedTotal: 0,
|
|
15
|
+
suites: 0,
|
|
16
|
+
suitesCompleted: 0,
|
|
17
|
+
startTime: Date.now(),
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
// suite data keyed by file path
|
|
21
|
+
suites: {},
|
|
22
|
+
suiteOrder: [],
|
|
23
|
+
|
|
24
|
+
// flat list of all test results for the results panel
|
|
25
|
+
resultLines: [], // display strings
|
|
26
|
+
resultMeta: [], // { status, failureIndex }
|
|
27
|
+
|
|
28
|
+
// all failure objects for detail view
|
|
29
|
+
failures: [], // { title, suiteName, messages, duration }
|
|
30
|
+
|
|
31
|
+
// panel focus
|
|
32
|
+
focus: 'results', // 'results' | 'suites'
|
|
33
|
+
resultCursor: -1,
|
|
34
|
+
suiteCursor: 0,
|
|
35
|
+
|
|
36
|
+
// suite detail overlay
|
|
37
|
+
suiteDetailOpen: false,
|
|
38
|
+
suiteDetailPath: null,
|
|
39
|
+
suiteDetailLines: [],
|
|
40
|
+
suiteDetailMeta: [],
|
|
41
|
+
suiteDetailCursor: 0,
|
|
42
|
+
|
|
43
|
+
// test detail overlay
|
|
44
|
+
testDetailOpen: false,
|
|
45
|
+
|
|
46
|
+
// animation
|
|
47
|
+
spinFrame: 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { createState };
|
package/lib/ui/keys.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const suiteDetailOverlay = require('./overlays/suiteDetail');
|
|
4
|
+
const { openTestDetail } = require('./overlays/testDetail');
|
|
5
|
+
|
|
6
|
+
function bindKeys(screen, widgets, state, render) {
|
|
7
|
+
screen.key(['q', 'C-c'], () => {
|
|
8
|
+
render.destroy();
|
|
9
|
+
process.exit(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
screen.key(['tab'], () => {
|
|
13
|
+
if (state.suiteDetailOpen || state.testDetailOpen) return;
|
|
14
|
+
state.focus = state.focus === 'results' ? 'suites' : 'results';
|
|
15
|
+
render.all();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
screen.key(['up', 'k'], () => {
|
|
19
|
+
if (state.testDetailOpen) {
|
|
20
|
+
widgets.testDetail.scroll(-1); screen.render(); return;
|
|
21
|
+
}
|
|
22
|
+
if (state.suiteDetailOpen) {
|
|
23
|
+
suiteDetailOverlay.moveCursor(state, -1);
|
|
24
|
+
suiteDetailOverlay.refreshSuiteDetail(widgets.suiteDetail, state);
|
|
25
|
+
screen.render(); return;
|
|
26
|
+
}
|
|
27
|
+
if (state.focus === 'results') {
|
|
28
|
+
if (state.resultLines.length === 0) return;
|
|
29
|
+
state.resultCursor = state.resultCursor <= 0
|
|
30
|
+
? state.resultLines.length - 1
|
|
31
|
+
: state.resultCursor - 1;
|
|
32
|
+
} else {
|
|
33
|
+
if (state.suiteOrder.length === 0) return;
|
|
34
|
+
state.suiteCursor = state.suiteCursor <= 0
|
|
35
|
+
? state.suiteOrder.length - 1
|
|
36
|
+
: state.suiteCursor - 1;
|
|
37
|
+
}
|
|
38
|
+
render.all();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
screen.key(['down', 'j'], () => {
|
|
42
|
+
if (state.testDetailOpen) {
|
|
43
|
+
widgets.testDetail.scroll(1); screen.render(); return;
|
|
44
|
+
}
|
|
45
|
+
if (state.suiteDetailOpen) {
|
|
46
|
+
suiteDetailOverlay.moveCursor(state, 1);
|
|
47
|
+
suiteDetailOverlay.refreshSuiteDetail(widgets.suiteDetail, state);
|
|
48
|
+
screen.render(); return;
|
|
49
|
+
}
|
|
50
|
+
if (state.focus === 'results') {
|
|
51
|
+
if (state.resultLines.length === 0) return;
|
|
52
|
+
state.resultCursor = state.resultCursor >= state.resultLines.length - 1
|
|
53
|
+
? 0
|
|
54
|
+
: state.resultCursor + 1;
|
|
55
|
+
} else {
|
|
56
|
+
if (state.suiteOrder.length === 0) return;
|
|
57
|
+
state.suiteCursor = state.suiteCursor >= state.suiteOrder.length - 1
|
|
58
|
+
? 0
|
|
59
|
+
: state.suiteCursor + 1;
|
|
60
|
+
}
|
|
61
|
+
render.all();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
screen.key(['enter'], () => {
|
|
65
|
+
if (state.testDetailOpen) return;
|
|
66
|
+
|
|
67
|
+
if (state.suiteDetailOpen) {
|
|
68
|
+
const meta = state.suiteDetailMeta[state.suiteDetailCursor];
|
|
69
|
+
if (meta && meta.type === 'test' && meta.failureObj) {
|
|
70
|
+
state.testDetailOpen = true;
|
|
71
|
+
openTestDetail(widgets.testDetail, meta.failureObj, state.stats);
|
|
72
|
+
screen.render();
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (state.focus === 'results') {
|
|
78
|
+
if (state.resultCursor < 0 || state.resultCursor >= state.resultMeta.length) return;
|
|
79
|
+
const meta = state.resultMeta[state.resultCursor];
|
|
80
|
+
if (meta.status !== 'failed') return;
|
|
81
|
+
state.testDetailOpen = true;
|
|
82
|
+
openTestDetail(widgets.testDetail, state.failures[meta.failureIndex], state.stats);
|
|
83
|
+
screen.render();
|
|
84
|
+
} else {
|
|
85
|
+
if (state.suiteOrder.length === 0) return;
|
|
86
|
+
const path = state.suiteOrder[state.suiteCursor];
|
|
87
|
+
if (!path) return;
|
|
88
|
+
_openSuiteDetail(widgets, state, path);
|
|
89
|
+
screen.render();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
screen.key(['escape'], () => {
|
|
94
|
+
if (state.testDetailOpen) {
|
|
95
|
+
state.testDetailOpen = false;
|
|
96
|
+
widgets.testDetail.hide();
|
|
97
|
+
screen.render();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (state.suiteDetailOpen) {
|
|
101
|
+
state.suiteDetailOpen = false;
|
|
102
|
+
state.suiteDetailPath = null;
|
|
103
|
+
state.suiteDetailLines = [];
|
|
104
|
+
state.suiteDetailMeta = [];
|
|
105
|
+
widgets.suiteDetail.hide();
|
|
106
|
+
screen.render();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function _openSuiteDetail(widgets, state, path) {
|
|
112
|
+
const result = suiteDetailOverlay.buildSuiteDetailLines(state.suites[path], path);
|
|
113
|
+
state.suiteDetailOpen = true;
|
|
114
|
+
state.suiteDetailPath = path;
|
|
115
|
+
state.suiteDetailLines = result.lines;
|
|
116
|
+
state.suiteDetailMeta = result.meta;
|
|
117
|
+
state.suiteDetailCursor = result.meta.findIndex(m => m.type === 'test' && m.failureObj);
|
|
118
|
+
if (state.suiteDetailCursor < 0) state.suiteDetailCursor = 0;
|
|
119
|
+
widgets.suiteDetail.setLabel(result.label);
|
|
120
|
+
widgets.suiteDetail.style.border.fg = result.hasFailed ? 'red' : 'green';
|
|
121
|
+
widgets.suiteDetail.show();
|
|
122
|
+
suiteDetailOverlay.refreshSuiteDetail(widgets.suiteDetail, state);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Called when a suite receives new results while suite detail is open
|
|
126
|
+
function refreshOpenSuiteDetail(widgets, state) {
|
|
127
|
+
if (!state.suiteDetailOpen || !state.suiteDetailPath) return;
|
|
128
|
+
const result = suiteDetailOverlay.buildSuiteDetailLines(
|
|
129
|
+
state.suites[state.suiteDetailPath],
|
|
130
|
+
state.suiteDetailPath
|
|
131
|
+
);
|
|
132
|
+
state.suiteDetailLines = result.lines;
|
|
133
|
+
state.suiteDetailMeta = result.meta;
|
|
134
|
+
widgets.suiteDetail.setLabel(result.label);
|
|
135
|
+
widgets.suiteDetail.style.border.fg = result.hasFailed ? 'red' : 'green';
|
|
136
|
+
suiteDetailOverlay.refreshSuiteDetail(widgets.suiteDetail, state);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { bindKeys, refreshOpenSuiteDetail };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
|
|
5
|
+
function createSuiteDetail(screen) {
|
|
6
|
+
const widget = blessed.list({
|
|
7
|
+
top: '3%', left: '3%', width: '94%', height: '94%',
|
|
8
|
+
label: ' Suite Detail ',
|
|
9
|
+
border: { type: 'line' },
|
|
10
|
+
tags: true, scrollable: true, alwaysScroll: true,
|
|
11
|
+
keys: false, mouse: false,
|
|
12
|
+
scrollbar: { ch: '|', style: { fg: 'magenta' } },
|
|
13
|
+
style: {
|
|
14
|
+
border: { fg: 'magenta' }, label: { fg: 'magenta', bold: true },
|
|
15
|
+
bg: '#08080a', item: { fg: 'white' },
|
|
16
|
+
},
|
|
17
|
+
padding: { left: 2, right: 2, top: 1, bottom: 1 },
|
|
18
|
+
hidden: true,
|
|
19
|
+
});
|
|
20
|
+
screen.append(widget);
|
|
21
|
+
return widget;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildSuiteDetailLines(suiteData, path) {
|
|
25
|
+
const s = suiteData;
|
|
26
|
+
const name = path.split('/').pop().replace(/\.test\.[jt]sx?$/, '');
|
|
27
|
+
const totalTests = (s.tests || []).length;
|
|
28
|
+
const duration = s.endTime && s.startTime
|
|
29
|
+
? ((s.endTime - s.startTime) / 1000).toFixed(2) + 's'
|
|
30
|
+
: 'running...';
|
|
31
|
+
const status = !s.done ? 'RUNNING' : s.failed > 0 ? 'FAILED' : 'PASSED';
|
|
32
|
+
const statusColor = !s.done ? 'yellow' : s.failed > 0 ? 'red' : 'green';
|
|
33
|
+
const passRate = totalTests > 0 ? Math.round((s.passed / totalTests) * 100) : 0;
|
|
34
|
+
|
|
35
|
+
const lines = [];
|
|
36
|
+
const meta = [];
|
|
37
|
+
|
|
38
|
+
const add = (text, m = { type: 'other' }) => { lines.push(text); meta.push(m); };
|
|
39
|
+
|
|
40
|
+
add(`{${statusColor}-fg}{bold}Suite: ${name} [${status}]{/bold}{/${statusColor}-fg}`);
|
|
41
|
+
add('');
|
|
42
|
+
add(`{yellow-fg}File :{/yellow-fg} {grey-fg}${path}{/grey-fg}`);
|
|
43
|
+
add(`{yellow-fg}Duration :{/yellow-fg} ${duration}`);
|
|
44
|
+
add(`{yellow-fg}Pass rate:{/yellow-fg} {${statusColor}-fg}${passRate}%{/${statusColor}-fg}`);
|
|
45
|
+
add(`{yellow-fg}Tests :{/yellow-fg} ${totalTests} total {green-fg}${s.passed} passed{/green-fg} {red-fg}${s.failed} failed{/red-fg}`);
|
|
46
|
+
add(`{yellow-fg}Slowest :{/yellow-fg} ${_slowest(s.tests || [])}`);
|
|
47
|
+
add(`{yellow-fg}Fastest :{/yellow-fg} ${_fastest(s.tests || [])}`);
|
|
48
|
+
add('');
|
|
49
|
+
add(`{cyan-fg}── Test Results (${totalTests}) ─────────────────────────────────────────────{/cyan-fg}`);
|
|
50
|
+
add('');
|
|
51
|
+
|
|
52
|
+
const tests = s.tests || [];
|
|
53
|
+
if (tests.length === 0) {
|
|
54
|
+
add(' {grey-fg}(no results yet){/grey-fg}');
|
|
55
|
+
} else {
|
|
56
|
+
tests.forEach(t => {
|
|
57
|
+
if (t.status === 'passed') {
|
|
58
|
+
add(
|
|
59
|
+
` {green-fg}PASS{/green-fg} {white-fg}${t.title}{/white-fg} {grey-fg}(${t.duration != null ? t.duration + 'ms' : '?'}){/grey-fg}`,
|
|
60
|
+
{ type: 'test', failureObj: null }
|
|
61
|
+
);
|
|
62
|
+
} else if (t.status === 'failed') {
|
|
63
|
+
add(
|
|
64
|
+
` {red-fg}FAIL{/red-fg} {red-fg}${t.title}{/red-fg} {grey-fg}(${t.duration != null ? t.duration + 'ms' : '?'}){/grey-fg} {cyan-fg}[Enter]{/cyan-fg}`,
|
|
65
|
+
{
|
|
66
|
+
type: 'test',
|
|
67
|
+
failureObj: { title: t.title, suiteName: name, messages: t.messages || [], duration: t.duration },
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
add(
|
|
72
|
+
` {yellow-fg}SKIP{/yellow-fg} {grey-fg}${t.title}{/grey-fg}`,
|
|
73
|
+
{ type: 'test', failureObj: null }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
add('');
|
|
80
|
+
add('{grey-fg} [j/k] navigate failed tests [Enter] open failure [Esc] back{/grey-fg}');
|
|
81
|
+
|
|
82
|
+
return { lines, meta, name, hasFailed: s.failed > 0, label: ` Suite: ${name} [${s.passed}p ${s.failed}f] ` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function refreshSuiteDetail(widget, state) {
|
|
86
|
+
const items = state.suiteDetailLines.map((line, i) => {
|
|
87
|
+
const m = state.suiteDetailMeta[i];
|
|
88
|
+
const isFailed = m && m.type === 'test' && m.failureObj;
|
|
89
|
+
if (i === state.suiteDetailCursor && isFailed) {
|
|
90
|
+
return `{#3a0a0a-bg}> ${line.trimStart()}{/#3a0a0a-bg}`;
|
|
91
|
+
}
|
|
92
|
+
return line;
|
|
93
|
+
});
|
|
94
|
+
widget.setItems(items);
|
|
95
|
+
widget.scrollTo(state.suiteDetailCursor);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function moveCursor(state, dir) {
|
|
99
|
+
const meta = state.suiteDetailMeta;
|
|
100
|
+
if (meta.length === 0) return;
|
|
101
|
+
const failIndices = meta
|
|
102
|
+
.map((m, i) => (m.type === 'test' && m.failureObj) ? i : -1)
|
|
103
|
+
.filter(i => i >= 0);
|
|
104
|
+
if (failIndices.length === 0) return;
|
|
105
|
+
const pos = failIndices.indexOf(state.suiteDetailCursor);
|
|
106
|
+
if (dir > 0) {
|
|
107
|
+
state.suiteDetailCursor = pos < failIndices.length - 1 ? failIndices[pos + 1] : failIndices[0];
|
|
108
|
+
} else {
|
|
109
|
+
state.suiteDetailCursor = pos > 0 ? failIndices[pos - 1] : failIndices[failIndices.length - 1];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _slowest(tests) {
|
|
114
|
+
const t = tests.filter(t => t.duration != null).sort((a, b) => b.duration - a.duration)[0];
|
|
115
|
+
return t ? `{white-fg}${t.title}{/white-fg} {grey-fg}(${t.duration}ms){/grey-fg}` : 'N/A';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _fastest(tests) {
|
|
119
|
+
const t = tests.filter(t => t.duration != null).sort((a, b) => a.duration - b.duration)[0];
|
|
120
|
+
return t ? `{white-fg}${t.title}{/white-fg} {grey-fg}(${t.duration}ms){/grey-fg}` : 'N/A';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { createSuiteDetail, buildSuiteDetailLines, refreshSuiteDetail, moveCursor };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
|
|
5
|
+
function createTestDetail(screen) {
|
|
6
|
+
const widget = blessed.box({
|
|
7
|
+
top: '8%', left: '8%', width: '84%', height: '84%',
|
|
8
|
+
label: ' Test Failure Detail ',
|
|
9
|
+
border: { type: 'line' },
|
|
10
|
+
tags: true, scrollable: true, alwaysScroll: true,
|
|
11
|
+
keys: true, vi: true,
|
|
12
|
+
scrollbar: { ch: '|', style: { fg: 'red' } },
|
|
13
|
+
style: {
|
|
14
|
+
border: { fg: 'red' }, label: { fg: 'red', bold: true },
|
|
15
|
+
bg: '#0d0000', fg: 'white',
|
|
16
|
+
},
|
|
17
|
+
padding: { left: 2, right: 2, top: 1, bottom: 1 },
|
|
18
|
+
hidden: true,
|
|
19
|
+
});
|
|
20
|
+
screen.append(widget);
|
|
21
|
+
return widget;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function openTestDetail(widget, failure, stats) {
|
|
25
|
+
const rawMsg = (failure.messages || []).join('\n');
|
|
26
|
+
const expectedMatch = rawMsg.match(/Expected[:\s]+(.+)/);
|
|
27
|
+
const receivedMatch = rawMsg.match(/Received[:\s]+(.+)/);
|
|
28
|
+
const stackLines = rawMsg.split('\n')
|
|
29
|
+
.filter(l => l.trim().startsWith('at '))
|
|
30
|
+
.map(l => l.trim());
|
|
31
|
+
|
|
32
|
+
const lines = [
|
|
33
|
+
`{red-fg}{bold}FAILED: ${failure.suiteName} > ${failure.title}{/bold}{/red-fg}`,
|
|
34
|
+
'',
|
|
35
|
+
`{yellow-fg}Suite :{/yellow-fg} ${failure.suiteName}`,
|
|
36
|
+
`{yellow-fg}Test :{/yellow-fg} ${failure.title}`,
|
|
37
|
+
`{yellow-fg}Duration:{/yellow-fg} ${failure.duration != null ? failure.duration + 'ms' : 'N/A'}`,
|
|
38
|
+
'',
|
|
39
|
+
'{cyan-fg}── Error Message ──────────────────────────────────────────────────{/cyan-fg}',
|
|
40
|
+
'',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
if (expectedMatch) lines.push(` {green-fg}Expected:{/green-fg} ${expectedMatch[1].trim()}`);
|
|
44
|
+
if (receivedMatch) lines.push(` {red-fg}Received:{/red-fg} ${receivedMatch[1].trim()}`);
|
|
45
|
+
|
|
46
|
+
lines.push('');
|
|
47
|
+
rawMsg.split('\n').slice(0, 15).forEach(l =>
|
|
48
|
+
lines.push(` ${l.replace(/\{/g, '(').replace(/\}/g, ')')}`)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
lines.push(
|
|
52
|
+
'',
|
|
53
|
+
'{cyan-fg}── Stack Trace ─────────────────────────────────────────────────────{/cyan-fg}',
|
|
54
|
+
'',
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (stackLines.length > 0) {
|
|
58
|
+
const firstUser = stackLines.findIndex(l => !l.includes('node_modules'));
|
|
59
|
+
stackLines.forEach((l, i) => {
|
|
60
|
+
if (i === firstUser) lines.push(` {yellow-fg}> ${l}{/yellow-fg}`);
|
|
61
|
+
else lines.push(` {grey-fg}${l}{/grey-fg}`);
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(' {grey-fg}(no stack trace){/grey-fg}');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
lines.push(
|
|
68
|
+
'',
|
|
69
|
+
'{cyan-fg}── Run Metrics ─────────────────────────────────────────────────────{/cyan-fg}',
|
|
70
|
+
'',
|
|
71
|
+
` {white-fg}Passed :{/white-fg} {green-fg}${stats.passed}{/green-fg}`,
|
|
72
|
+
` {white-fg}Failed :{/white-fg} {red-fg}${stats.failed}{/red-fg}`,
|
|
73
|
+
` {white-fg}Skipped :{/white-fg} {yellow-fg}${stats.skipped}{/yellow-fg}`,
|
|
74
|
+
` {white-fg}Elapsed :{/white-fg} ${((Date.now() - stats.startTime) / 1000).toFixed(1)}s`,
|
|
75
|
+
'',
|
|
76
|
+
'{grey-fg} [Esc] back [j/k] scroll{/grey-fg}',
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
widget.setLabel(` Test Failure: ${failure.title} `);
|
|
80
|
+
widget.setContent(lines.join('\n'));
|
|
81
|
+
widget.scrollTo(0);
|
|
82
|
+
widget.show();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { createTestDetail, openTestDetail };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
const { SPINNER } = require('../../constants');
|
|
5
|
+
|
|
6
|
+
function createFooter(screen) {
|
|
7
|
+
const widget = blessed.box({
|
|
8
|
+
bottom: 0, left: 0, width: '100%', height: 3,
|
|
9
|
+
tags: true,
|
|
10
|
+
style: { fg: 'white', bg: '#111133' },
|
|
11
|
+
});
|
|
12
|
+
screen.append(widget);
|
|
13
|
+
return widget;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function updateFooter(widget, state) {
|
|
17
|
+
const elapsed = ((Date.now() - state.stats.startTime) / 1000).toFixed(1);
|
|
18
|
+
const suiteStr = `${state.stats.suitesCompleted}/${state.stats.suites} suites`;
|
|
19
|
+
const spin = SPINNER[state.spinFrame];
|
|
20
|
+
const running = Object.values(state.suites).filter(s => !s.done).length;
|
|
21
|
+
const runningStr = running > 0 ? ` {yellow-fg}${spin} ${running} running{/yellow-fg} |` : '';
|
|
22
|
+
const hint = _getHint(state);
|
|
23
|
+
|
|
24
|
+
widget.setContent(
|
|
25
|
+
`{center}{grey-fg} Time: ${elapsed}s |${runningStr} ${suiteStr} | ${hint} | [q] quit {/grey-fg}{/center}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _getHint(state) {
|
|
30
|
+
if (state.testDetailOpen) {
|
|
31
|
+
return '{cyan-fg}[Esc]{/cyan-fg} close | {cyan-fg}[j/k]{/cyan-fg} scroll';
|
|
32
|
+
}
|
|
33
|
+
if (state.suiteDetailOpen) {
|
|
34
|
+
return '{cyan-fg}[j/k]{/cyan-fg} navigate failed tests | {cyan-fg}[Enter]{/cyan-fg} open failure | {cyan-fg}[Esc]{/cyan-fg} back';
|
|
35
|
+
}
|
|
36
|
+
if (state.focus === 'results') {
|
|
37
|
+
return '{cyan-fg}[Tab]{/cyan-fg} switch panel | {cyan-fg}[Enter]{/cyan-fg} open failure';
|
|
38
|
+
}
|
|
39
|
+
return '{cyan-fg}[Tab]{/cyan-fg} switch panel | {cyan-fg}[Enter]{/cyan-fg} open suite';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { createFooter, updateFooter };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
|
|
5
|
+
function createHeader(screen) {
|
|
6
|
+
const widget = blessed.box({
|
|
7
|
+
top: 0, left: 0, width: '100%', height: 3,
|
|
8
|
+
content: '{center}{bold} JEST TEST DASHBOARD {/bold}{/center}',
|
|
9
|
+
tags: true,
|
|
10
|
+
style: { fg: 'white', bg: 'blue', bold: true },
|
|
11
|
+
});
|
|
12
|
+
screen.append(widget);
|
|
13
|
+
return widget;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function updateHeader(widget, ok) {
|
|
17
|
+
widget.style.bg = ok ? 'green' : 'red';
|
|
18
|
+
widget.setContent(
|
|
19
|
+
`{center}{bold} ${ok ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'} {/bold}{/center}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { createHeader, updateHeader };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
|
|
5
|
+
function createProgress(screen) {
|
|
6
|
+
const widget = blessed.box({
|
|
7
|
+
top: 5, left: 0, width: '100%', height: 2,
|
|
8
|
+
tags: true,
|
|
9
|
+
style: { fg: 'white', bg: '#111133' },
|
|
10
|
+
});
|
|
11
|
+
screen.append(widget);
|
|
12
|
+
return widget;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function updateProgress(widget, stats, screenWidth) {
|
|
16
|
+
const done = stats.passed + stats.failed + stats.skipped;
|
|
17
|
+
const total = stats.expectedTotal || done || 1;
|
|
18
|
+
const pct = Math.min(100, Math.round((done / total) * 100));
|
|
19
|
+
const bar = _buildBar(pct, screenWidth, stats.failed > 0);
|
|
20
|
+
widget.setContent(` Progress: ${bar} ${pct}%`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _buildBar(pct, screenWidth, hasFailed) {
|
|
24
|
+
const w = Math.max(20, (screenWidth || 80) - 20);
|
|
25
|
+
const filled = Math.round((pct / 100) * w);
|
|
26
|
+
const bar = '#'.repeat(filled) + '-'.repeat(w - filled);
|
|
27
|
+
const color = hasFailed ? 'red' : 'green';
|
|
28
|
+
return `{${color}-fg}${bar}{/${color}-fg}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { createProgress, updateProgress };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
|
|
5
|
+
function createResults(screen) {
|
|
6
|
+
const widget = blessed.list({
|
|
7
|
+
top: 7, left: 0, width: '65%', bottom: 3,
|
|
8
|
+
label: ' Test Results ',
|
|
9
|
+
border: { type: 'line' },
|
|
10
|
+
tags: true, scrollable: true, alwaysScroll: true,
|
|
11
|
+
keys: false, mouse: false,
|
|
12
|
+
scrollbar: { ch: '|', style: { fg: 'cyan' } },
|
|
13
|
+
style: {
|
|
14
|
+
border: { fg: 'cyan' }, label: { fg: 'cyan', bold: true },
|
|
15
|
+
bg: '#0a0a1a', item: { fg: 'white' },
|
|
16
|
+
},
|
|
17
|
+
padding: { left: 1, right: 1 },
|
|
18
|
+
});
|
|
19
|
+
screen.append(widget);
|
|
20
|
+
return widget;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function refreshResults(widget, state) {
|
|
24
|
+
const items = state.resultLines.map((line, i) => {
|
|
25
|
+
if (state.focus === 'results' && i === state.resultCursor) {
|
|
26
|
+
return `{#1a1a4a-bg}${line}{/#1a1a4a-bg}`;
|
|
27
|
+
}
|
|
28
|
+
return line;
|
|
29
|
+
});
|
|
30
|
+
widget.setItems(items);
|
|
31
|
+
if (state.focus === 'results' && state.resultCursor >= 0) {
|
|
32
|
+
widget.scrollTo(state.resultCursor);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function updateBorder(widget, focused) {
|
|
37
|
+
widget.style.border.fg = focused ? 'white' : 'cyan';
|
|
38
|
+
widget.setLabel(focused ? ' Test Results [FOCUSED] ' : ' Test Results ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { createResults, refreshResults, updateBorder };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
|
|
5
|
+
function createStats(screen) {
|
|
6
|
+
const widget = blessed.box({
|
|
7
|
+
top: 3, left: 0, width: '100%', height: 2,
|
|
8
|
+
tags: true,
|
|
9
|
+
style: { fg: 'white', bg: '#111133' },
|
|
10
|
+
});
|
|
11
|
+
screen.append(widget);
|
|
12
|
+
return widget;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function updateStats(widget, stats) {
|
|
16
|
+
const { passed, failed, skipped, total } = stats;
|
|
17
|
+
widget.setContent(
|
|
18
|
+
'{center}' +
|
|
19
|
+
`{green-fg}{bold} PASSED: ${passed} {/bold}{/green-fg}` +
|
|
20
|
+
`{red-fg}{bold} FAILED: ${failed} {/bold}{/red-fg}` +
|
|
21
|
+
`{yellow-fg}{bold} SKIPPED: ${skipped} {/bold}{/yellow-fg}` +
|
|
22
|
+
`{white-fg} TOTAL: ${total} {/white-fg}` +
|
|
23
|
+
'{/center}'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { createStats, updateStats };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
const { SPINNER } = require('../../constants');
|
|
5
|
+
|
|
6
|
+
function createSuites(screen) {
|
|
7
|
+
const widget = blessed.list({
|
|
8
|
+
top: 7, right: 0, width: '35%', bottom: 3,
|
|
9
|
+
label: ' Suites ',
|
|
10
|
+
border: { type: 'line' },
|
|
11
|
+
tags: true, scrollable: true, alwaysScroll: true,
|
|
12
|
+
keys: false, mouse: false,
|
|
13
|
+
scrollbar: { ch: '|', style: { fg: 'magenta' } },
|
|
14
|
+
style: {
|
|
15
|
+
border: { fg: 'magenta' }, label: { fg: 'magenta', bold: true },
|
|
16
|
+
bg: '#0a0a1a', item: { fg: 'white' },
|
|
17
|
+
},
|
|
18
|
+
padding: { left: 1, right: 1 },
|
|
19
|
+
});
|
|
20
|
+
screen.append(widget);
|
|
21
|
+
return widget;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function updateSuites(widget, state) {
|
|
25
|
+
const spin = SPINNER[state.spinFrame];
|
|
26
|
+
const items = state.suiteOrder.map((path, i) => {
|
|
27
|
+
const s = state.suites[path];
|
|
28
|
+
const name = path.split('/').pop().replace(/\.test\.[jt]sx?$/, '');
|
|
29
|
+
const elapsed = s.startTime ? ` ${((Date.now() - s.startTime) / 1000).toFixed(1)}s` : '';
|
|
30
|
+
|
|
31
|
+
let icon, detail;
|
|
32
|
+
if (s.done) {
|
|
33
|
+
icon = s.failed > 0 ? '{red-fg}FAIL{/red-fg}' : '{green-fg}PASS{/green-fg}';
|
|
34
|
+
detail = ` {grey-fg}[${s.passed}p ${s.failed}f]{/grey-fg}`;
|
|
35
|
+
} else {
|
|
36
|
+
icon = `{yellow-fg}${spin}{/yellow-fg}`;
|
|
37
|
+
detail = `{grey-fg}${elapsed} [${s.passed}p ${s.failed}f]{/grey-fg}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const runningLines = s.running && s.running.size > 0
|
|
41
|
+
? '\n' + [...s.running].map(t => ` {cyan-fg}> ${t}{/cyan-fg}`).join('\n')
|
|
42
|
+
: '';
|
|
43
|
+
|
|
44
|
+
const isCursor = state.focus === 'suites' && i === state.suiteCursor;
|
|
45
|
+
const bg = isCursor ? '{#1a0a3a-bg}' : '';
|
|
46
|
+
const bgEnd = isCursor ? '{/#1a0a3a-bg}' : '';
|
|
47
|
+
const hint = isCursor ? ' {cyan-fg}[Enter]{/cyan-fg}' : '';
|
|
48
|
+
|
|
49
|
+
return `${bg}${icon} {white-fg}${name}{/white-fg}${detail}${hint}${bgEnd}${runningLines}`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
widget.setItems(items.length ? items : ['{grey-fg}waiting...{/grey-fg}']);
|
|
53
|
+
if (state.focus === 'suites' && state.suiteCursor >= 0) {
|
|
54
|
+
widget.scrollTo(state.suiteCursor);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function updateBorder(widget, focused) {
|
|
59
|
+
widget.style.border.fg = focused ? 'white' : 'magenta';
|
|
60
|
+
widget.setLabel(focused ? ' Suites [FOCUSED] ' : ' Suites ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { createSuites, updateSuites, updateBorder };
|
package/lib/ui/screen.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
const { SPINNER } = require('../constants');
|
|
5
|
+
const { createHeader, updateHeader } = require('./panels/header');
|
|
6
|
+
const { createStats, updateStats } = require('./panels/stats');
|
|
7
|
+
const { createProgress, updateProgress } = require('./panels/progress');
|
|
8
|
+
const { createResults, refreshResults, updateBorder: updateResultsBorder } = require('./panels/results');
|
|
9
|
+
const { createSuites, updateSuites, updateBorder: updateSuitesBorder } = require('./panels/suites');
|
|
10
|
+
const { createFooter, updateFooter } = require('./panels/footer');
|
|
11
|
+
const { createSuiteDetail } = require('./overlays/suiteDetail');
|
|
12
|
+
const { createTestDetail } = require('./overlays/testDetail');
|
|
13
|
+
const { bindKeys, refreshOpenSuiteDetail } = require('./keys');
|
|
14
|
+
|
|
15
|
+
function createScreen(state) {
|
|
16
|
+
const screen = blessed.screen({
|
|
17
|
+
smartCSR: true,
|
|
18
|
+
title: 'Jest Dashboard',
|
|
19
|
+
fullUnicode: false,
|
|
20
|
+
warnings: false,
|
|
21
|
+
terminal: 'xterm-256color',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const widgets = {
|
|
25
|
+
header: createHeader(screen),
|
|
26
|
+
stats: createStats(screen),
|
|
27
|
+
progress: createProgress(screen),
|
|
28
|
+
results: createResults(screen),
|
|
29
|
+
suites: createSuites(screen),
|
|
30
|
+
footer: createFooter(screen),
|
|
31
|
+
suiteDetail: createSuiteDetail(screen),
|
|
32
|
+
testDetail: createTestDetail(screen),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// render helpers passed to key bindings
|
|
36
|
+
const render = {
|
|
37
|
+
all: () => renderAll(screen, widgets, state),
|
|
38
|
+
destroy: () => {
|
|
39
|
+
if (state._ticker) clearInterval(state._ticker);
|
|
40
|
+
screen.destroy();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
bindKeys(screen, widgets, state, render);
|
|
45
|
+
|
|
46
|
+
// animation ticker
|
|
47
|
+
state._ticker = setInterval(() => {
|
|
48
|
+
state.spinFrame = (state.spinFrame + 1) % SPINNER.length;
|
|
49
|
+
updateSuites(widgets.suites, state);
|
|
50
|
+
updateFooter(widgets.footer, state);
|
|
51
|
+
screen.render();
|
|
52
|
+
}, 120);
|
|
53
|
+
|
|
54
|
+
renderAll(screen, widgets, state);
|
|
55
|
+
|
|
56
|
+
return { screen, widgets, renderAll: () => renderAll(screen, widgets, state), refreshOpenSuiteDetail: () => refreshOpenSuiteDetail(widgets, state) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderAll(screen, widgets, state) {
|
|
60
|
+
updateStats(widgets.stats, state.stats);
|
|
61
|
+
updateProgress(widgets.progress, state.stats, screen.width);
|
|
62
|
+
updateSuites(widgets.suites, state);
|
|
63
|
+
updateFooter(widgets.footer, state);
|
|
64
|
+
_updateBorders(widgets, state);
|
|
65
|
+
refreshResults(widgets.results, state);
|
|
66
|
+
screen.render();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _updateBorders(widgets, state) {
|
|
70
|
+
updateResultsBorder(widgets.results, state.focus === 'results');
|
|
71
|
+
updateSuitesBorder(widgets.suites, state.focus === 'suites');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { createScreen };
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jestronaut",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An interactive terminal dashboard UI for Jest — navigate live test results, suites, and failure details without leaving your terminal",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"jestronaut": "bin/jestronaut.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"jest",
|
|
11
|
+
"reporter",
|
|
12
|
+
"dashboard",
|
|
13
|
+
"tui",
|
|
14
|
+
"terminal",
|
|
15
|
+
"blessed",
|
|
16
|
+
"jestronaut"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=16"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"blessed": "^0.1.81"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"jest": ">=27"
|
|
27
|
+
}
|
|
28
|
+
}
|