Files
template-app/e2e/support/stack.ts
T
2026-11-14 09:56:45 +00:00

232 lines
9.7 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The whole app, running, for the end-to-end suite: a Postgres with the three roles a linked managed
// database exposes, the fake platform of @conusai-kit/testing (Blob, OCR jobs, audit, usage), the
// migrate job, the worker and the web shell -- the same four things compose.yaml runs, as processes
// on this machine so the suite needs no image build.
//
// Postgres is the one thing that needs Docker. The web shell is a development-mode build on `server.mjs`,
// because @conusai-kit/auth refuses the password-free dev sign-in the suite uses in a production start
// (AUTH_DEV_LOGIN with NODE_ENV=production fails at boot, spec §7), which is the right refusal and
// not one to work around. It is a build and not `vite dev`: that server hands the browser its modules one by
// one after the `load` event, so a spec that fills a form right after `page.goto` would beat hydration
// (and the form would submit natively, as a reload).
import { type ChildProcess, execFileSync, spawn, spawnSync } from "node:child_process"
import { randomBytes } from "node:crypto"
import { mkdirSync, openSync, readFileSync } from "node:fs"
import { join } from "node:path"
import {
fakeNativeServices,
startFakePlatform,
validReply, // kit:documents
} from "@conusai-kit/testing/fake-platform"
import { deterministicModel } from "../../test/support/fake-model" // kit:documents
export const APP_ROOT = new URL("../..", import.meta.url).pathname
const LOGS = join(APP_ROOT, "e2e", ".logs")
export const PORTS = {
web: Number(process.env.E2E_WEB_PORT ?? 3100),
postgres: Number(process.env.E2E_POSTGRES_PORT ?? 55432),
}
export interface Stack {
readonly baseUrl: string
readonly platformUrl: string
readonly usageOrganization: string
/** The fake platform, so a spec can change what its OCR reads. */
readonly platform: Awaited<ReturnType<typeof startFakePlatform>>
stop(): Promise<void>
}
const url = (role: string): string => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${PORTS.postgres}/appdev`
function sh(command: string, args: string[]): string {
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
}
async function until(label: string, check: () => Promise<boolean> | boolean, seconds = 120): Promise<void> {
const deadline = Date.now() + seconds * 1000
for (;;) {
try {
if (await check()) return
} catch {
// not yet
}
if (Date.now() > deadline) throw new Error(`e2e: ${label} did not come up in ${seconds}s (logs in e2e/.logs)`)
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
/**
* Runs a node script of the app to its end without blocking this process. The fake platform is served from this
* process, and a script that calls it (the seed binds the demo organizations to the business services) would wait
* forever on a synchronous child: this process could not answer until the child had exited.
*/
function runToEnd(label: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn("node", args, { cwd: APP_ROOT, env, stdio: "inherit" })
child.on("error", reject)
child.on("exit", (code, signal) =>
code === 0 ? resolve() : reject(new Error(`e2e: ${label} exited with ${code ?? signal}`)),
)
})
}
function spawnLogged(name: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): ChildProcess {
mkdirSync(LOGS, { recursive: true })
const out = openSync(join(LOGS, `${name}.log`), "w")
// A process group of its own, so stopping it stops what it started.
return spawn(command, args, { cwd, env, stdio: ["ignore", out, out], detached: true })
}
function stopGroup(child: ChildProcess): void {
if (child.pid === undefined) return
try {
process.kill(-child.pid, "SIGTERM")
} catch {
// already gone
}
}
export async function startStack(document: Record<string, unknown>): Promise<Stack> {
const processes: ChildProcess[] = []
const name = `e2e-postgres-${randomBytes(4).toString("hex")}`
const simulated = fakeNativeServices({ token: "e2e-token", projectId: 42 })
// The deployment's own business gateway, CRM and the rest, as `pnpm dev` starts them (scripts/dev.mjs): a business
// procedure answers only for an organization an administrator bound (`bindBusinessServices` of support/app.ts).
const platform = await startFakePlatform(0, {
nativeServices: simulated.nativeServices,
// The captured-mail journey deliberately models an approved reconciliation grant.
deployment: { ...simulated.authority, permissions: ["emails:send", "emails:read"] },
})
platform.ocr.setDocument(document)
// The model is the deterministic reader of the unit tests: the suite proves the app around a model, not a model.
platform.ai.respond((request) => (request.response_format ? deterministicModel(request) : validReply(request))) // kit:documents
const container = sh("docker", [
"run",
"-d",
// No --rm: a server that exits keeps its log for the error below; stopPostgres removes the container.
"--name",
name,
"--label",
"conusai.test=app-e2e",
"-p",
`127.0.0.1:${PORTS.postgres}:5432`,
"-e",
"POSTGRES_PASSWORD=postgres",
"-e",
"POSTGRES_DB=appdev",
// The roles script goes in through dev/postgres-entrypoint.sh, whatever mode the checkout gave it.
"-v",
`${join(APP_ROOT, "dev")}:/conusai-dev:ro`,
"--entrypoint",
"sh",
"postgres:18",
"/conusai-dev/postgres-entrypoint.sh",
"postgres",
])
const stopPostgres = (): void => {
try {
sh("docker", ["rm", "-f", "--volumes", container])
} catch {
// already gone
}
}
try {
// The init script creates the roles after the server first starts, then it restarts: "ready"
// is the app's own login working, not pg_isready.
await until("postgres", () => {
sh("docker", ["exec", container, "psql", "-U", "appdev_app", "-h", "127.0.0.1", "-d", "appdev", "-c", "select 1"])
return true
}).catch((error: Error) => {
// The server's own words, not only that it did not come up.
// `docker logs` replays the server's stderr on its own stderr.
const logs = spawnSync("docker", ["logs", "--tail", "20", name], { encoding: "utf8" })
const log = `${logs.stdout ?? ""}${logs.stderr ?? ""}`
throw new Error(`${error.message}; the container's log ends:\n${log}`)
})
const common: NodeJS.ProcessEnv = {
...process.env,
// The fake platform is a development and test switch the app accepts in these modes alone (config/src/env.ts).
NODE_ENV: "development",
CONUSAI_FAKE_PLATFORM: "1",
// What the platform injects when the environment has OTLP: the web shell sends its logs and spans to
// the fake platform, where a spec reads them back with the request's trace id.
OTEL_EXPORTER_OTLP_ENDPOINT: `${platform.url}/otel`,
CONUSAI_API_URL: `${platform.url}/api`,
CONUSAI_API_TOKEN: "e2e-token",
// What the platform injects for usage metering (AT4.1): the fake platform keeps the usage of
// this organization beside the audit events, which is where a spec reads both.
...simulated.deployment,
CONUSAI_PUBLIC_URL: `http://127.0.0.1:${PORTS.web}`,
CONUSAI_RELEASE: "e2e",
POSTGRES_URL: url("app"),
POSTGRES_READER_URL: url("reader"),
AUTH_SESSION_SECRET: randomBytes(32).toString("base64url"),
}
delete common.POSTGRES_MIGRATE_URL
// The migrate job holds the owner's URL and nothing else does.
await runToEnd("the migrate job", ["apps/worker/dist/migrate.js"], {
...common,
POSTGRES_MIGRATE_URL: url("owner"),
})
// The demo data, written with the kit by the same script `pnpm dev` runs: the specs that look at it
// are proof it works, and the others each make their own organization beside it.
await runToEnd("the demo data", ["apps/worker/dist/seed-main.js"], common)
// The build reads NODE_ENV from `common` and writes it in, so the shell's security headers are the
// development ones this run's server expects. It replaces whatever is in `dist`, from the source as it is.
// `E2E_BUILD_MODE=production` builds for production instead, for a spec that asks for the bundle that
// ships: the server still runs as above, since the dev sign-in refuses a production start.
const web = join(APP_ROOT, "apps", "web")
const mode = process.env.E2E_BUILD_MODE === "production" ? "production" : "development"
execFileSync(process.execPath, ["node_modules/vite/bin/vite.js", "build", "--mode", mode], {
cwd: web,
env: { ...common, NODE_ENV: mode },
stdio: "inherit",
})
processes.push(
spawnLogged("worker", "node", ["apps/worker/dist/main.js"], APP_ROOT, {
...common,
WORKER_HEARTBEAT_FILE: join(LOGS, "worker-heartbeat"),
}),
)
processes.push(
spawnLogged("web", process.execPath, ["server.mjs", "--port", String(PORTS.web)], web, {
...common,
HOSTNAME: "127.0.0.1",
AUTH_DEV_LOGIN: "1",
AUTH_INSECURE_COOKIES: "1",
}),
)
const baseUrl = `http://127.0.0.1:${PORTS.web}`
await until("the web shell", async () => (await fetch(`${baseUrl}/api/ready`)).ok, 180)
await until("the worker", () => readFileSync(join(LOGS, "worker.log"), "utf8").includes("worker: serving"), 60)
return {
baseUrl,
platformUrl: platform.url,
usageOrganization: simulated.deployment.CONUSAI_ORG_SLUG,
platform,
async stop() {
for (const child of processes) stopGroup(child)
await platform.close()
stopPostgres()
},
}
} catch (error) {
for (const child of processes) stopGroup(child)
await platform.close()
stopPostgres()
throw error
}
}