Files
suaveplan/packages/devtools/tooling/test-vite/src/browser-environment/browser-environment.ts
2026-05-27 23:47:53 +01:00

137 lines
3.9 KiB
TypeScript

import { webcrypto } from "node:crypto";
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
const BrowserEnvironmentOptionsSchema = z
.object({
target: z.unknown().optional(),
mockLocalStorage: z.boolean().default(true),
mockMatchMedia: z.boolean().default(true),
mockResizeObserver: z.boolean().default(true),
mockIntersectionObserver: z.boolean().default(true),
mockCrypto: z.boolean().default(true),
})
.strict();
export type BrowserEnvironmentOptions = z.input<
typeof BrowserEnvironmentOptionsSchema
>;
type TargetRecord = Record<string, unknown>;
class MemoryStorage {
private readonly store = new Map<string, string>();
public get length(): number {
return this.store.size;
}
public clear(): void {
this.store.clear();
}
public getItem(key: string): string | null {
return this.store.has(key) ? (this.store.get(key) as string) : null;
}
public key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null;
}
public removeItem(key: string): void {
this.store.delete(key);
}
public setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
}
function fakeMatchMedia(query: string): {
matches: boolean;
media: string;
addEventListener: () => void;
removeEventListener: () => void;
addListener: () => void;
removeListener: () => void;
onchange: null;
dispatchEvent: () => boolean;
} {
return {
matches: false,
media: query,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
onchange: null,
dispatchEvent: () => true,
};
}
class FakeResizeObserver {
public observe(): void {
/* noop */
}
public unobserve(): void {
/* noop */
}
public disconnect(): void {
/* noop */
}
}
class FakeIntersectionObserver {
public readonly root: null = null;
public readonly rootMargin: string = "";
public readonly thresholds: readonly number[] = [];
public observe(): void {
/* noop */
}
public unobserve(): void {
/* noop */
}
public disconnect(): void {
/* noop */
}
public takeRecords(): readonly unknown[] {
return [];
}
}
/**
* Install DOM environment shims on `target` (defaults to `globalThis`).
*
* Each shim is installed only when absent on the target — re-running the
* function on an already-set-up target is a no-op. Individual shims can
* be skipped with the `mock*: false` flags.
*
* @throws {TestViteError} with `TEST_VITE.INVALID_OPTIONS` on bad input.
*/
export function setupBrowserEnvironment(
opts: BrowserEnvironmentOptions = {}
): void {
const parsed = BrowserEnvironmentOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"setupBrowserEnvironment: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
const target = (o.target ?? globalThis) as TargetRecord;
if (o.mockLocalStorage && target["localStorage"] === undefined) {
target["localStorage"] = new MemoryStorage();
}
if (o.mockMatchMedia && target["matchMedia"] === undefined) {
target["matchMedia"] = fakeMatchMedia;
}
if (o.mockResizeObserver && target["ResizeObserver"] === undefined) {
target["ResizeObserver"] = FakeResizeObserver;
}
if (
o.mockIntersectionObserver &&
target["IntersectionObserver"] === undefined
) {
target["IntersectionObserver"] = FakeIntersectionObserver;
}
if (o.mockCrypto && target["crypto"] === undefined) {
target["crypto"] = webcrypto;
}
}