190 lines
7.1 KiB
TypeScript
190 lines
7.1 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 runs under `next dev`, 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.
|
|
|
|
import { type ChildProcess, execFileSync, spawn } from "node:child_process"
|
|
import { randomBytes } from "node:crypto"
|
|
import { mkdirSync, openSync, readFileSync, rmSync } from "node:fs"
|
|
import { join } from "node:path"
|
|
import { startFakePlatform } from "@conusai-kit/testing/fake-platform"
|
|
import { deterministicModel } from "../../test/support/fake-model"
|
|
|
|
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
|
|
/** 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))
|
|
}
|
|
}
|
|
|
|
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 (next dev forks).
|
|
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 platform = await startFakePlatform()
|
|
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(deterministicModel)
|
|
|
|
sh("docker", [
|
|
"run",
|
|
"-d",
|
|
"--rm",
|
|
"--name",
|
|
name,
|
|
"-p",
|
|
`127.0.0.1:${PORTS.postgres}:5432`,
|
|
"-e",
|
|
"POSTGRES_PASSWORD=postgres",
|
|
"-e",
|
|
"POSTGRES_DB=appdev",
|
|
"-v",
|
|
`${join(APP_ROOT, "dev", "postgres-roles.sql")}:/docker-entrypoint-initdb.d/10-roles.sql:ro`,
|
|
// pgvector: the run chooses the pgvector store (KNOWLEDGE_VECTOR_STORE below), whose table the
|
|
// migrate job creates in this Postgres.
|
|
"pgvector/pgvector:pg18",
|
|
])
|
|
const stopPostgres = (): void => {
|
|
try {
|
|
sh("docker", ["rm", "-f", name])
|
|
} 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", name, "psql", "-U", "appdev_app", "-h", "127.0.0.1", "-d", "appdev", "-c", "select 1"])
|
|
return true
|
|
})
|
|
|
|
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",
|
|
CONUSAI_PROJECT_ID: "42",
|
|
// 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.
|
|
CONUSAI_ORG_ID: "e2e",
|
|
CONUSAI_PUBLIC_URL: `http://127.0.0.1:${PORTS.web}`,
|
|
CONUSAI_RELEASE: "e2e",
|
|
POSTGRES_URL: url("app"),
|
|
POSTGRES_READER_URL: url("reader"),
|
|
KNOWLEDGE_VECTOR_STORE: "pgvector",
|
|
AUTH_SESSION_SECRET: randomBytes(32).toString("base64url"),
|
|
}
|
|
delete common.POSTGRES_MIGRATE_URL
|
|
|
|
// The migrate job holds the owner's URL and nothing else does.
|
|
execFileSync("node", ["apps/worker/dist/migrate.js"], {
|
|
cwd: APP_ROOT,
|
|
env: { ...common, POSTGRES_MIGRATE_URL: url("owner") },
|
|
stdio: "inherit",
|
|
})
|
|
|
|
// 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.
|
|
execFileSync("node", ["apps/worker/dist/seed-main.js"], { cwd: APP_ROOT, env: common, stdio: "inherit" })
|
|
|
|
// A dev server killed mid-write leaves a cache Turbopack panics on at the next start: begin clean.
|
|
rmSync(join(APP_ROOT, "apps", "web", ".next"), { recursive: true, force: true })
|
|
|
|
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,
|
|
["node_modules/next/dist/bin/next", "dev", "-p", String(PORTS.web), "-H", "127.0.0.1"],
|
|
join(APP_ROOT, "apps", "web"),
|
|
{
|
|
...common,
|
|
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,
|
|
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
|
|
}
|
|
}
|