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.
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ export function buildProgressData(stats, width = 80) {
5
+ const total = stats.suites || 1;
6
+ const done = stats.suitesCompleted || 0;
7
+ const barWidth = Math.max(10, width - 20);
8
+ const filled = Math.min(barWidth, Math.round((done / total) * barWidth));
9
+ const empty = barWidth - filled;
10
+ const pct = Math.round((done / total) * 100);
11
+ const color = stats.failed > 0 ? 'red' : done === total ? 'green' : 'cyan';
12
+ const bar = '#'.repeat(filled) + '-'.repeat(empty);
13
+ return { total, done, barWidth, filled, empty, pct, color, bar };
14
+ }
15
+
16
+ export function Progress({ stats, width }) {
17
+ const { pct, color, bar, done, total } = buildProgressData(stats, width);
18
+
19
+ return React.createElement(
20
+ Box,
21
+ { height: 2, backgroundColor: '#111133', alignItems: 'center', paddingLeft: 2, gap: 1 },
22
+ React.createElement(Text, { color: 'white' }, `Suites ${done}/${total}`),
23
+ React.createElement(Text, { color }, `[${bar}]`),
24
+ React.createElement(Text, { color }, `${pct}%`)
25
+ );
26
+ }
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { ScrollableList } from './ScrollableList.js';
3
+
4
+ export function buildItems(resultItems) {
5
+ return resultItems.map(item => {
6
+ let text = `[${item.icon}] `;
7
+ if (item.ancestor) text += `${item.ancestor} > `;
8
+ text += item.title;
9
+ if (item.duration != null) text += ` (${item.duration}ms)`;
10
+ if (item.isFailed) text += ' [Enter]';
11
+ return { text, color: item.titleColor };
12
+ });
13
+ }
14
+
15
+ export function ResultsList({ state, height }) {
16
+ const { resultItems, resultCursor, focus } = state;
17
+ const focused = focus === 'results';
18
+ return React.createElement(ScrollableList, {
19
+ items: buildItems(resultItems),
20
+ selectedIndex: resultCursor,
21
+ height,
22
+ width: '100%',
23
+ borderColor: focused ? 'cyan' : 'gray',
24
+ focused,
25
+ });
26
+ }
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ export function ScrollableBox({ lines, scrollOffset, height, width, borderColor }) {
5
+ const innerHeight = Math.max(1, (height || 10) - 2);
6
+ const offset = Math.max(0, Math.min(scrollOffset || 0, Math.max(0, lines.length - innerHeight)));
7
+ const visible = lines.slice(offset, offset + innerHeight);
8
+
9
+ return React.createElement(
10
+ Box,
11
+ { flexDirection: 'column', borderStyle: 'single', borderColor: borderColor || 'red', width: width || '100%', height: height || 10 },
12
+ visible.map((line, i) =>
13
+ React.createElement(
14
+ Box,
15
+ { key: offset + i, paddingLeft: 2 },
16
+ React.createElement(Text, { color: line.color, wrap: 'truncate' }, line.text || '')
17
+ )
18
+ )
19
+ );
20
+ }
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ export function computeScrollOffset(selectedIndex, visibleHeight, totalItems) {
5
+ if (totalItems === 0 || visibleHeight <= 0) return 0;
6
+ const maxOffset = Math.max(0, totalItems - visibleHeight);
7
+ const offset = Math.max(0, selectedIndex - Math.floor(visibleHeight / 2));
8
+ return Math.min(offset, maxOffset);
9
+ }
10
+
11
+ export function ScrollableList({ items, selectedIndex, height, width, borderColor, focused }) {
12
+ const innerHeight = Math.max(1, (height || 10) - 2);
13
+ const scrollOffset = computeScrollOffset(selectedIndex, innerHeight, items.length);
14
+ const visible = items.slice(scrollOffset, scrollOffset + innerHeight);
15
+
16
+ return React.createElement(
17
+ Box,
18
+ { flexDirection: 'column', borderStyle: 'single', borderColor: borderColor || 'white', width: width || '100%', height: height || 10 },
19
+ visible.map((item, i) => {
20
+ const absIndex = scrollOffset + i;
21
+ const isSelected = absIndex === selectedIndex && focused;
22
+ const bg = item.bg || (isSelected ? '#2a2a6a' : undefined);
23
+ return React.createElement(
24
+ Box,
25
+ { key: absIndex, paddingLeft: 1, paddingRight: 1, width: '100%', backgroundColor: bg },
26
+ React.createElement(Text, { color: item.color, backgroundColor: bg, wrap: 'truncate' }, item.text || '')
27
+ );
28
+ })
29
+ );
30
+ }
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ export function Stats({ stats }) {
5
+ return React.createElement(
6
+ Box,
7
+ { height: 2, backgroundColor: '#111133', justifyContent: 'center', alignItems: 'center', gap: 4 },
8
+ React.createElement(Text, { color: 'green', bold: true }, `PASSED: ${stats.passed}`),
9
+ React.createElement(Text, { color: 'red', bold: true }, `FAILED: ${stats.failed}`),
10
+ React.createElement(Text, { color: 'yellow',bold: true }, `SKIPPED: ${stats.skipped}`),
11
+ React.createElement(Text, { color: 'white' }, `TOTAL: ${stats.total}`)
12
+ );
13
+ }
@@ -0,0 +1,111 @@
1
+ import React from 'react';
2
+ import { Box } from 'ink';
3
+ import { ScrollableList } from './ScrollableList.js';
4
+
5
+ export function buildSuiteDetailItems(suiteData, path) {
6
+ const s = suiteData;
7
+ const name = path.split('/').pop().replace(/\.test\.[jt]sx?$/, '');
8
+ const totalTests = (s.tests || []).length;
9
+ const duration = s.endTime && s.startTime
10
+ ? ((s.endTime - s.startTime) / 1000).toFixed(2) + 's'
11
+ : 'running...';
12
+ const status = !s.done ? 'RUNNING' : s.failed > 0 ? 'FAILED' : 'PASSED';
13
+ const statusColor = !s.done ? 'yellow' : s.failed > 0 ? 'red' : 'green';
14
+ const passRate = totalTests > 0 ? Math.round((s.passed / totalTests) * 100) : 0;
15
+
16
+ const items = [];
17
+ const add = (text, color = 'white', type = 'other', failureObj = null) =>
18
+ items.push({ text, color, type, failureObj });
19
+
20
+ add(`Suite: ${name} [${status}]`, statusColor);
21
+ add('');
22
+ add(`File : ${path}`, 'yellow');
23
+ add(`Duration : ${duration}`, 'white');
24
+ add(`Pass rate: ${passRate}%`, statusColor);
25
+ add(`Tests : ${totalTests} total ${s.passed} passed ${s.failed} failed`, 'white');
26
+ add(`Slowest : ${_slowest(s.tests || [])}`, 'white');
27
+ add(`Fastest : ${_fastest(s.tests || [])}`, 'white');
28
+ add('');
29
+ add(`── Test Results (${totalTests}) ──────────────────────────────`, 'cyan');
30
+ add('');
31
+
32
+ const tests = s.tests || [];
33
+ if (tests.length === 0) {
34
+ add(' (no results yet)', 'gray');
35
+ } else {
36
+ for (const t of tests) {
37
+ const dur = t.duration != null ? t.duration + 'ms' : '?';
38
+ if (t.status === 'passed') {
39
+ add(` PASS ${t.title} (${dur})`, 'green', 'test', null);
40
+ } else if (t.status === 'failed') {
41
+ add(
42
+ ` FAIL ${t.title} (${dur}) [Enter]`,
43
+ 'red', 'test',
44
+ { title: t.title, suiteName: name, messages: t.messages || [], duration: t.duration }
45
+ );
46
+ } else {
47
+ add(` SKIP ${t.title}`, 'yellow', 'test', null);
48
+ }
49
+ }
50
+ }
51
+
52
+ add('');
53
+ add(' [j/k] navigate failed [Enter] open failure [Esc] back', 'gray');
54
+
55
+ return {
56
+ items,
57
+ name,
58
+ hasFailed: s.failed > 0,
59
+ label: ` Suite: ${name} [${s.passed}p ${s.failed}f] `,
60
+ };
61
+ }
62
+
63
+ export function moveCursor(state, dir) {
64
+ const items = state.suiteDetailItems;
65
+ if (!items.length) return;
66
+ const failIndices = items.map((m, i) => (m.type === 'test' && m.failureObj) ? i : -1).filter(i => i >= 0);
67
+ if (!failIndices.length) return;
68
+ const pos = failIndices.indexOf(state.suiteDetailCursor);
69
+ if (dir > 0) {
70
+ state.suiteDetailCursor = pos < failIndices.length - 1 ? failIndices[pos + 1] : failIndices[0];
71
+ } else {
72
+ state.suiteDetailCursor = pos > 0 ? failIndices[pos - 1] : failIndices[failIndices.length - 1];
73
+ }
74
+ }
75
+
76
+ function _slowest(tests) {
77
+ const t = tests.filter(t => t.duration != null).sort((a, b) => b.duration - a.duration)[0];
78
+ return t ? `${t.title} (${t.duration}ms)` : 'N/A';
79
+ }
80
+
81
+ function _fastest(tests) {
82
+ const t = tests.filter(t => t.duration != null).sort((a, b) => a.duration - b.duration)[0];
83
+ return t ? `${t.title} (${t.duration}ms)` : 'N/A';
84
+ }
85
+
86
+ export function SuiteDetailOverlay({ state, rows }) {
87
+ const { suiteDetailItems, suiteDetailCursor, suiteDetailPath } = state;
88
+ const hasFailed = suiteDetailItems.some(i => i.type === 'test' && i.failureObj);
89
+
90
+ const listItems = suiteDetailItems.map((item, i) => {
91
+ const isSelected = i === suiteDetailCursor && item.type === 'test' && item.failureObj;
92
+ return {
93
+ text: isSelected ? `> ${item.text.trimStart()}` : ` ${item.text}`,
94
+ color: item.color,
95
+ bg: isSelected ? '#5a0a0a' : undefined,
96
+ };
97
+ });
98
+
99
+ return React.createElement(
100
+ Box,
101
+ { flexDirection: 'column', height: rows || 30 },
102
+ React.createElement(ScrollableList, {
103
+ items: listItems,
104
+ selectedIndex: suiteDetailCursor,
105
+ height: rows || 30,
106
+ width: '100%',
107
+ borderColor: hasFailed ? 'red' : 'green',
108
+ focused: true,
109
+ })
110
+ );
111
+ }
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { SPINNER } from '../../constants.js';
3
+ import { ScrollableList } from './ScrollableList.js';
4
+
5
+ function buildItems(state) {
6
+ const { suiteOrder, suites, focus, suiteCursor, suiteDetailOpen, testDetailOpen, spinFrame } = state;
7
+ const spin = SPINNER[spinFrame % SPINNER.length];
8
+
9
+ if (suiteOrder.length === 0) return [{ text: 'waiting...' }];
10
+
11
+ return suiteOrder.map((path, i) => {
12
+ const s = suites[path];
13
+ const name = path.split('/').pop().replace(/\.test\.[jt]sx?$/, '');
14
+ const elapsed = s.startTime ? ` ${((Date.now() - s.startTime) / 1000).toFixed(1)}s` : '';
15
+ const prefix = s.done ? (s.failed > 0 ? 'FAIL' : 'PASS') : spin;
16
+ const counts = ` [${s.passed}p ${s.failed}f]`;
17
+ const isCursor = focus === 'suites' && !suiteDetailOpen && !testDetailOpen && i === suiteCursor;
18
+ const hint = isCursor ? ' [Enter]' : '';
19
+ const running = s.running && s.running.size > 0 ? ' > ' + [...s.running].join(', ') : '';
20
+ return {
21
+ text: `${prefix} ${name}${s.done ? counts : elapsed + counts}${hint}${running}`,
22
+ color: s.done ? (s.failed > 0 ? 'red' : 'green') : 'yellow',
23
+ };
24
+ });
25
+ }
26
+
27
+ export function SuitesList({ state, height }) {
28
+ const focused = state.focus === 'suites';
29
+ return React.createElement(ScrollableList, {
30
+ items: buildItems(state),
31
+ selectedIndex: state.suiteCursor,
32
+ height,
33
+ width: '100%',
34
+ borderColor: focused ? 'white' : 'magenta',
35
+ focused,
36
+ });
37
+ }
@@ -0,0 +1,75 @@
1
+ import React from 'react';
2
+ import { Box } from 'ink';
3
+ import { ScrollableBox } from './ScrollableBox.js';
4
+
5
+ export function buildLines(failure, stats) {
6
+ const rawMsg = (failure.messages || []).join('\n');
7
+ const expectedMatch = rawMsg.match(/Expected[:\s]+(.+)/);
8
+ const receivedMatch = rawMsg.match(/Received[:\s]+(.+)/);
9
+ const stackLines = rawMsg.split('\n').filter(l => l.trim().startsWith('at ')).map(l => l.trim());
10
+
11
+ const lines = [
12
+ { text: `FAILED: ${failure.suiteName} > ${failure.title}`, color: 'red' },
13
+ { text: '' },
14
+ { text: `Suite : ${failure.suiteName}`, color: 'yellow' },
15
+ { text: `Test : ${failure.title}`, color: 'yellow' },
16
+ { text: `Duration: ${failure.duration != null ? failure.duration + 'ms' : 'N/A'}`, color: 'yellow' },
17
+ { text: '' },
18
+ { text: '── Error Message ──────────────────────────────────────────', color: 'cyan' },
19
+ { text: '' },
20
+ ];
21
+
22
+ if (expectedMatch) lines.push({ text: ` Expected: ${expectedMatch[1].trim()}`, color: 'green' });
23
+ if (receivedMatch) lines.push({ text: ` Received: ${receivedMatch[1].trim()}`, color: 'red' });
24
+
25
+ lines.push({ text: '' });
26
+ rawMsg.split('\n').slice(0, 15).forEach(l =>
27
+ lines.push({ text: ` ${l.replace(/\{/g, '(').replace(/\}/g, ')')}`, color: 'white' })
28
+ );
29
+
30
+ lines.push(
31
+ { text: '' },
32
+ { text: '── Stack Trace ─────────────────────────────────────────────', color: 'cyan' },
33
+ { text: '' }
34
+ );
35
+
36
+ if (stackLines.length > 0) {
37
+ const firstUser = stackLines.findIndex(l => !l.includes('node_modules'));
38
+ stackLines.forEach((l, i) =>
39
+ lines.push({ text: ` ${i === firstUser ? '> ' : ' '}${l}`, color: i === firstUser ? 'yellow' : 'gray' })
40
+ );
41
+ } else {
42
+ lines.push({ text: ' (no stack trace)', color: 'gray' });
43
+ }
44
+
45
+ lines.push(
46
+ { text: '' },
47
+ { text: '── Run Metrics ─────────────────────────────────────────────', color: 'cyan' },
48
+ { text: '' },
49
+ { text: ` Passed : ${stats.passed}`, color: 'green' },
50
+ { text: ` Failed : ${stats.failed}`, color: 'red' },
51
+ { text: ` Skipped : ${stats.skipped}`, color: 'yellow' },
52
+ { text: ` Elapsed : ${(((stats.endTime || Date.now()) - stats.startTime) / 1000).toFixed(1)}s`, color: 'white' },
53
+ { text: '' },
54
+ { text: ' [Esc] back [j/k] scroll', color: 'gray' }
55
+ );
56
+
57
+ return lines;
58
+ }
59
+
60
+ export function TestDetailOverlay({ state, rows }) {
61
+ const { testDetailFailure, testDetailScrollOffset, stats } = state;
62
+ if (!testDetailFailure) return null;
63
+ const lines = buildLines(testDetailFailure, stats);
64
+ return React.createElement(
65
+ Box,
66
+ { flexDirection: 'column', height: rows || 30 },
67
+ React.createElement(ScrollableBox, {
68
+ lines,
69
+ scrollOffset: testDetailScrollOffset,
70
+ height: rows || 30,
71
+ width: '100%',
72
+ borderColor: 'red',
73
+ })
74
+ );
75
+ }
@@ -0,0 +1,18 @@
1
+ import { EventEmitter } from 'events';
2
+ import { createState, resetState } from '../state.js';
3
+
4
+ export class JestronautStore extends EventEmitter {
5
+ constructor() {
6
+ super();
7
+ this.state = createState();
8
+ }
9
+
10
+ reset() {
11
+ resetState(this.state);
12
+ this.notify();
13
+ }
14
+
15
+ notify() {
16
+ this.emit('update', this.state);
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "jestronaut",
3
- "version": "0.2.0",
3
+ "version": "0.3.16",
4
4
  "description": "An interactive terminal dashboard UI for Jest — navigate live test results, suites, and failure details without leaving your terminal",
5
+ "type": "module",
5
6
  "main": "index.js",
6
7
  "bin": {
7
8
  "jestronaut": "./bin/jestronaut.js"
@@ -12,18 +13,20 @@
12
13
  "dashboard",
13
14
  "tui",
14
15
  "terminal",
15
- "blessed",
16
+ "ink",
17
+ "react",
16
18
  "jestronaut"
17
19
  ],
18
20
  "license": "MIT",
19
21
  "engines": {
20
- "node": ">=16"
22
+ "node": ">=18"
21
23
  },
22
24
  "dependencies": {
23
- "blessed": "^0.1.81"
25
+ "ink": "^5.2.1"
24
26
  },
25
27
  "peerDependencies": {
26
- "jest": ">=27"
28
+ "jest": ">=27",
29
+ "react": "^18.3.1"
27
30
  },
28
31
  "author": "Deep Nandi",
29
32
  "repository": {
package/lib/ui/keys.js DELETED
@@ -1,140 +0,0 @@
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
- widgets.suiteDetail.setFront();
123
- suiteDetailOverlay.refreshSuiteDetail(widgets.suiteDetail, state);
124
- }
125
-
126
- // Called when a suite receives new results while suite detail is open
127
- function refreshOpenSuiteDetail(widgets, state) {
128
- if (!state.suiteDetailOpen || !state.suiteDetailPath) return;
129
- const result = suiteDetailOverlay.buildSuiteDetailLines(
130
- state.suites[state.suiteDetailPath],
131
- state.suiteDetailPath
132
- );
133
- state.suiteDetailLines = result.lines;
134
- state.suiteDetailMeta = result.meta;
135
- widgets.suiteDetail.setLabel(result.label);
136
- widgets.suiteDetail.style.border.fg = result.hasFailed ? 'red' : 'green';
137
- suiteDetailOverlay.refreshSuiteDetail(widgets.suiteDetail, state);
138
- }
139
-
140
- module.exports = { bindKeys, refreshOpenSuiteDetail };
@@ -1,123 +0,0 @@
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', bg: '#08080a' },
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 };