import { Buffer } from 'node:buffer' import { afterEach, describe, expect, it, vi } from 'vitest' import { CommandExitError, type CommandHandle, type CommandResult, type Sandbox, } from '@deepseek-ai/dsh-e2b' import type { PtySendOperation, PtySessionStatus } from '@deepseek-ai/dsh-pty' import { E2BPtySession } from '@deepseek-ai/dsh-pty-e2b' import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts' function commandError(exitCode: number): CommandExitError { return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` }) } class FakePtyHandle { pid = 123 readonly result = Promise.withResolvers() disconnects = 0 kills = 0 disconnectError: unknown private settled = false wait(): Promise { return this.result.promise } async disconnect(): Promise { this.disconnects += 1 if (this.disconnectError !== undefined) throw this.disconnectError } async kill(): Promise { this.kills += 1 return true } exit(exitCode = 0): void { if (this.settled) return this.settled = true this.result.resolve({ exitCode, stdout: '', stderr: '' }) } failExit(exitCode: number): void { if (this.settled) return this.settled = true this.result.reject(commandError(exitCode)) } crash(error: unknown): void { if (this.settled) return this.settled = true this.result.reject(error) } asHandle(): CommandHandle { return this as unknown as CommandHandle } } class FakeSandbox { readonly sent: Array<{ pid: number; data: Buffer }> = [] readonly commands: string[] = [] readonly killed: number[] = [] pgid = '456\n' sendError: unknown commandError: unknown killError: unknown onTerm: (() => void) | undefined onKill: (() => void) | undefined readonly sandbox = { pty: { sendInput: async (pid: number, data: Uint8Array): Promise => { this.sent.push({ pid, data: Buffer.from(data) }) if (this.sendError !== undefined) throw this.sendError }, kill: async (pid: number): Promise => { this.killed.push(pid) if (this.killError !== undefined) throw this.killError this.onKill?.() return true }, }, commands: { run: async (command: string): Promise => { this.commands.push(command) if (this.commandError !== undefined) { const error = this.commandError this.commandError = undefined throw error } if (command.startsWith('ps ')) return { exitCode: 0, stdout: this.pgid, stderr: '' } if (command.startsWith('kill -TERM')) this.onTerm?.() return { exitCode: 0, stdout: '', stderr: '' } }, }, } as unknown as Sandbox } function config(overrides: Partial = {}): ResolvedConfig { return { backendType: 'shell', rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64, pollIntervalMs: 10, idleSilenceMs: 40, timeoutMs: 100, disposeGraceMs: 20, ...overrides, } } async function initialize(session: E2BPtySession): Promise { const pending = session.initialize() session.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) await vi.advanceTimersByTimeAsync(10) await pending } afterEach(() => { vi.useRealTimers() }) describe('E2BPtySession readiness, output, and signals', () => { it('initializes, sends UTF-8 input, settles at a prompt, and reads bounded scrollback', async () => { vi.useFakeTimers() const fake = new FakeSandbox() const handle = new FakePtyHandle() const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config({ maxReadBytes: 12 })) expect(session.read({})).toMatchObject({ text: '', totalLines: 0 }) await initialize(session) expect(session.motd).toBe('dsh> ') const operation = session.startSend({ text: 'printf 你好', submit: true }) expect(fake.sent).toEqual([{ pid: 123, data: Buffer.from('printf 你好\r') }]) session.onData(Buffer.from('一\n二\n三\x1b]133;D;0\x07dsh> ')) const bounded = operation.readOutput() expect(bounded.delta).toContain('三') expect(bounded.truncated).toBe(true) await vi.advanceTimersByTimeAsync(10) expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', sessionStatus: { kind: 'running' } }) expect(operation.cancel()).toBe(false) expect(session.read({ count: 2 }).text).toContain('dsh>') expect(session.read({ offset: 99 })).toMatchObject({ text: '', lineBegin: 99, lineEnd: 99 }) expect(() => session.read({ offset: -1 })).toThrow('non-negative safe integer') expect(() => session.read({ offset: 1.5 })).toThrow('non-negative safe integer') expect(() => session.read({ count: 0 })).toThrow('positive safe integer') expect(() => session.read({ count: 1.5 })).toThrow('positive safe integer') await expect(session.signal('SIGTERM')).resolves.toEqual({ delivered: true, targetPgid: 456 }) expect(fake.commands).toContain('kill -TERM -- -456') expect(session.status()).toEqual({ kind: 'running' }) }) it('distinguishes inferred idle, timeout, session exit, and no-output startup timeout', async () => { vi.useFakeTimers() const fake = new FakeSandbox() const handle = new FakePtyHandle() const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config()) await initialize(session) const inferred = session.startSend({ text: '', submit: false }) await vi.advanceTimersByTimeAsync(40) expect((await inferred.done).waitReason).toBe('inferred_idle') const timeout = session.startSend({ text: '', submit: false }) for (let index = 0; index < 3; index += 1) { await vi.advanceTimersByTimeAsync(30) session.onData(Buffer.from('.')) } await vi.advanceTimersByTimeAsync(10) expect((await timeout.done).waitReason).toBe('timeout') const exiting = session.startSend({ text: '', submit: false }) handle.failExit(143) expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, }) expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited') const startupHandle = new FakePtyHandle() const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), config()) const timedOut = expect(startup.initialize()).rejects.toThrow('startup timeout') await vi.advanceTimersByTimeAsync(100) await timedOut }) it('handles split prompt text, stale operations, and explicit cancellation', async () => { vi.useFakeTimers() const fake = new FakeSandbox() const handle = new FakePtyHandle() const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config()) const initializing = session.initialize() session.onData(Buffer.from('\x1b]133;D;0\x07')) await vi.advanceTimersByTimeAsync(20) session.onData(Buffer.from('dsh> ')) await vi.advanceTimersByTimeAsync(10) await initializing const operation = session.startSend({ text: 'sleep', submit: true }) const internal = session as unknown as { pollReadiness(operation: PtySendOperation): void interrupt(operation: PtySendOperation): void settleActive(reason: 'timeout'): void failActive(error: unknown): void appendOutput(text: string): void statusValue: PtySessionStatus } internal.pollReadiness({} as PtySendOperation) internal.interrupt({} as PtySendOperation) internal.appendOutput('') fake.pgid = '789\n' expect(operation.cancel()).toBe(true) await vi.advanceTimersByTimeAsync(0) expect(fake.commands).toContain('kill -INT -- -789') session.onData(Buffer.from('\x1b]133;D;130\x07dsh> ')) await vi.advanceTimersByTimeAsync(10) await operation.done internal.settleActive('timeout') internal.failActive(new Error('ignored')) const operationInternal = operation as unknown as { append(text: string): void settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void fail(error: unknown): void } operationInternal.append('ignored') operationInternal.settle('timeout', { kind: 'running' }, false) operationInternal.fail(new Error('ignored')) }) it('observes AbortSignal and contains send or foreground lookup failures', async () => { vi.useFakeTimers() const fake = new FakeSandbox() const handle = new FakePtyHandle() const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config()) await initialize(session) const controller = new AbortController() const aborting = session.startSend({ text: '', submit: false, signal: controller.signal }) expect(() => session.startSend({ text: '', submit: false })).toThrow('active send') fake.pgid = 'not-a-pgid\n' controller.abort() await expect(aborting.done).rejects.toThrow('cannot resolve foreground process group') const already = new AbortController() already.abort() expect(() => session.startSend({ text: '', submit: false, signal: already.signal })).toThrow('aborted before write') fake.sendError = new Error('send failed') const failed = session.startSend({ text: 'x', submit: false }) await expect(failed.done).rejects.toThrow('send failed') fake.pgid = '123\n' await expect(session.signal('SIGKILL')).rejects.toThrow('refusing to SIGKILL') fake.pgid = '0\n' await expect(session.signal('SIGINT')).rejects.toThrow('cannot resolve') const deferred = Promise.withResolvers() fake.sendError = undefined const sendInput = vi.spyOn(fake.sandbox.pty, 'sendInput').mockReturnValueOnce(deferred.promise) const late = session.startSend({ text: 'late', submit: false }) session.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) await vi.advanceTimersByTimeAsync(10) await late.done deferred.reject(new Error('late failure')) await vi.advanceTimersByTimeAsync(0) expect(sendInput).toHaveBeenCalled() }) it('preserves startup abort reasons and classifies invalid UTF-8 transport failures', async () => { const fake = new FakeSandbox() const abortHandle = new FakePtyHandle() const abortSession = new E2BPtySession(fake.sandbox, abortHandle.asHandle(), config()) const controller = new AbortController() const reason = new Error('startup cancelled') const initializing = abortSession.initialize(controller.signal) const rejected = expect(initializing).rejects.toBe(reason) controller.abort(reason) await rejected const invalidHandle = new FakePtyHandle() const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), config()) const pending = invalid.startSend({ text: '', submit: false }) invalid.onData(Uint8Array.from([0xff])) await expect(pending.done).rejects.toThrow('invalid UTF-8') expect(invalid.status()).toEqual({ kind: 'exited', exitCode: null, signal: null }) const crashHandle = new FakePtyHandle() const crashed = new E2BPtySession(fake.sandbox, crashHandle.asHandle(), config()) const active = crashed.startSend({ text: '', submit: false }) crashHandle.crash('transport gone') await expect(active.done).rejects.toEqual(new Error('transport gone')) const startupExitHandle = new FakePtyHandle() const startupExit = new E2BPtySession(fake.sandbox, startupExitHandle.asHandle(), config()) const exitedDuringStartup = expect(startupExit.initialize()).rejects.toThrow('exited during startup') startupExitHandle.exit(7) await exitedDuringStartup }) it('covers empty bounded reads and polling an exited active session', async () => { vi.useFakeTimers() const fake = new FakeSandbox() const tinyHandle = new FakePtyHandle() const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), config({ maxReadBytes: 1 })) tiny.onData(Buffer.from('你')) expect(tiny.read({ count: 1 })).toMatchObject({ text: '', lineEnd: 0 }) const handle = new FakePtyHandle() const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config()) const operation = session.startSend({ text: '', submit: false }) const internal = session as unknown as { pollReadiness(operation: PtySendOperation): void clearActive(): void statusValue: PtySessionStatus } internal.statusValue = { kind: 'exited', exitCode: 7, signal: null } internal.pollReadiness(operation) expect((await operation.done).waitReason).toBe('session_exit') internal.clearActive() }) }) describe('E2BPtySession teardown', () => { it('terminates the process group once, awaits exit, and disconnects', async () => { vi.useFakeTimers() const fake = new FakeSandbox() const handle = new FakePtyHandle() fake.onTerm = () => { handle.failExit(143) } const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config()) const first = session.close('done') expect(session.close('again')).toBe(first) await first expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGTERM' }) expect(handle.disconnects).toBe(1) expect(() => session.startSend({ text: '', submit: false })).toThrow('closing') }) it('contains an already-gone TERM, escalates to KILL, and reports a survivor', async () => { vi.useFakeTimers() const gone = new FakeSandbox() const goneHandle = new FakePtyHandle() gone.commandError = commandError(1) gone.onKill = () => { goneHandle.failExit(137) } const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), config()) const closingGone = goneSession.close('gone') await vi.advanceTimersByTimeAsync(20) await closingGone expect(gone.killed).toEqual([123]) expect(goneSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGKILL' }) const survivor = new FakeSandbox() const survivorHandle = new FakePtyHandle() const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), config()) const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving pid: 123') await vi.advanceTimersByTimeAsync(40) await failed survivorHandle.exit() await expect(survivorSession.close('retry')).resolves.toBeUndefined() }) it('propagates cleanup transport failures and lets close retry', async () => { const fake = new FakeSandbox() const handle = new FakePtyHandle() fake.commandError = new Error('TERM transport failed') const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config()) await expect(session.close('failure')).rejects.toThrow('TERM transport failed') handle.exit() await expect(session.close('retry')).resolves.toBeUndefined() const invalidTailHandle = new FakePtyHandle() const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), config()) invalidTail.onData(Uint8Array.from([0xe2])) invalidTailHandle.exit() await expect(invalidTail.close('invalid tail')).rejects.toThrow('invalid UTF-8') const normalHandle = new FakePtyHandle() normalHandle.disconnectError = new Error('disconnect raced') const normal = new E2BPtySession(fake.sandbox, normalHandle.asHandle(), config()) normalHandle.exit(7) await Promise.resolve() expect(normal.status()).toEqual({ kind: 'exited', exitCode: 7, signal: null }) await expect(normal.close('already exited')).resolves.toBeUndefined() }) it('kills a remotely live PTY after its host transport fails', async () => { const fake = new FakeSandbox() const handle = new FakePtyHandle() const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config()) const active = session.startSend({ text: '', submit: false }) session.onData(Uint8Array.from([0xff])) await expect(active.done).rejects.toThrow('invalid UTF-8') expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null }) fake.onTerm = () => { handle.failExit(143) } await expect(session.close('transport failed')).rejects.toThrow('invalid UTF-8') expect(fake.commands).toContain('kill -TERM -- -123') expect(handle.disconnects).toBe(1) }) })