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
57 changes: 49 additions & 8 deletions lib/internal/assert/assertion_error.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ const {
ObjectPrototypeHasOwnProperty,
SafeSet,
String,
StringPrototypeLastIndexOf,
StringPrototypeRepeat,
StringPrototypeSlice,
StringPrototypeSplit,
} = primordials;

const { isError } = require('internal/util');
const { totalmem } = require('os');

const { inspect } = require('internal/util/inspect');
const colors = require('internal/util/colors');
Expand All @@ -37,11 +39,23 @@ const kReadableOperator = {
'Expected "actual" not to be reference-equal to "expected":',
notDeepEqual: 'Expected "actual" not to be loosely deep-equal to:',
notIdentical: 'Values have same structure but are not reference-equal:',
notIdenticalTruncated: 'Values are not equal, but their inspected outputs ' +
'were truncated before any difference was found:',
notDeepEqualUnequal: 'Expected values not to be loosely deep-equal:',
};

const kMaxShortStringLength = 12;
const kMaxLongStringLength = 512;
// Truncation limit for inspect output to prevent OOM during diff generation.
// Scaled to system memory: 512KB under 1GB, 1MB under 2GB, 2MB otherwise.
const kGB = 1024 ** 3;
const kMB = 1024 ** 2;
const totalMem = totalmem();
const kMaxInspectOutputLength =
totalMem < kGB ? kMB / 2 :
totalMem < 2 * kGB ? kMB :
2 * kMB;
const kTruncationMarker = '\n... [truncated]';

const kMethodsWithCustomMessageDiff = new SafeSet()
.add('deepStrictEqual')
Expand Down Expand Up @@ -72,7 +86,7 @@ function copyError(source) {
function inspectValue(val) {
// The util.inspect default values could be changed. This makes sure the
// error messages contain the necessary information nevertheless.
return inspect(val, {
const result = inspect(val, {
compact: false,
customInspect: false,
depth: 1000,
Expand All @@ -85,6 +99,22 @@ function inspectValue(val) {
// Inspect getters as we also check them when comparing entries.
getters: true,
});

// Truncate if the output is too large to prevent OOM during diff generation.
// Objects with deeply nested structures can produce exponentially large
// inspect output that causes memory exhaustion when passed to the diff
// algorithm.
if (result.length > kMaxInspectOutputLength) {
let sliced = StringPrototypeSlice(result, 0, kMaxInspectOutputLength);
// Cut at the last complete line so the diff never sees a partial line.
const lastNewline = StringPrototypeLastIndexOf(sliced, '\n');
if (lastNewline !== -1) {
sliced = StringPrototypeSlice(sliced, 0, lastNewline);
}
return { value: sliced + kTruncationMarker, truncated: true };
}

return { value: result, truncated: false };
}

function getErrorMessage(operator, message) {
Expand Down Expand Up @@ -187,8 +217,16 @@ function createErrDiff(actual, expected, operator, customMessage, diffType = 'si

let skipped = false;
let message = '';
const inspectedActual = inspectValue(actual);
const inspectedExpected = inspectValue(expected);
const { value: inspectedActual, truncated: actualTruncated } =
inspectValue(actual);
const { value: inspectedExpected, truncated: expectedTruncated } =
inspectValue(expected);
const truncated = actualTruncated || expectedTruncated;

// The diff is incomplete when the inspect output was truncated.
if (truncated) {
skipped = true;
}
const inspectedSplitActual = StringPrototypeSplit(inspectedActual, '\n');
const inspectedSplitExpected = StringPrototypeSplit(inspectedExpected, '\n');
const showSimpleDiff = isSimpleDiff(actual, inspectedSplitActual, expected, inspectedSplitExpected);
Expand All @@ -204,8 +242,11 @@ function createErrDiff(actual, expected, operator, customMessage, diffType = 'si
skipped = true;
}
} else if (inspectedActual === inspectedExpected) {
// Handles the case where the objects are structurally the same but different references
operator = 'notIdentical';
// Handles the case where the objects are structurally the same but
// different references. When both outputs were truncated to an identical
// prefix, the values differ beyond the truncation limit instead, so avoid
// claiming they have the same structure.
operator = truncated ? 'notIdenticalTruncated' : 'notIdentical';
if (inspectedSplitActual.length > 50 && diffType !== 'full') {
message = `${ArrayPrototypeJoin(ArrayPrototypeSlice(inspectedSplitActual, 0, 50), '\n')}\n...}`;
skipped = true;
Expand Down Expand Up @@ -294,7 +335,7 @@ class AssertionError extends Error {
// In case the objects are equal but the operator requires unequal, show
// the first object and say A equals B
let base = kReadableOperator[operator];
const res = StringPrototypeSplit(inspectValue(actual), '\n');
const res = StringPrototypeSplit(inspectValue(actual).value, '\n');

// In case "actual" is an object or a function, it should not be
// reference equal.
Expand All @@ -319,8 +360,8 @@ class AssertionError extends Error {
super(`${base}\n\n${ArrayPrototypeJoin(res, '\n')}\n`);
}
} else {
let res = inspectValue(actual);
let other = inspectValue(expected);
let res = inspectValue(actual).value;
let other = inspectValue(expected).value;
const knownOperator = kReadableOperator[operator];
if (operator === 'notDeepEqual' && res === other) {
res = `${knownOperator}\n\n${res}`;
Expand Down
194 changes: 185 additions & 9 deletions lib/internal/assert/myers_diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

const {
ArrayPrototypePush,
ArrayPrototypeSlice,
Int32Array,
MathMax,
MathMin,
MathRound,
RegExpPrototypeExec,
StringPrototypeEndsWith,
} = primordials;

Expand All @@ -14,7 +19,11 @@ const {

const colors = require('internal/util/colors');

const kChunkSize = 512;
const kNopLinesToCollapse = 5;
// Lines that are just structural characters make poor alignment anchors
// because they appear many times and don't uniquely identify a position.
const kTrivialLinePattern = /^\s*[{}[\],]+\s*$/;
const kOperations = {
DELETE: -1,
NOP: 0,
Expand All @@ -31,19 +40,11 @@ function areLinesEqual(actual, expected, checkCommaDisparity) {
return false;
}

function myersDiff(actual, expected, checkCommaDisparity = false) {
function myersDiffInternal(actual, expected, checkCommaDisparity) {
const actualLength = actual.length;
const expectedLength = expected.length;
const max = actualLength + expectedLength;

if (max > 2 ** 31 - 1) {
throw new ERR_OUT_OF_RANGE(
'myersDiff input size',
'< 2^31',
max,
);
}

const v = new Int32Array(2 * max + 1);
const trace = [];

Expand Down Expand Up @@ -124,6 +125,181 @@ function backtrack(trace, actual, expected, checkCommaDisparity) {
return result;
}

function myersDiff(actual, expected, checkCommaDisparity = false) {
const actualLength = actual.length;
const expectedLength = expected.length;
const max = actualLength + expectedLength;

if (max > 2 ** 31 - 1) {
throw new ERR_OUT_OF_RANGE(
'myersDiff input size',
'< 2^31',
max,
);
}

// For small inputs, run the algorithm directly
if (actualLength <= kChunkSize && expectedLength <= kChunkSize) {
return myersDiffInternal(actual, expected, checkCommaDisparity);
}

const boundaries = findAlignedBoundaries(
actual, expected, checkCommaDisparity,
);

// Process chunks and concatenate results (last chunk first for reversed order)
const result = [];
for (let i = boundaries.length - 2; i >= 0; i--) {
const actualStart = boundaries[i].actualIdx;
const actualEnd = boundaries[i + 1].actualIdx;
const expectedStart = boundaries[i].expectedIdx;
const expectedEnd = boundaries[i + 1].expectedIdx;

const actualChunk = ArrayPrototypeSlice(actual, actualStart, actualEnd);
const expectedChunk = ArrayPrototypeSlice(expected, expectedStart, expectedEnd);

if (actualChunk.length === 0 && expectedChunk.length === 0) continue;

if (actualChunk.length === 0) {
for (let j = expectedChunk.length - 1; j >= 0; j--) {
ArrayPrototypePush(result, [kOperations.DELETE, expectedChunk[j]]);
}
continue;
}

if (expectedChunk.length === 0) {
for (let j = actualChunk.length - 1; j >= 0; j--) {
ArrayPrototypePush(result, [kOperations.INSERT, actualChunk[j]]);
}
continue;
}

const chunkDiff = myersDiffInternal(actualChunk, expectedChunk, checkCommaDisparity);
for (let j = 0; j < chunkDiff.length; j++) {
ArrayPrototypePush(result, chunkDiff[j]);
}
}

return result;
}

function findAlignedBoundaries(actual, expected, checkCommaDisparity) {
const actualLen = actual.length;
const expectedLen = expected.length;
const boundaries = [{ actualIdx: 0, expectedIdx: 0 }];
const searchRadius = kChunkSize / 2;

// Add boundaries until the remainder of both sides fits in a single chunk.
// Stepping along the larger remaining side keeps every chunk bounded on
// both sides, which bounds the O((N + M) * D) memory of each
// myersDiffInternal() call.
let prev = boundaries[0];
while (actualLen - prev.actualIdx > kChunkSize ||
expectedLen - prev.expectedIdx > kChunkSize) {
const actualRemaining = actualLen - prev.actualIdx;
const expectedRemaining = expectedLen - prev.expectedIdx;

let targetActual;
let targetExpected;
if (actualRemaining >= expectedRemaining) {
targetActual = prev.actualIdx + kChunkSize;
targetExpected = prev.expectedIdx +
MathRound(kChunkSize * expectedRemaining / actualRemaining);
} else {
targetExpected = prev.expectedIdx + kChunkSize;
targetActual = prev.actualIdx +
MathRound(kChunkSize * actualRemaining / expectedRemaining);
}

const anchor = findAnchorNear(
actual, expected, targetActual, targetExpected,
prev, searchRadius, checkCommaDisparity,
);

let next;
if (anchor !== undefined) {
next = anchor;
} else {
// Fallback: use the proportional position. The chunk boundary may split
// a matching region, so the diff stays correct but may not be minimal.
next = { actualIdx: targetActual, expectedIdx: targetExpected };
}
ArrayPrototypePush(boundaries, next);
prev = next;
}

ArrayPrototypePush(boundaries, { actualIdx: actualLen, expectedIdx: expectedLen });
return boundaries;
}

// Search outward from targetActual and targetExpected for a non-trivial
// line that matches in both arrays, with adjacent context verification.
function findAnchorNear(actual, expected, targetActual, targetExpected,
prevBoundary, searchRadius, checkCommaDisparity) {
const actualLen = actual.length;

const searchStart = MathMax(prevBoundary.expectedIdx + 1, targetExpected - searchRadius);
const searchEnd = MathMin(expected.length - 1, targetExpected + searchRadius);
if (searchStart > searchEnd) {
return undefined;
}
const maxExpectedOffset = MathMax(
targetExpected - searchStart,
searchEnd - targetExpected,
);

for (let offset = 0; offset <= searchRadius; offset++) {
const candidates = offset === 0 ? [targetActual] : [targetActual + offset, targetActual - offset];

for (let i = 0; i < candidates.length; i++) {
const actualIdx = candidates[i];
if (actualIdx <= prevBoundary.actualIdx || actualIdx >= actualLen) {
continue;
}

const line = actual[actualIdx];
if (isTrivialLine(line)) {
continue;
}

for (let j = 0; j <= maxExpectedOffset; j++) {
const offsets = j === 0 ? [0] : [j, -j];
for (let k = 0; k < offsets.length; k++) {
const expectedIdx = targetExpected + offsets[k];
if (expectedIdx < searchStart || expectedIdx > searchEnd) {
continue;
}

if (
areLinesEqual(line, expected[expectedIdx], checkCommaDisparity) &&
hasAdjacentMatch(actual, expected, actualIdx, expectedIdx, checkCommaDisparity)
) {
return { actualIdx, expectedIdx };
}
}
}
}
}

return undefined;
}

function hasAdjacentMatch(actual, expected, actualIdx, expectedIdx, checkCommaDisparity) {
if (actualIdx > 0 && expectedIdx > 0 &&
areLinesEqual(actual[actualIdx - 1], expected[expectedIdx - 1], checkCommaDisparity)) {
return true;
}
if (actualIdx < actual.length - 1 && expectedIdx < expected.length - 1 &&
areLinesEqual(actual[actualIdx + 1], expected[expectedIdx + 1], checkCommaDisparity)) {
return true;
}
return false;
}

function isTrivialLine(line) {
return RegExpPrototypeExec(kTrivialLinePattern, line) !== null;
}

function printSimpleMyersDiff(diff) {
let message = '';

Expand Down
Loading