diff options
Diffstat (limited to 'ext/node/polyfills/assertion_error.ts')
-rw-r--r-- | ext/node/polyfills/assertion_error.ts | 579 |
1 files changed, 579 insertions, 0 deletions
diff --git a/ext/node/polyfills/assertion_error.ts b/ext/node/polyfills/assertion_error.ts new file mode 100644 index 000000000..f2e221f20 --- /dev/null +++ b/ext/node/polyfills/assertion_error.ts @@ -0,0 +1,579 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. + +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: + +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { inspect } from "internal:deno_node/polyfills/util.ts"; +import { stripColor as removeColors } from "internal:deno_node/polyfills/_util/std_fmt_colors.ts"; + +function getConsoleWidth(): number { + try { + return Deno.consoleSize().columns; + } catch { + return 80; + } +} + +// TODO(schwarzkopfb): we should implement Node's concept of "primordials" +// Ref: https://github.com/denoland/deno/issues/6040#issuecomment-637305828 +const MathMax = Math.max; +const { Error } = globalThis; +const { + create: ObjectCreate, + defineProperty: ObjectDefineProperty, + getPrototypeOf: ObjectGetPrototypeOf, + getOwnPropertyDescriptor: ObjectGetOwnPropertyDescriptor, + keys: ObjectKeys, +} = Object; + +import { ERR_INVALID_ARG_TYPE } from "internal:deno_node/polyfills/internal/errors.ts"; + +let blue = ""; +let green = ""; +let red = ""; +let defaultColor = ""; + +const kReadableOperator: { [key: string]: string } = { + deepStrictEqual: "Expected values to be strictly deep-equal:", + strictEqual: "Expected values to be strictly equal:", + strictEqualObject: 'Expected "actual" to be reference-equal to "expected":', + deepEqual: "Expected values to be loosely deep-equal:", + notDeepStrictEqual: 'Expected "actual" not to be strictly deep-equal to:', + notStrictEqual: 'Expected "actual" to be strictly unequal to:', + notStrictEqualObject: + '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:", + notDeepEqualUnequal: "Expected values not to be loosely deep-equal:", +}; + +// Comparing short primitives should just show === / !== instead of using the +// diff. +const kMaxShortLength = 12; + +export function copyError(source: Error): Error { + const keys = ObjectKeys(source); + const target = ObjectCreate(ObjectGetPrototypeOf(source)); + for (const key of keys) { + const desc = ObjectGetOwnPropertyDescriptor(source, key); + + if (desc !== undefined) { + ObjectDefineProperty(target, key, desc); + } + } + ObjectDefineProperty(target, "message", { value: source.message }); + return target; +} + +export function inspectValue(val: unknown): string { + // The util.inspect default values could be changed. This makes sure the + // error messages contain the necessary information nevertheless. + return inspect( + val, + { + compact: true, + customInspect: false, + depth: 1000, + maxArrayLength: Infinity, + // Assert compares only enumerable properties (with a few exceptions). + showHidden: false, + // Assert does not detect proxies currently. + showProxy: false, + sorted: true, + // Inspect getters as we also check them when comparing entries. + getters: true, + }, + ); +} + +export function createErrDiff( + actual: unknown, + expected: unknown, + operator: string, +): string { + let other = ""; + let res = ""; + let end = ""; + let skipped = false; + const actualInspected = inspectValue(actual); + const actualLines = actualInspected.split("\n"); + const expectedLines = inspectValue(expected).split("\n"); + + let i = 0; + let indicator = ""; + + // In case both values are objects or functions explicitly mark them as not + // reference equal for the `strictEqual` operator. + if ( + operator === "strictEqual" && + ((typeof actual === "object" && actual !== null && + typeof expected === "object" && expected !== null) || + (typeof actual === "function" && typeof expected === "function")) + ) { + operator = "strictEqualObject"; + } + + // If "actual" and "expected" fit on a single line and they are not strictly + // equal, check further special handling. + if ( + actualLines.length === 1 && expectedLines.length === 1 && + actualLines[0] !== expectedLines[0] + ) { + // Check for the visible length using the `removeColors()` function, if + // appropriate. + const c = inspect.defaultOptions.colors; + const actualRaw = c ? removeColors(actualLines[0]) : actualLines[0]; + const expectedRaw = c ? removeColors(expectedLines[0]) : expectedLines[0]; + const inputLength = actualRaw.length + expectedRaw.length; + // If the character length of "actual" and "expected" together is less than + // kMaxShortLength and if neither is an object and at least one of them is + // not `zero`, use the strict equal comparison to visualize the output. + if (inputLength <= kMaxShortLength) { + if ( + (typeof actual !== "object" || actual === null) && + (typeof expected !== "object" || expected === null) && + (actual !== 0 || expected !== 0) + ) { // -0 === +0 + return `${kReadableOperator[operator]}\n\n` + + `${actualLines[0]} !== ${expectedLines[0]}\n`; + } + } else if (operator !== "strictEqualObject") { + // If the stderr is a tty and the input length is lower than the current + // columns per line, add a mismatch indicator below the output. If it is + // not a tty, use a default value of 80 characters. + const maxLength = Deno.isatty(Deno.stderr.rid) ? getConsoleWidth() : 80; + if (inputLength < maxLength) { + while (actualRaw[i] === expectedRaw[i]) { + i++; + } + // Ignore the first characters. + if (i > 2) { + // Add position indicator for the first mismatch in case it is a + // single line and the input length is less than the column length. + indicator = `\n ${" ".repeat(i)}^`; + i = 0; + } + } + } + } + + // Remove all ending lines that match (this optimizes the output for + // readability by reducing the number of total changed lines). + let a = actualLines[actualLines.length - 1]; + let b = expectedLines[expectedLines.length - 1]; + while (a === b) { + if (i++ < 3) { + end = `\n ${a}${end}`; + } else { + other = a; + } + actualLines.pop(); + expectedLines.pop(); + if (actualLines.length === 0 || expectedLines.length === 0) { + break; + } + a = actualLines[actualLines.length - 1]; + b = expectedLines[expectedLines.length - 1]; + } + + const maxLines = MathMax(actualLines.length, expectedLines.length); + // Strict equal with identical objects that are not identical by reference. + // E.g., assert.deepStrictEqual({ a: Symbol() }, { a: Symbol() }) + if (maxLines === 0) { + // We have to get the result again. The lines were all removed before. + const actualLines = actualInspected.split("\n"); + + // Only remove lines in case it makes sense to collapse those. + if (actualLines.length > 50) { + actualLines[46] = `${blue}...${defaultColor}`; + while (actualLines.length > 47) { + actualLines.pop(); + } + } + + return `${kReadableOperator.notIdentical}\n\n${actualLines.join("\n")}\n`; + } + + // There were at least five identical lines at the end. Mark a couple of + // skipped. + if (i >= 5) { + end = `\n${blue}...${defaultColor}${end}`; + skipped = true; + } + if (other !== "") { + end = `\n ${other}${end}`; + other = ""; + } + + let printedLines = 0; + let identical = 0; + const msg = kReadableOperator[operator] + + `\n${green}+ actual${defaultColor} ${red}- expected${defaultColor}`; + const skippedMsg = ` ${blue}...${defaultColor} Lines skipped`; + + let lines = actualLines; + let plusMinus = `${green}+${defaultColor}`; + let maxLength = expectedLines.length; + if (actualLines.length < maxLines) { + lines = expectedLines; + plusMinus = `${red}-${defaultColor}`; + maxLength = actualLines.length; + } + + for (i = 0; i < maxLines; i++) { + if (maxLength < i + 1) { + // If more than two former lines are identical, print them. Collapse them + // in case more than five lines were identical. + if (identical > 2) { + if (identical > 3) { + if (identical > 4) { + if (identical === 5) { + res += `\n ${lines[i - 3]}`; + printedLines++; + } else { + res += `\n${blue}...${defaultColor}`; + skipped = true; + } + } + res += `\n ${lines[i - 2]}`; + printedLines++; + } + res += `\n ${lines[i - 1]}`; + printedLines++; + } + // No identical lines before. + identical = 0; + // Add the expected line to the cache. + if (lines === actualLines) { + res += `\n${plusMinus} ${lines[i]}`; + } else { + other += `\n${plusMinus} ${lines[i]}`; + } + printedLines++; + // Only extra actual lines exist + // Lines diverge + } else { + const expectedLine = expectedLines[i]; + let actualLine = actualLines[i]; + // If the lines diverge, specifically check for lines that only diverge by + // a trailing comma. In that case it is actually identical and we should + // mark it as such. + let divergingLines = actualLine !== expectedLine && + (!actualLine.endsWith(",") || + actualLine.slice(0, -1) !== expectedLine); + // If the expected line has a trailing comma but is otherwise identical, + // add a comma at the end of the actual line. Otherwise the output could + // look weird as in: + // + // [ + // 1 // No comma at the end! + // + 2 + // ] + // + if ( + divergingLines && + expectedLine.endsWith(",") && + expectedLine.slice(0, -1) === actualLine + ) { + divergingLines = false; + actualLine += ","; + } + if (divergingLines) { + // If more than two former lines are identical, print them. Collapse + // them in case more than five lines were identical. + if (identical > 2) { + if (identical > 3) { + if (identical > 4) { + if (identical === 5) { + res += `\n ${actualLines[i - 3]}`; + printedLines++; + } else { + res += `\n${blue}...${defaultColor}`; + skipped = true; + } + } + res += `\n ${actualLines[i - 2]}`; + printedLines++; + } + res += `\n ${actualLines[i - 1]}`; + printedLines++; + } + // No identical lines before. + identical = 0; + // Add the actual line to the result and cache the expected diverging + // line so consecutive diverging lines show up as +++--- and not +-+-+-. + res += `\n${green}+${defaultColor} ${actualLine}`; + other += `\n${red}-${defaultColor} ${expectedLine}`; + printedLines += 2; + // Lines are identical + } else { + // Add all cached information to the result before adding other things + // and reset the cache. + res += other; + other = ""; + identical++; + // The very first identical line since the last diverging line is be + // added to the result. + if (identical <= 2) { + res += `\n ${actualLine}`; + printedLines++; + } + } + } + // Inspected object to big (Show ~50 rows max) + if (printedLines > 50 && i < maxLines - 2) { + return `${msg}${skippedMsg}\n${res}\n${blue}...${defaultColor}${other}\n` + + `${blue}...${defaultColor}`; + } + } + + return `${msg}${skipped ? skippedMsg : ""}\n${res}${other}${end}${indicator}`; +} + +export interface AssertionErrorDetailsDescriptor { + message: string; + actual: unknown; + expected: unknown; + operator: string; + stack: Error; +} + +export interface AssertionErrorConstructorOptions { + message?: string; + actual?: unknown; + expected?: unknown; + operator?: string; + details?: AssertionErrorDetailsDescriptor[]; + // deno-lint-ignore ban-types + stackStartFn?: Function; + // Compatibility with older versions. + // deno-lint-ignore ban-types + stackStartFunction?: Function; +} + +interface ErrorWithStackTraceLimit extends ErrorConstructor { + stackTraceLimit: number; +} + +export class AssertionError extends Error { + [key: string]: unknown; + + // deno-lint-ignore constructor-super + constructor(options: AssertionErrorConstructorOptions) { + if (typeof options !== "object" || options === null) { + throw new ERR_INVALID_ARG_TYPE("options", "Object", options); + } + const { + message, + operator, + stackStartFn, + details, + // Compatibility with older versions. + stackStartFunction, + } = options; + let { + actual, + expected, + } = options; + + // TODO(schwarzkopfb): `stackTraceLimit` should be added to `ErrorConstructor` in + // cli/dts/lib.deno.shared_globals.d.ts + const limit = (Error as ErrorWithStackTraceLimit).stackTraceLimit; + (Error as ErrorWithStackTraceLimit).stackTraceLimit = 0; + + if (message != null) { + super(String(message)); + } else { + if (Deno.isatty(Deno.stderr.rid)) { + // Reset on each call to make sure we handle dynamically set environment + // variables correct. + if (Deno.noColor) { + blue = ""; + green = ""; + defaultColor = ""; + red = ""; + } else { + blue = "\u001b[34m"; + green = "\u001b[32m"; + defaultColor = "\u001b[39m"; + red = "\u001b[31m"; + } + } + // Prevent the error stack from being visible by duplicating the error + // in a very close way to the original in case both sides are actually + // instances of Error. + if ( + typeof actual === "object" && actual !== null && + typeof expected === "object" && expected !== null && + "stack" in actual && actual instanceof Error && + "stack" in expected && expected instanceof Error + ) { + actual = copyError(actual); + expected = copyError(expected); + } + + if (operator === "deepStrictEqual" || operator === "strictEqual") { + super(createErrDiff(actual, expected, operator)); + } else if ( + operator === "notDeepStrictEqual" || + operator === "notStrictEqual" + ) { + // 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 = inspectValue(actual).split("\n"); + + // In case "actual" is an object or a function, it should not be + // reference equal. + if ( + operator === "notStrictEqual" && + ((typeof actual === "object" && actual !== null) || + typeof actual === "function") + ) { + base = kReadableOperator.notStrictEqualObject; + } + + // Only remove lines in case it makes sense to collapse those. + if (res.length > 50) { + res[46] = `${blue}...${defaultColor}`; + while (res.length > 47) { + res.pop(); + } + } + + // Only print a single input. + if (res.length === 1) { + super(`${base}${res[0].length > 5 ? "\n\n" : " "}${res[0]}`); + } else { + super(`${base}\n\n${res.join("\n")}\n`); + } + } else { + let res = inspectValue(actual); + let other = inspectValue(expected); + const knownOperator = kReadableOperator[operator ?? ""]; + if (operator === "notDeepEqual" && res === other) { + res = `${knownOperator}\n\n${res}`; + if (res.length > 1024) { + res = `${res.slice(0, 1021)}...`; + } + super(res); + } else { + if (res.length > 512) { + res = `${res.slice(0, 509)}...`; + } + if (other.length > 512) { + other = `${other.slice(0, 509)}...`; + } + if (operator === "deepEqual") { + res = `${knownOperator}\n\n${res}\n\nshould loosely deep-equal\n\n`; + } else { + const newOp = kReadableOperator[`${operator}Unequal`]; + if (newOp) { + res = `${newOp}\n\n${res}\n\nshould not loosely deep-equal\n\n`; + } else { + other = ` ${operator} ${other}`; + } + } + super(`${res}${other}`); + } + } + } + + (Error as ErrorWithStackTraceLimit).stackTraceLimit = limit; + + this.generatedMessage = !message; + ObjectDefineProperty(this, "name", { + __proto__: null, + value: "AssertionError [ERR_ASSERTION]", + enumerable: false, + writable: true, + configurable: true, + // deno-lint-ignore no-explicit-any + } as any); + this.code = "ERR_ASSERTION"; + + if (details) { + this.actual = undefined; + this.expected = undefined; + this.operator = undefined; + + for (let i = 0; i < details.length; i++) { + this["message " + i] = details[i].message; + this["actual " + i] = details[i].actual; + this["expected " + i] = details[i].expected; + this["operator " + i] = details[i].operator; + this["stack trace " + i] = details[i].stack; + } + } else { + this.actual = actual; + this.expected = expected; + this.operator = operator; + } + + // @ts-ignore this function is not available in lib.dom.d.ts + Error.captureStackTrace(this, stackStartFn || stackStartFunction); + // Create error message including the error code in the name. + this.stack; + // Reset the name. + this.name = "AssertionError"; + } + + override toString() { + return `${this.name} [${this.code}]: ${this.message}`; + } + + [inspect.custom](_recurseTimes: number, ctx: Record<string, unknown>) { + // Long strings should not be fully inspected. + const tmpActual = this.actual; + const tmpExpected = this.expected; + + for (const name of ["actual", "expected"]) { + if (typeof this[name] === "string") { + const value = this[name] as string; + const lines = value.split("\n"); + if (lines.length > 10) { + lines.length = 10; + this[name] = `${lines.join("\n")}\n...`; + } else if (value.length > 512) { + this[name] = `${value.slice(512)}...`; + } + } + } + + // This limits the `actual` and `expected` property default inspection to + // the minimum depth. Otherwise those values would be too verbose compared + // to the actual error message which contains a combined view of these two + // input values. + const result = inspect(this, { + ...ctx, + customInspect: false, + depth: 0, + }); + + // Reset the properties after inspection. + this.actual = tmpActual; + this.expected = tmpExpected; + + return result; + } +} + +export default AssertionError; |