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 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
+ [![npm](https://img.shields.io/npm/v/jestronaut)](https://www.npmjs.com/package/jestronaut)
5
6
  ![License](https://img.shields.io/badge/license-MIT-blue)
6
7
  ![Jest](https://img.shields.io/badge/jest-%3E%3D27-orange)
7
- ![Node](https://img.shields.io/badge/node-%3E%3D16-green)
8
+ ![Node](https://img.shields.io/badge/node-%3E%3D18-green)
8
9
 
9
10
  ## Screenshots
10
11
 
11
12
  ### Main Dashboard
12
13
  ![Main Dashboard](assets/dashboard.png)
13
14
 
15
+ ### Watch Mode
16
+ ![Watch Mode](assets/watch-mode.png)
17
+
14
18
  ### Suite Detail
15
19
  ![Suite Detail](assets/suite-detail.png)
16
20
 
@@ -104,7 +108,7 @@ npm test
104
108
 
105
109
  ## Requirements
106
110
 
107
- - Node >= 16
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 stealing raw TTY control from blessed.
51
- // Blessed will call setRawMode itself when the screen is created in the reporter.
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
- if (!rawModeSet && val) {
58
- rawModeSet = true;
59
- return realSetRawMode(val);
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
- if (!val) {
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
- // Intercept stdin data events: when TUI is active, only dispatch to blessed's
71
- // listeners skip Jest's watch mode listener so it doesn't trigger a re-run.
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
- process.stdin.emit = (event, ...args) => {
74
- if (event === 'data' && global.__jestronaut_block_jest_input__ && global.__jestronaut_blessed_listeners__) {
75
- // Call only blessed's listeners, skip Jest's
76
- global.__jestronaut_blessed_listeners__.forEach(l => l(...args));
77
- return true;
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__ — { screen, widgets, startTicker, state } created once, reused across re-runs
84
- // __jestronaut_blessed_listeners__ Set of stdin 'data' listeners registered by blessed (set in reporter constructor)
85
- // __jestronaut_block_jest_input__ boolean; true when TUI should handle all input exclusively (set in renderAll)
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 } = require('jest');
82
+ const { run } = await import('jest');
89
83
  run();
package/index.js CHANGED
@@ -1,3 +1 @@
1
- 'use strict';
2
-
3
- module.exports = require('./lib/reporter');
1
+ export { default } from './lib/reporter.js';
package/lib/constants.js CHANGED
@@ -1,5 +1 @@
1
- 'use strict';
2
-
3
- const SPINNER = ['|', '/', '-', '\\'];
4
-
5
- module.exports = { SPINNER };
1
+ export const SPINNER = ['|', '/', '-', '\\'];
package/lib/reporter.js CHANGED
@@ -1,52 +1,24 @@
1
- 'use strict';
1
+ import { createApp } from './ui/app.js';
2
+ import { buildSuiteDetailItems } from './ui/components/SuiteDetailOverlay.js';
2
3
 
3
- const { createState, resetState } = 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 {
4
+ export default class DashboardReporter {
11
5
  constructor(globalConfig) {
12
6
  this._globalConfig = globalConfig;
13
7
  const watchMode = globalConfig.watch || globalConfig.watchAll || false;
14
- // On watch re-runs, reuse the shared state that key handlers reference
15
- if (global.__jestronaut_ui__) {
16
- this._state = global.__jestronaut_ui__.state;
17
- this._state.watchMode = watchMode;
18
- this._state.watchWaiting = watchMode;
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._renderAll();
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
- this._renderAll();
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._renderAll();
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._renderAll();
87
+ this._store.notify();
134
88
  }
135
89
 
136
90
  onRunComplete(_, results) {
137
91
  const s = this._state;
138
- if (s._ticker) { clearInterval(s._ticker); s._ticker = null; }
139
-
140
- const elapsed = ((Date.now() - s.stats.startTime) / 1000).toFixed(2);
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
- updateStats(this._widgets.stats, s.stats);
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
- module.exports = DashboardReporter;
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
- '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() {
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
- // flat list of all test results for the results panel
26
- resultLines: [], // display strings
27
- resultMeta: [], // { status, failureIndex }
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
- suiteDetailLines: [],
41
- suiteDetailMeta: [],
23
+ suiteDetailItems: [], // [{ type, text, color, failureObj }]
42
24
  suiteDetailCursor: 0,
43
-
44
- // test detail overlay
45
25
  testDetailOpen: false,
46
-
47
- // animation
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, expectedTotal: 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.resultLines = [];
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.suiteDetailLines = [];
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 are intentionally preserved across resets
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
+ }