test(swarm): 12 tests covering WindowsTerminalBackend lifecycle, race, pid validation

为 WindowsTerminalBackend 加 12 个测试覆盖 v2 全部新行为,含 5 个 v1 兼容 + 7 个
v2 新场景。配套构造函数 options 对象,测试用 pidFileDir: tempDir 隔离防泄漏到
真实 OS tmpdir。

新场景覆盖:
- unlinks stale pidFile so a stale pid is not adopted
- rejects re-spawn on a ready pane
- throws on unknown paneId in sendCommandToPane
- rejects corrupted pidFile content ("123abc") and times out
- killPane awaits in-flight spawn before killing (kill-while-spawn race)
- Stop-Process failure clears cached pid and marks pane dead
- killPane uses cached pid and returns false when pane is unknown

createBackend helper 改用 options 对象 + simulatePidWrite 模拟 powershell 写
pidFile,pidFileDir 注入 tempDir,env CLAUDE_WT_PANE_TIMEOUT_MS beforeEach 设置
afterEach 清理。
This commit is contained in:
unraid 2026-05-18 18:31:44 +08:00
parent bde8f45bef
commit 9b6b9a5d59

View File

@ -14,20 +14,43 @@ beforeEach(async () => {
`windows-terminal-backend-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
await mkdir(tempDir, { recursive: true })
process.env.CLAUDE_WT_PANE_TIMEOUT_MS = '2000'
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
delete process.env.CLAUDE_WT_PANE_TIMEOUT_MS
})
function createBackend(calls: Call[]): WindowsTerminalBackend {
return new WindowsTerminalBackend(
async (command, args) => {
function createBackend(
calls: Call[],
opts: { simulatePidWrite?: boolean | number } = {},
): WindowsTerminalBackend {
const simulate = opts.simulatePidWrite !== false
const delayMs =
typeof opts.simulatePidWrite === 'number' ? opts.simulatePidWrite : 30
return new WindowsTerminalBackend({
runCommand: async (command, args) => {
calls.push({ command, args })
if (simulate && command === 'wt.exe') {
const encIdx = args.indexOf('-EncodedCommand')
if (encIdx >= 0) {
const decoded = Buffer.from(args[encIdx + 1]!, 'base64').toString(
'utf16le',
)
const match = decoded.match(/Set-Content -LiteralPath '([^']+)'/)
if (match) {
setTimeout(() => {
writeFile(match[1]!, '54321', 'utf-8').catch(() => {})
}, delayMs)
}
}
}
return { stdout: 'ok', stderr: '', code: 0 }
},
() => 'windows',
)
getPlatform: () => 'windows',
pidFileDir: tempDir,
})
}
function decodeEncodedCommand(call: Call): {
@ -78,25 +101,236 @@ describe('WindowsTerminalBackend', () => {
expect(args.join(' ')).toContain('-w -1 new-tab --title')
})
test('force kills the recorded teammate shell pid when available', async () => {
test('force kills the cached pid from sendCommandToPane without reading pidFile', async () => {
const calls: Call[] = []
const backend = createBackend(calls)
const pane = await backend.createTeammatePaneInSwarmView('killer', 'red')
// sendCommandToPane resolves — simulate writes '54321' to pidFile, which
// becomes pane.pid. killPane should use the cached pid, not re-read the file.
await backend.sendCommandToPane(pane.paneId, "Write-Output 'running'")
const { decodedLauncher } = decodeEncodedCommand(calls[0]!)
const pidFile = decodedLauncher.match(
/Set-Content -LiteralPath '([^']+)'/,
)?.[1]
expect(pidFile).toBeString()
await writeFile(pidFile!, '12345', 'utf-8')
const killed = await backend.killPane(pane.paneId)
expect(killed).toBe(true)
expect(calls[calls.length - 1]!.command).toBe('powershell.exe')
expect(calls[calls.length - 1]!.args.join(' ')).toContain(
'Stop-Process -Id 12345',
'Stop-Process -Id 54321',
)
})
test('throws a diagnostic error when pidFile never appears within timeout', async () => {
process.env.CLAUDE_WT_PANE_TIMEOUT_MS = '300'
const calls: Call[] = []
const backend = createBackend(calls, { simulatePidWrite: false })
const pane = await backend.createTeammatePaneInSwarmView('slowpane', 'blue')
let caught: unknown
try {
await backend.sendCommandToPane(pane.paneId, "Write-Output 'x'")
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toMatch(
/Windows Terminal pane failed to launch within 300ms/,
)
})
test('error message includes paneId pidFile and override hint', async () => {
process.env.CLAUDE_WT_PANE_TIMEOUT_MS = '250'
const calls: Call[] = []
const backend = createBackend(calls, { simulatePidWrite: false })
const pane = await backend.createTeammatePaneInSwarmView(
'diagpane',
'green',
)
let caught: unknown
try {
await backend.sendCommandToPane(pane.paneId, "Write-Output 'x'")
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(Error)
const msg = (caught as Error).message
expect(msg).toContain(pane.paneId)
expect(msg).toContain('CLAUDE_WT_PANE_TIMEOUT_MS')
})
test('unlinks stale pidFile so a stale pid is not adopted', async () => {
const calls: Call[] = []
const backend = createBackend(calls, { simulatePidWrite: 30 })
const pane = await backend.createTeammatePaneInSwarmView('stale', 'pink')
// pidFile path is deterministic: <tempDir>/<sanitized paneId>.pid
const stalePidFile = join(
tempDir,
`${pane.paneId.replace(/[^a-zA-Z0-9_-]/g, '-')}.pid`,
)
// Pre-seed stale content. If sendCommandToPane did NOT unlink, waitForPidFile
// would immediately accept '99999' and cache it as pane.pid. With unlink,
// simulate's '54321' is the value killPane sees.
await writeFile(stalePidFile, '99999', 'utf-8')
await backend.sendCommandToPane(pane.paneId, "Write-Output 'x'")
const killed = await backend.killPane(pane.paneId)
expect(killed).toBe(true)
expect(calls[calls.length - 1]!.args.join(' ')).toContain(
'Stop-Process -Id 54321',
)
})
test('rejects re-spawn on a ready pane', async () => {
const calls: Call[] = []
const backend = createBackend(calls)
const pane = await backend.createTeammatePaneInSwarmView('reentry', 'cyan')
await backend.sendCommandToPane(pane.paneId, "Write-Output 'first'")
// pane.status === 'ready' now. Second sendCommandToPane must throw.
let caught: unknown
try {
await backend.sendCommandToPane(pane.paneId, "Write-Output 'second'")
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toMatch(/already spawned/)
})
test('throws on unknown paneId in sendCommandToPane', async () => {
const calls: Call[] = []
const backend = createBackend(calls)
let caught: unknown
try {
await backend.sendCommandToPane('wt-nonexistent', "Write-Output 'x'")
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toContain('Unknown Windows Terminal pane')
})
test('rejects corrupted pidFile content ("123abc") and times out', async () => {
process.env.CLAUDE_WT_PANE_TIMEOUT_MS = '400'
const calls: Call[] = []
// Custom runner writes invalid pid content (not all digits).
const backend = new WindowsTerminalBackend({
runCommand: async (command, args) => {
calls.push({ command, args })
if (command === 'wt.exe') {
const encIdx = args.indexOf('-EncodedCommand')
if (encIdx >= 0) {
const decoded = Buffer.from(args[encIdx + 1]!, 'base64').toString(
'utf16le',
)
const match = decoded.match(/Set-Content -LiteralPath '([^']+)'/)
if (match) {
setTimeout(() => {
writeFile(match[1]!, '123abc', 'utf-8').catch(() => {})
}, 30)
}
}
}
return { stdout: 'ok', stderr: '', code: 0 }
},
getPlatform: () => 'windows',
pidFileDir: tempDir,
})
const pane = await backend.createTeammatePaneInSwarmView('corrupt', 'red')
let caught: unknown
try {
await backend.sendCommandToPane(pane.paneId, "Write-Output 'x'")
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(Error)
// Inner error from waitForPidFile must reach the wrapped diagnostic message.
const msg = (caught as Error).message
expect(msg).toMatch(/failed to launch within 400ms/)
expect(msg).toMatch(/not a valid pid|invalid pid|123abc/)
})
test('killPane awaits in-flight spawn before killing (kill-while-spawn race)', async () => {
// simulatePidWrite: 800ms — sendCommandToPane stays in waitForPidFile for ~800ms.
process.env.CLAUDE_WT_PANE_TIMEOUT_MS = '3000'
const calls: Call[] = []
const backend = createBackend(calls, { simulatePidWrite: 800 })
const pane = await backend.createTeammatePaneInSwarmView('racy', 'blue')
// Start spawn but don't await it yet.
const spawnP = backend.sendCommandToPane(pane.paneId, "Write-Output 'x'")
// 50ms later, call killPane — pane is still 'spawning', killPane must
// await spawnPromise (which resolves at ~800ms when simulate writes pid 54321),
// then kill using the cached pid.
await new Promise(r => setTimeout(r, 50))
const killP = backend.killPane(pane.paneId)
// Both must resolve cleanly.
await spawnP
const killed = await killP
expect(killed).toBe(true)
// The kill must target the freshly-spawned pid (54321), not have used a
// stale-or-missing fallback path.
const killCall = calls[calls.length - 1]!
expect(killCall.command).toBe('powershell.exe')
expect(killCall.args.join(' ')).toContain('Stop-Process -Id 54321')
})
test('Stop-Process failure clears cached pid and marks pane dead', async () => {
const calls: Call[] = []
// Runner returns code 1 only for powershell.exe (kill); wt.exe succeeds.
const backend = new WindowsTerminalBackend({
runCommand: async (command, args) => {
calls.push({ command, args })
if (command === 'wt.exe') {
const encIdx = args.indexOf('-EncodedCommand')
if (encIdx >= 0) {
const decoded = Buffer.from(args[encIdx + 1]!, 'base64').toString(
'utf16le',
)
const match = decoded.match(/Set-Content -LiteralPath '([^']+)'/)
if (match) {
setTimeout(() => {
writeFile(match[1]!, '54321', 'utf-8').catch(() => {})
}, 30)
}
}
return { stdout: 'ok', stderr: '', code: 0 }
}
// powershell Stop-Process fails
return { stdout: '', stderr: 'access denied', code: 1 }
},
getPlatform: () => 'windows',
pidFileDir: tempDir,
})
const pane = await backend.createTeammatePaneInSwarmView('dier', 'orange')
await backend.sendCommandToPane(pane.paneId, "Write-Output 'x'")
const killed = await backend.killPane(pane.paneId)
expect(killed).toBe(false) // Stop-Process exit 1 → false
// After kill failure, pane is removed from map: second killPane → false (not retry).
const killedAgain = await backend.killPane(pane.paneId)
expect(killedAgain).toBe(false)
// Critically: only ONE powershell call happened — the second killPane returned
// false from "pane not in map", not from another Stop-Process attempt.
const psCalls = calls.filter(c => c.command === 'powershell.exe')
expect(psCalls.length).toBe(1)
})
test('killPane uses cached pid and returns false when pane is unknown', async () => {
const calls: Call[] = []
const backend = createBackend(calls, { simulatePidWrite: 30 })
const pane = await backend.createTeammatePaneInSwarmView('cached', 'yellow')
await backend.sendCommandToPane(pane.paneId, "Write-Output 'x'")
// After sendCommandToPane, pane.pid = 54321 (from simulate). killPane must
// use this cached pid without reading the pidFile at all.
const killed = await backend.killPane(pane.paneId)
expect(killed).toBe(true)
expect(calls[calls.length - 1]!.args.join(' ')).toContain(
'Stop-Process -Id 54321',
)
// After kill, pane is removed — a second killPane must return false.
const killedAgain = await backend.killPane(pane.paneId)
expect(killedAgain).toBe(false)
})
})