@@ -0,0 +1,184 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What every spec does: sign in through the dev adapter, call a procedure the way the page's own
|
||||
// forms do (with the page's CSRF token), and make an organization to work in.
|
||||
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { type APIRequestContext, expect, type Page } from "@playwright/test"
|
||||
import axe from "axe-core"
|
||||
|
||||
export const unique = (prefix: string): string => `${prefix}-${randomBytes(3).toString("hex")}`
|
||||
|
||||
/**
|
||||
* Waits until the page is interactive: the shell sets `data-hydrated` on <html> once React has hydrated it
|
||||
* (`apps/web/src/ui/hydrated.ts`). A click or a key before that is lost, so act after this, not in a retry loop.
|
||||
*/
|
||||
export async function ready(page: Page): Promise<void> {
|
||||
await expect(page.locator("html")).toHaveAttribute("data-hydrated", "true")
|
||||
}
|
||||
|
||||
/** Signs in as `email` (created on first use) and lands on the home page, ready to be used. */
|
||||
export async function signIn(page: Page, email: string): Promise<void> {
|
||||
await page.goto(`/auth/dev-login?email=${encodeURIComponent(email)}`)
|
||||
await expect(page.locator('meta[name="csrf-token"]')).toHaveCount(1)
|
||||
await ready(page)
|
||||
}
|
||||
|
||||
/** The CSRF token of the page the browser is on: what the page's own forms send back. */
|
||||
const csrfOf = (page: Page): Promise<string> =>
|
||||
page
|
||||
.locator('meta[name="csrf-token"]')
|
||||
.getAttribute("content")
|
||||
.then((value) => value ?? "")
|
||||
|
||||
/** Calls a procedure with the session of `page`. Throws on a refusal, with the problem's own words. */
|
||||
export async function call<T = unknown>(page: Page, procedure: string, input: unknown): Promise<T> {
|
||||
const response = await callRaw(page.request, await csrfOf(page), procedure, input)
|
||||
const body = (await response.json().catch(() => null)) as T
|
||||
if (!response.ok()) throw new Error(`${procedure} answered ${response.status()}: ${JSON.stringify(body)}`)
|
||||
return body
|
||||
}
|
||||
|
||||
export const callRaw = (request: APIRequestContext, csrf: string, procedure: string, input: unknown) =>
|
||||
request.post(`/api/v1/${procedure}`, {
|
||||
data: input,
|
||||
headers: { "x-csrf-token": csrf, "content-type": "application/json", "sec-fetch-site": "same-origin" },
|
||||
})
|
||||
|
||||
export interface Workspace {
|
||||
/** The organization's id: the app tenant a business-service binding names. */
|
||||
orgId: string
|
||||
org: string
|
||||
workspace: string
|
||||
project: string
|
||||
}
|
||||
|
||||
/** The signed-in user's own organization with one workspace and one project. */
|
||||
export async function makeOrganization(page: Page, prefix = "acme"): Promise<Workspace> {
|
||||
const org = unique(prefix)
|
||||
const { orgId } = await call<{ orgId: string }>(page, "tenancy.org.create", { slug: org, name: `Org ${org}` })
|
||||
const workspace = (
|
||||
await call<{ slug: string }>(page, "tenancy.workspace.create", {
|
||||
org,
|
||||
slug: "main",
|
||||
name: "Main",
|
||||
visibility: "org",
|
||||
})
|
||||
).slug
|
||||
const project = (
|
||||
await call<{ slug: string }>(page, "tenancy.project.create", {
|
||||
org,
|
||||
workspace,
|
||||
slug: "review",
|
||||
name: "Review",
|
||||
moduleKey: "documents",
|
||||
})
|
||||
).slug
|
||||
return { orgId, org, workspace, project }
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds an organization to the deployment on the fake platform, with exactly the service scopes the app's selected
|
||||
* business modules declare (`declaredBusinessScopes`, as `pnpm dev` binds its demo organizations). In Cloud this is a
|
||||
* project administrator's explicit act, and a lookup never creates a binding; a spec does it on purpose, after it has
|
||||
* seen the refusal an unbound organization gets.
|
||||
*/
|
||||
export async function bindBusinessServices(
|
||||
orgId: string,
|
||||
grants: Readonly<Record<string, readonly string[]>>,
|
||||
): Promise<void> {
|
||||
const base = process.env.E2E_PLATFORM_URL
|
||||
if (!base) throw new Error("e2e: E2E_PLATFORM_URL is set by the global setup")
|
||||
const response = await fetch(`${base}/__e2e/app-bindings`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ app_tenant_id: orgId, grants }),
|
||||
})
|
||||
if (!response.ok)
|
||||
throw new Error(`e2e: the fake platform refused the binding (${response.status}: ${await response.text()})`)
|
||||
}
|
||||
|
||||
/** Puts axe into the page. */
|
||||
async function injectAxe(page: Page): Promise<void> {
|
||||
await page.addScriptTag({ content: axe.source })
|
||||
}
|
||||
|
||||
/** WCAG 2.2 AA violations on the page the browser is on, as axe finds them. */
|
||||
export async function violations(page: Page): Promise<string[]> {
|
||||
await injectAxe(page)
|
||||
// Finite entrance/hover transitions temporarily blend text and backgrounds.
|
||||
// Audit their settled paint without disabling motion or waiting on continuous busy indicators.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getAnimations()
|
||||
.every(
|
||||
(animation) =>
|
||||
!Number.isFinite(animation.effect?.getComputedTiming().endTime) ||
|
||||
animation.playState === "finished" ||
|
||||
animation.playState === "idle",
|
||||
),
|
||||
undefined,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
const results = await page.evaluate(() =>
|
||||
(
|
||||
globalThis as unknown as {
|
||||
axe: {
|
||||
run(
|
||||
context: unknown,
|
||||
options: unknown,
|
||||
): Promise<{ violations: Array<{ id: string; nodes: Array<{ target: unknown[] }> }> }>
|
||||
}
|
||||
}
|
||||
).axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }),
|
||||
)
|
||||
return results.violations.map(
|
||||
(violation) => `${violation.id}: ${violation.nodes.map((node) => node.target.join(" ")).join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** What the fake platform has recorded: the audit events and usage facts the worker sent, and the blobs it stored. */
|
||||
export interface PlatformState {
|
||||
audit: Array<{ action: string; resource_id: string | null }>
|
||||
usage: Array<Record<string, unknown>>
|
||||
blobs: Array<{ pathname: string; contentType: string; size: number }>
|
||||
otel: { logs: Array<Record<string, unknown>>; traces: Array<Record<string, unknown>> }
|
||||
}
|
||||
|
||||
export async function platformState(): Promise<PlatformState> {
|
||||
const base = process.env.E2E_PLATFORM_URL
|
||||
if (!base) throw new Error("e2e: E2E_PLATFORM_URL is set by the global setup")
|
||||
const response = await fetch(`${base}/__e2e/state`)
|
||||
if (!response.ok) throw new Error(`e2e: the fake platform answered ${response.status}`)
|
||||
const state = (await response.json()) as PlatformState
|
||||
// Audit and objects use the fake token’s tenant. Billing is scoped to the
|
||||
// server-pinned deployment organization, as it is on Cloud.
|
||||
const organization = process.env.E2E_USAGE_ORGANIZATION
|
||||
if (!organization) throw new Error("e2e: the global setup pins E2E_USAGE_ORGANIZATION")
|
||||
const billed = await fetch(`${base}/__e2e/state`, { headers: { "x-conusai-organization": organization } })
|
||||
if (!billed.ok) throw new Error(`e2e: the fake billing state answered ${billed.status}`)
|
||||
return { ...state, usage: ((await billed.json()) as PlatformState).usage }
|
||||
}
|
||||
|
||||
/** Opens the person's menu at the foot of the frame's sidebar. */
|
||||
export async function openPersonMenu(page: Page): Promise<void> {
|
||||
await ready(page)
|
||||
await page.locator('[data-guide="frame.person"]').click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a language from the person's menu. The page changes language in place (the loaders run again, nothing
|
||||
* reloads), so this waits for the document's `lang` to move off the one it had.
|
||||
*/
|
||||
export async function chooseLanguage(page: Page, language: string): Promise<void> {
|
||||
const before = await page.locator("html").getAttribute("lang")
|
||||
await openPersonMenu(page)
|
||||
await page.getByRole("menuitemradio", { name: language }).click()
|
||||
await expect(page.locator("html")).not.toHaveAttribute("lang", before ?? "")
|
||||
}
|
||||
|
||||
/** Opens the scope switcher in the frame's sidebar. */
|
||||
export async function openScope(page: Page): Promise<void> {
|
||||
await ready(page)
|
||||
await page.locator('[data-guide="frame.scope"]').click()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Starts the stack once for the whole run and stops it after (Playwright's global setup returns the
|
||||
// teardown). The OCR the fake platform answers with is the documents module's own fixture: a three-page
|
||||
// document with four labelled lines and a drawing page the extraction leaves out, which is what the specs review.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { APP_ROOT, startStack } from "./stack"
|
||||
|
||||
export default async function globalSetup(): Promise<() => Promise<void>> {
|
||||
const document = JSON.parse(
|
||||
readFileSync(join(APP_ROOT, "modules", "documents", "__tests__", "fixtures", "ocr-document.json"), "utf8"),
|
||||
) as Record<string, unknown>
|
||||
const stack = await startStack(document)
|
||||
process.env.E2E_BASE_URL = stack.baseUrl
|
||||
process.env.E2E_PLATFORM_URL = stack.platformUrl
|
||||
process.env.E2E_USAGE_ORGANIZATION = stack.usageOrganization
|
||||
return async () => {
|
||||
await stack.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user