Skip to content

Commit f7df09d

Browse files
Feat/test runner (#12)
* feat(app,web): wire up test file execution in TUI and web Add test runner support across TUI and web packages so test files (*.test.ts, *.spec.ts, etc.) can be executed directly from the UI using the existing server-side POST /test infrastructure. TUI: - Add 'test' file type with detection and checkmark icon in file tree/picker - Add useTestRunner hook mirroring useScriptRunner - Add FrameworkSelectDialog for manual framework selection - Wire test execution into app (Enter key, cancel with Escape) Web: - Add test SDK methods (getTestFrameworks, runTest, cancelTest) - Add testStarted/testOutput/testFinished SSE event handling - Add TestRunnerProvider context with framework dialog state - Wire test panel and framework dialog into MainContent Also use muted color for stderr output instead of error red, since test runners (e.g. bun test) write normal output to stderr. * fix linting errors * use observer.reset() in test runner for full state clear
1 parent 1608bdf commit f7df09d

File tree

19 files changed

+656
-39
lines changed

19 files changed

+656
-39
lines changed

packages/app/src/tui/app.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ import { ExecutionList } from './components/execution-list';
77
import { FileRequestPicker } from './components/file-request-picker';
88
import { FileTree } from './components/file-tree';
99
import { ExecutionDetailView } from './components/execution-detail';
10+
import { FrameworkSelectDialog } from './components/framework-select';
1011
import { RunnerSelectDialog } from './components/runner-select';
1112
import { ScriptOutput } from './components/script-output';
1213
import { useDialog, useExit, useObserver, useSDK, useStore } from './context';
13-
import { isRunnableScript, isHttpFile } from './store';
14+
import { isRunnableScript, isHttpFile, isTestFile } from './store';
1415
import { rgba, theme } from './theme';
1516
import {
1617
useExecutionDetail,
1718
useFlowSubscription,
1819
useScriptRunner,
20+
useTestRunner,
1921
useWorkspace,
2022
useRequestExecution,
2123
useKeyboardCommands
@@ -55,6 +57,14 @@ export function App() {
5557
}
5658
});
5759

60+
const testRunner = useTestRunner({
61+
onFrameworkDialogNeeded: (testPath, options, onSelect) => {
62+
dialog.replace(() => (
63+
<FrameworkSelectDialog testPath={testPath} options={options} onSelect={onSelect} />
64+
));
65+
}
66+
});
67+
5868
// Left panel mode state
5969
const [leftPanelMode, setLeftPanelMode] = createSignal<LeftPanelMode>('tree');
6070
const [panelHidden, setPanelHidden] = createSignal(false);
@@ -74,7 +84,9 @@ export function App() {
7484

7585
// File execution handler - delegates to appropriate executor
7686
function handleFileExecute(filePath: string) {
77-
if (isRunnableScript(filePath)) {
87+
if (isTestFile(filePath)) {
88+
void testRunner.runTest(filePath);
89+
} else if (isRunnableScript(filePath)) {
7890
void scriptRunner.runScript(filePath);
7991
} else if (isHttpFile(filePath)) {
8092
void executeFirstRequest(filePath);
@@ -93,6 +105,7 @@ export function App() {
93105
// Cleanup handler
94106
async function cleanupAndExit() {
95107
scriptRunner.cleanup();
108+
testRunner.cleanup();
96109
flowSubscription.cleanup();
97110
// Best-effort finish flow
98111
const flowId = observer.state.flowId;
@@ -135,7 +148,10 @@ export function App() {
135148
}
136149
}
137150
},
138-
onCancel: scriptRunner.cancelScript,
151+
onCancel: () => {
152+
scriptRunner.cancelScript();
153+
testRunner.cancelTest();
154+
},
139155
onNavigateDown: () => {
140156
if (leftPanelMode() === 'tree') {
141157
store.selectNext();
@@ -170,6 +186,7 @@ export function App() {
170186
// Cleanup on unmount
171187
onCleanup(() => {
172188
scriptRunner.cleanup();
189+
testRunner.cleanup();
173190
flowSubscription.cleanup();
174191
});
175192

packages/app/src/tui/components/file-request-picker.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, type J
44
import { useDialog, useStore } from '../context';
55
import { rgba, theme } from '../theme';
66
import { normalizeKey } from '../util/normalize-key';
7-
import { isRunnableScript, isHttpFile } from '../store';
7+
import { isRunnableScript, isHttpFile, isTestFile } from '../store';
88

9-
type PickerItemType = 'script' | 'http';
9+
type PickerItemType = 'test' | 'script' | 'http';
1010

1111
interface PickerItem {
1212
id: string;
@@ -36,14 +36,23 @@ export function FileRequestPicker(props: FileRequestPickerProps): JSX.Element {
3636
// Build flat list of picker items from store files
3737
const pickerItems = createMemo((): PickerItem[] => {
3838
const files = store.files();
39+
const tests: PickerItem[] = [];
3940
const scripts: PickerItem[] = [];
4041
const httpFiles: PickerItem[] = [];
4142

4243
for (const file of files) {
4344
const path = file.path;
4445
const fileName = path.includes('/') ? path.substring(path.lastIndexOf('/') + 1) : path;
4546

46-
if (isRunnableScript(path)) {
47+
if (isTestFile(path)) {
48+
tests.push({
49+
id: `test:${path}`,
50+
type: 'test',
51+
filePath: path,
52+
fileName,
53+
searchText: path
54+
});
55+
} else if (isRunnableScript(path)) {
4756
scripts.push({
4857
id: `script:${path}`,
4958
type: 'script',
@@ -63,11 +72,12 @@ export function FileRequestPicker(props: FileRequestPickerProps): JSX.Element {
6372
}
6473

6574
// Sort alphabetically by file path
75+
tests.sort((a, b) => a.filePath.localeCompare(b.filePath));
6676
scripts.sort((a, b) => a.filePath.localeCompare(b.filePath));
6777
httpFiles.sort((a, b) => a.filePath.localeCompare(b.filePath));
6878

69-
// Scripts first, then HTTP files
70-
return [...scripts, ...httpFiles];
79+
// Tests first, then scripts, then HTTP files
80+
return [...tests, ...scripts, ...httpFiles];
7181
});
7282

7383
// Filter items by fuzzy search on file path
@@ -260,9 +270,11 @@ export function FileRequestPicker(props: FileRequestPickerProps): JSX.Element {
260270
>
261271
{isPendingSend()
262272
? 'Press ctrl+enter to confirm'
263-
: item.type === 'script'
264-
? `▷ ${item.filePath}`
265-
: item.filePath}
273+
: item.type === 'test'
274+
? `✓ ${item.filePath}`
275+
: item.type === 'script'
276+
? `▷ ${item.filePath}`
277+
: item.filePath}
266278
</text>
267279
</box>
268280
);

packages/app/src/tui/components/file-tree.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ function FileTreeRow(props: FileTreeRowProps) {
8484
return isExpanded ? '\u25BC ' : '> ';
8585
}
8686
// Show different icons for file types
87+
if (node.fileType === 'test') {
88+
return '\u2713 '; // ✓ for test files
89+
}
8790
if (node.fileType === 'script') {
8891
return '\u25B7 '; // ▷ for runnable scripts
8992
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { JSX } from 'solid-js';
2+
import { useDialog } from '../context/dialog';
3+
import { DialogSelect, type DialogSelectOption } from './dialog-select';
4+
import type { TestFrameworkOption } from '../sdk';
5+
6+
export interface FrameworkSelectProps {
7+
testPath: string;
8+
options: TestFrameworkOption[];
9+
onSelect: (frameworkId: string) => void;
10+
}
11+
12+
export function FrameworkSelectDialog(props: FrameworkSelectProps): JSX.Element {
13+
const dialog = useDialog();
14+
15+
const options: DialogSelectOption<string>[] = props.options.map((opt) => ({
16+
title: opt.label,
17+
value: opt.id,
18+
description: `Run with ${opt.label}`
19+
}));
20+
21+
const handleSelect = (opt: DialogSelectOption<string>) => {
22+
dialog.clear();
23+
props.onSelect(opt.value);
24+
};
25+
26+
const testName = () => {
27+
const parts = props.testPath.split('/');
28+
return parts[parts.length - 1] ?? props.testPath;
29+
};
30+
31+
return (
32+
<DialogSelect
33+
title={`Select framework for ${testName()}`}
34+
placeholder="Choose a test framework..."
35+
options={options}
36+
onSelect={handleSelect}
37+
/>
38+
);
39+
}

packages/app/src/tui/components/script-output.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export function ScriptOutput(props: ScriptOutputProps) {
9999
<For each={combinedLines()}>
100100
{(line, index) => (
101101
<box id={`output-${index()}`} height={1} flexShrink={0} paddingLeft={1}>
102-
<text fg={rgba(line.isError ? theme.error : theme.text)}>{line.text}</text>
102+
<text fg={rgba(line.isError ? theme.textMuted : theme.text)}>{line.text}</text>
103103
</box>
104104
)}
105105
</For>

packages/app/src/tui/hooks/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,9 @@ export {
1212
type ScriptRunnerReturn,
1313
useScriptRunner
1414
} from './use-script-runner';
15+
export {
16+
type TestRunnerOptions,
17+
type TestRunnerReturn,
18+
useTestRunner
19+
} from './use-test-runner';
1520
export { useWorkspace, type WorkspaceReturn } from './use-workspace';
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* useTestRunner Hook
3+
*
4+
* Encapsulates test execution lifecycle via server-side execution.
5+
* Handles framework detection/selection, flow creation, SSE subscription,
6+
* and server-side process spawning with live output streaming.
7+
*/
8+
9+
import { type Accessor, createSignal } from 'solid-js';
10+
import { useObserver, useSDK } from '../context';
11+
import type { TestFrameworkOption } from '../sdk';
12+
import { useFlowSubscription } from './use-flow-subscription';
13+
14+
export interface TestRunnerOptions {
15+
onFrameworkDialogNeeded: (
16+
testPath: string,
17+
options: TestFrameworkOption[],
18+
onSelect: (frameworkId: string) => void
19+
) => void;
20+
}
21+
22+
export interface TestRunnerReturn {
23+
runTest: (testPath: string) => Promise<void>;
24+
cancelTest: () => void;
25+
isRunning: Accessor<boolean>;
26+
cleanup: () => void;
27+
}
28+
29+
export function useTestRunner(options: TestRunnerOptions): TestRunnerReturn {
30+
const sdk = useSDK();
31+
const observer = useObserver();
32+
const flowSubscription = useFlowSubscription();
33+
34+
let currentRunId: string | undefined;
35+
const [isStarting, setIsStarting] = createSignal(false);
36+
37+
async function startTest(testPath: string, frameworkId?: string) {
38+
let flowId: string | undefined;
39+
try {
40+
const createdFlow = await sdk.createFlow(`Test: ${testPath}`);
41+
flowId = createdFlow.flowId;
42+
43+
observer.setState('flowId', flowId);
44+
flowSubscription.subscribe(flowId);
45+
46+
const { runId } = await sdk.runTest(testPath, frameworkId, flowId);
47+
currentRunId = runId;
48+
49+
if (!observer.state.runningScript) {
50+
observer.setState('runningScript', {
51+
path: testPath,
52+
pid: 0,
53+
startedAt: Date.now()
54+
});
55+
}
56+
} catch (err) {
57+
console.error('Failed to start test:', err);
58+
if (flowId) {
59+
flowSubscription.unsubscribe();
60+
observer.setState('flowId', undefined);
61+
}
62+
observer.setState('sseStatus', 'error');
63+
currentRunId = undefined;
64+
}
65+
}
66+
67+
async function handleRunTest(testPath: string) {
68+
if (observer.state.runningScript || isStarting()) {
69+
return;
70+
}
71+
72+
setIsStarting(true);
73+
observer.reset();
74+
75+
try {
76+
const { detected, options: frameworkOptions } = await sdk.getTestFrameworks(testPath);
77+
78+
if (detected) {
79+
await startTest(testPath, detected);
80+
} else {
81+
options.onFrameworkDialogNeeded(testPath, frameworkOptions, (selectedFrameworkId) => {
82+
void startTest(testPath, selectedFrameworkId);
83+
});
84+
}
85+
} catch (err) {
86+
console.error('Failed to get test frameworks:', err);
87+
observer.setState('sseStatus', 'error');
88+
} finally {
89+
setIsStarting(false);
90+
}
91+
}
92+
93+
async function cancelTest() {
94+
if (currentRunId) {
95+
try {
96+
await sdk.cancelTest(currentRunId);
97+
} catch {
98+
// Test may have already finished
99+
}
100+
currentRunId = undefined;
101+
}
102+
setIsStarting(false);
103+
flowSubscription.unsubscribe();
104+
}
105+
106+
function cleanup() {
107+
if (currentRunId) {
108+
sdk.cancelTest(currentRunId).catch(() => {});
109+
currentRunId = undefined;
110+
}
111+
flowSubscription.cleanup();
112+
setIsStarting(false);
113+
}
114+
115+
return {
116+
runTest: handleRunTest,
117+
cancelTest,
118+
isRunning: () => isStarting() || !!observer.state.runningScript,
119+
cleanup
120+
};
121+
}

packages/app/src/tui/store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { WorkspaceFile, WorkspaceRequest } from './sdk';
77

88
export type ConnectionStatus = 'connecting' | 'connected' | 'error';
99

10-
export type FileType = 'http' | 'script' | 'other';
10+
export type FileType = 'http' | 'script' | 'test' | 'other';
1111

1212
export interface TreeNode {
1313
name: string;
@@ -39,6 +39,7 @@ const TEST_FILE_PATTERNS = [
3939
export function getFileType(path: string): FileType {
4040
const ext = path.substring(path.lastIndexOf('.')).toLowerCase();
4141
if (HTTP_EXTENSIONS.has(ext)) return 'http';
42+
if (isTestFile(path)) return 'test';
4243
if (SCRIPT_EXTENSIONS.has(ext)) return 'script';
4344
return 'other';
4445
}

packages/app/test/server/app.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -532,11 +532,7 @@ describe('script token authentication', () => {
532532
describe('script token middleware', () => {
533533
test('valid script token sets authMethod=script and scriptTokenPayload', async () => {
534534
// Generate a valid script token
535-
const { token, payload } = generateScriptToken(
536-
serverToken,
537-
'test-flow-id',
538-
'test-session-id'
539-
);
535+
const { token } = generateScriptToken(serverToken, 'test-flow-id', 'test-session-id');
540536

541537
// Make request to an endpoint that allows script tokens
542538
// Health endpoint is accessible to all auth methods

0 commit comments

Comments
 (0)