feat: W1 batch — config, protocol, ring-buffer, origin, mock-pty (TDD)

Parallel module-builder subagents, disjoint file ownership, shared tree:
- T4 src/config.ts        — loadConfig+M1 origins (32 tests)
- T5 src/protocol.ts      — parse/serialize+SESSION_ID_RE+M7 (60 tests)
- T6 src/session/ring-buffer.ts — byte-accurate, no UTF-8/ANSI split+M2 (15 tests)
- T7 src/http/origin.ts   — host+port match, undefined rejected (12 tests)
- T3 test/helpers/mock-pty.ts — IPty double w/ emitData/emitExit (9 tests)

Verified by orchestrator: tsc --noEmit clean; full suite 128 tests pass.
This commit is contained in:
Yaojia Wang
2026-06-16 08:09:40 +02:00
parent 0e10dc21c3
commit 0fa0b69ce9
15 changed files with 1579 additions and 0 deletions

View File

View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import { createMockPty } from './mock-pty'
describe('createMockPty', () => {
it('emitData triggers all onData listeners', () => {
const pty = createMockPty()
const listener1Results: string[] = []
const listener2Results: string[] = []
pty.onData((data) => {
listener1Results.push(data)
})
pty.onData((data) => {
listener2Results.push(data)
})
pty.emitData('hello')
pty.emitData(' world')
expect(listener1Results).toEqual(['hello', ' world'])
expect(listener2Results).toEqual(['hello', ' world'])
})
it('onData returns a disposable that unsubscribes', () => {
const pty = createMockPty()
const results: string[] = []
const disposable = pty.onData((data) => {
results.push(data)
})
pty.emitData('first')
disposable.dispose()
pty.emitData('second')
expect(results).toEqual(['first'])
})
it('records write calls', () => {
const pty = createMockPty()
pty.write('input 1')
pty.write('input 2')
expect(pty.writes).toEqual(['input 1', 'input 2'])
})
it('records resize calls', () => {
const pty = createMockPty()
pty.resize(100, 50)
pty.resize(120, 60)
expect(pty.resizes).toEqual([
{ cols: 100, rows: 50 },
{ cols: 120, rows: 60 },
])
})
it('emitExit triggers onExit listeners', () => {
const pty = createMockPty()
const results: Array<{ exitCode: number; signal?: number }> = []
pty.onExit((event) => {
results.push(event)
})
pty.emitExit(0)
pty.emitExit(1, 9)
expect(results).toEqual([
{ exitCode: 0 },
{ exitCode: 1, signal: 9 },
])
})
it('onExit returns a disposable that unsubscribes', () => {
const pty = createMockPty()
const results: Array<{ exitCode: number; signal?: number }> = []
const disposable = pty.onExit((event) => {
results.push(event)
})
pty.emitExit(0)
disposable.dispose()
pty.emitExit(1)
expect(results).toEqual([{ exitCode: 0 }])
})
it('kill marks killed flag', () => {
const pty = createMockPty()
expect(pty.killed).toBe(false)
pty.kill()
expect(pty.killed).toBe(true)
})
it('kill can accept an optional signal', () => {
const pty = createMockPty()
pty.kill('SIGTERM')
expect(pty.killed).toBe(true)
})
it('has IPty interface properties', () => {
const pty = createMockPty()
expect(typeof pty.pid).toBe('number')
expect(typeof pty.cols).toBe('number')
expect(typeof pty.rows).toBe('number')
expect(typeof pty.onData).toBe('function')
expect(typeof pty.onExit).toBe('function')
expect(typeof pty.write).toBe('function')
expect(typeof pty.resize).toBe('function')
expect(typeof pty.kill).toBe('function')
})
})

123
test/helpers/mock-pty.ts Normal file
View File

@@ -0,0 +1,123 @@
import { IPty, PtyEvent, IDisposable } from '../../src/types'
/**
* Mock IPty for testing.
*
* Implements the full IPty interface with manual trigger methods
* (emitData, emitExit) and recording of calls (writes, resizes, killed).
*
* All properties are mutable for testing convenience.
*/
export interface MockIPty extends IPty {
/** Recorded write() calls. */
writes: string[]
/** Recorded resize() calls. */
resizes: Array<{ cols: number; rows: number }>
/** Whether kill() was called. */
killed: boolean
/** Manually trigger onData listeners. */
emitData(data: string): void
/** Manually trigger onExit listeners. */
emitExit(exitCode: number, signal?: number): void
}
/**
* Create a mock PTY for testing.
*
* pid, cols, rows are fixed but can be overridden via options for specific tests.
*/
export function createMockPty(options?: {
pid?: number
cols?: number
rows?: number
}): MockIPty {
const pid = options?.pid ?? 12345
let cols = options?.cols ?? 80
let rows = options?.rows ?? 24
const writes: string[] = []
const resizes: Array<{ cols: number; rows: number }> = []
let killed = false
// Event listeners: store arrays of listeners for each event type
const dataListeners: Array<(data: string) => void> = []
const exitListeners: Array<(event: { exitCode: number; signal?: number }) => void> = []
const onData: PtyEvent<string> = (listener: (e: string) => void): IDisposable => {
dataListeners.push(listener)
return {
dispose() {
const index = dataListeners.indexOf(listener)
if (index !== -1) {
dataListeners.splice(index, 1)
}
},
}
}
const onExit: PtyEvent<{ exitCode: number; signal?: number }> = (
listener: (e: { exitCode: number; signal?: number }) => void,
): IDisposable => {
exitListeners.push(listener)
return {
dispose() {
const index = exitListeners.indexOf(listener)
if (index !== -1) {
exitListeners.splice(index, 1)
}
},
}
}
const write = (data: string): void => {
writes.push(data)
}
const resize = (newCols: number, newRows: number): void => {
cols = newCols
rows = newRows
resizes.push({ cols: newCols, rows: newRows })
}
const kill = (signal?: string): void => {
killed = true
}
const emitData = (data: string): void => {
for (const listener of dataListeners) {
listener(data)
}
}
const emitExit = (exitCode: number, signal?: number): void => {
const event: { exitCode: number; signal?: number } = { exitCode }
if (signal !== undefined) {
event.signal = signal
}
for (const listener of exitListeners) {
listener(event)
}
}
return {
pid,
get cols() {
return cols
},
get rows() {
return rows
},
onData,
onExit,
write,
resize,
kill,
writes,
resizes,
get killed() {
return killed
},
emitData,
emitExit,
}
}