Files
orca/src/main/daemon/session-terminal-control.test.ts
T
mirror 34fa2cd5ca
Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
source snapshot 2026-09-02
2026-09-02 02:03:16 +08:00

272 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Session } from './session'
import { PRODUCER_PAUSE_FAILSAFE_MS } from './session-producer-pause'
function createRecordingSubprocess() {
const written: string[] = []
let onData: ((data: string) => void) | null = null
let onExit: ((code: number) => void) | null = null
let killed = false
let clearCalls = 0
let pauseCalls = 0
let resumeCalls = 0
return {
pid: 12345,
foregroundProcess: null as string | null,
written,
get killed() {
return killed
},
get clearCalls() {
return clearCalls
},
get pauseCalls() {
return pauseCalls
},
get resumeCalls() {
return resumeCalls
},
getForegroundProcess(): string | null {
return this.foregroundProcess
},
write(data: string) {
written.push(data)
},
resize(_cols: number, _rows: number) {},
pause() {
pauseCalls += 1
},
resume() {
resumeCalls += 1
},
clear() {
clearCalls += 1
},
kill() {
killed = true
},
terminateOwnedTree: () => 'unavailable' as const,
forceKill() {
killed = true
},
signal(_signal: string) {},
onData(callback: (data: string) => void) {
onData = callback
},
onExit(callback: (code: number) => void) {
onExit = callback
},
dispose() {},
simulateData(data: string) {
onData?.(data)
},
simulateExit(code: number) {
onExit?.(code)
}
}
}
describe('Session terminal control', () => {
let session: Session
let subprocess: ReturnType<typeof createRecordingSubprocess>
beforeEach(() => {
vi.useFakeTimers()
subprocess = createRecordingSubprocess()
})
afterEach(() => {
session?.dispose()
vi.useRealTimers()
})
function createSession(options: { shellReadySupported?: boolean } = {}): void {
session = new Session({
sessionId: 'test-session',
cols: 80,
rows: 24,
subprocess,
shellReadySupported: options.shellReadySupported ?? false
})
}
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const original = process.platform
Object.defineProperty(process, 'platform', { value: platform })
try {
run()
} finally {
Object.defineProperty(process, 'platform', { value: original })
}
}
describe('clearScrollback', () => {
it('resyncs the native PTY screen state alongside the emulator clear', () => {
createSession()
session.clearScrollback()
expect(subprocess.clearCalls).toBe(1)
expect(session.takePendingOutput(false)?.records).toContainEqual({ kind: 'clear' })
})
it('nudges a Windows PowerShell prompt to repaint with a form feed', async () => {
createSession()
subprocess.foregroundProcess = 'powershell.exe'
subprocess.simulateData('PS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual(['\x0c'])
})
it('does not send a form feed while input is pending at the prompt', async () => {
createSession()
subprocess.foregroundProcess = 'powershell.exe'
subprocess.simulateData('PS C:\\Users\\me> fd')
await vi.advanceTimersByTimeAsync(10)
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
})
it('does not send or queue a form feed before shell-ready', async () => {
createSession({ shellReadySupported: true })
subprocess.foregroundProcess = 'powershell.exe'
subprocess.simulateData('PS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nPS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
expect(subprocess.written).toEqual([])
})
it('does not send a form feed at a PowerShell continuation prompt', async () => {
createSession()
subprocess.foregroundProcess = 'powershell.exe'
subprocess.simulateData('PS C:\\Users\\me> {\r\n>> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
})
it('does not send a form feed while a command owns the foreground', async () => {
createSession()
subprocess.foregroundProcess = 'node'
subprocess.simulateData('PS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('win32', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
})
it('does not send a form feed on POSIX platforms', async () => {
createSession()
subprocess.foregroundProcess = 'pwsh'
subprocess.simulateData('PS C:\\Users\\me> ')
await vi.advanceTimersByTimeAsync(10)
withPlatform('linux', () => session.clearScrollback())
expect(subprocess.written).toEqual([])
})
it('does not touch the subprocess after dispose', () => {
createSession()
session.dispose()
session.clearScrollback()
expect(subprocess.clearCalls).toBe(0)
})
})
describe('producer flow control', () => {
it('auto-resumes when the owner loses the resume signal', () => {
createSession()
session.pauseProducer()
expect(subprocess.pauseCalls).toBe(1)
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1)
expect(subprocess.resumeCalls).toBe(0)
vi.advanceTimersByTime(1)
expect(subprocess.resumeCalls).toBe(1)
})
it('resumeProducer resumes once and cancels the failsafe', () => {
createSession()
session.pauseProducer()
session.resumeProducer()
expect(subprocess.resumeCalls).toBe(1)
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2)
expect(subprocess.resumeCalls).toBe(1)
})
it('ignores resumeProducer without a matching pause', () => {
createSession()
session.resumeProducer()
expect(subprocess.resumeCalls).toBe(0)
})
it('re-pausing re-arms the failsafe window', () => {
createSession()
session.pauseProducer()
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1_000)
session.pauseProducer()
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1)
expect(subprocess.resumeCalls).toBe(0)
vi.advanceTimersByTime(1)
expect(subprocess.resumeCalls).toBe(1)
})
it('resumes a paused producer before kill signals the child', () => {
createSession()
session.pauseProducer()
session.kill()
expect(subprocess.resumeCalls).toBe(1)
expect(subprocess.killed).toBe(true)
})
it('dispose resumes a paused producer and clears the failsafe', () => {
createSession()
session.pauseProducer()
session.dispose()
expect(subprocess.resumeCalls).toBe(1)
expect(vi.getTimerCount()).toBe(0)
})
it('subprocess exit clears the failsafe without resuming a reaped child', () => {
createSession()
session.pauseProducer()
subprocess.simulateExit(0)
vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2)
expect(subprocess.resumeCalls).toBe(0)
})
it('ignores pauseProducer on an exited session', () => {
createSession()
subprocess.simulateExit(0)
session.pauseProducer()
expect(subprocess.pauseCalls).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
it('detaching the last client resumes a paused producer', () => {
createSession()
const token = session.attachClient({ onData: () => {}, onExit: () => {} })
session.pauseProducer()
session.detachClient(token)
expect(subprocess.resumeCalls).toBe(1)
})
it('keeps the pause while another client is attached', () => {
createSession()
const token = session.attachClient({ onData: () => {}, onExit: () => {} })
session.attachClient({ onData: () => {}, onExit: () => {} })
session.pauseProducer()
session.detachClient(token)
expect(subprocess.resumeCalls).toBe(0)
})
it('detachAllClients resumes a paused producer', () => {
createSession()
session.attachClient({ onData: () => {}, onExit: () => {} })
session.pauseProducer()
session.detachAllClients()
expect(subprocess.resumeCalls).toBe(1)
})
})
})