Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add InteractiveWriter with spinner support #7

Merged
merged 3 commits into from
Feb 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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