Skip to content
Open
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
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@
"@types/reflux": "0.4.1",
"@types/trusted-types": "^2.0.7",
"ansi-sequence-parser": "^1.1.3",
"ansi-to-react": "^6.1.6",
"base64-arraybuffer": "^1.0.1",
"cbor2": "^1.12.0",
"classnames": "2.3.1",
Expand Down
26 changes: 0 additions & 26 deletions pnpm-lock.yaml

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

122 changes: 122 additions & 0 deletions static/app/components/ansiText.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {render, screen} from 'sentry-test/reactTestingLibrary';

import {AnsiText} from 'sentry/components/ansiText';

function renderSegments(text: string) {
render(<AnsiText>{text}</AnsiText>);

const wrapper = screen.getByText('FATAL').parentElement;

return {
segments: Array.from(wrapper?.querySelectorAll('span') ?? [], span => ({
text: span.textContent,
colored: span.style.color !== '',
})),
wrapper,
};
}

describe('AnsiText', () => {
it('renders text without escape codes when given ANSI colors', () => {
const {container} = render(<AnsiText>{'\x1B[31mfailed\x1B[0m to connect'}</AnsiText>);

expect(container).toHaveTextContent(/^failed to connect$/);
});

it('blends ANSI colors into the text and background colors when given colored segments', () => {
render(<AnsiText>{'\x1B[31;44mcolored'}</AnsiText>);

const segment = screen.getByText('colored');
expect(segment.style.color).toContain('color-mix(in srgb,');
expect(segment.style.backgroundColor).toContain('color-mix(in srgb,');
});

it('resolves 256-color and truecolor codes to their RGB values', () => {
render(
<AnsiText>{'\x1B[38;5;208mpalette\x1B[0m \x1B[38;2;1;2;3mtruecolor'}</AnsiText>
);

expect(screen.getByText('palette').style.color).toContain('rgb(255, 135, 0)');
expect(screen.getByText('truecolor').style.color).toContain('rgb(1, 2, 3)');
});

it('applies every decoration when given combined decoration codes', () => {
render(<AnsiText>{'\x1B[1;3;4;9mdecorated'}</AnsiText>);

expect(screen.getByText('decorated')).toHaveStyle({
fontWeight: 'bold',
fontStyle: 'italic',
textDecorationLine: 'underline line-through',
});
});

it('removes non-color escape codes when given cursor control sequences', () => {
const {container} = render(<AnsiText>{'\x1B[2Kdone\x1B[?25h'}</AnsiText>);

expect(container).toHaveTextContent(/^done$/);
});

it('colors only the escaped text when given uncolored spaces around a colored word', () => {
const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0m ');

expect(segments).toEqual([
{text: ' ', colored: false},
{text: 'FATAL', colored: true},
{text: ' ', colored: false},
]);
});

it('colors a trailing space when given the space inside the escape codes', () => {
const {segments} = renderSegments(' \x1B[31mFATAL \x1B[0m ');

expect(segments).toEqual([
{text: ' ', colored: false},
{text: 'FATAL ', colored: true},
{text: ' ', colored: false},
]);
});

it('colors a leading space when given the space inside the escape codes', () => {
const {segments} = renderSegments(' \x1B[31m FATAL\x1B[0m ');

expect(segments).toEqual([
{text: ' ', colored: false},
{text: ' FATAL', colored: true},
{text: ' ', colored: false},
]);
});

it('colors only the escaped word when given uncolored text before it and a space after it', () => {
const {segments} = renderSegments('ABC\x1B[31mFATAL\x1B[0m ');

expect(segments).toEqual([
{text: 'ABC', colored: false},
{text: 'FATAL', colored: true},
{text: ' ', colored: false},
]);
});

it('colors only the escaped word when given a space before it and uncolored text after it', () => {
const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0mABC');

expect(segments).toEqual([
{text: ' ', colored: false},
{text: 'FATAL', colored: true},
{text: 'ABC', colored: false},
]);
});

it('collapses whitespace by default when given escape codes', () => {
const {wrapper} = renderSegments(' \x1B[41m FATAL \x1B[0m ');

expect(wrapper).not.toHaveStyle({whiteSpaceCollapse: 'preserve'});
});

it('preserves whitespace when given escape codes and preserveWhitespace', () => {
render(<AnsiText preserveWhitespace>{' \x1B[41m FATAL \x1B[0m '}</AnsiText>);

expect(screen.getByText('FATAL').parentElement).toHaveStyle({
whiteSpaceCollapse: 'preserve',
});
});
});
92 changes: 92 additions & 0 deletions static/app/components/ansiText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {useMemo, type CSSProperties, type ReactNode} from 'react';
import {useTheme} from '@emotion/react';
import {
createColorPalette,
parseAnsiSequences,
type ParseToken,
} from 'ansi-sequence-parser';

import {hasAnsi, stripAnsi} from 'sentry/utils/ansiEscapeCodes';

const ANSI_BACKGROUND_STRENGTH = '15%';
const ANSI_TEXT_STRENGTH = '50%';

const PRESERVE_WHITESPACE_STYLE: CSSProperties = {whiteSpaceCollapse: 'preserve'};

type ColorPalette = ReturnType<typeof createColorPalette>;

interface AnsiTextProps {
children: string;
preserveWhitespace?: boolean;
renderText?: (text: string) => ReactNode;
}

export function AnsiText({
children,
preserveWhitespace,
renderText = text => text,
}: AnsiTextProps) {
const theme = useTheme();
const palette = useMemo(() => createColorPalette(theme.tokens.syntax.ansi), [theme]);
const tokens = useMemo(
() =>
hasAnsi(children)
? parseAnsiSequences(children)
.map(token => ({...token, value: stripAnsi(token.value)}))
.filter(token => token.value)
: undefined,
[children]
);
Comment thread
JoshuaKGoldberg marked this conversation as resolved.

if (!tokens) {
return renderText(children);
}

return (
<span style={preserveWhitespace ? PRESERVE_WHITESPACE_STYLE : undefined}>
{tokens.map((token, index) => (
<span key={index} style={getTokenStyle(token, palette)}>
{renderText(token.value)}
</span>
))}
</span>
);
}

function getTokenStyle(
{background, decorations, foreground}: ParseToken,
palette: ColorPalette
): CSSProperties {
const style: CSSProperties = {};

if (foreground) {
style.color = `color-mix(in srgb, ${palette.value(foreground)} ${ANSI_TEXT_STRENGTH}, currentColor)`;
}

if (background) {
style.backgroundColor = `color-mix(in srgb, ${palette.value(background)} ${ANSI_BACKGROUND_STRENGTH}, transparent)`;
}

if (decorations.has('bold')) {
style.fontWeight = 'bold';
}

if (decorations.has('dim')) {
style.opacity = 0.7;
}

if (decorations.has('italic')) {
style.fontStyle = 'italic';
}

const lines = [
decorations.has('underline') && 'underline',
decorations.has('strikethrough') && 'line-through',
decorations.has('overline') && 'overline',
].filter(Boolean);
if (lines.length) {
style.textDecorationLine = lines.join(' ');
}

return style;
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,31 @@ describe('LogFileViewer', () => {
);
});

it('renders colored text without escape codes when given ANSI colors', async () => {
fetchMock.route(
attachmentUrl,
fetchMock.Response(new TextEncoder().encode('\x1B[31mfailed\x1B[0m to connect'))
);

render(<ExampleLogFileViewer />);

expect((await screen.findByText('failed')).style.color).toContain(
'color-mix(in srgb,'
);
expect(screen.getByText('to connect')).toBeInTheDocument();
});

it('renders only the final write when given carriage-return progress updates', async () => {
fetchMock.route(
attachmentUrl,
fetchMock.Response(new TextEncoder().encode('progress 10%\rprogress 100%'))
);

render(<ExampleLogFileViewer />);

expect(await screen.findByText('progress 100%')).toBeInTheDocument();
});

it('renders an error when the attachment cannot be downloaded', async () => {
fetchMock.route(attachmentUrl, '', {status: 404});

Expand Down
Loading
Loading