feat(terminal): enhance terminal task rendering with progress, lifecycle helpers, and configurable output modes
This commit is contained in:
@@ -3,25 +3,6 @@ import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as smartcli from '../ts/index.js';
|
||||
|
||||
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 create a new Smartcli', async () => {
|
||||
const smartCliTestObject = new smartcli.Smartcli();
|
||||
expect(smartCliTestObject).toBeInstanceOf(smartcli.Smartcli);
|
||||
@@ -55,64 +36,4 @@ tap.test('should accept a command', async () => {
|
||||
expect(hasExecuted).toBeTrue();
|
||||
});
|
||||
|
||||
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 });
|
||||
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('[build package] running tsbuild');
|
||||
expect(output).toInclude('[ok] build package - 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 });
|
||||
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('[run] install dependencies');
|
||||
expect(stream.toString()).toInclude('\u001B[2K');
|
||||
|
||||
task.complete('installed');
|
||||
|
||||
expect(stream.toString()).toInclude('[ok] install dependencies - installed');
|
||||
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 });
|
||||
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('[err] 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 });
|
||||
const task = terminal.createTask({ job: 'publish package' });
|
||||
|
||||
task.attachError('registry rejected package');
|
||||
|
||||
const output = stream.toString();
|
||||
expect(output).toInclude('[err] publish package - registry rejected package');
|
||||
expect(terminal.getTasks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import * as smartcli from '../ts/index.js';
|
||||
|
||||
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 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 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();
|
||||
Reference in New Issue
Block a user