87 lines
4.8 KiB
TypeScript
87 lines
4.8 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The image the three processes run from (spec §13). These are the properties a deployment
|
|
// depends on, and each one has cost a deployment somewhere: a manifest the install stage does not
|
|
// copy, a root process, a base image nobody pinned to the Node the app is written for.
|
|
|
|
import { readdirSync, readFileSync } from "node:fs"
|
|
import { describe, expect, it } from "vitest"
|
|
import { parse } from "yaml"
|
|
|
|
const dockerfile = readFileSync(new URL("../Dockerfile", import.meta.url), "utf8")
|
|
const workspace = parse(readFileSync(new URL("../pnpm-workspace.yaml", import.meta.url), "utf8")) as {
|
|
minimumReleaseAgeExclude: string[]
|
|
trustPolicy: string
|
|
trustPolicyExclude: string[]
|
|
}
|
|
|
|
describe("the Dockerfile", () => {
|
|
it("is multi-stage, with the runtime stage compose.yaml names", () => {
|
|
expect(dockerfile).toMatch(/^FROM node:24-slim AS deps$/m)
|
|
expect(dockerfile).toMatch(/^FROM deps AS build$/m)
|
|
expect(dockerfile).toMatch(/^FROM node:24-slim AS runtime$/m)
|
|
})
|
|
|
|
it("copies the manifest of every workspace package before installing", () => {
|
|
// The install stage resolves the whole workspace, so a package whose manifest is missing
|
|
// fails the install with "no package named @app/<id> is present in the workspace".
|
|
const packages = readdirSync(new URL("..", import.meta.url), { withFileTypes: true })
|
|
expect(packages.some((entry) => entry.name === "pnpm-workspace.yaml")).toBe(true)
|
|
for (const dir of ["config", "modules", "apps/web", "apps/worker"]) {
|
|
expect(dockerfile, `${dir}/package.json is not copied`).toContain(`COPY ${dir}/package.json ./${dir}/`)
|
|
}
|
|
})
|
|
|
|
it("keeps code root-owned but lets the runtime user write generated code", () => {
|
|
expect(dockerfile).toMatch(/^USER node$/m)
|
|
expect(dockerfile).toContain("--chown=root:root")
|
|
expect(dockerfile).toContain(
|
|
"chown -R node:node /app/convex/_generated /app/better-auth/functions/_generated /app/better-auth/functions/betterAuth/_generated",
|
|
)
|
|
})
|
|
|
|
it("takes the registry credential as a build secret, never as a layer or a build argument", () => {
|
|
// Two build paths deliver the same credential under two ids: a preset single-image build
|
|
// mounts it as `npmrc`, a compose build as `conusai_npmrc` (the platform prefixes its compose
|
|
// secrets so a tenant's own can never collide). The install must accept either — a
|
|
// deployment that built through the preset path got no credential at all and pnpm fell back
|
|
// to the public registry, where the vendor scopes 404 (stage, deployment 4, 2026-10-20).
|
|
expect(dockerfile).toContain("--mount=type=secret,id=conusai_npmrc,target=/run/secrets/conusai_npmrc")
|
|
expect(dockerfile).toContain("--mount=type=secret,id=npmrc,target=/run/secrets/npmrc")
|
|
expect(dockerfile).not.toMatch(/ARG .*(TOKEN|PASSWORD|SECRET)/)
|
|
expect(dockerfile).not.toMatch(/_authToken/)
|
|
})
|
|
|
|
it("excludes the vendor scopes from the release-age cooldown", () => {
|
|
// minimumReleaseAge blocks versions published in the last seven days — but every dev
|
|
// bundle publishes the vendor packages minutes before a deployment installs them, from
|
|
// the ConusAI vendor registry, so the cooldown must never apply to either scope
|
|
// (stage, deployment 5, 2026-10-20: ERR_PNPM_NO_MATURE_MATCHING_VERSION).
|
|
expect(workspace.minimumReleaseAgeExclude).toContain("@conusai-sdk/*")
|
|
expect(workspace.minimumReleaseAgeExclude).toContain("@conusai-kit/*")
|
|
})
|
|
|
|
it("keeps the trust policy on, excepting one exact version", () => {
|
|
// A range or a whole package here would switch the policy off for releases nobody has looked at: an entry names
|
|
// the version, its chain and its reason (pnpm-workspace.yaml), and its integrity is checked against the registry.
|
|
expect(workspace.trustPolicy).toBe("no-downgrade")
|
|
expect(workspace.trustPolicyExclude).toEqual(["semver@6.3.1"])
|
|
})
|
|
|
|
it("starts no process by default: compose.yaml names the one each service runs", () => {
|
|
const cmd = dockerfile.match(/^CMD (.*)$/m)?.[1] ?? ""
|
|
expect(cmd).toContain("compose.yaml")
|
|
expect(cmd).not.toMatch(/server\.mjs|vite|main\.js|migrate\.js/)
|
|
})
|
|
|
|
it("carries the Start build, and compose.yaml starts the web process on it with no package manager", () => {
|
|
// The web service's argv is the server itself: a package manager as PID 1 does not forward SIGTERM.
|
|
const compose = parse(readFileSync(new URL("../compose.yaml", import.meta.url), "utf8")) as {
|
|
services: Record<string, { command?: string[]; working_dir?: string }>
|
|
}
|
|
expect(compose.services.web?.working_dir).toBe("/app/apps/web")
|
|
expect(compose.services.web?.command).toEqual(["node", "server.mjs"])
|
|
expect(dockerfile).not.toContain(".next")
|
|
expect(dockerfile).not.toMatch(/\bnext (?:build|start)\b/)
|
|
})
|
|
})
|