Repository: denoland/deno
Pull request: fix(ext/node): fix inspect of CustomEvent of Node.js

This PR fixes the custom inspect of CustomEvent for Node.js. This enables `parallel/test-events-customevent.js`.

towards #29595 

Changed files:
filename: ext/node/polyfills/internal/event_target.mjs
patch: @@ -4,6 +4,13 @@
 // TODO(petamoriken): enable prefer-primordials for node polyfills
 // deno-lint-ignore-file prefer-primordials
 
+import { primordials } from "ext:core/mod.js";
+const {
+  NumberIsInteger,
+  ObjectAssign,
+  SymbolFor,
+  SymbolToStringTag,
+} = primordials;
 import {
   ERR_EVENT_RECURSION,
   ERR_INVALID_ARG_TYPE,
@@ -105,7 +112,7 @@ class Event extends WebEvent {
       return name;
     }
 
-    const opts = Object.assign({}, options, {
+    const opts = ObjectAssign({}, options, {
       depth: NumberIsInteger(options.depth) ? options.depth - 1 : options.depth,
     });
 
@@ -316,6 +323,15 @@ Object.defineProperties(
     eventPhase: kEnumerableProperty,
     cancelBubble: kEnumerableProperty,
     stopPropagation: kEnumerableProperty,
+    // The parent class `WebEvent` has this field defined, which has greater
+    // precedence than Symbol.for('nodejs.util.inspect.custom') and shadow it.
+    // Overwriting it to `undefined` cancels the shadowing.
+    [SymbolFor("Deno.privateCustomInspect")]: {
+      value: undefined,
+      writable: true,
+      enumerable: false,
+      configurable: true,
+    },
   },
 );
 
@@ -340,6 +356,7 @@ class CustomEvent extends Event {
     }
     super(type, options);
     this[kDetail] = options?.detail ?? null;
+    this[SymbolToStringTag] = "CustomEvent";
   }
 
   /**
@@ -353,17 +370,6 @@ class CustomEvent extends Event {
   }
 }
 
-Object.defineProperties(CustomEvent.prototype, {
-  [Symbol.toStringTag]: {
-    __proto__: null,
-    writable: false,
-    enumerable: false,
-    configurable: true,
-    value: "CustomEvent",
-  },
-  detail: kEnumerableProperty,
-});
-
 class NodeCustomEvent extends Event {
   constructor(type, options) {
     super(type, options);
@@ -763,9 +769,7 @@ class EventTarget extends WebEventTarget {
     }
 
     const opts = ObjectAssign({}, options, {
-      depth: Number.isInteger(options.depth)
-        ? options.depth - 1
-        : options.depth,
+      depth: NumberIsInteger(options.depth) ? options.depth - 1 : options.depth,
     });
 
     return `${name} ${inspect({}, opts)}`;

filename: tests/node_compat/config.jsonc
patch: @@ -536,6 +536,7 @@
       "test-event-emitter-symbols.js",
       "test-event-target.js",
       "test-events-add-abort-listener.mjs",
+      "test-events-customevent.js",
       "test-events-getmaxlisteners.js",
       "test-events-list.js",
       "test-events-listener-count-with-listener.js",

filename: tests/node_compat/runner/TODO.md
patch: @@ -1,7 +1,7 @@
 <!-- deno-fmt-ignore-file -->
 # Remaining Node Tests
 
-1201 tests out of 3993 have been ported from Node 23.9.0 (30.08% ported, 70.52% remaining).
+1202 tests out of 3993 have been ported from Node 23.9.0 (30.10% ported, 70.50% remaining).
 
 NOTE: This file should not be manually edited. Please edit `tests/node_compat/config.json` and run `deno task setup` in `tests/node_compat/runner` dir instead.
 
@@ -743,7 +743,6 @@ NOTE: This file should not be manually edited. Please edit `tests/node_compat/co
 - [parallel/test-esm-loader-hooks-inspect-wait.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-esm-loader-hooks-inspect-wait.js)
 - [parallel/test-eval-disallow-code-generation-from-strings.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-eval-disallow-code-generation-from-strings.js)
 - [parallel/test-eventemitter-asyncresource.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-eventemitter-asyncresource.js)
-- [parallel/test-events-customevent.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-events-customevent.js)
 - [parallel/test-events-static-geteventlisteners.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-events-static-geteventlisteners.js)
 - [parallel/test-eventsource-disabled.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-eventsource-disabled.js)
 - [parallel/test-eventsource.js](https://github.com/nodejs/node/tree/v23.9.0/test/parallel/test-eventsource.js)

filename: tests/node_compat/test/parallel/test-events-customevent.js
patch: @@ -0,0 +1,332 @@
+// deno-fmt-ignore-file
+// deno-lint-ignore-file
+
+// Copyright Joyent and Node contributors. All rights reserved. MIT license.
+// Taken from Node 23.9.0
+// This file is automatically generated by `tests/node_compat/runner/setup.ts`. Do not modify this file manually.
+
+// Flags: --expose-internals
+
+'use strict';
+
+const common = require('../common');
+const { ok, strictEqual, deepStrictEqual, throws } = require('node:assert');
+const { inspect } = require('node:util');
+const { Event, EventTarget, CustomEvent } = require('internal/event_target');
+
+{
+  ok(CustomEvent);
+
+  // Default string
+  const tag = Object.prototype.toString.call(new CustomEvent('$'));
+  strictEqual(tag, '[object CustomEvent]');
+}
+
+{
+  // No argument behavior - throw TypeError
+  throws(() => {
+    new CustomEvent();
+  }, TypeError);
+
+  throws(() => new CustomEvent(Symbol()), TypeError);
+
+  // Too many arguments passed behavior - ignore additional arguments
+  const ev = new CustomEvent('foo', {}, {});
+  strictEqual(ev.type, 'foo');
+}
+
+{
+  const ev = new CustomEvent('$');
+  strictEqual(ev.type, '$');
+  strictEqual(ev.bubbles, false);
+  strictEqual(ev.cancelable, false);
+  strictEqual(ev.detail, null);
+}
+
+{
+  // Coercion to string works
+  strictEqual(new CustomEvent(1).type, '1');
+  strictEqual(new CustomEvent(false).type, 'false');
+  strictEqual(new CustomEvent({}).type, String({}));
+}
+
+{
+  const ev = new CustomEvent('$', {
+    detail: 56,
+    sweet: 'x',
+    cancelable: true,
+  });
+  strictEqual(ev.type, '$');
+  strictEqual(ev.bubbles, false);
+  strictEqual(ev.cancelable, true);
+  strictEqual(ev.sweet, undefined);
+  strictEqual(ev.detail, 56);
+}
+
+{
+  // Any types of value for `detail` are acceptable.
+  ['foo', 1, false, [], {}].forEach((i) => {
+    const ev = new CustomEvent('$', { detail: i });
+    strictEqual(ev.detail, i);
+  });
+}
+
+{
+  // Readonly `detail` behavior
+  const ev = new CustomEvent('$', {
+    detail: 56,
+  });
+  strictEqual(ev.detail, 56);
+  try {
+    ev.detail = 96;
+    // eslint-disable-next-line no-unused-vars
+  } catch (error) {
+    common.mustCall()();
+  }
+  strictEqual(ev.detail, 56);
+}
+
+{
+  const ev = new Event('$', {
+    detail: 96,
+  });
+  strictEqual(ev.detail, undefined);
+}
+
+// The following tests verify whether CustomEvent works the same as Event
+// except carrying custom data. They're based on `parallel/test-eventtarget.js`.
+
+{
+  const ev = new CustomEvent('$');
+  strictEqual(ev.type, '$');
+  strictEqual(ev.bubbles, false);
+  strictEqual(ev.cancelable, false);
+  strictEqual(ev.detail, null);
+
+  strictEqual(ev.defaultPrevented, false);
+  strictEqual(typeof ev.timeStamp, 'number');
+
+  // Compatibility properties with the DOM
+  deepStrictEqual(ev.composedPath(), []);
+  strictEqual(ev.returnValue, true);
+  strictEqual(ev.composed, false);
+  strictEqual(ev.isTrusted, false);
+  strictEqual(ev.eventPhase, 0);
+  strictEqual(ev.cancelBubble, false);
+
+  // Not cancelable
+  ev.preventDefault();
+  strictEqual(ev.defaultPrevented, false);
+}
+
+{
+  // Invalid options
+  ['foo', 1, false].forEach((i) =>
+    throws(() => new CustomEvent('foo', i), {
+      code: 'ERR_INVALID_ARG_TYPE',
+      name: 'TypeError',
+      message:
+        'The "options" argument must be of type object.' +
+        common.invalidArgTypeHelper(i),
+    }),
+  );
+}
+
+{
+  const ev = new CustomEvent('$');
+  strictEqual(ev.constructor.name, 'CustomEvent');
+
+  // CustomEvent Statics
+  strictEqual(CustomEvent.NONE, 0);
+  strictEqual(CustomEvent.CAPTURING_PHASE, 1);
+  strictEqual(CustomEvent.AT_TARGET, 2);
+  strictEqual(CustomEvent.BUBBLING_PHASE, 3);
+  strictEqual(new CustomEvent('foo').eventPhase, CustomEvent.NONE);
+
+  // CustomEvent is a function
+  strictEqual(CustomEvent.length, 1);
+}
+
+{
+  const ev = new CustomEvent('foo');
+  strictEqual(ev.cancelBubble, false);
+  ev.cancelBubble = true;
+  strictEqual(ev.cancelBubble, true);
+}
+{
+  const ev = new CustomEvent('foo');
+  strictEqual(ev.cancelBubble, false);
+  ev.stopPropagation();
+  strictEqual(ev.cancelBubble, true);
+}
+{
+  const ev = new CustomEvent('foo');
+  strictEqual(ev.cancelBubble, false);
+  ev.cancelBubble = 'some-truthy-value';
+  strictEqual(ev.cancelBubble, true);
+}
+{
+  const ev = new CustomEvent('foo');
+  strictEqual(ev.cancelBubble, false);
+  ev.cancelBubble = true;
+  strictEqual(ev.cancelBubble, true);
+}
+{
+  const ev = new CustomEvent('foo');
+  strictEqual(ev.cancelBubble, false);
+  ev.stopPropagation();
+  strictEqual(ev.cancelBubble, true);
+}
+{
+  const ev = new CustomEvent('foo');
+  strictEqual(ev.cancelBubble, false);
+  ev.cancelBubble = 'some-truthy-value';
+  strictEqual(ev.cancelBubble, true);
+}
+{
+  const ev = new CustomEvent('foo', { cancelable: true });
+  strictEqual(ev.type, 'foo');
+  strictEqual(ev.cancelable, true);
+  strictEqual(ev.defaultPrevented, false);
+
+  ev.preventDefault();
+  strictEqual(ev.defaultPrevented, true);
+}
+{
+  const ev = new CustomEvent('foo');
+  strictEqual(ev.isTrusted, false);
+}
+
+// Works with EventTarget
+
+{
+  const obj = { sweet: 'x', memory: { x: 56, y: 96 } };
+  const et = new EventTarget();
+  const ev = new CustomEvent('$', { detail: obj });
+  const fn = common.mustCall((event) => {
+    strictEqual(event, ev);
+    deepStrictEqual(event.detail, obj);
+  });
+  et.addEventListener('$', fn);
+  et.dispatchEvent(ev);
+}
+
+{
+  const eventTarget = new EventTarget();
+  const event = new CustomEvent('$');
+  eventTarget.dispatchEvent(event);
+  strictEqual(event.target, eventTarget);
+}
+
+{
+  const obj = { sweet: 'x' };
+  const eventTarget = new EventTarget();
+
+  const ev1 = common.mustCall(function(event) {
+    strictEqual(event.type, 'foo');
+    strictEqual(event.detail, obj);
+    strictEqual(this, eventTarget);
+    strictEqual(event.eventPhase, 2);
+  }, 2);
+
+  const ev2 = {
+    handleEvent: common.mustCall(function(event) {
+      strictEqual(event.type, 'foo');
+      strictEqual(event.detail, obj);
+      strictEqual(this, ev2);
+    }),
+  };
+
+  eventTarget.addEventListener('foo', ev1);
+  eventTarget.addEventListener('foo', ev2, { once: true });
+  ok(eventTarget.dispatchEvent(new CustomEvent('foo', { detail: obj })));
+  eventTarget.dispatchEvent(new CustomEvent('foo', { detail: obj }));
+
+  eventTarget.removeEventListener('foo', ev1);
+  eventTarget.dispatchEvent(new CustomEvent('foo'));
+}
+
+{
+  // Same event dispatched multiple times.
+  const obj = { sweet: 'x' };
+  const event = new CustomEvent('foo', { detail: obj });
+  const eventTarget1 = new EventTarget();
+  const eventTarget2 = new EventTarget();
+
+  eventTarget1.addEventListener(
+    'foo',
+    common.mustCall((event) => {
+      strictEqual(event.eventPhase, CustomEvent.AT_TARGET);
+      strictEqual(event.target, eventTarget1);
+      strictEqual(event.detail, obj);
+      deepStrictEqual(event.composedPath(), [eventTarget1]);
+    }),
+  );
+
+  eventTarget2.addEventListener(
+    'foo',
+    common.mustCall((event) => {
+      strictEqual(event.eventPhase, CustomEvent.AT_TARGET);
+      strictEqual(event.target, eventTarget2);
+      strictEqual(event.detail, obj);
+      deepStrictEqual(event.composedPath(), [eventTarget2]);
+    }),
+  );
+
+  eventTarget1.dispatchEvent(event);
+  strictEqual(event.eventPhase, CustomEvent.NONE);
+  strictEqual(event.target, eventTarget1);
+  deepStrictEqual(event.composedPath(), []);
+
+  eventTarget2.dispatchEvent(event);
+  strictEqual(event.eventPhase, CustomEvent.NONE);
+  strictEqual(event.target, eventTarget2);
+  deepStrictEqual(event.composedPath(), []);
+}
+
+{
+  const obj = { sweet: 'x' };
+  const target = new EventTarget();
+  const event = new CustomEvent('foo', { detail: obj });
+
+  strictEqual(event.target, null);
+
+  target.addEventListener(
+    'foo',
+    common.mustCall((event) => {
+      strictEqual(event.target, target);
+      strictEqual(event.currentTarget, target);
+      strictEqual(event.srcElement, target);
+      strictEqual(event.detail, obj);
+    }),
+  );
+  target.dispatchEvent(event);
+}
+
+{
+  // Event subclassing
+  const SubEvent = class extends CustomEvent {};
+  const ev = new SubEvent('foo', { detail: 56 });
+  const eventTarget = new EventTarget();
+  const fn = common.mustCall((event) => {
+    strictEqual(event, ev);
+    strictEqual(event.detail, 56);
+  });
+  eventTarget.addEventListener('foo', fn, { once: true });
+  eventTarget.dispatchEvent(ev);
+}
+
+// Works with inspect
+
+{
+  const ev = new CustomEvent('test');
+  const evConstructorName = inspect(ev, {
+    depth: -1,
+  });
+  strictEqual(evConstructorName, 'CustomEvent');
+
+  const inspectResult = inspect(ev, {
+    depth: 1,
+  });
+  ok(inspectResult.includes('CustomEvent'));
+}