137 lines
3.9 KiB
TypeScript
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;
|
|
}
|
|
}
|