| 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'; |
|
|
| |
| vi.mock('child_process', () => ({ |
| spawn: vi.fn(), |
| execSync: vi.fn(), |
| execFile: vi.fn(), |
| })); |
|
|
| |
| vi.mock('@/lib/secure-fs.js', () => ({ |
| access: vi.fn(), |
| })); |
|
|
| |
| 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(); |
|
|
| |
| vi.mocked(secureFs.access).mockResolvedValue(undefined); |
|
|
| |
| 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); |
|
|
| |
| 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 { |
| |
| } |
| }); |
|
|
| 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); |
|
|
| |
| vi.mocked(spawn).mockImplementation(() => { |
| const p = createMockProcess(); |
| |
| return p as any; |
| }); |
|
|
| |
| const promise1 = service.startDevServer(worktreeDir, worktreeDir); |
|
|
| |
| 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 () => { |
| |
| 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)); |
|
|
| |
| const mockServer = new EventEmitter() as any; |
| mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { |
| |
| 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 () => { |
| |
| 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)); |
|
|
| |
| 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); |
|
|
| |
| await new Promise((resolve) => setTimeout(resolve, 100)); |
|
|
| |
| 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); |
|
|
| |
| mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5555/\n')); |
|
|
| |
| 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); |
| }); |
| }); |
|
|
| |
| 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; |
| } |
|
|