diff --git a/lib/internal/assert/assertion_error.js b/lib/internal/assert/assertion_error.js index 5dbf1e7a341380..2f1721ee1627d7 100644 --- a/lib/internal/assert/assertion_error.js +++ b/lib/internal/assert/assertion_error.js @@ -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'); @@ -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') @@ -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, @@ -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) { @@ -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); @@ -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; @@ -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. @@ -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}`; diff --git a/lib/internal/assert/myers_diff.js b/lib/internal/assert/myers_diff.js index ee6359042e31b8..5864757f38a841 100644 --- a/lib/internal/assert/myers_diff.js +++ b/lib/internal/assert/myers_diff.js @@ -2,7 +2,12 @@ const { ArrayPrototypePush, + ArrayPrototypeSlice, Int32Array, + MathMax, + MathMin, + MathRound, + RegExpPrototypeExec, StringPrototypeEndsWith, } = primordials; @@ -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, @@ -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 = []; @@ -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 = ''; diff --git a/test/parallel/test-assert-large-object-diff-oom.js b/test/parallel/test-assert-large-object-diff-oom.js new file mode 100644 index 00000000000000..ecd1a339c3c21c --- /dev/null +++ b/test/parallel/test-assert-large-object-diff-oom.js @@ -0,0 +1,142 @@ +// Flags: --max-old-space-size=512 +'use strict'; + +// Regression test: assert.strictEqual should not OOM when comparing objects +// with many converging paths to shared objects. Such objects cause exponential +// growth in util.inspect output, which previously led to OOM during error +// message generation. + +const common = require('../common'); +const os = require('os'); +const assert = require('assert'); + +// This test creates objects with exponential inspect output that requires +// significant memory. Skip on systems with less than 1GB total memory. +const totalMemMB = os.totalmem() / 1024 / 1024; +if (totalMemMB < 1024) { + common.skip(`insufficient system memory (${Math.round(totalMemMB)}MB, need 1024MB)`); +} + +// Test: should throw AssertionError, not OOM +{ + const { doc1, doc2 } = createTestObjects(); + + assert.throws( + () => assert.strictEqual(doc1, doc2), + (err) => { + assert.ok(err instanceof assert.AssertionError); + // Message should be bounded (fix truncates inspect output at 2MB) + assert.ok(err.message.length < 5 * 1024 * 1024); + return true; + } + ); +} + +// Test: asymmetric comparison (huge object vs tiny object) should not OOM. +// The truncated inspect output of the huge side is still ~100k lines, and +// diffing it against a few lines must not exhaust memory either. +{ + const { doc1 } = createTestObjects(); + + for (const args of [[doc1, { a: 1 }], [{ a: 1 }, doc1]]) { + assert.throws( + () => assert.deepStrictEqual(args[0], args[1]), + (err) => { + assert.ok(err instanceof assert.AssertionError); + assert.ok(err.message.length < 5 * 1024 * 1024); + return true; + } + ); + } +} + +// Test: when both inspect outputs are truncated to identical prefixes, the +// error must say the output was truncated instead of claiming the values +// have the same structure. +{ + const actual = {}; + const expected = {}; + for (let i = 0; i < 200000; i++) { + actual['prop_' + i] = i; + expected['prop_' + i] = i; + } + // Sorts last in the inspect output, beyond the truncation limit. + actual.zzzz = 'actual'; + expected.zzzz = 'expected'; + + assert.throws( + () => assert.deepStrictEqual(actual, expected), + (err) => { + assert.ok(err instanceof assert.AssertionError); + assert.match(err.message, /truncated/); + assert.doesNotMatch(err.message, /same structure/); + return true; + } + ); +} + +// Creates objects where many paths converge on shared objects, causing +// exponential growth in util.inspect output at high depths. +function createTestObjects() { + const base = createBase(); + + const s1 = createSchema(base, 's1'); + const s2 = createSchema(base, 's2'); + base.schemas.s1 = s1; + base.schemas.s2 = s2; + + const doc1 = createDoc(s1, base); + const doc2 = createDoc(s2, base); + + // Populated refs create additional converging paths + for (let i = 0; i < 2; i++) { + const ps = createSchema(base, 'p' + i); + base.schemas['p' + i] = ps; + doc1.$__.pop['r' + i] = { value: createDoc(ps, base), opts: { base, schema: ps } }; + } + + // Cross-link creates more converging paths + doc1.$__.pop.r0.value.$__parent = doc2; + + return { doc1, doc2 }; +} + +function createBase() { + const base = { types: {}, schemas: {} }; + for (let i = 0; i < 4; i++) { + base.types['t' + i] = { + base, + caster: { base }, + opts: { base, validators: [{ base }, { base }] } + }; + } + return base; +} + +function createSchema(base, name) { + const schema = { name, base, paths: {}, children: [] }; + for (let i = 0; i < 6; i++) { + schema.paths['f' + i] = { + schema, base, + type: base.types['t' + (i % 4)], + caster: base.types['t' + (i % 4)].caster, + opts: { schema, base, validators: [{ schema, base }] } + }; + } + for (let i = 0; i < 2; i++) { + const child = { name: name + '_c' + i, base, parent: schema, paths: {} }; + for (let j = 0; j < 3; j++) { + child.paths['cf' + j] = { schema: child, base, type: base.types['t' + (j % 4)] }; + } + schema.children.push(child); + } + return schema; +} + +function createDoc(schema, base) { + const doc = { schema, base, $__: { scopes: {}, pop: {} } }; + for (let i = 0; i < 6; i++) { + doc.$__.scopes['p' + i] = { schema, base, type: base.types['t' + (i % 4)] }; + } + return doc; +} diff --git a/test/parallel/test-assert-myers-diff.js b/test/parallel/test-assert-myers-diff.js index 31db3cd704ae06..bf63af21075f83 100644 --- a/test/parallel/test-assert-myers-diff.js +++ b/test/parallel/test-assert-myers-diff.js @@ -6,6 +6,7 @@ const assert = require('assert'); const { myersDiff } = require('internal/assert/myers_diff'); +// Test: myersDiff input size limit { const arr1 = { length: 2 ** 31 - 1 }; const arr2 = { length: 2 }; @@ -23,3 +24,205 @@ const { myersDiff } = require('internal/assert/myers_diff'); }) ); } + +// Test: small input correctness +{ + const actual = ['a', 'b', 'X', 'c', 'd']; + const expected = ['a', 'b', 'c', 'd']; + const ops = diffToForwardOps(myersDiff(actual, expected)); + + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + const nops = ops.filter((o) => o.op === 0); + + assert.strictEqual(inserts.length, 1); + assert.strictEqual(inserts[0].value, 'X'); + assert.strictEqual(deletes.length, 0); + assert.strictEqual(nops.length, 4); +} + +// Test: aligned boundary correctness - extra lines in the middle +// When expected has extra lines, aligned boundaries should produce +// only real INSERT/DELETE/NOP operations with no phantom diffs. +{ + const { actual, expected } = createAlignedTestArrays({ lineCount: 600, extraLineAt: 100 }); + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + const nops = ops.filter((o) => o.op === 0); + + assert.strictEqual(inserts.length, 1); + assert.strictEqual(inserts[0].value, 'EXTRA_100'); + assert.strictEqual(deletes.length, 0, new Error('should produce no phantom DELETEs')); + assert.strictEqual(nops.length, 600); +} + +// Test: multiple extra lines across chunk boundaries +{ + const expected = []; + for (let i = 0; i < 1200; i++) expected.push('line_' + i); + + const actual = []; + for (let i = 0; i < 1200; i++) { + if (i === 100) { + actual.push('EXTRA_A'); + actual.push('EXTRA_B'); + } + actual.push('line_' + i); + } + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + + assert.strictEqual(inserts.length, 2); + assert.strictEqual(inserts[0].value, 'EXTRA_A'); + assert.strictEqual(inserts[1].value, 'EXTRA_B'); + assert.strictEqual(deletes.length, 0, new Error('should produce no phantom DELETEs')); +} + +// Test: large identical inputs produce all NOPs +{ + const lines = []; + for (let i = 0; i < 600; i++) lines.push('line_' + i); + + const result = myersDiff(lines, lines); + const ops = diffToForwardOps(result); + + assert.strictEqual(ops.length, 600); + assert.ok(ops.every((o) => o.op === 0)); +} + +// Test: one side much longer than the other +// Diff should be correct (rebuild both sides from ops matches originals) +{ + const actual = []; + for (let i = 0; i < 700; i++) actual.push('line_' + i); + + const expected = []; + for (let i = 0; i < 200; i++) expected.push('line_' + i); + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + // Verify correctness: rebuild both sides from diff ops + const { rebuiltActual, rebuiltExpected } = rebuildFromOps(ops); + assert.deepStrictEqual(rebuiltActual, actual); + assert.deepStrictEqual(rebuiltExpected, expected); + + // Chunked diff with fallback boundaries may not find all 200 shared lines, + // but the diff must still be correct (rebuilds match originals). + const nops = ops.filter((o) => o.op === 0); + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + assert.ok(nops.length >= 146, `expected at least 146 NOPs, got ${nops.length}`); + assert.strictEqual(nops.length + inserts.length, actual.length); + assert.strictEqual(nops.length + deletes.length, expected.length); +} + +// Test: expected side longer (deletions) +{ + const actual = []; + for (let i = 0; i < 200; i++) actual.push('line_' + i); + + const expected = []; + for (let i = 0; i < 700; i++) expected.push('line_' + i); + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + const { rebuiltActual, rebuiltExpected } = rebuildFromOps(ops); + assert.deepStrictEqual(rebuiltActual, actual); + assert.deepStrictEqual(rebuiltExpected, expected); + + const nops = ops.filter((o) => o.op === 0); + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + assert.ok(nops.length >= 146, `expected at least 146 NOPs, got ${nops.length}`); + assert.strictEqual(nops.length + inserts.length, actual.length); + assert.strictEqual(nops.length + deletes.length, expected.length); +} + +// Test: asymmetric input sizes must keep every chunk bounded on both sides. +// Boundaries derived from the actual side only used to leave the entire +// oversized side in a single chunk, and Myers memory is O((N + M) * D), +// which caused OOM when one side was large and mostly dissimilar. +{ + const small = ['alpha', 'beta', 'gamma']; + const huge = []; + for (let i = 0; i < 500000; i++) huge.push('unique_line_' + i); + + for (const { actual, expected } of [ + { actual: small, expected: huge }, + { actual: huge, expected: small }, + ]) { + const ops = diffToForwardOps(myersDiff(actual, expected)); + + const { rebuiltActual, rebuiltExpected } = rebuildFromOps(ops); + assert.deepStrictEqual(rebuiltActual, actual); + assert.deepStrictEqual(rebuiltExpected, expected); + } +} + +// Test: no matching anchor available (all unique lines) +// Should gracefully fall back and still produce a valid diff +{ + const actual = []; + const expected = []; + for (let i = 0; i < 600; i++) { + actual.push('actual_unique_' + i); + expected.push('expected_unique_' + i); + } + + const result = myersDiff(actual, expected); + const ops = diffToForwardOps(result); + + const inserts = ops.filter((o) => o.op === 1); + const deletes = ops.filter((o) => o.op === -1); + + // All lines are different, so we should get 600 inserts and 600 deletes + assert.strictEqual(inserts.length, 600); + assert.strictEqual(deletes.length, 600); +} + +function diffToForwardOps(diff) { + const ops = []; + for (let i = diff.length - 1; i >= 0; i--) { + ops.push({ op: diff[i][0], value: diff[i][1] }); + } + return ops; +} + +function rebuildFromOps(ops) { + const rebuiltActual = []; + const rebuiltExpected = []; + for (let i = 0; i < ops.length; i++) { + if (ops[i].op === 0) { + rebuiltActual.push(ops[i].value); + rebuiltExpected.push(ops[i].value); + } else if (ops[i].op === 1) { + rebuiltActual.push(ops[i].value); + } else { + rebuiltExpected.push(ops[i].value); + } + } + return { rebuiltActual, rebuiltExpected }; +} + +function createAlignedTestArrays({ lineCount, extraLineAt } = {}) { + const expected = []; + for (let i = 0; i < lineCount; i++) expected.push('line_' + i); + + const actual = []; + for (let i = 0; i < lineCount; i++) { + if (i === extraLineAt) actual.push('EXTRA_' + extraLineAt); + actual.push('line_' + i); + } + + return { actual, expected }; +}