From 59a0e901e47b88ced736fc0db864b0728c51b22d Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 31 Jul 2026 17:15:05 -0600 Subject: [PATCH 1/2] test(web): silence msw and jsdom log noise Add global MSW handlers for availability and email-updates, export scoped calendars defaults for opt-in tests, and redact jsdom Window/Event graphs from Bun expect diffs and console output. --- .../__mocks__/server/mock.handlers.ts | 36 +++++ packages/web/src/__tests__/setup/jsdom-env.ts | 125 +++++++++++++++++- .../Grid/MainGrid/MainGrid.test.tsx | 3 +- .../MainGrid/MainGridBusyPeriods.test.tsx | 5 +- .../eventReadOnlyInteraction.test.tsx | 4 +- .../hooks/shortcuts/useWeekShortcuts.test.tsx | 4 +- 6 files changed, 163 insertions(+), 14 deletions(-) diff --git a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts index e023b62be8..89c98df404 100644 --- a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts +++ b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts @@ -3,6 +3,10 @@ import { rest } from "msw"; import { Origin } from "@core/constants/core.constants"; import { Status } from "@core/errors/status.codes"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; +import { + getLocalCalendarSentinelId, + synthesizeLocalCalendar, +} from "@web/calendars/local-calendar.sentinel"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { freshenEventStartEndDate } from "@web/views/Week/week-view.render.test.utils"; @@ -17,10 +21,36 @@ const createGoogleImportEvent: typeof createMockStandaloneEvent = ( dateDiff, ); +// Default authenticated calendars response. Uses the local sentinel id so +// unseeded mounts that race past session auth still get a writable column +// instead of an MSW unhandled-request error. Scoped to per-test server.use +// overrides when tests need a different fixture list — not registered globally +// because a default /calendars success changes event-list calendarIds and +// breaks suite-order-dependent hook/grid tests that expect the legacy +// undefined (all-calendars) read until calendars are explicitly seeded. +export const defaultMockCalendars = [ + synthesizeLocalCalendar(getLocalCalendarSentinelId()), +]; + +export const defaultCalendarsHandlers = [ + rest.get(`${ENV_WEB.API_BASEURL}/calendars`, (_req, res, ctx) => { + return res( + ctx.status(Status.OK), + ctx.json({ calendars: defaultMockCalendars }), + ); + }), +]; + 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 +89,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], From 903666cf6ffbdb8f6002a85eb7ea17659d135c5b Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 31 Jul 2026 17:18:59 -0600 Subject: [PATCH 2/2] test(web): drop unused calendars msw export Keep /calendars off globalHandlers to avoid calendarIds regressions and satisfy knip without exporting dead handler helpers. --- .../__mocks__/server/mock.handlers.ts | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts index 89c98df404..d60ae8356d 100644 --- a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts +++ b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts @@ -3,10 +3,6 @@ import { rest } from "msw"; import { Origin } from "@core/constants/core.constants"; import { Status } from "@core/errors/status.codes"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; -import { - getLocalCalendarSentinelId, - synthesizeLocalCalendar, -} from "@web/calendars/local-calendar.sentinel"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { freshenEventStartEndDate } from "@web/views/Week/week-view.render.test.utils"; @@ -21,25 +17,11 @@ const createGoogleImportEvent: typeof createMockStandaloneEvent = ( dateDiff, ); -// Default authenticated calendars response. Uses the local sentinel id so -// unseeded mounts that race past session auth still get a writable column -// instead of an MSW unhandled-request error. Scoped to per-test server.use -// overrides when tests need a different fixture list — not registered globally -// because a default /calendars success changes event-list calendarIds and -// breaks suite-order-dependent hook/grid tests that expect the legacy -// undefined (all-calendars) read until calendars are explicitly seeded. -export const defaultMockCalendars = [ - synthesizeLocalCalendar(getLocalCalendarSentinelId()), -]; - -export const defaultCalendarsHandlers = [ - rest.get(`${ENV_WEB.API_BASEURL}/calendars`, (_req, res, ctx) => { - return res( - ctx.status(Status.OK), - ctx.json({ calendars: defaultMockCalendars }), - ); - }), -]; +// 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) => {