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
43 changes: 43 additions & 0 deletions assert/equals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>): 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<string | symbol, unknown>)[k],
seen,
)
) {
return true;
}
Comment on lines +33 to +41

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflect.ownKeys + value[k] invokes getters, including throwing ones. format() already inspects values so this is mostly pre-existing, but it means getters get invoked an extra time on the failure path, and a throwing getter would surface as a confusing error inside the assertion failure rather than the original AssertionError. Worth a try/catch around the property access. (The actualString === expectedString gate suggested above also shrinks this exposure to the rare identical-string case.)

}
return false;
}

/**
* Make an assertion that `actual` and `expected` are equal, deeply. If not
* deeply equal, then throw.
Expand Down Expand Up @@ -54,6 +89,14 @@ export function assertEquals<T>(
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.";
}
Comment on lines +92 to +98

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main concern: this heuristic is too broad and produces noisy false positives.

The note fires whenever a function exists anywhere in either operand, even when the function is identical on both sides and unrelated to the inequality. Common case:

const handler = () => {};
assertEquals({ x: 1, onClick: handler }, { x: 2, onClick: handler });

Here onClick is the same reference on both sides — the real diff is x — yet the user gets a note about functions comparing by reference, which is irrelevant and misleading. std's own tests frequently compare objects holding identical method/handler references while other fields differ, so this would attach a spurious note to many existing failures.

Suggest gating on the genuinely-confusing condition: the formatted strings are identical but the values are unequal. That's exactly the issue's scenario, never fires when the diff is already meaningful, and skips the tree walk in the common case:

const actualString = format(actual);
const expectedString = format(expected);
if (
  actualString === expectedString &&
  (containsFunction(actual, new WeakSet()) ||
   containsFunction(expected, new WeakSet()))
) {
  message += "\n  Note: ...";
}

This requires moving the check below the format() calls on lines 100-101.


const actualString = format(actual);
const expectedString = format(expected);
const stringDiff = (typeof actual === "string") &&
Expand Down
51 changes: 51 additions & 0 deletions assert/equals_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
Comment on lines +226 to +230

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor style: this file already imports from @std/assertassertStringIncludes(message, "function properties are compared by reference") is more idiomatic than the hand-rolled if (!message.includes(...)) throw new Error(...) and gives better failure output. Same applies to the array case (~line 243) and the negative case (use assertNotMatch/a plain assertEquals(message.includes(...), false) or assert(!...) at ~line 256).

}

// 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() {
Expand Down
Loading