import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as smartcli from '../ts/index.js'; const delay = async (millisecondsArg: number) => { await new Promise((resolve) => setTimeout(resolve, millisecondsArg)); }; class TestWritable implements smartcli.ISmartcliWritable { public chunks: string[] = []; public isTTY: boolean; public columns = 80; constructor(isTTYArg: boolean) { this.isTTY = isTTYArg; } public write(chunkArg: string): boolean { this.chunks.push(chunkArg); return true; } public toString(): string { return this.chunks.join(''); } } tap.test('should render terminal tasks in non-interactive mode', async () => { const stream = new TestWritable(false); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false, symbols: 'ascii', nonInteractiveThrottleMs: 0, }); const task = terminal.createTask({ job: 'build package', rows: 2 }); task.update('running tsbuild'); task.complete('done'); const output = stream.toString(); expect(output).toInclude('start build package'); expect(output).toInclude('update build package: running tsbuild'); expect(output).toInclude('done build package in'); expect(output).toInclude(': done'); expect(terminal.getTasks()).toHaveLength(0); }); tap.test('should render fixed rows in interactive mode', async () => { const stream = new TestWritable(true); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false, symbols: 'ascii', cleanup: false, }); const task = terminal.createTask({ job: 'install dependencies', rows: 2 }); task.update('fetching packages'); const renderedRows = task.renderPlainRows(80); expect(renderedRows).toHaveLength(2); expect(renderedRows[0]).toInclude('* install dependencies'); expect(stream.toString()).toInclude('\u001B[?25l'); expect(stream.toString()).toInclude('\u001B[2K'); task.complete('installed'); expect(stream.toString()).toInclude('OK install dependencies'); expect(stream.toString()).toInclude('installed'); expect(stream.toString()).toInclude('\u001B[?25h'); expect(terminal.getTasks()).toHaveLength(0); }); tap.test('should attach persistent terminal task errors', async () => { const stream = new TestWritable(true); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false, symbols: 'ascii', cleanup: false, }); const task = terminal.createTask({ job: 'deploy release', rows: 3 }); task.attachError('deployment failed', { keepOpen: true }); expect(task.status).toEqual('failed'); expect(task.getErrorLines()).toContain('deployment failed'); expect(task.renderPlainRows(80)[0]).toInclude('X deploy release'); expect(terminal.getTasks()).toHaveLength(1); terminal.clear(); }); tap.test('should collapse failed terminal tasks into permanent output', async () => { const stream = new TestWritable(false); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false, symbols: 'ascii', }); const task = terminal.createTask({ job: 'publish package' }); task.attachError('registry rejected package'); const output = stream.toString(); expect(output).toInclude('fail publish package in'); expect(output).toInclude(': registry rejected package'); expect(terminal.getTasks()).toHaveLength(0); }); tap.test('should prefix every non-interactive multiline message with the task name', async () => { const stream = new TestWritable(false); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false, nonInteractiveThrottleMs: 0, }); const updateTask = terminal.task('multiline update'); updateTask.update('line one\nline two'); updateTask.complete('done'); const failedTask = terminal.task('multiline failure'); failedTask.fail('first failure line\nsecond failure line'); const output = stream.toString(); expect(output).toInclude('update multiline update: line one'); expect(output).toInclude('update multiline update: line two'); expect(output).toInclude('fail multiline failure in'); expect(output).toInclude('fail multiline failure: second failure line'); }); tap.test('should set task progress', async () => { const stream = new TestWritable(true); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false, symbols: 'ascii', cleanup: false, }); const task = terminal.task('process files', { rows: 2 }); task.setProgress(2, 5, 'processed files'); expect(task.getLastLogLine()).toInclude('40% (2/5)'); expect(task.renderPlainRows(80)[0]).toInclude('40% (2/5)'); terminal.clear(); }); tap.test('should render an optional second-precision timer', async () => { const stream = new TestWritable(true); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false, symbols: 'ascii', cleanup: false, }); const task = terminal.task('timed task', { rows: 2, showTimer: true }); expect(task.getTimerText()).toEqual('0s'); expect(task.renderPlainRows(80)[0]).toInclude('0s'); task.complete('timed complete'); expect(stream.toString()).toInclude('timed complete'); }); tap.test('should render an optional spinner', async () => { const stream = new TestWritable(true); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false, symbols: 'ascii', cleanup: false, }); const task = terminal.task('spinner task', { rows: 2, showSpinner: true, spinnerFrames: ['a', 'b'], spinnerIntervalMs: 20, }); await delay(90); const output = stream.toString(); expect(output).toInclude('spinner task'); expect(output.includes('a spinner task') || output.includes('b spinner task')).toBeTrue(); expect(task.getLiveRenderIntervalMs()).toEqual(20); task.complete('spinner complete'); }); tap.test('should auto-complete task.run', async () => { const stream = new TestWritable(false); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false, nonInteractiveThrottleMs: 0, }); const task = terminal.task('run operation'); const result = await task.run(async (taskArg) => { taskArg.update('inside operation'); return 'result'; }, { successMessage: 'operation finished' }); expect(result).toEqual('result'); expect(task.status).toEqual('completed'); expect(stream.toString()).toInclude('done run operation in'); expect(stream.toString()).toInclude('operation finished'); }); tap.test('should auto-fail task.run', async () => { const stream = new TestWritable(false); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false }); const task = terminal.task('failing operation'); let caughtError: Error | undefined; try { await task.run(async () => { throw new Error('operation failed'); }); } catch (error) { caughtError = error as Error; } expect(caughtError?.message).toEqual('operation failed'); expect(task.status).toEqual('failed'); expect(stream.toString()).toInclude('fail failing operation in'); expect(stream.toString()).toInclude('operation failed'); }); tap.test('should throttle duplicate non-interactive updates', async () => { const stream = new TestWritable(false); const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false, nonInteractiveThrottleMs: 10000, }); const task = terminal.task('quiet task'); task.update('same update'); task.update('same update'); task.update('different but throttled'); const output = stream.toString(); expect(output).toInclude('update quiet task: same update'); expect(output).not.toInclude('different but throttled'); }); export default tap.start();