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

@@ -17,6 +17,7 @@ import {
import { Readable, Writable } from 'node:stream'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
/**
@@ -209,25 +210,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
}, 30_000)
})
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, [acpBin, '--config', configArg], {
cwd,
env: {
...process.env,
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
child = proc
let stderr = ''
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
proc.stdin.end()
/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */
async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
const result = await execa(process.execPath, [acpBin, '--config', configArg], {
cwd,
env: {
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
input: '',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
})
if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`)
return { code: result.exitCode ?? -1, stderr: result.stderr }
}

View File

@@ -1,4 +1,3 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -6,6 +5,7 @@ import { dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { zstdDecompress } from 'node:zlib'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
/**
@@ -114,36 +114,34 @@ interface BinResult {
readonly stderr: string
}
function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [cliBin, ...args], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
const subprocess = execa(process.execPath, [cliBin, ...args], {
cwd,
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdin: 'ignore',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
// Genuinely custom mid-stream logic: the signal cases deliver `interrupt`
// once the first streamed chunk proves the turn is in flight.
if (interrupt !== undefined) {
let streamed = ''
let interrupted = false
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdout += chunk
if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) {
subprocess.stdout.on('data', (chunk: Buffer) => {
streamed += chunk.toString('utf8')
if (!interrupted && streamed.includes('assistant/chunk')) {
interrupted = true
child.kill(interrupt)
subprocess.kill(interrupt)
}
})
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
child.once('error', (error) => { clearTimeout(timer); reject(error) })
child.once('exit', (code, signal) => {
clearTimeout(timer)
resolveResult({ code: code ?? -1, signal, stdout, stderr })
})
})
}
const result = await subprocess
if (result.timedOut) {
throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr }
}
let consumer: string | undefined