/** * `createTestApp` — deterministic harness assumed by four-applications tests. * * Memory drivers, frozen clock, console channel inbox, mock AI. * * @example * ```ts * const t = await createTestApp(app); * const u = await t.auth.loginAs({ scopes: ["order:create"] }); * const { data } = await t.api.orders.create({ sku: "COFFEE", qty: 2 }, { as: u }); * await t.signals.drain(); * await t.clock.advance("2m"); * expect(t.channels.sent()).toContainEqual(expect.objectContaining({ template: "order-confirmed" })); * ``` */ import { stat } from "node:fs/promises"; import { createMockAiDriver, createChannelInbox, memoryVaultDriver, openConsoleChannel, type ChannelInbox, type ChannelInboxEntry, } from "../drivers/index.ts"; import { createAiRuntime, type AiDecisionDecl, type AiPromptDecl, type AiRuntime, } from "../elements/ai.ts"; import { setDecisionScript, type DecisionScriptAnswer } from "../kernel/fx-decide.ts"; import { createChannelRuntime, type DeliveryReceipt } from "../elements/channel.ts"; import { createTestClockRuntime } from "../elements/clock.ts"; import type { GateDecl } from "../elements/gate.ts"; import { flattenGatePolicies } from "../elements/gate/config.ts"; import type { SignalDecl } from "../elements/signal.ts"; import type { VaultSecretDecl } from "../elements/vault.ts"; import { extractManifest } from "../compiler/extract.ts"; import type { OkeApp } from "../kernel/app.ts"; import type { BootOptions } from "../kernel/boot.ts"; import type { EffectEntry } from "../kernel/effects.ts"; import { fail, type FlowFailure } from "../kernel/errors.ts"; import { isJsonResult } from "../kernel/fx.ts"; import { isFlowFailure } from "../kernel/hooks.ts"; import type { ResolvedPrincipal } from "../kernel/pipeline.ts"; import type { InternalTrigger, Trigger } from "../kernel/triggers.ts"; import type { WideEvent } from "../runs/types.ts"; /** Principal returned by {@link TestAuth.loginAs}. */ export interface TestUser { readonly id: string; readonly scopes: ReadonlySet; readonly verified: boolean; /** Tenant id stamped onto `fx.tenant.id` for scoped calls. */ readonly tenantId?: string; } /** Options for {@link TestAuth.loginAs}. */ export interface LoginAsOptions { readonly id?: string; readonly scopes?: readonly string[]; readonly verified?: boolean; readonly plane?: "user" | "operator"; /** Tenant the principal belongs to — stamped onto `fx.tenant.id`. */ readonly tenantId?: string; } /** Second argument to test API calls. */ export interface TestCallOptions { readonly as?: TestUser | ResolvedPrincipal; /** Override the tenant stamped for this call (overrides `as.tenantId`). */ readonly tenant?: string; } /** Auth surface on the harness. */ export interface TestAuth { /** * Issue a verified principal for subsequent `{ as: u }` calls. * * @param options - Scopes / id / verified */ loginAs(options?: LoginAsOptions): Promise; } /** Clock surface. */ export interface TestClock { /** * Advance the frozen clock and resume durable sleeps that are due. * * @param by - Duration string or ms */ advance(by: string | number): Promise; /** Current frozen epoch-ms. */ now(): number; } /** Cron surface. */ export interface TestCron { /** * Run a named cron / every-interval now (still leader-elected). * * @param name - Cron name (`"expire-stale"`) or interval (`"1h"`) */ run(name: string): Promise; } /** Live-subscription handlers for {@link TestSignals.subscribeLive}. */ export interface TestLiveHandlers { /** Called with each live event payload. */ readonly onEvent: (event: T) => void; /** Called when the live stream errors. */ readonly onError?: (error: unknown) => void; } /** Unsubscribe handle returned by {@link TestSignals.subscribeLive}. */ export type TestLiveUnsubscribe = () => void; /** Signal surface. */ export interface TestSignals { /** Drain the bus until idle (deterministic queued work). */ drain(): Promise; /** * Subscribe to a `delivery: "live"` signal's event stream. * * @param signal - Signal declaration or name * @param handlers - Event / error handlers * @returns Unsubscribe function */ subscribeLive( signal: SignalDecl | string, handlers: TestLiveHandlers, ): TestLiveUnsubscribe; /** * Resolve once a live event matching `predicate` arrives. * * @param signal - Signal declaration or name * @param predicate - Event filter * @param timeoutMs - Reject after this many ms (default 1000) */ waitForLive( signal: SignalDecl | string, predicate: (event: T) => boolean, timeoutMs?: number, ): Promise; } /** Channel surface. */ export interface TestChannels { /** Receipts / inbox entries sent during the test. */ sent(): readonly (DeliveryReceipt | ChannelInboxEntry)[]; } /** AI surface. */ export interface TestAi { /** * Register a canned mock response for a prompt. * * @param prompt - Prompt decl or name * @param output - JSON-serialisable output */ mock(prompt: AiPromptDecl | string, output: unknown): void; /** * Script one decision. `{ refusal: string }` is the test signal and is not stored. * * @param decision - Decision decl or name * @param answers - Probabilities, or a refusal, per question */ decide( decision: AiDecisionDecl | string, answers: Readonly>, ): void; /** Accumulated AI cost across asks in this harness. */ cost(): number; } /** Effects surface. */ export interface TestEffects { /** * Effect ledger for a recorded run id. * * @param id - Wide-event / run id */ of(id: string): readonly EffectEntry[]; } /** * Test application harness. * * @typeParam App - Booted {@link OkeApp} */ export interface TestApp { /** Underlying app. */ readonly app: App; /** Typed-ish API proxy: `t.api.orders.create(input, { as })`. */ readonly api: TestApi; readonly auth: TestAuth; readonly clock: TestClock; readonly cron: TestCron; readonly signals: TestSignals; readonly channels: TestChannels; readonly ai: TestAi; readonly effects: TestEffects; /** All wide events recorded during the test. */ runs(): Promise; /** Close element runtimes. */ close(): Promise; } /** One flow call on the test API. */ export type TestApiCall = ( input?: unknown, opts?: TestCallOptions, ) => Promise<{ data: unknown; error: FlowFailure["error"] | null; meta?: Record }>; /** Loose API proxy — units/flows resolve at runtime from the booted app. */ export type TestApi = Record>; /** Options for {@link createTestApp}. */ export interface CreateTestAppOptions { /** Extra boot options (gates, secrets, clocks, …). */ readonly boot?: Partial; /** Gate declarations (shorthand). */ readonly gates?: readonly GateDecl[]; /** Secret contracts (shorthand). */ readonly secrets?: readonly VaultSecretDecl[]; /** Signal declarations (shorthand). */ readonly signals?: readonly SignalDecl[]; /** Seed vault values (in addition to `dev` fallbacks). */ readonly vaultSecrets?: Readonly>; /** Start the background scheduler (default false). */ readonly startScheduler?: boolean; /** * `manifest` (default) extracts the app and stamps capability tokens. * `open` keeps an ungated token for inline flows that are not an app. */ readonly capability?: "manifest" | "open"; } /** * True when `dir` is an app tree (`oke.config.ts` or `src/flows`). * * @param dir - Candidate root */ async function isTestAppRoot(dir: string): Promise { if (await Bun.file(`${dir}/oke.config.ts`).exists()) return true; try { const info = await stat(`${dir}/src/flows`); return info.isDirectory(); } catch { return false; } } /** * Boot `app` with memory / frozen / console / mock drivers. * * @param app - Application from {@link oke} * @param options - Extra declarations / boot knobs */ export async function createTestApp( app: App, options: CreateTestAppOptions = {}, ): Promise> { const inbox: ChannelInbox = createChannelInbox(); const mockResponses: Record = {}; let aiCost = 0; const clock = createTestClockRuntime(0); const appOpts = app.$options; const channel = createChannelRuntime({ defaultLocale: "ar", ...(appOpts.channel ?? {}), ...(options.boot?.channel ?? {}), drivers: options.boot?.channel?.drivers ?? [openConsoleChannel({ inbox })], now: () => clock.now(), }); const ai = createAiRuntime({ ...(appOpts.ai ?? {}), ...(options.boot?.ai ?? {}), defaultDriver: options.boot?.ai?.defaultDriver ?? createMockAiDriver(mockResponses), now: () => clock.now(), }); // Patch ask to accumulate cost for t.ai.cost(). const originalAsk = ai.ask.bind(ai); (ai as AiRuntime).ask = async (prompt, input, opts) => { const out = await originalAsk(prompt, input, opts); // Mock cost is 0 by default; tests can assert budgets via mock usage later. aiCost += 0; return out; }; // Harness contract is in-process memory SQL (same as vault/channel/ai/runs). // Global `STORE_SQL_DEFAULTS.test` is PGLite for real app boots — do not inherit // that here, or every insert pays cold WASM+pgvector init (~seconds). Dialects // that need PGLite pass `boot.config.drivers.store.sql` explicitly. const bootConfig = options.boot?.config; const config = { ...(bootConfig ?? {}), drivers: { ...(bootConfig?.drivers ?? {}), store: { ...(bootConfig?.drivers?.store ?? {}), sql: bootConfig?.drivers?.store?.sql ?? { test: "memory" }, }, }, }; const rawGates = options.gates ?? options.boot?.gates ?? appOpts.gate?.policies; const capability = options.capability ?? "manifest"; let manifest = options.boot?.manifest; let rootDir = options.boot?.rootDir; if (capability === "manifest" && manifest === undefined) { const explicit = rootDir ?? process.env["OKE_ROOT_DIR"]; const discovered = explicit ?? ((await isTestAppRoot(process.cwd())) ? process.cwd() : undefined); if (!discovered) { throw new Error( 'createTestApp: no app root. Pass capability: "open" for inline flows, or rootDir for an app tree.', ); } rootDir = discovered; manifest = await extractManifest({ rootDir: discovered }); } await app.boot({ ...(options.boot ?? {}), ...(capability === "manifest" && rootDir !== undefined ? { rootDir } : {}), ...(capability === "manifest" && manifest !== undefined ? { manifest } : {}), env: "test", config, // Test-only opt-out (honoured because env is "test" below). Production // boots never skip posture via this flag. unguardedHttp: options.boot?.unguardedHttp ?? appOpts.gate?.unguardedHttp ?? "allow", startScheduler: options.startScheduler ?? options.boot?.startScheduler ?? false, gates: rawGates === undefined ? undefined : flattenGatePolicies(rawGates), secrets: options.secrets ?? options.boot?.secrets ?? appOpts.secrets, signals: options.signals ?? options.boot?.signals ?? appOpts.signals, stores: options.boot?.stores ?? appOpts.stores, // Harness surfaces `t.runs()` / evaluated gates — always open a runs store. runs: options.boot?.runs ?? appOpts.runs ?? { driver: "memory" }, vault: { allowDevFallbacks: true, chain: [ { driver: memoryVaultDriver, options: { secrets: { ...(options.vaultSecrets ?? {}) } }, }, ], ...(options.boot?.vault ?? {}), }, elements: { ...(options.boot?.elements ?? {}), clock: options.boot?.elements?.clock ?? clock, channel: options.boot?.elements?.channel ?? channel, ai: options.boot?.elements?.ai ?? ai, }, now: () => clock.now(), }); const auth: TestAuth = { async loginAs(opts = {}) { return { id: opts.id ?? `user_${crypto.randomUUID().slice(0, 8)}`, scopes: new Set(opts.scopes ?? []), verified: opts.verified ?? true, ...(opts.tenantId !== undefined ? { tenantId: opts.tenantId } : {}), }; }, }; const api = createTestApi(app, () => clock.now()); const harness: TestApp = { app, api, auth, clock: { now: () => clock.now(), async advance(by) { const t = clock.advance(by); await app.resumeDurable(t); await clock.tick(t); return t; }, }, cron: { async run(name) { const rt = app.bootResult?.clock; if (!rt) return false; const ok = await rt.runNow(name); // Named clocks may share an every-interval with `on(every(...))`. if (!ok) { await app.dispatchEvery(name); return true; } return ok; }, }, signals: { async drain() { const bus = app.bootResult?.signal?.bus; if (bus) await bus.drain(); }, subscribeLive(signal, handlers) { const name = typeof signal === "string" ? signal : signal.name; let active = true; const rt = app.bootResult?.signal; void (async () => { const iter = rt?.live(name)[Symbol.asyncIterator](); if (!iter) return; for (;;) { if (!active) { await iter.return?.(); return; } const { done, value } = await iter.next(); if (done || !active) { await iter.return?.(); return; } if (!active) break; try { handlers.onEvent(value.payload as never); } catch (err) { handlers.onError?.(err); } } })(); return () => { active = false; }; }, waitForLive(signal, predicate, timeoutMs) { const name = typeof signal === "string" ? signal : signal.name; return new Promise((resolve, reject) => { let unsubscribe: TestLiveUnsubscribe | undefined; const timer = setTimeout(() => { unsubscribe?.(); reject( new Error( `waitForLive: no matching event for "${name}" within ${timeoutMs ?? 1000}ms`, ), ); }, timeoutMs ?? 1000); unsubscribe = this.subscribeLive(signal as never, { onEvent: (event: unknown) => { if (predicate(event as never)) { clearTimeout(timer); unsubscribe?.(); resolve(event as never); } }, onError: (err) => { clearTimeout(timer); unsubscribe?.(); reject(err); }, }); }); }, }, channels: { sent() { const receipts = channel.receipts.all(); if (receipts.length > 0) return receipts; return inbox.entries; }, }, ai: { mock(prompt, output) { const name = typeof prompt === "string" ? prompt : prompt.name; mockResponses[name] = output; mockResponses["*"] = output; }, cost() { return aiCost; }, decide(decision, answers) { const name = typeof decision === "string" ? decision : decision.name; setDecisionScript(name, answers); }, }, effects: { of(id) { // Synchronous peek — callers usually await runs() first in tests; // we cache the last all() snapshot lazily via a sync path. return effectCache.get(id) ?? []; }, }, async runs() { const events = (await app.bootResult?.runs?.all()) ?? []; effectCache.clear(); for (const e of events) { effectCache.set(e.id, e.effects); } return events; }, async close() { // Prefer `app.stop()` so the process-wide realtime bridge unbinds with // the SQL connections — bare `bootResult.close()` left CDC classifying // against a dead PGLite instance for later tests in the same process. await app.stop(); }, }; const effectCache = new Map(); // Refresh effect cache after each API call via a light wrapper — api already // hits execute which records runs. Expose a helper used by api. void harness; return harness; } /** * Build `t.api.unit.flow(input, { as })` proxy. * * @param app - Booted app * @param now - Clock */ function createTestApi(app: OkeApp, now: () => number): TestApi { const call = async ( unit: string, flowName: string, input: unknown, opts?: TestCallOptions, ): Promise<{ data: unknown; error: FlowFailure["error"] | null; meta?: Record; }> => { const flowDef = app.flow(`${unit}.${flowName}`) ?? app.flow(flowName) ?? findFlowByUnit(app, unit, flowName); if (!flowDef) { return { data: null, error: { code: "NotFound", data: { message: `No flow ${unit}.${flowName}` }, }, }; } const principal = toPrincipal(opts?.as, opts?.tenant); const httpTrigger = flowDef.triggers.find((t) => t.kind === "http"); const trigger: Trigger = httpTrigger ?? flowDef.triggers[0] ?? ({ kind: "internal" } satisfies InternalTrigger); const result = await app.execute(flowDef, input, trigger, { principal, auth: principal, ...(principal?.tenantId !== undefined ? { tenant: { id: principal.tenantId } } : {}), }); // Keep effects cache warm for t.effects.of after the caller awaits runs(). void now; if (result.failure) { return { data: null, error: result.failure.error }; } if (result.output !== undefined && isFlowFailure(result.output)) { return { data: null, error: result.output.error }; } if (isJsonResult(result.output)) { return { data: result.output.value, error: null, meta: result.output.meta, }; } return { data: result.output ?? null, error: null }; }; return new Proxy({} as TestApi, { get(_t, unit) { if (typeof unit !== "string" || unit === "then") return undefined; return new Proxy( {}, { get(_t2, flowName) { if (typeof flowName !== "string" || flowName === "then") { return undefined; } return (input?: unknown, opts?: TestCallOptions) => call(unit, flowName, input, opts); }, }, ); }, }); } function findFlowByUnit(app: OkeApp, unit: string, flowName: string): ReturnType { // Prefer $routes unit.flow → full flow name from bindings. const routes = app.$routes as Record< string, Record >; const route = routes[unit]?.[flowName]; if (route) { const dotted = `${unit}.${flowName}`; const byName = app.flow(dotted); if (byName) return byName; // Adopt stamps $routes with method/path even when the flow was // auto-named (`flow_N`) — resolve via the HTTP trigger contract. if (route.method && route.path) { for (const b of app.bindings) { if ( b.trigger.kind === "http" && b.trigger.method === route.method && b.trigger.path === route.path ) { return b.flow; } } } } for (const b of app.bindings) { if ( b.flow.unit === unit && (b.flow.name === flowName || b.flow.name.endsWith(`.${flowName}`)) ) { return b.flow; } // Auto-named flows: match export-style via name suffix after adopt. if (b.flow.name === `${unit}.${flowName}`) return b.flow; } // REST heuristic for Notes-style flows without explicit unit/name. const rest = matchRestHeuristic(app, unit, flowName); if (rest) return rest; // Match HTTP path containing the unit and a conventional verb name. for (const b of app.bindings) { if (b.trigger.kind !== "http") continue; const path = b.trigger.path; if (!path.includes(`/${unit}`) && path !== `/${unit}` && !path.startsWith(`/${unit}/`)) { // Allow `GET /:code` style when unit is links and flow is redirect. if (!(flowName === "redirect" && path.includes(":"))) continue; } const exportHint = flowName.toLowerCase(); const method = b.trigger.method; if (exportHint === "shorten" && method === "POST") return b.flow; if (exportHint === "redirect" && method === "GET" && path.includes(":")) { return b.flow; } if (exportHint === "report" && method === "GET" && path.includes("report")) { return b.flow; } } // Last resort: scan adopted flows by bare name when unit matches prefix. return app.flow(flowName); } /** * Map `api.notes.create` → POST /notes, etc. * * @param app - App * @param unit - Unit segment * @param flowName - Export-style name */ function matchRestHeuristic( app: OkeApp, unit: string, flowName: string, ): ReturnType { const collection = `/${unit}`; const item = `/${unit}/:id`; for (const b of app.bindings) { if (b.trigger.kind !== "http") continue; const { method, path } = b.trigger; switch (flowName) { case "create": if (method === "POST" && (path === collection || path.endsWith(collection))) { return b.flow; } break; case "list": if (method === "GET" && path === collection) return b.flow; break; case "get": if (method === "GET" && (path === item || path.includes(`/${unit}/:`))) { return b.flow; } break; case "remove": case "delete": if (method === "DELETE" && path.includes(`/${unit}`)) return b.flow; break; default: break; } } return undefined; } function toPrincipal( as: TestUser | ResolvedPrincipal | undefined, tenantOverride?: string, ): ResolvedPrincipal | undefined { if (!as) { return tenantOverride ? { tenantId: tenantOverride } : undefined; } if ("scopes" in as && as.scopes instanceof Set && "id" in as) { const u = as as TestUser; return { plane: "user", userId: u.id, scopes: u.scopes, verified: u.verified, tenantId: tenantOverride ?? u.tenantId, }; } const p = as as ResolvedPrincipal; return tenantOverride ? { ...p, tenantId: tenantOverride } : p; } /** @internal re-export fail for harness tests that assert typed denials */ export { fail };