Repository Public
1/**2 * Test helpers for runtime-fs plugins: a bare Fastify instance with the same error mapping3 * contract as main.ts (`{error:{code,message}}`), temp workspaces and a minimal RuntimeConfig.4 * Only imported by *.test.ts files.5 */6import Fastify, { type FastifyInstance } from 'fastify';7import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs';8import os from 'node:os';9import path from 'node:path';10import type { RuntimeConfig } from './config.js';11import { RtError, type RuntimePlugin } from './plugins.js';1213/** Temp dir under os.tmpdir(), realpath'd (macOS: /var → /private/var). */14export function makeTmp(prefix = 'cr-fs-'): string {15 return realpathSync(mkdtempSync(path.join(os.tmpdir(), prefix)));16}17export function rmTmp(dir: string): void {18 if (!dir.startsWith(realpathSync(os.tmpdir()) + path.sep)) throw new Error('refusing to remove a non-temp dir');19 rmSync(dir, { recursive: true, force: true });20}21export function mkdirp(...parts: string[]): string {22 const p = path.join(...parts);23 mkdirSync(p, { recursive: true });24 return p;25}2627export function makeConfig(roots: string[], over: Partial<RuntimeConfig> = {}): RuntimeConfig {28 return {29 dataDir: path.join(os.tmpdir(), 'cr-unused-data'),30 socketPath: path.join(os.tmpdir(), 'cr-unused.sock'),31 token: 't'.repeat(40),32 workspaceRoots: roots.map((p) => ({ label: path.basename(p), path: p })),33 agent: 'claude',34 claudeBin: 'claude',35 maxSessions: 12,36 execHosts: [],37 termBufferBytes: 4 * 1024 * 1024,38 tmuxSocketName: 'cr-test',39 shell: process.env.SHELL ?? '/bin/zsh',40 logLevel: 'silent',41 ...over,42 };43}4445export async function buildApp(config: RuntimeConfig, ...plugins: RuntimePlugin[]): Promise<FastifyInstance> {46 const app = Fastify({ logger: false });47 app.setErrorHandler((err: unknown, _req, reply) => {48 if (err instanceof RtError) {49 void reply.code(err.status).send({ error: { code: err.code, message: err.message } });50 return;51 }52 const e = err as { statusCode?: number; code?: string; message?: string };53 const status = typeof e.statusCode === 'number' ? e.statusCode : 500;54 void reply.code(status).send({ error: { code: e.code ?? 'internal', message: e.message ?? 'error' } });55 });56 for (const plugin of plugins) await plugin(app, { config });57 await app.ready();58 return app;59}6061export interface Injected<T = unknown> {62 status: number;63 body: T;64 headers: Record<string, string | string[] | number | undefined>;65 raw: Buffer;66}67export async function call<T = unknown>(68 app: FastifyInstance,69 method: 'GET' | 'POST',70 url: string,71 body?: unknown,72): Promise<Injected<T>> {73 const res = await app.inject({ method, url, ...(body === undefined ? {} : { payload: body as object }) });74 let parsed: unknown = null;75 try {76 parsed = res.body.length > 0 ? JSON.parse(res.body) : null;77 } catch {78 parsed = res.body;79 }80 return { status: res.statusCode, body: parsed as T, headers: res.headers, raw: res.rawPayload };81}82export function errCode(r: Injected): string | undefined {83 return (r.body as { error?: { code?: string } } | null)?.error?.code;84}85