/** * @wrnexus/test — testing utilities for WrNexus apps. Runs on `bun test` (via * `wrnexus test`). Import everything from one place: * * import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test"; * * test("counter renders its label", async () => { * const html = await renderComponent(SRC, { start: 3, label: "Hits" }); * expect(html).toContain("Hits"); * }); * * test("home page responds", async () => { * const app = await createHarness("examples/basic-app"); * const res = await app.fetch("/"); * expect(res.status).toBe(200); * await app.close(); * }); */ import { mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { compileWrnFile } from "@wrnexus/compiler"; import { createContext } from "@wrnexus/core"; import type { Context } from "@wrnexus/core"; import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr"; import { startServer } from "@wrnexus/dev-server"; import { loadAppConfig, loadEnv, findStyleEntry, headToString, resolveProfile, } from "@wrnexus/styles"; // One-import DX: re-export the bun:test primitives. export { test, expect, describe, it, beforeEach, afterEach, beforeAll, afterAll, mock, spyOn, } from "bun:test"; export { createContext } from "@wrnexus/core"; // --- Component rendering --------------------------------------------------- let seq = 0; /** Compile a `.wrn` component source + render it to HTML with the given props. */ export async function renderComponent( source: string, props: Record = {}, ): Promise { const dir = join(tmpdir(), "wrnexus-test"); mkdirSync(dir, { recursive: true }); const file = join(dir, `c${seq++}.ts`); writeFileSync(file, compileWrnFile(source, file), "utf8"); const mod = (await import(pathToFileURL(file).href)) as { render?: (props: Record) => string; }; if (typeof mod.render !== "function") { throw new Error("renderComponent expects a `component` (with a render export)"); } return String(mod.render(props)); } // --- Reactive DOM (happy-dom) ---------------------------------------------- /** * Mount server-rendered HTML in a happy-dom window with the reactive runtime * hydrated, so you can test `data-scope`/`data-text`/`data-for`/`data-show` * behaviour. Returns the window; assert on `win.document`. */ export function mountHtml(html: string): { document: Document; window: unknown; querySelector: (sel: string) => Element | null; querySelectorAll: (sel: string) => Element[]; } { // happy-dom is a dev dependency of the workspace; loaded lazily so importing // this package never requires it unless you actually mount DOM. // eslint-disable-next-line @typescript-eslint/no-require-imports const { Window } = require("happy-dom") as { Window: new () => unknown }; const win = new Window() as Record & { document: Document }; (win.document as unknown as { body: { innerHTML: string } }).body.innerHTML = `
${html}
`; const g = globalThis as Record; g.window = win; g.document = win.document; g.NodeFilter = win.NodeFilter; g.HTMLInputElement = win.HTMLInputElement; g.HTMLOptionElement = win.HTMLOptionElement; g.HTMLSelectElement = win.HTMLSelectElement; g.HTMLTextAreaElement = win.HTMLTextAreaElement; (0, eval)(getReactiveRuntime()); (0, eval)(getComponentControllerRuntime()); (win as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.( win.document, ); return { document: win.document, window: win, querySelector: (sel) => win.document.querySelector(sel), querySelectorAll: (sel) => Array.from(win.document.querySelectorAll(sel)), }; } // --- API route handlers ---------------------------------------------------- /** Call an API route handler with a `Context` built from a Request. */ export async function callRoute( handler: (ctx: Context) => Response | Promise, request: Request, ): Promise { const ctx = createContext(request, new URL(request.url)); return handler(ctx); } // --- Full app harness (real in-process server) ----------------------------- export interface Harness { /** Base URL of the ephemeral test server. */ url: string; /** Fetch a path on the app (relative to `url`). */ fetch(path: string, init?: RequestInit): Promise; /** The scanned router (pages/api/realtime/components). */ router: unknown; /** Stop the server. */ close(): void; } export interface HarnessOptions { /** Config/env profile. Default "test". */ profile?: string; } /** * Boot the app on an ephemeral port for integration tests (pages, API routes, * middleware, the full pipeline). Uses the "test" profile by default so it picks * up your test database/env. Remember to `await app.close()`. */ export async function createHarness( projectRoot: string, options: HarnessOptions = {}, ): Promise { const root = resolve(projectRoot); const appDir = join(root, "app"); const profile = resolveProfile({ explicit: options.profile ?? "test" }); loadEnv(root, profile); const config = await loadAppConfig(root, profile); const styleEntry = findStyleEntry(appDir, root, config.styles?.entry); const server = await startServer({ appDir, port: 0, // ephemeral mode: "development", hmr: false, styleEntry, stylesConfig: config.styles, head: headToString(config.head), seo: config.seo, security: config.security, theme: config.theme, i18n: config.i18n, db: config.db, databases: config.databases, }); return { url: server.url, router: server.router, fetch: (path, init) => fetch(server.url + path, init), close: () => server.stop(), }; } export { testRequest, testContext, readJsonResponse, expectProblem, deferred, waitFor, MemoryCookieJar, } from "./advanced.ts"; export type { TestRequestOptions, JsonResponse, Deferred, WaitForOptions } from "./advanced.ts"; export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts"; export type { TransactionalDatabase, BrowserArtifactPage } from "./platform.ts";