diff --git a/assert/equals.ts b/assert/equals.ts index b8370584425b..711e04b2e6e1 100644 --- a/assert/equals.ts +++ b/assert/equals.ts @@ -8,6 +8,41 @@ import { format } from "@std/internal/format"; import { AssertionError } from "./assertion_error.ts"; +// Walks `value` (avoiding cycles) and returns true if any own property, +// array element, Map value, or Set element is a function. Used to surface +// a hint when assertEquals throws on inputs whose only difference is a +// function property, which prints as an identical-looking `[Function: …]` +// in the diff but compares by reference. +function containsFunction(value: unknown, seen: WeakSet): boolean { + if (typeof value === "function") return true; + if (value === null || typeof value !== "object") return false; + if (seen.has(value as object)) return false; + seen.add(value as object); + if (value instanceof Map) { + for (const v of value.values()) { + if (containsFunction(v, seen)) return true; + } + return false; + } + if (value instanceof Set) { + for (const v of value.values()) { + if (containsFunction(v, seen)) return true; + } + return false; + } + for (const k of Reflect.ownKeys(value as object)) { + if ( + containsFunction( + (value as Record)[k], + seen, + ) + ) { + return true; + } + } + return false; +} + /** * Make an assertion that `actual` and `expected` are equal, deeply. If not * deeply equal, then throw. @@ -54,6 +89,14 @@ export function assertEquals( const msgSuffix = msg ? `: ${msg}` : "."; let message = `Values are not equal${msgSuffix}`; + if ( + containsFunction(actual, new WeakSet()) || + containsFunction(expected, new WeakSet()) + ) { + message += + "\n Note: function properties are compared by reference, so two distinct functions print the same as `[Function: name]` but are not equal."; + } + const actualString = format(actual); const expectedString = format(expected); const stringDiff = (typeof actual === "string") && diff --git a/assert/equals_test.ts b/assert/equals_test.ts index 8f23661d6b2f..cec97e5e855c 100644 --- a/assert/equals_test.ts +++ b/assert/equals_test.ts @@ -207,6 +207,57 @@ Deno.test({ }, }); +Deno.test({ + name: + "assertEquals() adds a function-by-reference hint when functions are present", + fn() { + // https://github.com/denoland/std/issues/6878 + // Identical-looking function properties print as `[Function: y]` on + // both sides, which makes the failure confusing. The hint clarifies + // that functions compare by reference. + const error = assertThrows( + () => + assertEquals( + { x: 1, y: () => 2 }, + { x: 1, y: () => 2 }, + ), + AssertionError, + ); + const message = stripAnsiCode((error as AssertionError).message); + if (!message.includes("function properties are compared by reference")) { + throw new Error( + `expected message to include the function-reference hint, got:\n${message}`, + ); + } + + // Also fires for function values nested in arrays and Maps. + const arrayError = assertThrows( + () => assertEquals([() => 1], [() => 1]), + AssertionError, + ); + if ( + !stripAnsiCode((arrayError as AssertionError).message).includes( + "function properties are compared by reference", + ) + ) { + throw new Error("expected hint for array of functions"); + } + + // Does NOT fire when neither side has any function values. + const plainError = assertThrows( + () => assertEquals({ x: 1 }, { x: 2 }), + AssertionError, + ); + if ( + stripAnsiCode((plainError as AssertionError).message).includes( + "function properties are compared by reference", + ) + ) { + throw new Error("hint must not appear when there are no functions"); + } + }, +}); + Deno.test({ name: "assertEquals() matches same Set with object keys", fn() {