mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +00:00
Fix #684: Prevent Windows reserved filename creation - Add sanitizeFilename() utility to detect and prefix Windows reserved names (NUL, CON, PRN, AUX, COM1-9, LPT1-9) - Apply sanitization to save-image route to prevent "nul" file creation - Add 23 comprehensive tests for filename sanitization edge cases Fix #576: Detect actual dev server port from output - Parse stdout/stderr for real server URLs (Vite, Next.js, generic formats) - Update server URL when detected instead of using allocated PORT - Emit dev-server:url-detected event for frontend updates - Add 6 tests for URL detection patterns Fix #193: Commit only feature-specific changes - Change from 'git add -A' to branch-aware file staging - Use git diff to find files changed on feature branch only - Prevent committing unrelated changes from other features - Maintain backward compatibility with main branch workflow All fixes include comprehensive tests and maintain backward compatibility. Test results: 1,968 tests passed (547 package + 1,421 server tests)
538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
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';
|
|
|
|
// 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 { spawn, execSync } from 'child_process';
|
|
import * as secureFs from '@/lib/secure-fs.js';
|
|
import net from 'net';
|
|
|
|
describe('dev-server-service.ts', () => {
|
|
let testDir: string;
|
|
let originalHostname: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
|
|
// Store and set HOSTNAME for consistent test behavior
|
|
originalHostname = process.env.HOSTNAME;
|
|
process.env.HOSTNAME = 'localhost';
|
|
|
|
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
|
|
await fs.mkdir(testDir, { recursive: true });
|
|
|
|
// 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 () => {
|
|
// Restore original HOSTNAME
|
|
if (originalHostname === undefined) {
|
|
delete process.env.HOSTNAME;
|
|
} else {
|
|
process.env.HOSTNAME = originalHostname;
|
|
}
|
|
|
|
try {
|
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe('getDevServerService', () => {
|
|
it('should return a singleton instance', async () => {
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
|
|
const instance1 = getDevServerService();
|
|
const instance2 = getDevServerService();
|
|
|
|
expect(instance1).toBe(instance2);
|
|
});
|
|
});
|
|
|
|
describe('startDevServer', () => {
|
|
it('should return error if worktree path does not exist', async () => {
|
|
vi.mocked(secureFs.access).mockRejectedValueOnce(new Error('File not found'));
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
const result = await service.startDevServer('/project', '/nonexistent/worktree');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('does not exist');
|
|
});
|
|
|
|
it('should return error if no package.json found', async () => {
|
|
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
|
|
if (typeof p === 'string' && p.includes('package.json')) {
|
|
throw new Error('File not found');
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
const result = await service.startDevServer(testDir, testDir);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('No package.json found');
|
|
});
|
|
|
|
it('should detect npm as package manager with package-lock.json', async () => {
|
|
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
|
|
const pathStr = typeof p === 'string' ? p : '';
|
|
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
|
|
if (pathStr.includes('pnpm-lock.yaml')) throw new Error('Not found');
|
|
if (pathStr.includes('yarn.lock')) throw new Error('Not found');
|
|
if (pathStr.includes('package-lock.json')) return undefined;
|
|
if (pathStr.includes('package.json')) return undefined;
|
|
return undefined;
|
|
});
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
expect(spawn).toHaveBeenCalledWith('npm', ['run', 'dev'], expect.any(Object));
|
|
});
|
|
|
|
it('should detect yarn as package manager with yarn.lock', async () => {
|
|
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
|
|
const pathStr = typeof p === 'string' ? p : '';
|
|
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
|
|
if (pathStr.includes('pnpm-lock.yaml')) throw new Error('Not found');
|
|
if (pathStr.includes('yarn.lock')) return undefined;
|
|
if (pathStr.includes('package.json')) return undefined;
|
|
return undefined;
|
|
});
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
expect(spawn).toHaveBeenCalledWith('yarn', ['dev'], expect.any(Object));
|
|
});
|
|
|
|
it('should detect pnpm as package manager with pnpm-lock.yaml', async () => {
|
|
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
|
|
const pathStr = typeof p === 'string' ? p : '';
|
|
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
|
|
if (pathStr.includes('pnpm-lock.yaml')) return undefined;
|
|
if (pathStr.includes('package.json')) return undefined;
|
|
return undefined;
|
|
});
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
expect(spawn).toHaveBeenCalledWith('pnpm', ['run', 'dev'], expect.any(Object));
|
|
});
|
|
|
|
it('should detect bun as package manager with bun.lockb', async () => {
|
|
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
|
|
const pathStr = typeof p === 'string' ? p : '';
|
|
if (pathStr.includes('bun.lockb')) return undefined;
|
|
if (pathStr.includes('package.json')) return undefined;
|
|
return undefined;
|
|
});
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
expect(spawn).toHaveBeenCalledWith('bun', ['run', 'dev'], expect.any(Object));
|
|
});
|
|
|
|
it('should return existing server info if already running', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
// Start first server
|
|
const result1 = await service.startDevServer(testDir, testDir);
|
|
expect(result1.success).toBe(true);
|
|
|
|
// Try to start again - should return existing
|
|
const result2 = await service.startDevServer(testDir, testDir);
|
|
expect(result2.success).toBe(true);
|
|
expect(result2.result?.message).toContain('already running');
|
|
});
|
|
|
|
it('should start dev server successfully', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
const result = await service.startDevServer(testDir, testDir);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.result).toBeDefined();
|
|
expect(result.result?.port).toBeGreaterThanOrEqual(3001);
|
|
expect(result.result?.url).toContain('http://localhost:');
|
|
});
|
|
});
|
|
|
|
describe('stopDevServer', () => {
|
|
it('should return success if server not found', async () => {
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
const result = await service.stopDevServer('/nonexistent/path');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.result?.message).toContain('already stopped');
|
|
});
|
|
|
|
it('should stop a running server', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
// Start server
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
// Stop server
|
|
const result = await service.stopDevServer(testDir);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
|
});
|
|
});
|
|
|
|
describe('listDevServers', () => {
|
|
it('should return empty list when no servers running', async () => {
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
const result = service.listDevServers();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.result.servers).toEqual([]);
|
|
});
|
|
|
|
it('should list running servers', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
const result = service.listDevServers();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.result.servers.length).toBeGreaterThanOrEqual(1);
|
|
expect(result.result.servers[0].worktreePath).toBe(testDir);
|
|
});
|
|
});
|
|
|
|
describe('isRunning', () => {
|
|
it('should return false for non-running server', async () => {
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
expect(service.isRunning('/some/path')).toBe(false);
|
|
});
|
|
|
|
it('should return true for running server', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
expect(service.isRunning(testDir)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getServerInfo', () => {
|
|
it('should return undefined for non-running server', async () => {
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
expect(service.getServerInfo('/some/path')).toBeUndefined();
|
|
});
|
|
|
|
it('should return info for running server', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
const info = service.getServerInfo(testDir);
|
|
expect(info).toBeDefined();
|
|
expect(info?.worktreePath).toBe(testDir);
|
|
expect(info?.port).toBeGreaterThanOrEqual(3001);
|
|
});
|
|
});
|
|
|
|
describe('getAllocatedPorts', () => {
|
|
it('should return allocated ports', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
const ports = service.getAllocatedPorts();
|
|
expect(ports.length).toBeGreaterThanOrEqual(1);
|
|
expect(ports[0]).toBeGreaterThanOrEqual(3001);
|
|
});
|
|
});
|
|
|
|
describe('stopAll', () => {
|
|
it('should stop all running servers', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
await service.stopAll();
|
|
|
|
expect(service.listDevServers().result.servers).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('URL detection from output', () => {
|
|
it('should detect Vite format URL', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
// Start server
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
// Simulate Vite output
|
|
mockProcess.stdout.emit('data', Buffer.from(' VITE v5.0.0 ready in 123 ms\n'));
|
|
mockProcess.stdout.emit('data', Buffer.from(' ➜ Local: http://localhost:5173/\n'));
|
|
|
|
// Give it a moment to process
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
const serverInfo = service.getServerInfo(testDir);
|
|
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
|
expect(serverInfo?.urlDetected).toBe(true);
|
|
});
|
|
|
|
it('should detect Next.js format URL', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
// Simulate Next.js output
|
|
mockProcess.stdout.emit(
|
|
'data',
|
|
Buffer.from('ready - started server on 0.0.0.0:3000, url: http://localhost:3000\n')
|
|
);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
const serverInfo = service.getServerInfo(testDir);
|
|
expect(serverInfo?.url).toBe('http://localhost:3000');
|
|
expect(serverInfo?.urlDetected).toBe(true);
|
|
});
|
|
|
|
it('should detect generic localhost URL', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
// Simulate generic output with URL
|
|
mockProcess.stdout.emit('data', Buffer.from('Server running at http://localhost:8080\n'));
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
const serverInfo = service.getServerInfo(testDir);
|
|
expect(serverInfo?.url).toBe('http://localhost:8080');
|
|
expect(serverInfo?.urlDetected).toBe(true);
|
|
});
|
|
|
|
it('should keep initial URL if no URL detected in output', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
const result = await service.startDevServer(testDir, testDir);
|
|
|
|
// Simulate output without URL
|
|
mockProcess.stdout.emit('data', Buffer.from('Server starting...\n'));
|
|
mockProcess.stdout.emit('data', Buffer.from('Ready!\n'));
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
const serverInfo = service.getServerInfo(testDir);
|
|
// Should keep the initial allocated URL
|
|
expect(serverInfo?.url).toBe(result.result?.url);
|
|
expect(serverInfo?.urlDetected).toBe(false);
|
|
});
|
|
|
|
it('should detect HTTPS URLs', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
// Simulate HTTPS dev server
|
|
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
const serverInfo = service.getServerInfo(testDir);
|
|
expect(serverInfo?.url).toBe('https://localhost:3443');
|
|
expect(serverInfo?.urlDetected).toBe(true);
|
|
});
|
|
|
|
it('should only detect URL once (not update after first detection)', async () => {
|
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
|
|
const mockProcess = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
|
|
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
|
const service = getDevServerService();
|
|
|
|
await service.startDevServer(testDir, testDir);
|
|
|
|
// First URL
|
|
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
const firstUrl = service.getServerInfo(testDir)?.url;
|
|
|
|
// Try to emit another URL
|
|
mockProcess.stdout.emit('data', Buffer.from('Network: http://192.168.1.1:5173/\n'));
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
// Should keep the first detected URL
|
|
const serverInfo = service.getServerInfo(testDir);
|
|
expect(serverInfo?.url).toBe(firstUrl);
|
|
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
|
});
|
|
});
|
|
});
|
|
|
|
// 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;
|
|
|
|
// Don't exit immediately - let the test control the lifecycle
|
|
return mockProcess;
|
|
}
|