test: stabilize timing-sensitive terminal checks

This commit is contained in:
Tianyi Cui
2026-07-27 15:33:36 +08:00
parent 026366ac4a
commit 9c53cd7d07
8 changed files with 66 additions and 46 deletions

View File

@@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent {
}
}
async function harness(mode: 'danger-full-access' | 'workspace-write') {
async function harness(
mode: 'danger-full-access' | 'workspace-write',
timing: { idleSilenceMs?: number; timeoutMs?: number } = {},
) {
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
roots.push(root)
const ctx = new Context()
@@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
const fiber = await ctx.plugin(ptyLocal, {
pollIntervalMs: 10,
exactProbeAfterMs: 20,
idleSilenceMs: 250,
timeoutMs: 2000,
idleSilenceMs: timing.idleSilenceMs ?? 250,
timeoutMs: timing.timeoutMs ?? 2_000,
disposeGraceMs: 500,
scrollbackLines: 100,
scrollbackMaxBytes: 32_768,
@@ -63,8 +66,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
const deadline = Date.now() + 2_000
async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
let output = ''
while (!output.includes(expected) && Date.now() < deadline) {
output += operation.readOutput().delta
@@ -131,20 +134,25 @@ describe('pty-local real shell', () => {
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
it('cancels a raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access')
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access', {
idleSilenceMs: 10_000,
timeoutMs: 15_000,
})
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const controller = new AbortController()
const ready = 'RAW_READY'
// Delay readiness beyond the shared harness's short send bound so this
// process test owns enough slack for loaded macOS startup and shell echo.
// The interactive shell echoes the command, so only child output may contain the readiness marker.
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_" + "READY", flush=True); time.sleep(60)\''
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\''
expect(command).not.toContain(ready)
const foreground = ctx.pty.startSend(agent, created.sessionId, {
text: command,
submit: true,
signal: controller.signal,
})
await waitForOutput(foreground, ready)
await waitForOutput(foreground, ready, 15_000)
controller.abort()
const result = await foreground.done
expect(result.waitReason).toBe('stdin_read')
@@ -155,5 +163,5 @@ describe('pty-local real shell', () => {
expect(after.viewport).toContain('AFTER_SIGINT')
expect(after.waitReason).toBe('stdin_read')
await ctx.pty.kill(agent, created.sessionId)
}, 10_000)
}, 20_000)
})

View File

@@ -228,33 +228,45 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7
describe('TUI terminal-state snapshots', () => {
it('pins an in-flight reasoning and Markdown stream', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {
harness.agent.status = 'running'
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
// Freeze the loader's first animation interval so this semantic snapshot
// cannot select a different spinner frame under scheduler contention.
const frozenLoaderTimer = setInterval(() => {}, 60_000)
const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer)
try {
await renderAfter(harness, () => {
harness.agent.status = 'running'
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
})
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
})
})
await checkpoint('conversation-streaming', harness.terminal)
await disposeSnapshot(harness)
const loaderIntervalMs = intervals.mock.calls[0]?.[1]
if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval')
await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5))
await checkpoint('conversation-streaming', harness.terminal)
} finally {
intervals.mockRestore()
clearInterval(frozenLoaderTimer)
await disposeSnapshot(harness)
}
})
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {