jestronaut 0.1.0 → 0.2.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 CHANGED
@@ -6,6 +6,17 @@ An interactive terminal dashboard for Jest — navigate live test results, suite
6
6
  ![Jest](https://img.shields.io/badge/jest-%3E%3D27-orange)
7
7
  ![Node](https://img.shields.io/badge/node-%3E%3D16-green)
8
8
 
9
+ ## Screenshots
10
+
11
+ ### Main Dashboard
12
+ ![Main Dashboard](assets/dashboard.png)
13
+
14
+ ### Suite Detail
15
+ ![Suite Detail](assets/suite-detail.png)
16
+
17
+ ### Failure Detail
18
+ ![Failure Detail](assets/failure-detail.png)
19
+
9
20
  ## Features
10
21
 
11
22
  - Live dashboard updates as tests run
package/bin/jestronaut.js CHANGED
@@ -14,6 +14,17 @@ const SUPPRESS = [
14
14
  'Jest did not exit',
15
15
  'detectOpenHandles',
16
16
  'asynchronous operations',
17
+ 'Watch Usage',
18
+ 'Press `',
19
+ 'Press a',
20
+ 'Press f',
21
+ 'Press p',
22
+ 'Press t',
23
+ 'Press q',
24
+ 'Press Enter',
25
+ 'No tests found',
26
+ 'ran all test suites',
27
+ 'Ran all test suites',
17
28
  ];
18
29
 
19
30
  function shouldSuppress(chunk) {
@@ -36,6 +47,43 @@ process.stderr.write = (chunk, enc, cb) => {
36
47
  // Clear terminal before handing off to Jest
37
48
  realStdout('\x1b[2J\x1b[H');
38
49
 
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.
53
+ const realSetRawMode = process.stdin.setRawMode && process.stdin.setRawMode.bind(process.stdin);
54
+ if (realSetRawMode) {
55
+ let rawModeSet = false;
56
+ process.stdin.setRawMode = (val) => {
57
+ if (!rawModeSet && val) {
58
+ rawModeSet = true;
59
+ return realSetRawMode(val);
60
+ }
61
+ if (!val) {
62
+ // allow turning off (e.g. on exit)
63
+ rawModeSet = false;
64
+ return realSetRawMode(val);
65
+ }
66
+ return process.stdin;
67
+ };
68
+ }
69
+
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.
72
+ 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);
80
+ };
81
+
82
+ // 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)
86
+
39
87
  // Forward all CLI args so flags like --testPathPattern, --watch etc. still work
40
88
  const { run } = require('jest');
41
89
  run();
package/lib/reporter.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { createState } = require('./state');
3
+ const { createState, resetState } = require('./state');
4
4
  const { createScreen } = require('./ui/screen');
5
5
  const { updateHeader } = require('./ui/panels/header');
6
6
  const { updateStats } = require('./ui/panels/stats');
@@ -10,18 +10,41 @@ const { updateFooter } = require('./ui/panels/footer');
10
10
  class DashboardReporter {
11
11
  constructor(globalConfig) {
12
12
  this._globalConfig = globalConfig;
13
- this._state = createState();
14
- const { screen, widgets, renderAll, refreshOpenSuiteDetail } = createScreen(this._state);
13
+ 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
+ }
15
32
  this._screen = screen;
16
33
  this._widgets = widgets;
17
34
  this._renderAll = renderAll;
18
35
  this._refreshOpenSuiteDetail = refreshOpenSuiteDetail;
36
+ this._startTicker = startTicker;
19
37
  }
20
38
 
21
39
  onRunStart(results) {
22
40
  const s = this._state;
41
+ resetState(s);
42
+ s.watchWaiting = false;
43
+ this._widgets.suiteDetail.hide();
44
+ this._widgets.testDetail.hide();
45
+ this._startTicker();
23
46
  s.stats.suites = results.numTotalTestSuites;
24
- s.stats.expectedTotal = results.numTotalTests || 0;
47
+ s.stats.expectedTotal = 0;
25
48
  s.stats.startTime = Date.now();
26
49
  this._renderAll();
27
50
  }
@@ -122,7 +145,8 @@ class DashboardReporter {
122
145
  `{center}{bold} ${ok ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'} — ${elapsed}s {/bold}{/center}`
123
146
  );
124
147
 
125
- s.stats.expectedTotal = s.stats.total;
148
+ s.stats.endTime = Date.now();
149
+ if (s.watchMode) s.watchWaiting = true;
126
150
  updateStats(this._widgets.stats, s.stats);
127
151
  updateProgress(this._widgets.progress, s.stats, this._screen.width);
128
152
  updateFooter(this._widgets.footer, s);
package/lib/state.js CHANGED
@@ -15,6 +15,7 @@ function createState() {
15
15
  suites: 0,
16
16
  suitesCompleted: 0,
17
17
  startTime: Date.now(),
18
+ endTime: null,
18
19
  },
19
20
 
20
21
  // suite data keyed by file path
@@ -45,7 +46,36 @@ function createState() {
45
46
 
46
47
  // animation
47
48
  spinFrame: 0,
49
+
50
+ // watch mode
51
+ watchMode: false,
52
+ watchWaiting: false,
53
+ };
54
+ }
55
+
56
+ function resetState(state) {
57
+ state.stats = {
58
+ passed: 0, failed: 0, skipped: 0,
59
+ total: 0, expectedTotal: 0,
60
+ suites: 0, suitesCompleted: 0,
61
+ startTime: Date.now(),
62
+ endTime: null,
48
63
  };
64
+ state.suites = {};
65
+ state.suiteOrder = [];
66
+ state.resultLines = [];
67
+ state.resultMeta = [];
68
+ state.failures = [];
69
+ state.resultCursor = -1;
70
+ state.suiteCursor = 0;
71
+ state.suiteDetailOpen = false;
72
+ state.suiteDetailPath = null;
73
+ state.suiteDetailLines = [];
74
+ state.suiteDetailMeta = [];
75
+ state.suiteDetailCursor = 0;
76
+ state.testDetailOpen = false;
77
+ state.spinFrame = 0;
78
+ // watchMode and watchWaiting are intentionally preserved across resets
49
79
  }
50
80
 
51
- module.exports = { createState };
81
+ module.exports = { createState, resetState };
package/lib/ui/keys.js CHANGED
@@ -119,6 +119,7 @@ function _openSuiteDetail(widgets, state, path) {
119
119
  widgets.suiteDetail.setLabel(result.label);
120
120
  widgets.suiteDetail.style.border.fg = result.hasFailed ? 'red' : 'green';
121
121
  widgets.suiteDetail.show();
122
+ widgets.suiteDetail.setFront();
122
123
  suiteDetailOverlay.refreshSuiteDetail(widgets.suiteDetail, state);
123
124
  }
124
125
 
@@ -12,7 +12,7 @@ function createSuiteDetail(screen) {
12
12
  scrollbar: { ch: '|', style: { fg: 'magenta' } },
13
13
  style: {
14
14
  border: { fg: 'magenta' }, label: { fg: 'magenta', bold: true },
15
- bg: '#08080a', item: { fg: 'white' },
15
+ bg: '#08080a', item: { fg: 'white', bg: '#08080a' },
16
16
  },
17
17
  padding: { left: 2, right: 2, top: 1, bottom: 1 },
18
18
  hidden: true,
@@ -80,6 +80,7 @@ function openTestDetail(widget, failure, stats) {
80
80
  widget.setContent(lines.join('\n'));
81
81
  widget.scrollTo(0);
82
82
  widget.show();
83
+ widget.setFront();
83
84
  }
84
85
 
85
86
  module.exports = { createTestDetail, openTestDetail };
@@ -14,7 +14,8 @@ function createFooter(screen) {
14
14
  }
15
15
 
16
16
  function updateFooter(widget, state) {
17
- const elapsed = ((Date.now() - state.stats.startTime) / 1000).toFixed(1);
17
+ const endTime = state.stats.endTime || Date.now();
18
+ const elapsed = ((endTime - state.stats.startTime) / 1000).toFixed(1);
18
19
  const suiteStr = `${state.stats.suitesCompleted}/${state.stats.suites} suites`;
19
20
  const spin = SPINNER[state.spinFrame];
20
21
  const running = Object.values(state.suites).filter(s => !s.done).length;
@@ -33,6 +34,9 @@ function _getHint(state) {
33
34
  if (state.suiteDetailOpen) {
34
35
  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
  }
37
+ if (state.watchWaiting) {
38
+ return '{yellow-fg}[a]{/yellow-fg} run all tests';
39
+ }
36
40
  if (state.focus === 'results') {
37
41
  return '{cyan-fg}[Tab]{/cyan-fg} switch panel | {cyan-fg}[Enter]{/cyan-fg} open failure';
38
42
  }
@@ -4,7 +4,7 @@ const blessed = require('blessed');
4
4
 
5
5
  function createHeader(screen) {
6
6
  const widget = blessed.box({
7
- top: 0, left: 0, width: '100%', height: 3,
7
+ top: 0, left: 0, width: '100%', height: 2,
8
8
  content: '{center}{bold} JEST TEST DASHBOARD {/bold}{/center}',
9
9
  tags: true,
10
10
  style: { fg: 'white', bg: 'blue', bold: true },
@@ -4,7 +4,7 @@ const blessed = require('blessed');
4
4
 
5
5
  function createProgress(screen) {
6
6
  const widget = blessed.box({
7
- top: 5, left: 0, width: '100%', height: 2,
7
+ top: 4, left: 0, width: '100%', height: 2,
8
8
  tags: true,
9
9
  style: { fg: 'white', bg: '#111133' },
10
10
  });
@@ -13,9 +13,8 @@ function createProgress(screen) {
13
13
  }
14
14
 
15
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));
16
+ const suiteTotal = stats.suites || 1;
17
+ const pct = Math.min(100, Math.round((stats.suitesCompleted / suiteTotal) * 100));
19
18
  const bar = _buildBar(pct, screenWidth, stats.failed > 0);
20
19
  widget.setContent(` Progress: ${bar} ${pct}%`);
21
20
  }
@@ -4,7 +4,7 @@ const blessed = require('blessed');
4
4
 
5
5
  function createResults(screen) {
6
6
  const widget = blessed.list({
7
- top: 7, left: 0, width: '65%', bottom: 3,
7
+ top: 6, left: 0, width: '65%', bottom: 3,
8
8
  label: ' Test Results ',
9
9
  border: { type: 'line' },
10
10
  tags: true, scrollable: true, alwaysScroll: true,
@@ -12,7 +12,8 @@ function createResults(screen) {
12
12
  scrollbar: { ch: '|', style: { fg: 'cyan' } },
13
13
  style: {
14
14
  border: { fg: 'cyan' }, label: { fg: 'cyan', bold: true },
15
- bg: '#0a0a1a', item: { fg: 'white' },
15
+ bg: '#0a0a1a', item: { fg: 'white', bg: '#0a0a1a' },
16
+ selected: { bg: '#1a1a4a', bold: true },
16
17
  },
17
18
  padding: { left: 1, right: 1 },
18
19
  });
@@ -21,15 +22,14 @@ function createResults(screen) {
21
22
  }
22
23
 
23
24
  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) {
25
+ const overlayOpen = state.suiteDetailOpen || state.testDetailOpen;
26
+ const active = !overlayOpen && state.focus === 'results' && state.resultCursor >= 0;
27
+ widget.setItems(state.resultLines);
28
+ if (active) {
29
+ widget.select(state.resultCursor);
32
30
  widget.scrollTo(state.resultCursor);
31
+ } else {
32
+ widget.select(-1);
33
33
  }
34
34
  }
35
35
 
@@ -4,7 +4,7 @@ const blessed = require('blessed');
4
4
 
5
5
  function createStats(screen) {
6
6
  const widget = blessed.box({
7
- top: 3, left: 0, width: '100%', height: 2,
7
+ top: 2, left: 0, width: '100%', height: 2,
8
8
  tags: true,
9
9
  style: { fg: 'white', bg: '#111133' },
10
10
  });
@@ -5,7 +5,7 @@ const { SPINNER } = require('../../constants');
5
5
 
6
6
  function createSuites(screen) {
7
7
  const widget = blessed.list({
8
- top: 7, right: 0, width: '35%', bottom: 3,
8
+ top: 6, right: 0, width: '35%', bottom: 3,
9
9
  label: ' Suites ',
10
10
  border: { type: 'line' },
11
11
  tags: true, scrollable: true, alwaysScroll: true,
@@ -13,7 +13,8 @@ function createSuites(screen) {
13
13
  scrollbar: { ch: '|', style: { fg: 'magenta' } },
14
14
  style: {
15
15
  border: { fg: 'magenta' }, label: { fg: 'magenta', bold: true },
16
- bg: '#0a0a1a', item: { fg: 'white' },
16
+ bg: '#0a0a1a', item: { fg: 'white', bg: '#0a0a1a' },
17
+ selected: { bg: '#1a0a3a', bold: true },
17
18
  },
18
19
  padding: { left: 1, right: 1 },
19
20
  });
@@ -41,17 +42,19 @@ function updateSuites(widget, state) {
41
42
  ? '\n' + [...s.running].map(t => ` {cyan-fg}> ${t}{/cyan-fg}`).join('\n')
42
43
  : '';
43
44
 
44
- const isCursor = state.focus === 'suites' && i === state.suiteCursor;
45
- const bg = isCursor ? '{#1a0a3a-bg}' : '';
46
- const bgEnd = isCursor ? '{/#1a0a3a-bg}' : '';
45
+ const isCursor = state.focus === 'suites' && !state.suiteDetailOpen && !state.testDetailOpen && i === state.suiteCursor;
47
46
  const hint = isCursor ? ' {cyan-fg}[Enter]{/cyan-fg}' : '';
48
47
 
49
- return `${bg}${icon} {white-fg}${name}{/white-fg}${detail}${hint}${bgEnd}${runningLines}`;
48
+ return `${icon} {white-fg}${name}{/white-fg}${detail}${hint}${runningLines}`;
50
49
  });
51
50
 
52
51
  widget.setItems(items.length ? items : ['{grey-fg}waiting...{/grey-fg}']);
53
- if (state.focus === 'suites' && state.suiteCursor >= 0) {
52
+ const active = state.focus === 'suites' && !state.suiteDetailOpen && !state.testDetailOpen;
53
+ if (active && state.suiteCursor >= 0) {
54
+ widget.select(state.suiteCursor);
54
55
  widget.scrollTo(state.suiteCursor);
56
+ } else {
57
+ widget.select(-1);
55
58
  }
56
59
  }
57
60
 
package/lib/ui/screen.js CHANGED
@@ -13,6 +13,19 @@ const { createTestDetail } = require('./overlays/testDetail');
13
13
  const { bindKeys, refreshOpenSuiteDetail } = require('./keys');
14
14
 
15
15
  function createScreen(state) {
16
+ // Reuse screen and widgets across watch re-runs — Jest re-instantiates the
17
+ // reporter on every run, but we must not create a second blessed screen.
18
+ if (global.__jestronaut_ui__) {
19
+ const { screen, widgets, startTicker, state: sharedState } = global.__jestronaut_ui__;
20
+ screen.realloc();
21
+ return {
22
+ screen, widgets,
23
+ renderAll: () => renderAll(screen, widgets, sharedState),
24
+ refreshOpenSuiteDetail: () => refreshOpenSuiteDetail(widgets, sharedState),
25
+ startTicker,
26
+ };
27
+ }
28
+
16
29
  const screen = blessed.screen({
17
30
  smartCSR: true,
18
31
  title: 'Jest Dashboard',
@@ -43,20 +56,33 @@ function createScreen(state) {
43
56
 
44
57
  bindKeys(screen, widgets, state, render);
45
58
 
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);
59
+ function startTicker() {
60
+ if (state._ticker) clearInterval(state._ticker);
61
+ state._ticker = setInterval(() => {
62
+ state.spinFrame = (state.spinFrame + 1) % SPINNER.length;
63
+ updateSuites(widgets.suites, state);
64
+ updateFooter(widgets.footer, state);
65
+ screen.render();
66
+ }, 120);
67
+ }
68
+
69
+ global.__jestronaut_ui__ = { screen, widgets, startTicker, state };
53
70
 
71
+ startTicker();
54
72
  renderAll(screen, widgets, state);
55
73
 
56
- return { screen, widgets, renderAll: () => renderAll(screen, widgets, state), refreshOpenSuiteDetail: () => refreshOpenSuiteDetail(widgets, state) };
74
+ return { screen, widgets, renderAll: () => renderAll(screen, widgets, state), refreshOpenSuiteDetail: () => refreshOpenSuiteDetail(widgets, state), startTicker };
57
75
  }
58
76
 
59
77
  function renderAll(screen, widgets, state) {
78
+ // Only pass keypresses to Jest's watch mode when truly idle:
79
+ // watch waiting, no overlays, and no results to navigate
80
+ global.__jestronaut_block_jest_input__ = !(
81
+ state.watchWaiting &&
82
+ !state.suiteDetailOpen &&
83
+ !state.testDetailOpen &&
84
+ state.resultLines.length === 0
85
+ );
60
86
  updateStats(widgets.stats, state.stats);
61
87
  updateProgress(widgets.progress, state.stats, screen.width);
62
88
  updateSuites(widgets.suites, state);
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "jestronaut",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "An interactive terminal dashboard UI for Jest — navigate live test results, suites, and failure details without leaving your terminal",
5
5
  "main": "index.js",
6
6
  "bin": {
7
- "jestronaut": "bin/jestronaut.js"
7
+ "jestronaut": "./bin/jestronaut.js"
8
8
  },
9
9
  "keywords": [
10
10
  "jest",
@@ -24,5 +24,14 @@
24
24
  },
25
25
  "peerDependencies": {
26
26
  "jest": ">=27"
27
+ },
28
+ "author": "Deep Nandi",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/realdeepnandi/jestronaut.git"
32
+ },
33
+ "homepage": "https://github.com/realdeepnandi/jestronaut#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/realdeepnandi/jestronaut/issues"
27
36
  }
28
37
  }