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 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
+ ![License](https://img.shields.io/badge/license-MIT-blue)
6
+ ![Jest](https://img.shields.io/badge/jest-%3E%3D27-orange)
7
+ ![Node](https://img.shields.io/badge/node-%3E%3D16-green)
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
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('./lib/reporter');
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const SPINNER = ['|', '/', '-', '\\'];
4
+
5
+ module.exports = { SPINNER };
@@ -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 };
@@ -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
+ }