Skip to content

Commit

Permalink
feat: add InteractiveWriter with spinner support (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
zamotany committed Feb 4, 2020
1 parent 9928ab8 commit 36564d2
Show file tree
Hide file tree
Showing 8 changed files with 551 additions and 7 deletions.
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ Result:
![screenshot](https://raw.githubusercontent.com/zamotany/cli-tag-logger/master/screenshot.png)


## Examples

You can start examples from `examples/` directory running:

```bash
yarn example <example_name>
```

For instance:

```
yarn example spinners
```

## API

### Logging
Expand Down Expand Up @@ -163,6 +177,10 @@ print(`won't be logged`);

- `ConsoleWriter({ filter }?: { filer?: FilterConfig })` - writes messages to `process.stdout` (this writer is used by exported `print` function); supports filtering
- `FileWriter(filename: string, { filter, json }?: { filer?: FilterConfig; json?: boolean })` - writes messages to a file; supports filtering
- `InteractiveWriter` - writes messages to `process.stdout` and allows to draw a spinner at the bottom:
- `startSpinner(message: string, { type, interval }?: SpinnerOptions): void` - starts a spinner with a given `message` next to it; supports all spinners from [cli-spinners](https://github.com/sindresorhus/cli-spinners)
- `updateSpinner(...values: ComposableValues): void` - updates a message printed next to the spinner
- `stopSpinner(...values: ComposableValues): void` - stops a running spinner and prints given `values` in it's place

and a single abstract class `Writer`.

Expand All @@ -187,6 +205,42 @@ print(success`This will be printed in your terminal as well as in ${path.resolve

`composeWriters` function accepts unlimited amount of writers, but the first writer is called a _main_ writer. All of the functions (except for `print` and `onPrint`) from the _main_ writer will be exposed inside returned object.


Take `InteractiveWriter` for example - it has additional 3 methods: `startSpinner`, `updateSpinner` and `stopSpinner`. If `InteractiveWriter` is the _main_ writer, all of those 3 functions will be available for you:

```ts
import { info, InteractiveWriter, FileWriter, composeWriters } from 'cli-tag-logger';

const { print, startSpinner, updateSpinner, stopSpinner } = composeWriters(
new InteractiveWriter(),
new FileWriter()
);

print(info`Hello`)
startSpinner(info`I'm spinning`);

setTimeout(() => {
updateSpinner(info`I'm getting dizzy...`);
}, 1000);

setTimeout(() => {
stopSpinner(`Done`);
}, 2000);
```

However if you change the order and `FileWriter` will come first, only `print` function will be exposed, since this is the only function that `FileWriter` provides:

```ts
import { info, InteractiveWriter, FileWriter, composeWriters } from 'cli-tag-logger';

const { print } = composeWriters(
new FileWriter(),
new InteractiveWriter()
);

print(info`I'm the only function available`);
```

#### Creating custom writer

If you want to create your own writer, you need to extend abstract `Writer` class and implement `onPrint` function:
Expand Down
42 changes: 42 additions & 0 deletions example/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
InteractiveWriter,
FileWriter,
info,
composeWriters,
success,
} from '..';

const delay = (timeout: number) =>
new Promise(resolve => setTimeout(resolve, timeout));

(async () => {
const { updateSpinner, stopSpinner, startSpinner, print } = composeWriters(
new InteractiveWriter(),
new FileWriter('logs/output.log')
);

print(info`hello world`);
startSpinner(info`This spinner is spinnin'`, { type: 'bouncingBar' });

let interval = setInterval(() => {
print(info`new message ${Date.now()}`);
}, 1000);

await delay(3000);
updateSpinner(info`This will be a very long lin${'e'.repeat(300)}`);

await delay(6000);
stopSpinner(success`Finished`);
clearInterval(interval);

await delay(500);
startSpinner(success`Another spinner`);

interval = setInterval(() => {
print(info`new message ${Date.now()}`);
}, 1000);

await delay(5000);
stopSpinner();
clearInterval(interval);
})();
4 changes: 4 additions & 0 deletions example/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "..",
"include": ["*"]
}
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,21 @@
"watch": "yarn clean && concurrently \"yarn build:cjs --watch\" \"yarn build:ts --watch --preserveWatchOutput\"",
"typecheck": "tsc --noEmit",
"lint": "eslint --ext '.ts' src",
"test": "jest"
"test": "jest",
"example": "cd example && ../node_modules/.bin/babel-node --extensions .ts --env-name test --config-file ../babel.config.js"
},
"dependencies": {
"cli-spinners": "^2.2.0",
"colorette": "^1.1.0",
"fs-extra": "^8.1.0",
"signal-exit": "^3.0.2",
"strip-ansi": "^6.0.0"
"strip-ansi": "^6.0.0",
"terminal-kit": "^1.32.3"
},
"devDependencies": {
"@babel/cli": "^7.7.4",
"@babel/core": "^7.7.4",
"@babel/node": "^7.8.4",
"@babel/preset-env": "^7.7.4",
"@babel/preset-typescript": "^7.7.4",
"@callstack/eslint-config": "^8.0.0",
Expand All @@ -60,6 +64,7 @@
"@types/mkdirp": "^0.5.2",
"@types/node": "10",
"@types/signal-exit": "^3.0.0",
"@types/terminal-kit": "^1.28.0",
"@typescript-eslint/eslint-plugin": "^2.9.0",
"@typescript-eslint/parser": "^2.9.0",
"babel-jest": "^24.9.0",
Expand Down
87 changes: 87 additions & 0 deletions src/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import path from 'path';
import fs from 'fs-extra';
import stripAnsi from 'strip-ansi';
import onExit from 'signal-exit';
import spinners from 'cli-spinners';
import { terminal } from 'terminal-kit';

import { compose, ComposableValues } from './utils';
import { FilterConfig, Tester, createTester } from './filter';
Expand Down Expand Up @@ -95,3 +97,88 @@ export class FileWriter extends Writer {
this.fd !== undefined && fs.appendFileSync(this.fd, data + '\n');
}
}

type SpinnerOptions = {
type?: spinners.SpinnerName;
interval?: number;
};

export class InteractiveWriter extends Writer {
private interval: NodeJS.Timeout | undefined;
private message: string = '';
private frame: string = '';
private running: boolean = false;
private messageMaxLength: number;

constructor() {
super();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { default: _, ...spinnerTypes } = spinners;
const longestSpinnerFrameLength = Object.values(
(spinnerTypes as unknown) as Record<string, spinners.Spinner>
).reduce((length, spinner) => {
return Math.max(length, ...spinner.frames.map(frame => frame.length));
}, 0);
this.messageMaxLength = terminal.width - longestSpinnerFrameLength - 1;
}

private truncateMessage(message: string) {
const [messageFirstLine] = message.split('\n');
return messageFirstLine.slice(0, this.messageMaxLength);
}

private clearAndResetCursor() {
terminal.eraseLine();
terminal.move(-terminal.width, 0);
}

private draw() {
if (this.running) {
this.clearAndResetCursor();
terminal(this.frame, ' ', this.message);
}
}

onPrint(message: string) {
this.clearAndResetCursor();
terminal(message + '\n');
this.draw();
}

startSpinner = (
message: string,
{ type = 'line', interval = spinners[type].interval }: SpinnerOptions = {}
) => {
let frameKey = 0;
this.message = this.truncateMessage(message);

terminal.hideCursor(true);

onExit(() => {
terminal.hideCursor(false);
});

this.running = true;
this.interval = setInterval(() => {
this.frame = spinners[type].frames[frameKey];
frameKey++;
frameKey = frameKey >= spinners[type].frames.length ? 0 : frameKey;
this.draw();
}, interval);
};

updateSpinner = (...values: ComposableValues) => {
this.message = this.truncateMessage(compose(...values));
};

stopSpinner = (...values: ComposableValues) => {
this.running = false;
if (values.length > 0) {
this.print(...values);
} else {
this.clearAndResetCursor();
}
terminal.hideCursor(false);
this.interval && clearInterval(this.interval);
};
}
43 changes: 43 additions & 0 deletions test/__snapshots__/integration.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,46 @@ Object {
"yellowBright": [Function],
}
`;

exports[`cli-tag-logger should render spinner 1`] = `
Array [
"eraseLine: ",
"move: -1000",
"terminal: message 1
",
"eraseLine: ",
"move: -1000",
"terminal: - start",
"eraseLine: ",
"move: -1000",
"terminal: message 2
",
"eraseLine: ",
"move: -1000",
"terminal: - start",
"eraseLine: ",
"move: -1000",
"terminal: \\\\ start",
"eraseLine: ",
"move: -1000",
"terminal: | start",
"eraseLine: ",
"move: -1000",
"terminal: / update",
"eraseLine: ",
"move: -1000",
"terminal: - update",
"eraseLine: ",
"move: -1000",
"terminal: \\\\ update",
"eraseLine: ",
"move: -1000",
"terminal: | update",
"eraseLine: ",
"move: -1000",
"eraseLine: ",
"move: -1000",
"terminal: message 3
",
]
`;
63 changes: 63 additions & 0 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import del from 'del';
import path from 'path';
import fs from 'fs';
import * as log from '../src';
import { terminal } from 'terminal-kit';

jest.mock('terminal-kit', () => ({
terminal: jest.fn(),
}));

describe('cli-tag-logger', () => {
beforeAll(() => {
Expand Down Expand Up @@ -127,4 +132,62 @@ describe('cli-tag-logger', () => {
expect(writer1.data).toEqual(['hello']);
expect(writer2.data).toEqual(['hello']);
});

it('should render spinner', () =>
new Promise(resolve => {
const actions = [];

((terminal as unknown) as jest.Mock).mockImplementation((...args) => {
actions.push(['terminal', args]);
});
terminal.width = 100;
((terminal as unknown) as jest.Mock & {
eraseLine: Function;
}).eraseLine = jest.fn((...args) => {
actions.push(['eraseLine', args]);
});
((terminal.move as unknown) as jest.Mock) = jest.fn((...args) => {
actions.push(['move', args]);
});
((terminal as unknown) as jest.Mock & {
hideCursor: Function;
}).hideCursor = jest.fn();

const {
print,
startSpinner,
stopSpinner,
updateSpinner,
} = log.composeWriters(new log.InteractiveWriter());

print('message 1');

startSpinner('start', { interval: 5 });

setTimeout(() => {
print('message 2');
}, 10);

setTimeout(() => {
updateSpinner('update');
}, 20);

setTimeout(() => {
stopSpinner();
print('message 3');

expect(terminal.eraseLine).toHaveBeenCalledTimes(12);
expect(terminal.move).toHaveBeenCalledTimes(12);
expect(terminal).toHaveBeenCalledTimes(11);
expect(terminal.hideCursor).toHaveBeenCalledTimes(2);

expect(
actions.map(item => `${item[0]}: ${item[1].join('')}`)
).toMatchSnapshot();

resolve();
}, 40);

expect(true).toBe(true);
}));
});
Loading

0 comments on commit 36564d2

Please sign in to comment.