bun-ui-tests 1.0.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.
Files changed (4) hide show
  1. package/README.md +115 -0
  2. package/cli.ts +197 -0
  3. package/package.json +27 -0
  4. package/ui-runner.ts +597 -0
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # 🧪 Bun Test UI
2
+
3
+ Uma interface visual moderna e bonita para rodar e visualizar seus testes Bun em tempo real.
4
+
5
+ ![Tests](https://img.shields.io/badge/tests-passing-blue)
6
+ ![Bun](https://img.shields.io/badge/bun-1.0+-blue)
7
+ ![License](https://img.shields.io/badge/license-MIT-green)
8
+
9
+ ## ✨ Features
10
+
11
+ - 🎨 **Interface Moderna** - Design limpo com tema dark
12
+ - ⚡ **Tempo Real** - Veja os testes rodando ao vivo via WebSocket
13
+ - 🔍 **Busca Recursiva** - Encontra automaticamente todos os arquivos de teste no projeto
14
+ - 📂 **Organização por Arquivo** - Testes agrupados por arquivo com expansão/colapso
15
+ - ▶️ **Execução Seletiva** - Rode todos os testes, um arquivo específico, ou um teste individual
16
+ - 📊 **Estatísticas** - Contador de testes passados, falhos e em execução
17
+ - 🎯 **Suporte Completo** - Detecta `.test.ts`, `.spec.js`, `_test.tsx` e mais
18
+ - 🚀 **Performance** - Backend compilado para máxima velocidade
19
+
20
+ ## 🚀 Instalação Rápida
21
+
22
+ ```bash
23
+ # Clone o repositório
24
+ git clone <repo-url>
25
+ cd buntestui
26
+
27
+ # Instale globalmente
28
+ bun link
29
+
30
+ # Builde o projeto
31
+ buntestui build
32
+ ```
33
+
34
+ ## 📖 Uso
35
+
36
+ ### Em qualquer projeto com testes Bun:
37
+
38
+ ```bash
39
+ cd ~/meu-projeto
40
+ buntestui run
41
+ ```
42
+
43
+ Abra seu navegador em **http://localhost:3000** e veja a mágica acontecer! ✨
44
+
45
+ ## 🎮 Comandos
46
+
47
+ | Comando | Descrição |
48
+ |---------|-----------|
49
+ | `buntestui build` | Builda o frontend (Vite) e backend (executável) |
50
+ | `buntestui run` | Inicia o Test UI em modo produção (usa arquivos buildados) |
51
+ | `buntestui dev` | Inicia em modo desenvolvimento (hot reload) |
52
+ | `buntestui help` | Mostra ajuda |
53
+
54
+ ## 📋 Padrões de Teste Suportados
55
+
56
+ O Bun Test UI detecta automaticamente:
57
+
58
+ - `*.test.ts` / `*.test.js` / `*.test.tsx` / `*.test.jsx`
59
+ - `*.spec.ts` / `*.spec.js` / `*.spec.tsx` / `*.spec.jsx`
60
+ - `*_test.ts` / `*_test.js` / `*_test.tsx` / `*_test.jsx`
61
+ - `*_spec.ts` / `*_spec.js` / `*_spec.tsx` / `*_spec.jsx`
62
+
63
+ ## 🏗️ Arquitetura
64
+
65
+ ```
66
+ ┌─────────────┐ WebSocket ┌─────────────┐
67
+ │ │ (port 5060) │ │
68
+ │ Frontend │◄─────────────────────────►│ Backend │
69
+ │ (React) │ │ (Bun) │
70
+ │ port 5050 │ │ │
71
+ └─────────────┘ └─────────────┘
72
+
73
+
74
+ ┌──────────────┐
75
+ │ bun test │
76
+ │ (spawned) │
77
+ └──────────────┘
78
+ ```
79
+
80
+ - **Frontend**: React + Vite + Tailwind CSS
81
+ - **Backend**: Bun native WebSocket + process spawning
82
+ - **Comunicação**: WebSocket em tempo real
83
+ - **Compilado**: Executável standalone (~93MB)
84
+
85
+ ## 🎨 Interface
86
+
87
+ A interface possui:
88
+
89
+ - **Painel de Testes**: Lista de arquivos e testes com status visual
90
+ - **Painel de Output**: Logs em tempo real com syntax highlighting
91
+ - **Header**: Estatísticas e controles de execução
92
+ - **Design Responsivo**: Funciona em mobile e desktop
93
+
94
+ ### Cores:
95
+
96
+ - 🔵 **Azul**: Testes passando, status conectado
97
+ - 🟢 **Verde**: Logs de sucesso `(pass)`
98
+ - 🔴 **Vermelho**: Testes falhando, erros
99
+ - 🟡 **Amarelo**: Testes em execução
100
+
101
+ ## 📚 Documentação Completa
102
+
103
+ Veja [INSTALL.md](./INSTALL.md) para instruções detalhadas de instalação e uso.
104
+
105
+ ## 🤝 Contribuindo
106
+
107
+ Contribuições são bem-vindas! Sinta-se livre para abrir issues e pull requests.
108
+
109
+ ## 📄 Licença
110
+
111
+ MIT
112
+
113
+ ## 🙏 Agradecimentos
114
+
115
+ Feito com ❤️ para a comunidade Bun
package/cli.ts ADDED
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { readFile, access } from "node:fs/promises";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ const COMMANDS = {
12
+ run: "Run the test UI (production mode)",
13
+ dev: "Run in development mode (hot reload)",
14
+ help: "Show this help message"
15
+ };
16
+
17
+ async function showHelp() {
18
+ console.log(`
19
+ 🧪 Bun Test UI - A beautiful UI for running Bun tests
20
+
21
+ Usage:
22
+ buntestui <command>
23
+
24
+ Commands:
25
+ run Start the test UI (production mode)
26
+ dev Start in development mode (hot reload enabled)
27
+ help Show this help message
28
+
29
+ Examples:
30
+ buntestui run # Run in production mode
31
+ buntestui dev # Run in development mode (for testing)
32
+ `);
33
+ }
34
+
35
+ async function buildFrontend() {
36
+ console.log("🏗️ Building frontend...\n");
37
+
38
+ const appDir = join(__dirname, "app");
39
+
40
+ return new Promise<void>((resolve, reject) => {
41
+ const proc = spawn("bun", ["run", "build"], {
42
+ cwd: appDir,
43
+ stdio: "inherit",
44
+ shell: true
45
+ });
46
+
47
+ proc.on("close", (code) => {
48
+ if (code === 0) {
49
+ console.log("\n✅ Frontend built successfully!");
50
+ resolve();
51
+ } else {
52
+ reject(new Error(`Frontend build failed with code ${code}`));
53
+ }
54
+ });
55
+
56
+ proc.on("error", (err) => {
57
+ reject(err);
58
+ });
59
+ });
60
+ }
61
+
62
+ async function checkBuildExists(): Promise<boolean> {
63
+ try {
64
+ const distPath = join(__dirname, "app", "dist", "index.html");
65
+ await access(distPath);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ async function runTestUI() {
73
+ // Verifica se o build do frontend existe
74
+ const buildExists = await checkBuildExists();
75
+
76
+ if (!buildExists) {
77
+ console.log("⚠️ Frontend assets not found.\n");
78
+ console.log("If you are running from source, please run: bun run build");
79
+ console.log("If you installed via npm, this might be a packaging issue.\n");
80
+ process.exit(1);
81
+ }
82
+
83
+ console.log("🚀 Starting Bun Test UI (Production Mode)...\n");
84
+ console.log("📡 WebSocket server: ws://localhost:5050/ws");
85
+ console.log("🌐 Frontend: http://localhost:5050\n");
86
+ console.log("Press Ctrl+C to stop\n");
87
+
88
+ // Roda o script do backend diretamente com Bun
89
+ // O usuário OBRIGATORIAMENTE tem Bun instalado para usar esta ferramenta
90
+ const runnerScript = join(__dirname, "ui-runner.ts");
91
+
92
+ const proc = spawn("bun", ["run", runnerScript], {
93
+ cwd: process.cwd(), // Roda no diretório atual do usuário
94
+ stdio: "inherit",
95
+ shell: false,
96
+ env: { ...process.env, NODE_ENV: "production" }
97
+ });
98
+
99
+ proc.on("close", (code) => {
100
+ if (code !== 0) {
101
+ console.error(`\n❌ Process exited with code ${code}`);
102
+ process.exit(code || 1);
103
+ }
104
+ });
105
+
106
+ proc.on("error", (err) => {
107
+ console.error("❌ Error starting test UI:", err);
108
+ process.exit(1);
109
+ });
110
+
111
+ // Handle Ctrl+C
112
+ process.on("SIGINT", () => {
113
+ console.log("\n\n👋 Stopping Bun Test UI...");
114
+ proc.kill("SIGINT");
115
+ process.exit(0);
116
+ });
117
+ }
118
+
119
+ async function runDevMode() {
120
+ console.log("🚀 Starting Bun Test UI (Development Mode)...\n");
121
+ console.log("📡 WebSocket server: ws://localhost:5060");
122
+ console.log("🌐 Frontend: http://localhost:5050 (with hot reload)\n");
123
+ console.log("Press Ctrl+C to stop\n");
124
+
125
+ // Inicia o backend (ui-runner.ts) com bun run e flag de dev mode
126
+ const backendPath = join(__dirname, "ui-runner.ts");
127
+ const backendProc = spawn("bun", ["run", backendPath], {
128
+ cwd: process.cwd(),
129
+ stdio: "inherit",
130
+ shell: true,
131
+ env: { ...process.env, BUN_TEST_UI_DEV: "true" }
132
+ });
133
+
134
+ // Aguarda um pouco para o backend iniciar
135
+ await new Promise(resolve => setTimeout(resolve, 1000));
136
+
137
+ // Inicia o frontend em modo dev
138
+ const appDir = join(__dirname, "app");
139
+ const frontendProc = spawn("bun", ["run", "dev"], {
140
+ cwd: appDir,
141
+ stdio: "inherit",
142
+ shell: true
143
+ });
144
+
145
+ // Handle Ctrl+C
146
+ process.on("SIGINT", () => {
147
+ console.log("\n\n👋 Stopping Bun Test UI...");
148
+ backendProc.kill("SIGINT");
149
+ frontendProc.kill("SIGINT");
150
+ process.exit(0);
151
+ });
152
+
153
+ // Se um processo terminar, termina o outro também
154
+ backendProc.on("close", (code) => {
155
+ console.log("\n❌ Backend stopped");
156
+ frontendProc.kill("SIGINT");
157
+ process.exit(code || 1);
158
+ });
159
+
160
+ frontendProc.on("close", (code) => {
161
+ console.log("\n❌ Frontend stopped");
162
+ backendProc.kill("SIGINT");
163
+ process.exit(code || 1);
164
+ });
165
+ }
166
+
167
+ // Main
168
+ const command = process.argv[2];
169
+
170
+ switch (command) {
171
+ case "run":
172
+ runTestUI()
173
+ .catch((err) => {
174
+ console.error("❌ Failed to start:", err);
175
+ process.exit(1);
176
+ });
177
+ break;
178
+
179
+ case "dev":
180
+ runDevMode()
181
+ .catch((err) => {
182
+ console.error("❌ Failed to start dev mode:", err);
183
+ process.exit(1);
184
+ });
185
+ break;
186
+
187
+ case "help":
188
+ case undefined:
189
+ showHelp();
190
+ process.exit(0);
191
+ break;
192
+
193
+ default:
194
+ console.error(`❌ Unknown command: ${command}\n`);
195
+ showHelp();
196
+ process.exit(1);
197
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "bun-ui-tests",
3
+ "version": "1.0.0",
4
+ "description": "A beautiful UI for running Bun tests",
5
+ "type": "module",
6
+ "bin": {
7
+ "buntestui": "cli.ts"
8
+ },
9
+ "files": [
10
+ "cli.ts",
11
+ "ui-runner.ts",
12
+ "app/dist"
13
+ ],
14
+ "scripts": {
15
+ "dev": "bun run ui-runner.ts",
16
+ "prepublishOnly": "cd app && bun install && bun run build"
17
+ },
18
+ "keywords": [
19
+ "bun",
20
+ "test",
21
+ "ui",
22
+ "testing",
23
+ "runner"
24
+ ],
25
+ "author": "killd",
26
+ "license": "MIT"
27
+ }
package/ui-runner.ts ADDED
@@ -0,0 +1,597 @@
1
+ /**
2
+ * UI Runner for Bun Test
3
+ *
4
+ * Este runner NÃO usa APIs internas do bun:test (que não existem publicamente).
5
+ * Ele apenas spawna um processo `bun test` e faz parsing do stdout/stderr.
6
+ *
7
+ * Arquitetura:
8
+ * - Spawna `bun test` como processo filho
9
+ * - Captura stdout/stderr em tempo real
10
+ * - Faz parsing básico da saída (✓, ✗, nomes de testes)
11
+ * - Emite eventos via WebSocket para a UI
12
+ */
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { readdir, readFile, stat } from "node:fs/promises";
16
+ import { join, relative, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { existsSync } from "node:fs";
19
+
20
+ // Determina o diretório do executável ou script
21
+ const getBaseDir = () => {
22
+ // Se rodando como executável compilado, pega o diretório onde o CLI foi instalado
23
+ // O executável fica na raiz do pacote, então app/dist fica relativo a ele
24
+ if (import.meta.path && !import.meta.path.endsWith('.ts')) {
25
+ // É um executável compilado - usa process.execPath (path real do executável)
26
+ // import.meta.path retorna um path virtual do bunfs
27
+ return dirname(process.execPath);
28
+ }
29
+ // Se rodando como script .ts em dev, usa o dirname do próprio arquivo
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ return dirname(__filename);
32
+ };
33
+
34
+ const baseDir = getBaseDir();
35
+ const distPath = join(baseDir, "app", "dist");
36
+ const isDevMode = process.env.BUN_TEST_UI_DEV === "true";
37
+
38
+ // WebSocket Handler (lógica compartilhada)
39
+ const websocketHandler = {
40
+ async open(ws: any) {
41
+ console.log("✓ UI connected");
42
+
43
+ // Escaneia arquivos de teste
44
+ const testFiles = await scanTestFiles();
45
+
46
+ // Escaneia todos os testes de cada arquivo
47
+ console.log("📖 Reading test files to extract test names...");
48
+ const testsMap = await scanAllTests();
49
+
50
+ // Envia evento de conexão com lista de arquivos e testes
51
+ ws.send(JSON.stringify({
52
+ type: "connected",
53
+ payload: {
54
+ message: "Runner ready",
55
+ testFiles,
56
+ testsMap // { "test/example.test.ts": ["test1", "test2", ...] }
57
+ }
58
+ }));
59
+
60
+ console.log(`✓ Found ${testFiles.length} test files with ${Object.values(testsMap).flat().length} tests total`);
61
+ },
62
+ message(ws: any, message: any) {
63
+ try {
64
+ const data = JSON.parse(message.toString());
65
+ console.log('📨 [WEBSOCKET] Mensagem recebida:', data.type, data.payload);
66
+
67
+ // Processa comandos da UI
68
+ if (data.type === "run:request") {
69
+ const file = data.payload?.file;
70
+ const testName = data.payload?.testName;
71
+
72
+ console.log('▶️ [RUN REQUEST] file:', file, 'testName:', testName);
73
+
74
+ if (testName) {
75
+ console.log(`Running specific test: ${testName} in ${file}`);
76
+ } else if (file) {
77
+ console.log(`Running file: ${file}`);
78
+ } else {
79
+ console.log("Running all tests");
80
+ }
81
+
82
+ runTests(ws, file, testName);
83
+ } else {
84
+ console.log('⚠️ [WEBSOCKET] Tipo desconhecido:', data.type);
85
+ }
86
+ } catch (err) {
87
+ console.error("Error processing message:", err);
88
+ }
89
+ },
90
+ close(ws: any) {
91
+ console.log("✗ UI disconnected");
92
+ },
93
+ };
94
+
95
+ if (isDevMode) {
96
+ // === MODO DESENVOLVIMENTO ===
97
+ // Frontend roda via Vite na porta 5050
98
+ // Backend roda separadamente na porta 5060 (apenas WS)
99
+
100
+ Bun.serve({
101
+ port: 5060,
102
+ fetch(req, server) {
103
+ // Aceita upgrade em qualquer path ou especificamente /ws
104
+ if (server.upgrade(req)) {
105
+ return;
106
+ }
107
+ return new Response("Bun Test UI Backend (Dev Mode)", { status: 200 });
108
+ },
109
+ websocket: websocketHandler
110
+ });
111
+
112
+ console.log("📡 WebSocket server running on ws://localhost:5060");
113
+
114
+ } else {
115
+ // === MODO PRODUÇÃO ===
116
+ // Servidor ÚNICO na porta 5050
117
+ // Serve arquivos estáticos do frontend E WebSocket no mesmo endpoint
118
+
119
+ const PORT = 5050;
120
+
121
+ Bun.serve({
122
+ port: PORT,
123
+ async fetch(req, server) {
124
+ const url = new URL(req.url);
125
+
126
+ // 1. WebSocket Upgrade (/ws)
127
+ if (url.pathname === "/ws") {
128
+ if (server.upgrade(req)) {
129
+ return;
130
+ }
131
+ return new Response("WebSocket upgrade failed", { status: 400 });
132
+ }
133
+
134
+ // 2. Arquivos Estáticos (Frontend)
135
+ if (existsSync(distPath)) {
136
+ let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
137
+ const fullPath = join(distPath, filePath);
138
+
139
+ try {
140
+ const file = Bun.file(fullPath);
141
+ if (await file.exists()) {
142
+ return new Response(file);
143
+ }
144
+
145
+ // Se não encontrou o arquivo e não é um asset (SPA fallback)
146
+ if (!filePath.includes(".")) {
147
+ const indexFile = Bun.file(join(distPath, "index.html"));
148
+ return new Response(indexFile);
149
+ }
150
+ } catch (err) {
151
+ console.error("Error serving file:", err);
152
+ }
153
+ } else {
154
+ return new Response("Frontend build not found. Run 'buntestui build' first.", { status: 404 });
155
+ }
156
+
157
+ return new Response("Not found", { status: 404 });
158
+ },
159
+ websocket: websocketHandler
160
+ });
161
+
162
+ console.log(`🚀 Server running on http://localhost:${PORT}`);
163
+ console.log(`📡 WebSocket endpoint available at ws://localhost:${PORT}/ws`);
164
+ }
165
+
166
+
167
+ /**
168
+ * Escaneia recursivamente todo o projeto e retorna lista de arquivos de teste
169
+ * Suporta os padrões do Bun: .test.ts, .test.js, .test.tsx, .test.jsx, _test.ts, _test.js, etc
170
+ */
171
+ async function scanTestFiles(): Promise<string[]> {
172
+ const testFiles: string[] = [];
173
+ const rootDir = process.cwd();
174
+
175
+ // Pastas a ignorar durante a busca recursiva
176
+ const ignoreDirs = new Set([
177
+ 'node_modules',
178
+ '.git',
179
+ 'dist',
180
+ 'build',
181
+ '.next',
182
+ '.svelte-kit',
183
+ 'coverage',
184
+ '.turbo',
185
+ '.cache',
186
+ 'out',
187
+ '.vercel',
188
+ '.netlify'
189
+ ]);
190
+
191
+ // Padrões de arquivos de teste suportados pelo Bun
192
+ const testPatterns = [
193
+ /\.test\.(ts|tsx|js|jsx)$/, // file.test.ts, file.test.js, etc
194
+ /\.spec\.(ts|tsx|js|jsx)$/, // file.spec.ts, file.spec.js, etc
195
+ /_test\.(ts|tsx|js|jsx)$/, // file_test.ts, file_test.js, etc
196
+ /_spec\.(ts|tsx|js|jsx)$/, // file_spec.ts, file_spec.js, etc
197
+ ];
198
+
199
+ async function scanDirectory(dir: string): Promise<void> {
200
+ try {
201
+ const entries = await readdir(dir, { withFileTypes: true });
202
+
203
+ for (const entry of entries) {
204
+ const fullPath = join(dir, entry.name);
205
+
206
+ if (entry.isDirectory()) {
207
+ // Ignora diretórios na lista de ignorados
208
+ if (!ignoreDirs.has(entry.name)) {
209
+ await scanDirectory(fullPath);
210
+ }
211
+ } else if (entry.isFile()) {
212
+ // Verifica se o arquivo corresponde a algum padrão de teste
213
+ if (testPatterns.some(pattern => pattern.test(entry.name))) {
214
+ // Retorna path relativo ao diretório raiz
215
+ const relativePath = relative(rootDir, fullPath);
216
+ testFiles.push(relativePath);
217
+ }
218
+ }
219
+ }
220
+ } catch (err) {
221
+ // Ignora erros de leitura de diretórios (permissões, etc)
222
+ console.error(`Error scanning directory ${dir}:`, err);
223
+ }
224
+ }
225
+
226
+ await scanDirectory(rootDir);
227
+
228
+ // Ordena os arquivos alfabeticamente
229
+ testFiles.sort();
230
+
231
+ return testFiles;
232
+ }
233
+
234
+ /**
235
+ * Extrai os nomes dos testes de um arquivo sem executá-lo
236
+ * Faz parsing do código para encontrar test(), it() e describe()
237
+ */
238
+ async function extractTestsFromFile(filePath: string): Promise<string[]> {
239
+ try {
240
+ const fullPath = join(process.cwd(), filePath);
241
+ const content = await readFile(fullPath, "utf-8");
242
+ const tests: string[] = [];
243
+
244
+ // Regex para capturar test("nome"), it("nome"), describe("nome")
245
+ // Suporta aspas simples e duplas
246
+ const testRegex = /(?:test|it|describe)\s*\(\s*[`"'](.+?)[`"']/g;
247
+
248
+ let match;
249
+ while ((match = testRegex.exec(content)) !== null) {
250
+ tests.push(match[1]);
251
+ }
252
+
253
+ return tests;
254
+ } catch (err) {
255
+ console.error(`Error reading file ${filePath}:`, err);
256
+ return [];
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Escaneia todos os arquivos e retorna um mapa de arquivo -> testes
262
+ */
263
+ async function scanAllTests(): Promise<Record<string, string[]>> {
264
+ const testFiles = await scanTestFiles();
265
+ const testsMap: Record<string, string[]> = {};
266
+
267
+ for (const file of testFiles) {
268
+ const tests = await extractTestsFromFile(file);
269
+ testsMap[file] = tests;
270
+ }
271
+
272
+ return testsMap;
273
+ }
274
+
275
+ /**
276
+ * Executa bun test e faz parsing da saída
277
+ * @param ws WebSocket connection
278
+ * @param file Arquivo específico para testar (opcional)
279
+ * @param testName Nome do teste específico (opcional, requer file)
280
+ */
281
+ function runTests(ws: any, file?: string, testName?: string) {
282
+ const args = ["test"];
283
+
284
+ if (file) {
285
+ args.push(file);
286
+ }
287
+
288
+ // Bun suporta --test-name-pattern para filtrar testes
289
+ // Escapa caracteres especiais de regex e usa o nome exato
290
+ if (testName) {
291
+ // Escapa caracteres especiais de regex
292
+ const escapedName = testName.replace(/[.*+?^${}()|[\\]/g, '\\$&');
293
+ args.push("--test-name-pattern", escapedName);
294
+ }
295
+
296
+ console.log('🚀 [RUN] Comando:', `bun ${args.join(" ")}`);
297
+ console.log(`Starting bun test ${args.slice(1).join(" ") || "(all)"}...`);
298
+
299
+ // Emite evento de início
300
+ ws.send(JSON.stringify({
301
+ type: "run:start",
302
+ payload: {
303
+ timestamp: Date.now(),
304
+ file: file || null,
305
+ testName: testName || null
306
+ }
307
+ }));
308
+
309
+ // Spawna o processo bun test
310
+ // IMPORTANTE: Não usamos nenhuma API interna do bun:test
311
+ const bunTest = spawn("bun", args, {
312
+ cwd: process.cwd(),
313
+ env: { ...process.env, FORCE_COLOR: "0" }, // Desabilita cores para facilitar parsing
314
+ });
315
+
316
+ let currentTestFile = "";
317
+ let buffer = "";
318
+ let errorBlock: string[] = []; // Acumula linhas de erro
319
+ let summaryBlock: string[] = []; // Acumula linhas de resumo
320
+ let inErrorBlock = false;
321
+ let inSummaryBlock = false;
322
+
323
+ // Função para verificar se é linha de bloco de erro
324
+ const isErrorLine = (line: string): boolean => {
325
+ const trimmed = line.trim();
326
+ // Linha que é só espaços seguidos de ^ (indicador de erro)
327
+ const hasErrorPointer = /^\s*\^/.test(line);
328
+
329
+ return (
330
+ /^\d+\s*\|/.test(trimmed) || // Linha de código: "72 |"
331
+ trimmed.startsWith('error:') || // "error: expect..."
332
+ trimmed.startsWith('Expected:') || // "Expected: 3"
333
+ trimmed.startsWith('Received:') || // "Received: 2"
334
+ trimmed.includes('at <anonymous>') || // Stack trace
335
+ hasErrorPointer || // Seta de erro " ^"
336
+ /^\s+at\s/.test(line) || // " at ..." stack trace
337
+ trimmed.startsWith('(fail)') // Linha de fail - FAZ PARTE DO BLOCO!
338
+ );
339
+ };
340
+
341
+ // Função para verificar se é linha de resumo
342
+ const isSummaryLine = (line: string): boolean => {
343
+ const trimmed = line.trim();
344
+ return (
345
+ /^\d+\s+pass$/.test(trimmed) || // "19 pass"
346
+ /^\d+\s+fail$/.test(trimmed) || // "1 fail"
347
+ /^\d+\s+expect\(\)\s+calls/.test(trimmed) || // "33 expect() calls"
348
+ /^Ran\s+\d+/.test(trimmed) // "Ran 20 tests..."
349
+ );
350
+ };
351
+
352
+ // Função para enviar bloco de erro
353
+ const flushErrorBlock = () => {
354
+ if (errorBlock.length > 0) {
355
+ console.log('📦 [ERROR BLOCK] Enviando bloco com', errorBlock.length, 'linhas:');
356
+ console.log('---START---');
357
+ console.log(errorBlock.join('\n'));
358
+ console.log('---END---');
359
+
360
+ ws.send(JSON.stringify({
361
+ type: "log",
362
+ payload: { message: errorBlock.join('\n'), stream: "stdout" }
363
+ }));
364
+ errorBlock = [];
365
+ inErrorBlock = false;
366
+ }
367
+ };
368
+
369
+ // Função para enviar bloco de resumo
370
+ const flushSummaryBlock = () => {
371
+ if (summaryBlock.length > 0) {
372
+ console.log('📊 [SUMMARY BLOCK] Enviando bloco com', summaryBlock.length, 'linhas:');
373
+ console.log('---START---');
374
+ console.log(summaryBlock.join('\n'));
375
+ console.log('---END---');
376
+
377
+ ws.send(JSON.stringify({
378
+ type: "log",
379
+ payload: { message: summaryBlock.join('\n'), stream: "stdout" }
380
+ }));
381
+ summaryBlock = [];
382
+ inSummaryBlock = false;
383
+ }
384
+ };
385
+
386
+ // Processa stdout linha por linha
387
+ bunTest.stdout.on("data", (data) => {
388
+ const text = data.toString();
389
+ buffer += text;
390
+
391
+ // Processa linhas completas
392
+ const lines = buffer.split("\n");
393
+ buffer = lines.pop() || ""; // Guarda última linha incompleta
394
+
395
+ for (const line of lines) {
396
+ currentTestFile = processLine(line, ws, currentTestFile);
397
+
398
+ // Linhas vazias podem fazer parte de blocos
399
+ const trimmed = line.trim();
400
+
401
+ // Verifica se é linha de erro
402
+ if (isErrorLine(line)) {
403
+ console.log('🔴 [ERROR LINE] Detectado:', line.substring(0, 50));
404
+ // Se estava no resumo, envia o resumo primeiro
405
+ flushSummaryBlock();
406
+
407
+ inErrorBlock = true;
408
+ errorBlock.push(line);
409
+ continue;
410
+ }
411
+
412
+ // Linha vazia dentro de bloco de erro - mantém no bloco
413
+ if (inErrorBlock && !trimmed) {
414
+ console.log('🔴 [ERROR EMPTY] Linha vazia no bloco de erro');
415
+ errorBlock.push(line);
416
+ continue;
417
+ }
418
+
419
+ // Verifica se é linha de resumo
420
+ if (isSummaryLine(line)) {
421
+ console.log('📊 [SUMMARY LINE] Detectado:', line.substring(0, 50));
422
+ // Se estava no erro, envia o erro primeiro
423
+ flushErrorBlock();
424
+
425
+ inSummaryBlock = true;
426
+ summaryBlock.push(line);
427
+ continue;
428
+ }
429
+
430
+ // Linha normal - envia blocos pendentes e depois a linha
431
+ flushErrorBlock();
432
+ flushSummaryBlock();
433
+
434
+ // Envia linhas normais separadamente (se não vazia)
435
+ if (trimmed) {
436
+ console.log('📝 [NORMAL LINE] Enviando:', line.substring(0, 50));
437
+ ws.send(JSON.stringify({
438
+ type: "log",
439
+ payload: { message: line, stream: "stdout" }
440
+ }));
441
+ }
442
+ }
443
+ });
444
+
445
+ bunTest.stderr.on("data", (data) => {
446
+ const text = data.toString();
447
+
448
+ // Envia stderr linha por linha também
449
+ const lines = text.split("\n").filter(line => line.trim());
450
+ for (const line of lines) {
451
+ ws.send(JSON.stringify({
452
+ type: "log",
453
+ payload: { message: line, stream: "stderr" }
454
+ }));
455
+ }
456
+ });
457
+
458
+ bunTest.on("close", (code) => {
459
+ console.log(`bun test exited with code ${code}`);
460
+
461
+ // Processa buffer pendente antes de finalizar
462
+ if (buffer.trim()) {
463
+ console.log('⚠️ [CLOSE] Buffer pendente:', buffer.substring(0, 100));
464
+ const lines = buffer.split("\n");
465
+ for (const line of lines) {
466
+ if (line.trim()) {
467
+ currentTestFile = processLine(line, ws, currentTestFile);
468
+
469
+ const trimmed = line.trim();
470
+
471
+ if (isErrorLine(line)) {
472
+ inErrorBlock = true;
473
+ errorBlock.push(line);
474
+ } else if (inErrorBlock && !trimmed) {
475
+ errorBlock.push(line);
476
+ } else if (isSummaryLine(line)) {
477
+ flushErrorBlock();
478
+ inSummaryBlock = true;
479
+ summaryBlock.push(line);
480
+ } else {
481
+ flushErrorBlock();
482
+ flushSummaryBlock();
483
+ ws.send(JSON.stringify({
484
+ type: "log",
485
+ payload: { message: line, stream: "stdout" }
486
+ }));
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ // Envia blocos pendentes antes de finalizar
493
+ flushErrorBlock();
494
+ flushSummaryBlock();
495
+
496
+ console.log('✅ [CLOSE] Processo finalizado, todos os logs enviados');
497
+
498
+ ws.send(JSON.stringify({
499
+ type: "run:complete",
500
+ payload: {
501
+ exitCode: code,
502
+ timestamp: Date.now()
503
+ }
504
+ }));
505
+ });
506
+
507
+ bunTest.on("error", (error) => {
508
+ console.error("Error running bun test:", error);
509
+
510
+ ws.send(JSON.stringify({
511
+ type: "error",
512
+ payload: { message: error.message }
513
+ }));
514
+ });
515
+ }
516
+
517
+ /**
518
+ * Faz parsing de uma linha de saída do bun test
519
+ *
520
+ * Formatos esperados (baseado na saída real do bun test):
521
+ * - ✓ test name [0.00ms]
522
+ * - ✗ test name [0.00ms]
523
+ * - test/file.test.ts:
524
+ *
525
+ * @returns O currentTestFile atualizado
526
+ */
527
+ function processLine(line: string, ws: any, currentTestFile: string): string {
528
+ const trimmed = line.trim();
529
+
530
+ if (!trimmed) return currentTestFile;
531
+
532
+ // Detecta arquivo de teste (com suporte a todos os padrões)
533
+ if (trimmed.match(/\.(test|spec)\.(ts|js|tsx|jsx):/) || trimmed.match(/_(test|spec)\.(ts|js|tsx|jsx):/)) {
534
+ const filePath = trimmed.replace(":", "");
535
+
536
+ // Envia quebra de linha antes do nome do arquivo para separar visualmente
537
+ ws.send(JSON.stringify({
538
+ type: "log",
539
+ payload: { message: "", stream: "stdout" }
540
+ }));
541
+
542
+ ws.send(JSON.stringify({
543
+ type: "file:start",
544
+ payload: { filePath }
545
+ }));
546
+ return filePath; // Atualiza o arquivo atual
547
+ }
548
+
549
+ // Detecta teste que passou - formato: (pass) test name [0.00ms]
550
+ const passMatch = trimmed.match(/^\(pass\)\s+(.+?)\s+\[(.+?)\]/);
551
+ if (passMatch) {
552
+ const [, testName, duration] = passMatch;
553
+
554
+ ws.send(JSON.stringify({
555
+ type: "test:pass",
556
+ payload: {
557
+ testName: testName.trim(),
558
+ duration: duration.trim(),
559
+ timestamp: Date.now(),
560
+ filePath: currentTestFile
561
+ }
562
+ }));
563
+ return currentTestFile;
564
+ }
565
+
566
+ // Detecta teste que falhou - formato: (fail) test name [0.00ms]
567
+ const failMatch = trimmed.match(/^\(fail\)\s+(.+?)(?:\s+\[(.+?)\])?/);
568
+ if (failMatch) {
569
+ const [, testName, duration] = failMatch;
570
+
571
+ ws.send(JSON.stringify({
572
+ type: "test:fail",
573
+ payload: {
574
+ testName: testName.trim(),
575
+ duration: duration?.trim() || "N/A",
576
+ timestamp: Date.now(),
577
+ filePath: currentTestFile
578
+ }
579
+ }));
580
+ return currentTestFile;
581
+ }
582
+
583
+ // Detecta início de teste (quando aparece sem ✓ ou ✗)
584
+ // Isso pode variar dependendo da versão do Bun
585
+ if (trimmed.match(/^(test|it|describe)\s/)) {
586
+ ws.send(JSON.stringify({
587
+ type: "test:start",
588
+ payload: {
589
+ testName: trimmed,
590
+ timestamp: Date.now(),
591
+ filePath: currentTestFile
592
+ }
593
+ }));
594
+ }
595
+
596
+ return currentTestFile;
597
+ }