196 lines
6.2 KiB
TypeScript
196 lines
6.2 KiB
TypeScript
/**
|
|
* @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<string, unknown> = {},
|
|
): Promise<string> {
|
|
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, unknown>) => 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<string, unknown> & { document: Document };
|
|
(win.document as unknown as { body: { innerHTML: string } }).body.innerHTML =
|
|
`<div id="app">${html}</div>`;
|
|
const g = globalThis as Record<string, unknown>;
|
|
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<Response>,
|
|
request: Request,
|
|
): Promise<Response> {
|
|
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<Response>;
|
|
/** 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<Harness> {
|
|
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";
|