Files
apes-sidebar/tests/agent-pty.spec.ts
Menghuan1918 b2e497b9c9 fix(test): keep the wait needles out of the shells the skip cases spawn
main went red on the merge commit (run 34579312780) with a real assertion:
`tests/agent-pty.spec.ts:413` expected `skipWait` to report 1 registered wait
and got 0. The cause is the test's own preparation, not the registry: an
interactive shell ECHOES what is written to it, so a terminal opened with a
command that contains the wait needle already has that needle in its
transcript by the time `waitFor` runs — `waitFor`'s documented fast path then
resolves the wait immediately and `skipWait` finds nothing to skip.

POSIX /bin/sh does not echo a command written to a non-tty, so this had been
latent for as long as the suite existed; PowerShell does echo it, which is why
it only ever failed on Windows, and why it took a Windows run to expose it
rather than a code change.

The three affected cases (skip / waiting-snapshot / wait-notification) now open
a BARE shell and wait for the shell to be up rather than for a printed marker,
via a new `waitForShellReady` helper that watches for output of any kind. That
removes both halves of the hazard: no needle in the command, and no dependency
on which words a given shell prints at startup. Nothing about what the cases
assert changed — they are about the registry's wait bookkeeping, which needs a
live terminal and no particular output.

Verified: tests/agent-pty.spec.ts 33 passed (and the three cases now finish in
milliseconds instead of waiting out a transcript poll), full suite 127 files /
1343 passed / 9 skipped.
2026-09-11 16:37:03 +08:00

567 lines
23 KiB
TypeScript

/**
* AgentPtyRegistry unit tests: spawn real ptys (mirroring the pty-manager
* smoke tests) and verify uuid-keyed CRUD, transcript bounding, exit
* tracking, and change-event subscription. The "AttachConsole failed"
* console noise from node-pty's ConPTY module on Windows is expected and
* does not affect the assertions.
*/
import { describe, expect, it, vi } from 'vitest'
import {
AgentPtyRegistry,
ALLOWED_SIGNALS,
snapshotOf,
tryResizePty,
} from '../src/agent-pty.ts'
import { SidebarError } from '../src/wire.ts'
/**
* Resolve a shell binary for tests: on Windows use PowerShell (available on
* every modern Windows host), on POSIX use /bin/sh (universally available).
*/
function testShell(): string {
return process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'
}
/**
* Wait for a freshly spawned shell to have produced ANY output.
*
* The alternative — waiting for a string the prepared command prints — ties
* the test to shell wording, and waiting for a string the command CONTAINS is
* worse: an interactive shell echoes what is written to it (PowerShell does,
* POSIX /bin/sh does not), so the needle would already be in the transcript
* before the point the test means to exercise. The suites that only need "the
* shell is up" therefore wait for output of any kind.
* @param registry - the registry under test.
* @param uuid - the terminal to watch.
* @returns nothing; the caller asserts straight after.
*/
async function waitForShellReady(registry: AgentPtyRegistry, uuid: string): Promise<void> {
await waitForTranscript(registry, uuid, '', 0, true)
}
/** Wait for a terminal's transcript to contain a substring (or timeout). */
async function waitForTranscript(
registry: AgentPtyRegistry,
uuid: string,
needle: string,
timeoutMs = 15_000,
anyOutput = false,
): Promise<string> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const handle = registry.get(uuid)
if (handle !== undefined) {
if (anyOutput ? handle.transcript.length > 0 : handle.transcript.includes(needle)) return handle.transcript
}
await new Promise(resolve => setTimeout(resolve, 50))
}
const handle = registry.get(uuid)
return handle?.transcript ?? ''
}
describe('tryResizePty', () => {
it('clamps valid dimensions before resizing', () => {
const resize = vi.fn()
expect(tryResizePty({ resize }, 5000, 80.9)).toBe(true)
expect(resize).toHaveBeenCalledWith(1024, 80)
})
it('contains a node-pty resize failure', () => {
const resize = vi.fn(() => {
throw new Error('Usage: pty.resize(fd, cols, rows, xPixel, yPixel)')
})
expect(tryResizePty({ resize }, 80, 24)).toBe(false)
expect(resize).toHaveBeenCalledWith(80, 24)
})
it.each([
[Number.NaN, 24],
[Number.POSITIVE_INFINITY, 24],
[80, Number.NEGATIVE_INFINITY],
])('rejects non-finite dimensions without calling node-pty (%s, %s)', (cols, rows) => {
const resize = vi.fn()
expect(tryResizePty({ resize }, cols, rows)).toBe(false)
expect(resize).not.toHaveBeenCalled()
})
})
/**
* The registry suite really starts a shell per terminal — a PowerShell +
* ConPTY pair on Windows, /bin/sh elsewhere. vitest's 5000 ms default is
* below a cold PowerShell start on a loaded 2-core CI runner (a sibling spec
* measured 12.1 s on 2026-09-10), which is what turned six ci-windows runs
* red in the 2026-09-09/10 window. Generous, but still finite: a genuinely
* hung spawn fails the case.
*/
describe('AgentPtyRegistry', { timeout: 30_000 }, () => {
it('creates a terminal with a uuid, writes the command to stdin, and lists it', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'echo test', 'echo hello-agent-pty', process.cwd(), 80, 24)
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
const list = registry.list('s1')
expect(list).toHaveLength(1)
expect(list[0]!.uuid).toBe(uuid)
expect(list[0]!.title).toBe('echo test')
expect(list[0]!.command).toBe('echo hello-agent-pty')
expect(list[0]!.exited).toBe(false)
// The command was written to stdin; wait for the output.
const transcript = await waitForTranscript(registry, uuid, 'hello-agent-pty')
expect(transcript).toContain('hello-agent-pty')
} finally {
registry.disposeAll()
}
})
it('spawns a bare shell when command is empty', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'bare', '', process.cwd(), 80, 24)
const handle = registry.get(uuid)
expect(handle).toBeDefined()
expect(handle!.command).toBe('')
expect(handle!.exited).toBe(false)
// The pty process exists; its transcript may take a moment to
// accumulate on Windows ConPTY. The handle is alive and listed.
expect(registry.list('s1')).toHaveLength(1)
} finally {
registry.disposeAll()
}
})
it('sends raw text to stdin (send-keys semantics)', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'sender', '', process.cwd(), 80, 24)
// Wait for the shell prompt, then send a command.
await waitForTranscript(registry, uuid, '', 1000)
// The registry's send() writes verbatim; the caller (tool layer) is
// responsible for appending \r when submit=true. Here we test the
// registry directly: send text + \r to submit.
registry.send(uuid, 'echo sent-via-send\r')
const transcript = await waitForTranscript(registry, uuid, 'sent-via-send')
expect(transcript).toContain('sent-via-send')
} finally {
registry.disposeAll()
}
})
it('reads a bounded page of the transcript', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'reader', 'echo line1\necho line2\necho line3', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'line3')
const page = registry.read(uuid)
expect(page.totalLines).toBeGreaterThan(0)
expect(page.text).toContain('line1')
expect(page.text).toContain('line3')
// Negative offset reads from the end.
const tail = registry.read(uuid, -2)
expect(tail.lineBegin).toBeGreaterThanOrEqual(0)
expect(tail.lineEnd).toBe(tail.lineBegin + tail.text.split('\n').length)
} finally {
registry.disposeAll()
}
})
it('resizes without throwing', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'resizer', '', process.cwd(), 80, 24)
expect(() => registry.resize(uuid, 120, 40)).not.toThrow()
} finally {
registry.disposeAll()
}
})
it('resize clamps to the 2..1024 range and returns the applied dimensions', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'clamp', '', process.cwd(), 80, 24)
// Oversized / undersized requests report the clamped values, so the
// echoed result always matches the pty (the tool contract).
expect(registry.resize(uuid, 5000, 1)).toEqual({ cols: 1024, rows: 2 })
expect(registry.resize(uuid, -3, 80.9)).toEqual({ cols: 2, rows: 80 })
expect(registry.resize(uuid, 120, 40)).toEqual({ cols: 120, rows: 40 })
// create clamps through the same helper.
const other = registry.create('s1', 'clamped-create', '', process.cwd(), 1, 9999)
expect(registry.get(other)!.pty.cols).toBe(2)
expect(registry.get(other)!.pty.rows).toBe(1024)
} finally {
registry.disposeAll()
}
})
it('assertOwned rejects a uuid owned by another session', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const mine = registry.create('s1', 'mine', '', process.cwd(), 80, 24)
const theirs = registry.create('s2', 'theirs', '', process.cwd(), 80, 24)
expect(() => registry.assertOwned(mine, 's1')).not.toThrow()
// A foreign uuid is indistinguishable from an unknown one.
expect(() => registry.assertOwned(theirs, 's1')).toThrow(/not found/)
expect(() => registry.assertOwned('nope', 's1')).toThrow(/not found/)
} finally {
registry.disposeAll()
}
})
it('closes a terminal and removes it from the list', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'closer', '', process.cwd(), 80, 24)
expect(registry.list('s1')).toHaveLength(1)
const closed = registry.close(uuid)
expect(closed).toBe(true)
expect(registry.list('s1')).toHaveLength(0)
// Closing again is a no-op.
const closedAgain = registry.close(uuid)
expect(closedAgain).toBe(false)
} finally {
registry.disposeAll()
}
})
it('scopes list by session id', () => {
const registry = new AgentPtyRegistry(testShell())
try {
registry.create('s1', 'a', '', process.cwd(), 80, 24)
registry.create('s1', 'b', '', process.cwd(), 80, 24)
registry.create('s2', 'c', '', process.cwd(), 80, 24)
expect(registry.list('s1')).toHaveLength(2)
expect(registry.list('s2')).toHaveLength(1)
expect(registry.list('s3')).toHaveLength(0)
} finally {
registry.disposeAll()
}
})
it('fires change listeners on create, close, and exit', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
let changeCount = 0
registry.subscribe(() => { changeCount += 1 })
const uuid = registry.create('s1', 'watched', '', process.cwd(), 80, 24)
// create fires one change.
expect(changeCount).toBeGreaterThanOrEqual(1)
registry.close(uuid)
// close fires another change.
expect(changeCount).toBeGreaterThanOrEqual(2)
} finally {
registry.disposeAll()
}
})
it('disposeAll closes every terminal', () => {
const registry = new AgentPtyRegistry(testShell())
registry.create('s1', 'a', '', process.cwd(), 80, 24)
registry.create('s2', 'b', '', process.cwd(), 80, 24)
registry.disposeAll()
expect(registry.list('s1')).toHaveLength(0)
expect(registry.list('s2')).toHaveLength(0)
})
it('snapshotOf drops the pty reference and transcript', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'snap', '', process.cwd(), 80, 24)
const handle = registry.get(uuid)!
const snap = snapshotOf(handle)
expect(snap.uuid).toBe(uuid)
expect(snap.title).toBe('snap')
expect(snap.exited).toBe(false)
// No pty or transcript fields on the snapshot.
expect('pty' in snap).toBe(false)
expect('transcript' in snap).toBe(false)
// sessionId is registry-internal ownership, never serialized (it would
// violate the terminal_list output schema's additionalProperties:false).
expect('sessionId' in snap).toBe(false)
expect('sessionId' in registry.list('s1')[0]!).toBe(false)
} finally {
registry.disposeAll()
}
})
it('ALLOWED_SIGNALS includes SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGTSTP', () => {
expect(ALLOWED_SIGNALS).toContain('SIGINT')
expect(ALLOWED_SIGNALS).toContain('SIGTERM')
expect(ALLOWED_SIGNALS).toContain('SIGKILL')
expect(ALLOWED_SIGNALS).toContain('SIGHUP')
expect(ALLOWED_SIGNALS).toContain('SIGTSTP')
})
it('waitFor returns found when the needle is already in the transcript', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'echo-test', 'echo wait-for-fast', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'wait-for-fast')
// The needle is already present; waitFor should return immediately.
const result = await registry.waitFor(uuid, 'wait-for-fast', 2000)
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('wait-for-fast')
expect(result.elapsedMs).toBeLessThan(500)
}
} finally {
registry.disposeAll()
}
})
it('waitFor supports regex alternation and reports which alternative matched', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// Multi-outcome pattern (build success/failure): the terminal prints
// only the FAILURE marker; the alternation must match immediately and
// the `match` field must tell WHICH alternative hit.
const uuid = registry.create('s1', 'multi-outcome', 'echo BUILD_FAIL', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'BUILD_FAIL')
const result = await registry.waitFor(uuid, 'BUILD_(OK|FAIL)', 2000)
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('BUILD_(OK|FAIL)')
expect(result.match).toBe('BUILD_FAIL')
}
} finally {
registry.disposeAll()
}
})
it('waitFor falls back to verbatim matching when the pattern is not a valid regex', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// `BUILD_(OK` does not compile as a regex (unbalanced group); the wait
// must degrade to literal substring matching and still find it.
const uuid = registry.create('s1', 'bad-regex', 'echo "BUILD_(OK literal"', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'BUILD_(OK')
const result = await registry.waitFor(uuid, 'BUILD_(OK', 2000)
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.match).toBe('BUILD_(OK')
}
} finally {
registry.disposeAll()
}
})
it('waitFor regex matches text a plain substring could not express', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'regex-version', 'echo version v1.2.3 ready', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'v1.2.3')
const result = await registry.waitFor(uuid, String.raw`v\d+\.\d+\.\d+`, 2000)
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.match).toBe('v1.2.3')
}
} finally {
registry.disposeAll()
}
})
it('waitFor returns found after the needle appears (async output)', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// Spawn a shell WITH a command (so output is guaranteed), but start
// the wait for a DIFFERENT needle that only appears after we send
// a follow-up command. This exercises the subscribe/poll path: the
// wait must notice output arriving AFTER it started, not just find
// pre-existing content.
const uuid = registry.create('s1', 'async-echo', 'echo shell-ready', process.cwd(), 80, 24)
// Wait for the initial command's output so we know the shell is live.
await waitForTranscript(registry, uuid, 'shell-ready', 10_000)
// Start the wait BEFORE sending the follow-up command. The needle is
// NOT in the transcript yet. ConPTY under test concurrency can be slow,
// so the timeout is generous.
const waitPromise = registry.waitFor(uuid, 'UNIQUE_NEEDLE_42', 30_000)
// Give the wait a moment to subscribe, then send the command.
await new Promise(resolve => setTimeout(resolve, 200))
registry.send(uuid, 'echo UNIQUE_NEEDLE_42\r')
const result = await waitPromise
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('UNIQUE_NEEDLE_42')
}
} finally {
registry.disposeAll()
}
}, 40_000)
it('waitFor returns timeout when the needle never appears', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'silent', '', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, '', 1000)
// Wait for a needle that will never appear; use a short timeout to
// keep the test fast.
const result = await registry.waitFor(uuid, 'this-needle-will-never-appear', 500)
expect(result.kind).toBe('timeout')
if (result.kind === 'timeout') {
expect(result.timeoutMs).toBe(500)
expect(result.totalLines).toBeGreaterThan(0)
}
} finally {
registry.disposeAll()
}
})
it('waitFor throws on an empty needle', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'empty-needle', '', process.cwd(), 80, 24)
await expect(registry.waitFor(uuid, '', 500)).rejects.toThrow()
} finally {
registry.disposeAll()
}
})
it('waitFor throws on an unknown uuid', async () => {
const registry = new AgentPtyRegistry(testShell())
await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow()
})
it('waitFor returns skipped when the user skips from the sidebar', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// A BARE shell, and the wait needle appears nowhere in its preparation:
// an interactive shell echoes what is written to it, so a command that
// spelled the needle would already have put it in the transcript before
// this point — waitFor's fast path would resolve the wait and there
// would be nothing left to skip. POSIX /bin/sh does not echo a command
// written to a non-tty and hid this for a long time; PowerShell does,
// which is why it only ever failed on Windows.
const uuid = registry.create('s1', 'skip-test', '', process.cwd(), 80, 24)
await waitForShellReady(registry, uuid)
// waitFor registers its record synchronously (before the first poll
// await), so the skip can fire immediately after the call.
const waitPromise = registry.waitFor(uuid, 'NEVER_APPEARS_XYZ', 30_000)
expect(await registry.skipWait(uuid)).toBe(1)
const result = await waitPromise
expect(result.kind).toBe('skipped')
if (result.kind === 'skipped') expect(result.needle).toBe('NEVER_APPEARS_XYZ')
// Idempotent: nothing left to skip once the wait resolved.
expect(registry.skipWait(uuid)).toBe(0)
} finally {
registry.disposeAll()
}
})
it('snapshot exposes waiting while a wait is active and clears after it ends', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// Same echo hazard as the skip case above: a bare shell puts no needle
// in the transcript on its own.
const uuid = registry.create('s1', 'wait-snap', '', process.cwd(), 80, 24)
await waitForShellReady(registry, uuid)
const waitPromise = registry.waitFor(uuid, 'LATER_MARK_9', 30_000)
// Registration happens synchronously before waitFor's first await.
expect(registry.list('s1')[0]?.waiting?.needle).toBe('LATER_MARK_9')
expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number')
expect(registry.skipWait(uuid)).toBe(1)
const result = await waitPromise
expect(result.kind).toBe('skipped')
expect(registry.list('s1')[0]?.waiting).toBeUndefined()
} finally {
registry.disposeAll()
}
})
it('concurrent waits on one uuid: snapshot shows the latest needle, skipWait skips all', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'concurrent', '', process.cwd(), 80, 24)
// The bare shell never emits either needle → both waits miss the fast
// paths and register their records (synchronously, before waitFor's
// first poll await — the documented concurrent-wait contract).
const first = registry.waitFor(uuid, 'NEVER_A_XYZ', 60_000)
const second = registry.waitFor(uuid, 'NEVER_B_XYZ', 60_000)
// The banner mirrors the LATEST wait (waits.at(-1)).
expect(registry.list('s1')[0]?.waiting?.needle).toBe('NEVER_B_XYZ')
expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number')
// One skip transitions EVERY active wait on the terminal.
expect(registry.skipWait(uuid)).toBe(2)
expect(await first).toEqual({ kind: 'skipped', needle: 'NEVER_A_XYZ' })
expect(await second).toEqual({ kind: 'skipped', needle: 'NEVER_B_XYZ' })
// Both resolved → the wait state cleared from the snapshot.
expect(registry.list('s1')[0]?.waiting).toBeUndefined()
// Idempotent: nothing left to skip.
expect(registry.skipWait(uuid)).toBe(0)
} finally {
registry.disposeAll()
}
})
it('fires change listeners when a wait starts and ends', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// Same echo hazard as the skip case above: a bare shell puts no needle
// in the transcript on its own.
const uuid = registry.create('s1', 'watched-wait', '', process.cwd(), 80, 24)
await waitForShellReady(registry, uuid)
let changes = 0
const unsubscribe = registry.subscribe(() => { changes += 1 })
const waitPromise = registry.waitFor(uuid, 'NEVER_NOTIFY_1', 30_000)
const afterStart = changes
expect(afterStart).toBeGreaterThanOrEqual(1)
expect(await registry.skipWait(uuid)).toBe(1)
expect(await waitPromise).toEqual({ kind: 'skipped', needle: 'NEVER_NOTIFY_1' })
expect(changes).toBeGreaterThan(afterStart)
unsubscribe()
} finally {
registry.disposeAll()
}
})
it('skipWait rejects an unknown uuid with not-found', () => {
const registry = new AgentPtyRegistry(testShell())
try {
expect(() => registry.skipWait('missing-uuid')).toThrow(/not found/)
// The HTTP layer maps `status` straight onto the response, so the 404
// contract rides this field — assert it, not just the message.
let thrown: unknown
try {
registry.skipWait('missing-uuid')
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(SidebarError)
expect((thrown as SidebarError).status).toBe(404)
expect((thrown as SidebarError).code).toBe('not-found')
} finally {
registry.disposeAll()
}
})
it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'signal-test', '', process.cwd(), 80, 24)
// SIGINT and SIGTSTP are delivered by writing \x03 / \x1a to the pty
// stdin — the cross-platform way terminals send Ctrl+C / Ctrl+Z.
// These must NOT throw (the old kill(SIGINT) path threw on Windows).
expect(() => registry.signal(uuid, 'SIGINT')).not.toThrow()
expect(() => registry.signal(uuid, 'SIGTSTP')).not.toThrow()
// The control byte was written to the pty; the shell received it.
// On POSIX the line discipline generates the signal; on Windows
// ConPTY translates it. Either way the write path is exercised.
} finally {
registry.disposeAll()
}
})
it('delivers SIGKILL via the process-termination path', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'kill-test', '', process.cwd(), 80, 24)
// SIGKILL uses pty.kill() (TerminateProcess on Windows). Must not
// throw even when the pty's kill() rejects named signals — the
// fallback to the default kill ensures the signal takes effect.
expect(() => registry.signal(uuid, 'SIGKILL')).not.toThrow()
} finally {
registry.disposeAll()
}
})
})