Skip to content

Commit 79998db

Browse files
committed
test(stdio): cover stdio/progress (was 0%)
23 tests covering ProgressBar (constructor defaults, update + tick, clamping, total=0 guard, render throttling, custom tokens, every :placeholder in the default format, color fallback for unknown names, TTY-vs-non-TTY clearLine paths, terminate behaviour with and without clear, post-terminate guard) and createProgressIndicator (label prefix, total=0 guard, floor semantics). Uses an in-memory mock stream — no real stderr writes.
1 parent 42c67dc commit 79998db

1 file changed

Lines changed: 263 additions & 0 deletions

File tree

test/unit/stdio/progress.test.mts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/**
2+
* @fileoverview Unit tests for stdio/progress.
3+
*
4+
* Covers ProgressBar class (constructor defaults, update, tick, throttling,
5+
* render path with custom tokens/colors, time formatting, terminate, clear)
6+
* and the createProgressIndicator helper.
7+
*
8+
* Uses a mock stream so tests don't write to the real stderr.
9+
*/
10+
11+
import { describe, expect, it, vi } from 'vitest'
12+
13+
import {
14+
ProgressBar,
15+
createProgressIndicator,
16+
} from '@socketsecurity/lib/stdio/progress'
17+
18+
function createMockStream(isTTY = true): NodeJS.WriteStream {
19+
const writes: string[] = []
20+
return {
21+
isTTY,
22+
write: (data: string) => {
23+
writes.push(String(data))
24+
return true
25+
},
26+
cursorTo: vi.fn(),
27+
clearLine: vi.fn(),
28+
// expose for assertions
29+
_writes: writes,
30+
} as unknown as NodeJS.WriteStream
31+
}
32+
33+
describe('stdio/progress', () => {
34+
describe('ProgressBar', () => {
35+
it('renders a bar on update', () => {
36+
const stream = createMockStream() as any
37+
const bar = new ProgressBar(100, { stream, renderThrottle: 0 })
38+
bar.update(50)
39+
expect(stream._writes.length).toBeGreaterThan(0)
40+
const output = stream._writes.join('')
41+
expect(output).toContain('50%')
42+
expect(output).toContain('50/100')
43+
})
44+
45+
it('clamps current to total', () => {
46+
const stream = createMockStream() as any
47+
const bar = new ProgressBar(10, { stream, renderThrottle: 0 })
48+
bar.update(999)
49+
const output = stream._writes.join('')
50+
expect(output).toContain('100%')
51+
expect(output).toContain('10/10')
52+
})
53+
54+
it('handles total=0 (avoids divide-by-zero)', () => {
55+
const stream = createMockStream() as any
56+
const bar = new ProgressBar(0, { stream, renderThrottle: 0 })
57+
bar.update(0)
58+
const output = stream._writes.join('')
59+
expect(output).toContain('0%')
60+
})
61+
62+
it('tick increments by 1 by default', () => {
63+
const stream = createMockStream() as any
64+
const bar = new ProgressBar(100, { stream, renderThrottle: 0 })
65+
bar.tick()
66+
const output = stream._writes.join('')
67+
expect(output).toContain('1/100')
68+
})
69+
70+
it('tick(n) increments by n', () => {
71+
const stream = createMockStream() as any
72+
const bar = new ProgressBar(100, { stream, renderThrottle: 0 })
73+
bar.tick(25)
74+
const output = stream._writes.join('')
75+
expect(output).toContain('25/100')
76+
})
77+
78+
it('terminates and emits newline when complete (without clear option)', () => {
79+
const stream = createMockStream() as any
80+
const bar = new ProgressBar(10, { stream, renderThrottle: 0 })
81+
bar.update(10)
82+
const lastWrite = stream._writes[stream._writes.length - 1]
83+
expect(lastWrite).toBe('\n')
84+
})
85+
86+
it('clears the line on terminate when clear=true', () => {
87+
const stream = createMockStream() as any
88+
const bar = new ProgressBar(10, {
89+
stream,
90+
renderThrottle: 0,
91+
clear: true,
92+
})
93+
bar.update(10)
94+
// No final newline emitted (clear path).
95+
expect(stream._writes[stream._writes.length - 1]).not.toBe('\n')
96+
// cursorTo was called for the TTY clear.
97+
expect(stream.cursorTo).toHaveBeenCalled()
98+
})
99+
100+
it('writes spaces to clear when stream is not a TTY', () => {
101+
const stream = createMockStream(false) as any
102+
const bar = new ProgressBar(10, {
103+
stream,
104+
renderThrottle: 0,
105+
clear: true,
106+
})
107+
bar.update(5) // partial update so non-TTY clear path fires
108+
bar.update(10)
109+
const output = stream._writes.join('')
110+
expect(output).toContain('\r')
111+
})
112+
113+
it('returns early after terminate', () => {
114+
const stream = createMockStream() as any
115+
const bar = new ProgressBar(10, { stream, renderThrottle: 0 })
116+
bar.update(10)
117+
const writeCountBefore = stream._writes.length
118+
bar.update(5)
119+
bar.tick(1)
120+
// No additional writes after termination.
121+
expect(stream._writes.length).toBe(writeCountBefore)
122+
})
123+
124+
it('throttles renders within renderThrottle window', () => {
125+
const stream = createMockStream() as any
126+
const bar = new ProgressBar(100, { stream, renderThrottle: 1000 })
127+
bar.update(10)
128+
const before = stream._writes.length
129+
// Second update within throttle window should be skipped.
130+
bar.update(20)
131+
expect(stream._writes.length).toBe(before)
132+
})
133+
134+
it('does NOT throttle the final update (current >= total)', () => {
135+
const stream = createMockStream() as any
136+
const bar = new ProgressBar(100, { stream, renderThrottle: 1000 })
137+
bar.update(50)
138+
const before = stream._writes.length
139+
// Even within throttle window, hitting total should render.
140+
bar.update(100)
141+
expect(stream._writes.length).toBeGreaterThan(before)
142+
})
143+
144+
it('replaces custom tokens passed to update()', () => {
145+
const stream = createMockStream() as any
146+
const bar = new ProgressBar(100, {
147+
stream,
148+
renderThrottle: 0,
149+
format: ':bar :percent :status',
150+
})
151+
bar.update(50, { status: 'downloading' })
152+
expect(stream._writes.join('')).toContain('downloading')
153+
})
154+
155+
it('replaces :elapsed and :eta tokens', () => {
156+
const stream = createMockStream() as any
157+
const bar = new ProgressBar(100, {
158+
stream,
159+
renderThrottle: 0,
160+
format: ':percent elapsed=:elapsed eta=:eta',
161+
})
162+
bar.update(50)
163+
const out = stream._writes.join('')
164+
expect(out).toMatch(/elapsed=\d+s/)
165+
expect(out).toMatch(/eta=\d+s/)
166+
})
167+
168+
it('formats time over 60 seconds as MmSs', () => {
169+
const stream = createMockStream() as any
170+
const bar = new ProgressBar(1000, {
171+
stream,
172+
renderThrottle: 0,
173+
format: ':eta',
174+
})
175+
// To exercise the >60s branch we simulate elapsed by patching startTime.
176+
const inst: any = bar
177+
inst.startTime = Date.now() - 120000 // 2 minutes ago
178+
bar.update(100) // 10% done after 2min → eta = 18min
179+
const out = stream._writes.join('')
180+
expect(out).toMatch(/\d+m\d+s/)
181+
})
182+
183+
it('clamps negative time deltas to 0s', () => {
184+
const stream = createMockStream() as any
185+
const bar = new ProgressBar(100, {
186+
stream,
187+
renderThrottle: 0,
188+
format: ':eta',
189+
})
190+
// current === 0 → eta = 0 → "0s".
191+
bar.update(0)
192+
expect(stream._writes.join('')).toContain('0s')
193+
})
194+
195+
it('honors color option (cyan default, others map)', () => {
196+
const stream = createMockStream() as any
197+
const bar = new ProgressBar(100, {
198+
stream,
199+
renderThrottle: 0,
200+
color: 'green',
201+
})
202+
bar.update(50)
203+
// Just ensure no throw and bar renders.
204+
expect(stream._writes.length).toBeGreaterThan(0)
205+
})
206+
207+
it('falls back to identity when color name is unknown', () => {
208+
const stream = createMockStream() as any
209+
const bar = new ProgressBar(100, {
210+
stream,
211+
renderThrottle: 0,
212+
color: 'nonsense' as any,
213+
})
214+
bar.update(50)
215+
expect(stream._writes.length).toBeGreaterThan(0)
216+
})
217+
218+
it('uses process.stderr by default when no stream is provided', () => {
219+
// Construct with no options — should still produce a bar (using stderr).
220+
// We don't actually want stderr writes in tests, so spy on it.
221+
const writeSpy = vi
222+
.spyOn(process.stderr, 'write')
223+
.mockImplementation(() => true)
224+
try {
225+
const bar = new ProgressBar(10, { renderThrottle: 0 })
226+
bar.update(5)
227+
expect(writeSpy).toHaveBeenCalled()
228+
} finally {
229+
writeSpy.mockRestore()
230+
}
231+
})
232+
})
233+
234+
describe('createProgressIndicator', () => {
235+
it('returns "[N%] current/total" format', () => {
236+
const result = createProgressIndicator(50, 100)
237+
expect(result).toContain('[50%]')
238+
expect(result).toContain('50/100')
239+
})
240+
241+
it('handles 0/total (0%)', () => {
242+
const result = createProgressIndicator(0, 100)
243+
expect(result).toContain('[0%]')
244+
})
245+
246+
it('handles total=0 without divide-by-zero (returns 0%)', () => {
247+
const result = createProgressIndicator(0, 0)
248+
expect(result).toContain('[0%]')
249+
})
250+
251+
it('floors percent (does not round up)', () => {
252+
const result = createProgressIndicator(33, 100)
253+
expect(result).toContain('[33%]')
254+
})
255+
256+
it('prefixes with label when supplied', () => {
257+
const result = createProgressIndicator(3, 10, 'Files')
258+
expect(result.startsWith('Files: ')).toBe(true)
259+
expect(result).toContain('[30%]')
260+
expect(result).toContain('3/10')
261+
})
262+
})
263+
})

0 commit comments

Comments
 (0)