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],