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/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']; 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']; 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. } 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;