jestronaut 0.2.0 → 0.3.16
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/LICENSE +21 -0
- package/README.md +6 -2
- package/bin/jestronaut.js +21 -27
- package/index.js +1 -3
- package/lib/constants.js +1 -5
- package/lib/reporter.js +34 -85
- package/lib/state.js +21 -38
- package/lib/ui/app.js +20 -0
- package/lib/ui/components/Dashboard.js +179 -0
- package/lib/ui/components/Footer.js +33 -0
- package/lib/ui/components/Header.js +19 -0
- package/lib/ui/components/Progress.js +26 -0
- package/lib/ui/components/ResultsList.js +26 -0
- package/lib/ui/components/ScrollableBox.js +20 -0
- package/lib/ui/components/ScrollableList.js +30 -0
- package/lib/ui/components/Stats.js +13 -0
- package/lib/ui/components/SuiteDetailOverlay.js +111 -0
- package/lib/ui/components/SuitesList.js +37 -0
- package/lib/ui/components/TestDetailOverlay.js +75 -0
- package/lib/ui/store.js +18 -0
- package/package.json +8 -5
- package/lib/ui/keys.js +0 -140
- package/lib/ui/overlays/suiteDetail.js +0 -123
- package/lib/ui/overlays/testDetail.js +0 -86
- package/lib/ui/panels/footer.js +0 -46
- package/lib/ui/panels/header.js +0 -23
- package/lib/ui/panels/progress.js +0 -30
- package/lib/ui/panels/results.js +0 -41
- package/lib/ui/panels/stats.js +0 -27
- package/lib/ui/panels/suites.js +0 -66
- package/lib/ui/screen.js +0 -100
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deep Nandi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
An interactive terminal dashboard for Jest — navigate live test results, suites, and failure stack traces without leaving your terminal.
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/jestronaut)
|
|
5
6
|

|
|
6
7
|

|
|
7
|
-

|
|
8
9
|
|
|
9
10
|
## Screenshots
|
|
10
11
|
|
|
11
12
|
### Main Dashboard
|
|
12
13
|

|
|
13
14
|
|
|
15
|
+
### Watch Mode
|
|
16
|
+

|
|
17
|
+
|
|
14
18
|
### Suite Detail
|
|
15
19
|

|
|
16
20
|
|
|
@@ -104,7 +108,7 @@ npm test
|
|
|
104
108
|
|
|
105
109
|
## Requirements
|
|
106
110
|
|
|
107
|
-
- Node >=
|
|
111
|
+
- Node >= 18
|
|
108
112
|
- Jest >= 27
|
|
109
113
|
|
|
110
114
|
## License
|
package/bin/jestronaut.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
2
|
|
|
4
3
|
// Intercept stdout/stderr before Jest loads anything so its early
|
|
5
4
|
// output ("Determining test suites...") doesn't bleed into the TUI.
|
|
@@ -47,43 +46,38 @@ process.stderr.write = (chunk, enc, cb) => {
|
|
|
47
46
|
// Clear terminal before handing off to Jest
|
|
48
47
|
realStdout('\x1b[2J\x1b[H');
|
|
49
48
|
|
|
50
|
-
// Block Jest's watch mode from
|
|
51
|
-
//
|
|
52
|
-
// We block ALL subsequent calls after that so Jest can't interfere.
|
|
49
|
+
// Block Jest's watch mode from turning off raw mode while the TUI is active.
|
|
50
|
+
// ink manages raw mode itself — we only prevent Jest from disabling it mid-run.
|
|
53
51
|
const realSetRawMode = process.stdin.setRawMode && process.stdin.setRawMode.bind(process.stdin);
|
|
54
52
|
if (realSetRawMode) {
|
|
55
|
-
let rawModeSet = false;
|
|
56
53
|
process.stdin.setRawMode = (val) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
// Allow enabling raw mode always (ink may call this multiple times).
|
|
55
|
+
// Block disabling raw mode when the TUI is holding input focus.
|
|
56
|
+
if (!val && global.__jestronaut_block_jest_input__) {
|
|
57
|
+
return process.stdin;
|
|
60
58
|
}
|
|
61
|
-
|
|
62
|
-
// allow turning off (e.g. on exit)
|
|
63
|
-
rawModeSet = false;
|
|
64
|
-
return realSetRawMode(val);
|
|
65
|
-
}
|
|
66
|
-
return process.stdin;
|
|
59
|
+
return realSetRawMode(val);
|
|
67
60
|
};
|
|
68
61
|
}
|
|
69
62
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
63
|
+
// Gate Jest's stdin keypresses using EventEmitter.prototype.emit patch.
|
|
64
|
+
// This is more reliable than patching stdin.on because Node's stream internals
|
|
65
|
+
// may bypass a patched .on() after setEncoding() is called.
|
|
72
66
|
const realEmit = process.stdin.emit.bind(process.stdin);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return realEmit(event, ...args);
|
|
67
|
+
global.__jestronaut_emit__ = realEmit;
|
|
68
|
+
|
|
69
|
+
const _origEmit = process.stdin.emit;
|
|
70
|
+
process.stdin.emit = function(event, ...args) {
|
|
71
|
+
if (event === 'data' && global.__jestronaut_block_jest_input__) return true;
|
|
72
|
+
return _origEmit.apply(this, [event, ...args]);
|
|
80
73
|
};
|
|
81
74
|
|
|
82
75
|
// Global contract for watch mode:
|
|
83
|
-
// __jestronaut_ui__
|
|
84
|
-
//
|
|
85
|
-
//
|
|
76
|
+
// __jestronaut_ui__ — { store, unmount } created once, reused across re-runs
|
|
77
|
+
// __jestronaut_block_jest_input__ — boolean; true when TUI holds input focus
|
|
78
|
+
// __jestronaut_jest_keypress__ — Jest's raw onKeypress listener (unwrapped)
|
|
79
|
+
// __jestronaut_emit__ — original process.stdin.emit before any patching
|
|
86
80
|
|
|
87
81
|
// Forward all CLI args so flags like --testPathPattern, --watch etc. still work
|
|
88
|
-
const { run } =
|
|
82
|
+
const { run } = await import('jest');
|
|
89
83
|
run();
|
package/index.js
CHANGED
package/lib/constants.js
CHANGED
package/lib/reporter.js
CHANGED
|
@@ -1,52 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
import { createApp } from './ui/app.js';
|
|
2
|
+
import { buildSuiteDetailItems } from './ui/components/SuiteDetailOverlay.js';
|
|
2
3
|
|
|
3
|
-
|
|
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 {
|
|
4
|
+
export default class DashboardReporter {
|
|
11
5
|
constructor(globalConfig) {
|
|
12
6
|
this._globalConfig = globalConfig;
|
|
13
7
|
const watchMode = globalConfig.watch || globalConfig.watchAll || false;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} else {
|
|
20
|
-
this._state = createState();
|
|
21
|
-
this._state.watchMode = watchMode;
|
|
22
|
-
this._state.watchWaiting = watchMode;
|
|
23
|
-
}
|
|
24
|
-
const beforeScreen = new Set(process.stdin.listeners('data'));
|
|
25
|
-
const { screen, widgets, renderAll, refreshOpenSuiteDetail, startTicker } = createScreen(this._state);
|
|
26
|
-
// Only set blessed listeners once — on first construction when blessed registers them
|
|
27
|
-
if (!global.__jestronaut_blessed_listeners__) {
|
|
28
|
-
global.__jestronaut_blessed_listeners__ = new Set(
|
|
29
|
-
process.stdin.listeners('data').filter(l => !beforeScreen.has(l))
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
this._screen = screen;
|
|
33
|
-
this._widgets = widgets;
|
|
34
|
-
this._renderAll = renderAll;
|
|
35
|
-
this._refreshOpenSuiteDetail = refreshOpenSuiteDetail;
|
|
36
|
-
this._startTicker = startTicker;
|
|
8
|
+
const ui = createApp();
|
|
9
|
+
this._store = ui.store;
|
|
10
|
+
this._state = ui.store.state;
|
|
11
|
+
this._state.watchMode = watchMode;
|
|
12
|
+
this._state.watchWaiting = watchMode;
|
|
37
13
|
}
|
|
38
14
|
|
|
39
15
|
onRunStart(results) {
|
|
16
|
+
this._store.reset();
|
|
40
17
|
const s = this._state;
|
|
41
|
-
resetState(s);
|
|
42
18
|
s.watchWaiting = false;
|
|
43
|
-
this._widgets.suiteDetail.hide();
|
|
44
|
-
this._widgets.testDetail.hide();
|
|
45
|
-
this._startTicker();
|
|
46
19
|
s.stats.suites = results.numTotalTestSuites;
|
|
47
|
-
s.stats.expectedTotal = 0;
|
|
48
20
|
s.stats.startTime = Date.now();
|
|
49
|
-
this.
|
|
21
|
+
this._store.notify();
|
|
50
22
|
}
|
|
51
23
|
|
|
52
24
|
onTestFileStart(test) {
|
|
@@ -56,12 +28,17 @@ class DashboardReporter {
|
|
|
56
28
|
running: new Set(), tests: [],
|
|
57
29
|
};
|
|
58
30
|
this._state.suiteOrder.push(test.path);
|
|
59
|
-
|
|
31
|
+
// Keep suite count in sync in case onRunStart received 0 (Jest discovers tests lazily)
|
|
32
|
+
if (this._state.suiteOrder.length > this._state.stats.suites) {
|
|
33
|
+
this._state.stats.suites = this._state.suiteOrder.length;
|
|
34
|
+
}
|
|
35
|
+
this._store.notify();
|
|
60
36
|
}
|
|
61
37
|
|
|
62
38
|
onTestCaseStart(test, info) {
|
|
63
39
|
const suite = this._state.suites[test.path];
|
|
64
40
|
if (suite && info) suite.running.add(info.fullName || info.title);
|
|
41
|
+
this._store.notify();
|
|
65
42
|
}
|
|
66
43
|
|
|
67
44
|
onTestCaseResult(test, r) {
|
|
@@ -73,52 +50,29 @@ class DashboardReporter {
|
|
|
73
50
|
|
|
74
51
|
s.stats.total++;
|
|
75
52
|
if (suite.running) suite.running.delete(r.fullName || r.title);
|
|
76
|
-
|
|
77
|
-
suite.tests.push({
|
|
78
|
-
title: r.title,
|
|
79
|
-
status: r.status,
|
|
80
|
-
duration: r.duration,
|
|
81
|
-
messages: r.failureMessages || [],
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
let icon, textColor;
|
|
53
|
+
suite.tests.push({ title: r.title, status: r.status, duration: r.duration, messages: r.failureMessages || [] });
|
|
85
54
|
|
|
86
55
|
if (r.status === 'passed') {
|
|
87
56
|
s.stats.passed++;
|
|
88
57
|
suite.passed++;
|
|
89
|
-
icon = '{green-fg}PASS{/green-fg}';
|
|
90
|
-
textColor = 'white-fg';
|
|
91
58
|
s.resultMeta.push({ status: 'passed', failureIndex: -1 });
|
|
59
|
+
s.resultItems.push({ icon: 'PASS', iconColor: 'green', ancestor: r.ancestorTitles.join(' > '), title: r.title, titleColor: 'white', duration: r.duration ?? null, isFailed: false });
|
|
92
60
|
} else if (r.status === 'failed') {
|
|
93
61
|
s.stats.failed++;
|
|
94
62
|
suite.failed++;
|
|
95
|
-
icon = '{red-fg}FAIL{/red-fg}';
|
|
96
|
-
textColor = 'red-fg';
|
|
97
63
|
const failureIndex = s.failures.length;
|
|
98
|
-
s.failures.push({
|
|
99
|
-
title: r.title,
|
|
100
|
-
suiteName: r.ancestorTitles.join(' > '),
|
|
101
|
-
messages: r.failureMessages || [],
|
|
102
|
-
duration: r.duration,
|
|
103
|
-
});
|
|
64
|
+
s.failures.push({ title: r.title, suiteName: r.ancestorTitles.join(' > '), messages: r.failureMessages || [], duration: r.duration });
|
|
104
65
|
s.resultMeta.push({ status: 'failed', failureIndex });
|
|
66
|
+
s.resultItems.push({ icon: 'FAIL', iconColor: 'red', ancestor: r.ancestorTitles.join(' > '), title: r.title, titleColor: 'red', duration: r.duration ?? null, isFailed: true });
|
|
105
67
|
} else {
|
|
106
68
|
s.stats.skipped++;
|
|
107
|
-
icon = '{yellow-fg}SKIP{/yellow-fg}';
|
|
108
|
-
textColor = 'yellow-fg';
|
|
109
69
|
s.resultMeta.push({ status: r.status, failureIndex: -1 });
|
|
70
|
+
s.resultItems.push({ icon: 'SKIP', iconColor: 'yellow', ancestor: r.ancestorTitles.join(' > '), title: r.title, titleColor: 'yellow', duration: r.duration ?? null, isFailed: false });
|
|
110
71
|
}
|
|
111
72
|
|
|
112
73
|
s.suites[test.path] = suite;
|
|
113
|
-
|
|
114
|
-
const ms = r.duration != null ? ` {grey-fg}(${r.duration}ms){/grey-fg}` : '';
|
|
115
|
-
const ancestor = r.ancestorTitles.join(' > ');
|
|
116
|
-
const prefix = ancestor ? `{grey-fg}${ancestor} >{/grey-fg} ` : '';
|
|
117
|
-
const hint = r.status === 'failed' ? ' {cyan-fg}[Enter]{/cyan-fg}' : '';
|
|
118
|
-
s.resultLines.push(`[${icon}] ${prefix}{${textColor}}${r.title}{/${textColor}}${ms}${hint}`);
|
|
119
|
-
|
|
120
74
|
this._refreshOpenSuiteDetail();
|
|
121
|
-
this.
|
|
75
|
+
this._store.notify();
|
|
122
76
|
}
|
|
123
77
|
|
|
124
78
|
onTestFileResult(test) {
|
|
@@ -130,28 +84,23 @@ class DashboardReporter {
|
|
|
130
84
|
this._state.stats.suitesCompleted++;
|
|
131
85
|
}
|
|
132
86
|
this._refreshOpenSuiteDetail();
|
|
133
|
-
this.
|
|
87
|
+
this._store.notify();
|
|
134
88
|
}
|
|
135
89
|
|
|
136
90
|
onRunComplete(_, results) {
|
|
137
91
|
const s = this._state;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const ok = results.numFailedTests === 0;
|
|
142
|
-
|
|
143
|
-
updateHeader(this._widgets.header, ok);
|
|
144
|
-
this._widgets.header.setContent(
|
|
145
|
-
`{center}{bold} ${ok ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'} — ${elapsed}s {/bold}{/center}`
|
|
146
|
-
);
|
|
147
|
-
|
|
92
|
+
s.runComplete = true;
|
|
93
|
+
s.runOk = results.numFailedTests === 0;
|
|
94
|
+
s.runElapsed = ((Date.now() - s.stats.startTime) / 1000).toFixed(2);
|
|
148
95
|
s.stats.endTime = Date.now();
|
|
149
96
|
if (s.watchMode) s.watchWaiting = true;
|
|
150
|
-
|
|
151
|
-
updateProgress(this._widgets.progress, s.stats, this._screen.width);
|
|
152
|
-
updateFooter(this._widgets.footer, s);
|
|
153
|
-
this._renderAll();
|
|
97
|
+
this._store.notify();
|
|
154
98
|
}
|
|
155
|
-
}
|
|
156
99
|
|
|
157
|
-
|
|
100
|
+
_refreshOpenSuiteDetail() {
|
|
101
|
+
const s = this._state;
|
|
102
|
+
if (!s.suiteDetailOpen || !s.suiteDetailPath) return;
|
|
103
|
+
const result = buildSuiteDetailItems(s.suites[s.suiteDetailPath], s.suiteDetailPath);
|
|
104
|
+
s.suiteDetailItems = result.items;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/lib/state.js
CHANGED
|
@@ -1,81 +1,64 @@
|
|
|
1
|
-
|
|
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() {
|
|
1
|
+
export function createState() {
|
|
7
2
|
return {
|
|
8
|
-
// run-level stats
|
|
9
3
|
stats: {
|
|
10
4
|
passed: 0,
|
|
11
5
|
failed: 0,
|
|
12
6
|
skipped: 0,
|
|
13
7
|
total: 0,
|
|
14
|
-
expectedTotal: 0,
|
|
15
8
|
suites: 0,
|
|
16
9
|
suitesCompleted: 0,
|
|
17
10
|
startTime: Date.now(),
|
|
18
11
|
endTime: null,
|
|
19
12
|
},
|
|
20
|
-
|
|
21
|
-
// suite data keyed by file path
|
|
22
13
|
suites: {},
|
|
23
14
|
suiteOrder: [],
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// all failure objects for detail view
|
|
30
|
-
failures: [], // { title, suiteName, messages, duration }
|
|
31
|
-
|
|
32
|
-
// panel focus
|
|
33
|
-
focus: 'results', // 'results' | 'suites'
|
|
15
|
+
resultItems: [], // [{ icon, iconColor, ancestor, title, titleColor, duration, isFailed }]
|
|
16
|
+
resultMeta: [], // [{ status, failureIndex }]
|
|
17
|
+
failures: [], // [{ title, suiteName, messages, duration }]
|
|
18
|
+
focus: 'results',
|
|
34
19
|
resultCursor: -1,
|
|
35
20
|
suiteCursor: 0,
|
|
36
|
-
|
|
37
|
-
// suite detail overlay
|
|
38
21
|
suiteDetailOpen: false,
|
|
39
22
|
suiteDetailPath: null,
|
|
40
|
-
|
|
41
|
-
suiteDetailMeta: [],
|
|
23
|
+
suiteDetailItems: [], // [{ type, text, color, failureObj }]
|
|
42
24
|
suiteDetailCursor: 0,
|
|
43
|
-
|
|
44
|
-
// test detail overlay
|
|
45
25
|
testDetailOpen: false,
|
|
46
|
-
|
|
47
|
-
|
|
26
|
+
testDetailFailure: null,
|
|
27
|
+
testDetailScrollOffset: 0,
|
|
28
|
+
runComplete: false,
|
|
29
|
+
runOk: false,
|
|
30
|
+
runElapsed: null,
|
|
48
31
|
spinFrame: 0,
|
|
49
|
-
|
|
50
|
-
// watch mode
|
|
51
32
|
watchMode: false,
|
|
52
33
|
watchWaiting: false,
|
|
53
34
|
};
|
|
54
35
|
}
|
|
55
36
|
|
|
56
|
-
function resetState(state) {
|
|
37
|
+
export function resetState(state) {
|
|
57
38
|
state.stats = {
|
|
58
39
|
passed: 0, failed: 0, skipped: 0,
|
|
59
|
-
total: 0,
|
|
40
|
+
total: 0,
|
|
60
41
|
suites: 0, suitesCompleted: 0,
|
|
61
42
|
startTime: Date.now(),
|
|
62
43
|
endTime: null,
|
|
63
44
|
};
|
|
64
45
|
state.suites = {};
|
|
65
46
|
state.suiteOrder = [];
|
|
66
|
-
state.
|
|
47
|
+
state.resultItems = [];
|
|
67
48
|
state.resultMeta = [];
|
|
68
49
|
state.failures = [];
|
|
69
50
|
state.resultCursor = -1;
|
|
70
51
|
state.suiteCursor = 0;
|
|
71
52
|
state.suiteDetailOpen = false;
|
|
72
53
|
state.suiteDetailPath = null;
|
|
73
|
-
state.
|
|
74
|
-
state.suiteDetailMeta = [];
|
|
54
|
+
state.suiteDetailItems = [];
|
|
75
55
|
state.suiteDetailCursor = 0;
|
|
76
56
|
state.testDetailOpen = false;
|
|
57
|
+
state.testDetailFailure = null;
|
|
58
|
+
state.testDetailScrollOffset = 0;
|
|
59
|
+
state.runComplete = false;
|
|
60
|
+
state.runOk = false;
|
|
61
|
+
state.runElapsed = null;
|
|
77
62
|
state.spinFrame = 0;
|
|
78
|
-
// watchMode and watchWaiting
|
|
63
|
+
// watchMode and watchWaiting preserved across resets
|
|
79
64
|
}
|
|
80
|
-
|
|
81
|
-
module.exports = { createState, resetState };
|
package/lib/ui/app.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { render } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { JestronautStore } from './store.js';
|
|
4
|
+
import { Dashboard } from './components/Dashboard.js';
|
|
5
|
+
|
|
6
|
+
export function createApp() {
|
|
7
|
+
if (global.__jestronaut_ui__) {
|
|
8
|
+
return global.__jestronaut_ui__;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const store = new JestronautStore();
|
|
12
|
+
const { unmount } = render(
|
|
13
|
+
React.createElement(Dashboard, { store }),
|
|
14
|
+
{ exitOnCtrlC: false }
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const ui = { store, unmount };
|
|
18
|
+
global.__jestronaut_ui__ = ui;
|
|
19
|
+
return ui;
|
|
20
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import React, { useState, useEffect, useReducer } from 'react';
|
|
2
|
+
import { Box, useInput, useApp, useStdout } from 'ink';
|
|
3
|
+
import { SPINNER } from '../../constants.js';
|
|
4
|
+
import { Header } from './Header.js';
|
|
5
|
+
import { Stats } from './Stats.js';
|
|
6
|
+
import { Progress } from './Progress.js';
|
|
7
|
+
import { ResultsList } from './ResultsList.js';
|
|
8
|
+
import { SuitesList } from './SuitesList.js';
|
|
9
|
+
import { Footer } from './Footer.js';
|
|
10
|
+
import { SuiteDetailOverlay, buildSuiteDetailItems, moveCursor as moveSuiteDetailCursor } from './SuiteDetailOverlay.js';
|
|
11
|
+
import { TestDetailOverlay, buildLines as buildTestDetailLines } from './TestDetailOverlay.js';
|
|
12
|
+
|
|
13
|
+
const FIXED_ROWS = 2 + 2 + 2 + 3; // header + stats + progress + footer
|
|
14
|
+
|
|
15
|
+
export function Dashboard({ store }) {
|
|
16
|
+
const { exit } = useApp();
|
|
17
|
+
const { stdout } = useStdout();
|
|
18
|
+
const [dims, setDims] = useState({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
19
|
+
const [, forceUpdate] = useReducer(x => x + 1, 0);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const onResize = () => setDims({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
23
|
+
stdout.on('resize', onResize);
|
|
24
|
+
return () => stdout.off('resize', onResize);
|
|
25
|
+
}, [stdout]);
|
|
26
|
+
|
|
27
|
+
const { columns, rows } = dims;
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
store.on('update', forceUpdate);
|
|
31
|
+
return () => store.off('update', forceUpdate);
|
|
32
|
+
}, [store]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
global.__jestronaut_block_jest_input__ = true;
|
|
36
|
+
return () => { global.__jestronaut_block_jest_input__ = false; };
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const id = setInterval(() => {
|
|
41
|
+
if (store.state.runComplete && !store.state.watchWaiting) return;
|
|
42
|
+
store.state.spinFrame = (store.state.spinFrame + 1) % SPINNER.length;
|
|
43
|
+
store.notify();
|
|
44
|
+
}, 120);
|
|
45
|
+
return () => clearInterval(id);
|
|
46
|
+
}, [store]);
|
|
47
|
+
|
|
48
|
+
const state = store.state;
|
|
49
|
+
|
|
50
|
+
useInput((input, key) => {
|
|
51
|
+
const s = store.state;
|
|
52
|
+
|
|
53
|
+
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
54
|
+
exit();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (input === 'a' && s.watchWaiting) {
|
|
59
|
+
// Briefly unblock, forward 'a' to Jest's watch listener, then re-block
|
|
60
|
+
global.__jestronaut_block_jest_input__ = false;
|
|
61
|
+
if (global.__jestronaut_emit__) global.__jestronaut_emit__('data', 'a');
|
|
62
|
+
global.__jestronaut_block_jest_input__ = true;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (key.tab && !s.suiteDetailOpen && !s.testDetailOpen) {
|
|
67
|
+
s.focus = s.focus === 'results' ? 'suites' : 'results';
|
|
68
|
+
store.notify();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (key.upArrow || input === 'k') {
|
|
73
|
+
if (s.testDetailOpen) {
|
|
74
|
+
s.testDetailScrollOffset = Math.max(0, (s.testDetailScrollOffset || 0) - 1);
|
|
75
|
+
} else if (s.suiteDetailOpen) {
|
|
76
|
+
moveSuiteDetailCursor(s, -1);
|
|
77
|
+
} else if (s.focus === 'results') {
|
|
78
|
+
if (!s.resultItems.length) return;
|
|
79
|
+
s.resultCursor = s.resultCursor <= 0 ? s.resultItems.length - 1 : s.resultCursor - 1;
|
|
80
|
+
} else {
|
|
81
|
+
if (!s.suiteOrder.length) return;
|
|
82
|
+
s.suiteCursor = s.suiteCursor <= 0 ? s.suiteOrder.length - 1 : s.suiteCursor - 1;
|
|
83
|
+
}
|
|
84
|
+
store.notify();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (key.downArrow || input === 'j') {
|
|
89
|
+
if (s.testDetailOpen) {
|
|
90
|
+
const totalLines = s.testDetailFailure ? buildTestDetailLines(s.testDetailFailure, s.stats).length : 0;
|
|
91
|
+
const innerHeight = Math.max(1, rows - 2);
|
|
92
|
+
const maxOffset = Math.max(0, totalLines - innerHeight);
|
|
93
|
+
s.testDetailScrollOffset = Math.min(maxOffset, (s.testDetailScrollOffset || 0) + 1);
|
|
94
|
+
} else if (s.suiteDetailOpen) {
|
|
95
|
+
moveSuiteDetailCursor(s, 1);
|
|
96
|
+
} else if (s.focus === 'results') {
|
|
97
|
+
if (!s.resultItems.length) return;
|
|
98
|
+
s.resultCursor = s.resultCursor >= s.resultItems.length - 1 ? 0 : s.resultCursor + 1;
|
|
99
|
+
} else {
|
|
100
|
+
if (!s.suiteOrder.length) return;
|
|
101
|
+
s.suiteCursor = s.suiteCursor >= s.suiteOrder.length - 1 ? 0 : s.suiteCursor + 1;
|
|
102
|
+
}
|
|
103
|
+
store.notify();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (key.return) {
|
|
108
|
+
if (s.testDetailOpen) return;
|
|
109
|
+
if (s.suiteDetailOpen) {
|
|
110
|
+
const item = s.suiteDetailItems[s.suiteDetailCursor];
|
|
111
|
+
if (item && item.type === 'test' && item.failureObj) {
|
|
112
|
+
s.testDetailOpen = true;
|
|
113
|
+
s.testDetailFailure = item.failureObj;
|
|
114
|
+
s.testDetailScrollOffset = 0;
|
|
115
|
+
store.notify();
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (s.focus === 'results') {
|
|
120
|
+
if (s.resultCursor < 0 || s.resultCursor >= s.resultMeta.length) return;
|
|
121
|
+
const meta = s.resultMeta[s.resultCursor];
|
|
122
|
+
if (meta.status !== 'failed') return;
|
|
123
|
+
s.testDetailOpen = true;
|
|
124
|
+
s.testDetailFailure = s.failures[meta.failureIndex];
|
|
125
|
+
s.testDetailScrollOffset = 0;
|
|
126
|
+
} else {
|
|
127
|
+
if (!s.suiteOrder.length) return;
|
|
128
|
+
const path = s.suiteOrder[s.suiteCursor];
|
|
129
|
+
if (!path) return;
|
|
130
|
+
const result = buildSuiteDetailItems(s.suites[path], path);
|
|
131
|
+
s.suiteDetailOpen = true;
|
|
132
|
+
s.suiteDetailPath = path;
|
|
133
|
+
s.suiteDetailItems = result.items;
|
|
134
|
+
s.suiteDetailCursor = result.items.findIndex(m => m.type === 'test' && m.failureObj);
|
|
135
|
+
if (s.suiteDetailCursor < 0) s.suiteDetailCursor = 0;
|
|
136
|
+
}
|
|
137
|
+
store.notify();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (key.escape) {
|
|
142
|
+
if (s.testDetailOpen) {
|
|
143
|
+
s.testDetailOpen = false;
|
|
144
|
+
s.testDetailFailure = null;
|
|
145
|
+
s.testDetailScrollOffset = 0;
|
|
146
|
+
} else if (s.suiteDetailOpen) {
|
|
147
|
+
s.suiteDetailOpen = false;
|
|
148
|
+
s.suiteDetailPath = null;
|
|
149
|
+
s.suiteDetailItems = [];
|
|
150
|
+
s.suiteDetailCursor = 0;
|
|
151
|
+
}
|
|
152
|
+
store.notify();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (state.testDetailOpen) {
|
|
157
|
+
return React.createElement(TestDetailOverlay, { state, rows });
|
|
158
|
+
}
|
|
159
|
+
if (state.suiteDetailOpen) {
|
|
160
|
+
return React.createElement(SuiteDetailOverlay, { state, rows });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const listHeight = Math.max(4, rows - FIXED_ROWS);
|
|
164
|
+
|
|
165
|
+
return React.createElement(
|
|
166
|
+
Box,
|
|
167
|
+
{ flexDirection: 'column', height: rows, width: columns },
|
|
168
|
+
React.createElement(Header, { state }),
|
|
169
|
+
React.createElement(Stats, { stats: state.stats }),
|
|
170
|
+
React.createElement(Progress, { stats: state.stats, width: columns }),
|
|
171
|
+
React.createElement(
|
|
172
|
+
Box,
|
|
173
|
+
{ flexDirection: 'row', height: listHeight },
|
|
174
|
+
React.createElement(Box, { width: '65%' }, React.createElement(ResultsList, { state, height: listHeight })),
|
|
175
|
+
React.createElement(Box, { width: '35%' }, React.createElement(SuitesList, { state, height: listHeight }))
|
|
176
|
+
),
|
|
177
|
+
React.createElement(Footer, { state })
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { SPINNER } from '../../constants.js';
|
|
4
|
+
|
|
5
|
+
export function Footer({ state }) {
|
|
6
|
+
const { stats, testDetailOpen, suiteDetailOpen, watchWaiting, spinFrame, suites } = state;
|
|
7
|
+
const elapsed = stats.startTime
|
|
8
|
+
? (((stats.endTime || Date.now()) - stats.startTime) / 1000).toFixed(1) + 's'
|
|
9
|
+
: '0.0s';
|
|
10
|
+
const runningCount = Object.values(suites).filter(s => !s.done).length;
|
|
11
|
+
const spin = SPINNER[spinFrame % SPINNER.length];
|
|
12
|
+
|
|
13
|
+
let hint;
|
|
14
|
+
if (testDetailOpen) hint = '[Esc] close [j/k] scroll';
|
|
15
|
+
else if (suiteDetailOpen) hint = '[j/k] navigate failed [Enter] open failure [Esc] back';
|
|
16
|
+
else if (watchWaiting) hint = '[a] run all tests [q] quit';
|
|
17
|
+
else hint = '[Tab] switch panel [j/k] navigate [Enter] open failure [q] quit';
|
|
18
|
+
|
|
19
|
+
const statusText = runningCount > 0
|
|
20
|
+
? `${spin} Running ${runningCount} suite${runningCount > 1 ? 's' : ''}...`
|
|
21
|
+
: `Elapsed: ${elapsed}`;
|
|
22
|
+
|
|
23
|
+
return React.createElement(
|
|
24
|
+
Box,
|
|
25
|
+
{ height: 3, backgroundColor: '#111133', flexDirection: 'column', justifyContent: 'center', paddingLeft: 2 },
|
|
26
|
+
React.createElement(
|
|
27
|
+
Box,
|
|
28
|
+
{ justifyContent: 'space-between' },
|
|
29
|
+
React.createElement(Text, { color: 'cyan' }, hint),
|
|
30
|
+
React.createElement(Text, { color: 'gray' }, statusText)
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
export function Header({ state }) {
|
|
5
|
+
const { runComplete, runOk, runElapsed } = state;
|
|
6
|
+
let content, bg;
|
|
7
|
+
if (runComplete) {
|
|
8
|
+
content = runOk ? `ALL TESTS PASSED — ${runElapsed}s` : `SOME TESTS FAILED — ${runElapsed}s`;
|
|
9
|
+
bg = runOk ? 'green' : 'red';
|
|
10
|
+
} else {
|
|
11
|
+
content = 'JEST TEST DASHBOARD';
|
|
12
|
+
bg = 'blue';
|
|
13
|
+
}
|
|
14
|
+
return React.createElement(
|
|
15
|
+
Box,
|
|
16
|
+
{ height: 2, backgroundColor: bg, justifyContent: 'center', alignItems: 'center' },
|
|
17
|
+
React.createElement(Text, { bold: true, color: 'white' }, content)
|
|
18
|
+
);
|
|
19
|
+
}
|