|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Dogfood boot harness. |
| 4 | +// |
| 5 | +// Boots a real ObjectStack app **in-process** against an in-memory SQLite |
| 6 | +// database, wired with the same service plugins `objectstack dev` loads, and |
| 7 | +// exposes the live HTTP surface via Hono's request-injection (no port, no |
| 8 | +// sockets — CI-stable). Tests then exercise the app exactly as a browser |
| 9 | +// client would: sign in, hit `/api/v1/...`, assert on real responses. |
| 10 | +// |
| 11 | +// Why this exists: the bucketing regression fixed in #2018 passed every static |
| 12 | +// gate (build, 900+ unit tests, spec-liveness, CodeQL) because each layer was |
| 13 | +// individually correct and individually mocked — the break only appeared when |
| 14 | +// the real engine + strategy + settings + REST context ran together. Unit |
| 15 | +// tests that mock the protocol/server (e.g. rest.test.ts) cannot catch that. |
| 16 | +// This harness runs the integrated stack so they can. |
| 17 | + |
| 18 | +import { ObjectKernel, AppPlugin, DriverPlugin, createDispatcherPlugin } from '@objectstack/runtime'; |
| 19 | +import { ObjectQLPlugin } from '@objectstack/objectql'; |
| 20 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 21 | +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; |
| 22 | +import { createRestApiPlugin } from '@objectstack/rest'; |
| 23 | +import { AuthPlugin } from '@objectstack/plugin-auth'; |
| 24 | +import { SecurityPlugin } from '@objectstack/plugin-security'; |
| 25 | +import { SettingsServicePlugin } from '@objectstack/service-settings'; |
| 26 | +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; |
| 27 | + |
| 28 | +/** A Hono app exposes `.request(path, init)` returning a standard `Response`. */ |
| 29 | +interface InjectableApp { |
| 30 | + request(input: string, init?: RequestInit): Promise<Response>; |
| 31 | +} |
| 32 | + |
| 33 | +const API_PREFIX = '/api/v1'; |
| 34 | +const DEFAULT_ADMIN_EMAIL = 'admin@objectos.ai'; |
| 35 | +const DEFAULT_ADMIN_PASSWORD = 'admin123'; |
| 36 | + |
| 37 | +export interface DogfoodStack { |
| 38 | + /** The booted kernel — for direct service calls when bypassing HTTP is intentional. */ |
| 39 | + kernel: ObjectKernel; |
| 40 | + /** Inject an HTTP request through the real Hono app (no socket). Path is relative to `/api/v1`. */ |
| 41 | + api(path: string, init?: RequestInit): Promise<Response>; |
| 42 | + /** Inject a request at an absolute path (e.g. `/api/settings/...`). */ |
| 43 | + raw(path: string, init?: RequestInit): Promise<Response>; |
| 44 | + /** Sign in through the real auth route; returns a bearer token. Defaults to the dev admin. */ |
| 45 | + signIn(email?: string, password?: string): Promise<string>; |
| 46 | + /** Convenience: an authed JSON request relative to `/api/v1`. */ |
| 47 | + apiAs(token: string, method: string, path: string, body?: unknown): Promise<Response>; |
| 48 | + /** Tear down the kernel (close DB / HTTP handles). */ |
| 49 | + stop(): Promise<void>; |
| 50 | +} |
| 51 | + |
| 52 | +export interface BootOptions { |
| 53 | + /** Override the dev admin credentials the harness signs in with. */ |
| 54 | + admin?: { email: string; password: string }; |
| 55 | +} |
| 56 | + |
| 57 | +/** |
| 58 | + * Boot an app config in-process and return a live dogfood stack. |
| 59 | + * |
| 60 | + * `NODE_ENV` is forced to `development` so the auth plugin's dev-admin |
| 61 | + * bootstrap provisions a known, loginable admin (mirrors `objectstack dev`). |
| 62 | + */ |
| 63 | +export async function bootDogfoodStack( |
| 64 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 65 | + config: any, |
| 66 | + opts: BootOptions = {}, |
| 67 | +): Promise<DogfoodStack> { |
| 68 | + process.env.NODE_ENV = 'development'; |
| 69 | + |
| 70 | + const kernel = new ObjectKernel(); |
| 71 | + |
| 72 | + // Data engine + in-memory SQLite (pure-JS WASM driver — no native build, CI-safe). |
| 73 | + await kernel.use(new ObjectQLPlugin()); |
| 74 | + await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); |
| 75 | + |
| 76 | + // HTTP server (registers the `http-server` IHttpServer service the REST + |
| 77 | + // dispatcher plugins mount their routes onto). Port 0 = ephemeral; we never |
| 78 | + // hit the socket — requests are injected through the Hono app directly. |
| 79 | + await kernel.use(new HonoServerPlugin({ port: 0 })); |
| 80 | + |
| 81 | + // The app under test (objects, datasets, cubes, flows, seed data). |
| 82 | + await kernel.use(new AppPlugin(config)); |
| 83 | + |
| 84 | + // Service plugins `objectstack dev` auto-loads for an app of this shape. |
| 85 | + await kernel.use(new SettingsServicePlugin()); |
| 86 | + await kernel.use(new AnalyticsServicePlugin()); |
| 87 | + await kernel.use(new AuthPlugin({ secret: 'dogfood-regression-secret' })); |
| 88 | + await kernel.use(new SecurityPlugin()); |
| 89 | + |
| 90 | + // REST + dispatcher route surfaces (mount onto the http-server service). |
| 91 | + await kernel.use(createRestApiPlugin({ api: { api: { requireAuth: true } } as never })); |
| 92 | + await kernel.use(createDispatcherPlugin({})); |
| 93 | + |
| 94 | + // Fire the ready lifecycle: seed data, dev-admin bootstrap, route registration. |
| 95 | + await kernel.bootstrap(); |
| 96 | + |
| 97 | + const httpServer = await kernel.getServiceAsync<{ getRawApp(): InjectableApp; close?(): Promise<void> }>( |
| 98 | + 'http-server', |
| 99 | + ); |
| 100 | + const app = httpServer.getRawApp(); |
| 101 | + |
| 102 | + const raw = (path: string, init?: RequestInit) => app.request(path, init); |
| 103 | + const api = (path: string, init?: RequestInit) => raw(`${API_PREFIX}${path}`, init); |
| 104 | + |
| 105 | + const admin = opts.admin ?? { email: DEFAULT_ADMIN_EMAIL, password: DEFAULT_ADMIN_PASSWORD }; |
| 106 | + |
| 107 | + const signIn = async ( |
| 108 | + email: string = admin.email, |
| 109 | + password: string = admin.password, |
| 110 | + ): Promise<string> => { |
| 111 | + const res = await api('/auth/sign-in/email', { |
| 112 | + method: 'POST', |
| 113 | + headers: { 'Content-Type': 'application/json' }, |
| 114 | + body: JSON.stringify({ email, password }), |
| 115 | + }); |
| 116 | + if (!res.ok) { |
| 117 | + throw new Error(`dogfood signIn failed: ${res.status} ${await res.text()}`); |
| 118 | + } |
| 119 | + const data = (await res.json()) as { token?: string }; |
| 120 | + if (!data.token) throw new Error('dogfood signIn: no token in response'); |
| 121 | + return data.token; |
| 122 | + }; |
| 123 | + |
| 124 | + const apiAs = (token: string, method: string, path: string, body?: unknown) => |
| 125 | + api(path, { |
| 126 | + method, |
| 127 | + headers: { |
| 128 | + 'Content-Type': 'application/json', |
| 129 | + Authorization: `Bearer ${token}`, |
| 130 | + }, |
| 131 | + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), |
| 132 | + }); |
| 133 | + |
| 134 | + const stop = async () => { |
| 135 | + try { |
| 136 | + await httpServer.close?.(); |
| 137 | + } catch { |
| 138 | + /* best-effort */ |
| 139 | + } |
| 140 | + try { |
| 141 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 142 | + await (kernel as any).shutdown?.(); |
| 143 | + } catch { |
| 144 | + /* best-effort */ |
| 145 | + } |
| 146 | + }; |
| 147 | + |
| 148 | + return { kernel, api, raw, signIn, apiAs, stop }; |
| 149 | +} |
0 commit comments