codeburn/tests/scan-progress.test.ts
AgentSeal 2914ea66a6 fix(parser): keep scan-progress out of the interactive dashboard and compare TUIs
The cold-scan progress line was gated only on stderr.isTTY, which is also
true when the interactive dashboard and `codeburn compare` render Ink to
the same terminal — so the \r progress line printed over their frame and
garbled the screen (confirmed under a PTY). isTTY alone can't tell an Ink
UI apart from a plain CLI command.

Gate on an explicit 'interactive Ink UI is live' flag instead: the two
interactive entrypoints call setInteractiveScanUI() before they render, so
their scans (and the dashboard's 30s auto-refresh, including the
getPlanUsages -> parseAllSessions path) stay silent, while plain CLI
commands (overview, export, status) still show progress. Verified under a
PTY: dashboard and compare silent, overview shows progress, piped/non-TTY
silent. Adds a gate unit test covering the interactive-active, plain-TTY,
non-TTY, threshold, and finish()-clear cases.
2026-07-10 00:50:35 +02:00

66 lines
2.5 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest'
import { createScanProgress, setInteractiveScanUI } from '../src/parser.js'
// The scan-progress line must be silent while an interactive Ink UI (dashboard,
// compare) is live, because it renders to the same terminal. The end-to-end
// proof (cold dashboard WITH a plan stays silent; cold overview shows progress)
// is documented in the PR and was run under a PTY.
describe('createScanProgress gate', () => {
const origTTY = process.stderr.isTTY
afterEach(() => {
setInteractiveScanUI(false)
Object.defineProperty(process.stderr, 'isTTY', { value: origTTY, configurable: true })
vi.restoreAllMocks()
})
function forceTTY(v: boolean) {
Object.defineProperty(process.stderr, 'isTTY', { value: v, configurable: true })
}
it('writes progress for a plain CLI command in a TTY over the threshold', async () => {
forceTTY(true)
const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const p = createScanProgress('scanning', 100)
await p.tick(100) // final tick bypasses the 100ms throttle
expect(w).toHaveBeenCalled()
expect(String(w.mock.calls[0]![0])).toContain('scanning 100/100')
})
it('goes silent once an interactive Ink UI is active, even in a TTY', async () => {
forceTTY(true)
setInteractiveScanUI() // dashboard/compare call this right before render()
const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const p = createScanProgress('scanning', 100)
for (let i = 1; i <= 100; i++) await p.tick(i)
p.finish()
expect(w).not.toHaveBeenCalled()
})
it('finish() clears the line when progress was shown', async () => {
forceTTY(true)
const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const p = createScanProgress('scanning', 100)
await p.tick(100)
w.mockClear()
p.finish()
expect(String(w.mock.calls[0]![0])).toBe('\r\x1b[K')
})
it('writes nothing when stderr is not a TTY (piped/captured output)', async () => {
forceTTY(false)
const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const p = createScanProgress('scanning', 100)
await p.tick(100)
p.finish()
expect(w).not.toHaveBeenCalled()
})
it('stays silent below the small-tree threshold', async () => {
forceTTY(true)
const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const p = createScanProgress('scanning', 5)
await p.tick(5)
expect(w).not.toHaveBeenCalled()
})
})