mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-25 16:19:39 +00:00
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { PassThrough } from 'node:stream'
|
|
|
|
import React, { useEffect } from 'react'
|
|
import { Text } from 'ink'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { RESIZE_DEBOUNCE_MS, createDebouncedResizeStream, renderDebouncedInteractive } from '../src/dashboard.js'
|
|
import { BSU, ESU, stripSyncUpdateEscapes } from '../src/ink-win.js'
|
|
|
|
function makeTerminal(columns = 100, rows = 24): PassThrough & NodeJS.WriteStream {
|
|
const terminal = new PassThrough() as PassThrough & NodeJS.WriteStream
|
|
terminal.isTTY = true
|
|
terminal.columns = columns
|
|
terminal.rows = rows
|
|
return terminal
|
|
}
|
|
|
|
function paintedFrames(writes: string[]): string[] {
|
|
return writes
|
|
.map(chunk => stripSyncUpdateEscapes(chunk))
|
|
.flatMap(chunk => chunk.match(/FRAME:[^\r\n]*/g) ?? [])
|
|
}
|
|
|
|
const CLEAR_ALTERNATE_SCREEN = '\u001B[2J\u001B[H'
|
|
|
|
describe('interactive dashboard resize stream', () => {
|
|
afterEach(() => vi.useRealTimers())
|
|
|
|
it('publishes one settled paint after a resize burst', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal()
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const app = renderDebouncedInteractive(terminal, size => (
|
|
React.createElement(Text, null, `FRAME:${size.columns}x${size.rows}`)
|
|
), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: true,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
terminal.columns = 99
|
|
terminal.emit('resize')
|
|
await vi.advanceTimersByTimeAsync(50)
|
|
terminal.columns = 98
|
|
terminal.emit('resize')
|
|
await vi.advanceTimersByTimeAsync(50)
|
|
terminal.columns = 97
|
|
terminal.rows = 30
|
|
terminal.emit('resize')
|
|
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100)
|
|
|
|
const frames = paintedFrames(writes)
|
|
expect(frames.filter(frame => frame !== 'FRAME:97x30'), 'a resize burst must not paint intermediate sizes').toEqual([])
|
|
expect(frames).toContain('FRAME:97x30')
|
|
|
|
app.unmount()
|
|
app.dispose()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
})
|
|
|
|
it('keeps the clear on the settled repaint despite a synchronized interloper', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal(80, 100)
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
let interactive: ReturnType<typeof renderDebouncedInteractive> | undefined
|
|
const app = renderDebouncedInteractive(terminal, size => {
|
|
if (size.columns === 180) {
|
|
interactive?.stdout.write(`${BSU}INTERLOPER${ESU}`)
|
|
}
|
|
return React.createElement(Text, null, `FRAME:${size.columns}x${size.rows}`)
|
|
}, {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: true,
|
|
})
|
|
interactive = app
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
terminal.columns = 120
|
|
terminal.emit('resize')
|
|
await vi.advanceTimersByTimeAsync(50)
|
|
terminal.columns = 180
|
|
terminal.rows = 70
|
|
terminal.emit('resize')
|
|
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100)
|
|
|
|
const output = stripSyncUpdateEscapes(writes.join(''))
|
|
const rawOutput = writes.join('')
|
|
const clearAt = output.indexOf(CLEAR_ALTERNATE_SCREEN)
|
|
const settledFrameAt = output.indexOf('FRAME:180x70')
|
|
expect(output.split(CLEAR_ALTERNATE_SCREEN)).toHaveLength(3)
|
|
expect(clearAt).toBeGreaterThanOrEqual(0)
|
|
expect(settledFrameAt).toBeGreaterThan(clearAt)
|
|
const rawClearAt = rawOutput.indexOf(CLEAR_ALTERNATE_SCREEN)
|
|
const rawFrameAt = rawOutput.indexOf('FRAME:180x70')
|
|
const targetFrameStart = rawOutput.lastIndexOf(BSU, rawFrameAt)
|
|
const targetFrameEnd = rawOutput.indexOf(ESU, rawFrameAt)
|
|
const targetFrame = rawOutput.slice(targetFrameStart, targetFrameEnd + ESU.length)
|
|
expect(rawOutput.lastIndexOf(BSU, rawClearAt)).toBeGreaterThanOrEqual(0)
|
|
expect(targetFrameEnd).toBeGreaterThan(rawFrameAt)
|
|
expect(targetFrame).toContain(CLEAR_ALTERNATE_SCREEN)
|
|
|
|
app.unmount()
|
|
app.dispose()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
})
|
|
|
|
it('keeps a narrowing resize free of intermediate frames', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal(180, 70)
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const app = renderDebouncedInteractive(terminal, size => (
|
|
React.createElement(Text, null, `FRAME:${size.columns}x${size.rows}`)
|
|
), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: true,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
terminal.columns = 80
|
|
terminal.emit('resize')
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100)
|
|
|
|
const output = stripSyncUpdateEscapes(writes.join(''))
|
|
expect(new Set(paintedFrames(writes))).toEqual(new Set(['FRAME:80x70']))
|
|
expect(output).toContain(CLEAR_ALTERNATE_SCREEN)
|
|
|
|
app.unmount()
|
|
app.dispose()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
})
|
|
|
|
it('does not blank the alternate screen when the settled frame is unchanged', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal(280, 60)
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const app = renderDebouncedInteractive(terminal, () => (
|
|
React.createElement(Text, null, 'FRAME:stable')
|
|
), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: true,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
terminal.columns = 300
|
|
terminal.emit('resize')
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100)
|
|
|
|
const output = writes.join('')
|
|
expect(stripSyncUpdateEscapes(output)).not.toContain(CLEAR_ALTERNATE_SCREEN)
|
|
expect(output).not.toContain(BSU)
|
|
expect(output).not.toContain(ESU)
|
|
|
|
app.unmount()
|
|
app.dispose()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
})
|
|
|
|
it('does not clear an unchanged alternate screen in screen-reader mode', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal(280, 60)
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const app = renderDebouncedInteractive(terminal, () => (
|
|
React.createElement(Text, null, 'FRAME:stable')
|
|
), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: true,
|
|
isScreenReaderEnabled: true,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
terminal.columns = 300
|
|
terminal.emit('resize')
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100)
|
|
|
|
expect(stripSyncUpdateEscapes(writes.join(''))).not.toContain(CLEAR_ALTERNATE_SCREEN)
|
|
|
|
app.unmount()
|
|
app.dispose()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
})
|
|
|
|
it('inserts the reset only inside an Ink-owned synchronized repaint', () => {
|
|
const terminal = makeTerminal()
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const stdout = createDebouncedResizeStream(terminal, RESIZE_DEBOUNCE_MS)
|
|
const redraw = stdout.armAlternateScreenClear()
|
|
|
|
stdout.write(`${BSU}FRAME:ink-owned`)
|
|
stdout.write(ESU)
|
|
redraw.cancel()
|
|
|
|
expect(writes.join('')).toBe(`${BSU}${CLEAR_ALTERNATE_SCREEN}FRAME:ink-owned${ESU}`)
|
|
stdout.dispose()
|
|
})
|
|
|
|
it('does not let an unsynchronized write consume or nest the pending reset', () => {
|
|
const terminal = makeTerminal()
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const stdout = createDebouncedResizeStream(terminal, RESIZE_DEBOUNCE_MS)
|
|
const redraw = stdout.armAlternateScreenClear()
|
|
|
|
stdout.write('incidental-log-clear')
|
|
stdout.write(`${BSU}FRAME:ink-owned`)
|
|
stdout.write(ESU)
|
|
redraw.cancel()
|
|
|
|
expect(writes.join('')).toBe(`incidental-log-clear${BSU}${CLEAR_ALTERNATE_SCREEN}FRAME:ink-owned${ESU}`)
|
|
expect(writes.join('').split(BSU)).toHaveLength(2)
|
|
expect(writes.join('').split(ESU)).toHaveLength(2)
|
|
stdout.dispose()
|
|
})
|
|
|
|
it('keeps a replacement clear from being cancelled by a superseded resize', () => {
|
|
const terminal = makeTerminal()
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const stdout = createDebouncedResizeStream(terminal, RESIZE_DEBOUNCE_MS)
|
|
const superseded = stdout.armAlternateScreenClear()
|
|
const current = stdout.armAlternateScreenClear()
|
|
|
|
superseded.cancel()
|
|
stdout.write(`${BSU}FRAME:current${ESU}`)
|
|
current.cancel()
|
|
|
|
expect(writes.join('')).toBe(`${BSU}${CLEAR_ALTERNATE_SCREEN}FRAME:current${ESU}`)
|
|
stdout.dispose()
|
|
})
|
|
|
|
it('preserves the reset and frame when a Windows-style sink strips sync escapes', () => {
|
|
const terminal = makeTerminal()
|
|
const writes: string[] = []
|
|
const originalWrite = terminal.write.bind(terminal)
|
|
terminal.write = ((chunk: unknown, ...args: unknown[]) => {
|
|
const stripped = typeof chunk === 'string' ? stripSyncUpdateEscapes(chunk) : chunk
|
|
return (originalWrite as (...values: unknown[]) => boolean)(stripped, ...args)
|
|
}) as typeof terminal.write
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const stdout = createDebouncedResizeStream(terminal, RESIZE_DEBOUNCE_MS)
|
|
const redraw = stdout.armAlternateScreenClear()
|
|
|
|
stdout.write(`${BSU}FRAME:windows${ESU}`)
|
|
redraw.cancel()
|
|
|
|
expect(writes.join('')).toBe(`${CLEAR_ALTERNATE_SCREEN}FRAME:windows`)
|
|
stdout.dispose()
|
|
})
|
|
|
|
it('does not erase the primary screen when alternate-screen rendering is disabled', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal(80, 40)
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const app = renderDebouncedInteractive(terminal, size => (
|
|
React.createElement(Text, null, `FRAME:${size.columns}x${size.rows}`)
|
|
), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: false,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
terminal.columns = 120
|
|
terminal.emit('resize')
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100)
|
|
|
|
const output = stripSyncUpdateEscapes(writes.join(''))
|
|
expect(output).not.toContain(CLEAR_ALTERNATE_SCREEN)
|
|
expect(output).toContain('FRAME:120x40')
|
|
|
|
app.unmount()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
})
|
|
|
|
it('paints a mid-burst state update when the burst nets to no size change', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal()
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
let updateVisibleState = () => {}
|
|
const StatefulProbe = ({ size }: { size: { columns: number; rows: number } }) => {
|
|
const [revision, setRevision] = React.useState(0)
|
|
updateVisibleState = () => setRevision(value => value + 1)
|
|
return React.createElement(Text, null, `FRAME:revision=${revision}:size=${size.columns}x${size.rows}`)
|
|
}
|
|
const app = renderDebouncedInteractive(terminal, size => React.createElement(StatefulProbe, { size }), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: true,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
terminal.columns = 80
|
|
terminal.emit('resize')
|
|
updateVisibleState()
|
|
terminal.columns = 100
|
|
terminal.emit('resize')
|
|
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100)
|
|
|
|
expect(paintedFrames(writes).some(frame => frame.includes('revision=1')), 'a mid-burst state update must reach the terminal even when net size is unchanged').toBe(true)
|
|
|
|
app.unmount()
|
|
app.dispose()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
})
|
|
|
|
it('paints a state update after a spurious identical-dimension SIGWINCH', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal()
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
let updateVisibleState = () => {}
|
|
const StatefulProbe = ({ size }: { size: { columns: number; rows: number } }) => {
|
|
const [revision, setRevision] = React.useState(0)
|
|
updateVisibleState = () => setRevision(value => value + 1)
|
|
return React.createElement(Text, null, `FRAME:revision=${revision}:size=${size.columns}x${size.rows}`)
|
|
}
|
|
const app = renderDebouncedInteractive(terminal, size => React.createElement(StatefulProbe, { size }), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: true,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
terminal.emit('resize')
|
|
updateVisibleState()
|
|
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100)
|
|
|
|
expect(paintedFrames(writes).some(frame => frame.includes('revision=1')), 'a state update must still paint after a no-op SIGWINCH').toBe(true)
|
|
|
|
app.unmount()
|
|
app.dispose()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
})
|
|
|
|
it('removes the source relay and cancels pending resize delivery on unmount', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal()
|
|
const renderedSizes: Array<{ columns: number; rows: number }> = []
|
|
const Probe = ({ size }: { size: { columns: number; rows: number } }) => {
|
|
useEffect(() => {
|
|
renderedSizes.push(size)
|
|
}, [size])
|
|
return React.createElement(Text, null, `FRAME:${size.columns}x${size.rows}`)
|
|
}
|
|
const app = renderDebouncedInteractive(terminal, size => React.createElement(Probe, { size }), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
renderedSizes.length = 0
|
|
|
|
terminal.columns = 90
|
|
terminal.emit('resize')
|
|
app.unmount()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
|
|
await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS)
|
|
expect(renderedSizes).toEqual([])
|
|
expect(terminal.listenerCount('resize')).toBe(0)
|
|
})
|
|
|
|
it('cancels an armed clear when unmounted', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = makeTerminal()
|
|
const writes: string[] = []
|
|
terminal.on('data', chunk => writes.push(String(chunk)))
|
|
const app = renderDebouncedInteractive(terminal, size => (
|
|
React.createElement(Text, null, `FRAME:${size.columns}x${size.rows}`)
|
|
), {
|
|
interactive: true,
|
|
patchConsole: false,
|
|
alternateScreen: true,
|
|
})
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
writes.length = 0
|
|
|
|
app.stdout.armAlternateScreenClear()
|
|
app.unmount()
|
|
await vi.runAllTimersAsync()
|
|
await app.waitUntilExit()
|
|
writes.length = 0
|
|
app.stdout.write(`${BSU}AFTER-UNMOUNT${ESU}`)
|
|
|
|
expect(writes.join('')).toBe(`${BSU}AFTER-UNMOUNT${ESU}`)
|
|
expect(terminal.listenerCount('resize')).toBe(0)
|
|
})
|
|
|
|
it('disposes a stream that never rendered', () => {
|
|
const terminal = makeTerminal()
|
|
const stdout = createDebouncedResizeStream(terminal, RESIZE_DEBOUNCE_MS)
|
|
expect(terminal.listenerCount('resize')).toBe(1)
|
|
stdout.dispose()
|
|
expect(terminal.listenerCount('resize')).toBe(0)
|
|
|
|
const resize = vi.fn()
|
|
stdout.on('resize', resize)
|
|
terminal.emit('resize')
|
|
expect(resize).not.toHaveBeenCalled()
|
|
expect(terminal.listenerCount('resize')).toBe(0)
|
|
})
|
|
})
|