From 5cff014b02406587602421a5362f8b55e08719e9 Mon Sep 17 00:00:00 2001
From: David Luis <95457148+babiabeo@users.noreply.github.com>
Date: Tue, 23 Jul 2024 09:16:10 +0700
Subject: [PATCH] fix(assert): fix `assertObjectMatch()` prints arrays as
 objects (#5503)

fix(assert): fix ssertObjectMatch prints arrays as objects
---
 assert/object_match.ts      | 66 +++++++++++++++++++++++++++++++++++--
 assert/object_match_test.ts | 51 ++++++++++++++++++++++++++++
 2 files changed, 115 insertions(+), 2 deletions(-)

diff --git a/assert/object_match.ts b/assert/object_match.ts
index c7a38378e..67990dfa2 100644
--- a/assert/object_match.ts
+++ b/assert/object_match.ts
@@ -49,7 +49,7 @@ function isObject(val: unknown): boolean {
   return typeof val === "object" && val !== null;
 }
 
-function filter(a: loose, b: loose) {
+function filter(a: loose, b: loose): loose {
   const seen = new WeakMap();
   return filterObject(a, b);
 
@@ -97,7 +97,7 @@ function filter(a: loose, b: loose) {
 
       // On array references, build a filtered array and filter nested objects inside
       if (Array.isArray(value) && Array.isArray(subset)) {
-        filtered[key] = filterObject({ ...value }, { ...subset });
+        filtered[key] = filterArray(value, subset);
         continue;
       }
 
@@ -135,4 +135,66 @@ function filter(a: loose, b: loose) {
 
     return filtered;
   }
+
+  function filterArray(a: unknown[], b: unknown[]): unknown[] {
+    // Prevent infinite loop with circular references with same filter
+    if (seen.has(a) && (seen.get(a) === b)) {
+      return a;
+    }
+
+    seen.set(a, b);
+
+    const filtered: unknown[] = [];
+    const count = Math.min(a.length, b.length);
+
+    for (let i = 0; i < count; ++i) {
+      const value = a[i];
+      const subset = b[i];
+
+      // On regexp references, keep value as it to avoid loosing pattern and flags
+      if (value instanceof RegExp) {
+        filtered.push(value);
+        continue;
+      }
+
+      // On array references, build a filtered array and filter nested objects inside
+      if (Array.isArray(value) && Array.isArray(subset)) {
+        filtered.push(filterArray(value, subset));
+        continue;
+      }
+
+      // On nested objects references, build a filtered object recursively
+      if (isObject(value) && isObject(subset)) {
+        // When both operands are maps, build a filtered map with common keys and filter nested objects inside
+        if ((value instanceof Map) && (subset instanceof Map)) {
+          const map = new Map(
+            [...value].filter(([k]) => subset.has(k))
+              .map(([k, v]) => {
+                const v2 = subset.get(k);
+                if (isObject(v) && isObject(v2)) {
+                  return [k, filterObject(v as loose, v2 as loose)];
+                }
+
+                return [k, v];
+              }),
+          );
+          filtered.push(map);
+          continue;
+        }
+
+        // When both operands are set, build a filtered set with common values
+        if ((value instanceof Set) && (subset instanceof Set)) {
+          filtered.push(value.intersection(subset));
+          continue;
+        }
+
+        filtered.push(filterObject(value as loose, subset as loose));
+        continue;
+      }
+
+      filtered.push(value);
+    }
+
+    return filtered;
+  }
 }
diff --git a/assert/object_match_test.ts b/assert/object_match_test.ts
index de2d8d4db..98a311a68 100644
--- a/assert/object_match_test.ts
+++ b/assert/object_match_test.ts
@@ -31,6 +31,11 @@ const n = {
     ["b", b],
   ]),
 };
+const o = { foo: [new Map([["bar", n.bar], ["baz", null]])] };
+const p = { bar: [new Set([1, 2, 3])] };
+const q = { foo: [1, 2] as unknown[] };
+q.foo[2] = q.foo;
+const r = { bar: [[1, [2, [q]]]] };
 
 Deno.test("assertObjectMatch() matches simple subset", () => {
   assertObjectMatch(a, {
@@ -79,6 +84,7 @@ Deno.test("assertObjectMatch() matches subset with circular reference", () => {
       },
     },
   });
+  assertObjectMatch(q, { foo: [1, 2, [1, 2, [1, 2, [1, 2]]]] });
 });
 
 Deno.test("assertObjectMatch() matches subset with interface", () => {
@@ -105,6 +111,7 @@ Deno.test("assertObjectMatch() matches subset with nested array inside", () => {
   assertObjectMatch(j, { foo: [[1, 2, 3]] });
   assertObjectMatch(k, { foo: [[1, [2, [3]]]] });
   assertObjectMatch(l, { foo: [[1, [2, [a, e, j, k]]]] });
+  assertObjectMatch(r, { bar: [[1, [2, [q]]]] });
 });
 
 Deno.test("assertObjectMatch() matches subset with regexp", () => {
@@ -117,6 +124,9 @@ Deno.test("assertObjectMatch() matches subset with built-in data structures", ()
   assertObjectMatch(n, { bar: new Map([["bar", 2]]) });
   assertObjectMatch(n, { baz: new Map([["b", b]]) });
   assertObjectMatch(n, { baz: new Map([["b", { foo: true }]]) });
+  assertObjectMatch(o, { foo: [new Map([["baz", null]])] });
+  assertObjectMatch(o, { foo: [new Map([["bar", n.bar]])] });
+  assertObjectMatch(p, { bar: [new Set([2, 3])] });
 });
 
 Deno.test("assertObjectMatch() throws when a key is missing from subset", () => {
@@ -341,6 +351,10 @@ Deno.test("assertObjectMatch() prints inputs correctly", () => {
         description: "foo",
       },
       name: "somegroup",
+      nodes: [
+        "somenode",
+        "someothernode",
+      ],
     },
   };
 
@@ -356,9 +370,46 @@ Deno.test("assertObjectMatch() prints inputs correctly", () => {
 +         description: "foo",
 +       },
 +       name: "somegroup",
++       nodes: [
++         "somenode",
++         "someothernode",
++       ],
 -       message: "NodeNotFound",
       },
       protocol: "graph",
     }`,
   );
+
+  assertThrows(
+    () => assertObjectMatch({ foo: [] }, { foo: ["bar"] }),
+    AssertionError,
+    `    {
++     foo: [
++       "bar",
++     ],
+-     foo: [],
+    }`,
+  );
+
+  const a = {};
+  const b = {};
+
+  Object.defineProperty(a, "hello", {
+    value: "world",
+    enumerable: false,
+  });
+
+  Object.defineProperty(b, "foo", {
+    value: "bar",
+    enumerable: false,
+  });
+
+  assertThrows(
+    () => assertObjectMatch(a, b),
+    AssertionError,
+    `    {
+-     hello: "world",
++     foo: "bar",
+    }`,
+  );
 });
