Skip to content

Commit 2c2586d

Browse files
feat: Show non-printable control characters to diff output (#15696)
1 parent 73dbef5 commit 2c2586d

File tree

5 files changed

+154
-3
lines changed

5 files changed

+154
-3
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 30.0.2
44

5+
### Features
6+
7+
- `[jest-diff]` Show non-printable control characters to diffs ([#15696](https://github.com/facebook/jest/pull/15696))
8+
59
### Fixes
610

711
- `[jest-matcher-utils]` Make 'deepCyclicCopyObject' safer by setting descriptors to a null-prototype object ([#15689](https://github.com/jestjs/jest/pull/15689))
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {escapeControlCharacters} from '../escapeControlCharacters';
9+
10+
describe('escapeControlCharacters', () => {
11+
test('preserves regular printable characters', () => {
12+
const input = 'Jest 123!@#$%^&*()';
13+
expect(escapeControlCharacters(input)).toBe(input);
14+
});
15+
16+
test('preserves whitespace characters that are meaningful for formatting', () => {
17+
const input = 'line1\nline2\tindented\rcarriage';
18+
expect(escapeControlCharacters(input)).toBe(input);
19+
});
20+
21+
test('escapes NULL character', () => {
22+
const input = 'before\u0000after';
23+
expect(escapeControlCharacters(input)).toBe('before\\x00after');
24+
});
25+
26+
test('escapes SOH (Start of Heading) character', () => {
27+
const input = 'before\u0001after';
28+
expect(escapeControlCharacters(input)).toBe('before\\x01after');
29+
});
30+
31+
test('escapes backspace character to \\b', () => {
32+
const input = 'before\u0008after';
33+
expect(escapeControlCharacters(input)).toBe('before\\bafter');
34+
});
35+
36+
test('escapes vertical tab character to \\v', () => {
37+
const input = 'before\u000Bafter';
38+
expect(escapeControlCharacters(input)).toBe('before\\vafter');
39+
});
40+
41+
test('escapes form feed character to \\f', () => {
42+
const input = 'before\u000Cafter';
43+
expect(escapeControlCharacters(input)).toBe('before\\fafter');
44+
});
45+
46+
test('escapes ESC (Escape) character', () => {
47+
const input = 'before\u001Bafter';
48+
expect(escapeControlCharacters(input)).toBe('before\\x1bafter');
49+
});
50+
51+
test('escapes DEL character', () => {
52+
const input = 'before\u007Fafter';
53+
expect(escapeControlCharacters(input)).toBe('before\\x7fafter');
54+
});
55+
56+
test('escapes C1 control characters', () => {
57+
const input = 'before\u0080\u0081\u009Fafter';
58+
expect(escapeControlCharacters(input)).toBe('before\\x80\\x81\\x9fafter');
59+
});
60+
61+
test('handles mixed control characters and regular text', () => {
62+
const input = 'FIX\u00014.4\u00019=68\u00135=A\u0001MSG_TYPE=D';
63+
expect(escapeControlCharacters(input)).toBe(
64+
'FIX\\x014.4\\x019=68\\x135=A\\x01MSG_TYPE=D',
65+
);
66+
});
67+
68+
test('handles financial message protocol string with control characters', () => {
69+
const input = '8=FIXT.1.1\u00019=68\u00135=A\u00134=1\u00149=ISLD';
70+
expect(escapeControlCharacters(input)).toBe(
71+
'8=FIXT.1.1\\x019=68\\x135=A\\x134=1\\x149=ISLD',
72+
);
73+
});
74+
75+
test('preserves empty string', () => {
76+
expect(escapeControlCharacters('')).toBe('');
77+
});
78+
79+
test('handles string with only control characters', () => {
80+
const input = '\u0000\u0001\u0002\u0003';
81+
expect(escapeControlCharacters(input)).toBe('\\x00\\x01\\x02\\x03');
82+
});
83+
84+
test('preserves Unicode characters that are not control characters', () => {
85+
const input = 'café 中文 🚀 αβγ';
86+
expect(escapeControlCharacters(input)).toBe(input);
87+
});
88+
89+
test('handles BEL (Bell) character', () => {
90+
const input = 'alert\u0007sound';
91+
expect(escapeControlCharacters(input)).toBe('alert\\x07sound');
92+
});
93+
94+
test('preserves newlines in multiline strings', () => {
95+
const input = 'line 1\nline 2\nline 3';
96+
expect(escapeControlCharacters(input)).toBe(input);
97+
});
98+
99+
test('preserves tabs for code formatting', () => {
100+
const input = 'function() {\n\treturn true;\n}';
101+
expect(escapeControlCharacters(input)).toBe(input);
102+
});
103+
104+
test('escapes multiple consecutive control characters', () => {
105+
const input = 'data\u0001\u0002\u0003separator';
106+
expect(escapeControlCharacters(input)).toBe('data\\x01\\x02\\x03separator');
107+
});
108+
109+
test('handles control characters at string boundaries', () => {
110+
const startControl = '\u0001start';
111+
const endControl = 'end\u0001';
112+
expect(escapeControlCharacters(startControl)).toBe('\\x01start');
113+
expect(escapeControlCharacters(endControl)).toBe('end\\x01');
114+
});
115+
});

packages/jest-diff/src/diffLines.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import diff from '@jest/diff-sequences';
99
import {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff} from './cleanupSemantic';
10+
import {escapeControlCharacters} from './escapeControlCharacters';
1011
import {
1112
joinAlignedDiffsExpand,
1213
joinAlignedDiffsNoExpand,
@@ -101,8 +102,8 @@ export const diffLinesUnified = (
101102
): string =>
102103
printDiffLines(
103104
diffLinesRaw(
104-
isEmptyString(aLines) ? [] : aLines,
105-
isEmptyString(bLines) ? [] : bLines,
105+
isEmptyString(aLines) ? [] : aLines.map(escapeControlCharacters),
106+
isEmptyString(bLines) ? [] : bLines.map(escapeControlCharacters),
106107
),
107108
normalizeDiffOptions(options),
108109
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
// Escape control characters to make them visible in diffs
9+
export const escapeControlCharacters = (str: string): string =>
10+
str.replaceAll(
11+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
12+
(match: string) => {
13+
switch (match) {
14+
case '\b':
15+
return '\\b';
16+
case '\f':
17+
return '\\f';
18+
case '\v':
19+
return '\\v';
20+
default: {
21+
const code = match.codePointAt(0);
22+
return `\\x${code!.toString(16).padStart(2, '0')}`;
23+
}
24+
}
25+
},
26+
);

packages/jest-diff/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import {DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, Diff} from './cleanupSemantic';
1616
import {NO_DIFF_MESSAGE, SIMILAR_MESSAGE} from './constants';
1717
import {diffLinesRaw, diffLinesUnified, diffLinesUnified2} from './diffLines';
18+
import {escapeControlCharacters} from './escapeControlCharacters';
1819
import {normalizeDiffOptions} from './normalizeDiffOptions';
1920
import {diffStringsRaw, diffStringsUnified} from './printDiffs';
2021
import type {DiffOptions} from './types';
@@ -96,7 +97,11 @@ export function diff(a: any, b: any, options?: DiffOptions): string | null {
9697

9798
switch (aType) {
9899
case 'string':
99-
return diffLinesUnified(a.split('\n'), b.split('\n'), options);
100+
return diffLinesUnified(
101+
escapeControlCharacters(a).split('\n'),
102+
escapeControlCharacters(b).split('\n'),
103+
options,
104+
);
100105
case 'boolean':
101106
case 'number':
102107
return comparePrimitive(a, b, options);

0 commit comments

Comments
 (0)