Spaces:
Paused
Paused
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | |
| import { EventEmitter } from 'events'; | |
| import path from 'path'; | |
| import os from 'os'; | |
| import fs from 'fs/promises'; | |
| import { spawn, execSync } from 'child_process'; | |
| // Mock child_process | |
| vi.mock('child_process', () => ({ | |
| spawn: vi.fn(), | |
| execSync: vi.fn(), | |
| execFile: vi.fn(), | |
| })); | |
| // Mock secure-fs | |
| vi.mock('@/lib/secure-fs.js', () => ({ | |
| access: vi.fn(), | |
| })); | |
| // Mock net | |
| vi.mock('net', () => ({ | |
| default: { | |
| createServer: vi.fn(), | |
| }, | |
| createServer: vi.fn(), | |
| })); | |
| import * as secureFs from '@/lib/secure-fs.js'; | |
| import net from 'net'; | |
| describe('DevServerService Persistence & Sync', () => { | |
| let testDataDir: string; | |
| let worktreeDir: string; | |
| let mockEmitter: EventEmitter; | |
| beforeEach(async () => { | |
| vi.clearAllMocks(); | |
| vi.resetModules(); | |
| testDataDir = path.join(os.tmpdir(), `dev-server-persistence-test-${Date.now()}`); | |
| worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-test-${Date.now()}`); | |
| await fs.mkdir(testDataDir, { recursive: true }); | |
| await fs.mkdir(worktreeDir, { recursive: true }); | |
| mockEmitter = new EventEmitter(); | |
| // Default mock for secureFs.access - return resolved (file exists) | |
| vi.mocked(secureFs.access).mockResolvedValue(undefined); | |
| // Default mock for net.createServer - port available | |
| const mockServer = new EventEmitter() as any; | |
| mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { | |
| process.nextTick(() => mockServer.emit('listening')); | |
| }); | |
| mockServer.close = vi.fn(); | |
| vi.mocked(net.createServer).mockReturnValue(mockServer); | |
| // Default mock for execSync - no process on port | |
| vi.mocked(execSync).mockImplementation(() => { | |
| throw new Error('No process found'); | |
| }); | |
| }); | |
| afterEach(async () => { | |
| try { | |
| await fs.rm(testDataDir, { recursive: true, force: true }); | |
| await fs.rm(worktreeDir, { recursive: true, force: true }); | |
| } catch { | |
| // Ignore cleanup errors | |
| } | |
| }); | |
| it('should emit dev-server:starting when startDevServer is called', async () => { | |
| const { getDevServerService } = await import('@/services/dev-server-service.js'); | |
| const service = getDevServerService(); | |
| await service.initialize(testDataDir, mockEmitter as any); | |
| const mockProcess = createMockProcess(); | |
| vi.mocked(spawn).mockReturnValue(mockProcess as any); | |
| const events: any[] = []; | |
| mockEmitter.on('dev-server:starting', (payload) => events.push(payload)); | |
| await service.startDevServer(worktreeDir, worktreeDir); | |
| expect(events.length).toBe(1); | |
| expect(events[0].worktreePath).toBe(worktreeDir); | |
| }); | |
| it('should prevent concurrent starts for the same worktree', async () => { | |
| const { getDevServerService } = await import('@/services/dev-server-service.js'); | |
| const service = getDevServerService(); | |
| await service.initialize(testDataDir, mockEmitter as any); | |
| // Delay spawn to simulate long starting time | |
| vi.mocked(spawn).mockImplementation(() => { | |
| const p = createMockProcess(); | |
| // Don't return immediately, simulate some work | |
| return p as any; | |
| }); | |
| // Start first one (don't await yet if we want to test concurrency) | |
| const promise1 = service.startDevServer(worktreeDir, worktreeDir); | |
| // Try to start second one immediately | |
| const result2 = await service.startDevServer(worktreeDir, worktreeDir); | |
| expect(result2.success).toBe(false); | |
| expect(result2.error).toContain('already starting'); | |
| await promise1; | |
| }); | |
| it('should persist state to dev-servers.json when started', async () => { | |
| const { getDevServerService } = await import('@/services/dev-server-service.js'); | |
| const service = getDevServerService(); | |
| await service.initialize(testDataDir, mockEmitter as any); | |
| const mockProcess = createMockProcess(); | |
| vi.mocked(spawn).mockReturnValue(mockProcess as any); | |
| await service.startDevServer(worktreeDir, worktreeDir); | |
| const statePath = path.join(testDataDir, 'dev-servers.json'); | |
| const exists = await fs | |
| .access(statePath) | |
| .then(() => true) | |
| .catch(() => false); | |
| expect(exists).toBe(true); | |
| const content = await fs.readFile(statePath, 'utf-8'); | |
| const state = JSON.parse(content); | |
| expect(state.length).toBe(1); | |
| expect(state[0].worktreePath).toBe(worktreeDir); | |
| }); | |
| it('should load state from dev-servers.json on initialize', async () => { | |
| // 1. Create a fake state file | |
| const persistedInfo = [ | |
| { | |
| worktreePath: worktreeDir, | |
| allocatedPort: 3005, | |
| port: 3005, | |
| url: 'http://localhost:3005', | |
| startedAt: new Date().toISOString(), | |
| urlDetected: true, | |
| customCommand: 'npm run dev', | |
| }, | |
| ]; | |
| await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo)); | |
| // 2. Mock port as IN USE (so it re-attaches) | |
| const mockServer = new EventEmitter() as any; | |
| mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { | |
| // Fail to listen = port in use | |
| process.nextTick(() => mockServer.emit('error', new Error('EADDRINUSE'))); | |
| }); | |
| vi.mocked(net.createServer).mockReturnValue(mockServer); | |
| const { getDevServerService } = await import('@/services/dev-server-service.js'); | |
| const service = getDevServerService(); | |
| await service.initialize(testDataDir, mockEmitter as any); | |
| expect(service.isRunning(worktreeDir)).toBe(true); | |
| const info = service.getServerInfo(worktreeDir); | |
| expect(info?.port).toBe(3005); | |
| }); | |
| it('should prune stale servers from state on initialize if port is available', async () => { | |
| // 1. Create a fake state file | |
| const persistedInfo = [ | |
| { | |
| worktreePath: worktreeDir, | |
| allocatedPort: 3005, | |
| port: 3005, | |
| url: 'http://localhost:3005', | |
| startedAt: new Date().toISOString(), | |
| urlDetected: true, | |
| }, | |
| ]; | |
| await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo)); | |
| // 2. Mock port as AVAILABLE (so it prunes) | |
| const mockServer = new EventEmitter() as any; | |
| mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { | |
| process.nextTick(() => mockServer.emit('listening')); | |
| }); | |
| mockServer.close = vi.fn(); | |
| vi.mocked(net.createServer).mockReturnValue(mockServer); | |
| const { getDevServerService } = await import('@/services/dev-server-service.js'); | |
| const service = getDevServerService(); | |
| await service.initialize(testDataDir, mockEmitter as any); | |
| expect(service.isRunning(worktreeDir)).toBe(false); | |
| // Give it a moment to complete the pruning saveState | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| // Check if file was updated | |
| const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8'); | |
| const state = JSON.parse(content); | |
| expect(state.length).toBe(0); | |
| }); | |
| it('should update persisted state when URL is detected', async () => { | |
| const { getDevServerService } = await import('@/services/dev-server-service.js'); | |
| const service = getDevServerService(); | |
| await service.initialize(testDataDir, mockEmitter as any); | |
| const mockProcess = createMockProcess(); | |
| vi.mocked(spawn).mockReturnValue(mockProcess as any); | |
| await service.startDevServer(worktreeDir, worktreeDir); | |
| // Simulate output with URL | |
| mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5555/\n')); | |
| // Give it a moment to process and save (needs to wait for saveQueue) | |
| await new Promise((resolve) => setTimeout(resolve, 300)); | |
| const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8'); | |
| const state = JSON.parse(content); | |
| expect(state[0].url).toBe('http://localhost:5555/'); | |
| expect(state[0].port).toBe(5555); | |
| expect(state[0].urlDetected).toBe(true); | |
| }); | |
| }); | |
| // Helper to create a mock child process | |
| function createMockProcess() { | |
| const mockProcess = new EventEmitter() as any; | |
| mockProcess.stdout = new EventEmitter(); | |
| mockProcess.stderr = new EventEmitter(); | |
| mockProcess.kill = vi.fn(); | |
| mockProcess.killed = false; | |
| mockProcess.pid = 12345; | |
| mockProcess.unref = vi.fn(); | |
| return mockProcess; | |
| } | |