test: adopt execa for hand-rolled subprocess plumbing, parseArgs for llm-mock-server CLI, vi.waitFor for poll loops

Implements the execa Agent Note's four sub-changes:
- execa (root devDep + loader-smoke dep) replaces the hand-rolled
  spawn-collect-timeout choreography in loader-smoke, apps/cli and
  cli-demo/acp-demo built-bin e2e, lsp-local and code-runtime-worker
  built-lib e2e, the tui pty-harness outer collector, the jsonrpc
  keyless smoke, and crash-recovery's child spawn. Genuinely custom
  parts stay custom: cli-demo's interrupt-on-marker, jsonrpc's
  line-predicate protocol driving, crash-recovery's SIGKILL-at-failpoint.
  The two loader-smoke /* v8 ignore */ OS-error branches are gone.
- llm-mock-server CLI tokenizes via node:util parseArgs; numeric
  coercion/bounds/cross-option constraints stay manual; pinned
  error-message tests updated to the parseArgs texts.
- both loadRootEnv copies in apps/web/tests are deleted: the owning
  vitest configs (web unconditionally, snapshot in record mode)
  already load the repo-root .env before these files run.
- the four poll loops (acp-snapshot harness waits + crash-recovery
  waitForFile) ride vi.waitFor with explicit {interval, timeout}.
This commit is contained in:
Tianyi Cui
2026-07-26 22:29:41 +08:00
parent c9dc097749
commit c4647a8609
17 changed files with 364 additions and 321 deletions

View File

@@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { basename, dirname, join, delimiter } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { vi } from 'vitest'
import {
ClientSideConnection,
PROTOCOL_VERSION,
@@ -457,17 +457,25 @@ async function waitForPersistedTurnStart(
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
minimumTurn?: number,
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (true) {
let invalidRecord: Error | undefined
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
const openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return
if (Date.now() >= deadline) {
let openTurn: number | undefined
try {
openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
} catch (error) {
// A malformed persisted record is a scenario bug, not a not-yet state:
// vi.waitFor retries every callback throw, so capture the validation
// failure, resolve the wait, and rethrow immediately below.
invalidRecord = error instanceof Error ? error : new Error(String(error))
return
}
if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) {
const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}`
throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`)
}
await delay(WAIT_POLL_INTERVAL_MS)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
if (invalidRecord !== undefined) throw invalidRecord
}
/**
@@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd(
sessionId: string,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (true) {
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
if (log !== undefined && latestTurnIsClosed(log.content)) return
if (Date.now() >= deadline) {
if (log === undefined || !latestTurnIsClosed(log.content)) {
throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`)
}
await delay(WAIT_POLL_INTERVAL_MS)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Wait for a cwd-relative marker proving an external action reached readiness. */
@@ -499,13 +504,11 @@ async function waitForWorkspaceFile(
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
const target = join(cwd, path)
const deadline = Date.now() + timeoutMs
while (!existsSync(target)) {
if (Date.now() >= deadline) {
await vi.waitFor(() => {
if (!existsSync(target)) {
throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`)
}
await delay(WAIT_POLL_INTERVAL_MS)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Return whether the last complete raw-JSONL turn boundary closes its turn. */

View File

@@ -3,6 +3,7 @@
* @module @deepseek-ai/dsh-llm-mock-server/cli
*/
import { parseArgs } from 'node:util'
import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
import type {
ConcreteMockLlmBehavior,
@@ -63,14 +64,6 @@ Other:
--help
`
function optionValue(argv: readonly string[], index: number, option: string): string {
const value = argv[index + 1]
if (value === undefined || value.startsWith('--')) {
throw new Error(`dsh-llm-mock-server: ${option} requires a value`)
}
return value
}
function numberValue(option: string, value: string): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`)
@@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights {
return weights
}
/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */
const CLI_OPTIONS = {
'sequence': { type: 'string' },
'host': { type: 'string' },
'port': { type: 'string' },
'api-key': { type: 'string' },
'listen-delay-ms': { type: 'string' },
'repeat-last': { type: 'boolean' },
'seed': { type: 'string' },
'random-weights': { type: 'string' },
'success-text': { type: 'string' },
'partial-text': { type: 'string' },
'reasoning-text': { type: 'string' },
'chunk-size': { type: 'string' },
'chunk-delay-ms': { type: 'string' },
'disconnect-delay-ms': { type: 'string' },
'retry-after-ms': { type: 'string' },
'request-id': { type: 'string' },
'tool-name': { type: 'string' },
'tool-arguments': { type: 'string' },
} as const
/**
* Parse standalone server arguments without starting a process or listener.
* Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric
* coercion, bounds, and cross-option constraints remain manual below it.
* @param argv - arguments after the executable name.
* @returns help or validated run configuration.
*/
export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult {
if (argv.includes('--help')) return { kind: 'help' }
let sequenceRaw: string | undefined
let host: string | undefined
let port = 8_000
let apiKey: string | undefined
let listenDelayMs: number | undefined
let repeatLast = false
let randomSeed: number | undefined
let randomWeights: MockLlmRandomWeights | undefined
let successText: string | undefined
let partialText: string | undefined
let reasoningText: string | undefined
let chunkSize: number | undefined
let chunkDelayMs: number | undefined
let disconnectDelayMs: number | undefined
let retryAfterMs: number | undefined
let requestId: string | undefined
let toolName: string | undefined
let toolArguments: string | undefined
const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false })
for (let index = 0; index < argv.length; index += 1) {
const option = argv[index] as string
if (option === '--repeat-last') {
repeatLast = true
continue
}
const value = optionValue(argv, index, option)
index += 1
switch (option) {
case '--sequence': sequenceRaw = value; break
case '--host': host = value; break
case '--port': port = numberValue(option, value); break
case '--api-key': apiKey = value; break
case '--listen-delay-ms':
listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
break
case '--seed': randomSeed = numberValue(option, value); break
case '--random-weights': randomWeights = parseRandomWeights(value); break
case '--success-text': successText = value; break
case '--partial-text': partialText = value; break
case '--reasoning-text': reasoningText = value; break
case '--chunk-size': chunkSize = numberValue(option, value); break
case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break
case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break
case '--retry-after-ms': retryAfterMs = numberValue(option, value); break
case '--request-id': requestId = value; break
case '--tool-name': toolName = value; break
case '--tool-arguments': toolArguments = value; break
default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`)
}
}
const host = values.host
const port = values.port === undefined ? 8_000 : numberValue('--port', values.port)
const apiKey = values['api-key']
const listenDelayMs = values['listen-delay-ms'] === undefined
? undefined
: boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
const repeatLast = values['repeat-last'] ?? false
const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed)
const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights'])
const successText = values['success-text']
const partialText = values['partial-text']
const reasoningText = values['reasoning-text']
const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size'])
const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms'])
const disconnectDelayMs = values['disconnect-delay-ms'] === undefined
? undefined
: numberValue('--disconnect-delay-ms', values['disconnect-delay-ms'])
const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms'])
const requestId = values['request-id']
const toolName = values['tool-name']
const toolArguments = values['tool-arguments']
if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
const sequenceRaw = values.sequence
const parsedSequence = parseSequence(sequenceRaw)
if (parsedSequence.startsUnavailable && port === 0) {
throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port')

View File

@@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => {
it.each([
[[], /--sequence is required/],
[['--wat'], /requires a value/],
[['--wat', 'x'], /unknown option/],
// Tokenizer-level failures carry node:util parseArgs's own messages.
[['--wat'], /Unknown option '--wat'/],
[['--wat', 'x'], /Unknown option '--wat'/],
[['--port'], /Option '--port <value>' argument missing/],
[['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/],
[['--port', 'NaN', '--sequence', 'success'], /finite number/],
[['--sequence', 'success,'], /non-empty/],
[['--sequence', 'success,connection_refused'], /only as the first/],
@@ -110,7 +113,8 @@ describe('mock LLM server CLI parser', () => {
[['--sequence', 'unknown'], /unknown behavior/],
[['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/],
[['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/],
// `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe.
[['--sequence', 'connection_refused,success', '--listen-delay-ms=-1'], /integer between 0 and 2147483647/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/],
[['--sequence', 'success', '--seed', '1'], /require random/],

View File

@@ -27,6 +27,7 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"execa": "^10.0.0",
"tsx": "^4.22.4"
},
"peerDependencies": {

View File

@@ -11,10 +11,10 @@
* @module @deepseek-ai/dsh-loader-smoke
*/
import { spawn } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execa } from 'execa'
const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
@@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
tsconfigPath: options.tsconfigPath,
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
})
const result = await new Promise<LoaderSmokeResult>((resolve, reject) => {
const child = spawn(launch.command, launch.args, {
cwd,
env: { ...process.env, ...launch.env },
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let deferredFailure: Error | undefined
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
child.kill('SIGKILL')
}, processTimeoutMs)
child.once('exit', (code) => {
clearTimeout(timer)
if (deferredFailure !== undefined) {
reject(deferredFailure)
} else if (code === 0) {
resolve({ stdout, stderr })
} else {
reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
}
})
// process.execPath and a just-created pipe make these OS-error paths
// impractical to induce without replacing the boundary under test.
/* v8 ignore start */
child.once('error', (error) => {
clearTimeout(timer)
reject(new Error(`${options.label} failed to start: ${error.message}`))
})
child.stdin.once('error', (error) => {
deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
child.kill('SIGKILL')
})
/* v8 ignore stop */
child.stdin.end()
// `input: ''` writes nothing and closes stdin — the fixture-visible
// stdin-close contract. `reject: false` folds spawn errors, the SIGKILL
// deadline, and nonzero exits into independent result fields, so the
// diagnostics below embed both streams on every failure.
const result = await execa(launch.command, launch.args, {
cwd,
env: launch.env,
input: '',
timeout: processTimeoutMs,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
if (result.timedOut) {
throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
if (result.failed) {
throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
await options.inspect?.(cwd)
return result
return { stdout: result.stdout, stderr: result.stderr }
} finally {
await rm(cwd, { recursive: true, force: true })
}