ab388b4f8c
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
205 lines
7.9 KiB
TypeScript
205 lines
7.9 KiB
TypeScript
/**
|
|
* Test helpers: a temp directory root + an in-memory DB (":memory:") + injecting
|
|
* requests via app.request() + building Trace files.
|
|
* None of these tests listen on a port or make real LLM requests.
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { Hono } from "hono";
|
|
import type { OmniMessage } from "@prismshadow/penguin-core";
|
|
import { buildAppDeps, createApp } from "../src/app.js";
|
|
import type { AppDeps, BuildDepsOverrides } from "../src/app.js";
|
|
import { openDatabase } from "../src/db/database.js";
|
|
import { TraceIndexRepo } from "../src/db/repos/trace-index.js";
|
|
import { SessionSources } from "../src/runtime/session-sources.js";
|
|
import { TraceIndexService } from "../src/services/trace-index.js";
|
|
import { TraceService } from "../src/services/trace-service.js";
|
|
import type { TraceSessionIndex } from "../src/services/trace-service.js";
|
|
import type { AppEnv } from "../src/auth/middleware.js";
|
|
import { ADMIN_USER_ID } from "../src/auth/service.js";
|
|
import type { ServerConfig } from "../src/config.js";
|
|
import type { UserInfo } from "../src/api/types.js";
|
|
|
|
export async function makeTempRoot(): Promise<string> {
|
|
return fs.mkdtemp(path.join(os.tmpdir(), "penguin-server-test-"));
|
|
}
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
/** Fixed seeded-admin password injected into every test app (in production the seed generates a random one). */
|
|
export const TEST_ADMIN_PASSWORD = "penguin-0000";
|
|
|
|
export function testConfig(root: string): ServerConfig {
|
|
return {
|
|
root,
|
|
host: "127.0.0.1",
|
|
// Nothing listens in tests, but the value is not inert: preview URLs are built from
|
|
// the server's own port, so keep it realistic rather than 0.
|
|
port: 7364,
|
|
dbPath: ":memory:",
|
|
previewOrigin: null,
|
|
// Points to a nonexistent directory: static hosting is disabled in tests.
|
|
webDist: path.join(root, "__no_web_dist__"),
|
|
// Fixed seed password so loginAdmin needs no seed-time capture.
|
|
seedAdminPassword: TEST_ADMIN_PASSWORD,
|
|
authSessionTtlMs: 7 * DAY_MS,
|
|
authSessionRenewMs: 6 * DAY_MS,
|
|
desktopToken: null,
|
|
portFile: null,
|
|
};
|
|
}
|
|
|
|
export interface TestApp {
|
|
app: Hono<AppEnv>;
|
|
deps: AppDeps;
|
|
root: string;
|
|
/** Initial password of the seeded admin (TEST_ADMIN_PASSWORD unless overridden via `config.seedAdminPassword`). */
|
|
adminPassword: string;
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
export interface TestAppOptions extends BuildDepsOverrides {
|
|
/** Runs before seeding the admin (for scenarios pre-populating a default_project config as the CLI would). */
|
|
beforeSeed?: (root: string) => Promise<void>;
|
|
/** Overrides merged onto the default test ServerConfig (e.g. `previewOrigin`). */
|
|
config?: Partial<ServerConfig>;
|
|
}
|
|
|
|
export async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {
|
|
const { beforeSeed, config, ...overrides } = options;
|
|
const root = await makeTempRoot();
|
|
if (beforeSeed) await beforeSeed(root);
|
|
const deps = buildAppDeps({ ...testConfig(root), ...config }, { log: () => {}, ...overrides });
|
|
// Consistent with the startup entrypoint: seed the built-in admin (owning default_project),
|
|
// keeping the password it returns (only null if a beforeSeed hook ever pre-created users).
|
|
const adminPassword = (await deps.authService.seedAdmin()) ?? TEST_ADMIN_PASSWORD;
|
|
const app = createApp(deps);
|
|
return {
|
|
app,
|
|
deps,
|
|
root,
|
|
adminPassword,
|
|
cleanup: async () => {
|
|
deps.channels.dispose();
|
|
deps.db.close();
|
|
// maxRetries: Windows can report ENOTEMPTY/EBUSY while handles from the test's own
|
|
// just-closed files (SQLite, trace writers) are still being released — Node's rm
|
|
// retries these codes with a delay. A plain rm was the top cause of ci-windows
|
|
// cascades (cleanup throws → hook timeout → "database is not open" in later files).
|
|
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Logs in and returns the session cookie (`penguin_session=...`). */
|
|
export async function loginUser(
|
|
app: Hono<AppEnv>,
|
|
userId: string,
|
|
password: string,
|
|
): Promise<{ cookie: string; user: UserInfo }> {
|
|
const res = await app.request("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ userId, password }),
|
|
});
|
|
if (res.status !== 200) {
|
|
throw new Error(`Login failed: ${res.status} ${await res.text()}`);
|
|
}
|
|
const setCookie = res.headers.get("set-cookie");
|
|
if (!setCookie) throw new Error("Login response is missing set-cookie");
|
|
const body = (await res.json()) as { user: UserInfo };
|
|
return { cookie: setCookie.split(";")[0]!, user: body.user };
|
|
}
|
|
|
|
/** Logs in as the seeded admin (every test app seeds with TEST_ADMIN_PASSWORD). */
|
|
export function loginAdmin(app: Hono<AppEnv>): Promise<{ cookie: string; user: UserInfo }> {
|
|
return loginUser(app, ADMIN_USER_ID, TEST_ADMIN_PASSWORD);
|
|
}
|
|
|
|
/** Admin creates the account and logs in as that user (the only way to create test users while registration is closed). */
|
|
export async function provisionUser(
|
|
app: Hono<AppEnv>,
|
|
userId: string,
|
|
password = "password-123",
|
|
): Promise<{ cookie: string; user: UserInfo }> {
|
|
if (userId === ADMIN_USER_ID) return loginAdmin(app);
|
|
const admin = await loginAdmin(app);
|
|
const res = await apiClient(app, admin.cookie).post("/api/admin/users", { userId, password });
|
|
if (res.status !== 201) {
|
|
throw new Error(`Account creation failed: ${res.status} ${await res.text()}`);
|
|
}
|
|
return loginUser(app, userId, password);
|
|
}
|
|
|
|
/** JSON request client that carries the cookie. */
|
|
export function apiClient(app: Hono<AppEnv>, cookie: string) {
|
|
const call = (method: string) => (apiPath: string, body?: unknown) =>
|
|
app.request(apiPath, {
|
|
method,
|
|
headers: {
|
|
cookie,
|
|
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
},
|
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
return {
|
|
get: (apiPath: string) => app.request(apiPath, { headers: { cookie } }),
|
|
post: call("POST"),
|
|
put: call("PUT"),
|
|
patch: call("PATCH"),
|
|
delete: call("DELETE"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Index-backed TraceService for pure service tests: an in-memory DB with the real
|
|
* schema, a real reconciler over the temp root, and a shared origin registry — the
|
|
* same wiring app.ts assembles, minus the HTTP app.
|
|
*/
|
|
export function makeTraceHarness(
|
|
root: string,
|
|
opts: { sessions?: TraceSessionIndex; sources?: SessionSources } = {},
|
|
): {
|
|
traceIndex: TraceIndexService;
|
|
service: TraceService;
|
|
sources: SessionSources;
|
|
close: () => void;
|
|
} {
|
|
const db = openDatabase(":memory:");
|
|
const sources = opts.sources ?? new SessionSources();
|
|
const traceIndex = new TraceIndexService(root, new TraceIndexRepo(db), sources);
|
|
const service = new TraceService(root, {
|
|
index: traceIndex,
|
|
...(opts.sessions !== undefined ? { sessions: opts.sessions } : {}),
|
|
sources,
|
|
});
|
|
return { traceIndex, service, sources, close: () => db.close() };
|
|
}
|
|
|
|
/** Writes a Trace JSONL file directly (for building historical / discovery scenarios). */
|
|
export async function writeTraceFile(
|
|
root: string,
|
|
projectId: string,
|
|
agentId: string,
|
|
dateDir: string,
|
|
sessionId: string,
|
|
index: number,
|
|
messages: OmniMessage[],
|
|
): Promise<string> {
|
|
const dir = path.join(root, projectId, "agents", agentId, "traces", dateDir);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const file = path.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
|
|
await fs.writeFile(file, messages.map((m) => JSON.stringify(m)).join("\n") + "\n", "utf8");
|
|
return file;
|
|
}
|
|
|
|
/** Simple wait: until the condition is true or it times out. */
|
|
export async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
const start = Date.now();
|
|
while (!cond()) {
|
|
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
}
|
|
}
|