From 457ee14ee1d42c7d0a17e36d1cd743fbbaae9a52 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 20:55:18 +0000 Subject: [PATCH 1/4] Implement Event, CustomEvent and EventTarget in TypeScript Add standalone TypeScript implementations of Event, CustomEvent and EventTarget to the per-isolate bootstrap (src/per_isolate/events/), following the TS streams model. Under the new experimental typescript_implemented_events flag, main.ts installs them as the Event, CustomEvent and EventTarget globals. This is the first step of moving these classes out of basics.h. Nothing native creates, dispatches or listens through the TS classes yet: the runtime's own events (MessageEvent and the rest), the global scope and AbortSignal stay C++, and the C++ classes stay registered. The two implementations accept none of each other's events or targets. The TS classes follow the DOM spec and consult none of the C++ implementation's compat flags (pedantic_wpt, workers_api_getters_setters_on_prototype, set_event_target_this, spec_compliant_dispatch_exceptions and the rest). Where the C++ classes deviate from the spec without a flag, the TS ones do not: they support capture and passive listeners, initEvent() and initCustomEvent(); take timeStamp from Date.now(); define isTrusted on each instance ([LegacyUnforgeable]); look handleEvent up at every call; ignore listener results; and have no generic on reflection. Listener exceptions are reported through the global reportError(). The {signal} option registers an abort algorithm with utils.addAbortAlgorithm() that holds the listener's registration weakly, so a long-lived signal retains no dead target. primordials.ts gains FunctionPrototypeCall and DateNow. Compatibility: the TS globals replace the C++ ones only under typescript_implemented_events, which is experimental and has no enable date, so no existing Worker's API surface changes. --- src/per_isolate/AGENTS.md | 9 +- src/per_isolate/events/AGENTS.md | 63 ++++ src/per_isolate/events/common.ts | 101 +++++ src/per_isolate/events/event-target.ts | 381 +++++++++++++++++++ src/per_isolate/events/event.ts | 477 ++++++++++++++++++++++++ src/per_isolate/events/events.ts | 11 + src/per_isolate/main.ts | 31 ++ src/per_isolate/primordials.ts | 8 + src/workerd/io/compatibility-date.capnp | 8 + 9 files changed, 1085 insertions(+), 4 deletions(-) create mode 100644 src/per_isolate/events/AGENTS.md create mode 100644 src/per_isolate/events/common.ts create mode 100644 src/per_isolate/events/event-target.ts create mode 100644 src/per_isolate/events/event.ts create mode 100644 src/per_isolate/events/events.ts diff --git a/src/per_isolate/AGENTS.md b/src/per_isolate/AGENTS.md index d67b2fa225f..a3115d8ff6d 100644 --- a/src/per_isolate/AGENTS.md +++ b/src/per_isolate/AGENTS.md @@ -23,7 +23,8 @@ at context creation, before any user code. Gated by the - `main.ts` installs the real stream globals (ReadableStream et al.) when the `typescript_implemented_streams` compat flag is enabled, plus the internal-testing `ReadableStreamDrainingReader` under - `expose_draining_reader`. + `expose_draining_reader`; and `Event`, `CustomEvent` and `EventTarget` + (`events/`) under `typescript_implemented_events`. - Files are auto-discovered (`BUILD.bazel` and `tsconfig.json` both glob `**/*.ts`). No registration needed for new files. - Local convention: no copyright headers in this directory's bootstrap @@ -46,8 +47,8 @@ at context creation, before any user code. Gated by the The bootstrap runs for every worker regardless of compat date — both layouts must work. JSG *methods* are always on the prototype. - Internal cross-module surfaces are exported as a single namespace - object (`internalsForPipe`, `nativeStreamInternals`), never - re-exported to users. + object (`internalsForPipe`, `nativeStreamInternals`, `eventInternals`), + never re-exported to users. - Internal class dispatch uses private-brand `in` checks, NEVER `instanceof` (classes reachable from the global have user-reachable `Symbol.hasInstance`). @@ -220,4 +221,4 @@ to prevent accidental mutation by downstream bootstrap scripts. - **NEVER** add a runtime require cycle between bootstrap scripts. See also `webstreams/AGENTS.md` for streams-specific architecture and -anti-patterns. +anti-patterns, and `events/AGENTS.md` for the events classes. diff --git a/src/per_isolate/events/AGENTS.md b/src/per_isolate/events/AGENTS.md new file mode 100644 index 00000000000..01453e3d125 --- /dev/null +++ b/src/per_isolate/events/AGENTS.md @@ -0,0 +1,63 @@ +# src/per_isolate/events/ + +TypeScript `Event`, `CustomEvent` and `EventTarget` (DOM §2), installed on +the global by `main.ts` under `typescript_implemented_events`. The C++ +classes (`src/workerd/api/basics.{h,c++}`) stay registered: the runtime's +own events (`MessageEvent` and the rest) and targets (the global, +`AbortSignal`) are still C++, and the two implementations accept none of +each other's objects. File headers are the authoritative docs. + +Parent directory conventions (primordials discipline, the JSG capture trap, +private-brand dispatch, no `instanceof`) apply here; see +`src/per_isolate/AGENTS.md`. + +## FILE MAP + +| File | Role | +| --- | --- | +| `common.ts` | WebIDL helpers (`toDictionary`, `notEnoughArguments`, `toStringTagProperty`, ...) and `requireAbortSignal`; leaf module | +| `event.ts` | `Event`, `CustomEvent`, and `eventInternals`: the frozen, null-prototype namespace of the spec steps `event-target.ts` performs on an event (`beginDispatch`, `enterInvoke`, `endDispatch`, ...); requires `common` | +| `event-target.ts` | `EventTarget`: listener storage, the options' conversion, dispatch, the `{signal}` abort algorithm; requires `common` and `event` | +| `events.ts` | Aggregator of the user-visible classes | + +## INVARIANTS + +- **The TS classes consult no C++ compat flag.** They hard-code the modern, + spec behavior at every compat date. The parity suites + (`src/tests/events/`) pin every place this differs from the C++ classes, + in their `AGENTS.md` ledgers; a behavior change here updates the ledger + row and its test. +- **Event state is private fields**, one `#flags` word for the spec's flags. + `event-target.ts` reaches it only through `eventInternals`, never the + public attributes, which user code can shadow. +- **`isTrusted` is an own, non-configurable accessor** defined in the + constructor ([LegacyUnforgeable]), with one getter shared by every + instance so their maps stay shared. +- **Listener records are null-prototype**; a type's list is created by the + first add and deleted once it empties. Removal marks the record + `removed` before splicing it, which is what a dispatch's clone of the + list checks. +- **Listeners are called through the captured `FunctionPrototypeCall`**, + and an object listener's `handleEvent` is read at every call. Results are + ignored. +- **Listener exceptions go to the global `reportError()` captured at load** + (not the property's current value), and never reach the + `dispatchEvent()` caller. A `reportError()` that itself throws (a + throwing `error` listener without `spec_compliant_dispatch_exceptions`) + is dropped. +- **The `{signal}` abort algorithm holds its record through a `WeakRef`**, + so a long-lived signal never retains a dead target or its listeners; the + record holds the registration handle, whose collection unregisters the + algorithm. +- **`requireAbortSignal` brand-checks through the C++ `aborted` getter.** + Under the instance-property layout (old compat dates) there is no getter, + and the check accepts any object whose `aborted` is a boolean + (event-target ledger #14). + +## ANTI-PATTERNS + +- **NEVER** read an event's public attributes, or call its public methods, + from `event-target.ts`; go through `eventInternals`. +- **NEVER** capture `globalThis.EventTarget` or `globalThis.Event` in + another bootstrap module: under the flag they are these classes, and + without it the C++ ones. `require('events/events')` for these. diff --git a/src/per_isolate/events/common.ts b/src/per_isolate/events/common.ts new file mode 100644 index 00000000000..af2a4653724 --- /dev/null +++ b/src/per_isolate/events/common.ts @@ -0,0 +1,101 @@ +'use strict'; + +// Helpers shared by the events modules. Leaf module: requires nothing. +// +// TODO(cleanup): isObject, kNullProtoObject, kEnumerable, +// toStringTagProperty and toDictionary duplicate the WebIDL helpers in +// webstreams/common.ts (isObject is that file's requireObject check), and +// requireAbortSignal duplicates the signal check in webstreams/readable.ts's +// convertPipeOptions. Reconcile them into one shared module. + +const { AbortSignalAbortedGet, ObjectFreeze, TypeError } = primordials; + +// WebIDL's check that a value is an Object: true for functions too, false +// for null and every other primitive. +function isObject(value: unknown): value is object { + return ( + (typeof value === 'object' && value !== null) || typeof value === 'function' + ); +} + +// What an omitted (or null) dictionary argument stands for. Null-prototype: +// WebIDL reads nothing for an omitted dictionary, so neither may we. +const kNullProtoObject: object = ObjectFreeze({ __proto__: null }); + +const kEnumerable: PropertyDescriptor = ObjectFreeze({ + __proto__: kNullProtoObject, + enumerable: true, +}); + +function toStringTagProperty(tag: string): PropertyDescriptor { + return { + __proto__: kNullProtoObject, + value: tag, + writable: false, + enumerable: false, + configurable: true, + } as PropertyDescriptor; +} + +// WebIDL conversion of an optional dictionary argument: undefined and null +// mean an empty dictionary, any other object (a function included) is read +// as the dictionary, and every other primitive is a TypeError. +function toDictionary(value: unknown, message: string): object { + if (value == null) return kNullProtoObject; + if (!isObject(value)) throw new TypeError(message); + return value; +} + +// WebIDL's TypeError for a call with fewer arguments than the operation +// requires. `context` names the call, as in "Failed to execute +// 'dispatchEvent' on 'EventTarget'". +function notEnoughArguments( + context: string, + required: number, + present: number +): TypeError { + const noun = required === 1 ? 'argument' : 'arguments'; + return new TypeError( + `${context}: ${required} ${noun} required, but only ${present} present.` + ); +} + +// WebIDL conversion of a value to an AbortSignal, returning whether it has +// aborted. Under the modern JSG layout the captured `aborted` getter throws +// for a receiver that is not an AbortSignal; under the instance-property +// layout (old compat dates) the capture is a plain read that cannot +// brand-check, so additionally require the boolean a genuine signal's own +// data property carries. Under old dates a forged {aborted: } +// passes: utils.addAbortAlgorithm() rejects it when a listener is added, but +// a null listener, or a forgery claiming to have aborted, returns before +// that (event-target suite ledger #14). +function requireAbortSignal(value: unknown, message: string): boolean { + let aborted: unknown; + try { + aborted = AbortSignalAbortedGet(value); + } catch { + throw new TypeError(message); + } + if (typeof aborted !== 'boolean') throw new TypeError(message); + return aborted; +} + +export type CommonHelpers = { + isObject: typeof isObject; + kNullProtoObject: typeof kNullProtoObject; + kEnumerable: typeof kEnumerable; + toStringTagProperty: typeof toStringTagProperty; + toDictionary: typeof toDictionary; + notEnoughArguments: typeof notEnoughArguments; + requireAbortSignal: typeof requireAbortSignal; +}; + +module.exports = ObjectFreeze({ + isObject, + kNullProtoObject, + kEnumerable, + toStringTagProperty, + toDictionary, + notEnoughArguments, + requireAbortSignal, +}); diff --git a/src/per_isolate/events/event-target.ts b/src/per_isolate/events/event-target.ts new file mode 100644 index 00000000000..88be0ba13dd --- /dev/null +++ b/src/per_isolate/events/event-target.ts @@ -0,0 +1,381 @@ +'use strict'; + +// EventTarget (DOM §2.7-§2.10). +// +// Listeners are kept per type, in lists created by addEventListener() and +// deleted once they empty. A target has no parent, so the dispatch path is +// the target alone and both of dispatch's passes run at it: the capturing +// pass runs its capture listeners, then the bubbling pass runs the rest. +// +// Listener exceptions are reported through the global reportError(), which +// fires the global 'error' event and logs the exception unless a listener +// cancels it. Nothing reaches the dispatchEvent() caller. + +import type { CommonHelpers } from './common'; +import type { Event, EventInternals } from './event'; + +const { + ArrayPrototypePush, + FunctionPrototypeBind, + FunctionPrototypeCall, + ObjectDefineProperties, + SafeMap, + SymbolToStringTag, + TypeError, + WeakRef, + WeakRefPrototypeDeref, +} = primordials; + +const { + isObject, + kEnumerable, + notEnoughArguments, + requireAbortSignal, + toStringTagProperty, +} = require('events/common') as CommonHelpers; + +const { + isEvent, + type: getEventType, + beginDispatch, + enterInvoke, + setInPassiveListener, + isImmediatePropagationStopped, + endDispatch, +} = (require('events/event') as { eventInternals: EventInternals }) + .eventInternals; + +// Captured before user code can replace it. +const reportError = FunctionPrototypeBind( + globalThis.reportError, + globalThis +) as (error: unknown) => void; + +// The spec's event listener struct, less its type, which keys the list that +// holds it. Its identity is (callback, capture); duplicates are never added. +interface ListenerRecord { + readonly callback: object; + readonly capture: boolean; + readonly passive: boolean; + readonly once: boolean; + removed: boolean; + // The target whose list holds the record, for the {signal} option's abort + // algorithm, which holds the record only weakly. + readonly target: EventTarget; + // The abort algorithm's registration with the {signal} option's signal. + registration: AbortAlgorithmHandle | undefined; +} + +interface ListenerOptionsDict { + capture?: unknown; + once?: unknown; + passive?: unknown; + signal?: unknown; +} + +// WebIDL conversion of an EventListener? argument: undefined and null mean +// no listener, and any other object, callable or not, is the listener. +function convertListener(callback: unknown, context: string): object | null { + if (callback == null) return null; + if (!isObject(callback)) { + throw new TypeError(`${context}: parameter 2 is not of type 'Object'.`); + } + return callback; +} + +// Both scans run over a live list, which never holds a removed record. +function findListener( + list: ListenerRecord[], + callback: object, + capture: boolean +): ListenerRecord | undefined { + for (let i = 0; i < list.length; i++) { + const record = list[i] as ListenerRecord; + if (record.callback === callback && record.capture === capture) { + return record; + } + } + return undefined; +} + +function indexOfRecord(list: ListenerRecord[], record: ListenerRecord): number { + for (let i = 0; i < list.length; i++) { + if (list[i] === record) return i; + } + return -1; +} + +// The records of `list` that run in one dispatch pass, in order: the capture +// listeners for the capturing pass, the rest for the bubbling pass. This is +// the spec's clone of the list, less the records the pass would skip anyway. +// undefined if there are none. +function listenersForPass( + list: ListenerRecord[], + capture: boolean +): ListenerRecord[] | undefined { + let snapshot: ListenerRecord[] | undefined; + for (let i = 0; i < list.length; i++) { + const record = list[i] as ListenerRecord; + if (record.capture !== capture) continue; + if (snapshot === undefined) snapshot = [record]; + else ArrayPrototypePush(snapshot, record); + } + return snapshot; +} + +// WebIDL's "call a user object's operation" for an EventListener: a callable +// listener is called with the target as `this`; any other listener's +// handleEvent is read at every call, and called with the listener as `this`. +// Return values are ignored. +function callListener( + callback: object, + target: EventTarget, + event: Event +): void { + if (typeof callback === 'function') { + FunctionPrototypeCall(callback, target, event); + return; + } + const handleEvent: unknown = (callback as { handleEvent?: unknown }) + .handleEvent; + if (typeof handleEvent !== 'function') { + throw new TypeError("The listener's handleEvent is not a function."); + } + FunctionPrototypeCall(handleEvent, callback, event); +} + +// Inner invoke's "report the exception". The global reportError() throws +// only when an 'error' listener throws under the legacy dispatch policy +// (spec_compliant_dispatch_exceptions off), and that exception is dropped. +function reportException(exception: unknown): void { + try { + reportError(exception); + } catch { + // Dropped (see above). + } +} + +let assertIsEventTarget: (self: unknown) => void; +// The spec's "remove an event listener". Idempotent. +let removeListener: ( + target: EventTarget, + type: string, + record: ListenerRecord +) => void; + +// The {signal} option's abort steps: remove the listener. They hold the +// record only weakly, and the record holds its target, so a signal that +// outlives a target does not keep the target or its listeners alive. A +// collected record takes its registration with it, which unregisters the +// algorithm. +function newAbortAlgorithm(type: string, record: ListenerRecord): () => void { + const recordRef = new WeakRef(record); + return () => { + const live = WeakRefPrototypeDeref(recordRef) as ListenerRecord | undefined; + if (live !== undefined) removeListener(live.target, type, live); + }; +} + +class EventTarget { + // A SafeMap from type to listener list, created by the first + // addEventListener(). + #listeners: Map | undefined = undefined; + + static { + assertIsEventTarget = (self: unknown) => { + if (!isObject(self) || !(#listeners in self)) { + throw new TypeError('Illegal invocation'); + } + }; + + removeListener = (target, type, record) => { + if (record.removed) return; + record.removed = true; + const listeners = target.#listeners; + const list = listeners?.get(type); + if (list !== undefined) { + const index = indexOfRecord(list, record); + if (index !== -1) { + const last = list.length - 1; + for (let i = index; i < last; i++) list[i] = list[i + 1]!; + list.length = last; + if (last === 0) listeners!.delete(type); + } + } + const registration = record.registration; + if (registration !== undefined) { + record.registration = undefined; + registration.remove(); + } + }; + } + + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options: boolean | AddEventListenerOptions | undefined = undefined + ): void { + assertIsEventTarget(this); + if (arguments.length < 2) { + throw notEnoughArguments( + "Failed to execute 'addEventListener' on 'EventTarget'", + 2, + arguments.length + ); + } + // WebIDL converts every argument, the options' members included, before + // anything else happens. + const typeString = `${type}`; + const listener = convertListener( + callback, + "Failed to execute 'addEventListener' on 'EventTarget'" + ); + let capture: boolean; + let once = false; + let passive = false; + let signal: unknown; + let aborted = false; + if (isObject(options)) { + const dict = options as ListenerOptionsDict; + capture = !!dict.capture; + once = !!dict.once; + passive = !!dict.passive; + signal = dict.signal; + if (signal !== undefined) { + aborted = requireAbortSignal( + signal, + "Failed to execute 'addEventListener' on 'EventTarget': " + + "Failed to read the 'signal' property from " + + "'AddEventListenerOptions': The provided value is not of type " + + "'AbortSignal'." + ); + } + } else { + capture = !!options; + } + + // The spec's "add an event listener". + if (aborted || listener === null) return; + let listeners = this.#listeners; + const list = listeners?.get(typeString); + if (list !== undefined && findListener(list, listener, capture)) { + return; + } + const record = { + __proto__: null, + callback: listener, + capture, + passive, + once, + removed: false, + target: this, + registration: undefined, + } as ListenerRecord; + if (signal !== undefined) { + record.registration = utils.addAbortAlgorithm( + signal as AbortSignal, + newAbortAlgorithm(typeString, record) + ); + } + if (list !== undefined) { + ArrayPrototypePush(list, record); + return; + } + if (listeners === undefined) { + listeners = new SafeMap() as Map; + this.#listeners = listeners; + } + listeners.set(typeString, [record]); + } + + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options: boolean | EventListenerOptions | undefined = undefined + ): void { + assertIsEventTarget(this); + if (arguments.length < 2) { + throw notEnoughArguments( + "Failed to execute 'removeEventListener' on 'EventTarget'", + 2, + arguments.length + ); + } + const typeString = `${type}`; + const listener = convertListener( + callback, + "Failed to execute 'removeEventListener' on 'EventTarget'" + ); + const capture = isObject(options) + ? !!(options as ListenerOptionsDict).capture + : !!options; + if (listener === null) return; + const list = this.#listeners?.get(typeString); + if (list === undefined) return; + const record = findListener(list, listener, capture); + if (record !== undefined) removeListener(this, typeString, record); + } + + dispatchEvent(event: Event): boolean { + assertIsEventTarget(this); + if (arguments.length < 1) { + throw notEnoughArguments( + "Failed to execute 'dispatchEvent' on 'EventTarget'", + 1, + arguments.length + ); + } + if (!isEvent(event)) { + throw new TypeError( + "Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'." + ); + } + beginDispatch(event, this); + // The type cannot change during dispatch: initEvent() does nothing then. + const type = getEventType(event); + let notCanceled: boolean; + try { + this.#invoke(event, type, true); + this.#invoke(event, type, false); + } finally { + notCanceled = endDispatch(event); + } + return notCanceled; + } + + // The spec's invoke and inner invoke at this target, for one pass. The pass + // runs over a snapshot of the list taken as it starts, so a listener added + // during the pass does not run in it, and a listener removed during the + // pass is skipped because removal marks it. + #invoke(event: Event, type: string, capture: boolean): void { + const list = this.#listeners?.get(type); + if (list === undefined || !enterInvoke(event, this)) return; + const snapshot = listenersForPass(list, capture); + if (snapshot === undefined) return; + for (let i = 0; i < snapshot.length; i++) { + const record = snapshot[i] as ListenerRecord; + if (record.removed) continue; + if (record.once) removeListener(this, type, record); + if (record.passive) setInPassiveListener(event, true); + try { + callListener(record.callback, this, event); + } catch (exception) { + reportException(exception); + } + if (record.passive) setInPassiveListener(event, false); + if (isImmediatePropagationStopped(event)) break; + } + } +} + +ObjectDefineProperties(EventTarget.prototype, { + __proto__: null, + addEventListener: kEnumerable, + removeEventListener: kEnumerable, + dispatchEvent: kEnumerable, + [SymbolToStringTag]: toStringTagProperty('EventTarget'), +}); + +module.exports = { + EventTarget, +}; diff --git a/src/per_isolate/events/event.ts b/src/per_isolate/events/event.ts new file mode 100644 index 00000000000..67175886eb2 --- /dev/null +++ b/src/per_isolate/events/event.ts @@ -0,0 +1,477 @@ +'use strict'; + +// Event and CustomEvent (DOM §2.2, §2.4). +// +// An event's state is its private fields, with the spec's flags packed into +// one word (#flags). There is no initialized flag: without createEvent(), +// every event is initialized. +// +// Dispatch lives in event-target.ts, which drives an event through +// eventInternals, one operation per spec step it performs. The namespace is +// built in Event's static block, so it reaches the private fields, and it is +// never exported to users. + +import type { CommonHelpers } from './common'; + +const { + DOMException, + DateNow, + ObjectDefineProperties, + ObjectDefineProperty, + ObjectFreeze, + ObjectGetOwnPropertyDescriptor, + SymbolToStringTag, + TypeError, +} = primordials; + +const { + isObject, + kEnumerable, + notEnoughArguments, + toDictionary, + toStringTagProperty, +} = require('events/common') as CommonHelpers; + +// The members of EventInit and CustomEventInit, as read from the user's +// dictionary. +interface EventInitDict { + bubbles?: unknown; + cancelable?: unknown; + composed?: unknown; +} + +interface CustomEventInitDict extends EventInitDict { + detail?: unknown; +} + +// #flags. kBubbles, kCancelable and kComposed hold the attributes of those +// names and kTrusted holds isTrusted; the rest are the spec's flags. +const kBubbles = 1 << 0; +const kCancelable = 1 << 1; +const kComposed = 1 << 2; +const kTrusted = 1 << 3; +const kStopPropagation = 1 << 4; +const kStopImmediatePropagation = 1 << 5; +const kCanceled = 1 << 6; +const kInPassiveListener = 1 << 7; +const kDispatch = 1 << 8; + +// The eventPhase values. Without a tree, an event is only ever at its target. +const NONE = 0; +const CAPTURING_PHASE = 1; +const AT_TARGET = 2; +const BUBBLING_PHASE = 3; + +// WebIDL conversion of an EventInit dictionary to the flags it sets. The +// members are read once each, in order, and converted with ToBoolean. +function convertEventInit(dict: EventInitDict): number { + return ( + (dict.bubbles ? kBubbles : 0) | + (dict.cancelable ? kCancelable : 0) | + (dict.composed ? kComposed : 0) + ); +} + +// The operations event-target.ts performs on an event, named for the spec +// steps they implement. +export interface EventInternals { + // WebIDL's conversion of an Event argument. + isEvent(value: unknown): value is Event; + type(event: Event): string; + // dispatchEvent() steps 1-2 and dispatch step 1: throw if the event is + // being dispatched; otherwise set the dispatch flag, clear isTrusted, and + // set the target. + beginDispatch(event: Event, target: object): void; + // Invoke steps 4-5: false if the stop propagation flag is set; otherwise + // set currentTarget. + enterInvoke(event: Event, target: object): boolean; + // Inner invoke steps 9 and 12. + setInPassiveListener(event: Event, value: boolean): void; + // Inner invoke step 14. + isImmediatePropagationStopped(event: Event): boolean; + // Dispatch steps 7-10 and 13: clear currentTarget, the dispatch flag and + // the stop flags, and return false if the event was canceled. It clears + // the in passive listener flag too, should an exception have escaped a + // passive listener's invocation. + endDispatch(event: Event): boolean; +} + +let assertIsEvent: (self: unknown) => void; +// The spec's "initialize" for initEvent() and initCustomEvent(), after +// their dispatch flag check: returns false, having done nothing, if the +// event is being dispatched. +let initializeEvent: ( + event: Event, + type: string, + bubbles: boolean, + cancelable: boolean +) => boolean; +// The [LegacyUnforgeable] isTrusted: an own accessor of every instance, +// with one getter shared by all of them. +let kIsTrustedProperty: PropertyDescriptor; +let eventInternals: EventInternals; + +class Event { + #type: string; + #flags: number; + #target: object | null = null; + #currentTarget: object | null = null; + #timeStamp: number; + + static { + assertIsEvent = (self: unknown) => { + if (!isObject(self) || !(#flags in self)) { + throw new TypeError('Illegal invocation'); + } + }; + + initializeEvent = (event, type, bubbles, cancelable) => { + const flags = event.#flags; + if (flags & kDispatch) return false; + // Unsets the stop propagation, stop immediate propagation and + // canceled flags and isTrusted. composed is kept. + event.#flags = + (flags & kComposed) | + (bubbles ? kBubbles : 0) | + (cancelable ? kCancelable : 0); + event.#target = null; + event.#type = type; + return true; + }; + + // An object literal's getter is named 'get isTrusted', as WebIDL + // requires. + const accessor = { + get isTrusted(): boolean { + const self: unknown = this; + assertIsEvent(self); + return ((self as Event).#flags & kTrusted) !== 0; + }, + }; + kIsTrustedProperty = ObjectFreeze({ + __proto__: null, + get: ObjectGetOwnPropertyDescriptor(accessor, 'isTrusted').get, + enumerable: true, + configurable: false, + }); + + eventInternals = ObjectFreeze({ + __proto__: null, + isEvent: (value: unknown): value is Event => + isObject(value) && #flags in value, + type: (event: Event): string => event.#type, + beginDispatch(event: Event, target: object): void { + const flags = event.#flags; + if (flags & kDispatch) { + throw new DOMException( + 'The event is already being dispatched.', + 'InvalidStateError' + ); + } + event.#flags = (flags | kDispatch) & ~kTrusted; + event.#target = target; + }, + enterInvoke(event: Event, target: object): boolean { + if (event.#flags & kStopPropagation) return false; + event.#currentTarget = target; + return true; + }, + setInPassiveListener(event: Event, value: boolean): void { + event.#flags = value + ? event.#flags | kInPassiveListener + : event.#flags & ~kInPassiveListener; + }, + isImmediatePropagationStopped: (event: Event): boolean => + (event.#flags & kStopImmediatePropagation) !== 0, + endDispatch(event: Event): boolean { + event.#currentTarget = null; + const flags = + event.#flags & + ~( + kDispatch | + kStopPropagation | + kStopImmediatePropagation | + kInPassiveListener + ); + event.#flags = flags; + return (flags & kCanceled) === 0; + }, + }) as EventInternals; + } + + constructor( + type: string, + eventInitDict: EventInitDict | undefined = undefined + ) { + if (arguments.length < 1) { + throw notEnoughArguments( + "Failed to construct 'Event'", + 1, + arguments.length + ); + } + this.#type = `${type}`; + this.#flags = convertEventInit( + toDictionary( + eventInitDict, + "Failed to construct 'Event': The provided value is not of type 'EventInit'." + ) + ); + this.#timeStamp = DateNow(); + ObjectDefineProperty(this, 'isTrusted', kIsTrustedProperty); + } + + get type(): string { + assertIsEvent(this); + return this.#type; + } + + get target(): object | null { + assertIsEvent(this); + return this.#target; + } + + get srcElement(): object | null { + assertIsEvent(this); + return this.#target; + } + + get currentTarget(): object | null { + assertIsEvent(this); + return this.#currentTarget; + } + + // The path is the target alone, and currentTarget is set exactly while it + // is non-empty and user code can observe it. + composedPath(): object[] { + assertIsEvent(this); + const currentTarget = this.#currentTarget; + return currentTarget === null ? [] : [currentTarget]; + } + + get eventPhase(): number { + assertIsEvent(this); + return this.#currentTarget === null ? NONE : AT_TARGET; + } + + stopPropagation(): void { + assertIsEvent(this); + this.#flags |= kStopPropagation; + } + + get cancelBubble(): boolean { + assertIsEvent(this); + return (this.#flags & kStopPropagation) !== 0; + } + + set cancelBubble(value: boolean) { + if (arguments.length < 1) { + throw notEnoughArguments( + "Failed to set the 'cancelBubble' property on 'Event'", + 1, + 0 + ); + } + assertIsEvent(this); + if (value) this.#flags |= kStopPropagation; + } + + stopImmediatePropagation(): void { + assertIsEvent(this); + this.#flags |= kStopPropagation | kStopImmediatePropagation; + } + + get bubbles(): boolean { + assertIsEvent(this); + return (this.#flags & kBubbles) !== 0; + } + + get cancelable(): boolean { + assertIsEvent(this); + return (this.#flags & kCancelable) !== 0; + } + + get returnValue(): boolean { + assertIsEvent(this); + return (this.#flags & kCanceled) === 0; + } + + set returnValue(value: boolean) { + if (arguments.length < 1) { + throw notEnoughArguments( + "Failed to set the 'returnValue' property on 'Event'", + 1, + 0 + ); + } + assertIsEvent(this); + if (!value) this.#setCanceledFlag(); + } + + preventDefault(): void { + assertIsEvent(this); + this.#setCanceledFlag(); + } + + get defaultPrevented(): boolean { + assertIsEvent(this); + return (this.#flags & kCanceled) !== 0; + } + + get composed(): boolean { + assertIsEvent(this); + return (this.#flags & kComposed) !== 0; + } + + get timeStamp(): number { + assertIsEvent(this); + return this.#timeStamp; + } + + initEvent( + type: string, + bubbles: boolean = false, + cancelable: boolean = false + ): void { + assertIsEvent(this); + if (arguments.length < 1) { + throw notEnoughArguments( + "Failed to execute 'initEvent' on 'Event'", + 1, + arguments.length + ); + } + initializeEvent(this, `${type}`, !!bubbles, !!cancelable); + } + + // The spec's "set the canceled flag". + #setCanceledFlag(): void { + const flags = this.#flags; + if ((flags & (kCancelable | kInPassiveListener)) === kCancelable) { + this.#flags = flags | kCanceled; + } + } +} + +let assertIsCustomEvent: (self: unknown) => void; + +class CustomEvent extends Event { + #detail: unknown = null; + + static { + assertIsCustomEvent = (self: unknown) => { + if (!isObject(self) || !(#detail in self)) { + throw new TypeError('Illegal invocation'); + } + }; + } + + constructor( + type: string, + eventInitDict: CustomEventInitDict | undefined = undefined + ) { + if (arguments.length < 1) { + throw notEnoughArguments( + "Failed to construct 'CustomEvent'", + 1, + arguments.length + ); + } + // Converted here, in WebIDL's order, so that Event's constructor gets a + // string and a dictionary, whose inherited members it reads before + // detail is read below. + const typeString = `${type}`; + const dict = toDictionary( + eventInitDict, + "Failed to construct 'CustomEvent': The provided value is not of type 'CustomEventInit'." + ) as CustomEventInitDict; + super(typeString, dict); + const detail = dict.detail; + if (detail !== undefined) this.#detail = detail; + } + + get detail(): unknown { + assertIsCustomEvent(this); + return this.#detail; + } + + initCustomEvent( + type: string, + bubbles: boolean = false, + cancelable: boolean = false, + detail: unknown = null + ): void { + assertIsCustomEvent(this); + if (arguments.length < 1) { + throw notEnoughArguments( + "Failed to execute 'initCustomEvent' on 'CustomEvent'", + 1, + arguments.length + ); + } + if (initializeEvent(this, `${type}`, !!bubbles, !!cancelable)) { + this.#detail = detail; + } + } +} + +// WebIDL constants: on the interface object and the prototype, read-only +// and non-configurable. +function constant(value: number): PropertyDescriptor { + return { + __proto__: null, + value, + writable: false, + enumerable: true, + configurable: false, + } as PropertyDescriptor; +} + +const kPhaseConstants = { + __proto__: null, + NONE: constant(NONE), + CAPTURING_PHASE: constant(CAPTURING_PHASE), + AT_TARGET: constant(AT_TARGET), + BUBBLING_PHASE: constant(BUBBLING_PHASE), +}; + +ObjectDefineProperties(Event, kPhaseConstants); + +ObjectDefineProperties(Event.prototype, { + __proto__: null, + type: kEnumerable, + target: kEnumerable, + srcElement: kEnumerable, + currentTarget: kEnumerable, + composedPath: kEnumerable, + eventPhase: kEnumerable, + stopPropagation: kEnumerable, + cancelBubble: kEnumerable, + stopImmediatePropagation: kEnumerable, + bubbles: kEnumerable, + cancelable: kEnumerable, + returnValue: kEnumerable, + preventDefault: kEnumerable, + defaultPrevented: kEnumerable, + composed: kEnumerable, + timeStamp: kEnumerable, + initEvent: kEnumerable, + [SymbolToStringTag]: toStringTagProperty('Event'), +}); +ObjectDefineProperties(Event.prototype, kPhaseConstants); + +ObjectDefineProperties(CustomEvent.prototype, { + __proto__: null, + detail: kEnumerable, + initCustomEvent: kEnumerable, + [SymbolToStringTag]: toStringTagProperty('CustomEvent'), +}); + +export type { Event, CustomEvent }; + +module.exports = { + Event, + CustomEvent, + // For event-target.ts only; never re-exported to users. + eventInternals, +}; diff --git a/src/per_isolate/events/events.ts b/src/per_isolate/events/events.ts new file mode 100644 index 00000000000..875b2ee856e --- /dev/null +++ b/src/per_isolate/events/events.ts @@ -0,0 +1,11 @@ +'use strict'; + +const { Event, CustomEvent } = require('events/event'); + +const { EventTarget } = require('events/event-target'); + +module.exports = { + Event, + CustomEvent, + EventTarget, +}; diff --git a/src/per_isolate/main.ts b/src/per_isolate/main.ts index bdaaa39547f..db4a0de0745 100644 --- a/src/per_isolate/main.ts +++ b/src/per_isolate/main.ts @@ -278,3 +278,34 @@ if (compatFlags['typescript_implemented_streams']) { }); } } + +// The TypeScript classes shadow the C++ ones on the global only. Events the +// runtime creates, AbortSignal, the global scope and the other built-in event +// targets are still the C++ classes, which stay registered. +if (compatFlags['typescript_implemented_events']) { + const { Event, CustomEvent, EventTarget } = require('events/events'); + + ObjectDefineProperties(globalThis, { + Event: { + __proto__: null, + configurable: true, + enumerable: false, + writable: true, + value: Event, + }, + CustomEvent: { + __proto__: null, + configurable: true, + enumerable: false, + writable: true, + value: CustomEvent, + }, + EventTarget: { + __proto__: null, + configurable: true, + enumerable: false, + writable: true, + value: EventTarget, + }, + }); +} diff --git a/src/per_isolate/primordials.ts b/src/per_isolate/primordials.ts index 648f39702d9..7ae826c9573 100644 --- a/src/per_isolate/primordials.ts +++ b/src/per_isolate/primordials.ts @@ -77,6 +77,10 @@ const WeakSetCtor = WeakSet; // Function const FunctionPrototypeBind = uncurryThis(FunctionPrototype.bind); +const FunctionPrototypeCall = uncurryThis(FunctionPrototype.call); + +// Date +const DateNow = Date.now; // Object const ObjectCreate = Object.create; @@ -699,6 +703,10 @@ module.exports = ObjectFreeze({ // Function FunctionPrototype, FunctionPrototypeBind, + FunctionPrototypeCall, + + // Date + DateNow, // Object ObjectPrototype, diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index bbac0edd1bf..cf29e2673f0 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1717,4 +1717,12 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # the origin's Cache-Control (for example, an origin `no-store` gets cached as `no-cache`). When # enabled, the cache option no longer sets `cf.cacheControl`. To override the origin's # Cache-Control, omit the `cache` option and use `cf.cacheControl` instead. + + typeScriptImplementedEvents @195 :Bool + $compatEnableFlag("typescript_implemented_events") + $experimental; + # When enabled, the per-isolate bootstrap installs TypeScript implementations of Event, + # CustomEvent and EventTarget as globals (src/per_isolate/events/). Events created by the + # runtime, AbortSignal, the global scope and the other built-in event targets still use the + # C++ classes. } From 890973b353f6e2daa5ee4f5416e2d545432b42e7 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 20:55:24 +0000 Subject: [PATCH 2/4] Run WPT dom/events against the TypeScript events Add a dom/events-ts WPT target, with the per-isolate bootstrap autogate and typescript_implemented_events. Its config is the C++ one (dom/events-test.ts) less the expectations the TypeScript classes clear: passive listeners, {signal} with capture, the Event constructors (timeStamp) and isTrusted. Removing a capture listener still fails, because the test does it on the global, which is still a C++ EventTarget. --- src/wpt/BUILD.bazel | 8 +++++ src/wpt/dom/events-test-ts.ts | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/wpt/dom/events-test-ts.ts diff --git a/src/wpt/BUILD.bazel b/src/wpt/BUILD.bazel index d08c1717cfe..03595c0eebb 100644 --- a/src/wpt/BUILD.bazel +++ b/src/wpt/BUILD.bazel @@ -56,6 +56,14 @@ wpt_test( wpt_directory = "@wpt//:dom/events@module", ) +wpt_test( + name = "dom/events-ts", + autogates = ["workerd-autogate-per-isolate-javascript-bootstrap"], + compat_flags = ["typescript_implemented_events"], + config = "dom/events-test-ts.ts", + wpt_directory = "@wpt//:dom/events@module", +) + wpt_test( name = "fetch/api", size = "large", diff --git a/src/wpt/dom/events-test-ts.ts b/src/wpt/dom/events-test-ts.ts new file mode 100644 index 00000000000..8d890bb0182 --- /dev/null +++ b/src/wpt/dom/events-test-ts.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { type TestRunnerConfig } from 'harness/harness'; + +// The dom/events WPT suite against the TypeScript Event, CustomEvent and +// EventTarget (src/per_isolate/events/). Expectations match the C++ +// configuration (events-test.ts) except where the TypeScript classes follow +// the spec and the C++ ones do not. +export default { + 'AddEventListenerOptions-once.any.js': {}, + 'AddEventListenerOptions-passive.any.js': {}, + 'AddEventListenerOptions-signal.any.js': {}, + 'Event-constructors.any.js': {}, + 'Event-dispatch-listener-order.window.js': { + comment: 'Test requires DOM', + // There is a single test, whose name is the empty string + omittedTests: [''], + }, + 'Event-isTrusted.any.js': {}, + 'EventListener-addEventListener.sub.window.js': { + comment: 'Only relevant to browsers', + omittedTests: [ + "EventListener.addEventListener doesn't throw when a cross origin object is passed in.", + ], + }, + 'EventTarget-add-remove-listener.any.js': {}, + 'EventTarget-addEventListener.any.js': {}, + 'EventTarget-constructible.any.js': {}, + 'EventTarget-removeEventListener.any.js': { + comment: + 'The test removes a capture listener from the global, which is still a C++ EventTarget', + expectedFailures: ['removing a null event listener should succeed'], + }, + 'event-global-extra.window.js': { + comment: 'Test is DOM-specific', + disabledTests: true, + }, + 'event-global-set-before-handleEvent-lookup.window.js': { + comment: 'window is not defined', + expectedFailures: ["window.event is set before 'handleEvent' lookup"], + }, + 'event-global.worker.js': { + comment: 'ReferenceError: importScripts is not defined', + disabledTests: true, + }, + 'legacy-pre-activation-behavior.window.js': { + comment: 'Only relevant to browsers', + omittedTests: ['Use NONE phase during legacy-pre-activation behavior'], + }, + 'relatedTarget.window.js': { + comment: 'Test is DOM-specific', + omittedTests: true, + }, + 'scrolling/scroll_support.js': { + comment: 'Only used by HTML files', + omittedTests: true, + }, + 'scrolling/scrollend-user-scroll-common.js': { + comment: 'Only used by HTML files', + omittedTests: true, + }, +} satisfies TestRunnerConfig; From c377cf9dccdd54a3699d3bdf0750e976fa15433e Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 20:55:24 +0000 Subject: [PATCH 3/4] Add an Event and CustomEvent parity suite Add src/tests/events/event/, which runs the same test modules against the C++ and the TypeScript Event and CustomEvent, following the streams suites' conventions (src/tests/streams/AGENTS.md): event-cpp, event-cpp-pedantic (with pedantic_wpt) and event-ts. A fourth cell, event-cpp-legacy, guards the C++ behaviors without workers_api_getters_setters_on_prototype and set_tostring_tag. The suite covers construction, the attributes, cancelation, the propagation flags, initEvent(), detail, subclassing, pollution and the WebIDL shape, with tests ported from WPT's HTML-only Event-initEvent and CustomEvent. Each place the implementations differ is a which-impl branch pinning both sides, recorded in the suite's AGENTS.md ledger: 19 rows, 3 of them gaps between the TS classes and the rest of the runtime that integration will close. --- src/tests/events/event/AGENTS.md | 69 ++++ src/tests/events/event/BUILD.bazel | 37 +++ src/tests/events/event/api-surface.js | 304 ++++++++++++++++++ src/tests/events/event/cancelation.js | 65 ++++ src/tests/events/event/construction.js | 201 ++++++++++++ src/tests/events/event/custom-event.js | 57 ++++ .../events/event/event-cpp-legacy.wd-test | 22 ++ .../events/event/event-cpp-pedantic.wd-test | 23 ++ src/tests/events/event/event-cpp.wd-test | 29 ++ src/tests/events/event/event-modules.capnp | 21 ++ src/tests/events/event/event-ts.wd-test | 26 ++ src/tests/events/event/init-event.js | 152 +++++++++ src/tests/events/event/interop.js | 67 ++++ .../event/legacy-instance-properties.js | 106 ++++++ src/tests/events/event/legacy-main.js | 15 + .../events/event/legacy-to-string-tag.js | 30 ++ src/tests/events/event/main.js | 82 +++++ src/tests/events/event/pollution.js | 123 +++++++ src/tests/events/event/propagation-flags.js | 102 ++++++ src/tests/events/event/subclassing.js | 124 +++++++ src/tests/events/event/which-impl.js | 12 + 21 files changed, 1667 insertions(+) create mode 100644 src/tests/events/event/AGENTS.md create mode 100644 src/tests/events/event/BUILD.bazel create mode 100644 src/tests/events/event/api-surface.js create mode 100644 src/tests/events/event/cancelation.js create mode 100644 src/tests/events/event/construction.js create mode 100644 src/tests/events/event/custom-event.js create mode 100644 src/tests/events/event/event-cpp-legacy.wd-test create mode 100644 src/tests/events/event/event-cpp-pedantic.wd-test create mode 100644 src/tests/events/event/event-cpp.wd-test create mode 100644 src/tests/events/event/event-modules.capnp create mode 100644 src/tests/events/event/event-ts.wd-test create mode 100644 src/tests/events/event/init-event.js create mode 100644 src/tests/events/event/interop.js create mode 100644 src/tests/events/event/legacy-instance-properties.js create mode 100644 src/tests/events/event/legacy-main.js create mode 100644 src/tests/events/event/legacy-to-string-tag.js create mode 100644 src/tests/events/event/main.js create mode 100644 src/tests/events/event/pollution.js create mode 100644 src/tests/events/event/propagation-flags.js create mode 100644 src/tests/events/event/subclassing.js create mode 100644 src/tests/events/event/which-impl.js diff --git a/src/tests/events/event/AGENTS.md b/src/tests/events/event/AGENTS.md new file mode 100644 index 00000000000..fde03b71660 --- /dev/null +++ b/src/tests/events/event/AGENTS.md @@ -0,0 +1,69 @@ +# Event and CustomEvent + +`Event` and `CustomEvent` outside dispatch: construction, attributes, +cancelation, the propagation flags, `initEvent()`, `detail`, and their +WebIDL shape. **The tests are the normative artifact.** How dispatch reads +and resets an event's state is the event-target suite's. + +The C++ column is `event-cpp` (the date-gated flags on, `pedantic_wpt` off); +where `event-cpp-pedantic` differs, it is noted. + +## Divergence ledger (C++ vs TypeScript) + +| # | Area | C++ | TypeScript | Pinned in | +| --- | --- | --- | --- | --- | +| 1 | `isTrusted` placement | a prototype getter (`pedantic_wpt`: an own, enumerable, non-writable, configurable data property); either can be shadowed, by a subclass field for one | an own, enumerable, non-configurable accessor on every instance, with one shared getter ([LegacyUnforgeable]) | `isTrustedPlacement`, `isTrustedCannotBeRedefined` | +| 2 | `target`, `srcElement` before any dispatch | `undefined` | `null` | `defaultAttributes` | +| 3 | `currentTarget` before any dispatch | `undefined` (`pedantic_wpt`: `null`) | `null` | `defaultAttributes` | +| 4 | `timeStamp` | always `0` | `Date.now()` as the event is constructed; `0` outside a request | `timeStampIsDateNowAtConstruction` | +| 5 | `returnValue` setter | none: assignment throws in strict code | `false` cancels, as `preventDefault()` does | `returnValueSetterCancels` | +| 6 | `cancelBubble = false` | unsets the stop propagation flag | does nothing (spec) | `cancelBubbleFalseIsNoOp` | +| 7 | `cancelBubble` after `stopImmediatePropagation()` | `false` | `true`: it sets the stop propagation flag too (spec) | `stopImmediatePropagationSetsCancelBubble` | +| 8 | a setter called without an argument | `cancelBubble`'s takes it as `false` | TypeError (WebIDL) | `setterArity` | +| 9 | `initEvent()`, `initCustomEvent()` | absent | present, re-running initialization (ported from WPT's `Event-initEvent.html`) | `init-event.js` (6 tests) | +| 10 | `detail` when the dictionary has none | `undefined` | `null` (spec) | `detailDefaultDiverges` | +| 11 | function `length`, accessor `name` | `0`, `""` | the WebIDL values | `functionMetadata` | +| 12 | `NONE`, `CAPTURING_PHASE`, `AT_TARGET`, `BUBBLING_PHASE` | configurable | non-configurable (WebIDL) | `phaseConstants` | +| 13 | `Symbol.toStringTag` | writable | non-writable (WebIDL) | `toStringTagBranding` | +| 14 | `CustomEvent`'s interface object | does not inherit from `Event`, so it has none of `Event`'s statics | inherits from `Event` (WebIDL) | `interfaceObjects` | +| 15 | `cloudflare:internal-class` marker | on the prototypes | absent | `internalClassMarker` | +| 16 | the interface objects' `prototype` property | writable | non-writable (WebIDL) | `interfaceObjects` | +| 17† | `structuredClone(event)` | DataCloneError | an ordinary object holding the own `isTrusted` | `structuredCloneDiverges` | +| 18† | `util.inspect(event)` | every attribute | the own `isTrusted` only | `inspectDiverges` | +| 19† | the runtime's events (`MessageEvent`, `ErrorEvent`, `CloseEvent`, ...) | instances of the global `Event` | C++ events, not instances of the global `Event` | `runtimeEventsAreCppEvents` | + +Rows 11 and 12 are the C++ side without the experimental +`spec_compliant_property_attributes`, and row 16 without the dateless +`immutable_api_prototypes`; WPT's cells turn both on. + +Parity worth noting (pinned): argument counts and brand checks (the +TypeError types match; messages differ); the `type` and dictionary +conversions, including each member read once, in WebIDL order, with +ToBoolean; `preventDefault()` needing `cancelable`; `stopPropagation()` +setting `cancelBubble`; `detail` returned as given; subclasses (attribute +overrides, `Reflect.construct()`, `new.target`); an omitted dictionary +reading nothing from a polluted `Object.prototype`, and construction +untouched by patched built-ins. + +## Compatibility flags + +| Flag | Pinned in main cells | Unflagged side | +| --- | --- | --- | +| `workers_api_getters_setters_on_prototype` (2022-01-31) | C++ cells | `event-cpp-legacy`: the attributes are per-instance data properties (enumerable and configurable; only `cancelBubble` writable, through its setter), a subclass cannot override one, and `srcElement` reads the current target (`legacy-instance-properties.js`) | +| `set_tostring_tag` (2024-09-26) | C++ cells | `event-cpp-legacy`: no `Symbol.toStringTag` (`legacy-to-string-tag.js`) | +| `pedantic_wpt` (dateless) | `event-cpp-pedantic` | the main C++ cell (rows 1 and 3) | + +## Module map + +| Module | Coverage | +| --- | --- | +| `api-surface.js` | members' placement and attributes, `Symbol.toStringTag`, constants, interface objects, function metadata, brand checks, `new` required; rows 1, 11-16 | +| `construction.js` | argument conversion, default attributes, `composedPath()`, `timeStamp`; rows 2-4 | +| `cancelation.js` | `preventDefault()`, `defaultPrevented`, `returnValue`; row 5 | +| `propagation-flags.js` | `stopPropagation()`, `stopImmediatePropagation()`, `cancelBubble`; rows 6-8 | +| `init-event.js` | `initEvent()`, `initCustomEvent()`; row 9 | +| `custom-event.js` | `detail`; row 10 | +| `subclassing.js` | subclasses of both classes, dispatching them; row 1 | +| `pollution.js` | polluted `Object.prototype` and patched built-ins (parity) | +| `interop.js` | `structuredClone()`, `util.inspect()`, the runtime's events; rows 17-19 | +| `legacy-instance-properties.js`, `legacy-to-string-tag.js` | the legacy cell (see the flags table) | diff --git a/src/tests/events/event/BUILD.bazel b/src/tests/events/event/BUILD.bazel new file mode 100644 index 00000000000..cec8fbc7f2b --- /dev/null +++ b/src/tests/events/event/BUILD.bazel @@ -0,0 +1,37 @@ +load("//:build/wd_test.bzl", "wd_test") + +# The Event suite runs the same test modules under three configs: event-cpp +# against the C++ implementation, event-cpp-pedantic against it with +# pedantic_wpt, and event-ts against the TypeScript implementation. +# event-cpp-legacy guards the C++ behaviors without the date-gated flags. + +event_suite_srcs = glob(["*.js"]) + ["event-modules.capnp"] + +wd_test( + size = "large", + src = "event-cpp.wd-test", + data = event_suite_srcs, +) + +wd_test( + size = "large", + src = "event-cpp-pedantic.wd-test", + data = event_suite_srcs, +) + +wd_test( + size = "large", + src = "event-ts.wd-test", + args = ["--experimental"], + data = event_suite_srcs, +) + +# Legacy (pre-flag) regression guard, C++ implementation only. The +# @all-compat-flags variant is disabled: at the 2999-12-31 date the flags +# the cell deliberately omits would all turn on. +wd_test( + size = "large", + src = "event-cpp-legacy.wd-test", + data = event_suite_srcs, + generate_all_compat_flags_variant = False, +) diff --git a/src/tests/events/event/api-surface.js b/src/tests/events/event/api-surface.js new file mode 100644 index 00000000000..d08e7886c35 --- /dev/null +++ b/src/tests/events/event/api-surface.js @@ -0,0 +1,304 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// The WebIDL shape of Event and CustomEvent: where each member lives, its +// property attributes and function metadata, and the brand checks. +// +// Divergences (ledger in AGENTS.md): +// - #1: isTrusted is a prototype getter in C++ (an own data property under +// pedantic_wpt); TS defines it on each instance, as [LegacyUnforgeable] +// requires. +// - #11: C++ functions report length 0 and its accessors have empty names. +// - #12: C++ constants are configurable. +// - #13: C++ Symbol.toStringTag is writable. +// - #14: C++ CustomEvent's interface object does not inherit from Event's. +// - #15: C++ prototypes carry the cloudflare:internal-class marker. +// - #16: C++ interface objects' prototype property is writable. + +import { deepStrictEqual, strictEqual, throws } from 'node:assert'; +import { usingTsImpl, pedanticWpt } from 'which-impl'; + +// Event's attributes, less isTrusted, which #1 places differently. +const kAttributes = [ + 'type', + 'target', + 'srcElement', + 'currentTarget', + 'eventPhase', + 'cancelBubble', + 'bubbles', + 'cancelable', + 'returnValue', + 'defaultPrevented', + 'composed', + 'timeStamp', +]; + +// The operations both implementations have (initEvent() is init-event.js's). +const kOperations = [ + 'composedPath', + 'stopPropagation', + 'stopImmediatePropagation', + 'preventDefault', +]; + +const kConstants = [ + ['NONE', 0], + ['CAPTURING_PHASE', 1], + ['AT_TARGET', 2], + ['BUBBLING_PHASE', 3], +]; + +// #13. +export const toStringTagBranding = { + test() { + for (const [ctor, tag] of [ + [Event, 'Event'], + [CustomEvent, 'CustomEvent'], + ]) { + deepStrictEqual( + Object.getOwnPropertyDescriptor(ctor.prototype, Symbol.toStringTag), + { + value: tag, + writable: !usingTsImpl, + enumerable: false, + configurable: true, + } + ); + } + strictEqual( + Object.prototype.toString.call(new Event('x')), + '[object Event]' + ); + strictEqual( + Object.prototype.toString.call(new CustomEvent('x')), + '[object CustomEvent]' + ); + }, +}; + +export const attributesArePrototypeAccessors = { + test() { + for (const name of kAttributes) { + const { get, set, enumerable, configurable } = + Object.getOwnPropertyDescriptor(Event.prototype, name); + strictEqual(typeof get, 'function', name); + strictEqual(enumerable, true, name); + strictEqual(configurable, true, name); + // C++ has no returnValue setter (#5). + const settable = + name === 'cancelBubble' || (name === 'returnValue' && usingTsImpl); + strictEqual(typeof set, settable ? 'function' : 'undefined', name); + } + const detail = Object.getOwnPropertyDescriptor( + CustomEvent.prototype, + 'detail' + ); + strictEqual(typeof detail.get, 'function'); + strictEqual(detail.set, undefined); + strictEqual(detail.enumerable, true); + strictEqual(detail.configurable, true); + const event = new CustomEvent('x', { detail: 1 }); + for (const name of [...kAttributes, 'detail']) { + strictEqual(Object.hasOwn(event, name), false, name); + } + }, +}; + +export const operationsArePrototypeMethods = { + test() { + for (const name of kOperations) { + const { value, writable, enumerable, configurable } = + Object.getOwnPropertyDescriptor(Event.prototype, name); + strictEqual(typeof value, 'function', name); + strictEqual(value.name, name); + strictEqual(writable, true, name); + strictEqual(enumerable, true, name); + strictEqual(configurable, true, name); + } + }, +}; + +// #1. +export const isTrustedPlacement = { + test() { + const event = new Event('x'); + strictEqual(event.isTrusted, false); + if (usingTsImpl) { + strictEqual(Object.hasOwn(Event.prototype, 'isTrusted'), false); + const own = Object.getOwnPropertyDescriptor(event, 'isTrusted'); + strictEqual(own.get.name, 'get isTrusted'); + strictEqual(own.set, undefined); + strictEqual(own.enumerable, true); + strictEqual(own.configurable, false); + // One getter, shared by every instance, CustomEvents included. + strictEqual( + Object.getOwnPropertyDescriptor(new CustomEvent('y'), 'isTrusted').get, + own.get + ); + } else if (pedanticWpt) { + strictEqual(Object.hasOwn(Event.prototype, 'isTrusted'), false); + deepStrictEqual(Object.getOwnPropertyDescriptor(event, 'isTrusted'), { + value: false, + writable: false, + enumerable: true, + configurable: true, + }); + } else { + strictEqual(Object.hasOwn(event, 'isTrusted'), false); + const { get, set, enumerable, configurable } = + Object.getOwnPropertyDescriptor(Event.prototype, 'isTrusted'); + strictEqual(typeof get, 'function'); + strictEqual(set, undefined); + strictEqual(enumerable, true); + strictEqual(configurable, true); + } + // It is an instance's only own property, wherever it lives. + const ownKeys = usingTsImpl || pedanticWpt ? ['isTrusted'] : []; + deepStrictEqual(Reflect.ownKeys(event), ownKeys); + deepStrictEqual( + Reflect.ownKeys(new CustomEvent('x', { detail: 1 })), + ownKeys + ); + }, +}; + +// #12. +export const phaseConstants = { + test() { + for (const [name, value] of kConstants) { + for (const holder of [Event, Event.prototype]) { + deepStrictEqual( + Object.getOwnPropertyDescriptor(holder, name), + { + value, + writable: false, + enumerable: true, + configurable: !usingTsImpl, + }, + name + ); + } + strictEqual(Object.hasOwn(CustomEvent.prototype, name), false, name); + strictEqual(new CustomEvent('x')[name], value, name); + } + }, +}; + +// #14 and #16. WebIDL makes CustomEvent's interface object inherit from +// Event's, and every interface object's prototype property read-only. +export const interfaceObjects = { + test() { + strictEqual(Object.getPrototypeOf(Event.prototype), Object.prototype); + strictEqual(Object.getPrototypeOf(CustomEvent.prototype), Event.prototype); + strictEqual(Object.getPrototypeOf(Event), Function.prototype); + strictEqual( + Object.getPrototypeOf(CustomEvent), + usingTsImpl ? Event : Function.prototype + ); + // So only TS's CustomEvent reaches the constants. + strictEqual(Object.hasOwn(CustomEvent, 'AT_TARGET'), false); + strictEqual(CustomEvent.AT_TARGET, usingTsImpl ? 2 : undefined); + for (const ctor of [Event, CustomEvent]) { + deepStrictEqual(Object.getOwnPropertyDescriptor(ctor, 'prototype'), { + value: ctor.prototype, + writable: !usingTsImpl, + enumerable: false, + configurable: false, + }); + deepStrictEqual( + Object.getOwnPropertyDescriptor(ctor.prototype, 'constructor'), + { value: ctor, writable: true, enumerable: false, configurable: true } + ); + } + }, +}; + +// #11. A sample of the WebIDL values; WPT's idlharness is not run for dom. +export const functionMetadata = { + test() { + strictEqual(Event.name, 'Event'); + strictEqual(CustomEvent.name, 'CustomEvent'); + strictEqual(Event.length, usingTsImpl ? 1 : 0); + strictEqual(CustomEvent.length, usingTsImpl ? 1 : 0); + for (const name of kOperations) { + strictEqual(Event.prototype[name].length, 0, name); + } + const type = Object.getOwnPropertyDescriptor(Event.prototype, 'type'); + strictEqual(type.get.name, usingTsImpl ? 'get type' : ''); + strictEqual(type.get.length, 0); + const detail = Object.getOwnPropertyDescriptor( + CustomEvent.prototype, + 'detail' + ); + strictEqual(detail.get.name, usingTsImpl ? 'get detail' : ''); + const cancelBubble = Object.getOwnPropertyDescriptor( + Event.prototype, + 'cancelBubble' + ); + strictEqual(cancelBubble.get.name, usingTsImpl ? 'get cancelBubble' : ''); + strictEqual(cancelBubble.set.name, usingTsImpl ? 'set cancelBubble' : ''); + strictEqual(cancelBubble.set.length, usingTsImpl ? 1 : 0); + }, +}; + +// Every member rejects a receiver that is not an Event, an object that +// merely inherits from Event.prototype included, and detail rejects an +// Event that is not a CustomEvent. +export const brandChecks = { + test() { + const cancelBubble = Object.getOwnPropertyDescriptor( + Event.prototype, + 'cancelBubble' + ).set; + for (const receiver of [ + {}, + undefined, + null, + Object.create(Event.prototype), + ]) { + for (const name of kAttributes) { + const { get } = Object.getOwnPropertyDescriptor(Event.prototype, name); + throws(() => get.call(receiver), TypeError, name); + } + for (const name of kOperations) { + throws(() => Event.prototype[name].call(receiver), TypeError, name); + } + throws(() => cancelBubble.call(receiver, true), TypeError); + } + // isTrusted's getter, where it has one (#1). + const isTrusted = usingTsImpl + ? Object.getOwnPropertyDescriptor(new Event('x'), 'isTrusted').get + : Object.getOwnPropertyDescriptor(Event.prototype, 'isTrusted')?.get; + strictEqual(typeof isTrusted, pedanticWpt ? 'undefined' : 'function'); + if (isTrusted !== undefined) { + throws(() => isTrusted.call({}), TypeError); + } + const detail = Object.getOwnPropertyDescriptor( + CustomEvent.prototype, + 'detail' + ).get; + throws(() => detail.call(new Event('x')), TypeError); + // Event's members accept a CustomEvent. + const type = Object.getOwnPropertyDescriptor(Event.prototype, 'type').get; + strictEqual(type.call(new CustomEvent('y')), 'y'); + }, +}; + +export const constructorsRequireNew = { + test() { + throws(() => Event('x'), TypeError); + throws(() => CustomEvent('x'), TypeError); + }, +}; + +// #15. +export const internalClassMarker = { + test() { + const marker = Symbol.for('cloudflare:internal-class'); + for (const ctor of [Event, CustomEvent]) { + strictEqual(ctor.prototype[marker], usingTsImpl ? undefined : marker); + } + }, +}; diff --git a/src/tests/events/event/cancelation.js b/src/tests/events/event/cancelation.js new file mode 100644 index 00000000000..f6547e14a31 --- /dev/null +++ b/src/tests/events/event/cancelation.js @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Canceling an event outside dispatch: preventDefault(), defaultPrevented +// and returnValue. How dispatch reads the canceled flag is +// event-target/dispatch.js's. +// +// Divergence (ledger in AGENTS.md): +// - #5: C++ returnValue has no setter, so assigning it throws in strict code +// (module code always is); TS's setter cancels, as preventDefault() does. + +import { strictEqual, throws } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +export const preventDefaultNeedsCancelable = { + test() { + const cancelable = new Event('x', { cancelable: true }); + cancelable.preventDefault(); + strictEqual(cancelable.defaultPrevented, true); + strictEqual(cancelable.returnValue, false); + cancelable.preventDefault(); + strictEqual(cancelable.defaultPrevented, true); + + const plain = new Event('x'); + plain.preventDefault(); + strictEqual(plain.defaultPrevented, false); + strictEqual(plain.returnValue, true); + }, +}; + +// #5. +export const returnValueSetterCancels = { + test() { + const event = new Event('x', { cancelable: true }); + if (!usingTsImpl) { + throws(() => { + event.returnValue = false; + }, TypeError); + strictEqual(event.defaultPrevented, false); + return; + } + event.returnValue = true; + strictEqual(event.defaultPrevented, false); + event.returnValue = false; + strictEqual(event.defaultPrevented, true); + strictEqual(event.returnValue, false); + // Setting true does not uncancel. + event.returnValue = true; + strictEqual(event.defaultPrevented, true); + + // ToBoolean. + const coerced = new Event('x', { cancelable: true }); + coerced.returnValue = 'false'; + strictEqual(coerced.defaultPrevented, false); + coerced.returnValue = 0; + strictEqual(coerced.defaultPrevented, true); + + // Like preventDefault(), it needs a cancelable event. + const plain = new Event('x'); + plain.returnValue = false; + strictEqual(plain.defaultPrevented, false); + strictEqual(plain.returnValue, true); + }, +}; diff --git a/src/tests/events/event/construction.js b/src/tests/events/event/construction.js new file mode 100644 index 00000000000..55ebb1ee904 --- /dev/null +++ b/src/tests/events/event/construction.js @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Constructing Event and CustomEvent: argument conversion and a new +// event's attributes. CustomEvent's detail is custom-event.js's. +// +// Divergences (ledger in AGENTS.md): +// - #2: C++ target and srcElement are undefined before any dispatch. +// - #3: C++ currentTarget is undefined before any dispatch (null under +// pedantic_wpt). +// - #4: C++ timeStamp is always 0. + +import { + deepStrictEqual, + notStrictEqual, + ok, + strictEqual, + throws, +} from 'node:assert'; +import { usingTsImpl, pedanticWpt } from 'which-impl'; + +const kInitMembers = ['bubbles', 'cancelable', 'composed']; + +export const typeArgumentIsRequired = { + test() { + throws(() => new Event(), TypeError); + throws(() => new CustomEvent(), TypeError); + }, +}; + +// ToString, so a Symbol throws and a throwing toString() propagates. +export const typeIsConvertedToString = { + test() { + strictEqual(new Event(null).type, 'null'); + strictEqual(new Event(undefined).type, 'undefined'); + strictEqual(new Event(1).type, '1'); + strictEqual(new Event('').type, ''); + strictEqual(new CustomEvent({ toString: () => 'y' }).type, 'y'); + throws(() => new Event(Symbol('s')), TypeError); + throws(() => new CustomEvent(Symbol('s')), TypeError); + const error = new Error('toString'); + const type = { + toString() { + throw error; + }, + }; + throws( + () => new Event(type), + (e) => e === error + ); + }, +}; + +// WebIDL dictionary conversion: undefined and null are an empty dictionary, +// any object (a function or an array too) is read, and every other +// primitive is a TypeError. +export const initDictionaryConversion = { + test() { + const withBubbles = () => {}; + withBubbles.bubbles = true; + for (const ctor of [Event, CustomEvent]) { + for (const init of [undefined, null, {}, [], () => {}]) { + strictEqual(new ctor('x', init).bubbles, false); + } + strictEqual(new ctor('x', withBubbles).bubbles, true); + for (const init of [true, 1, 'bubbles', Symbol('s'), 1n]) { + throws(() => new ctor('x', init), TypeError, String(init)); + } + } + }, +}; + +// The type is converted first; then each member is read once, the +// inherited EventInit members before detail. +export const initMembersReadOnceInOrder = { + test() { + for (const [ctor, expected] of [ + [Event, ['type', ...kInitMembers]], + [CustomEvent, ['type', ...kInitMembers, 'detail']], + ]) { + const log = []; + const init = {}; + for (const name of [...kInitMembers, 'detail']) { + Object.defineProperty(init, name, { + get() { + log.push(name); + return true; + }, + }); + } + const type = { + toString() { + log.push('type'); + return 't'; + }, + }; + new ctor(type, init); + deepStrictEqual(log, expected, ctor.name); + } + }, +}; + +export const initMembersAreConvertedToBoolean = { + test() { + const cases = [ + [undefined, false], + [null, false], + [0, false], + ['', false], + [NaN, false], + [0n, false], + [1, true], + ['false', true], + [{}, true], + [Symbol('s'), true], + [1n, true], + ]; + for (const [value, expected] of cases) { + const init = { bubbles: value, cancelable: value, composed: value }; + for (const event of [new Event('x', init), new CustomEvent('x', init)]) { + for (const name of kInitMembers) { + strictEqual(event[name], expected, `${name}: ${String(value)}`); + } + } + } + }, +}; + +export const initMembersAreIndependent = { + test() { + for (const name of kInitMembers) { + const event = new Event('x', { [name]: true }); + for (const other of kInitMembers) { + strictEqual(event[other], other === name, `${name} sets ${other}`); + } + } + }, +}; + +// #2 and #3. +export const defaultAttributes = { + test() { + const event = new Event('x'); + strictEqual(event.type, 'x'); + strictEqual(event.bubbles, false); + strictEqual(event.cancelable, false); + strictEqual(event.composed, false); + strictEqual(event.isTrusted, false); + strictEqual(event.defaultPrevented, false); + strictEqual(event.returnValue, true); + strictEqual(event.cancelBubble, false); + strictEqual(event.eventPhase, Event.NONE); + deepStrictEqual(event.composedPath(), []); + strictEqual(event.target, usingTsImpl ? null : undefined); + strictEqual(event.srcElement, usingTsImpl ? null : undefined); + strictEqual( + event.currentTarget, + usingTsImpl || pedanticWpt ? null : undefined + ); + }, +}; + +export const composedPathIsFresh = { + test() { + const event = new Event('x'); + const path = event.composedPath(); + notStrictEqual(event.composedPath(), path); + path.push(1); + deepStrictEqual(event.composedPath(), []); + }, +}; + +// #4. TS reads Date.now() as the event is constructed. Outside any request +// (here, as this module is evaluated) that is 0. +const timeStampAtModuleEvaluation = new Event('x').timeStamp; + +export const timeStampIsDateNowAtConstruction = { + async test() { + strictEqual(timeStampAtModuleEvaluation, 0); + const before = Date.now(); + const event = new Event('x'); + const custom = new CustomEvent('y'); + const after = Date.now(); + await scheduler.wait(10); + const later = new Event('z'); + if (usingTsImpl) { + for (const { timeStamp } of [event, custom]) { + ok( + before <= timeStamp && timeStamp <= after, + `${timeStamp} is not in [${before}, ${after}]` + ); + } + ok(later.timeStamp > after, `${later.timeStamp} <= ${after}`); + } else { + for (const e of [event, custom, later]) { + strictEqual(e.timeStamp, 0); + } + } + }, +}; diff --git a/src/tests/events/event/custom-event.js b/src/tests/events/event/custom-event.js new file mode 100644 index 00000000000..166e3d9b0b1 --- /dev/null +++ b/src/tests/events/event/custom-event.js @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// CustomEvent's detail (ported from WPT's CustomEvent.html), and a +// CustomEvent as an Event. +// +// Divergence (ledger in AGENTS.md): +// - #10: C++ leaves detail undefined when the dictionary has none; per spec +// it defaults to null. + +import { ok, strictEqual } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +// #10. +export const detailDefaultDiverges = { + test() { + const expected = usingTsImpl ? null : undefined; + strictEqual(new CustomEvent('x').detail, expected); + strictEqual(new CustomEvent('x', null).detail, expected); + strictEqual(new CustomEvent('x', {}).detail, expected); + strictEqual(new CustomEvent('x', { detail: undefined }).detail, expected); + }, +}; + +export const detailIsReturnedAsGiven = { + test() { + const object = {}; + const event = new CustomEvent('x', { detail: object }); + strictEqual(event.detail, object); + strictEqual(event.detail, object); + for (const detail of [null, 0, '', false, 'detail', 1n, Symbol.iterator]) { + strictEqual(new CustomEvent('x', { detail }).detail, detail); + } + }, +}; + +export const customEventIsAnEvent = { + test() { + const event = new CustomEvent('x', { cancelable: true, detail: 1 }); + ok(event instanceof CustomEvent); + ok(event instanceof Event); + event.preventDefault(); + strictEqual(event.defaultPrevented, true); + + const target = new EventTarget(); + let seen; + target.addEventListener('y', (e) => { + seen = e.detail; + }); + strictEqual( + target.dispatchEvent(new CustomEvent('y', { detail: 2 })), + true + ); + strictEqual(seen, 2); + }, +}; diff --git a/src/tests/events/event/event-cpp-legacy.wd-test b/src/tests/events/event/event-cpp-legacy.wd-test new file mode 100644 index 00000000000..4394d6da77c --- /dev/null +++ b/src/tests/events/event/event-cpp-legacy.wd-test @@ -0,0 +1,22 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Fully-unflagged legacy cell (C++ only): without +# workers_api_getters_setters_on_prototype Event's attributes are +# per-instance data properties, and without set_tostring_tag neither class +# is branded. The BUILD rule disables the @all-compat-flags variant, which +# would turn the omitted flags back on. + +const unitTests :Workerd.Config = ( + services = [ + ( name = "events-event-cpp-legacy", + worker = ( + modules = [ + (name = "main", esModule = embed "legacy-main.js"), + (name = "legacy-instance-properties", esModule = embed "legacy-instance-properties.js"), + (name = "legacy-to-string-tag", esModule = embed "legacy-to-string-tag.js"), + ], + compatibilityFlags = ["nodejs_compat"], + ) + ), + ], +); diff --git a/src/tests/events/event/event-cpp-pedantic.wd-test b/src/tests/events/event/event-cpp-pedantic.wd-test new file mode 100644 index 00000000000..0dd52d6b50f --- /dev/null +++ b/src/tests/events/event/event-cpp-pedantic.wd-test @@ -0,0 +1,23 @@ +using Workerd = import "/workerd/workerd.capnp"; +using EventModules = import "event-modules.capnp"; + +# The main C++ cell plus the dateless opt-in pedantic_wpt flag, which makes +# isTrusted an own data property of every event and currentTarget null +# outside dispatch. The shared modules branch on which-impl's pedanticWpt +# where it shows. + +const unitTests :Workerd.Config = ( + services = [ + ( name = "events-event-cpp-pedantic", + worker = ( + modules = EventModules.modules, + compatibilityFlags = [ + "nodejs_compat", + "workers_api_getters_setters_on_prototype", + "set_tostring_tag", + "pedantic_wpt", + ], + ) + ), + ], +); diff --git a/src/tests/events/event/event-cpp.wd-test b/src/tests/events/event/event-cpp.wd-test new file mode 100644 index 00000000000..aaac71e14ee --- /dev/null +++ b/src/tests/events/event/event-cpp.wd-test @@ -0,0 +1,29 @@ +using Workerd = import "/workerd/workerd.capnp"; +using EventModules = import "event-modules.capnp"; + +# Runs the Event suite against the C++ implementation; event-ts.wd-test runs +# the identical modules against the TypeScript implementation. +# +# No compatibilityDate: the wd_test variants supply the date axis and the +# tests must hold at both extremes. Date-gated semantics are pinned by flag, +# and event-cpp-legacy.wd-test guards their unflagged sides: +# - nodejs_compat: node:assert and node:util for the tests themselves. +# - workers_api_getters_setters_on_prototype: attributes are prototype +# accessors (unflagged, they are per-instance data properties, and +# srcElement reads the current target). +# - set_tostring_tag: Symbol.toStringTag branding. + +const unitTests :Workerd.Config = ( + services = [ + ( name = "events-event-cpp", + worker = ( + modules = EventModules.modules, + compatibilityFlags = [ + "nodejs_compat", + "workers_api_getters_setters_on_prototype", + "set_tostring_tag", + ], + ) + ), + ], +); diff --git a/src/tests/events/event/event-modules.capnp b/src/tests/events/event/event-modules.capnp new file mode 100644 index 00000000000..59761c43724 --- /dev/null +++ b/src/tests/events/event/event-modules.capnp @@ -0,0 +1,21 @@ +# Single source of truth for the Event suite's module set; every cell +# references this constant so the implementations run exactly the same +# embedded code. embed paths resolve relative to THIS file. + +@0xf262a336ae1c6da3; + +using Workerd = import "/workerd/workerd.capnp"; + +const modules :List(Workerd.Worker.Module) = [ + (name = "main", esModule = embed "main.js"), + (name = "which-impl", esModule = embed "which-impl.js"), + (name = "api-surface", esModule = embed "api-surface.js"), + (name = "construction", esModule = embed "construction.js"), + (name = "cancelation", esModule = embed "cancelation.js"), + (name = "propagation-flags", esModule = embed "propagation-flags.js"), + (name = "init-event", esModule = embed "init-event.js"), + (name = "custom-event", esModule = embed "custom-event.js"), + (name = "subclassing", esModule = embed "subclassing.js"), + (name = "pollution", esModule = embed "pollution.js"), + (name = "interop", esModule = embed "interop.js"), +]; diff --git a/src/tests/events/event/event-ts.wd-test b/src/tests/events/event/event-ts.wd-test new file mode 100644 index 00000000000..cb3aaac16ed --- /dev/null +++ b/src/tests/events/event/event-ts.wd-test @@ -0,0 +1,26 @@ +using Workerd = import "/workerd/workerd.capnp"; +using EventModules = import "event-modules.capnp"; + +# Runs the Event suite against the TypeScript implementation, with +# byte-for-byte the same modules as event-cpp.wd-test. The flags pinned +# there are deliberately absent: the TypeScript implementation hard-codes +# the modern behaviors, and the `@` / `@all-compat-flags` variants verify +# that indifference with the flags all off and all on. + +const unitTests :Workerd.Config = ( + services = [ + ( name = "events-event-ts", + worker = ( + modules = EventModules.modules, + compatibilityFlags = [ + "nodejs_compat", + "typescript_implemented_events", + "experimental", + ], + ) + ), + ], + autogates = [ + "workerd-autogate-per-isolate-javascript-bootstrap", + ], +); diff --git a/src/tests/events/event/init-event.js b/src/tests/events/event/init-event.js new file mode 100644 index 00000000000..a1b9f9b1d17 --- /dev/null +++ b/src/tests/events/event/init-event.js @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// The legacy initEvent() and initCustomEvent(), which re-run an event's +// initialization (ported from WPT's Event-initEvent.html). +// +// Divergence (ledger in AGENTS.md): +// - #9: C++ has neither method, which each test pins before stopping. + +import { deepStrictEqual, strictEqual, throws } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +// The C++ side of #9: pins the absence, then stops the test. +function hasInitMethods() { + if (usingTsImpl) return true; + strictEqual('initEvent' in Event.prototype, false); + strictEqual('initCustomEvent' in CustomEvent.prototype, false); + return false; +} + +export const initMethodsAreOperations = { + test() { + if (!hasInitMethods()) return; + for (const [proto, name] of [ + [Event.prototype, 'initEvent'], + [CustomEvent.prototype, 'initCustomEvent'], + ]) { + const { value, writable, enumerable, configurable } = + Object.getOwnPropertyDescriptor(proto, name); + strictEqual(value.name, name); + strictEqual(value.length, 1); + strictEqual(writable, true); + strictEqual(enumerable, true); + strictEqual(configurable, true); + } + }, +}; + +// It sets type, bubbles and cancelable, unsets the canceled flag, and +// resets target; composed is kept. +export const initEventReinitializes = { + test() { + if (!hasInitMethods()) return; + const target = new EventTarget(); + const event = new Event('x', { composed: true, cancelable: true }); + event.preventDefault(); + target.dispatchEvent(event); + strictEqual(event.target, target); + + event.initEvent('y', true, true); + strictEqual(event.type, 'y'); + strictEqual(event.bubbles, true); + strictEqual(event.cancelable, true); + strictEqual(event.composed, true); + strictEqual(event.defaultPrevented, false); + strictEqual(event.target, null); + strictEqual(event.isTrusted, false); + + event.initEvent('z'); + strictEqual(event.type, 'z'); + strictEqual(event.bubbles, false); + strictEqual(event.cancelable, false); + strictEqual(event.composed, true); + }, +}; + +// It unsets the stop propagation flags, so a stopped event reaches +// listeners again. +export const initEventUnsetsStopFlags = { + test() { + if (!hasInitMethods()) return; + for (const stop of ['stopPropagation', 'stopImmediatePropagation']) { + const target = new EventTarget(); + let calls = 0; + target.addEventListener('x', () => calls++); + const event = new Event('x'); + event[stop](); + event.initEvent('x'); + strictEqual(event.cancelBubble, false, stop); + target.dispatchEvent(event); + strictEqual(calls, 1, stop); + } + }, +}; + +// It does nothing while the event is being dispatched, though its +// arguments are still converted. +export const initEventDuringDispatchIsNoOp = { + test() { + if (!hasInitMethods()) return; + const target = new EventTarget(); + let conversions = 0; + const type = { + toString() { + conversions++; + return 'y'; + }, + }; + let seen; + target.addEventListener('x', (e) => { + e.initEvent(type, true, true); + e.initCustomEvent(type, true, true, 2); + seen = [e.type, e.bubbles, e.cancelable, e.detail, e.target === target]; + }); + target.dispatchEvent(new CustomEvent('x', { detail: 1 })); + deepStrictEqual(seen, ['x', false, false, 1, true]); + strictEqual(conversions, 2); + }, +}; + +export const initEventArgumentConversion = { + test() { + if (!hasInitMethods()) return; + const event = new Event('x'); + throws(() => event.initEvent(), TypeError); + throws(() => event.initEvent(Symbol('s')), TypeError); + throws(() => Event.prototype.initEvent.call({}, 'x'), TypeError); + event.initEvent(undefined); + strictEqual(event.type, 'undefined'); + event.initEvent('y', 1, ''); + strictEqual(event.bubbles, true); + strictEqual(event.cancelable, false); + event.initEvent('z', undefined, {}); + strictEqual(event.bubbles, false); + strictEqual(event.cancelable, true); + }, +}; + +// initCustomEvent() is initEvent() plus detail, which defaults to null; +// initEvent() leaves detail alone. +export const initCustomEventSetsDetail = { + test() { + if (!hasInitMethods()) return; + const event = new CustomEvent('x', { detail: 1 }); + event.initCustomEvent('y'); + strictEqual(event.type, 'y'); + strictEqual(event.detail, null); + const detail = {}; + event.initCustomEvent('z', true, true, detail); + strictEqual(event.detail, detail); + strictEqual(event.bubbles, true); + strictEqual(event.cancelable, true); + event.initEvent('w'); + strictEqual(event.detail, detail); + throws(() => event.initCustomEvent(), TypeError); + throws( + () => CustomEvent.prototype.initCustomEvent.call(new Event('x'), 'y'), + TypeError + ); + }, +}; diff --git a/src/tests/events/event/interop.js b/src/tests/events/event/interop.js new file mode 100644 index 00000000000..7f22df68262 --- /dev/null +++ b/src/tests/events/event/interop.js @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Events beyond the events implementation: structured clone, inspection, +// and the runtime's own Event subclasses. Until the integration phases the +// TypeScript events are not platform objects the rest of the runtime +// recognizes, so each of these diverges (ledger rows marked †; how they +// cross targets is event-target/interop.js's). +// +// Divergences (ledger in AGENTS.md): +// - #17 †: structuredClone() of an event. +// - #18 †: util.inspect() of an event. +// - #19 †: the runtime's Event subclasses are C++ events. + +import { deepStrictEqual, ok, strictEqual, throws } from 'node:assert'; +import util from 'node:util'; +import { usingTsImpl } from 'which-impl'; + +// #17 †. TS clones the event as an ordinary object with its one own +// property, read through the getter. +export const structuredCloneDiverges = { + test() { + const event = new Event('x'); + if (usingTsImpl) { + const clone = structuredClone(event); + deepStrictEqual(clone, { isTrusted: false }); + strictEqual(Object.getPrototypeOf(clone), Object.prototype); + } else { + throws(() => structuredClone(event), { name: 'DataCloneError' }); + } + }, +}; + +// #18 †. TS shows only the own isTrusted accessor. +export const inspectDiverges = { + test() { + const inspected = util.inspect(new Event('x')); + if (usingTsImpl) { + strictEqual(inspected, 'Event { isTrusted: [Getter] }'); + } else { + ok(inspected.startsWith('Event {'), inspected); + ok(inspected.includes("type: 'x'"), inspected); + ok(inspected.includes('timeStamp: 0'), inspected); + } + }, +}; + +// #19 †. MessageEvent, ErrorEvent, CloseEvent and the rest inherit from the +// C++ Event, which the TS Event's members reject. +export const runtimeEventsAreCppEvents = { + test() { + const { get } = Object.getOwnPropertyDescriptor(Event.prototype, 'type'); + for (const event of [ + new MessageEvent('message'), + new ErrorEvent('error'), + new CloseEvent('close'), + ]) { + strictEqual(event instanceof Event, !usingTsImpl, event.type); + if (usingTsImpl) { + throws(() => get.call(event), TypeError, event.type); + } else { + strictEqual(get.call(event), event.type); + } + } + }, +}; diff --git a/src/tests/events/event/legacy-instance-properties.js b/src/tests/events/event/legacy-instance-properties.js new file mode 100644 index 00000000000..377e1783cce --- /dev/null +++ b/src/tests/events/event/legacy-instance-properties.js @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Without workers_api_getters_setters_on_prototype, Event's attributes are +// properties of each instance, which V8 reports as data properties. None is +// on the prototype, so a subclass cannot override one, yet every read still +// runs the getter and assigning cancelBubble still runs its setter. +// (CustomEvent's detail is a prototype accessor either way.) srcElement +// reads the current target, not the target: outside dispatch it is null +// where target is undefined. The event-target suite's legacy cell has it +// during and after a dispatch. + +import { deepStrictEqual, strictEqual } from 'node:assert'; + +// Sorted. +const kAttributes = [ + 'bubbles', + 'cancelBubble', + 'cancelable', + 'composed', + 'currentTarget', + 'defaultPrevented', + 'eventPhase', + 'isTrusted', + 'returnValue', + 'srcElement', + 'target', + 'timeStamp', + 'type', +]; + +export const legacyAttributesAreOwnDataProperties = { + test() { + for (const event of [new Event('x'), new CustomEvent('x')]) { + deepStrictEqual(Object.keys(event).sort(), kAttributes); + for (const name of kAttributes) { + strictEqual( + Object.getOwnPropertyDescriptor(Event.prototype, name), + undefined, + name + ); + const { get, set, writable, enumerable, configurable } = + Object.getOwnPropertyDescriptor(event, name); + deepStrictEqual( + { get, set, writable, enumerable, configurable }, + { + get: undefined, + set: undefined, + writable: name === 'cancelBubble', + enumerable: true, + configurable: true, + }, + name + ); + } + } + const detail = Object.getOwnPropertyDescriptor( + CustomEvent.prototype, + 'detail' + ); + strictEqual(typeof detail.get, 'function'); + }, +}; + +export const legacyAttributesFollowTheEventsState = { + test() { + const event = new Event('x', { cancelable: true }); + strictEqual(event.defaultPrevented, false); + event.preventDefault(); + strictEqual(event.defaultPrevented, true); + strictEqual(event.returnValue, false); + event.stopPropagation(); + strictEqual(event.cancelBubble, true); + }, +}; + +// The setter converts the value; the property keeps no value of its own. +export const legacyCancelBubbleAssignmentRunsTheSetter = { + test() { + const event = new Event('x'); + event.cancelBubble = 'yes'; + strictEqual(event.cancelBubble, true); + }, +}; + +// The instance's own property shadows the subclass's accessor. +export const legacySubclassCannotOverrideAnAttribute = { + test() { + class MyEvent extends Event { + get type() { + return 'overridden'; + } + } + strictEqual(new MyEvent('x').type, 'x'); + }, +}; + +export const legacyTargetsBeforeDispatch = { + test() { + const event = new Event('x'); + strictEqual(event.target, undefined); + strictEqual(event.currentTarget, undefined); + strictEqual(event.srcElement, null); + }, +}; diff --git a/src/tests/events/event/legacy-main.js b/src/tests/events/event/legacy-main.js new file mode 100644 index 00000000000..bfa9ec7f1d8 --- /dev/null +++ b/src/tests/events/event/legacy-main.js @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Entry point for the legacy (unflagged) cell. + +export { + legacyAttributesAreOwnDataProperties, + legacyAttributesFollowTheEventsState, + legacyCancelBubbleAssignmentRunsTheSetter, + legacySubclassCannotOverrideAnAttribute, + legacyTargetsBeforeDispatch, +} from 'legacy-instance-properties'; + +export { legacyNoToStringTag } from 'legacy-to-string-tag'; diff --git a/src/tests/events/event/legacy-to-string-tag.js b/src/tests/events/event/legacy-to-string-tag.js new file mode 100644 index 00000000000..751df4b4c8b --- /dev/null +++ b/src/tests/events/event/legacy-to-string-tag.js @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Without set_tostring_tag, Event and CustomEvent have no +// Symbol.toStringTag, so Object.prototype.toString() reports their +// instances as plain objects. + +import { strictEqual } from 'node:assert'; + +export const legacyNoToStringTag = { + test() { + for (const [ctor, event] of [ + [Event, new Event('x')], + [CustomEvent, new CustomEvent('x')], + ]) { + strictEqual( + Object.getOwnPropertyDescriptor(ctor.prototype, Symbol.toStringTag), + undefined, + ctor.name + ); + strictEqual(event[Symbol.toStringTag], undefined, ctor.name); + strictEqual( + Object.prototype.toString.call(event), + '[object Object]', + ctor.name + ); + } + }, +}; diff --git a/src/tests/events/event/main.js b/src/tests/events/event/main.js new file mode 100644 index 00000000000..ba3a7ed189a --- /dev/null +++ b/src/tests/events/event/main.js @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Entry point for the Event suite. Re-exports are explicit (not +// `export *`) so a name collision between modules is a load-time +// SyntaxError instead of a silently dropped test. + +export { + toStringTagBranding, + attributesArePrototypeAccessors, + operationsArePrototypeMethods, + isTrustedPlacement, + phaseConstants, + interfaceObjects, + functionMetadata, + brandChecks, + constructorsRequireNew, + internalClassMarker, +} from 'api-surface'; + +export { + typeArgumentIsRequired, + typeIsConvertedToString, + initDictionaryConversion, + initMembersReadOnceInOrder, + initMembersAreConvertedToBoolean, + initMembersAreIndependent, + defaultAttributes, + composedPathIsFresh, + timeStampIsDateNowAtConstruction, +} from 'construction'; + +export { + preventDefaultNeedsCancelable, + returnValueSetterCancels, +} from 'cancelation'; + +export { + stopPropagationSetsCancelBubble, + cancelBubbleSetter, + cancelBubbleFalseIsNoOp, + stopImmediatePropagationSetsCancelBubble, + setterArity, +} from 'propagation-flags'; + +export { + initMethodsAreOperations, + initEventReinitializes, + initEventUnsetsStopFlags, + initEventDuringDispatchIsNoOp, + initEventArgumentConversion, + initCustomEventSetsDetail, +} from 'init-event'; + +export { + detailDefaultDiverges, + detailIsReturnedAsGiven, + customEventIsAnEvent, +} from 'custom-event'; + +export { + subclassConstruction, + subclassOverridesAnAttribute, + isTrustedCannotBeRedefined, + reflectConstructUsesNewTarget, + customEventSubclass, + subclassInstancesDispatch, +} from 'subclassing'; + +export { + omittedInitReadsNothing, + inheritedInitMembersAreRead, + descriptorPollutionDoesNotReachIsTrusted, + patchedBuiltinsAreNotUsed, +} from 'pollution'; + +export { + structuredCloneDiverges, + inspectDiverges, + runtimeEventsAreCppEvents, +} from 'interop'; diff --git a/src/tests/events/event/pollution.js b/src/tests/events/event/pollution.js new file mode 100644 index 00000000000..f02d16af0f1 --- /dev/null +++ b/src/tests/events/event/pollution.js @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Event and CustomEvent under a polluted environment. WebIDL reads a user's +// dictionary with [[Get]], so inherited members count, but an omitted +// dictionary reads nothing; and construction never goes through patchable +// built-ins. + +import { notStrictEqual, strictEqual } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +// Defines the entries on Object.prototype; returns a function removing them. +// The descriptors are null-prototype: once `get` is polluted, an ordinary +// one would inherit it. +function pollute(entries) { + for (const key of Object.keys(entries)) { + Object.defineProperty(Object.prototype, key, { + __proto__: null, + value: entries[key], + configurable: true, + writable: true, + }); + } + return () => { + for (const key of Object.keys(entries)) { + Reflect.deleteProperty(Object.prototype, key); + } + }; +} + +function withPollutedInitMembers(fn) { + const restore = pollute({ + bubbles: true, + cancelable: true, + composed: true, + detail: 'polluted', + }); + try { + fn(); + } finally { + restore(); + } +} + +export const omittedInitReadsNothing = { + test() { + withPollutedInitMembers(() => { + for (const event of [new Event('x'), new CustomEvent('x')]) { + strictEqual(event.bubbles, false); + strictEqual(event.cancelable, false); + strictEqual(event.composed, false); + } + // The default diverges as ever (#10). + strictEqual(new CustomEvent('x').detail, usingTsImpl ? null : undefined); + }); + }, +}; + +export const inheritedInitMembersAreRead = { + test() { + withPollutedInitMembers(() => { + for (const event of [new Event('x', {}), new CustomEvent('x', {})]) { + strictEqual(event.bubbles, true); + strictEqual(event.cancelable, true); + strictEqual(event.composed, true); + } + strictEqual(new CustomEvent('x', {}).detail, 'polluted'); + }); + }, +}; + +// A descriptor that inherited these would mix accessor and data fields. +export const descriptorPollutionDoesNotReachIsTrusted = { + test() { + const restore = pollute({ + get: () => true, + set: () => {}, + value: true, + writable: true, + configurable: true, + }); + let event; + try { + event = new Event('x'); + } finally { + restore(); + } + strictEqual(event.isTrusted, false); + if (usingTsImpl) { + const { get, configurable } = Object.getOwnPropertyDescriptor( + event, + 'isTrusted' + ); + strictEqual(get.name, 'get isTrusted'); + strictEqual(configurable, false); + } + }, +}; + +export const patchedBuiltinsAreNotUsed = { + test() { + const { now } = Date; + const { defineProperty } = Object; + Date.now = () => 42; + Object.defineProperty = () => { + throw new Error('patched Object.defineProperty'); + }; + let event; + let custom; + try { + event = new Event('x', { cancelable: true }); + custom = new CustomEvent('y', { detail: 1 }); + } finally { + Date.now = now; + Object.defineProperty = defineProperty; + } + notStrictEqual(event.timeStamp, 42); + strictEqual(event.isTrusted, false); + strictEqual(event.cancelable, true); + strictEqual(custom.detail, 1); + }, +}; diff --git a/src/tests/events/event/propagation-flags.js b/src/tests/events/event/propagation-flags.js new file mode 100644 index 00000000000..ca8ee1e8edd --- /dev/null +++ b/src/tests/events/event/propagation-flags.js @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// The stop propagation flags outside dispatch, through stopPropagation(), +// stopImmediatePropagation() and cancelBubble. How dispatch consults and +// clears them is event-target/dispatch.js's. +// +// Divergences (ledger in AGENTS.md): +// - #6: C++'s cancelBubble setter clears the flag on false; per spec, +// setting false does nothing. +// - #7: C++'s stopImmediatePropagation() leaves cancelBubble false; per +// spec, it sets the stop propagation flag too. +// - #8: C++'s cancelBubble setter takes a missing argument as false; WebIDL +// setters throw a TypeError. + +import { strictEqual, throws } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +export const stopPropagationSetsCancelBubble = { + test() { + const event = new Event('x'); + strictEqual(event.cancelBubble, false); + event.stopPropagation(); + strictEqual(event.cancelBubble, true); + event.stopPropagation(); + strictEqual(event.cancelBubble, true); + }, +}; + +// ToBoolean, and true stops propagation. +export const cancelBubbleSetter = { + test() { + const cases = [ + [true, true], + ['yes', true], + [1, true], + [{}, true], + [false, false], + [0, false], + ['', false], + [null, false], + [undefined, false], + ]; + for (const [value, expected] of cases) { + const event = new Event('x'); + event.cancelBubble = value; + strictEqual(event.cancelBubble, expected, String(value)); + } + }, +}; + +// #6. +export const cancelBubbleFalseIsNoOp = { + test() { + const stopped = new Event('x'); + stopped.stopPropagation(); + stopped.cancelBubble = false; + strictEqual(stopped.cancelBubble, usingTsImpl); + + const set = new Event('x'); + set.cancelBubble = true; + set.cancelBubble = false; + strictEqual(set.cancelBubble, usingTsImpl); + }, +}; + +// #7. +export const stopImmediatePropagationSetsCancelBubble = { + test() { + const event = new Event('x'); + event.stopImmediatePropagation(); + strictEqual(event.cancelBubble, usingTsImpl); + }, +}; + +// #8. Only a direct call can omit a setter's argument. +export const setterArity = { + test() { + const cancelBubble = Object.getOwnPropertyDescriptor( + Event.prototype, + 'cancelBubble' + ).set; + const event = new Event('x'); + event.stopPropagation(); + if (!usingTsImpl) { + cancelBubble.call(event); + strictEqual(event.cancelBubble, false); + return; + } + throws(() => cancelBubble.call(event), TypeError); + strictEqual(event.cancelBubble, true); + // returnValue's too; C++ has no returnValue setter (#5). + const returnValue = Object.getOwnPropertyDescriptor( + Event.prototype, + 'returnValue' + ).set; + const cancelable = new Event('x', { cancelable: true }); + throws(() => returnValue.call(cancelable), TypeError); + strictEqual(cancelable.defaultPrevented, false); + }, +}; diff --git a/src/tests/events/event/subclassing.js b/src/tests/events/event/subclassing.js new file mode 100644 index 00000000000..11c2022a166 --- /dev/null +++ b/src/tests/events/event/subclassing.js @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Subclassing Event and CustomEvent. +// +// Divergence (ledger in AGENTS.md): +// - #1: TS's isTrusted is an own non-configurable property of every +// instance ([LegacyUnforgeable]), so nothing can redefine it; C++'s (a +// prototype getter, or a configurable own data property under +// pedantic_wpt) can be shadowed, by a class field for one. + +import { ok, strictEqual, throws } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +export const subclassConstruction = { + test() { + class MyEvent extends Event { + constructor(type) { + super(type, { bubbles: true }); + this.extra = 1; + } + } + const event = new MyEvent('x'); + ok(event instanceof MyEvent); + ok(event instanceof Event); + strictEqual(event.type, 'x'); + strictEqual(event.bubbles, true); + strictEqual(event.isTrusted, false); + strictEqual(event.extra, 1); + strictEqual(Object.prototype.toString.call(event), '[object Event]'); + }, +}; + +export const subclassOverridesAnAttribute = { + test() { + class MyEvent extends Event { + get bubbles() { + return 'overridden'; + } + } + const event = new MyEvent('x'); + strictEqual(event.bubbles, 'overridden'); + const { get } = Object.getOwnPropertyDescriptor(Event.prototype, 'bubbles'); + strictEqual(get.call(event), false); + }, +}; + +// #1. +export const isTrustedCannotBeRedefined = { + test() { + class Forged extends Event { + isTrusted = true; + } + const event = new Event('x'); + if (usingTsImpl) { + throws(() => new Forged('x'), TypeError); + throws( + () => Object.defineProperty(event, 'isTrusted', { value: true }), + TypeError + ); + strictEqual(event.isTrusted, false); + } else { + strictEqual(new Forged('x').isTrusted, true); + Object.defineProperty(event, 'isTrusted', { value: true }); + strictEqual(event.isTrusted, true); + } + }, +}; + +export const reflectConstructUsesNewTarget = { + test() { + function Derived() {} + Derived.prototype = Object.create(Event.prototype); + const event = Reflect.construct( + Event, + ['x', { cancelable: true }], + Derived + ); + strictEqual(Object.getPrototypeOf(event), Derived.prototype); + strictEqual(event.type, 'x'); + event.preventDefault(); + strictEqual(event.defaultPrevented, true); + + // The instance is a CustomEvent though its prototype is not + // CustomEvent's. + const custom = Reflect.construct( + CustomEvent, + ['y', { detail: 1 }], + Derived + ); + strictEqual(Object.getPrototypeOf(custom), Derived.prototype); + const { get } = Object.getOwnPropertyDescriptor( + CustomEvent.prototype, + 'detail' + ); + strictEqual(get.call(custom), 1); + }, +}; + +export const customEventSubclass = { + test() { + class MyCustomEvent extends CustomEvent {} + const event = new MyCustomEvent('x', { detail: 3 }); + strictEqual(event.detail, 3); + ok(event instanceof CustomEvent); + ok(event instanceof Event); + strictEqual(Object.prototype.toString.call(event), '[object CustomEvent]'); + }, +}; + +export const subclassInstancesDispatch = { + test() { + class MyEvent extends Event {} + const target = new EventTarget(); + const event = new MyEvent('x'); + let seen; + target.addEventListener('x', (e) => { + seen = e; + }); + strictEqual(target.dispatchEvent(event), true); + strictEqual(seen, event); + }, +}; diff --git a/src/tests/events/event/which-impl.js b/src/tests/events/event/which-impl.js new file mode 100644 index 00000000000..f30947468b5 --- /dev/null +++ b/src/tests/events/event/which-impl.js @@ -0,0 +1,12 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Distinguishes which events implementation this worker is running, and +// whether the C++ one runs under pedantic_wpt, so tests can pin each side +// of a deliberate divergence exactly. + +const flags = globalThis.Cloudflare.compatibilityFlags; + +export const usingTsImpl = flags['typescript_implemented_events']; +export const pedanticWpt = flags['pedantic_wpt']; From c1923e8c46fdaa32695918e815c0b645085fe636 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 16 Oct 2026 20:55:25 +0000 Subject: [PATCH 4/4] Add an EventTarget parity suite Add src/tests/events/event-target/, the EventTarget counterpart of the Event suite: event-target-cpp, event-target-cpp-pedantic, event-target-ts and event-target-cpp-legacy. The legacy cell guards the C++ behaviors without dont_substitute_null_on_type_error, set_event_target_this, workers_api_getters_setters_on_prototype and set_tostring_tag. legacy-dispatch-exceptions-test.js already guards the side without spec_compliant_dispatch_exceptions. The suite covers adding and removing listeners, the options, dispatch, handleEvent, the {signal} option, listener exceptions (inside and outside a request), on properties, subclassing, pollution, garbage collection and the WebIDL shape. Its AGENTS.md ledger has 23 rows, 4 of them interop gaps: events cannot cross the implementations, AbortSignal is not an instance of the TS EventTarget, node:events' helpers reject it, and structuredClone() differs. src/tests/events/AGENTS.md describes what the two suites share. --- src/tests/events/AGENTS.md | 62 +++ src/tests/events/event-target/AGENTS.md | 90 +++++ src/tests/events/event-target/BUILD.bazel | 38 ++ src/tests/events/event-target/api-surface.js | 138 +++++++ src/tests/events/event-target/dispatch.js | 359 +++++++++++++++++ .../event-target-cpp-legacy.wd-test | 28 ++ .../event-target-cpp-pedantic.wd-test | 28 ++ .../event-target/event-target-cpp.wd-test | 44 +++ .../event-target/event-target-modules.capnp | 25 ++ .../event-target/event-target-ts.wd-test | 30 ++ src/tests/events/event-target/exceptions.js | 265 +++++++++++++ src/tests/events/event-target/gc.js | 100 +++++ src/tests/events/event-target/handle-event.js | 190 +++++++++ src/tests/events/event-target/helpers.js | 38 ++ src/tests/events/event-target/interop.js | 182 +++++++++ .../legacy-dispatch-attributes.js | 29 ++ .../legacy-listener-conversion.js | 22 ++ .../event-target/legacy-listener-this.js | 27 ++ src/tests/events/event-target/legacy-main.js | 10 + .../event-target/legacy-to-string-tag.js | 23 ++ src/tests/events/event-target/listeners.js | 239 +++++++++++ src/tests/events/event-target/main.js | 138 +++++++ src/tests/events/event-target/options.js | 374 ++++++++++++++++++ src/tests/events/event-target/pollution.js | 203 ++++++++++ src/tests/events/event-target/reflection.js | 58 +++ src/tests/events/event-target/signal.js | 262 ++++++++++++ src/tests/events/event-target/subclassing.js | 101 +++++ src/tests/events/event-target/which-impl.js | 16 + 28 files changed, 3119 insertions(+) create mode 100644 src/tests/events/AGENTS.md create mode 100644 src/tests/events/event-target/AGENTS.md create mode 100644 src/tests/events/event-target/BUILD.bazel create mode 100644 src/tests/events/event-target/api-surface.js create mode 100644 src/tests/events/event-target/dispatch.js create mode 100644 src/tests/events/event-target/event-target-cpp-legacy.wd-test create mode 100644 src/tests/events/event-target/event-target-cpp-pedantic.wd-test create mode 100644 src/tests/events/event-target/event-target-cpp.wd-test create mode 100644 src/tests/events/event-target/event-target-modules.capnp create mode 100644 src/tests/events/event-target/event-target-ts.wd-test create mode 100644 src/tests/events/event-target/exceptions.js create mode 100644 src/tests/events/event-target/gc.js create mode 100644 src/tests/events/event-target/handle-event.js create mode 100644 src/tests/events/event-target/helpers.js create mode 100644 src/tests/events/event-target/interop.js create mode 100644 src/tests/events/event-target/legacy-dispatch-attributes.js create mode 100644 src/tests/events/event-target/legacy-listener-conversion.js create mode 100644 src/tests/events/event-target/legacy-listener-this.js create mode 100644 src/tests/events/event-target/legacy-main.js create mode 100644 src/tests/events/event-target/legacy-to-string-tag.js create mode 100644 src/tests/events/event-target/listeners.js create mode 100644 src/tests/events/event-target/main.js create mode 100644 src/tests/events/event-target/options.js create mode 100644 src/tests/events/event-target/pollution.js create mode 100644 src/tests/events/event-target/reflection.js create mode 100644 src/tests/events/event-target/signal.js create mode 100644 src/tests/events/event-target/subclassing.js create mode 100644 src/tests/events/event-target/which-impl.js diff --git a/src/tests/events/AGENTS.md b/src/tests/events/AGENTS.md new file mode 100644 index 00000000000..bfcfe5df122 --- /dev/null +++ b/src/tests/events/AGENTS.md @@ -0,0 +1,62 @@ +# src/tests/events/ + +Parity suites for `Event`, `CustomEvent` and `EventTarget`: every test runs +against both implementations, the C++ one (`src/workerd/api/basics.{h,c++}`) +and the TypeScript one (`src/per_isolate/events/`, behind +`typescript_implemented_events`). The suites follow +`src/tests/streams/AGENTS.md` (one behavior per file, explicit re-exports in +`main.js`, the module list defined once in `-modules.capnp`, flags +rather than dates, legacy cells, `which-impl` branches for every +divergence). This file records only what differs. + +| Suite | Covers | +| --- | --- | +| `event/` | `Event` and `CustomEvent` outside dispatch | +| `event-target/` | listeners, options, dispatch, listener exceptions | + +Each suite's `AGENTS.md` holds its divergence ledger, its flags table and its +module map. Rows marked † are gaps between the TypeScript classes and the +rest of the runtime (whose events and targets are still C++), pinned so +that integration flips them. + +## Cells + +| Cell | Flags | +| --- | --- | +| `-cpp` | `nodejs_compat` plus every date-gated flag the suite observes (each config lists them, one reason per flag) | +| `-cpp-pedantic` | the same plus `pedantic_wpt`, which changes `isTrusted` and `currentTarget` | +| `-ts` | `nodejs_compat`, `typescript_implemented_events`, `experimental`, the `per-isolate-javascript-bootstrap` autogate; `args = ["--experimental"]` | +| `-cpp-legacy` | `nodejs_compat` only, running `legacy-*.js` through `legacy-main.js`; no `@all-compat-flags` variant | + +The TS cell pins none of the C++ flags: the TypeScript implementation +consults none of them, and its `@` and `@all-compat-flags` variants assert +that. (The one exception is indirect: the TS `{signal}` brand check reads +the C++ `AbortSignal`, whose layout follows +`workers_api_getters_setters_on_prototype`; event-target ledger #14.) + +`which-impl.js` exports `usingTsImpl` (`typescript_implemented_events`) and +`pedanticWpt` (`pedantic_wpt`); the event-target suite's also exports +`gettersSettersOnPrototype`. + +## Running + +``` +bazel test //src/tests/events/... +``` + +`@gc-stress` variants are `off-by-default`; run them by name with +`--test_tag_filters= --build_tag_filters=`. + +## Events tests that live elsewhere + +- `src/wpt/dom/events-test.ts` and `events-test-ts.ts`: WPT `dom/events` + against the C++ and the TypeScript implementation. +- `src/workerd/api/tests/events-test.js`: the C++ implementation alone, + under the modern flags: the runtime's Event subclasses + (`ExtendableEvent`, `MessageEvent`, `ErrorEvent`, `CloseEvent`), the + global as a target, and listener exceptions from a WebSocket. Its basic + `Event` and `EventTarget` cases overlap these suites. +- `src/workerd/api/tests/legacy-dispatch-exceptions-test.js`: dispatch + without `spec_compliant_dispatch_exceptions`. +- `src/workerd/api/tests/abortsignal-test.js`: `AbortSignal`, which stays C++ + until its own phase. diff --git a/src/tests/events/event-target/AGENTS.md b/src/tests/events/event-target/AGENTS.md new file mode 100644 index 00000000000..9d93c16b4a7 --- /dev/null +++ b/src/tests/events/event-target/AGENTS.md @@ -0,0 +1,90 @@ +# EventTarget + +`EventTarget`: adding and removing listeners, the options, dispatch, how a +listener is called, the `{signal}` option, listener exceptions, and the +WebIDL shape. **The tests are the normative artifact.** An event's own +state outside dispatch is the event suite's. + +The C++ column is `event-target-cpp` (the date-gated flags on, +`pedantic_wpt` off); where `event-target-cpp-pedantic` differs, it is noted. +A target has no parent, so a dispatch's path is the target alone, and both +of the spec's passes (capture listeners, then the rest) run at it. + +## Divergence ledger (C++ vs TypeScript) + +| # | Area | C++ | TypeScript | Pinned in | +| --- | --- | --- | --- | --- | +| 1 | `currentTarget` once dispatch ends | the target (`pedantic_wpt`: `null`) | `null` | `attributesAfterDispatch` | +| 2 | `dispatchEvent()` after `preventDefault()` on an event that is not cancelable | `false`, though `defaultPrevented` stays `false` | `true` (spec) | `dispatchResult`, `listenerReturningTrue` | +| 3 | an event canceled before dispatch, or in an earlier one | the flag is unset as dispatch starts: listeners see `defaultPrevented` `false`, the result is `true` | stays canceled: only `initEvent()` unsets the flag (spec) | `canceledFlagSurvivesDispatch` | +| 4 | the stop propagation flags across dispatches | stop propagation is never consulted, and neither flag is unset: `stopPropagation()` before dispatch stops nothing and `cancelBubble` stays `true`; an event stopped immediately never reaches a listener again | a stopped event reaches no listener, and both flags are unset as dispatch ends (spec) | `stopFlagsSetBeforeDispatch`, `stopFlagsSetDuringDispatch` | +| 5 | capture (`useCapture`, `{capture}`) | TypeError | part of a listener's identity; capture listeners run first, in a pass of their own (spec) | `captureOption`, `captureListenersRunFirst`, `captureIsPartOfIdentity`, `stopPropagationInCaptureSkipsBubbling`, `listenerAddedDuringCaptureRunsInBubbling` | +| 6 | `{passive: true}` | TypeError | `preventDefault()` does nothing in the listener; not part of identity (spec) | `passiveListenerCannotCancel`, `passiveIsNotPartOfIdentity`, `passiveFlagResetsAfterAThrow` | +| 7 | the options' read order | `capture`, `passive`, `once`, `signal` | `capture`, `once`, `passive`, `signal` (WebIDL: the inherited member, then the rest in lexicographic order) | `optionsReadOrder` | +| 8 | `removeEventListener()` with capture | TypeError, even for a null listener | removes the matching capture listener, if any | `removeWithCapture` | +| 9 | an object listener without a callable `handleEvent` (`{}`, `[]`) | TypeError from `addEventListener()` | accepted (a WebIDL callback interface); each call reports a TypeError, and a `handleEvent` defined later is used | `nonCallableObjectListener`, `nonCallableHandleEventIsReported` | +| 10 | when `handleEvent` is looked up | once, as the listener is added: a throwing getter throws from `addEventListener()`, and a replacement is never seen | at every call: a throwing getter's exception is reported like a listener's (WebIDL) | `handleEventLookup`, `handleEventReplacedAfterAdd`, `throwingHandleEventGetter` | +| 11 | a listener's result | `true` calls `preventDefault()`; any other non-boolean result logs a warning, once per target (console only, so unpinned) | ignored (spec) | `listenerReturningTrue` | +| 12 | a callable `on` property (own, or a subclass's method) | a listener for ``, called before the target's listeners with the global as `this` | not consulted: `EventTarget` declares no event handler attributes (spec) | `onTypeProperty`, `onTypeMethod` | +| 13 | a listener removed and re-added during the dispatch | runs in that dispatch | does not: the dispatch runs over a clone of the list, and the re-added listener is a new registration (spec) | `reAddedListenerDuringDispatch` | +| 14 | a forged `{signal: {aborted: }}` | TypeError | without `workers_api_getters_setters_on_prototype` the C++ `AbortSignal` has no `aborted` getter to brand-check with, so the forgery passes: `utils.addAbortAlgorithm()` rejects it once a listener is to be added, but a null listener, or a forgery claiming to have aborted, adds nothing silently; TypeError with the layout | `forgedSignal` | +| 15 | method `length` | `0` | `2`, `2`, `1` (WebIDL) | `functionMetadata` | +| 16 | `Symbol.toStringTag` | writable | non-writable (WebIDL) | `toStringTagBranding` | +| 17 | the interface object's `prototype` property | writable | non-writable (WebIDL) | `interfaceObject` | +| 18 | `cloudflare:internal-class` marker | on the prototype | absent | `internalClassMarker` | +| 19 | a listener exception outside any request (module evaluation) | logged only | reported as within one: the global `error` event fires, then the exception is logged unless a listener cancels it (HTML's "report an exception") | `exceptionsOutsideARequest` | +| 20† | events and targets of the other implementation | (one implementation) | a C++ event (the runtime's, such as `MessageEvent`) cannot be dispatched at a TS target, nor a TS event at a C++ one (the global, `AbortSignal`): TypeError. Neither implementation's methods accept the other's targets | `eventsAcrossImplementations`, `methodsAcrossImplementations`, `theGlobal` | +| 21† | `AbortSignal instanceof EventTarget` | `true` | `false`: `AbortSignal` extends the C++ class | `abortSignalIsNotAnEventTarget` | +| 22† | `node:events`' `setMaxListeners()` and `getEventListeners()` given an `AbortSignal` | accepted | `ERR_INVALID_ARG_TYPE`: they check `instanceof EventTarget` | `nodeEventsHelpers` | +| 23† | `structuredClone(target)` | DataCloneError | an ordinary object of the target's own enumerable properties | `structuredCloneDiverges` | + +Parity worth noting (pinned): argument counts and brand checks (the +TypeError types match; messages differ), including `addEventListener(type)` +without a listener; detached methods throwing, while the global's own +methods work called bare; arguments converted in order, before any step +runs, for a null listener too; duplicates ignored, removal by `(type, +callback, capture)` and registration order; a function listener's `this` +the target and an object listener's the object; `once` removing the +listener before calling it; a listener added during a dispatch waiting for +the next one, and one removed during it not running; `InvalidStateError` for +an event already being dispatched; nested dispatch; `{signal}` removing the +listener through an abort algorithm (before the signal's `abort` listeners, +never for a synthetic `abort` event, and only the registration it was added +with: a duplicate's signal is ignored, and an abort after remove and re-add +leaves the new registration); listener exceptions reported in order through +the runtime's own `reportError()` (not the global property), synchronously, +with the dispatch carrying on; a polluted `Object.prototype` reaching only +the user's own options and listener objects, and patched built-ins never +used; a long-lived signal retaining neither dead targets nor their +listeners. + +## Compatibility flags + +| Flag | Pinned in main cells | Unflagged side | +| --- | --- | --- | +| `workers_api_getters_setters_on_prototype` (2022-01-31) | C++ cells | `event-target-cpp-legacy`: `srcElement` reads the current target, so it is `null` once dispatch ends (`legacy-dispatch-attributes.js`). The TS cell runs at both layouts of the C++ `AbortSignal` (row 14) | +| `dont_substitute_null_on_type_error` (2022-06-01) | C++ cells | `event-target-cpp-legacy`: a primitive listener is taken as null (`legacy-listener-conversion.js`) | +| `set_tostring_tag` (2024-09-26) | C++ cells | `event-target-cpp-legacy`: no `Symbol.toStringTag` (`legacy-to-string-tag.js`) | +| `set_event_target_this` (2025-08-01) | C++ cells | `event-target-cpp-legacy`: a function listener's `this` is the global (`legacy-listener-this.js`) | +| `spec_compliant_dispatch_exceptions` (2026-09-15) | C++ cells | the first listener exception propagates: `src/workerd/api/tests/legacy-dispatch-exceptions-test.js` | +| `enable_weak_ref` (2025-05-05) | every cell, for `gc.js`'s `FinalizationRegistry` | none on this surface | +| `pedantic_wpt` (dateless) | `event-target-cpp-pedantic` | the main C++ cell (row 1) | + +## Module map + +| Module | Coverage | +| --- | --- | +| `api-surface.js` | `Symbol.toStringTag`, prototype methods, function metadata, the interface object, brand checks, detached methods; rows 15-18 | +| `listeners.js` | argument counts and conversion, nullish and primitive listeners, duplicates, removal, order, invocation, per-target registrations; row 9 | +| `options.js` | inert options, capture, passive, read order, `once`, removal with capture; rows 5-8 | +| `dispatch.js` | `dispatchEvent()`'s argument and result, attributes during and after dispatch, the canceled and stop flags, the list changing during dispatch, reentrant and nested dispatch, odd types; rows 1-4, 13 | +| `handle-event.js` | `handleEvent` lookup, listener results; rows 9-11 | +| `signal.js` | the `{signal}` option; row 14 | +| `exceptions.js` | listener exceptions, inside and outside a request; row 19 | +| `reflection.js` | `on` properties; row 12 | +| `interop.js` | the global, `AbortSignal`, `node:events`, `structuredClone()`, `util.inspect()`; rows 20-23 | +| `subclassing.js` | subclasses of `EventTarget` (parity) | +| `pollution.js` | polluted `Object.prototype` and patched built-ins (parity) | +| `gc.js` | what keeps listeners and targets alive (parity) | +| `helpers.js` | `collectReportedErrors()`, and the C++ `Event` and `EventTarget` for the cross-implementation tests | +| `legacy-*.js` | the legacy cell (see the flags table) | diff --git a/src/tests/events/event-target/BUILD.bazel b/src/tests/events/event-target/BUILD.bazel new file mode 100644 index 00000000000..40fc292606c --- /dev/null +++ b/src/tests/events/event-target/BUILD.bazel @@ -0,0 +1,38 @@ +load("//:build/wd_test.bzl", "wd_test") + +# The EventTarget suite runs the same test modules under three configs: +# event-target-cpp against the C++ implementation, event-target-cpp-pedantic +# against it with pedantic_wpt, and event-target-ts against the TypeScript +# implementation. event-target-cpp-legacy guards the C++ behaviors without +# the date-gated flags. + +event_target_suite_srcs = glob(["*.js"]) + ["event-target-modules.capnp"] + +wd_test( + size = "large", + src = "event-target-cpp.wd-test", + data = event_target_suite_srcs, +) + +wd_test( + size = "large", + src = "event-target-cpp-pedantic.wd-test", + data = event_target_suite_srcs, +) + +wd_test( + size = "large", + src = "event-target-ts.wd-test", + args = ["--experimental"], + data = event_target_suite_srcs, +) + +# Legacy (pre-flag) regression guard, C++ implementation only. The +# @all-compat-flags variant is disabled: at the 2999-12-31 date the flags +# the cell deliberately omits would all turn on. +wd_test( + size = "large", + src = "event-target-cpp-legacy.wd-test", + data = event_target_suite_srcs, + generate_all_compat_flags_variant = False, +) diff --git a/src/tests/events/event-target/api-surface.js b/src/tests/events/event-target/api-surface.js new file mode 100644 index 00000000000..c5d81f2b752 --- /dev/null +++ b/src/tests/events/event-target/api-surface.js @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// The WebIDL shape of EventTarget: member placement, property attributes +// and function metadata, and the brand checks. +// +// Divergences (ledger in AGENTS.md): +// - #15: C++ methods report length 0. +// - #16: C++ Symbol.toStringTag is writable. +// - #17: C++ EventTarget's prototype property is writable. +// - #18: C++ EventTarget.prototype carries the cloudflare:internal-class +// marker. + +import { deepStrictEqual, strictEqual, throws } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +const kMethods = ['addEventListener', 'removeEventListener', 'dispatchEvent']; + +// #16. +export const toStringTagBranding = { + test() { + deepStrictEqual( + Object.getOwnPropertyDescriptor( + EventTarget.prototype, + Symbol.toStringTag + ), + { + value: 'EventTarget', + writable: !usingTsImpl, + enumerable: false, + configurable: true, + } + ); + strictEqual( + Object.prototype.toString.call(new EventTarget()), + '[object EventTarget]' + ); + }, +}; + +export const methodsArePrototypeMethods = { + test() { + for (const name of kMethods) { + const { value, writable, enumerable, configurable } = + Object.getOwnPropertyDescriptor(EventTarget.prototype, name); + strictEqual(typeof value, 'function', name); + strictEqual(value.name, name); + strictEqual(writable, true, name); + strictEqual(enumerable, true, name); + strictEqual(configurable, true, name); + } + deepStrictEqual(Reflect.ownKeys(new EventTarget()), []); + }, +}; + +// #15. +export const functionMetadata = { + test() { + strictEqual(EventTarget.name, 'EventTarget'); + strictEqual(EventTarget.length, 0); + deepStrictEqual( + kMethods.map((name) => EventTarget.prototype[name].length), + usingTsImpl ? [2, 2, 1] : [0, 0, 0] + ); + }, +}; + +// #17. +export const interfaceObject = { + test() { + strictEqual(Object.getPrototypeOf(EventTarget), Function.prototype); + strictEqual(Object.getPrototypeOf(EventTarget.prototype), Object.prototype); + deepStrictEqual(Object.getOwnPropertyDescriptor(EventTarget, 'prototype'), { + value: EventTarget.prototype, + writable: !usingTsImpl, + enumerable: false, + configurable: false, + }); + deepStrictEqual( + Object.getOwnPropertyDescriptor(EventTarget.prototype, 'constructor'), + { + value: EventTarget, + writable: true, + enumerable: false, + configurable: true, + } + ); + throws(() => EventTarget(), TypeError); + }, +}; + +// Every method rejects a receiver that is not an EventTarget, an object that +// merely inherits from EventTarget.prototype included. +export const brandChecks = { + test() { + const listener = () => {}; + for (const receiver of [ + {}, + undefined, + null, + new Event('x'), + Object.create(EventTarget.prototype), + ]) { + const { addEventListener, removeEventListener, dispatchEvent } = + EventTarget.prototype; + throws(() => addEventListener.call(receiver, 'x', listener), TypeError); + throws( + () => removeEventListener.call(receiver, 'x', listener), + TypeError + ); + throws(() => dispatchEvent.call(receiver, new Event('x')), TypeError); + } + }, +}; + +// Neither implementation substitutes the global for a missing receiver. +// (The global's own methods are other functions: interop.js.) +export const detachedMethodsThrow = { + test() { + const { addEventListener, removeEventListener, dispatchEvent } = + EventTarget.prototype; + throws(() => addEventListener('x', () => {}), TypeError); + throws(() => removeEventListener('x', () => {}), TypeError); + throws(() => dispatchEvent(new Event('x')), TypeError); + }, +}; + +// #18. +export const internalClassMarker = { + test() { + const marker = Symbol.for('cloudflare:internal-class'); + strictEqual( + EventTarget.prototype[marker], + usingTsImpl ? undefined : marker + ); + }, +}; diff --git a/src/tests/events/event-target/dispatch.js b/src/tests/events/event-target/dispatch.js new file mode 100644 index 00000000000..89b61765b3b --- /dev/null +++ b/src/tests/events/event-target/dispatch.js @@ -0,0 +1,359 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Dispatch: dispatchEvent()'s argument and result, an event's attributes +// during and after dispatch, the canceled and stop propagation flags across +// a dispatch, and the listener list changing during one. +// +// Divergences (ledger in AGENTS.md): +// - #1: C++ leaves currentTarget set once dispatch ends (null under +// pedantic_wpt). +// - #2: C++'s dispatchEvent() returns false once preventDefault() has been +// called, even for an event that is not cancelable. +// - #3: C++ unsets the canceled flag as dispatch starts. +// - #4: C++ never consults the stop propagation flag, and never unsets +// either stop flag: stopPropagation() before dispatch stops nothing, and +// an event stopped immediately never reaches a listener again. +// - #13: C++ runs a listener removed and re-added during the dispatch. + +import { deepStrictEqual, ok, strictEqual, throws } from 'node:assert'; +import { usingTsImpl, pedanticWpt } from 'which-impl'; + +export const argumentMustBeAnEvent = { + test() { + const target = new EventTarget(); + for (const value of [ + undefined, + null, + {}, + 'x', + Object.create(Event.prototype), + ]) { + throws(() => target.dispatchEvent(value), TypeError, String(value)); + } + }, +}; + +export const attributesDuringDispatch = { + test() { + const target = new EventTarget(); + const event = new Event('x'); + let seen; + target.addEventListener('x', (e) => { + const path = e.composedPath(); + seen = { + event: e === event, + target: e.target === target, + currentTarget: e.currentTarget === target, + srcElement: e.srcElement === target, + eventPhase: e.eventPhase, + path: path.length === 1 && path[0] === target, + isTrusted: e.isTrusted, + }; + }); + target.dispatchEvent(event); + deepStrictEqual(seen, { + event: true, + target: true, + currentTarget: true, + srcElement: true, + eventPhase: Event.AT_TARGET, + path: true, + isTrusted: false, + }); + }, +}; + +// #1. +export const attributesAfterDispatch = { + test() { + const target = new EventTarget(); + const event = new Event('x'); + strictEqual(target.dispatchEvent(event), true); + strictEqual(event.target, target); + strictEqual(event.srcElement, target); + strictEqual(event.eventPhase, Event.NONE); + deepStrictEqual(event.composedPath(), []); + strictEqual( + event.currentTarget, + usingTsImpl || pedanticWpt ? null : target + ); + }, +}; + +// #2. +export const dispatchResult = { + test() { + function dispatch(init, cancel) { + const target = new EventTarget(); + if (cancel) { + target.addEventListener('x', (e) => { + e.preventDefault(); + }); + } + const event = new Event('x', init); + return [target.dispatchEvent(event), event.defaultPrevented]; + } + deepStrictEqual(dispatch({ cancelable: true }, false), [true, false]); + deepStrictEqual(dispatch({ cancelable: true }, true), [false, true]); + deepStrictEqual(dispatch({}, false), [true, false]); + // preventDefault() does nothing to an event that is not cancelable. + deepStrictEqual(dispatch({}, true), [usingTsImpl, false]); + }, +}; + +// #3. Per spec only initEvent() unsets the canceled flag, so an event +// canceled before dispatch, or in an earlier one, stays canceled. +export const canceledFlagSurvivesDispatch = { + test() { + const target = new EventTarget(); + const seen = []; + target.addEventListener('x', (e) => { + seen.push(e.defaultPrevented); + }); + + const before = new Event('x', { cancelable: true }); + before.preventDefault(); + const beforeResult = target.dispatchEvent(before); + + const canceler = new EventTarget(); + canceler.addEventListener('x', (e) => { + e.preventDefault(); + }); + const earlier = new Event('x', { cancelable: true }); + strictEqual(canceler.dispatchEvent(earlier), false); + const earlierResult = target.dispatchEvent(earlier); + + const kept = usingTsImpl; + deepStrictEqual([beforeResult, earlierResult], [!kept, !kept]); + deepStrictEqual(seen, [kept, kept]); + strictEqual(before.defaultPrevented, kept); + strictEqual(earlier.defaultPrevented, kept); + }, +}; + +// #4. Per spec a dispatch of a stopped event runs no listener, and every +// dispatch unsets the stop flags as it ends. +export const stopFlagsSetBeforeDispatch = { + test() { + // [calls after one dispatch, after two, cancelBubble] + const expected = usingTsImpl + ? { + stopPropagation: [0, 1, false], + stopImmediatePropagation: [0, 1, false], + } + : { + stopPropagation: [1, 2, true], + stopImmediatePropagation: [0, 0, false], + }; + for (const stop of ['stopPropagation', 'stopImmediatePropagation']) { + const target = new EventTarget(); + let calls = 0; + target.addEventListener('x', () => { + calls++; + }); + const event = new Event('x'); + event[stop](); + target.dispatchEvent(event); + const afterOne = calls; + target.dispatchEvent(event); + deepStrictEqual( + [afterOne, calls, event.cancelBubble], + expected[stop], + stop + ); + } + }, +}; + +// #4. stopPropagation() lets the target's other listeners run, as there is +// no other target to stop at; stopImmediatePropagation() stops them. +export const stopFlagsSetDuringDispatch = { + test() { + // [first dispatch, cancelBubble after it, second dispatch] + const expected = { + stopPropagation: [['a', 'b'], !usingTsImpl, ['a', 'b']], + stopImmediatePropagation: [['a'], false, usingTsImpl ? ['a'] : []], + }; + for (const stop of ['stopPropagation', 'stopImmediatePropagation']) { + const target = new EventTarget(); + const log = []; + target.addEventListener('x', (e) => { + log.push('a'); + e[stop](); + }); + target.addEventListener('x', () => { + log.push('b'); + }); + const event = new Event('x'); + target.dispatchEvent(event); + const first = log.splice(0); + const cancelBubble = event.cancelBubble; + target.dispatchEvent(event); + deepStrictEqual([first, cancelBubble, log], expected[stop], stop); + } + }, +}; + +export const removedListenerDoesNotRun = { + test() { + const target = new EventTarget(); + const log = []; + const second = () => { + log.push('second'); + }; + target.addEventListener('x', () => { + log.push('first'); + target.removeEventListener('x', second); + }); + target.addEventListener('x', second); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['first']); + }, +}; + +// A listener added during a dispatch waits for the next one. +export const addedListenerWaitsForTheNextDispatch = { + test() { + const target = new EventTarget(); + const log = []; + target.addEventListener( + 'x', + () => { + log.push('first'); + target.addEventListener('x', () => { + log.push('late'); + }); + }, + { once: true } + ); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['first']); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['first', 'late']); + }, +}; + +// #13. Per spec a dispatch runs over a clone of the listener list taken as +// it starts: removing a listener marks its registration removed, and adding +// it again makes a new registration, which the clone does not hold. +export const reAddedListenerDuringDispatch = { + test() { + const target = new EventTarget(); + const log = []; + const second = () => { + log.push('second'); + }; + target.addEventListener('x', () => { + log.push('first'); + target.removeEventListener('x', second); + target.addEventListener('x', second); + }); + target.addEventListener('x', second); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, usingTsImpl ? ['first'] : ['first', 'second']); + }, +}; + +// An event being dispatched cannot be dispatched again, at any target, and +// the failed attempt leaves the dispatch in progress alone. +export const reentrantDispatchThrows = { + test() { + const target = new EventTarget(); + const other = new EventTarget(); + const log = []; + const errors = []; + target.addEventListener('x', (e) => { + log.push('first'); + for (const at of [target, other]) { + try { + at.dispatchEvent(e); + } catch (error) { + errors.push(error); + } + } + log.push(e.currentTarget === target); + }); + target.addEventListener('x', () => { + log.push('second'); + }); + strictEqual(target.dispatchEvent(new Event('x')), true); + deepStrictEqual(log, ['first', true, 'second']); + strictEqual(errors.length, 2); + for (const error of errors) { + ok(error instanceof DOMException); + strictEqual(error.name, 'InvalidStateError'); + strictEqual(error.code, 11); + } + }, +}; + +// Another event can be dispatched from a listener, at the same target. +export const nestedDispatch = { + test() { + const target = new EventTarget(); + const outer = new Event('outer'); + const inner = new Event('inner'); + const log = []; + target.addEventListener('outer', (e) => { + log.push(['outer', e.eventPhase]); + target.dispatchEvent(inner); + log.push(['outer again', e.eventPhase, e.currentTarget === target]); + }); + target.addEventListener('inner', (e) => { + log.push(['inner', e.eventPhase, outer.eventPhase]); + }); + target.dispatchEvent(outer); + deepStrictEqual(log, [ + ['outer', Event.AT_TARGET], + ['inner', Event.AT_TARGET, Event.AT_TARGET], + ['outer again', Event.AT_TARGET, true], + ]); + }, +}; + +export const redispatchAtAnotherTarget = { + test() { + const first = new EventTarget(); + const second = new EventTarget(); + const event = new Event('x'); + let seen; + second.addEventListener('x', (e) => { + seen = e.target; + }); + first.dispatchEvent(event); + strictEqual(event.target, first); + second.dispatchEvent(event); + strictEqual(seen, second); + strictEqual(event.target, second); + }, +}; + +// Types are arbitrary strings, matched exactly, even ones that name +// Object.prototype members. +export const unusualTypes = { + test() { + const types = [ + '', + ' ', + 'é', + '\u{1F600}', + 'on', + '__proto__', + 'constructor', + 'hasOwnProperty', + ]; + const target = new EventTarget(); + const log = []; + for (const type of types) { + target.addEventListener(type, () => { + log.push(type); + }); + } + for (const type of types) { + strictEqual(target.dispatchEvent(new Event(type)), true, type); + } + deepStrictEqual(log, types); + }, +}; diff --git a/src/tests/events/event-target/event-target-cpp-legacy.wd-test b/src/tests/events/event-target/event-target-cpp-legacy.wd-test new file mode 100644 index 00000000000..7e3b58f7c40 --- /dev/null +++ b/src/tests/events/event-target/event-target-cpp-legacy.wd-test @@ -0,0 +1,28 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Fully-unflagged legacy cell (C++ only): without +# dont_substitute_null_on_type_error a primitive listener is taken as null, +# without set_event_target_this a function listener's `this` is the global, +# without workers_api_getters_setters_on_prototype srcElement reads the +# current target, and without set_tostring_tag EventTarget is not branded. +# Without spec_compliant_dispatch_exceptions the first listener exception +# propagates, which src/workerd/api/tests/legacy-dispatch-exceptions-test.js +# guards. The BUILD rule disables the @all-compat-flags variant, which would +# turn the omitted flags back on. + +const unitTests :Workerd.Config = ( + services = [ + ( name = "events-event-target-cpp-legacy", + worker = ( + modules = [ + (name = "main", esModule = embed "legacy-main.js"), + (name = "legacy-listener-conversion", esModule = embed "legacy-listener-conversion.js"), + (name = "legacy-listener-this", esModule = embed "legacy-listener-this.js"), + (name = "legacy-dispatch-attributes", esModule = embed "legacy-dispatch-attributes.js"), + (name = "legacy-to-string-tag", esModule = embed "legacy-to-string-tag.js"), + ], + compatibilityFlags = ["nodejs_compat"], + ) + ), + ], +); diff --git a/src/tests/events/event-target/event-target-cpp-pedantic.wd-test b/src/tests/events/event-target/event-target-cpp-pedantic.wd-test new file mode 100644 index 00000000000..6e1fe65480c --- /dev/null +++ b/src/tests/events/event-target/event-target-cpp-pedantic.wd-test @@ -0,0 +1,28 @@ +using Workerd = import "/workerd/workerd.capnp"; +using EventTargetModules = import "event-target-modules.capnp"; + +# The main C++ cell plus the dateless opt-in pedantic_wpt flag, which makes +# currentTarget null once dispatch ends. The shared modules branch on +# which-impl's pedanticWpt where it shows. + +const unitTests :Workerd.Config = ( + services = [ + ( name = "events-event-target-cpp-pedantic", + worker = ( + modules = EventTargetModules.modules, + compatibilityFlags = [ + "nodejs_compat", + "workers_api_getters_setters_on_prototype", + "dont_substitute_null_on_type_error", + "set_tostring_tag", + "set_event_target_this", + "spec_compliant_dispatch_exceptions", + "enable_weak_ref", + "pedantic_wpt", + ], + ) + ), + ], + # For gc.js. + v8Flags = ["--expose-gc"], +); diff --git a/src/tests/events/event-target/event-target-cpp.wd-test b/src/tests/events/event-target/event-target-cpp.wd-test new file mode 100644 index 00000000000..cb11599d4c5 --- /dev/null +++ b/src/tests/events/event-target/event-target-cpp.wd-test @@ -0,0 +1,44 @@ +using Workerd = import "/workerd/workerd.capnp"; +using EventTargetModules = import "event-target-modules.capnp"; + +# Runs the EventTarget suite against the C++ implementation; +# event-target-ts.wd-test runs the identical modules against the TypeScript +# implementation. +# +# No compatibilityDate: the wd_test variants supply the date axis and the +# tests must hold at both extremes. Date-gated semantics are pinned by flag, +# and event-target-cpp-legacy.wd-test guards their unflagged sides (all but +# one, as noted): +# - nodejs_compat: node:assert and node:events for the tests themselves. +# - workers_api_getters_setters_on_prototype: Event attributes are +# prototype accessors (unflagged, srcElement reads the current target). +# - dont_substitute_null_on_type_error: a primitive listener is a TypeError +# (unflagged, it is taken as null). +# - set_tostring_tag: Symbol.toStringTag branding. +# - set_event_target_this: a listener's `this` is the target, or its object +# (unflagged, the global). +# - spec_compliant_dispatch_exceptions: listener exceptions are reported +# and dispatch continues (unflagged, the first one propagates; guarded by +# src/workerd/api/tests/legacy-dispatch-exceptions-test.js). +# - enable_weak_ref: FinalizationRegistry for gc.js. + +const unitTests :Workerd.Config = ( + services = [ + ( name = "events-event-target-cpp", + worker = ( + modules = EventTargetModules.modules, + compatibilityFlags = [ + "nodejs_compat", + "workers_api_getters_setters_on_prototype", + "dont_substitute_null_on_type_error", + "set_tostring_tag", + "set_event_target_this", + "spec_compliant_dispatch_exceptions", + "enable_weak_ref", + ], + ) + ), + ], + # For gc.js. + v8Flags = ["--expose-gc"], +); diff --git a/src/tests/events/event-target/event-target-modules.capnp b/src/tests/events/event-target/event-target-modules.capnp new file mode 100644 index 00000000000..08d1fdfcd42 --- /dev/null +++ b/src/tests/events/event-target/event-target-modules.capnp @@ -0,0 +1,25 @@ +# Single source of truth for the EventTarget suite's module set; every cell +# references this constant so the implementations run exactly the same +# embedded code. embed paths resolve relative to THIS file. + +@0x95497f81d2eb304d; + +using Workerd = import "/workerd/workerd.capnp"; + +const modules :List(Workerd.Worker.Module) = [ + (name = "main", esModule = embed "main.js"), + (name = "which-impl", esModule = embed "which-impl.js"), + (name = "helpers", esModule = embed "helpers.js"), + (name = "api-surface", esModule = embed "api-surface.js"), + (name = "listeners", esModule = embed "listeners.js"), + (name = "options", esModule = embed "options.js"), + (name = "dispatch", esModule = embed "dispatch.js"), + (name = "handle-event", esModule = embed "handle-event.js"), + (name = "signal", esModule = embed "signal.js"), + (name = "exceptions", esModule = embed "exceptions.js"), + (name = "reflection", esModule = embed "reflection.js"), + (name = "interop", esModule = embed "interop.js"), + (name = "subclassing", esModule = embed "subclassing.js"), + (name = "pollution", esModule = embed "pollution.js"), + (name = "gc", esModule = embed "gc.js"), +]; diff --git a/src/tests/events/event-target/event-target-ts.wd-test b/src/tests/events/event-target/event-target-ts.wd-test new file mode 100644 index 00000000000..2b12f19bf8e --- /dev/null +++ b/src/tests/events/event-target/event-target-ts.wd-test @@ -0,0 +1,30 @@ +using Workerd = import "/workerd/workerd.capnp"; +using EventTargetModules = import "event-target-modules.capnp"; + +# Runs the EventTarget suite against the TypeScript implementation, with +# byte-for-byte the same modules as event-target-cpp.wd-test. The semantic +# flags pinned there are deliberately absent: the TypeScript implementation +# hard-codes the modern behaviors, and the `@` / `@all-compat-flags` +# variants verify that indifference with the flags all off and all on. +# enable_weak_ref is the tests' own need (gc.js), not the implementation's. + +const unitTests :Workerd.Config = ( + services = [ + ( name = "events-event-target-ts", + worker = ( + modules = EventTargetModules.modules, + compatibilityFlags = [ + "nodejs_compat", + "typescript_implemented_events", + "experimental", + "enable_weak_ref", + ], + ) + ), + ], + autogates = [ + "workerd-autogate-per-isolate-javascript-bootstrap", + ], + # For gc.js. + v8Flags = ["--expose-gc"], +); diff --git a/src/tests/events/event-target/exceptions.js b/src/tests/events/event-target/exceptions.js new file mode 100644 index 00000000000..49b1b28eb58 --- /dev/null +++ b/src/tests/events/event-target/exceptions.js @@ -0,0 +1,265 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Listener exceptions (DOM's inner invoke): each is reported, as HTML's +// "report an exception" does, and the dispatch carries on. Within a request +// the report fires the global 'error' event, then logs the exception unless +// a listener cancels the event. Nothing reaches the dispatchEvent() caller. +// (Without spec_compliant_dispatch_exceptions the C++ dispatch propagates +// the first exception instead; that side is guarded by +// src/workerd/api/tests/legacy-dispatch-exceptions-test.js.) +// +// Divergence (ledger in AGENTS.md): +// - #19: outside any request (in module evaluation), C++ only logs the +// exception; TS fires the global 'error' event there too. + +import { deepStrictEqual, strictEqual } from 'node:assert'; +import { collectReportedErrors } from 'helpers'; +import { usingTsImpl } from 'which-impl'; + +// Recorded as this module is evaluated, outside any request (#19). +const thrownAtModuleEvaluation = new Error('module evaluation'); +const atModuleEvaluation = (() => { + const target = new EventTarget(); + const log = []; + target.addEventListener('x', () => { + log.push('first'); + throw thrownAtModuleEvaluation; + }); + target.addEventListener('x', () => { + log.push('second'); + }); + let result; + const reported = collectReportedErrors(() => { + result = target.dispatchEvent(new Event('x')); + }); + return { log, result, reported }; +})(); + +export const exceptionsAreReported = { + test() { + const target = new EventTarget(); + const thrown = new Error('listener'); + const log = []; + target.addEventListener('x', () => { + log.push('first'); + throw thrown; + }); + target.addEventListener('x', () => { + log.push('second'); + }); + let result; + const reported = collectReportedErrors(() => { + result = target.dispatchEvent(new Event('x', { cancelable: true })); + }); + // An exception does not cancel the event. + strictEqual(result, true); + deepStrictEqual(log, ['first', 'second']); + strictEqual(reported.length, 1); + strictEqual(reported[0], thrown); + }, +}; + +// Each exception is reported, in order, whatever was thrown. +export const everyExceptionIsReported = { + test() { + const target = new EventTarget(); + const first = new Error('first'); + target.addEventListener('x', () => { + throw first; + }); + target.addEventListener('x', { + handleEvent() { + throw 42; + }, + }); + target.addEventListener('x', () => { + throw 'third'; + }); + const reported = collectReportedErrors(() => { + target.dispatchEvent(new Event('x')); + }); + strictEqual(reported.length, 3); + strictEqual(reported[0], first); + strictEqual(reported[1], 42); + strictEqual(reported[2], 'third'); + }, +}; + +// A throwing 'error' listener interrupts neither the report nor the +// dispatch that caused it. +export const throwingErrorListener = { + test() { + const target = new EventTarget(); + const log = []; + target.addEventListener('x', () => { + log.push('first'); + throw new Error('listener'); + }); + target.addEventListener('x', () => { + log.push('second'); + }); + const onError = () => { + log.push('error'); + throw new Error('error listener'); + }; + globalThis.addEventListener('error', onError); + let result; + try { + result = target.dispatchEvent(new Event('x')); + } finally { + globalThis.removeEventListener('error', onError); + } + strictEqual(result, true); + deepStrictEqual(log, ['first', 'error', 'second']); + }, +}; + +// The report runs synchronously, between the listeners. +export const reportRunsBetweenListeners = { + test() { + const target = new EventTarget(); + const log = []; + const second = () => { + log.push('second'); + }; + target.addEventListener('x', () => { + log.push('first'); + throw new Error('first'); + }); + target.addEventListener('x', second); + const onError = (e) => { + log.push('error'); + target.removeEventListener('x', second); + e.preventDefault(); + }; + globalThis.addEventListener('error', onError); + try { + target.dispatchEvent(new Event('x')); + } finally { + globalThis.removeEventListener('error', onError); + } + deepStrictEqual(log, ['first', 'error']); + }, +}; + +export const throwingOnceListenerIsRemoved = { + test() { + const target = new EventTarget(); + let calls = 0; + target.addEventListener( + 'x', + () => { + calls++; + throw new Error('once'); + }, + { once: true } + ); + const reported = collectReportedErrors(() => { + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + }); + strictEqual(calls, 1); + strictEqual(reported.length, 1); + }, +}; + +// The dispatch ends as ever, so the event can be dispatched again. +export const stateAfterAnException = { + test() { + const target = new EventTarget(); + const event = new Event('x'); + let calls = 0; + target.addEventListener('x', () => { + calls++; + throw new Error('listener'); + }); + let phase; + const reported = collectReportedErrors(() => { + target.dispatchEvent(event); + phase = event.eventPhase; + target.dispatchEvent(event); + }); + strictEqual(phase, Event.NONE); + strictEqual(calls, 2); + strictEqual(reported.length, 2); + }, +}; + +// The report goes through the runtime's own reportError(), not whatever the +// global reportError property holds. +export const replacedReportErrorIsNotCalled = { + test() { + const { reportError } = globalThis; + let calls = 0; + globalThis.reportError = () => { + calls++; + }; + const thrown = new Error('listener'); + let reported; + try { + const target = new EventTarget(); + target.addEventListener('x', () => { + throw thrown; + }); + reported = collectReportedErrors(() => { + target.dispatchEvent(new Event('x')); + }); + } finally { + globalThis.reportError = reportError; + } + strictEqual(calls, 0); + strictEqual(reported.length, 1); + strictEqual(reported[0], thrown); + }, +}; + +// An exception that cannot be logged as usual (its stack getter throws) is +// still reported, and the dispatch still carries on. Nothing cancels the +// 'error' event, so the exception is logged. +export const unloggableException = { + test() { + const target = new EventTarget(); + const thrown = new Error('stack'); + Object.defineProperty(thrown, 'stack', { + get() { + throw new Error('stack getter'); + }, + }); + const log = []; + target.addEventListener('x', () => { + throw thrown; + }); + target.addEventListener('x', () => { + log.push('second'); + }); + const onError = (e) => { + log.push(e.error === thrown); + }; + globalThis.addEventListener('error', onError); + let result; + try { + result = target.dispatchEvent(new Event('x')); + } finally { + globalThis.removeEventListener('error', onError); + } + strictEqual(result, true); + deepStrictEqual(log, [true, 'second']); + }, +}; + +// #19. +export const exceptionsOutsideARequest = { + test() { + const { log, result, reported } = atModuleEvaluation; + deepStrictEqual(log, ['first', 'second']); + strictEqual(result, true); + if (usingTsImpl) { + strictEqual(reported.length, 1); + strictEqual(reported[0], thrownAtModuleEvaluation); + } else { + strictEqual(reported.length, 0); + } + }, +}; diff --git a/src/tests/events/event-target/gc.js b/src/tests/events/event-target/gc.js new file mode 100644 index 00000000000..16c9a94c8cb --- /dev/null +++ b/src/tests/events/event-target/gc.js @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Garbage collection: a target keeps its listeners alive, and nothing else +// does. In particular a long-lived {signal} keeps neither a dead target nor +// its listeners alive. Requires --expose-gc and FinalizationRegistry +// (enable_weak_ref), which every cell config sets. + +import { strictEqual } from 'node:assert'; + +async function collectGarbage() { + for (let i = 0; i < 3; i++) { + gc(); + await scheduler.wait(5); + } +} + +// Returns a FinalizationRegistry and a function reading how many of its +// registrations have been collected. +function trackCollection() { + let collected = 0; + const registry = new FinalizationRegistry(() => { + collected++; + }); + return [registry, () => collected]; +} + +export const signalDoesNotRetainDeadTargets = { + async test() { + const controller = new AbortController(); + const [registry, collected] = trackCollection(); + // A separate frame, so no target lingers in this one's slots. + (() => { + for (let i = 0; i < 4; i++) { + const target = new EventTarget(); + const listener = () => {}; + target.addEventListener('x', listener, { signal: controller.signal }); + registry.register(target, 'target'); + registry.register(listener, 'listener'); + } + })(); + await collectGarbage(); + strictEqual(collected(), 8); + // There is nothing left to remove. + controller.abort(); + }, +}; + +export const targetRetainsItsListeners = { + async test() { + const target = new EventTarget(); + const [registry, collected] = trackCollection(); + let calls = 0; + (() => { + const listener = () => { + calls++; + }; + const object = { + handleEvent() { + calls++; + }, + }; + target.addEventListener('x', listener); + target.addEventListener('x', object); + registry.register(listener, 'listener'); + registry.register(object, 'object'); + })(); + await collectGarbage(); + strictEqual(collected(), 0); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 2); + }, +}; + +// Once a listener's registration ends (removed, run once, aborted), the +// target no longer keeps it alive. +export const endedRegistrationsReleaseTheirListeners = { + async test() { + const target = new EventTarget(); + const controller = new AbortController(); + const [registry, collected] = trackCollection(); + (() => { + const removed = () => {}; + const once = () => {}; + const aborted = () => {}; + target.addEventListener('x', removed); + target.addEventListener('x', once, { once: true }); + target.addEventListener('x', aborted, { signal: controller.signal }); + registry.register(removed, 'removed'); + registry.register(once, 'once'); + registry.register(aborted, 'aborted'); + target.removeEventListener('x', removed); + target.dispatchEvent(new Event('x')); + controller.abort(); + })(); + await collectGarbage(); + strictEqual(collected(), 3); + }, +}; diff --git a/src/tests/events/event-target/handle-event.js b/src/tests/events/event-target/handle-event.js new file mode 100644 index 00000000000..9d7429d3245 --- /dev/null +++ b/src/tests/events/event-target/handle-event.js @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// How a listener is called: an object listener's handleEvent, and what a +// listener returns. +// +// Divergences (ledger in AGENTS.md): +// - #9 (its dispatch side; listeners.js has the add): TS reports a +// TypeError for an object listener without a callable handleEvent. +// - #10: C++ looks handleEvent up once, as the listener is added, so a +// throwing getter makes addEventListener() throw; TS looks it up at every +// call, as WebIDL's "call a user object's operation" does. +// - #11: C++ cancels the event when a listener returns true (and warns, +// once per target, of any other result that is not a boolean); TS ignores +// the result. + +import { deepStrictEqual, ok, strictEqual, throws } from 'node:assert'; +import { collectReportedErrors } from 'helpers'; +import { usingTsImpl } from 'which-impl'; + +// #10. +export const handleEventLookup = { + test() { + const target = new EventTarget(); + let reads = 0; + let calls = 0; + const listener = { + get handleEvent() { + reads++; + return () => { + calls++; + }; + }, + }; + target.addEventListener('x', listener); + const readsAtAdd = reads; + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + deepStrictEqual( + { readsAtAdd, reads, calls }, + usingTsImpl + ? { readsAtAdd: 0, reads: 2, calls: 2 } + : { readsAtAdd: 1, reads: 1, calls: 2 } + ); + }, +}; + +// #10. +export const handleEventReplacedAfterAdd = { + test() { + const target = new EventTarget(); + const log = []; + const listener = { + handleEvent() { + log.push('old'); + }, + }; + target.addEventListener('x', listener); + listener.handleEvent = () => { + log.push('new'); + }; + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, [usingTsImpl ? 'new' : 'old']); + }, +}; + +// #10. In TS the getter's exception is a listener exception, reported as +// any other is. +export const throwingHandleEventGetter = { + test() { + const target = new EventTarget(); + const thrown = new Error('getter'); + const listener = { + get handleEvent() { + throw thrown; + }, + }; + if (!usingTsImpl) { + throws( + () => target.addEventListener('x', listener), + (e) => e === thrown + ); + return; + } + const log = []; + target.addEventListener('x', listener); + target.addEventListener('x', () => { + log.push('next'); + }); + const reported = collectReportedErrors(() => { + target.dispatchEvent(new Event('x')); + }); + strictEqual(reported.length, 1); + strictEqual(reported[0], thrown); + deepStrictEqual(log, ['next']); + }, +}; + +// #9. A handleEvent defined after the add is used. +export const nonCallableHandleEventIsReported = { + test() { + const target = new EventTarget(); + const listener = {}; + if (!usingTsImpl) { + throws(() => target.addEventListener('x', listener), TypeError); + return; + } + const log = []; + target.addEventListener('x', listener); + target.addEventListener('x', () => { + log.push('next'); + }); + const reported = collectReportedErrors(() => { + target.dispatchEvent(new Event('x')); + }); + strictEqual(reported.length, 1); + ok(reported[0] instanceof TypeError); + deepStrictEqual(log, ['next']); + + listener.handleEvent = function (e) { + log.push([this === listener, e.type]); + }; + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['next', [true, 'x'], 'next']); + }, +}; + +// A callable listener is called itself, whatever handleEvent it has. +export const callableListenerIgnoresHandleEvent = { + test() { + const target = new EventTarget(); + const log = []; + const listener = () => { + log.push('function'); + }; + listener.handleEvent = () => { + log.push('handleEvent'); + }; + target.addEventListener('x', listener); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['function']); + }, +}; + +export const inheritedHandleEvent = { + test() { + const log = []; + class Listener { + handleEvent(e) { + log.push([this instanceof Listener, e.type]); + } + } + const target = new EventTarget(); + target.addEventListener('x', new Listener()); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, [[true, 'x']]); + }, +}; + +// #11. Per spec a listener's result is ignored. +export const listenerReturningTrue = { + test() { + for (const cancelable of [true, false]) { + const target = new EventTarget(); + target.addEventListener('x', () => true); + const event = new Event('x', { cancelable }); + const result = target.dispatchEvent(event); + // C++ calls preventDefault(), which only a cancelable event heeds, + // though the result ignores that (#2). + deepStrictEqual( + [result, event.defaultPrevented], + usingTsImpl ? [true, false] : [false, cancelable], + `cancelable: ${cancelable}` + ); + } + }, +}; + +export const otherResultsAreIgnored = { + test() { + for (const value of [false, 1, 'true', {}, null, Promise.resolve(true)]) { + const target = new EventTarget(); + target.addEventListener('x', () => value); + const event = new Event('x', { cancelable: true }); + strictEqual(target.dispatchEvent(event), true, String(value)); + strictEqual(event.defaultPrevented, false, String(value)); + } + }, +}; diff --git a/src/tests/events/event-target/helpers.js b/src/tests/events/event-target/helpers.js new file mode 100644 index 00000000000..3659abaf409 --- /dev/null +++ b/src/tests/events/event-target/helpers.js @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Helpers for the modules that observe reported listener exceptions or +// need the C++ classes. + +// Runs fn with a global 'error' listener, and returns what that listener +// saw: the exception of every 'error' event, in order. The listener calls +// preventDefault(), which keeps the exceptions off the console. (The +// runtime's ErrorEvent is not cancelable, as HTML's is, but the global logs +// an exception only when its dispatch reports the event uncanceled, which +// in C++ ignores cancelability: ledger #2.) +export function collectReportedErrors(fn) { + const reported = []; + const listener = (event) => { + reported.push(event.error); + event.preventDefault(); + }; + globalThis.addEventListener('error', listener); + try { + fn(); + } finally { + globalThis.removeEventListener('error', listener); + } + return reported; +} + +// The C++ Event and EventTarget, which the C++ targets (the global, +// AbortSignal) and the runtime's own events (MessageEvent and the rest) +// are. They are the globals in the C++ cells; in the TS cell the globals are +// the TS classes (ledger #20). +export const NativeEvent = Object.getPrototypeOf( + MessageEvent.prototype +).constructor; +export const NativeEventTarget = Object.getPrototypeOf( + AbortSignal.prototype +).constructor; diff --git a/src/tests/events/event-target/interop.js b/src/tests/events/event-target/interop.js new file mode 100644 index 00000000000..0b4f5dd0729 --- /dev/null +++ b/src/tests/events/event-target/interop.js @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Targets and events beyond the events implementation. Until the +// integration phases the runtime's own targets (the global, AbortSignal) +// and events (MessageEvent and the rest) stay C++ in the TS cell, and the +// two implementations accept none of each other's objects, so each of these +// diverges (ledger rows marked †; how events cross structuredClone() and +// util.inspect() is event/interop.js's). +// +// Divergences (ledger in AGENTS.md): +// - #20 †: events cannot be dispatched across the implementations, nor +// their methods called on the other's targets. +// - #21 †: AbortSignal is not an EventTarget. +// - #22 †: node:events' helpers reject AbortSignal. +// - #23 †: structuredClone() of a target. + +import { + deepStrictEqual, + notStrictEqual, + strictEqual, + throws, +} from 'node:assert'; +import { getEventListeners, setMaxListeners } from 'node:events'; +import util from 'node:util'; +import { NativeEvent, NativeEventTarget } from 'helpers'; +import { usingTsImpl } from 'which-impl'; + +// #20 †. +export const eventsAcrossImplementations = { + test() { + const signal = new AbortController().signal; + const target = new EventTarget(); + let signalCalls = 0; + let targetCalls = 0; + signal.addEventListener('x', () => { + signalCalls++; + }); + target.addEventListener('x', () => { + targetCalls++; + }); + strictEqual(signal.dispatchEvent(new NativeEvent('x')), true); + strictEqual(signalCalls, 1); + + const nativeAtTarget = () => target.dispatchEvent(new MessageEvent('x')); + const tsAtSignal = () => signal.dispatchEvent(new Event('x')); + if (usingTsImpl) { + throws(nativeAtTarget, TypeError); + throws(tsAtSignal, TypeError); + } else { + strictEqual(nativeAtTarget(), true); + strictEqual(tsAtSignal(), true); + } + strictEqual(targetCalls, usingTsImpl ? 0 : 1); + strictEqual(signalCalls, usingTsImpl ? 1 : 2); + }, +}; + +// #20 †. +export const methodsAcrossImplementations = { + test() { + const listener = () => {}; + const tsMethodOnSignal = () => + EventTarget.prototype.addEventListener.call( + new AbortController().signal, + 'x', + listener + ); + const nativeMethodOnTarget = () => + NativeEventTarget.prototype.addEventListener.call( + new EventTarget(), + 'x', + listener + ); + if (usingTsImpl) { + throws(tsMethodOnSignal, TypeError); + throws(nativeMethodOnTarget, TypeError); + } else { + tsMethodOnSignal(); + nativeMethodOnTarget(); + } + }, +}; + +// The global is a C++ target in both cells, though no EventTarget (its +// prototype chain does not hold EventTarget.prototype): its own methods are +// other functions, which work called bare. +export const theGlobal = { + test() { + strictEqual(globalThis instanceof EventTarget, false); + notStrictEqual( + globalThis.addEventListener, + EventTarget.prototype.addEventListener + ); + const { addEventListener, dispatchEvent, removeEventListener } = globalThis; + let calls = 0; + const listener = () => { + calls++; + }; + addEventListener('interop', listener); + try { + strictEqual(dispatchEvent(new NativeEvent('interop')), true); + // #20 †. + if (usingTsImpl) { + throws(() => dispatchEvent(new Event('interop')), TypeError); + } else { + strictEqual(dispatchEvent(new Event('interop')), true); + } + } finally { + removeEventListener('interop', listener); + } + strictEqual(calls, usingTsImpl ? 1 : 2); + }, +}; + +// #21 †. +export const abortSignalIsNotAnEventTarget = { + test() { + const { signal } = new AbortController(); + strictEqual(signal instanceof NativeEventTarget, true); + strictEqual(signal instanceof EventTarget, !usingTsImpl); + strictEqual( + Object.getPrototypeOf(AbortSignal.prototype) === EventTarget.prototype, + !usingTsImpl + ); + }, +}; + +// #22 †. They take any EventTarget, which they check with instanceof. +// (getEventListeners() returns nothing for any EventTarget: the runtime has +// no way to list a target's listeners.) +export const nodeEventsHelpers = { + test() { + const target = new EventTarget(); + target.addEventListener('x', () => {}); + setMaxListeners(5, target); + deepStrictEqual(getEventListeners(target, 'x'), []); + + const { signal } = new AbortController(); + if (usingTsImpl) { + throws(() => setMaxListeners(5, signal), { + code: 'ERR_INVALID_ARG_TYPE', + }); + throws(() => getEventListeners(signal, 'abort'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + } else { + setMaxListeners(5, signal); + deepStrictEqual(getEventListeners(signal, 'abort'), []); + } + }, +}; + +// #23 †. TS clones a target as an ordinary object of its own enumerable +// properties. +export const structuredCloneDiverges = { + test() { + class Target extends EventTarget { + extra = 1; + } + for (const [target, clone] of [ + [new EventTarget(), {}], + [new Target(), { extra: 1 }], + ]) { + if (usingTsImpl) { + deepStrictEqual(structuredClone(target), clone); + } else { + throws(() => structuredClone(target), { name: 'DataCloneError' }); + } + } + }, +}; + +// Neither shows a target's listeners. +export const inspectHidesListeners = { + test() { + const target = new EventTarget(); + target.addEventListener('x', () => {}); + strictEqual(util.inspect(target), 'EventTarget {}'); + }, +}; diff --git a/src/tests/events/event-target/legacy-dispatch-attributes.js b/src/tests/events/event-target/legacy-dispatch-attributes.js new file mode 100644 index 00000000000..122b64ce9e7 --- /dev/null +++ b/src/tests/events/event-target/legacy-dispatch-attributes.js @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Without workers_api_getters_setters_on_prototype, an event's srcElement +// reads its current target, not its target (the event suite's legacy cell +// has the per-instance layout itself): the target during dispatch and null +// once it ends, while target and currentTarget stay the target. + +import { strictEqual } from 'node:assert'; + +export const legacySrcElementIsTheCurrentTarget = { + test() { + const target = new EventTarget(); + const event = new Event('x'); + let during; + target.addEventListener('x', (e) => { + during = [e.target, e.currentTarget, e.srcElement]; + }); + target.dispatchEvent(event); + strictEqual(during.length, 3); + for (const value of during) { + strictEqual(value, target); + } + strictEqual(event.target, target); + strictEqual(event.currentTarget, target); + strictEqual(event.srcElement, null); + }, +}; diff --git a/src/tests/events/event-target/legacy-listener-conversion.js b/src/tests/events/event-target/legacy-listener-conversion.js new file mode 100644 index 00000000000..365261ded71 --- /dev/null +++ b/src/tests/events/event-target/legacy-listener-conversion.js @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Without dont_substitute_null_on_type_error, a primitive listener fails +// conversion and is taken as null, so addEventListener() and +// removeEventListener() do nothing with it. An object without a callable +// handleEvent still throws, as it does with the flag. + +import { strictEqual, throws } from 'node:assert'; + +export const legacyPrimitiveListenersAreIgnored = { + test() { + const target = new EventTarget(); + for (const listener of [1, 's', false, true]) { + target.addEventListener('x', listener); + target.removeEventListener('x', listener); + } + strictEqual(target.dispatchEvent(new Event('x')), true); + throws(() => target.addEventListener('x', {}), TypeError); + }, +}; diff --git a/src/tests/events/event-target/legacy-listener-this.js b/src/tests/events/event-target/legacy-listener-this.js new file mode 100644 index 00000000000..89565b0ada8 --- /dev/null +++ b/src/tests/events/event-target/legacy-listener-this.js @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Without set_event_target_this, a function listener is called with the +// global as `this`, not the target. An object listener's handleEvent still +// gets the object. + +import { deepStrictEqual } from 'node:assert'; + +export const legacyListenerThis = { + test() { + const target = new EventTarget(); + const seen = []; + target.addEventListener('x', function () { + seen.push(this === globalThis); + }); + const object = { + handleEvent() { + seen.push(this === object); + }, + }; + target.addEventListener('x', object); + target.dispatchEvent(new Event('x')); + deepStrictEqual(seen, [true, true]); + }, +}; diff --git a/src/tests/events/event-target/legacy-main.js b/src/tests/events/event-target/legacy-main.js new file mode 100644 index 00000000000..ac0d8f05ebe --- /dev/null +++ b/src/tests/events/event-target/legacy-main.js @@ -0,0 +1,10 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Entry point for the legacy (unflagged) cell. + +export { legacyPrimitiveListenersAreIgnored } from 'legacy-listener-conversion'; +export { legacyListenerThis } from 'legacy-listener-this'; +export { legacySrcElementIsTheCurrentTarget } from 'legacy-dispatch-attributes'; +export { legacyNoToStringTag } from 'legacy-to-string-tag'; diff --git a/src/tests/events/event-target/legacy-to-string-tag.js b/src/tests/events/event-target/legacy-to-string-tag.js new file mode 100644 index 00000000000..0c3ae6c6e83 --- /dev/null +++ b/src/tests/events/event-target/legacy-to-string-tag.js @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Without set_tostring_tag, EventTarget has no Symbol.toStringTag, so +// Object.prototype.toString() reports a target as a plain object. + +import { strictEqual } from 'node:assert'; + +export const legacyNoToStringTag = { + test() { + strictEqual( + Object.getOwnPropertyDescriptor( + EventTarget.prototype, + Symbol.toStringTag + ), + undefined + ); + const target = new EventTarget(); + strictEqual(target[Symbol.toStringTag], undefined); + strictEqual(Object.prototype.toString.call(target), '[object Object]'); + }, +}; diff --git a/src/tests/events/event-target/listeners.js b/src/tests/events/event-target/listeners.js new file mode 100644 index 00000000000..88d814f4372 --- /dev/null +++ b/src/tests/events/event-target/listeners.js @@ -0,0 +1,239 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Adding and removing listeners: argument conversion, identity, order, and +// how a listener is called. The options argument is options.js's. +// +// Divergence (ledger in AGENTS.md): +// - #9: C++ rejects an object listener without a callable handleEvent when +// it is added; TS accepts any object (a WebIDL callback interface) and +// reports the failure at dispatch (handle-event.js). + +import { deepStrictEqual, strictEqual, throws } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +export const argumentCounts = { + test() { + const target = new EventTarget(); + throws(() => target.addEventListener(), TypeError); + throws(() => target.addEventListener('x'), TypeError); + throws(() => target.removeEventListener(), TypeError); + throws(() => target.removeEventListener('x'), TypeError); + throws(() => target.dispatchEvent(), TypeError); + }, +}; + +// The type is converted first, so its toString() runs even when the +// listener is then rejected. +export const argumentsAreConvertedInOrder = { + test() { + for (const method of ['addEventListener', 'removeEventListener']) { + const log = []; + const type = { + toString() { + log.push('type'); + return 'x'; + }, + }; + throws(() => new EventTarget()[method](type, 1), TypeError, method); + deepStrictEqual(log, ['type'], method); + } + }, +}; + +// A null or undefined listener is no listener: adding it does nothing, +// whatever the options. (Removing one with capture is options.js's #8.) +export const nullishListenerIsIgnored = { + test() { + const target = new EventTarget(); + for (const listener of [null, undefined]) { + for (const options of [ + undefined, + false, + true, + { capture: true }, + { once: true }, + { passive: true }, + ]) { + target.addEventListener('x', listener, options); + } + for (const options of [undefined, false, { capture: false }]) { + target.removeEventListener('x', listener, options); + } + } + strictEqual(target.dispatchEvent(new Event('x')), true); + }, +}; + +export const primitiveListenersThrow = { + test() { + const target = new EventTarget(); + for (const listener of [1, 'listener', false, true, Symbol('s'), 1n]) { + throws( + () => target.addEventListener('x', listener), + TypeError, + String(listener) + ); + throws( + () => target.removeEventListener('x', listener), + TypeError, + String(listener) + ); + } + }, +}; + +// #9. removeEventListener() accepts any object in both. +export const nonCallableObjectListener = { + test() { + for (const listener of [ + {}, + [], + { handleEvent: 1 }, + { handleEvent: {} }, + new EventTarget(), + ]) { + const target = new EventTarget(); + if (usingTsImpl) { + target.addEventListener('x', listener); + } else { + throws(() => target.addEventListener('x', listener), TypeError); + } + target.removeEventListener('x', listener); + } + }, +}; + +// A listener is identified by itself (and its capture flag: options.js), so +// adding it again is a no-op that keeps the first registration's options. +export const duplicatesAreIgnored = { + test() { + const target = new EventTarget(); + let calls = 0; + const listener = () => calls++; + target.addEventListener('x', listener); + target.addEventListener('x', listener); + target.addEventListener('x', listener, false); + target.addEventListener('x', listener, { once: true }); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 2); + + const object = { + handleEvent() { + calls++; + }, + }; + target.addEventListener('y', object); + target.addEventListener('y', object); + target.dispatchEvent(new Event('y')); + strictEqual(calls, 3); + }, +}; + +export const removalMatchesByIdentity = { + test() { + const target = new EventTarget(); + const log = []; + const listener = () => log.push('listener'); + target.addEventListener('x', listener); + target.removeEventListener('x', () => log.push('listener')); + target.removeEventListener('y', listener); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['listener']); + + target.removeEventListener('x', listener); + target.removeEventListener('x', listener); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['listener']); + new EventTarget().removeEventListener('x', listener); + }, +}; + +// Ported from WPT's Event-dispatch-order-at-target.html: registration +// order, and re-adding a removed listener moves it to the end. +export const listenersRunInRegistrationOrder = { + test() { + const target = new EventTarget(); + const log = []; + const a = () => log.push('a'); + const b = { handleEvent: () => log.push('b') }; + const c = () => log.push('c'); + target.addEventListener('x', a); + target.addEventListener('x', b); + target.addEventListener('x', c); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['a', 'b', 'c']); + + target.removeEventListener('x', a); + target.addEventListener('x', a); + log.length = 0; + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['b', 'c', 'a']); + }, +}; + +// Ported from WPT's EventTarget-this-of-listener.html: a function is called +// with the target as `this`, a handleEvent with its object, and either with +// the event alone. +export const listenerInvocation = { + test() { + const target = new EventTarget(); + const event = new Event('x'); + const calls = []; + target.addEventListener('x', function (...args) { + calls.push({ self: this, args }); + }); + const object = { + handleEvent(...args) { + calls.push({ self: this, args }); + }, + }; + target.addEventListener('x', object); + target.dispatchEvent(event); + strictEqual(calls.length, 2); + strictEqual(calls[0].self, target); + strictEqual(calls[1].self, object); + for (const { args } of calls) { + strictEqual(args.length, 1); + strictEqual(args[0], event); + } + }, +}; + +// ToString, so a Symbol throws; types are compared exactly. +export const typeIsConvertedToString = { + test() { + const target = new EventTarget(); + const log = []; + target.addEventListener({ toString: () => 'x' }, () => log.push('x')); + target.addEventListener(null, () => log.push('null')); + target.addEventListener(1, () => log.push('1')); + for (const type of ['x', 'null', '1', 'X', ' x']) { + target.dispatchEvent(new Event(type)); + } + deepStrictEqual(log, ['x', 'null', '1']); + throws(() => target.addEventListener(Symbol('s'), () => {}), TypeError); + throws(() => target.removeEventListener(Symbol('s'), () => {}), TypeError); + }, +}; + +// One listener on two targets is two registrations. +export const registrationsArePerTarget = { + test() { + const first = new EventTarget(); + const second = new EventTarget(); + const seen = []; + const listener = function () { + seen.push(this); + }; + first.addEventListener('x', listener); + second.addEventListener('x', listener); + first.removeEventListener('x', listener); + first.dispatchEvent(new Event('x')); + second.dispatchEvent(new Event('x')); + strictEqual(seen.length, 1); + strictEqual(seen[0], second); + }, +}; diff --git a/src/tests/events/event-target/main.js b/src/tests/events/event-target/main.js new file mode 100644 index 00000000000..3e19ac28a78 --- /dev/null +++ b/src/tests/events/event-target/main.js @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Entry point for the EventTarget suite. Re-exports are explicit (not +// `export *`) so a name collision between modules is a load-time +// SyntaxError instead of a silently dropped test. + +export { + toStringTagBranding, + methodsArePrototypeMethods, + functionMetadata, + interfaceObject, + brandChecks, + detachedMethodsThrow, + internalClassMarker, +} from 'api-surface'; + +export { + argumentCounts, + argumentsAreConvertedInOrder, + nullishListenerIsIgnored, + primitiveListenersThrow, + nonCallableObjectListener, + duplicatesAreIgnored, + removalMatchesByIdentity, + listenersRunInRegistrationOrder, + listenerInvocation, + typeIsConvertedToString, + registrationsArePerTarget, +} from 'listeners'; + +export { + inertOptions, + captureOption, + captureListenersRunFirst, + captureIsPartOfIdentity, + stopPropagationInCaptureSkipsBubbling, + listenerAddedDuringCaptureRunsInBubbling, + passiveListenerCannotCancel, + passiveIsNotPartOfIdentity, + passiveFlagResetsAfterAThrow, + optionsReadOrder, + onceRemovesBeforeInvoking, + onceIsConvertedToBoolean, + onceListenerRemovedEarlierDoesNotRun, + removeWithCapture, +} from 'options'; + +export { + argumentMustBeAnEvent, + attributesDuringDispatch, + attributesAfterDispatch, + dispatchResult, + canceledFlagSurvivesDispatch, + stopFlagsSetBeforeDispatch, + stopFlagsSetDuringDispatch, + removedListenerDoesNotRun, + addedListenerWaitsForTheNextDispatch, + reAddedListenerDuringDispatch, + reentrantDispatchThrows, + nestedDispatch, + redispatchAtAnotherTarget, + unusualTypes, +} from 'dispatch'; + +export { + handleEventLookup, + handleEventReplacedAfterAdd, + throwingHandleEventGetter, + nonCallableHandleEventIsReported, + callableListenerIgnoresHandleEvent, + inheritedHandleEvent, + listenerReturningTrue, + otherResultsAreIgnored, +} from 'handle-event'; + +export { + abortRemovesTheListener, + abortedSignalAddsNothing, + abortRemovesOnlyItsOwnRegistration, + duplicateIgnoresItsSignal, + oneSignalForManyListeners, + signalWithOnce, + removedBeforeTheAbortEvent, + syntheticAbortEventRemovesNothing, + abortDuringDispatch, + signalMustBeAnAbortSignal, + forgedSignal, +} from 'signal'; + +export { + exceptionsAreReported, + everyExceptionIsReported, + throwingErrorListener, + reportRunsBetweenListeners, + throwingOnceListenerIsRemoved, + stateAfterAnException, + replacedReportErrorIsNotCalled, + unloggableException, + exceptionsOutsideARequest, +} from 'exceptions'; + +export { + onTypeProperty, + onTypeMethod, + nonCallableOnTypeIsIgnored, +} from 'reflection'; + +export { + eventsAcrossImplementations, + methodsAcrossImplementations, + theGlobal, + abortSignalIsNotAnEventTarget, + nodeEventsHelpers, + structuredCloneDiverges, + inspectHidesListeners, +} from 'interop'; + +export { + subclassConstruction, + subclassOverridesAMethod, + reflectConstructUsesNewTarget, + subclassWithItsOwnState, +} from 'subclassing'; + +export { + patchedBuiltinsAreNotUsed, + omittedOptionsReadNothing, + inheritedOptionMembersAreRead, + inheritedHandleEventIsCalled, +} from 'pollution'; + +export { + signalDoesNotRetainDeadTargets, + targetRetainsItsListeners, + endedRegistrationsReleaseTheirListeners, +} from 'gc'; diff --git a/src/tests/events/event-target/options.js b/src/tests/events/event-target/options.js new file mode 100644 index 00000000000..84257b2a9f2 --- /dev/null +++ b/src/tests/events/event-target/options.js @@ -0,0 +1,374 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// The options argument: its conversion, capture, passive and once +// (ported in part from WPT's EventListenerOptions-capture.html). The +// signal member is signal.js's. +// +// Divergences (ledger in AGENTS.md): +// - #5: C++ rejects capture (useCapture or {capture}) with a TypeError; in +// TS capture is part of a listener's identity and capture listeners run +// first. +// - #6: C++ rejects {passive: true}; in TS a passive listener cannot cancel +// the event. +// - #7: C++ reads the members in declaration order (capture, passive, once, +// signal); TS in WebIDL's (capture, once, passive, signal). +// - #8: C++'s removeEventListener() rejects capture too, even for a null +// listener; in TS it removes the matching capture listener, if any. + +import { deepStrictEqual, strictEqual, throws } from 'node:assert'; +import { collectReportedErrors } from 'helpers'; +import { usingTsImpl } from 'which-impl'; + +// The C++ side of #5: pins the rejection, then stops the test. +function acceptsCapture(target) { + if (usingTsImpl) return true; + throws(() => target.addEventListener('x', () => {}, true), TypeError); + return false; +} + +// The C++ side of #6, likewise. +function acceptsPassive(target) { + if (usingTsImpl) return true; + throws( + () => target.addEventListener('x', () => {}, { passive: true }), + TypeError + ); + return false; +} + +// Options that ask for nothing, in every form: ToBoolean for a primitive, +// the dictionary's members for an object (a function or an array too). +export const inertOptions = { + test() { + for (const options of [ + undefined, + null, + false, + 0, + NaN, + '', + 0n, + {}, + [], + () => {}, + { capture: false }, + { capture: null }, + { passive: false }, + { once: false }, + { signal: undefined }, + ]) { + const target = new EventTarget(); + let calls = 0; + target.addEventListener('x', () => calls++, options); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 2, String(options)); + } + }, +}; + +// #5. +export const captureOption = { + test() { + for (const options of [ + true, + 1, + 'capture', + Symbol('s'), + 1n, + { capture: true }, + { capture: 'yes' }, + ]) { + const target = new EventTarget(); + let calls = 0; + const add = () => target.addEventListener('x', () => calls++, options); + if (usingTsImpl) { + add(); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1, String(options)); + } else { + throws(add, TypeError, String(options)); + } + } + }, +}; + +// #5. Both passes run at the target, so each listener sees AT_TARGET. +export const captureListenersRunFirst = { + test() { + const target = new EventTarget(); + if (!acceptsCapture(target)) return; + const log = []; + const record = (name) => (e) => log.push([name, e.eventPhase]); + target.addEventListener('x', record('bubble 1')); + target.addEventListener('x', record('capture 1'), true); + target.addEventListener('x', record('bubble 2'), { capture: false }); + target.addEventListener('x', record('capture 2'), { capture: true }); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, [ + ['capture 1', 2], + ['capture 2', 2], + ['bubble 1', 2], + ['bubble 2', 2], + ]); + }, +}; + +// #5. +export const captureIsPartOfIdentity = { + test() { + const target = new EventTarget(); + if (!acceptsCapture(target)) return; + let calls = 0; + const listener = () => calls++; + target.addEventListener('x', listener, true); + target.addEventListener('x', listener, false); + target.addEventListener('x', listener, { capture: true }); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 2); + target.removeEventListener('x', listener); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 3); + target.removeEventListener('x', listener, { capture: true }); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 3); + }, +}; + +// #5. The target's capture listeners are one invoke, so stopPropagation() +// in one lets the rest run, then skips the bubbling pass. +export const stopPropagationInCaptureSkipsBubbling = { + test() { + const target = new EventTarget(); + if (!acceptsCapture(target)) return; + const log = []; + target.addEventListener( + 'x', + (e) => { + log.push('capture 1'); + e.stopPropagation(); + }, + true + ); + target.addEventListener('x', () => log.push('capture 2'), true); + target.addEventListener('x', () => log.push('bubble')); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['capture 1', 'capture 2']); + }, +}; + +// #5. Each pass clones the list as it starts, so a listener added during +// the capturing pass runs in the bubbling pass if it is not a capture one. +export const listenerAddedDuringCaptureRunsInBubbling = { + test() { + const target = new EventTarget(); + if (!acceptsCapture(target)) return; + const log = []; + target.addEventListener( + 'x', + () => { + log.push('capture'); + target.addEventListener('x', () => log.push('late bubble')); + target.addEventListener('x', () => log.push('late capture'), true); + }, + true + ); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['capture', 'late bubble']); + }, +}; + +// #6. +export const passiveListenerCannotCancel = { + test() { + const target = new EventTarget(); + if (!acceptsPassive(target)) return; + const log = []; + target.addEventListener( + 'x', + (e) => { + e.preventDefault(); + log.push(e.defaultPrevented); + e.returnValue = false; + log.push(e.defaultPrevented); + }, + { passive: true } + ); + target.addEventListener('x', (e) => { + e.preventDefault(); + log.push(e.defaultPrevented); + }); + strictEqual( + target.dispatchEvent(new Event('x', { cancelable: true })), + false + ); + deepStrictEqual(log, [false, false, true]); + }, +}; + +// #6. A duplicate keeps the first registration's passive. +export const passiveIsNotPartOfIdentity = { + test() { + const target = new EventTarget(); + if (!acceptsPassive(target)) return; + let calls = 0; + const listener = (e) => { + calls++; + e.preventDefault(); + }; + target.addEventListener('x', listener, { passive: true }); + target.addEventListener('x', listener, { passive: false }); + strictEqual( + target.dispatchEvent(new Event('x', { cancelable: true })), + true + ); + strictEqual(calls, 1); + target.removeEventListener('x', listener); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + }, +}; + +// #6. The flag is reset after a passive listener throws. +export const passiveFlagResetsAfterAThrow = { + test() { + const target = new EventTarget(); + if (!acceptsPassive(target)) return; + const thrown = new Error('passive'); + let canceled; + let result; + const reported = collectReportedErrors(() => { + target.addEventListener( + 'x', + () => { + throw thrown; + }, + { passive: true } + ); + target.addEventListener('x', (e) => { + e.preventDefault(); + canceled = e.defaultPrevented; + }); + result = target.dispatchEvent(new Event('x', { cancelable: true })); + }); + strictEqual(reported.length, 1); + strictEqual(reported[0], thrown); + strictEqual(canceled, true); + strictEqual(result, false); + }, +}; + +// #7. Every member is read once, after the type is converted, even for a +// null listener; removeEventListener() reads capture alone. +export const optionsReadOrder = { + test() { + function readsOf(method, listener) { + const log = []; + const options = {}; + for (const name of ['capture', 'once', 'passive', 'signal']) { + Object.defineProperty(options, name, { + get() { + log.push(name); + return undefined; + }, + }); + } + const type = { + toString() { + log.push('type'); + return 'x'; + }, + }; + new EventTarget()[method](type, listener, options); + return log; + } + const expected = usingTsImpl + ? ['type', 'capture', 'once', 'passive', 'signal'] + : ['type', 'capture', 'passive', 'once', 'signal']; + deepStrictEqual( + readsOf('addEventListener', () => {}), + expected + ); + deepStrictEqual(readsOf('addEventListener', null), expected); + deepStrictEqual( + readsOf('removeEventListener', () => {}), + ['type', 'capture'] + ); + deepStrictEqual(readsOf('removeEventListener', null), ['type', 'capture']); + }, +}; + +// once removes the listener before calling it, so a listener that adds +// itself again registers afresh, for the next dispatch. +export const onceRemovesBeforeInvoking = { + test() { + const target = new EventTarget(); + let calls = 0; + const listener = () => { + calls++; + target.addEventListener('x', listener); + }; + target.addEventListener('x', listener, { once: true }); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 2); + }, +}; + +export const onceIsConvertedToBoolean = { + test() { + for (const [once, expected] of [ + [1, 1], + ['yes', 1], + [{}, 1], + [0, 2], + ['', 2], + [null, 2], + ]) { + const target = new EventTarget(); + let calls = 0; + target.addEventListener('x', () => calls++, { once }); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + strictEqual(calls, expected, String(once)); + } + }, +}; + +// A once listener removed by an earlier one does not run. +export const onceListenerRemovedEarlierDoesNotRun = { + test() { + const target = new EventTarget(); + const log = []; + const second = () => log.push('second'); + target.addEventListener('x', () => { + log.push('first'); + target.removeEventListener('x', second); + }); + target.addEventListener('x', second, { once: true }); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['first', 'first']); + }, +}; + +// #8. +export const removeWithCapture = { + test() { + const listener = () => {}; + for (const options of [true, 1, { capture: true }]) { + for (const candidate of [listener, null]) { + const target = new EventTarget(); + const remove = () => + target.removeEventListener('x', candidate, options); + if (usingTsImpl) { + remove(); + } else { + throws(remove, TypeError, String(options)); + } + } + } + }, +}; diff --git a/src/tests/events/event-target/pollution.js b/src/tests/events/event-target/pollution.js new file mode 100644 index 00000000000..a61b7b4c56e --- /dev/null +++ b/src/tests/events/event-target/pollution.js @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// EventTarget under a polluted environment. Its bookkeeping (the listener +// map and lists, calling listeners, the {signal} abort algorithm) never goes +// through a patchable built-in. The user's own options and listener objects +// are read with [[Get]], so inherited members count, but omitted options +// read nothing. + +import { strictEqual } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +// Defines the entries on Object.prototype; returns a function removing them. +// The descriptors are null-prototype, so no entry defined so far can reach +// a later one's. +function pollute(entries) { + for (const key of Object.keys(entries)) { + Object.defineProperty(Object.prototype, key, { + __proto__: null, + value: entries[key], + configurable: true, + writable: true, + }); + } + return () => { + for (const key of Object.keys(entries)) { + Reflect.deleteProperty(Object.prototype, key); + } + }; +} + +// Replaces object[key] with value (or, for undefined, deletes it); returns +// a function restoring it. +function patch(object, key, value) { + const original = Object.getOwnPropertyDescriptor(object, key); + if (value === undefined) { + Reflect.deleteProperty(object, key); + } else { + Object.defineProperty(object, key, { + __proto__: null, + value, + configurable: true, + writable: true, + }); + } + return () => { + Object.defineProperty(object, key, original); + }; +} + +// Nothing in the patched region may use a patched built-in, the test's own +// code included: no assertions, array methods or for...of until the +// restore. +export const patchedBuiltinsAreNotUsed = { + test() { + const target = new EventTarget(); + const controller = new AbortController(); + let calls = 0; + const first = () => { + calls++; + }; + const second = { + handleEvent() { + calls++; + }, + }; + const third = () => { + calls++; + }; + const sabotage = () => { + throw new Error('a patched built-in was used'); + }; + const restores = [ + patch(Array.prototype, 'push', sabotage), + patch(Array.prototype, 'splice', sabotage), + patch(Array.prototype, 'indexOf', sabotage), + patch(Array.prototype, 'slice', sabotage), + patch(Array.prototype, Symbol.iterator, sabotage), + patch(Array, Symbol.species, sabotage), + patch(Map.prototype, 'get', sabotage), + patch(Map.prototype, 'set', sabotage), + patch(Map.prototype, 'has', sabotage), + patch(Map.prototype, 'delete', sabotage), + patch(Function.prototype, 'call', sabotage), + patch(Function.prototype, 'apply', sabotage), + patch(Function.prototype, 'bind', sabotage), + patch(WeakRef.prototype, 'deref', sabotage), + patch(globalThis, 'WeakRef', undefined), + ]; + try { + target.addEventListener('x', first); + target.addEventListener('x', second, { signal: controller.signal }); + target.addEventListener('x', third, { once: true }); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + controller.abort(); + target.dispatchEvent(new Event('x')); + target.removeEventListener('x', first); + target.dispatchEvent(new Event('x')); + } finally { + for (let i = restores.length - 1; i >= 0; i--) restores[i](); + } + // 3 + 2 (third ran once) + 1 (second removed by the abort) + 0. + strictEqual(calls, 6); + }, +}; + +// Omitted options read nothing, so polluted members do nothing to them. +export const omittedOptionsReadNothing = { + test() { + const restore = pollute({ + capture: true, + once: true, + passive: true, + signal: AbortSignal.abort(), + }); + let calls = 0; + try { + const target = new EventTarget(); + target.addEventListener('x', () => { + calls++; + }); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + } finally { + restore(); + } + strictEqual(calls, 2); + }, +}; + +export const inheritedOptionMembersAreRead = { + test() { + let calls = 0; + const listener = () => { + calls++; + }; + + let restore = pollute({ once: true }); + try { + const target = new EventTarget(); + target.addEventListener('x', listener, {}); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('x')); + } finally { + restore(); + } + strictEqual(calls, 1); + + // An aborted signal adds nothing. + restore = pollute({ signal: AbortSignal.abort() }); + try { + const target = new EventTarget(); + target.addEventListener('x', listener, {}); + target.dispatchEvent(new Event('x')); + } finally { + restore(); + } + strictEqual(calls, 1); + + // capture, which C++ rejects (options.js's #5), and which TS's + // removeEventListener() reads too. + let rejected = false; + restore = pollute({ capture: true }); + try { + const target = new EventTarget(); + try { + target.addEventListener('x', listener, {}); + } catch (e) { + rejected = e instanceof TypeError; + } + if (!rejected) { + target.dispatchEvent(new Event('x')); + target.removeEventListener('x', listener, {}); + target.dispatchEvent(new Event('x')); + } + } finally { + restore(); + } + strictEqual(rejected, !usingTsImpl); + strictEqual(calls, usingTsImpl ? 2 : 1); + }, +}; + +export const inheritedHandleEventIsCalled = { + test() { + let calls = 0; + const restore = pollute({ + handleEvent() { + calls++; + }, + }); + try { + const target = new EventTarget(); + target.addEventListener('x', {}); + target.dispatchEvent(new Event('x')); + } finally { + restore(); + } + strictEqual(calls, 1); + }, +}; diff --git a/src/tests/events/event-target/reflection.js b/src/tests/events/event-target/reflection.js new file mode 100644 index 00000000000..d865e5fd74a --- /dev/null +++ b/src/tests/events/event-target/reflection.js @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Generic on properties. +// +// Divergence (ledger in AGENTS.md): +// - #12: C++ takes a callable on property of a target (its own, or +// inherited from a subclass) as a listener for , called before the +// target's listeners with the global as `this`. Per spec only an event +// handler attribute an interface declares is one, and EventTarget +// declares none. + +import { deepStrictEqual, strictEqual } from 'node:assert'; +import { usingTsImpl } from 'which-impl'; + +// #12. +export const onTypeProperty = { + test() { + const target = new EventTarget(); + const log = []; + target.addEventListener('foo', () => { + log.push('listener'); + }); + target.onfoo = function (event) { + log.push(['onfoo', this === globalThis, event.type]); + }; + strictEqual(target.dispatchEvent(new Event('foo')), true); + deepStrictEqual( + log, + usingTsImpl ? ['listener'] : [['onfoo', true, 'foo'], 'listener'] + ); + }, +}; + +// #12. +export const onTypeMethod = { + test() { + const log = []; + class Target extends EventTarget { + onfoo() { + log.push('onfoo'); + } + } + new Target().dispatchEvent(new Event('foo')); + deepStrictEqual(log, usingTsImpl ? [] : ['onfoo']); + }, +}; + +export const nonCallableOnTypeIsIgnored = { + test() { + const target = new EventTarget(); + for (const value of [1, 'handler', {}, null]) { + target.onfoo = value; + strictEqual(target.dispatchEvent(new Event('foo')), true); + } + }, +}; diff --git a/src/tests/events/event-target/signal.js b/src/tests/events/event-target/signal.js new file mode 100644 index 00000000000..67c5dcdb219 --- /dev/null +++ b/src/tests/events/event-target/signal.js @@ -0,0 +1,262 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// The {signal} option (ported in part from WPT's +// AddEventListenerOptions-signal.any.js): aborting the signal removes the +// listener, through an abort algorithm, so before the signal's 'abort' +// event and never for a synthetic one. +// +// Divergence (ledger in AGENTS.md): +// - #14: TS checks that the signal is an AbortSignal through C++'s aborted +// getter. Under the instance-property layout (at old dates, without +// workers_api_getters_setters_on_prototype) there is no getter, and a +// forged {aborted: } passes the check: utils.addAbortAlgorithm() +// rejects it once a listener is to be added, but a null listener, or a +// forgery claiming to have aborted, adds nothing before then. C++ +// rejects a forgery as the options are converted. + +import { deepStrictEqual, strictEqual, throws } from 'node:assert'; +import { NativeEvent } from 'helpers'; +import { usingTsImpl, gettersSettersOnPrototype } from 'which-impl'; + +export const abortRemovesTheListener = { + test() { + const target = new EventTarget(); + const controller = new AbortController(); + let calls = 0; + target.addEventListener( + 'x', + () => { + calls++; + }, + { signal: controller.signal } + ); + target.dispatchEvent(new Event('x')); + controller.abort(); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + }, +}; + +// An aborted signal adds nothing, and leaves the listener free to be added +// without one. +export const abortedSignalAddsNothing = { + test() { + const target = new EventTarget(); + let calls = 0; + const listener = () => { + calls++; + }; + target.addEventListener('x', listener, { signal: AbortSignal.abort() }); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 0); + target.addEventListener('x', listener); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + }, +}; + +// The abort algorithm removes the registration it was added with, not a +// later one of the same listener. +export const abortRemovesOnlyItsOwnRegistration = { + test() { + const target = new EventTarget(); + const controller = new AbortController(); + let calls = 0; + const listener = () => { + calls++; + }; + target.addEventListener('x', listener, { signal: controller.signal }); + target.removeEventListener('x', listener); + target.addEventListener('x', listener); + controller.abort(); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + }, +}; + +// A duplicate is ignored, signal and all. +export const duplicateIgnoresItsSignal = { + test() { + const target = new EventTarget(); + const controller = new AbortController(); + let calls = 0; + const listener = () => { + calls++; + }; + target.addEventListener('x', listener); + target.addEventListener('x', listener, { signal: controller.signal }); + controller.abort(); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + }, +}; + +export const oneSignalForManyListeners = { + test() { + const controller = new AbortController(); + const targets = [new EventTarget(), new EventTarget()]; + const log = []; + for (const [i, target] of targets.entries()) { + for (const type of ['x', 'y']) { + target.addEventListener( + type, + () => { + log.push(`${i}${type}`); + }, + { signal: controller.signal } + ); + } + } + const dispatchAll = () => { + for (const target of targets) { + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('y')); + } + }; + dispatchAll(); + controller.abort(); + dispatchAll(); + deepStrictEqual(log, ['0x', '0y', '1x', '1y']); + }, +}; + +// Aborting after a once listener has run, and removed itself, is harmless. +export const signalWithOnce = { + test() { + const target = new EventTarget(); + const controller = new AbortController(); + let calls = 0; + target.addEventListener( + 'x', + () => { + calls++; + }, + { once: true, signal: controller.signal } + ); + target.dispatchEvent(new Event('x')); + controller.abort(); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + }, +}; + +// The listener is gone by the time the signal's 'abort' listeners run, +// even one added before it. +export const removedBeforeTheAbortEvent = { + test() { + const target = new EventTarget(); + const controller = new AbortController(); + const log = []; + controller.signal.addEventListener('abort', () => { + target.dispatchEvent(new Event('x')); + log.push('abort'); + }); + target.addEventListener( + 'x', + () => { + log.push('x'); + }, + { signal: controller.signal } + ); + controller.abort(); + deepStrictEqual(log, ['abort']); + }, +}; + +// Only aborting removes the listener; an 'abort' event dispatched at the +// signal does not. (A C++ event, which the signal requires: helpers.js.) +export const syntheticAbortEventRemovesNothing = { + test() { + const target = new EventTarget(); + const controller = new AbortController(); + let calls = 0; + target.addEventListener( + 'x', + () => { + calls++; + }, + { signal: controller.signal } + ); + strictEqual( + controller.signal.dispatchEvent(new NativeEvent('abort')), + true + ); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + strictEqual(controller.signal.aborted, false); + }, +}; + +// A listener whose signal an earlier listener aborts does not run. +export const abortDuringDispatch = { + test() { + const target = new EventTarget(); + const controller = new AbortController(); + const log = []; + target.addEventListener('x', () => { + log.push('first'); + controller.abort(); + }); + target.addEventListener( + 'x', + () => { + log.push('second'); + }, + { signal: controller.signal } + ); + target.dispatchEvent(new Event('x')); + deepStrictEqual(log, ['first']); + }, +}; + +// Anything but undefined must be an AbortSignal, whatever the listener. +export const signalMustBeAnAbortSignal = { + test() { + const listener = () => {}; + for (const signal of [ + null, + {}, + 1, + 'signal', + true, + new EventTarget(), + Object.create(AbortSignal.prototype), + ]) { + for (const candidate of [listener, null]) { + throws( + () => new EventTarget().addEventListener('x', candidate, { signal }), + TypeError, + String(signal) + ); + } + } + }, +}; + +// #14. +export const forgedSignal = { + test() { + const listener = () => {}; + for (const aborted of [false, true]) { + for (const candidate of [listener, null]) { + const target = new EventTarget(); + const add = () => + target.addEventListener('x', candidate, { signal: { aborted } }); + const slipsThrough = + usingTsImpl && + !gettersSettersOnPrototype && + (aborted || candidate === null); + const label = `aborted: ${aborted}, listener: ${typeof candidate}`; + if (slipsThrough) { + add(); + } else { + throws(add, TypeError, label); + } + // Nothing was added either way. + strictEqual(target.dispatchEvent(new Event('x')), true, label); + } + } + }, +}; diff --git a/src/tests/events/event-target/subclassing.js b/src/tests/events/event-target/subclassing.js new file mode 100644 index 00000000000..5f3cc77fb9c --- /dev/null +++ b/src/tests/events/event-target/subclassing.js @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Subclassing EventTarget. (A subclass's on method is +// reflection.js's.) + +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; + +export const subclassConstruction = { + test() { + class MyTarget extends EventTarget { + constructor() { + super(); + this.extra = 1; + } + } + const target = new MyTarget(); + ok(target instanceof MyTarget); + ok(target instanceof EventTarget); + strictEqual(target.extra, 1); + strictEqual(Object.prototype.toString.call(target), '[object EventTarget]'); + const receivers = []; + target.addEventListener('x', function () { + receivers.push(this); + }); + strictEqual(target.dispatchEvent(new Event('x')), true); + strictEqual(receivers.length, 1); + strictEqual(receivers[0], target); + }, +}; + +// An override can call up to the method it overrides, which, like the +// other methods, still works on the subclass's instances. +export const subclassOverridesAMethod = { + test() { + const log = []; + class MyTarget extends EventTarget { + addEventListener(type, listener, options) { + log.push(['add', type]); + super.addEventListener(type, listener, options); + } + } + const target = new MyTarget(); + target.addEventListener('x', () => { + log.push('x'); + }); + EventTarget.prototype.addEventListener.call(target, 'y', () => { + log.push('y'); + }); + target.dispatchEvent(new Event('x')); + target.dispatchEvent(new Event('y')); + deepStrictEqual(log, [['add', 'x'], 'x', 'y']); + }, +}; + +export const reflectConstructUsesNewTarget = { + test() { + function Derived() {} + Derived.prototype = Object.create(EventTarget.prototype); + const target = Reflect.construct(EventTarget, [], Derived); + strictEqual(Object.getPrototypeOf(target), Derived.prototype); + let calls = 0; + target.addEventListener('x', () => { + calls++; + }); + target.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + }, +}; + +// A subclass with state of its own, over the listener machinery. +export const subclassWithItsOwnState = { + test() { + class Emitter extends EventTarget { + #subscriptions = 0; + on(type, listener) { + this.addEventListener(type, listener); + this.#subscriptions++; + return () => { + this.removeEventListener(type, listener); + this.#subscriptions--; + }; + } + get subscriptions() { + return this.#subscriptions; + } + } + const emitter = new Emitter(); + let calls = 0; + const off = emitter.on('x', () => { + calls++; + }); + strictEqual(emitter.subscriptions, 1); + emitter.dispatchEvent(new Event('x')); + off(); + emitter.dispatchEvent(new Event('x')); + strictEqual(calls, 1); + strictEqual(emitter.subscriptions, 0); + }, +}; diff --git a/src/tests/events/event-target/which-impl.js b/src/tests/events/event-target/which-impl.js new file mode 100644 index 00000000000..9f929985dee --- /dev/null +++ b/src/tests/events/event-target/which-impl.js @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Distinguishes which events implementation this worker is running, and +// whether the C++ one runs under pedantic_wpt, so tests can pin each side +// of a deliberate divergence exactly. gettersSettersOnPrototype gives the +// C++ AbortSignal's layout, which the TS brand check for {signal} depends +// on (ledger #14). + +const flags = globalThis.Cloudflare.compatibilityFlags; + +export const usingTsImpl = flags['typescript_implemented_events']; +export const pedanticWpt = flags['pedantic_wpt']; +export const gettersSettersOnPrototype = + flags['workers_api_getters_setters_on_prototype'];