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:
119
test/helpers/mock-pty.test.ts
Normal file
119
test/helpers/mock-pty.test.ts
Normal 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
123
test/helpers/mock-pty.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user