diff --git a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts index e023b62be8..d60ae8356d 100644 --- a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts +++ b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts @@ -17,10 +17,22 @@ const createGoogleImportEvent: typeof createMockStandaloneEvent = ( dateDiff, ); +// Authenticated mounts that race past session auth may fetch /calendars. +// Do not register a global handler here: a default success changes event-list +// calendarIds and breaks suite-order-dependent hook/grid tests that expect +// the legacy undefined (all-calendars) read until calendars are seeded. +// Tests that need a default response can server.use(rest.get(...)) locally. + export const globalHandlers = [ rest.get("http://localhost/version.json", (_req, res, ctx) => { return res(ctx.json({ version: "dev" })); }), + rest.get( + `${ENV_WEB.API_BASEURL}/calendars/availability`, + (_req, res, ctx) => { + return res(ctx.status(Status.OK), ctx.json({ busyPeriods: [] })); + }, + ), rest.get(`${ENV_WEB.API_BASEURL}/event`, (_req, res, ctx) => { const events = [ createGoogleImportEvent(), @@ -59,6 +71,12 @@ export const globalHandlers = [ rest.post(`${ENV_WEB.API_BASEURL}/user/metadata`, (req, res, ctx) => { return res(ctx.status(Status.OK), ctx.json(req.json())); }), + rest.get(`${ENV_WEB.API_BASEURL}/user/email-updates`, (_req, res, ctx) => { + return res(ctx.status(Status.OK), ctx.json({ status: "unavailable" })); + }), + rest.put(`${ENV_WEB.API_BASEURL}/user/email-updates`, (_req, res, ctx) => { + return res(ctx.status(Status.OK), ctx.json({ status: "subscribed" })); + }), rest.post(`${ENV_WEB.API_BASEURL}/signinup`, (_req, res, ctx) => { return res(ctx.json({ isNewUser: true })); }), diff --git a/packages/web/src/__tests__/setup/jsdom-env.ts b/packages/web/src/__tests__/setup/jsdom-env.ts index 3872a42550..80ef0567e9 100644 --- a/packages/web/src/__tests__/setup/jsdom-env.ts +++ b/packages/web/src/__tests__/setup/jsdom-env.ts @@ -1,4 +1,5 @@ import { JSDOM } from "jsdom"; +import { inspect } from "node:util"; export const dom = new JSDOM("", { pretendToBeVisual: true, @@ -23,10 +24,7 @@ Object.defineProperty(window, "HTMLIFrameElement", { globalThis.HTMLIFrameElement = window.HTMLIFrameElement; globalThis.HTMLAnchorElement = window.HTMLAnchorElement; globalThis.Node = window.Node; -globalThis.Event = window.Event; -globalThis.CustomEvent = window.CustomEvent; -globalThis.MouseEvent = window.MouseEvent; -globalThis.KeyboardEvent = window.KeyboardEvent; + // Bun's native globalThis.dispatchEvent/addEventListener operate on Bun's own // Event realm. Dexie constructs `new CustomEvent(...)` against the jsdom // Event class above, so dispatching through Bun's native EventTarget throws @@ -41,3 +39,122 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const noopAlert = () => {}; window.alert = noopAlert; globalThis.alert = noopAlert; + +// Bun/util.inspect walks jsdom Window/Event graphs by default (event +// listeners → document → SymbolTree → …), which can dump megabytes into +// failing-test diffs and console.error output. Keep a short label instead. +const inspectCustom = inspect.custom; + +Object.defineProperty(window, inspectCustom, { + configurable: true, + value() { + return "Window [jsdom]"; + }, +}); +Object.defineProperty(window.document, inspectCustom, { + configurable: true, + value() { + return "Document [jsdom]"; + }, +}); +Object.defineProperty(window.Node.prototype, inspectCustom, { + configurable: true, + value(this: Node) { + const name = this.nodeName?.toLowerCase?.() ?? "node"; + const id = this instanceof window.Element && this.id ? `#${this.id}` : ""; + return `${name}${id} [jsdom]`; + }, +}); +Object.defineProperty(window.Event.prototype, inspectCustom, { + configurable: true, + value(this: Event) { + return `${this.constructor?.name ?? "Event"}(${this.type}) [jsdom]`; + }, +}); + +// Bun's expect() diffs do not honor util.inspect.custom. They walk +// Event[Symbol(impl)]._globalObject into the full Window graph. Replace that +// field with a Proxy that still forwards gets for jsdom, but exposes no own +// keys for Bun's property enumerator. +function redactEventImplGlobalObject(event: Event) { + const implSym = Object.getOwnPropertySymbols(event).find( + (symbol) => String(symbol) === "Symbol(impl)", + ); + if (!implSym) return; + + const impl = (event as unknown as Record>)[ + implSym + ]; + const globalObject = impl?._globalObject; + if (!globalObject || typeof globalObject !== "object") return; + + const stub = new Proxy(globalObject, { + ownKeys() { + return []; + }, + getOwnPropertyDescriptor() { + return undefined; + }, + get(target, prop, receiver) { + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + return Reflect.has(target, prop); + }, + }); + + Object.defineProperty(impl, "_globalObject", { + configurable: true, + enumerable: true, + writable: true, + value: stub, + }); +} + +const EVENT_CONSTRUCTOR_NAMES = [ + "Event", + "CustomEvent", + "KeyboardEvent", + "MouseEvent", + "PointerEvent", + "FocusEvent", + "StorageEvent", + "InputEvent", + "WheelEvent", + "UIEvent", + "CompositionEvent", + "DragEvent", + "ClipboardEvent", + "SubmitEvent", + "MessageEvent", + "ErrorEvent", + "ProgressEvent", +] as const; + +type EventConstructor = new (...args: never[]) => Event; + +for (const name of EVENT_CONSTRUCTOR_NAMES) { + const Original = window[name as keyof Window]; + if (typeof Original !== "function") continue; + const OriginalCtor = Original as EventConstructor; + + const Redacted = function RedactedEvent( + this: unknown, + ...args: unknown[] + ): Event { + const event = Reflect.construct(OriginalCtor, args, new.target ?? Redacted); + redactEventImplGlobalObject(event as Event); + return event as Event; + }; + + Redacted.prototype = OriginalCtor.prototype; + Object.defineProperty(Redacted, "name", { value: name }); + Object.setPrototypeOf(Redacted, OriginalCtor); + + Object.defineProperty(window, name, { + configurable: true, + writable: true, + value: Redacted, + }); + (globalThis as Record)[name] = Redacted; +} diff --git a/packages/web/src/views/Week/components/Grid/MainGrid/MainGrid.test.tsx b/packages/web/src/views/Week/components/Grid/MainGrid/MainGrid.test.tsx index 7e93df7270..008d2be327 100644 --- a/packages/web/src/views/Week/components/Grid/MainGrid/MainGrid.test.tsx +++ b/packages/web/src/views/Week/components/Grid/MainGrid/MainGrid.test.tsx @@ -83,8 +83,7 @@ const toStrictEvent = (event: CompassEvent): Event => function Provider({ children }: PropsWithChildren) { // useState initializer: one client per mounted tree. Rebuilding an empty - // client on re-render makes the grid's calendars query really fetch - // /api/calendars (no handler here) - timing-dependent on slow CI runners. + // client on re-render drops seeded event/pending-mutation cache. const [queryClient] = useState(() => { const client = createCompassQueryClient(); seedPendingEventMutations(client, pendingEventIds); diff --git a/packages/web/src/views/Week/components/Grid/MainGrid/MainGridBusyPeriods.test.tsx b/packages/web/src/views/Week/components/Grid/MainGrid/MainGridBusyPeriods.test.tsx index e4929eb217..d255892747 100644 --- a/packages/web/src/views/Week/components/Grid/MainGrid/MainGridBusyPeriods.test.tsx +++ b/packages/web/src/views/Week/components/Grid/MainGrid/MainGridBusyPeriods.test.tsx @@ -78,9 +78,8 @@ const measurements = { // useState initializer: exactly one client per mounted tree (matches // eventReadOnlyInteraction.test.tsx's Provider) - seeding in the render body -// would rebuild an empty client on every re-render and the fresh cache would -// then really try to fetch /api/calendars and /api/calendars/availability -// (no handlers here), a timing-dependent failure on slow CI runners. +// would rebuild an empty client on every re-render and drop the fixture +// calendars/availability cache. function Provider({ children }: PropsWithChildren) { const [queryClient] = useState(() => { const client = createCompassQueryClient(); diff --git a/packages/web/src/views/Week/components/Grid/MainGrid/eventReadOnlyInteraction.test.tsx b/packages/web/src/views/Week/components/Grid/MainGrid/eventReadOnlyInteraction.test.tsx index 0ae9e25820..664219a59b 100644 --- a/packages/web/src/views/Week/components/Grid/MainGrid/eventReadOnlyInteraction.test.tsx +++ b/packages/web/src/views/Week/components/Grid/MainGrid/eventReadOnlyInteraction.test.tsx @@ -49,9 +49,7 @@ let seededCalendars: Calendar[] = []; function Provider({ children }: PropsWithChildren) { // useState initializer: exactly one client per mounted tree. Creating and // seeding in the render body rebuilds an EMPTY client on every re-render, - // and the fresh cache then really fetches /api/calendars (no handler in - // this file) - a timing-dependent failure that only shows on slow (CI) - // runners. + // which drops the seeded calendars/events and races a network refetch. const [queryClient] = useState(() => { const client = createCompassQueryClient(); seedEventQueries(client, seededEvents); diff --git a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.test.tsx b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.test.tsx index 0764ce722c..739d45175f 100644 --- a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.test.tsx +++ b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.test.tsx @@ -198,8 +198,8 @@ const renderShortcuts = (options?: { initialData: toNormalizedEventQueryData(events), }); // Always seed calendars so useWeekEventViewModel's visibility filter and - // useCalendarsQuery don't race a network fetch (MSW has no /api/calendars - // handler in this file). Default = writable calendar for the editable event. + // useCalendarsQuery see the fixture calendars instead of racing a fetch. + // Default = writable calendar for the editable event. queryClient.setQueryData( calendarQueryKeys.all, options?.calendars ?? [writableCalendar],