jestronaut 0.2.0 → 0.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/bin/jestronaut.js +21 -27
- package/index.js +1 -3
- package/lib/constants.js +1 -5
- package/lib/reporter.js +34 -85
- package/lib/state.js +21 -38
- package/lib/ui/app.js +20 -0
- package/lib/ui/components/Dashboard.js +179 -0
- package/lib/ui/components/Footer.js +33 -0
- package/lib/ui/components/Header.js +19 -0
- package/lib/ui/components/Progress.js +26 -0
- package/lib/ui/components/ResultsList.js +26 -0
- package/lib/ui/components/ScrollableBox.js +20 -0
- package/lib/ui/components/ScrollableList.js +30 -0
- package/lib/ui/components/Stats.js +13 -0
- package/lib/ui/components/SuiteDetailOverlay.js +111 -0
- package/lib/ui/components/SuitesList.js +37 -0
- package/lib/ui/components/TestDetailOverlay.js +75 -0
- package/lib/ui/store.js +18 -0
- package/package.json +8 -5
- package/lib/ui/keys.js +0 -140
- package/lib/ui/overlays/suiteDetail.js +0 -123
- package/lib/ui/overlays/testDetail.js +0 -86
- package/lib/ui/panels/footer.js +0 -46
- package/lib/ui/panels/header.js +0 -23
- package/lib/ui/panels/progress.js +0 -30
- package/lib/ui/panels/results.js +0 -41
- package/lib/ui/panels/stats.js +0 -27
- package/lib/ui/panels/suites.js +0 -66
- package/lib/ui/screen.js +0 -100
|
@@ -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
|
+
}
|
package/lib/ui/store.js
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
16
|
+
"ink",
|
|
17
|
+
"react",
|
|
16
18
|
"jestronaut"
|
|
17
19
|
],
|
|
18
20
|
"license": "MIT",
|
|
19
21
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
22
|
+
"node": ">=18"
|
|
21
23
|
},
|
|
22
24
|
"dependencies": {
|
|
23
|
-
"
|
|
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 };
|