Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/lovely-radios-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": patch
---

Fix `taskLog` raw message handling so carriage-return spinner updates do not accumulate repeated frames.
44 changes: 40 additions & 4 deletions packages/prompts/src/task-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@ const stripDestructiveANSI = (input: string): string => {
return input.replace(/\x1b\[(?:\d+;)*\d*[ABCDEFGHfJKSTsu]|\x1b\[(s|u)/g, '');
};

const replaceLastLine = (input: string, replacement: string): string => {
const lastNewline = input.lastIndexOf('\n');
if (lastNewline === -1) {
return replacement;
}
return `${input.slice(0, lastNewline + 1)}${replacement}`;
};

const appendRawMessage = (input: string, msg: string, prependNewline: boolean): string => {
let next = prependNewline ? `${input}\n` : input;
const [first, ...overwrites] = msg.split('\r');
next += first;
for (const overwrite of overwrites) {
next = replaceLastLine(next, overwrite);
}
return next;
};

const collapseCarriageReturnUpdates = (input: string): string => {
return input
.split('\n')
.map((line) => line.slice(line.lastIndexOf('\r') + 1))
.join('\n');
};

/**
* Renders a log which clears on success and remains on failure
*/
Expand Down Expand Up @@ -139,11 +164,22 @@ export const taskLog = (opts: TaskLogOptions) => {
};
const message = (buffer: BufferEntry, msg: string, mopts?: TaskLogMessageOptions) => {
clear(false);
if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer.value !== '') {
buffer.value += '\n';
const sanitized = stripDestructiveANSI(msg);
if (mopts?.raw === true) {
const rawMessage = sanitized.replace(/\n+$/g, '');
buffer.value = appendRawMessage(
buffer.value,
rawMessage,
buffer.value !== '' && !lastMessageWasRaw && !rawMessage.startsWith('\n') && !rawMessage.startsWith('\r')
);
lastMessageWasRaw = !sanitized.endsWith('\n');
} else {
if (buffer.value !== '') {
buffer.value += '\n';
}
buffer.value += collapseCarriageReturnUpdates(sanitized);
lastMessageWasRaw = false;
}
buffer.value += stripDestructiveANSI(msg);
lastMessageWasRaw = mopts?.raw === true;
if (opts.limit !== undefined) {
const lines = buffer.value.split('\n');
const linesToRemove = lines.length - opts.limit;
Expand Down
122 changes: 122 additions & 0 deletions packages/prompts/test/__snapshots__/task-log.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`taskLog (isCI = false) > error > clears carriage return spinner line when showLog = false 1`] = `
[
"│
",
"◇ foo
",
"│
",
"│ ◒ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│ ◐ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│ ◓ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│
■ some error!
",
]
`;

exports[`taskLog (isCI = false) > error > clears output if showLog = false 1`] = `
[
"│
Expand All @@ -21,6 +44,32 @@ exports[`taskLog (isCI = false) > error > clears output if showLog = false 1`] =
]
`;

exports[`taskLog (isCI = false) > error > renders latest carriage return spinner line with error 1`] = `
[
"│
",
"◇ foo
",
"│
",
"│ ◒ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│ ◐ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│ ◓ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│
■ some error!
",
"│
│ ◓ Cloning repository
",
]
`;

exports[`taskLog (isCI = false) > error > renders output with message 1`] = `
[
"│
Expand Down Expand Up @@ -712,6 +761,28 @@ exports[`taskLog (isCI = false) > message > raw = true appends message text unti
]
`;

exports[`taskLog (isCI = false) > message > raw = true replaces carriage return spinner updates 1`] = `
[
"│
",
"◇ foo
",
"│
",
"│ ◒ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│ ◐ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│ ◓ Cloning repository
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│ ◇ Repository cloned
",
]
`;

exports[`taskLog (isCI = false) > message > raw = true works when mixed with non-raw messages 1`] = `
[
"│
Expand Down Expand Up @@ -1164,6 +1235,23 @@ exports[`taskLog (isCI = false) > writes message header 1`] = `
]
`;

exports[`taskLog (isCI = true) > error > clears carriage return spinner line when showLog = false 1`] = `
[
"│
",
"◇ foo
",
"│
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"<erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│
■ some error!
",
]
`;

exports[`taskLog (isCI = true) > error > clears output if showLog = false 1`] = `
[
"│
Expand All @@ -1180,6 +1268,26 @@ exports[`taskLog (isCI = true) > error > clears output if showLog = false 1`] =
]
`;

exports[`taskLog (isCI = true) > error > renders latest carriage return spinner line with error 1`] = `
[
"│
",
"◇ foo
",
"│
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"<erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"│
■ some error!
",
"│
│ ◓ Cloning repository
",
]
`;

exports[`taskLog (isCI = true) > error > renders output with message 1`] = `
[
"│
Expand Down Expand Up @@ -1432,6 +1540,20 @@ exports[`taskLog (isCI = true) > message > raw = true appends message text until
]
`;

exports[`taskLog (isCI = true) > message > raw = true replaces carriage return spinner updates 1`] = `
[
"│
",
"◇ foo
",
"│
",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
"<erase.line><cursor.up count=1><erase.line><cursor.left count=1>",
]
`;

exports[`taskLog (isCI = true) > message > raw = true works when mixed with non-raw messages 1`] = `
[
"│
Expand Down
82 changes: 82 additions & 0 deletions packages/prompts/test/task-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,38 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => {
expect(output.buffer).toMatchSnapshot();
});

test('raw = true replaces carriage return spinner updates', async () => {
const log = prompts.taskLog({
input,
output,
title: 'foo',
});

log.message('◒ Cloning repository', { raw: true });
log.message('\r◐ Cloning repository', { raw: true });
log.message('\r◓ Cloning repository', { raw: true });
log.message('\r◇ Repository cloned\n', { raw: true });

expect(output.buffer).toMatchSnapshot();
});

test('raw = false keeps only the last carriage return update when success shows the log', async () => {
const log = prompts.taskLog({
input,
output,
title: 'foo',
});

log.message('◒ Cloning repository\r◐ Cloning repository\r◓ Cloning repository\r◇ Repository cloned');
log.success('done!', { showLog: true });

const renderedLog = output.buffer.at(-1);
expect(renderedLog).toContain('◇ Repository cloned');
expect(renderedLog).not.toContain('◒ Cloning repository');
expect(renderedLog).not.toContain('◐ Cloning repository');
expect(renderedLog).not.toContain('◓ Cloning repository');
});

test('prints empty lines', async () => {
const log = prompts.taskLog({
input,
Expand Down Expand Up @@ -178,6 +210,56 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => {

expect(output.buffer).toMatchSnapshot();
});

test('renders latest carriage return spinner line with error', () => {
const log = prompts.taskLog({
input,
output,
title: 'foo',
});

log.message('◒ Cloning repository', { raw: true });
log.message('\r◐ Cloning repository', { raw: true });
log.message('\r◓ Cloning repository', { raw: true });

log.error('some error!');

expect(output.buffer).toMatchSnapshot();
});

test('clears carriage return spinner line when showLog = false', () => {
const log = prompts.taskLog({
input,
output,
title: 'foo',
});

log.message('◒ Cloning repository', { raw: true });
log.message('\r◐ Cloning repository', { raw: true });
log.message('\r◓ Cloning repository', { raw: true });

log.error('some error!', { showLog: false });

expect(output.buffer).toMatchSnapshot();
});

test('raw = false keeps only the last carriage return update on error', () => {
const log = prompts.taskLog({
input,
output,
title: 'foo',
});

log.message('◒ Cloning repository\r◐ Cloning repository\r◓ Cloning repository\r◇ Repository cloned');

log.error('some error!');

const renderedLog = output.buffer.at(-1);
expect(renderedLog).toContain('◇ Repository cloned');
expect(renderedLog).not.toContain('◒ Cloning repository');
expect(renderedLog).not.toContain('◐ Cloning repository');
expect(renderedLog).not.toContain('◓ Cloning repository');
});
});

describe('success', () => {
Expand Down
Loading