Skip to content

Commit

Permalink
test: add jest matcher for clearer output on tests with ansi escapes
Browse files Browse the repository at this point in the history
  • Loading branch information
luciancooper committed Apr 20, 2024
1 parent a92b56a commit 5193538
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 122 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
},
"devDependencies": {
"@commitlint/cli": "^19.2.1",
"@jest/expect-utils": "^29.7.0",
"@lcooper/commitlint-config": "^2.0.0",
"@lcooper/eslint-config-jest": "^2.0.0",
"@lcooper/eslint-config-typescript": "^2.0.0",
Expand All @@ -76,6 +77,7 @@
"eslint": "^8.57.0",
"husky": "^9.0.11",
"jest": "^29.7.0",
"jest-matcher-utils": "^29.7.0",
"lint-staged": "^15.2.2",
"rimraf": "^5.0.5",
"svg-term": "^1.3.1",
Expand Down
41 changes: 41 additions & 0 deletions test/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { equals, iterableEquality } from '@jest/expect-utils';
import { printDiffOrStringify, type MatcherHintOptions } from 'jest-matcher-utils';
import { stripAnsi } from '../src';

declare global {
Expand All @@ -6,6 +8,7 @@ declare global {
interface Matchers<R> {
toMatchEachCodePoint: (expected?: number | boolean) => R
toMatchEachSequence: (expected?: boolean) => R
toMatchAnsi: (expected: string | string[]) => R
}
}
}
Expand All @@ -15,7 +18,45 @@ function hex(num: number) {
return '0'.repeat(Math.max(0, 4 - h.length)) + h;
}

function escAnsi<T extends string | string[]>(arg: T): T {
if (typeof arg !== 'string') return arg.map(escAnsi) as T;
return arg.replace(/[\x1B\x9B\x07]/g, (s) => {
const cp = s.codePointAt(0)!.toString(16);
return `\\x${'0'.repeat(Math.max(0, 2 - cp.length))}${cp}`;
}) as T;
}

expect.extend({
toMatchAnsi(received: string | string[], expected: string | string[]) {
const options: MatcherHintOptions = this.isNot != null ? { isNot: this.isNot } : {};
let pass: boolean;
if (typeof expected === 'string') {
pass = Object.is(received, expected);
options.comment = 'Object.is equality';
} else {
pass = equals(received, expected, [...this.customTesters, iterableEquality]);
options.comment = 'deep equality';
}
return {
pass,
message: pass ? () => (
this.utils.matcherHint('toMatchAnsi', undefined, undefined, options)
+ '\n\n'
+ `Expected: not ${this.utils.printExpected(escAnsi(expected)).replace(/\\\\(?=x)/g, '\\')}`
) : () => (
this.utils.matcherHint('toMatchAnsi', undefined, undefined, options)
+ '\n\n'
+ printDiffOrStringify(
escAnsi(expected),
escAnsi(received),
'Expected',
'Received',
this.expand !== false,
).replace(/\\\\(?=x)/g, '\\')
),
};
},

toMatchEachCodePoint(
received: [number, number | boolean, (number | boolean)?][],
expected?: number | boolean,
Expand Down
64 changes: 32 additions & 32 deletions test/sliceString.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,110 +25,110 @@ describe('sliceChars', () => {
});

test('slices ansi escape sequences', () => {
expect(sliceChars('fo\x1b[31mob\x1b[39mar', 0, 2)).toBe('fo');
expect(sliceChars('fo\x1b[31mob\x1b[39mar', 4, 6)).toBe('ar');
expect(sliceChars('fo\x1b[31mob\x1b[39mar', 1, 5)).toBe('o\x1b[31mob\x1b[39ma');
expect(sliceChars('fo\x1b[31mob\x1b[39mar', 0, 2)).toMatchAnsi('fo');
expect(sliceChars('fo\x1b[31mob\x1b[39mar', 4, 6)).toMatchAnsi('ar');
expect(sliceChars('fo\x1b[31mob\x1b[39mar', 1, 5)).toMatchAnsi('o\x1b[31mob\x1b[39ma');
expect(sliceChars('\x1b[31mfoo\x1b[39m\x1b[32mbar\x1b[39m', 1, 5))
.toBe('\x1b[31moo\x1b[39m\x1b[32mba\x1b[39m');
.toMatchAnsi('\x1b[31moo\x1b[39m\x1b[32mba\x1b[39m');
});

test('slices strings with both foreground & background styling', () => {
expect(sliceChars('\x1b[41m\x1b[33mfoobar\x1b[39m\x1b[49m', 1, 5))
.toBe('\x1b[41m\x1b[33mooba\x1b[39m\x1b[49m');
.toMatchAnsi('\x1b[41m\x1b[33mooba\x1b[39m\x1b[49m');
expect(sliceChars('\x1b[41mf\x1b[33mooba\x1b[39mr\x1b[49m', 2, 4))
.toBe('\x1b[41m\x1b[33mob\x1b[39m\x1b[49m');
.toMatchAnsi('\x1b[41m\x1b[33mob\x1b[39m\x1b[49m');
expect(sliceChars('fo\x1b[41m\x1b[33mob\x1b[39m\x1b[49mar', 1, 5))
.toBe('o\x1b[41m\x1b[33mob\x1b[39m\x1b[49ma');
.toMatchAnsi('o\x1b[41m\x1b[33mob\x1b[39m\x1b[49ma');
});

test('ignores empty escape sequences', () => {
// slice begins immediately after an empty escape sequence
expect(sliceChars('fo\x1b[42m\x1b[49m\x1b[41mob\x1b[49mar', 1, 5)).toBe('o\x1b[41mob\x1b[49ma');
expect(sliceChars('fo\x1b[42m\x1b[49m\x1b[41mob\x1b[49mar', 1, 5)).toMatchAnsi('o\x1b[41mob\x1b[49ma');
// slice ends immediately before an empty escape sequence
expect(sliceChars('fo\x1b[41mob\x1b[49ma\x1b[42m\x1b[49mr', 1, 5)).toBe('o\x1b[41mob\x1b[49ma');
expect(sliceChars('fo\x1b[41mob\x1b[49ma\x1b[42m\x1b[49mr', 1, 5)).toMatchAnsi('o\x1b[41mob\x1b[49ma');
// slice spans an empty escape sequence
expect(sliceChars('fo\x1b[41mo\x1b[32m\x1b[39mb\x1b[49mar', 1, 5)).toBe('o\x1b[41mob\x1b[49ma');
expect(sliceChars('fo\x1b[41mo\x1b[32m\x1b[39mb\x1b[49mar', 1, 5)).toMatchAnsi('o\x1b[41mob\x1b[49ma');
// string ends in an empty escape sequence
expect(sliceChars('fo\x1b[41mob\x1b[49mar\x1b[32m\x1b[39m', 1)).toBe('o\x1b[41mob\x1b[49mar');
expect(sliceChars('fo\x1b[41mob\x1b[49mar\x1b[32m\x1b[39m', 1)).toMatchAnsi('o\x1b[41mob\x1b[49mar');
});

test('handles unclosed escape sequences', () => {
// inputs contain unclosed foreground codes
expect(sliceChars('\x1b[32mfoobar')).toBe('\x1b[32mfoobar\x1b[39m');
expect(sliceChars('\x1b[32mfoobar', 1, 5)).toBe('\x1b[32mooba\x1b[39m');
expect(sliceChars('\x1b[32mfoobar')).toMatchAnsi('\x1b[32mfoobar\x1b[39m');
expect(sliceChars('\x1b[32mfoobar', 1, 5)).toMatchAnsi('\x1b[32mooba\x1b[39m');
// inputs contain unclosed foreground & background codes
expect(sliceChars('\x1b[41m\x1b[32mfoobar')).toBe('\x1b[41m\x1b[32mfoobar\x1b[39m\x1b[49m');
expect(sliceChars('\x1b[41m\x1b[32mfoobar', 1, 5)).toBe('\x1b[41m\x1b[32mooba\x1b[39m\x1b[49m');
expect(sliceChars('\x1b[41m\x1b[32mfoobar')).toMatchAnsi('\x1b[41m\x1b[32mfoobar\x1b[39m\x1b[49m');
expect(sliceChars('\x1b[41m\x1b[32mfoobar', 1, 5)).toMatchAnsi('\x1b[41m\x1b[32mooba\x1b[39m\x1b[49m');
});

test('ignores unnecessary opening escape sequences', () => {
// string ends with an opening 31m code, which should not appear in the result
expect(sliceChars('foo\x1b[32mbar\x1b[39m\x1b[31m', 4)).toBe('\x1b[32mar\x1b[39m');
expect(sliceChars('foo\x1b[32mbar\x1b[39m\x1b[31m', 4)).toMatchAnsi('\x1b[32mar\x1b[39m');
// the slice range ends on an opening 31m code, which should not appear in the result
expect(sliceChars('foo\x1b[32mba\x1b[31mr\x1b[39m', 3, 5)).toBe('\x1b[32mba\x1b[39m');
expect(sliceChars('foo\x1b[32mba\x1b[31mr\x1b[39m', 3, 5)).toMatchAnsi('\x1b[32mba\x1b[39m');
});

test('does not add unnecessary closing escape sequences', () => {
// all slice should have 2 foreground color opening codes and be closed with a single `\x1b[39m`
expect(sliceChars('\x1b[31m\x1b[32mfoo', 1)).toBe('\x1b[31m\x1b[32moo\x1b[39m');
expect(sliceChars('\x1b[31m\x1b[33mfoo\x1b[39mbar', 0, 4)).toBe('\x1b[31m\x1b[33mfoo\x1b[39mb');
expect(sliceChars('\x1b[31mf\x1b[33moo\x1b[39mbar', 1, 4)).toBe('\x1b[31m\x1b[33moo\x1b[39mb');
expect(sliceChars('\x1b[31m\x1b[32mfoo', 1)).toMatchAnsi('\x1b[31m\x1b[32moo\x1b[39m');
expect(sliceChars('\x1b[31m\x1b[33mfoo\x1b[39mbar', 0, 4)).toMatchAnsi('\x1b[31m\x1b[33mfoo\x1b[39mb');
expect(sliceChars('\x1b[31mf\x1b[33moo\x1b[39mbar', 1, 4)).toMatchAnsi('\x1b[31m\x1b[33moo\x1b[39mb');
});

test('handles unknown ansi style codes', () => {
// unknown escape sequence \x1b[1001m should be closed by a reset sequence \x1b[0m
expect(sliceChars('\x1b[1001mfoobar\x1b[49m', 0, 3)).toBe('\x1b[1001mfoo\x1b[0m');
expect(sliceChars('\x1b[1001mfoobar\x1b[49m', 0, 3)).toMatchAnsi('\x1b[1001mfoo\x1b[0m');
});

test('handles reset escape sequences', () => {
// both foreground and background are closed by a reset sequence (\x1b[0m)
expect(sliceChars('\x1b[32mfoo\x1b[41mbar\x1b[0m', 2)).toBe('\x1b[32mo\x1b[41mbar\x1b[0m');
expect(sliceChars('\x1b[32mfoo\x1b[41mbar\x1b[0m', 2)).toMatchAnsi('\x1b[32mo\x1b[41mbar\x1b[0m');
});

test('slices hyperlink escape sequences', () => {
// slice within a link
expect(sliceChars('\x1b]8;;link\x07foobar\x1b]8;;\x07', 0, 3))
.toBe('\x1b]8;;link\x07foo\x1b]8;;\x07');
.toMatchAnsi('\x1b]8;;link\x07foo\x1b]8;;\x07');
// slice begins outside link and ends inside link
expect(sliceChars('fo\x1b]8;;link\x07ob\x1b]8;;\x07ar', 0, 3))
.toBe('fo\x1b]8;;link\x07o\x1b]8;;\x07');
.toMatchAnsi('fo\x1b]8;;link\x07o\x1b]8;;\x07');
// slice spans the link
expect(sliceChars('fo\x1b]8;;link\x07ob\x1b]8;;\x07ar', 1, 5))
.toBe('o\x1b]8;;link\x07ob\x1b]8;;\x07a');
.toMatchAnsi('o\x1b]8;;link\x07ob\x1b]8;;\x07a');
// slice adjacent hyperlinks
expect(sliceChars('\x1b]8;;link1\x07foo\x1b]8;;\x07\x1b]8;;link2\x07bar\x1b]8;;\x07', 3, 5))
.toBe('\x1b]8;;link2\x07ba\x1b]8;;\x07');
.toMatchAnsi('\x1b]8;;link2\x07ba\x1b]8;;\x07');
});

test('slices hyperlink escape sequences that contain style sequences', () => {
// color styling contained in link text
expect(sliceChars('\x1b]8;;link\x07\x1b[41mfoo\x1b[49mbar\x1b]8;;\x07', 2, 6))
.toBe('\x1b]8;;link\x07\x1b[41mo\x1b[49mbar\x1b]8;;\x07');
.toMatchAnsi('\x1b]8;;link\x07\x1b[41mo\x1b[49mbar\x1b]8;;\x07');
// link and color styling overlap
expect(sliceChars('\x1b]8;;link\x07fo\x1b[32mob\x1b]8;;\x07ar\x1b[39m', 2))
.toBe('\x1b]8;;link\x07\x1b[32mob\x1b]8;;\x07ar\x1b[39m');
.toMatchAnsi('\x1b]8;;link\x07\x1b[32mob\x1b]8;;\x07ar\x1b[39m');
});

test('handles unclosed hyperlink escape sequences', () => {
expect(sliceChars('\x1b]8;;link1\x07foo\x1b]8;;link2\x07bar', 3))
.toBe('\x1b]8;;link1\x07\x1b]8;;link2\x07bar\x1b]8;;\x07');
.toMatchAnsi('\x1b]8;;link1\x07\x1b]8;;link2\x07bar\x1b]8;;\x07');
});

test('supports 8 bit color escape sequences', () => {
// foreground 55 & background 176
expect(sliceChars('\x1b[38;5;55m\x1b[48;5;176mfoobar\x1b[49m\x1b[39m', 0, 3))
.toBe('\x1b[38;5;55m\x1b[48;5;176mfoo\x1b[49m\x1b[39m');
.toMatchAnsi('\x1b[38;5;55m\x1b[48;5;176mfoo\x1b[49m\x1b[39m');
});

test('supports 24 bit color escape sequences', () => {
// foreground #6134eb & background #ccc0f0
expect(sliceChars('\x1b[38;2;97;52;235m\x1b[48;2;204;192;240mfoobar\x1b[49m\x1b[39m', 0, 3))
.toBe('\x1b[38;2;97;52;235m\x1b[48;2;204;192;240mfoo\x1b[49m\x1b[39m');
.toMatchAnsi('\x1b[38;2;97;52;235m\x1b[48;2;204;192;240mfoo\x1b[49m\x1b[39m');
});

test('ignores escape sequences that are not styles or hyperlinks', () => {
// contains a window title escape sequence
expect(sliceChars('foo\x1b]0;window_title\x07bar')).toBe('foobar');
expect(sliceChars('foo\x1b]0;window_title\x07bar')).toMatchAnsi('foobar');
});
});

Expand Down
44 changes: 22 additions & 22 deletions test/spliceChars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,106 +5,106 @@ import { spliceChars } from '../src';
describe('spliceChars', () => {
test('negative splice index', () => {
expect(spliceChars('abef', -2, 0, 'CD')).toBe('abCDef');
expect(spliceChars(chalk.green('abef'), -2, 0, 'CD')).toBe(chalk.green('abCDef'));
expect(spliceChars(chalk.green('abef'), -2, 0, 'CD')).toMatchAnsi(chalk.green('abCDef'));
});

test('negative splice index exceeds the length of the string', () => {
expect(spliceChars('def', -4, 0, 'ABC')).toBe('ABCdef');
expect(spliceChars(chalk.green('def'), -4, 0, 'ABC')).toBe(chalk.green('ABCdef'));
expect(spliceChars(chalk.green('def'), -4, 0, 'ABC')).toMatchAnsi(chalk.green('ABCdef'));
});

test('splice index exceeds than the length of the string', () => {
expect(spliceChars('abc', 4, 0, 'DEF')).toBe('abcDEF');
expect(spliceChars(chalk.green('abc'), 4, 0, 'DEF')).toBe(`${chalk.green('abc')}DEF`);
expect(spliceChars(chalk.green('abc'), 4, 0, 'DEF')).toMatchAnsi(`${chalk.green('abc')}DEF`);
});

describe('inserting characters', () => {
test('at the beginning of a string', () => {
expect(spliceChars('cdef', 0, 0, 'AB')).toBe('ABcdef');
expect(spliceChars(chalk.green('cdef'), 0, 0, 'AB')).toBe(chalk.green('ABcdef'));
expect(spliceChars(chalk.green('cdef'), 0, 0, 'AB')).toMatchAnsi(chalk.green('ABcdef'));
});

test('into the middle of a string', () => {
expect(spliceChars('abef', 2, 0, 'CD')).toBe('abCDef');
expect(spliceChars(chalk.green('abef'), 2, 0, 'CD')).toBe(chalk.green('abCDef'));
expect(spliceChars(chalk.green('abef'), 2, 0, 'CD')).toMatchAnsi(chalk.green('abCDef'));
});

test('at the end of a string', () => {
expect(spliceChars('abcd', 4, 0, 'EF')).toBe('abcdEF');
expect(spliceChars(chalk.green('abcd'), 4, 0, 'EF')).toBe(chalk.green('abcdEF'));
expect(spliceChars(chalk.green('abcd'), 4, 0, 'EF')).toMatchAnsi(chalk.green('abcdEF'));
});

test('between ansi escape sequences', () => {
expect(spliceChars(chalk.green('ab') + chalk.red('ef'), 2, 0, 'CD'))
.toBe(chalk.green('abCD') + chalk.red('ef'));
.toMatchAnsi(chalk.green('abCD') + chalk.red('ef'));
expect(spliceChars(chalk.green('ab') + chalk.red('cf'), 3, 0, 'DE'))
.toBe(chalk.green('ab') + chalk.red('cDEf'));
.toMatchAnsi(chalk.green('ab') + chalk.red('cDEf'));
expect(spliceChars(`${chalk.green('a')}b${chalk.red('def')}`, 2, 0, 'C'))
.toBe(`${chalk.green('a')}bC${chalk.red('def')}`);
.toMatchAnsi(`${chalk.green('a')}bC${chalk.red('def')}`);
});
});

describe('deleting characters', () => {
test('from the beginning of a string', () => {
expect(spliceChars('abcdef', 0, 2)).toBe('cdef');
expect(spliceChars(chalk.green('abcdef'), 0, 2)).toBe(chalk.green('cdef'));
expect(spliceChars(chalk.green('abcdef'), 0, 2)).toMatchAnsi(chalk.green('cdef'));
// across escape sequences
expect(spliceChars(chalk.green('abc') + chalk.red('def'), 0, 3)).toBe(chalk.red('def'));
expect(spliceChars(chalk.green('abc') + chalk.red('def'), 0, 3)).toMatchAnsi(chalk.red('def'));
});

test('from the middle of a string', () => {
expect(spliceChars('abcdef', 2, 2)).toBe('abef');
expect(spliceChars(chalk.green('abcdef'), 2, 2)).toBe(chalk.green('abef'));
expect(spliceChars(chalk.green('abcdef'), 2, 2)).toMatchAnsi(chalk.green('abef'));
// accross escape sequences
expect(spliceChars(chalk.green('abc') + chalk.red('def'), 2, 2))
.toBe(chalk.green('ab') + chalk.red('ef'));
.toMatchAnsi(chalk.green('ab') + chalk.red('ef'));
});

test('from the end of a string', () => {
expect(spliceChars('abcdef', 4, 2)).toBe('abcd');
expect(spliceChars(chalk.green('abcdef'), 4, 2)).toBe(chalk.green('abcd'));
expect(spliceChars(chalk.green('abcdef'), 4, 2)).toMatchAnsi(chalk.green('abcd'));
// across escape sequences
expect(spliceChars(chalk.green('abcd') + chalk.red('ef'), 4, 2)).toBe(chalk.green('abcd'));
expect(spliceChars(chalk.green('abcd') + chalk.red('ef'), 4, 2)).toMatchAnsi(chalk.green('abcd'));
});

test('the entire string', () => {
expect(spliceChars('abcdef', 0, 6)).toBe('');
expect(spliceChars(chalk.green('abcdef'), 0, 6)).toBe('');
expect(spliceChars(chalk.green('abcdef'), 0, 6)).toMatchAnsi('');
});

test('case where deleteCount exceeds the characters in the string', () => {
// the entire string
expect(spliceChars('abcdef', 0, 8)).toBe('');
// from the second character
expect(spliceChars(chalk.green('abcdef'), 2, 8)).toBe(chalk.green('ab'));
expect(spliceChars(chalk.green('abcdef'), 2, 8)).toMatchAnsi(chalk.green('ab'));
});
});

describe('replacing characters', () => {
test('at the beginning of a string', () => {
expect(spliceChars('abcdef', 0, 3, 'ABC')).toBe('ABCdef');
expect(spliceChars(chalk.bgRed(chalk.green('abc') + chalk.yellow('def')), 0, 3, 'ABC'))
.toBe(chalk.bgRed(chalk.green('ABC') + chalk.yellow('def')));
.toMatchAnsi(chalk.bgRed(chalk.green('ABC') + chalk.yellow('def')));
expect(spliceChars(chalk.bgRed(`${chalk.green('ab')}cd${chalk.yellow('ef')}`), 0, 4, 'ABCD'))
.toBe(chalk.bgRed(chalk.green('ABCD') + chalk.yellow('ef')));
.toMatchAnsi(chalk.bgRed(chalk.green('ABCD') + chalk.yellow('ef')));
});

test('in the middle of a string', () => {
expect(spliceChars('abcdef', 2, 2, 'CD')).toBe('abCDef');
expect(spliceChars(chalk.green('ab') + chalk.bgRed.yellow('cdef'), 2, 2, 'CD'))
.toBe(chalk.green('abCD') + chalk.bgRed.yellow('ef'));
.toMatchAnsi(chalk.green('abCD') + chalk.bgRed.yellow('ef'));
});

test('at the end of a string', () => {
expect(spliceChars('abcdef', 3, 3, 'DEF')).toBe('abcDEF');
expect(spliceChars(chalk.green('abc') + chalk.bgRed('def'), 3, 3, 'DEF'))
.toBe(chalk.green('abcDEF'));
.toMatchAnsi(chalk.green('abcDEF'));
});

test('the entire string', () => {
expect(spliceChars('abcdef', 0, 6, 'ABCDEF')).toBe('ABCDEF');
expect(spliceChars(chalk.bgRed(chalk.green('abc') + chalk.yellow('def')), 0, 6, 'ABCDEF'))
.toBe(chalk.bgRed.green('ABCDEF'));
.toMatchAnsi(chalk.bgRed.green('ABCDEF'));
});
});
});
Loading

0 comments on commit 5193538

Please sign in to comment.