test: call loaders and actions against the real app context

This commit is contained in:
Aarnav Tale
2026-10-17 17:26:30 -07:00
parent 74e79b175e
commit 5430324ef3
3 changed files with 174 additions and 2 deletions
+4 -1
View File
@@ -35,7 +35,7 @@ export function createAction<
C extends { principal: Principal },
T extends Record<string, v.ObjectEntries>,
>(options: ActionOptions<C, T>) {
return async (args: ActionFunctionArgs) => {
const action = async (args: ActionFunctionArgs) => {
const auth = args.context.get(authContext);
try {
@@ -73,4 +73,7 @@ export function createAction<
throw error;
}
};
// Lets tests enumerate every intent.
return Object.assign(action, { intents: Object.keys(options.intents) });
}
+161
View File
@@ -0,0 +1,161 @@
import { type } from "arktype";
import { drizzle } from "drizzle-orm/node-sqlite";
import { migrate } from "drizzle-orm/node-sqlite/migrator";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { headplaneConfig } from "~/server/config/config-schema";
import { createAppContext, createRouterContext } from "~/server/context";
import type { Headscale } from "~/server/headscale/api";
import type { Role } from "~/server/web/roles";
import type { HeadscaleVersion } from "../harness/versions";
import { createFakeHeadscale, type FakeHeadscale } from "./fake-headscale";
export interface TestAppOptions {
// Defaults to a fake Headscale of the latest version.
headscale?: { headscale: Headscale; apiKey: string };
version?: HeadscaleVersion;
// Merged over the minimal config, one top-level key at a time.
config?: Record<string, Record<string, unknown>>;
}
export type TestApp = Awaited<ReturnType<typeof createTestApp>>;
type RouteFunction = (args: LoaderFunctionArgs & ActionFunctionArgs) => unknown;
export interface CallOptions {
as?: string;
params?: Record<string, string>;
form?: Record<string, string | string[]>;
search?: string;
}
// The real app context and auth service around a fake or real Headscale.
// Sign in as a role to get a session cookie, then call loaders and actions
// the way React Router would.
export async function createTestApp(options: TestAppOptions = {}) {
let fake: FakeHeadscale | undefined;
let headscale = options.headscale;
if (!headscale) {
const created = await createFakeHeadscale(options.version);
fake = created.server;
headscale = { headscale: created.headscale, apiKey: created.server.apiKey };
}
const config = headplaneConfig({
...options.config,
server: {
cookie_secret: "test-cookie-secret-thirty-two-ch",
cookie_secure: false,
...options.config?.server,
},
headscale: {
url: "http://headscale.test",
api_key: headscale.apiKey,
...options.config?.headscale,
},
});
if (config instanceof type.errors) {
throw new Error(`Invalid test config: ${config.summary}`);
}
const db = drizzle(":memory:");
migrate(db, { migrationsFolder: "./drizzle" });
const ctx = await createAppContext(config, { db, headscale: headscale.headscale });
const api = headscale.headscale.client(headscale.apiKey);
let users = 0;
async function signIn(role: Role, profile: { name?: string; email?: string } = {}) {
// The first user always becomes the owner, so make sure one exists.
if (role !== "owner" && (await ctx.auth.listUsers()).length === 0) {
await ctx.auth.findOrCreateUser("test-owner", { name: "Owner" });
}
users += 1;
const subject = `test-${role}-${users}`;
const name = profile.name ?? `${role} ${users}`;
const userId = await ctx.auth.findOrCreateUser(subject, { name, email: profile.email });
if (role !== "owner") {
await ctx.auth.reassignUser(userId, role);
}
const setCookie = await ctx.auth.createOidcSession(userId, { name, email: profile.email });
return { userId, subject, cookie: setCookie.split(";")[0] };
}
async function signInWithApiKey(apiKey = headscale!.apiKey) {
const setCookie = await ctx.auth.createApiKeySession(apiKey, "API key", 3_600_000);
return { cookie: setCookie.split(";")[0] };
}
function request(path: string, call: CallOptions = {}) {
const headers = new Headers();
if (call.as) headers.set("cookie", call.as);
const url = `http://headplane.test${__PREFIX__}${path}${call.search ?? ""}`;
if (!call.form) {
return new Request(url, { headers });
}
const body = new FormData();
for (const [key, value] of Object.entries(call.form)) {
for (const item of [value].flat()) body.append(key, item);
}
return new Request(url, { method: "POST", headers, body });
}
// Runs a loader or action and returns what it returned or threw, since
// React Router treats a thrown Response the same as a returned one.
async function call(fn: RouteFunction, path: string, callOptions: CallOptions = {}) {
const args = {
request: request(path, callOptions),
params: callOptions.params ?? {},
context: createRouterContext(ctx),
url: new URL(`http://headplane.test${__PREFIX__}${path}${callOptions.search ?? ""}`),
pattern: path,
unstable_pattern: path,
};
try {
return await fn(args as unknown as LoaderFunctionArgs & ActionFunctionArgs);
} catch (error) {
return error;
}
}
return {
ctx,
api,
fake,
signIn,
signInWithApiKey,
request,
call,
async dispose() {
await ctx.dispose();
},
[Symbol.asyncDispose]() {
return ctx.dispose();
},
};
}
// The HTTP status a loader or action result maps to, so tests can assert on
// outcomes without caring whether the route returned or threw.
export function statusOf(result: unknown): number {
if (result instanceof Response) return result.status;
if (result instanceof Error) return 500;
if (
result &&
typeof result === "object" &&
"type" in result &&
result.type === "DataWithResponseInit"
) {
return (result as { init?: { status?: number } | null }).init?.status ?? 200;
}
return 200;
}
+9 -1
View File
@@ -1,12 +1,20 @@
import { rmSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { onTestFinished } from "vitest";
// Removed when the test finishes, or when the worker exits if it was made in
// a hook rather than a test.
export async function tempDir() {
const dir = await mkdtemp(join(tmpdir(), "headplane-test-"));
onTestFinished(() => rm(dir, { recursive: true, force: true }));
try {
onTestFinished(() => rm(dir, { recursive: true, force: true }));
} catch {
process.once("exit", () => rmSync(dir, { recursive: true, force: true }));
}
return dir;
}