@multiplatform.one/test-utils 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.ts ADDED
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Shared test setup for multiplatform.one packages.
3
+ *
4
+ * Use this as a vitest setupFile to get all the polyfills, mocks, and
5
+ * environment patches needed for testing Tamagui + React Native Web components.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // vitest.config.mjs
10
+ * import { createVitestConfig } from "@multiplatform.one/config/vitest";
11
+ * export default createVitestConfig({
12
+ * setupFiles: ["@multiplatform.one/test-utils/setup"],
13
+ * });
14
+ * ```
15
+ */
16
+
17
+ // localStorage polyfill FIRST so Tamagui plugin (tamagui-extract) can run during transform
18
+ const _noopStorage = {
19
+ getItem: () => null,
20
+ setItem: () => {},
21
+ removeItem: () => {},
22
+ clear: () => {},
23
+ get length() {
24
+ return 0;
25
+ },
26
+ key: () => null,
27
+ };
28
+ if (typeof globalThis !== "undefined" && typeof (globalThis as any).localStorage === "undefined") {
29
+ Object.defineProperty(globalThis, "localStorage", { value: _noopStorage, writable: true });
30
+ }
31
+ if (typeof global !== "undefined" && !("localStorage" in global)) {
32
+ Object.defineProperty(global, "localStorage", { value: _noopStorage, writable: true });
33
+ }
34
+
35
+ // DOM polyfills -- must run BEFORE any imports that use them
36
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
37
+ // happy-dom exposes CSSStyleDeclaration index properties as getter-only.
38
+ // Some RN-web/Tamagui render paths can transiently pass array-like style maps
39
+ // during test rendering, which makes React assign style[0], style[1], etc.
40
+ // Defining no-op numeric setters prevents hard crashes in tests.
41
+ if (typeof CSSStyleDeclaration !== "undefined" && CSSStyleDeclaration.prototype) {
42
+ for (let i = 0; i <= 64; i++) {
43
+ const key = String(i);
44
+ const desc = Object.getOwnPropertyDescriptor(CSSStyleDeclaration.prototype, key);
45
+ if (!desc || desc.set == null) {
46
+ Object.defineProperty(CSSStyleDeclaration.prototype, key, {
47
+ configurable: true,
48
+ enumerable: false,
49
+ get() {
50
+ return "";
51
+ },
52
+ set() {
53
+ // no-op in test environment
54
+ },
55
+ });
56
+ }
57
+ }
58
+ }
59
+
60
+ // Fix NodeList.prototype to ensure forEach exists
61
+ if (typeof NodeList !== "undefined" && NodeList.prototype) {
62
+ if (!NodeList.prototype.forEach) {
63
+ (NodeList.prototype as any).forEach = Array.prototype.forEach;
64
+ }
65
+ }
66
+
67
+ // Also add to HTMLCollection
68
+ if (typeof HTMLCollection !== "undefined" && HTMLCollection.prototype) {
69
+ if (!(HTMLCollection.prototype as any).forEach) {
70
+ (HTMLCollection.prototype as any).forEach = Array.prototype.forEach;
71
+ }
72
+ }
73
+
74
+ // Element.matches polyfill
75
+ const matchesImpl = function (this: any, selector: string) {
76
+ if (!this || typeof this !== "object") return false;
77
+ if (this.nodeType === 1) {
78
+ if (this.webkitMatchesSelector) return this.webkitMatchesSelector(selector);
79
+ if (this.mozMatchesSelector) return this.mozMatchesSelector(selector);
80
+ if (this.msMatchesSelector) return this.msMatchesSelector(selector);
81
+ const matches = (this.ownerDocument || document).querySelectorAll(selector);
82
+ let i = matches.length;
83
+ while (--i >= 0 && matches[i] !== this) {}
84
+ return i > -1;
85
+ }
86
+ return false;
87
+ };
88
+
89
+ if (Element.prototype && !Element.prototype.matches) {
90
+ Object.defineProperty(Element.prototype, "matches", {
91
+ value: matchesImpl,
92
+ writable: true,
93
+ configurable: true,
94
+ enumerable: false,
95
+ });
96
+ }
97
+
98
+ if (
99
+ typeof HTMLElement !== "undefined" &&
100
+ HTMLElement.prototype &&
101
+ !HTMLElement.prototype.matches
102
+ ) {
103
+ Object.defineProperty(HTMLElement.prototype, "matches", {
104
+ value: matchesImpl,
105
+ writable: true,
106
+ configurable: true,
107
+ enumerable: false,
108
+ });
109
+ }
110
+
111
+ // hasAttribute / getAttribute / setAttribute polyfills for all Node types
112
+ const hasAttributeImpl = function (this: any, name: string): boolean {
113
+ if (!this || this.nodeType !== 1) return false;
114
+ if (Element.prototype.hasAttribute && this instanceof Element) {
115
+ return Element.prototype.hasAttribute.call(this, name);
116
+ }
117
+ if (this.attributes) {
118
+ for (let i = 0; i < this.attributes.length; i++) {
119
+ if (this.attributes[i].name === name) return true;
120
+ }
121
+ }
122
+ return false;
123
+ };
124
+
125
+ const getAttributeImpl = function (this: any, name: string): string | null {
126
+ if (!this || this.nodeType !== 1) return null;
127
+ if (Element.prototype.getAttribute && this instanceof Element) {
128
+ return Element.prototype.getAttribute.call(this, name);
129
+ }
130
+ if (this.attributes) {
131
+ for (let i = 0; i < this.attributes.length; i++) {
132
+ if (this.attributes[i].name === name) return this.attributes[i].value;
133
+ }
134
+ }
135
+ return null;
136
+ };
137
+
138
+ const nodeTypes = (
139
+ [
140
+ typeof Node !== "undefined" && Node,
141
+ typeof Element !== "undefined" && Element,
142
+ typeof HTMLElement !== "undefined" && HTMLElement,
143
+ typeof HTMLDivElement !== "undefined" && HTMLDivElement,
144
+ typeof HTMLSpanElement !== "undefined" && HTMLSpanElement,
145
+ typeof HTMLInputElement !== "undefined" && HTMLInputElement,
146
+ typeof HTMLButtonElement !== "undefined" && HTMLButtonElement,
147
+ typeof HTMLFormElement !== "undefined" && HTMLFormElement,
148
+ typeof HTMLLabelElement !== "undefined" && HTMLLabelElement,
149
+ typeof Text !== "undefined" && Text,
150
+ typeof Comment !== "undefined" && Comment,
151
+ typeof Document !== "undefined" && Document,
152
+ typeof DocumentFragment !== "undefined" && DocumentFragment,
153
+ ] as Array<false | (new (...args: any[]) => any)>
154
+ ).filter(Boolean) as Array<new (...args: any[]) => any>;
155
+
156
+ for (const NodeType of nodeTypes) {
157
+ if ((NodeType as any)?.prototype) {
158
+ if (!(NodeType as any).prototype.hasAttribute) {
159
+ Object.defineProperty((NodeType as any).prototype, "hasAttribute", {
160
+ value: hasAttributeImpl,
161
+ writable: true,
162
+ configurable: true,
163
+ enumerable: false,
164
+ });
165
+ }
166
+ if (!(NodeType as any).prototype.getAttribute) {
167
+ Object.defineProperty((NodeType as any).prototype, "getAttribute", {
168
+ value: getAttributeImpl,
169
+ writable: true,
170
+ configurable: true,
171
+ enumerable: false,
172
+ });
173
+ }
174
+ if (!(NodeType as any).prototype.setAttribute) {
175
+ Object.defineProperty((NodeType as any).prototype, "setAttribute", {
176
+ value: function (name: string, value: string) {
177
+ if (this.nodeType === 1 && Element.prototype.setAttribute) {
178
+ Element.prototype.setAttribute.call(this, name, value);
179
+ }
180
+ },
181
+ writable: true,
182
+ configurable: true,
183
+ enumerable: false,
184
+ });
185
+ }
186
+ }
187
+ }
188
+
189
+ // Polyfill HTMLFormElement.prototype.requestSubmit for jsdom
190
+ if (typeof HTMLFormElement !== "undefined") {
191
+ Object.defineProperty(HTMLFormElement.prototype, "requestSubmit", {
192
+ value: function (submitter?: HTMLElement) {
193
+ if (submitter) {
194
+ if ((submitter as HTMLButtonElement).type !== "submit") {
195
+ throw new TypeError("The specified element is not a submit button");
196
+ }
197
+ if ((submitter as HTMLButtonElement).form !== this) {
198
+ throw new DOMException(
199
+ "The specified element is not owned by this form element",
200
+ "NotFoundError",
201
+ );
202
+ }
203
+ }
204
+ const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
205
+ this.dispatchEvent(submitEvent);
206
+ },
207
+ writable: true,
208
+ configurable: true,
209
+ enumerable: false,
210
+ });
211
+ }
212
+ }
213
+
214
+ // Now safe to import modules that depend on polyfills
215
+ import "@testing-library/jest-dom";
216
+ import "matchmedia-polyfill";
217
+ import { config } from "@tamagui/config";
218
+ import { cleanup } from "@testing-library/react";
219
+ import { createTamagui } from "tamagui";
220
+ import { afterEach, beforeEach, vi } from "vitest";
221
+
222
+ // Patch @floating-ui/react to handle missing forEach in test environment
223
+ try {
224
+ const floatingUiUtils = await import("@floating-ui/react/utils");
225
+ if (floatingUiUtils?.enableFocusInside) {
226
+ const originalEnableFocusInside = floatingUiUtils.enableFocusInside;
227
+ (floatingUiUtils as any).enableFocusInside = (container: any) => {
228
+ if (!container || !container.querySelectorAll) return;
229
+ const elements = container.querySelectorAll("[data-tabindex]");
230
+ if (!elements.forEach) {
231
+ (elements as any).forEach = Array.prototype.forEach;
232
+ }
233
+ return originalEnableFocusInside(container);
234
+ };
235
+ }
236
+ } catch {
237
+ // Ignore if module not found
238
+ }
239
+
240
+ // Initialize Tamagui with config
241
+ createTamagui(config);
242
+
243
+ // No-op localStorage for Node/vitest
244
+ const noopStorage = {
245
+ getItem: () => null,
246
+ setItem: () => {},
247
+ removeItem: () => {},
248
+ clear: () => {},
249
+ get length() {
250
+ return 0;
251
+ },
252
+ key: () => null,
253
+ };
254
+ vi.stubGlobal("localStorage", noopStorage);
255
+ Object.defineProperty(globalThis, "localStorage", { value: noopStorage, writable: true });
256
+ if (typeof global !== "undefined") {
257
+ Object.defineProperty(global, "localStorage", { value: noopStorage, writable: true });
258
+ }
259
+
260
+ // BroadcastChannel mock
261
+ if (
262
+ typeof globalThis !== "undefined" &&
263
+ typeof (globalThis as any).BroadcastChannel === "undefined"
264
+ ) {
265
+ (globalThis as any).BroadcastChannel = class BroadcastChannelMock {
266
+ name: string;
267
+ onmessage: ((event: MessageEvent) => void) | null = null;
268
+ constructor(name: string) {
269
+ this.name = name;
270
+ }
271
+ postMessage(_message: any) {}
272
+ close() {}
273
+ };
274
+ }
275
+
276
+ // IndexedDB mock
277
+ if (typeof window !== "undefined" && !window.indexedDB) {
278
+ Object.defineProperty(window, "indexedDB", {
279
+ value: {
280
+ open: () => ({ onsuccess: null, onerror: null, onupgradeneeded: null, result: null }),
281
+ deleteDatabase: () => ({ onsuccess: null, onerror: null }),
282
+ },
283
+ writable: true,
284
+ });
285
+ }
286
+
287
+ // Stub fetch to prevent real network calls
288
+ vi.stubGlobal(
289
+ "fetch",
290
+ vi.fn(() =>
291
+ Promise.resolve(
292
+ new Response(JSON.stringify({ data: [] }), {
293
+ status: 200,
294
+ headers: { "Content-Type": "application/json" },
295
+ }),
296
+ ),
297
+ ),
298
+ );
299
+
300
+ // matchMedia polyfill
301
+ window.matchMedia =
302
+ window.matchMedia ||
303
+ (() => ({
304
+ matches: false,
305
+ addListener: () => {},
306
+ removeListener: () => {},
307
+ }));
308
+
309
+ // requestAnimationFrame / cancelAnimationFrame polyfill
310
+ global.requestAnimationFrame = ((callback: FrameRequestCallback): number => {
311
+ return setTimeout(callback, 0) as unknown as number;
312
+ }) as typeof window.requestAnimationFrame;
313
+
314
+ global.cancelAnimationFrame = (id: number): void => {
315
+ clearTimeout(id);
316
+ };
317
+
318
+ // ResizeObserver mock
319
+ class MockResizeObserver {
320
+ observe() {}
321
+ unobserve() {}
322
+ disconnect() {}
323
+ }
324
+ window.ResizeObserver = MockResizeObserver as any;
325
+
326
+ // IntersectionObserver mock
327
+ class MockIntersectionObserver implements IntersectionObserver {
328
+ readonly root: Element | null = null;
329
+ readonly rootMargin: string = "0px";
330
+ readonly thresholds: ReadonlyArray<number> = [0];
331
+ observe(): void {}
332
+ unobserve(): void {}
333
+ disconnect(): void {}
334
+ takeRecords(): IntersectionObserverEntry[] {
335
+ return [];
336
+ }
337
+ }
338
+ if (typeof window !== "undefined") {
339
+ Object.defineProperty(window, "IntersectionObserver", {
340
+ value: MockIntersectionObserver,
341
+ writable: true,
342
+ configurable: true,
343
+ });
344
+ }
345
+ Object.defineProperty(globalThis, "IntersectionObserver", {
346
+ value: MockIntersectionObserver,
347
+ writable: true,
348
+ configurable: true,
349
+ });
350
+ if (typeof global !== "undefined") {
351
+ Object.defineProperty(global, "IntersectionObserver", {
352
+ value: MockIntersectionObserver,
353
+ writable: true,
354
+ configurable: true,
355
+ });
356
+ }
357
+ if (typeof self !== "undefined") {
358
+ Object.defineProperty(self, "IntersectionObserver", {
359
+ value: MockIntersectionObserver,
360
+ writable: true,
361
+ configurable: true,
362
+ });
363
+ }
364
+
365
+ // DataTransfer mock
366
+ if (typeof DataTransfer === "undefined") {
367
+ (global as any).DataTransfer = class DataTransfer {
368
+ private data: Map<string, string>;
369
+ constructor() {
370
+ this.data = new Map();
371
+ }
372
+ getData(format: string): string {
373
+ return this.data.get(format) || "";
374
+ }
375
+ setData(format: string, data: string): void {
376
+ this.data.set(format, data);
377
+ }
378
+ clearData(format?: string): void {
379
+ if (format) {
380
+ this.data.delete(format);
381
+ } else {
382
+ this.data.clear();
383
+ }
384
+ }
385
+ };
386
+ }
387
+
388
+ // ClipboardEvent mock
389
+ if (typeof ClipboardEvent === "undefined") {
390
+ (global as any).ClipboardEvent = class ClipboardEvent extends Event {
391
+ clipboardData: any;
392
+ constructor(type: string, options?: any) {
393
+ super(type, options);
394
+ this.clipboardData = options?.clipboardData || {
395
+ getData: () => "",
396
+ setData: () => {},
397
+ };
398
+ }
399
+ };
400
+ }
401
+
402
+ // TextEncoder fix for jsdom
403
+ class ESBuildAndJSDOMCompatibleTextEncoder extends TextEncoder {
404
+ encode(input: string) {
405
+ if (typeof input !== "string") throw new TypeError("`input` must be a string");
406
+ const decodedURI = decodeURIComponent(encodeURIComponent(input));
407
+ const arr = new Uint8Array(decodedURI.length);
408
+ const chars = decodedURI.split("");
409
+ for (let i = 0; i < chars.length; i++) {
410
+ arr[i] = (decodedURI[i] || "").charCodeAt(0);
411
+ }
412
+ return arr;
413
+ }
414
+ }
415
+ Object.defineProperty(global, "TextEncoder", {
416
+ value: ESBuildAndJSDOMCompatibleTextEncoder,
417
+ writable: true,
418
+ });
419
+
420
+ // Mock @multiplatform.one/theme
421
+ vi.mock("@multiplatform.one/theme", () => {
422
+ const emptyState = {};
423
+ const stateRecipe = {
424
+ hoverKnobProps: undefined,
425
+ pressKnobProps: undefined,
426
+ focusKnobProps: undefined,
427
+ focusVisibleKnobProps: undefined,
428
+ };
429
+ const knobProps = {
430
+ borderRadius: { borderRadius: "$4", borderWidth: 1 },
431
+ borderRadiusOuter: { borderRadius: "$5" },
432
+ elevation: undefined,
433
+ surface: { borderWidth: 1 },
434
+ inputSurface: { borderWidth: 1 },
435
+ panelPadding: { padding: "$4" },
436
+ gap: { gap: "$3" },
437
+ gapLg: { gap: "$4" },
438
+ sizeToken: "$4",
439
+ heading: { fontFamily: "$heading", fontWeight: "400" },
440
+ body: { fontFamily: "$body", fontWeight: "400" },
441
+ textWeight: { fontWeight: "400" },
442
+ transition: "quick",
443
+ outlined: false,
444
+ textAccent: "high",
445
+ space: "medium",
446
+ hoverStyle: emptyState,
447
+ pressStyle: emptyState,
448
+ focusStyle: emptyState,
449
+ focusVisibleStyle: emptyState,
450
+ };
451
+ return {
452
+ useResolvedKnobs: () => ({
453
+ knobProps,
454
+ control: { ...stateRecipe },
455
+ text: { ...stateRecipe },
456
+ elevation: { ...stateRecipe },
457
+ }),
458
+ };
459
+ });
460
+
461
+ // Mock 'one' package
462
+ vi.mock("one", () => ({
463
+ default: {},
464
+ SafeAreaView: ({ children }: { children: React.ReactNode }) => children,
465
+ Head: ({ children }: { children: React.ReactNode }) => children,
466
+ useRouter: () => ({
467
+ push: vi.fn(),
468
+ replace: vi.fn(),
469
+ back: vi.fn(),
470
+ }),
471
+ }));
472
+
473
+ // Mock @tamagui/animations-reanimated (used by @multiplatform.one/theme animations)
474
+ vi.mock("@tamagui/animations-reanimated", () => ({
475
+ createAnimations: (config: any) => ({
476
+ ...config,
477
+ View: undefined,
478
+ Text: undefined,
479
+ isReactNative: false,
480
+ animations: config,
481
+ useAnimatedNumber: (initial: any) => ({ value: initial, setValue: () => {} }),
482
+ useAnimatedNumberReaction: () => {},
483
+ useAnimatedNumberStyle: () => ({}),
484
+ AnimatedView: ({ children }: any) => children,
485
+ AnimatedText: ({ children }: any) => children,
486
+ }),
487
+ }));
488
+
489
+ // Mock react-native-reanimated to prevent ESM resolution failures
490
+ // (publicGlobals missing .js extension in ESM import from index.js)
491
+ vi.mock("react-native-reanimated", () => ({
492
+ default: {
493
+ useSharedValue: (init: any) => ({ value: init }),
494
+ useAnimatedStyle: () => ({}),
495
+ withTiming: (val: any) => val,
496
+ withSpring: (val: any) => val,
497
+ withDecay: (val: any) => val,
498
+ withSequence: (...args: any[]) => args[args.length - 1],
499
+ withDelay: (_: any, val: any) => val,
500
+ createAnimatedComponent: (component: any) => component,
501
+ useDerivedValue: (fn: () => any) => ({ value: fn() }),
502
+ useAnimatedRef: () => ({ current: null }),
503
+ runOnJS: (fn: any) => fn,
504
+ runOnUI: (fn: any) => fn,
505
+ Extrapolation: { CLAMP: "clamp" },
506
+ Layout: { duration: () => ({}) },
507
+ FadeIn: { duration: () => ({}) },
508
+ FadeOut: { duration: () => ({}) },
509
+ Easing: {
510
+ linear: (v: any) => v,
511
+ ease: (v: any) => v,
512
+ bezier: () => (v: any) => v,
513
+ },
514
+ measure: () => null,
515
+ scrollTo: () => {},
516
+ makeMutable: (init: any) => ({ value: init }),
517
+ },
518
+ useSharedValue: (init: any) => ({ value: init }),
519
+ useAnimatedStyle: () => ({}),
520
+ withTiming: (val: any) => val,
521
+ withSpring: (val: any) => val,
522
+ createAnimatedComponent: (component: any) => component,
523
+ runOnJS: (fn: any) => fn,
524
+ runOnUI: (fn: any) => fn,
525
+ useDerivedValue: (fn: () => any) => ({ value: fn() }),
526
+ useAnimatedRef: () => ({ current: null }),
527
+ Easing: {
528
+ linear: (v: any) => v,
529
+ ease: (v: any) => v,
530
+ bezier: () => (v: any) => v,
531
+ },
532
+ FadeIn: { duration: () => ({}) },
533
+ FadeOut: { duration: () => ({}) },
534
+ Layout: { duration: () => ({}) },
535
+ }));
536
+
537
+ // NOTE: @phosphor-icons/react is NOT mocked here. Do NOT add vi.mock() for it
538
+ // in this setup file — doing so triggers vitest to resolve the full barrel
539
+ // (3024+ icon files) before the mock factory can replace it, causing an
540
+ // infinite hang. The real icons work fine in happy-dom (they're just SVG
541
+ // components), so no mock is needed.
542
+
543
+ // Mock @react-navigation/native
544
+ vi.mock("@react-navigation/native", () => ({
545
+ NavigationContainer: ({ children }: { children: React.ReactNode }) => children,
546
+ useNavigation: () => ({}),
547
+ useRoute: () => ({}),
548
+ useFocusEffect: () => {},
549
+ }));
550
+
551
+ // Cleanup after each test
552
+ // Fix pointer-events: none that Tamagui sets on <html>, which causes
553
+ // @testing-library/user-event to refuse pointer interactions.
554
+ if (typeof document !== "undefined") {
555
+ const style = document.createElement("style");
556
+ style.textContent = "html { pointer-events: auto !important; }";
557
+ document.head.appendChild(style);
558
+ }
559
+
560
+ const ignoredTestWarnings = [
561
+ "React does not recognize the `scaleIcon` prop on a DOM element",
562
+ "React does not recognize the `pressTheme` prop on a DOM element",
563
+ "React does not recognize the `marginLeft` prop on a DOM element",
564
+ "Received `true` for a non-boolean attribute `editable`",
565
+ "non-boolean attribute `editable`",
566
+ "Received `true` for a non-boolean attribute `elevate`",
567
+ "Received `true` for a non-boolean attribute `bordered`",
568
+ "react-i18next:: useTranslation: You will need to pass in an i18next instance",
569
+ ];
570
+
571
+ const originalConsoleWarn = console.warn.bind(console);
572
+ const originalConsoleError = console.error.bind(console);
573
+ const shouldIgnoreTestWarning = (args: unknown[]) => {
574
+ const message = args
575
+ .map((arg) => {
576
+ if (typeof arg === "string") return arg;
577
+ if (arg instanceof Error) return arg.message;
578
+ return String(arg);
579
+ })
580
+ .join(" ");
581
+ if (ignoredTestWarnings.some((pattern) => message.includes(pattern))) {
582
+ return true;
583
+ }
584
+
585
+ // React often logs warnings with printf placeholders:
586
+ // "Received `%s` for a non-boolean attribute `%s`." + substitution args.
587
+ const firstArg = typeof args[0] === "string" ? args[0] : "";
588
+ const hasNonBooleanTemplate = firstArg.includes("non-boolean attribute");
589
+ if (hasNonBooleanTemplate) {
590
+ const tokens = args.map((arg) => String(arg));
591
+ if (tokens.includes("editable") || tokens.includes("elevate") || tokens.includes("bordered")) {
592
+ return true;
593
+ }
594
+ }
595
+
596
+ return false;
597
+ };
598
+
599
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn> | null = null;
600
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn> | null = null;
601
+
602
+ beforeEach(() => {
603
+ Object.defineProperty(globalThis, "IntersectionObserver", {
604
+ value: MockIntersectionObserver,
605
+ writable: true,
606
+ configurable: true,
607
+ });
608
+ if (typeof window !== "undefined") {
609
+ Object.defineProperty(window, "IntersectionObserver", {
610
+ value: MockIntersectionObserver,
611
+ writable: true,
612
+ configurable: true,
613
+ });
614
+ }
615
+ if (typeof global !== "undefined") {
616
+ Object.defineProperty(global, "IntersectionObserver", {
617
+ value: MockIntersectionObserver,
618
+ writable: true,
619
+ configurable: true,
620
+ });
621
+ }
622
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
623
+ if (shouldIgnoreTestWarning(args)) return;
624
+ originalConsoleWarn(...args);
625
+ });
626
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
627
+ if (shouldIgnoreTestWarning(args)) return;
628
+ originalConsoleError(...args);
629
+ });
630
+ });
631
+
632
+ afterEach(() => {
633
+ Object.defineProperty(globalThis, "IntersectionObserver", {
634
+ value: MockIntersectionObserver,
635
+ writable: true,
636
+ configurable: true,
637
+ });
638
+ if (typeof window !== "undefined") {
639
+ Object.defineProperty(window, "IntersectionObserver", {
640
+ value: MockIntersectionObserver,
641
+ writable: true,
642
+ configurable: true,
643
+ });
644
+ }
645
+ consoleWarnSpy?.mockRestore();
646
+ consoleErrorSpy?.mockRestore();
647
+ consoleWarnSpy = null;
648
+ consoleErrorSpy = null;
649
+ cleanup();
650
+ });