Files
hermes-web-ui/tests/server/coding-agent-run-manager-windows.test.ts
2026-07-06 10:47:30 +08:00

321 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const testState = vi.hoisted(() => {
class TestEmitter {
private readonly handlers = new Map<string, Array<(...args: any[]) => void>>()
on(event: string, handler: (...args: any[]) => void) {
const handlers = this.handlers.get(event) || []
handlers.push(handler)
this.handlers.set(event, handlers)
return this
}
emit(event: string, ...args: any[]) {
for (const handler of this.handlers.get(event) || []) handler(...args)
return true
}
}
return {
spawnCalls: [] as Array<{ command: string; args: string[]; options: any; child: any }>,
TestEmitter,
}
})
vi.mock('child_process', () => ({
spawn: vi.fn((command: string, args: string[], options: any) => {
const child = new testState.TestEmitter() as any
child.stdout = new testState.TestEmitter()
child.stderr = new testState.TestEmitter()
child.pid = 1234
child.exitCode = null
child.signalCode = null
child.killed = false
child.kill = vi.fn(() => {
child.killed = true
})
testState.spawnCalls.push({ command, args, options, child })
return child
}),
}))
import { CodingAgentRunManager } from '../../packages/server/src/services/agent-runner/coding-agent-run-manager'
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function setPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, 'platform', { value: platform })
}
beforeEach(() => {
testState.spawnCalls.length = 0
setPlatform('win32')
})
afterEach(() => {
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
})
describe('coding agent Windows process launch', () => {
it('runs npm .cmd shims through cmd.exe for hidden Claude Code chat turns', () => {
const manager = new CodingAgentRunManager()
;(manager as any).ensureDbSession = () => {}
;(manager as any).addUserMessage = () => {}
;(manager as any).emitToChat = () => {}
;(manager as any).markChatRunCompleted = () => {}
manager.start({
agentSessionId: 'agent-session-1',
agentId: 'claude-code',
mode: 'scoped',
profile: 'default',
provider: 'test-provider',
model: 'claude-test',
sessionId: 'chat-session-1',
command: 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\claude.cmd',
args: ['--settings', 'C:\\Users\\Administrator\\.hermes-web-ui\\settings.json'],
shellCommand: 'claude',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
manager.send('chat-session-1', 'test', { systemPrompt: 'system prompt\nsecond line' })
expect(testState.spawnCalls[0]).toMatchObject({
command: 'cmd.exe',
args: expect.arrayContaining(['/d', '/s', '/c']),
})
expect(testState.spawnCalls[0].args[3]).toContain('C:\\Users\\Administrator\\AppData\\Roaming\\npm\\claude.cmd')
expect(testState.spawnCalls[0].args[3]).toContain('^"--settings^"')
expect(testState.spawnCalls[0].args[3]).toContain('^"--append-system-prompt^"')
expect(testState.spawnCalls[0].args[3]).toContain('^"system^ prompt^ /^ second^ line^"')
expect(testState.spawnCalls[0].args[3]).not.toContain('\n')
expect(testState.spawnCalls[0].args[3]).not.toContain('\r')
expect(testState.spawnCalls[0].args[3]).toContain('^"test^"')
expect(testState.spawnCalls[0].options).toMatchObject({
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
windowsHide: true,
})
const run = (manager as any).runs.get('agent-session-1')
if (run?.idleTimer) clearTimeout(run.idleTimer)
;(manager as any).runs.clear()
;(manager as any).sessionIndex.clear()
})
it('runs npm .cmd shims through cmd.exe for hidden Codex chat turns', () => {
const manager = new CodingAgentRunManager()
;(manager as any).ensureDbSession = () => {}
;(manager as any).addUserMessage = () => {}
;(manager as any).emitToChat = () => {}
;(manager as any).markChatRunCompleted = () => {}
manager.start({
agentSessionId: 'agent-session-codex-1',
agentId: 'codex',
mode: 'scoped',
profile: 'default',
provider: 'test-provider',
model: 'gpt-test',
sessionId: 'chat-session-codex-1',
command: 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\codex.cmd',
args: ['--model', 'gpt-test'],
shellCommand: 'codex',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
manager.send('chat-session-codex-1', 'test', { systemPrompt: 'system prompt\nsecond line' })
expect(testState.spawnCalls[0]).toMatchObject({
command: 'cmd.exe',
args: expect.arrayContaining(['/d', '/s', '/c']),
})
expect(testState.spawnCalls[0].args[3]).toContain('C:\\Users\\Administrator\\AppData\\Roaming\\npm\\codex.cmd')
expect(testState.spawnCalls[0].args[3]).toContain('^"exec^"')
expect(testState.spawnCalls[0].args[3]).toContain('^"-c^"')
expect(testState.spawnCalls[0].args[3]).toContain('model_reasoning_summary=\\^"auto\\^"')
expect(testState.spawnCalls[0].args[3]).not.toContain('developer_instructions=')
expect(testState.spawnCalls[0].args[3]).not.toContain('system^ prompt^ /^ second^ line')
expect(testState.spawnCalls[0].args[3]).not.toContain('\n')
expect(testState.spawnCalls[0].args[3]).not.toContain('\r')
expect(testState.spawnCalls[0].args[3]).toContain('^"--model^"')
expect(testState.spawnCalls[0].args[3]).toContain('^"test^"')
expect(testState.spawnCalls[0].args[3]).not.toContain('system^ prompt\r\n\r\ntest')
expect(testState.spawnCalls[0].options).toMatchObject({
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
windowsHide: true,
})
const run = (manager as any).runs.get('agent-session-codex-1')
if (run?.idleTimer) clearTimeout(run.idleTimer)
;(manager as any).runs.clear()
;(manager as any).sessionIndex.clear()
})
it('preserves non-ASCII Windows .cmd paths when launching hidden chat turns', () => {
const manager = new CodingAgentRunManager()
;(manager as any).ensureDbSession = () => {}
;(manager as any).addUserMessage = () => {}
;(manager as any).emitToChat = () => {}
;(manager as any).markChatRunCompleted = () => {}
manager.start({
agentSessionId: 'agent-session-codex-unicode-1',
agentId: 'codex',
mode: 'scoped',
profile: 'default',
provider: 'test-provider',
model: 'gpt-test',
sessionId: 'chat-session-codex-unicode-1',
command: 'C:\\用户\\管理员\\AppData\\Roaming\\npm\\codex.cmd',
args: ['--model', 'gpt-test'],
shellCommand: 'codex',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
manager.send('chat-session-codex-unicode-1', 'test')
expect(testState.spawnCalls[0]).toMatchObject({
command: 'cmd.exe',
args: expect.arrayContaining(['/d', '/s', '/c']),
})
expect(testState.spawnCalls[0].args[3]).toContain('C:\\用户\\管理员\\AppData\\Roaming\\npm\\codex.cmd')
const run = (manager as any).runs.get('agent-session-codex-unicode-1')
if (run?.idleTimer) clearTimeout(run.idleTimer)
;(manager as any).runs.clear()
;(manager as any).sessionIndex.clear()
})
it('normalizes already quoted Windows .cmd paths before launching hidden chat turns', () => {
const manager = new CodingAgentRunManager()
;(manager as any).ensureDbSession = () => {}
;(manager as any).addUserMessage = () => {}
;(manager as any).emitToChat = () => {}
;(manager as any).markChatRunCompleted = () => {}
manager.start({
agentSessionId: 'agent-session-codex-quoted-1',
agentId: 'codex',
mode: 'scoped',
profile: 'default',
provider: 'test-provider',
model: 'gpt-test',
sessionId: 'chat-session-codex-quoted-1',
command: '"C:\\nvm4w\\nodejs\\codex.cmd"',
args: ['--model', 'gpt-test'],
shellCommand: 'codex',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
manager.send('chat-session-codex-quoted-1', 'test')
expect(testState.spawnCalls[0]).toMatchObject({
command: 'cmd.exe',
args: expect.arrayContaining(['/d', '/s', '/c']),
})
expect(testState.spawnCalls[0].args[3]).toContain('C:\\nvm4w\\nodejs\\codex.cmd')
expect(testState.spawnCalls[0].args[3]).not.toContain('"C:\\nvm4w\\nodejs\\codex.cmd"')
const run = (manager as any).runs.get('agent-session-codex-quoted-1')
if (run?.idleTimer) clearTimeout(run.idleTimer)
;(manager as any).runs.clear()
;(manager as any).sessionIndex.clear()
})
it('emits a readable failed run when a hidden Claude Code process cannot start', async () => {
const manager = new CodingAgentRunManager()
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).ensureDbSession = () => {}
;(manager as any).addUserMessage = () => {}
;(manager as any).markChatRunCompleted = (_sessionId: string, event: string) => {
emitted.push({ event: 'marked', payload: { event } })
}
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
manager.start({
agentSessionId: 'agent-session-error-1',
agentId: 'claude-code',
mode: 'scoped',
profile: 'default',
provider: 'test-provider',
model: 'claude-test',
sessionId: 'chat-session-error-1',
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
manager.send('chat-session-error-1', 'test')
testState.spawnCalls[0].child.emit('error', Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(emitted).toContainEqual(expect.objectContaining({
event: 'run.failed',
payload: expect.objectContaining({
error: 'spawn claude ENOENT',
}),
}))
const run = (manager as any).runs.get('agent-session-error-1')
if (run?.idleTimer) clearTimeout(run.idleTimer)
;(manager as any).runs.clear()
;(manager as any).sessionIndex.clear()
})
it('includes decoded stderr detail when a hidden Codex process exits non-zero', async () => {
const manager = new CodingAgentRunManager()
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).ensureDbSession = () => {}
;(manager as any).addUserMessage = () => {}
;(manager as any).markChatRunCompleted = (_sessionId: string, event: string) => {
emitted.push({ event: 'marked', payload: { event } })
}
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
manager.start({
agentSessionId: 'agent-session-codex-error-1',
agentId: 'codex',
mode: 'scoped',
profile: 'default',
provider: 'test-provider',
model: 'gpt-test',
sessionId: 'chat-session-codex-error-1',
command: 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\codex.cmd',
args: ['--model', 'gpt-test'],
shellCommand: 'codex',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
manager.send('chat-session-codex-error-1', 'test')
testState.spawnCalls[0].child.stderr.emit('data', Buffer.from([0xb2, 0xbb, 0xca, 0xc7]))
testState.spawnCalls[0].child.emit('exit', 1)
await new Promise(resolve => setTimeout(resolve, 0))
expect(emitted).toContainEqual(expect.objectContaining({
event: 'run.failed',
payload: expect.objectContaining({
error: 'Codex exited with code 1: 不是',
}),
}))
const run = (manager as any).runs.get('agent-session-codex-error-1')
if (run?.idleTimer) clearTimeout(run.idleTimer)
;(manager as any).runs.clear()
;(manager as any).sessionIndex.clear()
})
})