Skip to content

Commit 549a8b6

Browse files
committed
util: expose diff function used by the assertion errors
fix: #51740
1 parent 5d9b63d commit 549a8b6

File tree

7 files changed

+216
-7
lines changed

7 files changed

+216
-7
lines changed

doc/api/util.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,56 @@ The `--throw-deprecation` command-line flag and `process.throwDeprecation`
325325
property take precedence over `--trace-deprecation` and
326326
`process.traceDeprecation`.
327327

328+
## `util.diff(actual, expected)`
329+
330+
<!-- YAML
331+
added: REPLACEME
332+
-->
333+
334+
* `actual` {Array|string} The first value to compare
335+
* `expected` {Array|string} The second value to compare
336+
* Returns: {Array} An array of difference objects. Each object has properties:
337+
* `type` {string} One of 'insert', 'delete', or 'nop' (no operation/unchanged)
338+
* `value` {any} The value that was inserted, deleted, or unchanged
339+
* Algorithm complexity: O(N\*D), where:
340+
* N is the total length of the two sequences combined (N = actual.length + expected.length)
341+
* D is the edit distance (the minimum number of operations required to transform one sequence into the other).
342+
343+
[`util.diff()`][] compares two values and returns an array of objects representing
344+
the differences between them. It uses the Myers diff algorithm to compute minimal
345+
differences, which is the same algorithm used internally by assertion error messages.
346+
347+
The array elements appear in reverse order compared to the differences in the original values.
348+
If the values are equal according to `isDeepStrictEqual()`, an empty array is returned.
349+
350+
```js
351+
const { diff } = require('node:util');
352+
353+
// Comparing arrays
354+
const result = diff([1, 2, 3], [1, 2, 4]);
355+
console.log(result);
356+
// [
357+
// { type: 'delete', value: 4 },
358+
// { type: 'insert', value: 3 },
359+
// { type: 'nop', value: 2 },
360+
// { type: 'nop', value: 1 }
361+
// ]
362+
363+
// Comparing strings
364+
const strDiff = diff('abc', 'abd');
365+
console.log(strDiff);
366+
// [
367+
// { type: 'delete', value: 'd' },
368+
// { type: 'insert', value: 'c' },
369+
// { type: 'nop', value: 'b' },
370+
// { type: 'nop', value: 'a' }
371+
// ]
372+
373+
// Equal values return empty array
374+
console.log(diff('same', 'same'));
375+
// []
376+
```
377+
328378
## `util.format(format[, ...args])`
329379

330380
<!-- YAML
@@ -3622,6 +3672,7 @@ util.isArray({});
36223672
[`napi_create_external()`]: n-api.md#napi_create_external
36233673
[`target` and `handler`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy#Terminology
36243674
[`tty.hasColors()`]: tty.md#writestreamhascolorscount-env
3675+
[`util.diff()`]: #utildiffactual-expected
36253676
[`util.format()`]: #utilformatformat-args
36263677
[`util.inspect()`]: #utilinspectobject-options
36273678
[`util.promisify()`]: #utilpromisifyoriginal

lib/internal/assert/assertion_error.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function getColoredMyersDiff(actual, expected) {
108108
const header = `${colors.green}actual${colors.white} ${colors.red}expected${colors.white}`;
109109
const skipped = false;
110110

111-
const diff = myersDiff(StringPrototypeSplit(actual, ''), StringPrototypeSplit(expected, ''));
111+
const diff = myersDiff(StringPrototypeSplit(actual, ''), StringPrototypeSplit(expected, ''), true);
112112
let message = printSimpleMyersDiff(diff);
113113

114114
if (skipped) {

lib/internal/assert/consts.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
'use strict';
2+
3+
const kReadableOperator = {
4+
deepStrictEqual: 'Expected values to be strictly deep-equal:',
5+
partialDeepStrictEqual: 'Expected values to be partially and strictly deep-equal:',
6+
strictEqual: 'Expected values to be strictly equal:',
7+
strictEqualObject: 'Expected "actual" to be reference-equal to "expected":',
8+
deepEqual: 'Expected values to be loosely deep-equal:',
9+
notDeepStrictEqual: 'Expected "actual" not to be strictly deep-equal to:',
10+
notStrictEqual: 'Expected "actual" to be strictly unequal to:',
11+
notStrictEqualObject:
12+
'Expected "actual" not to be reference-equal to "expected":',
13+
notDeepEqual: 'Expected "actual" not to be loosely deep-equal to:',
14+
notIdentical: 'Values have same structure but are not reference-equal:',
15+
notDeepEqualUnequal: 'Expected values not to be loosely deep-equal:',
16+
};
17+
18+
module.exports = {
19+
kReadableOperator,
20+
};

lib/internal/assert/myers_diff.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function areLinesEqual(actual, expected, checkCommaDisparity) {
2020
return false;
2121
}
2222

23-
function myersDiff(actual, expected, checkCommaDisparity = false) {
23+
function myersDiff(actual, expected, checkCommaDisparity = false, useNullPrototypes = true) {
2424
const actualLength = actual.length;
2525
const expectedLength = expected.length;
2626
const max = actualLength + expectedLength;
@@ -51,13 +51,13 @@ function myersDiff(actual, expected, checkCommaDisparity = false) {
5151
v[offset] = x;
5252

5353
if (x >= actualLength && y >= expectedLength) {
54-
return backtrack(trace, actual, expected, checkCommaDisparity);
54+
return backtrack(trace, actual, expected, checkCommaDisparity, useNullPrototypes);
5555
}
5656
}
5757
}
5858
}
5959

60-
function backtrack(trace, actual, expected, checkCommaDisparity) {
60+
function backtrack(trace, actual, expected, checkCommaDisparity, useNullPrototypes) {
6161
const actualLength = actual.length;
6262
const expectedLength = expected.length;
6363
const max = actualLength + expectedLength;
@@ -87,16 +87,22 @@ function backtrack(trace, actual, expected, checkCommaDisparity) {
8787
while (x > prevX && y > prevY) {
8888
const actualItem = actual[x - 1];
8989
const value = checkCommaDisparity && !StringPrototypeEndsWith(actualItem, ',') ? expected[y - 1] : actualItem;
90-
ArrayPrototypePush(result, { __proto__: null, type: 'nop', value });
90+
ArrayPrototypePush(result, useNullPrototypes ?
91+
{ __proto__: null, type: 'nop', value } :
92+
{ type: 'nop', value });
9193
x--;
9294
y--;
9395
}
9496

9597
if (diffLevel > 0) {
9698
if (x > prevX) {
97-
ArrayPrototypePush(result, { __proto__: null, type: 'insert', value: actual[--x] });
99+
ArrayPrototypePush(result, useNullPrototypes ?
100+
{ __proto__: null, type: 'insert', value: actual[--x] } :
101+
{ type: 'insert', value: actual[--x] });
98102
} else {
99-
ArrayPrototypePush(result, { __proto__: null, type: 'delete', value: expected[--y] });
103+
ArrayPrototypePush(result, useNullPrototypes ?
104+
{ __proto__: null, type: 'delete', value: expected[--y] } :
105+
{ type: 'delete', value: expected[--y] });
100106
}
101107
}
102108
}

lib/internal/util/diff.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use strict';
2+
3+
const { isDeepStrictEqual } = require('util');
4+
const { myersDiff } = require('internal/assert/myers_diff');
5+
6+
/**
7+
* Generate a difference report between two values
8+
* @param {Array | string} actual - The first value to compare
9+
* @param {Array | string} expected - The second value to compare
10+
* @returns {Array} - An array of differences between the two values.
11+
* The order of the elements in the array is reversed from the order in which
12+
* the differences occurred.
13+
* The array contains objects with the following properties:
14+
* - type: 'delete', 'insert', or 'nop'
15+
* - value: The value that was deleted, inserted, or unchanged
16+
*/
17+
function diff(actual, expected) {
18+
if (isDeepStrictEqual(actual, expected)) {
19+
return [];
20+
}
21+
22+
return myersDiff(actual, expected, false, false);
23+
}
24+
25+
module.exports = {
26+
diff,
27+
};

lib/util.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,9 @@ defineLazyProperties(
479479
'internal/mime',
480480
['MIMEType', 'MIMEParams'],
481481
);
482+
483+
defineLazyProperties(
484+
module.exports,
485+
'internal/util/diff',
486+
['diff'],
487+
);

test/parallel/test-diff.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use strict';
2+
require('../common');
3+
4+
const { describe, it } = require('node:test');
5+
const { deepStrictEqual } = require('node:assert');
6+
7+
const { diff } = require('util');
8+
9+
describe('diff', () => {
10+
it('returns an empty array because actual and expected are the same', () => {
11+
const actual = 'foo';
12+
const expected = 'foo';
13+
14+
const result = diff(actual, expected);
15+
deepStrictEqual(result, []);
16+
});
17+
18+
it('returns the diff for strings', () => {
19+
const actual = '12345678';
20+
const expected = '12!!5!7!';
21+
const result = diff(actual, expected);
22+
23+
deepStrictEqual(result, [
24+
{
25+
type: 'delete',
26+
value: '!'
27+
},
28+
{
29+
type: 'insert',
30+
value: '8'
31+
},
32+
{
33+
type: 'nop',
34+
value: '7'
35+
},
36+
{
37+
type: 'delete',
38+
value: '!'
39+
},
40+
{
41+
type: 'insert',
42+
value: '6'
43+
},
44+
{
45+
type: 'nop',
46+
value: '5'
47+
},
48+
{
49+
type: 'delete',
50+
value: '!'
51+
},
52+
{
53+
type: 'delete',
54+
value: '!'
55+
},
56+
{
57+
type: 'insert',
58+
value: '4'
59+
},
60+
{
61+
type: 'insert',
62+
value: '3'
63+
},
64+
{
65+
type: 'nop',
66+
value: '2'
67+
},
68+
{
69+
type: 'nop',
70+
value: '1'
71+
}]
72+
);
73+
});
74+
75+
it('returns the diff for arrays', () => {
76+
const actual = [1, 2, 3];
77+
const expected = [1, 3, 4];
78+
const result = diff(actual, expected);
79+
80+
deepStrictEqual(result, [
81+
{
82+
type: 'delete',
83+
value: 4
84+
},
85+
{
86+
type: 'nop',
87+
value: 3
88+
},
89+
{
90+
type: 'insert',
91+
value: 2
92+
},
93+
{
94+
type: 'nop',
95+
value: 1
96+
}]
97+
);
98+
});
99+
});

0 commit comments

Comments
 (0)