orca/src/main/daemon/headless-emulator.test.ts

442 lines
16 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { HeadlessEmulator } from './headless-emulator'
describe('HeadlessEmulator', () => {
let emulator: HeadlessEmulator
afterEach(() => {
emulator?.dispose()
})
describe('construction', () => {
it('creates with specified dimensions', () => {
emulator = new HeadlessEmulator({ cols: 120, rows: 40 })
const snapshot = emulator.getSnapshot()
expect(snapshot.cols).toBe(120)
expect(snapshot.rows).toBe(40)
})
it('defaults cwd to null', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(emulator.getSnapshot().cwd).toBeNull()
})
})
describe('write and snapshot', () => {
it('captures written text in snapshot', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('hello world')
const snapshot = emulator.getSnapshot()
expect(snapshot.snapshotAnsi).toContain('hello world')
})
it('captures colored text', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[31mred text\x1b[0m')
const snapshot = emulator.getSnapshot()
expect(snapshot.snapshotAnsi).toContain('red text')
})
})
describe('OSC-7 CWD tracking', () => {
it('parses OSC-7 file URI to extract CWD', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file://localhost/Users/test/project\x07')
expect(emulator.getSnapshot().cwd).toBe('/Users/test/project')
})
it('handles OSC-7 with empty host', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///home/user/work\x07')
expect(emulator.getSnapshot().cwd).toBe('/home/user/work')
})
it('updates CWD when new OSC-7 arrives', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///first\x07')
expect(emulator.getSnapshot().cwd).toBe('/first')
await emulator.write('\x1b]7;file:///second\x07')
expect(emulator.getSnapshot().cwd).toBe('/second')
})
it('decodes percent-encoded paths', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///Users/test/my%20project\x07')
expect(emulator.getSnapshot().cwd).toBe('/Users/test/my project')
})
it('normalizes Windows drive-letter OSC-7 paths', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
await emulator.write('\x1b]7;file:///C:/Users/test/project\x07')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(emulator.getSnapshot().cwd).toBe('C:/Users/test/project')
})
it('preserves Windows UNC OSC-7 paths', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
await emulator.write('\x1b]7;file://server/share/project\x07')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(emulator.getSnapshot().cwd).toBe('\\\\server\\share\\project')
})
it('handles OSC-7 with ST terminator', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///path/here\x1b\\')
expect(emulator.getSnapshot().cwd).toBe('/path/here')
})
it('tracks OSC-7 CWD across split PTY chunks', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]7;file:///split')
await emulator.write('/project\x07')
expect(emulator.getSnapshot().cwd).toBe('/split/project')
})
it('tracks OSC-7 CWD when ESC and OSC marker arrive in separate chunks', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b')
await emulator.write(']7;file:///split-escape\x07')
expect(emulator.getSnapshot().cwd).toBe('/split-escape')
})
})
describe('OSC title tracking', () => {
it('captures the latest OSC window title in snapshots', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]0;Codex working\x07hello')
expect(emulator.getSnapshot().lastTitle).toBe('Codex working')
})
it('uses the last OSC title when a chunk contains multiple title updates', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]0;Codex working\x07output\x1b]2;Codex idle\x1b\\')
expect(emulator.getSnapshot().lastTitle).toBe('Codex idle')
})
it('tracks OSC titles across split PTY chunks', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]0;Codex work')
await emulator.write('ing\x07')
expect(emulator.getSnapshot().lastTitle).toBe('Codex working')
})
it('adopts title metadata seeded from an external serializer', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
emulator.setLastTitle('Seeded renderer title')
expect(emulator.getSnapshot().lastTitle).toBe('Seeded renderer title')
})
it('adopts cwd metadata seeded from persisted terminal history', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
emulator.setCwd('/projects/restored')
expect(emulator.getSnapshot().cwd).toBe('/projects/restored')
})
})
describe('resize', () => {
it('updates dimensions', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
emulator.resize(120, 40)
const snapshot = emulator.getSnapshot()
expect(snapshot.cols).toBe(120)
expect(snapshot.rows).toBe(40)
})
})
describe('clear scrollback (CSI 3J)', () => {
it('detects CSI 3J and clears scrollback', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
// Write enough lines to push into scrollback
const lines = Array.from({ length: 30 }, (_, i) => `line ${i}\r\n`).join('')
await emulator.write(lines)
const before = emulator.getSnapshot()
expect(before.scrollbackLines).toBeGreaterThan(0)
await emulator.write('\x1b[3J')
const after = emulator.getSnapshot()
expect(after.scrollbackLines).toBe(0)
})
})
describe('terminal modes', () => {
it('tracks bracketed paste mode', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(emulator.getSnapshot().modes.bracketedPaste).toBe(false)
await emulator.write('\x1b[?2004h')
expect(emulator.getSnapshot().modes.bracketedPaste).toBe(true)
await emulator.write('\x1b[?2004l')
expect(emulator.getSnapshot().modes.bracketedPaste).toBe(false)
})
it('tracks alternate screen mode', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(emulator.getSnapshot().modes.alternateScreen).toBe(false)
await emulator.write('\x1b[?1049h')
expect(emulator.getSnapshot().modes.alternateScreen).toBe(true)
await emulator.write('\x1b[?1049l')
expect(emulator.getSnapshot().modes.alternateScreen).toBe(false)
})
it('tracks mouse reporting mode', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(emulator.getSnapshot().modes.mouseTracking).toBe(false)
await emulator.write('\x1b[?1002;1006h')
expect(emulator.getSnapshot().modes.mouseTracking).toBe(true)
expect(emulator.getSnapshot().modes.mouseTrackingMode).toBe('drag')
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(true)
expect(emulator.getSnapshot().modes.sgrMousePixelsMode).toBe(false)
await emulator.write('\x1b[?1002;1006l')
expect(emulator.getSnapshot().modes.mouseTracking).toBe(false)
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false)
})
it('tracks split SGR mouse reporting sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002;100')
await emulator.write('6h')
expect(emulator.getSnapshot().modes.mouseTrackingMode).toBe('drag')
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(true)
await emulator.write('\x1b[?100')
await emulator.write('6l')
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false)
})
it('tracks long split private mouse mode sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
const fillerModes = Array.from({ length: 40 }, (_, i) => String(3000 + i)).join(';')
await emulator.write(`\x1b[?1002;${fillerModes};100`)
await emulator.write('6h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('tracks private mouse modes with leading zero params', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?01002;01006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('tracks C1 CSI private mouse mode sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x9b?1002;1006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('tracks split C1 CSI private mouse mode sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x9b?1002;100')
await emulator.write('6h')
await emulator.write('\x9b?1002l')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('tracks C1 CSI split before private marker', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x9b')
await emulator.write('?1002;1006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
})
it('does not retain complete private CSI queries as scan tail', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?2004$p')
await emulator.write('1002;1006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
})
it('does not expose mouse modes before xterm applies the same write', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
const writePromise = emulator.write('\x1b[?1049h\x1b[?1002;1006h')
const snapshot = emulator.getSnapshot()
await writePromise
expect(snapshot.modes.alternateScreen).toBe(false)
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.rehydrateSequences).toBe('')
const after = emulator.getSnapshot()
expect(after.modes.alternateScreen).toBe(true)
expect(after.modes.mouseTrackingMode).toBe('drag')
expect(after.modes.sgrMouseMode).toBe(true)
expect(after.rehydrateSequences).toContain('\x1b[?1049h')
expect(after.rehydrateSequences).toContain('\x1b[?1002h')
expect(after.rehydrateSequences).toContain('\x1b[?1006h')
})
it('clears SGR mouse reporting on full terminal reset', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002;1006h')
await emulator.write('\x1bc')
await emulator.write('\x1b[?1002h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
})
it('tracks SGR-pixels mouse reporting as a separate active encoding', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002;1006h')
await emulator.write('\x1b[?1016h')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTrackingMode).toBe('drag')
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.modes.sgrMousePixelsMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1016h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
})
})
describe('rehydration sequences', () => {
it('generates rehydration for non-default modes', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?2004h')
const snapshot = emulator.getSnapshot()
expect(snapshot.rehydrateSequences).toContain('\x1b[?2004h')
})
it('generates empty rehydration when all modes are default', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('just plain text')
const snapshot = emulator.getSnapshot()
expect(snapshot.rehydrateSequences).toBe('')
})
it('rehydrates mouse reporting after alternate screen activation', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1049h\x1b[?1002;1006h')
const snapshot = emulator.getSnapshot()
expect(snapshot.rehydrateSequences).toContain('\x1b[?1049h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
expect(snapshot.rehydrateSequences.indexOf('\x1b[?1049h')).toBeLessThan(
snapshot.rehydrateSequences.indexOf('\x1b[?1002h')
)
})
it('preserves mouse modes after mobile normalizes an alternate-screen replay', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('normal buffer\r\n\x1b[?1049h\x1b[?2004h\x1b[?1002;1006halternate')
const snapshot = emulator.getSnapshot()
const payload = snapshot.rehydrateSequences + snapshot.snapshotAnsi
expect(payload.split('\x1b[?1049h')).toHaveLength(2)
expect(payload.slice(payload.lastIndexOf('\x1b[?1049h'))).toContain('\x1b[?1002h')
expect(payload.slice(payload.lastIndexOf('\x1b[?1049h'))).toContain('\x1b[?1006h')
})
it('rehydrates mouse encoding independently from reporting mode', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002;1006h')
await emulator.write('\x1b[?1002l')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.mouseTracking).toBe(false)
expect(snapshot.modes.sgrMouseMode).toBe(true)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h')
})
})
describe('dispose', () => {
it('can be disposed without error', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(() => emulator.dispose()).not.toThrow()
})
})
})