Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 66 additions & 10 deletions src/cli/operations/dev/__tests__/codezip-dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ import { existsSync } from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockSpawn = vi.fn();
const mockRunSubprocessCapture = vi.fn();
const platformState = vi.hoisted(() => ({ isWindows: false }));
vi.mock('child_process', () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
spawnSync: vi.fn(() => ({ status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') })),
}));
vi.mock('../../../../lib/utils/subprocess', () => ({
runSubprocessCapture: (...args: unknown[]) => mockRunSubprocessCapture(...args),
}));

vi.mock('fs', () => ({
existsSync: vi.fn(() => true),
Expand All @@ -21,7 +26,11 @@ const mockExistsSync = vi.mocked(existsSync);

vi.mock('../../../../lib/utils/platform', () => ({
getVenvExecutable: (venvPath: string, executable: string) => `${venvPath}/bin/${executable}`,
isWindows: false,
getShellCommand: () => 'cmd',
getShellArgs: (command: string) => ['/c', command],
get isWindows() {
return platformState.isWindows;
},
}));

function createMockChildProcess() {
Expand All @@ -38,8 +47,13 @@ const defaultOptions: DevServerOptions = { port: 8080, envVars: { MY_KEY: 'secre

describe('CodeZipDevServer spawn config', () => {
beforeEach(() => {
platformState.isWindows = false;
mockSpawn.mockClear();
mockSpawn.mockReturnValue(createMockChildProcess());
mockRunSubprocessCapture.mockReset();
mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null });
vi.mocked(mockCallbacks.onLog).mockClear();
vi.mocked(mockCallbacks.onExit).mockClear();
});

afterEach(() => vi.restoreAllMocks());
Expand Down Expand Up @@ -155,6 +169,28 @@ describe('CodeZipDevServer spawn config', () => {
expect(env.LOCAL_DEV).toBe('1');
});

it('TypeScript HTTP: runs npx through cmd on Windows', async () => {
platformState.isWindows = true;
const config: DevConfig = {
agentName: 'TsAgent',
module: 'src/main.ts',
directory: 'C:\\project\\app',
hasConfig: true,
isPython: false,
buildType: 'CodeZip',
protocol: 'HTTP',
};

const server = new CodeZipDevServer(config, defaultOptions);
await server.start();

expect(mockSpawn).toHaveBeenCalledWith(
'cmd',
['/c', 'npx tsx watch src/main.ts'],
expect.objectContaining({ cwd: 'C:\\project\\app', detached: false })
);
});

it('TypeScript: installs node dependencies when node_modules missing', async () => {
mockExistsSync.mockImplementation((p: unknown) => {
const s = String(p);
Expand All @@ -163,13 +199,6 @@ describe('CodeZipDevServer spawn config', () => {
if (s.endsWith('yarn.lock')) return false;
return true;
});
mockSpawnSync.mockClear();
mockSpawnSync.mockReturnValue({
status: 0,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
} as any);

const config: DevConfig = {
agentName: 'TsAgent',
module: 'main.ts',
Expand All @@ -183,10 +212,37 @@ describe('CodeZipDevServer spawn config', () => {
const server = new CodeZipDevServer(config, defaultOptions);
await server.start();

expect(mockSpawnSync).toHaveBeenCalledWith('npm', ['install'], expect.objectContaining({ cwd: '/project/app' }));
expect(mockRunSubprocessCapture).toHaveBeenCalledWith('npm', ['install'], { cwd: '/project/app' });
mockExistsSync.mockImplementation(() => true);
});

it('TypeScript: reports the process creation error when dependency installation cannot start', async () => {
mockExistsSync.mockImplementation((p: unknown) => !String(p).endsWith('node_modules'));
mockRunSubprocessCapture.mockResolvedValue({
status: null,
code: -1,
stdout: '',
stderr: 'spawn npm ENOENT',
signal: null,
});
const config: DevConfig = {
agentName: 'TsAgent',
module: 'main.ts',
directory: '/project/app',
hasConfig: true,
isPython: false,
buildType: 'CodeZip',
protocol: 'HTTP',
};

const server = new CodeZipDevServer(config, defaultOptions);
const child = await server.start();

expect(child).toBeNull();
expect(mockCallbacks.onLog).toHaveBeenCalledWith('error', 'Failed to install Node dependencies: spawn npm ENOENT');
expect(mockSpawn).not.toHaveBeenCalled();
});

it('TypeScript: skips install when node_modules exists', async () => {
mockExistsSync.mockImplementation(() => true);
mockSpawnSync.mockClear();
Expand All @@ -204,7 +260,7 @@ describe('CodeZipDevServer spawn config', () => {
const server = new CodeZipDevServer(config, defaultOptions);
await server.start();

expect(mockSpawnSync).not.toHaveBeenCalledWith('npm', ['install'], expect.anything());
expect(mockRunSubprocessCapture).not.toHaveBeenCalled();
});

it('MCP: extracts file from module:function entrypoint', async () => {
Expand Down
31 changes: 20 additions & 11 deletions src/cli/operations/dev/codezip-dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getVenvExecutable } from '../../../lib/utils/platform';
import { getShellArgs, getShellCommand, getVenvExecutable, isWindows } from '../../../lib/utils/platform';
import { runSubprocessCapture } from '../../../lib/utils/subprocess';
import type { ProtocolMode } from '../../../schema';
import { A2A_PORT_ENV } from './constants';
import { DevServer, type LogLevel, type SpawnConfig } from './dev-server';
Expand Down Expand Up @@ -72,7 +73,7 @@ function ensurePythonVenv(
* Ensures Node dependencies are installed. Runs the appropriate package manager
* install if `node_modules` is missing. Detects pnpm/yarn via lockfile, else npm.
*/
function ensureNodeDeps(cwd: string, onLog: (level: LogLevel, message: string) => void): boolean {
async function ensureNodeDeps(cwd: string, onLog: (level: LogLevel, message: string) => void): Promise<boolean> {
if (existsSync(join(cwd, 'node_modules'))) {
return true;
}
Expand All @@ -88,9 +89,10 @@ function ensureNodeDeps(cwd: string, onLog: (level: LogLevel, message: string) =
}

onLog('system', 'Installing Node dependencies...');
const result = spawnSync(cmd, args, { cwd, stdio: 'pipe' });
if (result.status !== 0) {
onLog('error', `Failed to install Node dependencies: ${result.stderr?.toString() || 'unknown error'}`);
const result = await runSubprocessCapture(cmd, args, { cwd });
if (result.code !== 0) {
const detail = result.stderr || result.stdout || `${cmd} exited with code ${String(result.code)}`;
onLog('error', `Failed to install Node dependencies: ${detail}`);
return false;
}
onLog('system', 'Node dependencies ready');
Expand Down Expand Up @@ -125,12 +127,11 @@ function findOtelSitecustomizeDir(venvPath: string): string | undefined {

/** Dev server for CodeZip agents. Runs uvicorn (Python) or npx tsx (Node.js) locally. */
export class CodeZipDevServer extends DevServer {
protected prepare(): Promise<boolean> {
return Promise.resolve(
this.config.isPython
? ensurePythonVenv(this.config.directory, this.options.callbacks.onLog, this.config.protocol)
: ensureNodeDeps(this.config.directory, this.options.callbacks.onLog)
);
protected async prepare(): Promise<boolean> {
if (this.config.isPython) {
return ensurePythonVenv(this.config.directory, this.options.callbacks.onLog, this.config.protocol);
}
return ensureNodeDeps(this.config.directory, this.options.callbacks.onLog);
}

protected getSpawnConfig(): SpawnConfig {
Expand All @@ -155,6 +156,14 @@ export class CodeZipDevServer extends DevServer {
if (!isPython) {
// TS entrypoint is already a file path like "main.ts" — pass it straight to tsx.
const entryFile = module.split(':')[0] ?? module;
if (isWindows) {
return {
cmd: getShellCommand(),
args: getShellArgs(`npx tsx watch ${entryFile}`),
cwd: directory,
env,
};
}
return {
cmd: 'npx',
args: ['tsx', 'watch', entryFile],
Expand Down
67 changes: 67 additions & 0 deletions src/cli/operations/dev/web-ui/__tests__/run-web-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { runWebUI } from '../run-web-ui';
import type { WebUIOptions } from '../web-server';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const state = vi.hoisted(() => ({
loggerLog: vi.fn(),
loggerFinalize: vi.fn(),
consumerLog: vi.fn(),
serverOptions: undefined as WebUIOptions | undefined,
}));

vi.mock('../../../../logging', () => ({
ExecLogger: class {
log = state.loggerLog;
finalize = state.loggerFinalize;

getRelativeLogPath() {
return '.cli/logs/dev/test.log';
}
},
}));

vi.mock('../../server', () => ({
findAvailablePort: vi.fn().mockResolvedValue(8081),
}));

vi.mock('../../utils', () => ({
onShutdownSignal: vi.fn(),
openBrowser: vi.fn(),
}));

vi.mock('../web-server', () => ({
WebUIServer: class {
constructor(options: WebUIOptions) {
state.serverOptions = options;
}

start = vi.fn();
stop = vi.fn();
},
}));

describe('runWebUI logging', () => {
beforeEach(() => {
state.loggerLog.mockClear();
state.loggerFinalize.mockClear();
state.consumerLog.mockClear();
state.serverOptions = undefined;
});

it('writes web UI messages to the advertised log and the display handler', async () => {
void runWebUI({
logLabel: 'dev',
onLog: state.consumerLog,
serverOptions: {
mode: 'dev',
agents: [],
},
});

await vi.waitFor(() => expect(state.serverOptions).toBeDefined());
state.serverOptions!.onLog!('error', 'Failed to install Node dependencies: spawn npm ENOENT');

expect(state.loggerLog).toHaveBeenCalledWith('Failed to install Node dependencies: spawn npm ENOENT', 'error');
expect(state.consumerLog).toHaveBeenCalledWith('error', 'Failed to install Node dependencies: spawn npm ENOENT');
});
});
7 changes: 6 additions & 1 deletion src/cli/operations/dev/web-ui/run-web-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ export async function runWebUI(opts: RunWebUIOptions): Promise<void> {
console.log(`Starting web UI...`);
console.log(`Log: ${logger.getRelativeLogPath()}`);

const onLog =
const displayLog =
opts.onLog ??
((level: 'info' | 'warn' | 'error', msg: string) => {
if (level === 'error') console.error(`Web UI: ${msg}`);
});
const onLog = (level: 'info' | 'warn' | 'error', msg: string) => {
logger.log(msg, level);
displayLog(level, msg);
};

const webUI = new WebUIServer({
...serverOptions,
Expand All @@ -52,6 +56,7 @@ export async function runWebUI(opts: RunWebUIOptions): Promise<void> {
onShutdownSignal(() => {
console.log('\nStopping servers...');
webUI.stop();
logger.finalize(true);
});

// Keep process alive
Expand Down
1 change: 1 addition & 0 deletions src/lib/utils/__tests__/subprocess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('runSubprocessCapture', () => {
it('returns code -1 for unknown command', async () => {
const result = await runSubprocessCapture('__nonexistent_command_xyz__', []);
expect(result.code).toBe(-1);
expect(result.stderr).toContain('__nonexistent_command_xyz__');
});

it('respects cwd option', async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/utils/subprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ export async function runSubprocessCapture(
resolve({ stdout, stderr, code, signal });
});

child.on('error', () => {
resolve({ stdout, stderr, code: -1, signal: null });
child.on('error', error => {
resolve({ stdout, stderr: stderr || error.message, code: -1, signal: null });
});
});
}
Expand Down
Loading