A quadrant can be flipped to "monitor" mode via a 👁 header toggle: instead of the live interactive terminal, it renders the session's screen from periodic GET /live-sessions/:id/preview snapshots into a read-only xterm. A monitored quadrant never attaches a WS and never sends a resize, so — unlike a live quadrant (latest-writer-wins) — it does not drive the shared PTY size. That lets you watch a session in a small quadrant without shrinking it for another device using it full-screen (the cross-device shrink the split-grid design flagged). - public/cell-monitor.ts (new): mountCellMonitor(host, id) — polling read-only preview, scaled to fit, best-effort, disposes cleanly (no write after dispose). - tabs.ts: per-entry monitor state; applyLayout keeps the live pane hidden and starts/stops the monitor; 👁 toggle in the cell header; teardown on close / leaving grid mode. - style.css: monitor button + .cell-monitor container. - tests: cell-monitor.test.ts (poll→write, dispose, late-resolve guard) + monitor wiring in tabs.test.ts. typecheck + build clean, 1587 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84 lines
2.6 KiB
TypeScript
84 lines
2.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* test/cell-monitor.test.ts — read-only monitor preview for a grid quadrant.
|
|
*
|
|
* Stubs xterm + preview-grid.fetchPreview so we can assert the poll→write loop and
|
|
* the dispose teardown without a real terminal or server.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
const terms: FakeTerm[] = []
|
|
class FakeTerm {
|
|
cols = 80
|
|
rows = 24
|
|
open = vi.fn()
|
|
write = vi.fn((_data: string, cb?: () => void) => cb?.())
|
|
reset = vi.fn()
|
|
resize = vi.fn()
|
|
dispose = vi.fn()
|
|
constructor() {
|
|
terms.push(this)
|
|
}
|
|
}
|
|
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerm }))
|
|
|
|
const fetchPreview = vi.fn()
|
|
vi.mock('../public/preview-grid.js', () => ({
|
|
fetchPreview,
|
|
PREVIEW_CLEAR: '<CLEAR>',
|
|
PREVIEW_THEME: {},
|
|
}))
|
|
|
|
const { mountCellMonitor } = await import('../public/cell-monitor.js')
|
|
|
|
beforeEach(() => {
|
|
terms.length = 0
|
|
fetchPreview.mockReset()
|
|
})
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
describe('cell-monitor', () => {
|
|
it('opens a read-only term and writes the fetched preview into it', async () => {
|
|
fetchPreview.mockResolvedValue({ id: 's1', cols: 100, rows: 30, data: 'HELLO' })
|
|
const host = document.createElement('div')
|
|
const m = mountCellMonitor(host, 's1')
|
|
|
|
expect(terms).toHaveLength(1)
|
|
expect(terms[0]!.open).toHaveBeenCalledWith(host)
|
|
expect(fetchPreview).toHaveBeenCalledWith('s1')
|
|
|
|
await vi.waitFor(() => expect(terms[0]!.write).toHaveBeenCalled())
|
|
expect(terms[0]!.resize).toHaveBeenCalledWith(100, 30)
|
|
expect(terms[0]!.write.mock.calls[0]![0]).toBe('<CLEAR>HELLO')
|
|
m.dispose()
|
|
})
|
|
|
|
it('dispose() tears down the term', () => {
|
|
fetchPreview.mockResolvedValue({ id: 's1', cols: 80, rows: 24, data: 'X' })
|
|
const m = mountCellMonitor(document.createElement('div'), 's1')
|
|
m.dispose()
|
|
expect(terms[0]!.dispose).toHaveBeenCalled()
|
|
})
|
|
|
|
it('a failed preview fetch skips the write (best-effort)', async () => {
|
|
fetchPreview.mockResolvedValue(null)
|
|
const m = mountCellMonitor(document.createElement('div'), 's1')
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(terms[0]!.write).not.toHaveBeenCalled()
|
|
m.dispose()
|
|
})
|
|
|
|
it('does not write after dispose (guards a late resolve)', async () => {
|
|
let resolve!: (v: unknown) => void
|
|
fetchPreview.mockReturnValue(new Promise((r) => (resolve = r)))
|
|
const m = mountCellMonitor(document.createElement('div'), 's1')
|
|
m.dispose() // dispose BEFORE the fetch resolves
|
|
resolve({ id: 's1', cols: 80, rows: 24, data: 'LATE' })
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(terms[0]!.write).not.toHaveBeenCalled()
|
|
})
|
|
})
|