Skip to content

feat(@clack/prompts): adapt spinner to CI environment #169

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

Merged
merged 13 commits into from
Dec 14, 2024
Merged
5 changes: 5 additions & 0 deletions .changeset/thin-moose-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/prompts': patch
---

Adapts `spinner` output for static CI environments
3 changes: 2 additions & 1 deletion examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"scripts": {
"start": "jiti ./index.ts",
"spinner": "jiti ./spinner.ts"
"spinner": "jiti ./spinner.ts",
"spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts"
},
"devDependencies": {
"jiti": "^1.17.0"
Expand Down
36 changes: 36 additions & 0 deletions examples/basic/spinner-ci.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* This example addresses a issue reported in GitHub Actions where `spinner` was excessively writing messages,
* leading to confusion and cluttered output.
* To enhance the CI workflow and provide a smoother experience,
* the following changes have been made only for CI environment:
* - Messages will now only be written when a `spinner` method is called and the message updated, preventing unnecessary message repetition.
* - There will be no loading dots animation, instead it will be always `...`
* - Instead of erase the previous message, action that is blocked during CI, it will just write a new one.
*
* Issue: https://github.com/natemoo-re/clack/issues/168
*/
import * as p from '@clack/prompts';

const s = p.spinner();
let progress = 0;
let counter = 0;
let loop: NodeJS.Timer;

p.intro('Running spinner in CI environment');
s.start('spinner.start');
new Promise((resolve) => {
loop = setInterval(() => {
if (progress % 1000 === 0) {
counter++;
}
progress += 100;
s.message(`spinner.message [${counter}]`);
if (counter > 6) {
clearInterval(loop);
resolve(true);
}
}, 100);
}).then(() => {
s.stop('spinner.stop');
p.outro('Done');
});
2 changes: 1 addition & 1 deletion examples/basic/spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as p from '@clack/prompts';
p.intro('spinner start...');

const spin = p.spinner();
const total = 10000;
const total = 6000;
let progress = 0;
spin.start();

Expand Down
33 changes: 25 additions & 8 deletions packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,11 +643,13 @@ export const log = {
export const spinner = () => {
const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'];
const delay = unicode ? 80 : 120;
const isCI = process.env.CI === 'true';

let unblock: () => void;
let loop: NodeJS.Timeout;
let isSpinnerActive = false;
let _message = '';
let _prevMessage: string | undefined = undefined;

const handleExit = (code: number) => {
const msg = code > 1 ? 'Something went wrong' : 'Canceled';
Expand Down Expand Up @@ -676,44 +678,59 @@ export const spinner = () => {
process.removeListener('exit', handleExit);
};


const clearPrevMessage = () => {
if (_prevMessage === undefined) return;
if (isCI) process.stdout.write('\n');
const prevLines = _prevMessage.split('\n');
process.stdout.write(cursor.move(-999, prevLines.length - 1));
process.stdout.write(erase.down(prevLines.length));
};

const parseMessage = (msg: string): string => {
return msg.replace(/\.+$/, '');
};

const start = (msg = ''): void => {
isSpinnerActive = true;
unblock = block();
_message = msg.replace(/\.+$/, '');
_message = parseMessage(msg);
process.stdout.write(`${color.gray(S_BAR)}\n`);
let frameIndex = 0;
let dotsTimer = 0;
registerHooks();
loop = setInterval(() => {
if (isCI && _message === _prevMessage) {
return;
}
clearPrevMessage();
_prevMessage = _message;
const frame = color.magenta(frames[frameIndex]);
const loadingDots = '.'.repeat(Math.floor(dotsTimer)).slice(0, 3);
process.stdout.write(cursor.move(-999, 0));
process.stdout.write(erase.down(1));
const loadingDots = isCI ? '...' : '.'.repeat(Math.floor(dotsTimer)).slice(0, 3);
process.stdout.write(`${frame} ${_message}${loadingDots}`);
frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0;
dotsTimer = dotsTimer < frames.length ? dotsTimer + 0.125 : 0;
}, delay);
};

const stop = (msg = '', code = 0): void => {
_message = msg ?? _message;
isSpinnerActive = false;
clearInterval(loop);
clearPrevMessage();
const step =
code === 0
? color.green(S_STEP_SUBMIT)
: code === 1
? color.red(S_STEP_CANCEL)
: color.red(S_STEP_ERROR);
process.stdout.write(cursor.move(-999, 0));
process.stdout.write(erase.down(1));
_message = parseMessage(msg ?? _message);
process.stdout.write(`${step} ${_message}\n`);
clearHooks();
unblock();
};

const message = (msg = ''): void => {
_message = msg ?? _message;
_message = parseMessage(msg ?? _message);
};

return {
Expand Down
1 change: 1 addition & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading