first commit
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
# @wrnexus/test
|
||||
|
||||
> Testing utilities for WrNexus apps — component rendering, reactive-DOM mounting, route handler calls, and a full in-process app harness, plus a one-import re-export of `bun:test`.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/test` is the server-side test toolkit you reach for when writing tests
|
||||
for a WrNexus app. It runs under `bun test` (invoked via `wrnexus test`) and gives
|
||||
you a single import surface: the `bun:test` primitives (`test`, `expect`, `mock`,
|
||||
…) re-exported alongside WrNexus-aware helpers that compile `.wrn` components,
|
||||
hydrate server HTML in a DOM, invoke API route handlers, and boot the real app on
|
||||
an ephemeral port for integration tests.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/test
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
### Re-exported test primitives
|
||||
|
||||
For one-import DX, the following are re-exported straight from `bun:test`:
|
||||
|
||||
`test`, `expect`, `describe`, `it`, `beforeEach`, `afterEach`, `beforeAll`,
|
||||
`afterAll`, `mock`, `spyOn`.
|
||||
|
||||
`createContext` is also re-exported from `@wrnexus/core`.
|
||||
|
||||
### `renderComponent(source, props?)`
|
||||
|
||||
```ts
|
||||
function renderComponent(source: string, props?: Record<string, unknown>): Promise<string>;
|
||||
```
|
||||
|
||||
Compiles a `.wrn` component `source` string (via `@wrnexus/compiler`) and renders
|
||||
it to an HTML string with the given `props`. Throws if the compiled module has no
|
||||
`render` export.
|
||||
|
||||
### `mountHtml(html)`
|
||||
|
||||
```ts
|
||||
function mountHtml(html: string): {
|
||||
document: Document;
|
||||
window: unknown;
|
||||
querySelector: (sel: string) => Element | null;
|
||||
querySelectorAll: (sel: string) => Element[];
|
||||
};
|
||||
```
|
||||
|
||||
Mounts 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 plus `document` and query helpers; assert on those.
|
||||
|
||||
> `happy-dom` is loaded lazily (via `require`), so importing this package never
|
||||
> requires it unless you actually call `mountHtml`.
|
||||
|
||||
### `callRoute(handler, request)`
|
||||
|
||||
```ts
|
||||
function callRoute(
|
||||
handler: (ctx: Context) => Response | Promise<Response>,
|
||||
request: Request,
|
||||
): Promise<Response>;
|
||||
```
|
||||
|
||||
Calls an API route `handler` with a `Context` built from a `Request` (using
|
||||
`createContext`). Returns the handler's `Response`.
|
||||
|
||||
### `createHarness(projectRoot, options?)`
|
||||
|
||||
```ts
|
||||
function createHarness(projectRoot: string, options?: HarnessOptions): Promise<Harness>;
|
||||
|
||||
interface HarnessOptions {
|
||||
/** Config/env profile. Default "test". */
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
Boots the app at `projectRoot` on an ephemeral port (`port: 0`) for integration
|
||||
tests covering pages, API routes, middleware, and the full request pipeline. Loads
|
||||
env and app config for the given `profile` (default `"test"`) so it picks up your
|
||||
test database/env. The server runs in `development` mode with HMR disabled.
|
||||
Remember to `await app.close()` when done.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
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("reactive scope hydrates", () => {
|
||||
const { querySelector } = mountHtml(serverHtml);
|
||||
expect(querySelector("[data-text]")?.textContent).toBe("3");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
```
|
||||
|
||||
Calling an API route handler directly:
|
||||
|
||||
```ts
|
||||
import { test, expect, callRoute } from "@wrnexus/test";
|
||||
import { GET } from "../app/api/health.ts";
|
||||
|
||||
test("health endpoint", async () => {
|
||||
const res = await callRoute(GET, new Request("http://test/api/health"));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Runs under `bun test` (via `wrnexus test`); uses Bun's module
|
||||
loading and the `bun:test` runtime.
|
||||
- `mountHtml` requires **`happy-dom`** to be available in the workspace (loaded
|
||||
lazily; it's a dev dependency, not a runtime dependency of this package).
|
||||
- Works with the rest of the WrNexus toolchain:
|
||||
[`@wrnexus/compiler`](../compiler) (compiles `.wrn` sources),
|
||||
[`@wrnexus/core`](../core) (`Context` / `createContext`),
|
||||
[`@wrnexus/csr`](../csr) (reactive runtime for `mountHtml`),
|
||||
[`@wrnexus/dev-server`](../dev-server) (`startServer` behind `createHarness`),
|
||||
and [`@wrnexus/styles`](../styles) (config/env/profile loading for the harness).
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @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 { compileWireFile } from "@wrnexus/compiler";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { 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, compileWireFile(source), "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;
|
||||
(0, eval)(getReactiveRuntime());
|
||||
(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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect, renderComponent, mountHtml, callRoute } from "../src/index.ts";
|
||||
|
||||
const COUNTER = `component Counter {
|
||||
props {
|
||||
start = 0
|
||||
label = "Count"
|
||||
}
|
||||
state count = start
|
||||
view { <button @click="count++">{label}: {count}</button> }
|
||||
}`;
|
||||
|
||||
test("renderComponent compiles + renders a component with props", async () => {
|
||||
const html = await renderComponent(COUNTER, { start: 7, label: "Hits" });
|
||||
expect(html).toContain("Hits");
|
||||
expect(html).toContain('data-text="count"'); // reactive state span
|
||||
expect(html).toContain("7"); // baked initial value
|
||||
});
|
||||
|
||||
test("mountHtml hydrates the reactive runtime for DOM assertions", () => {
|
||||
const dom = mountHtml(
|
||||
`<div data-scope="items: [{t:'a'},{t:'b'}]"><ul><li data-for="i in items" data-text="i.t"></li></ul></div>`,
|
||||
);
|
||||
expect(dom.querySelectorAll("li").map((li) => li.textContent)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("callRoute invokes an API handler with a Context", async () => {
|
||||
const handler = (ctx: import("@wrnexus/core").Context) =>
|
||||
Response.json({ ok: true, path: ctx.url.pathname, method: ctx.req.method });
|
||||
const res = await callRoute(handler, new Request("http://x/api/ping", { method: "POST" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true, path: "/api/ping", method: "POST" });
|
||||
});
|
||||
Reference in New Issue
Block a user