diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e118f4dee..43e332643 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -87,10 +87,11 @@ jobs: env: E2E_BASE_URL: http://localhost:3000 - - name: Upload Playwright HTML report on failure - if: failure() + - name: Upload Playwright HTML report + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report path: playwright-report/ retention-days: 7 + if-no-files-found: ignore diff --git a/Dockerfile b/Dockerfile index 8f0861e5c..fe0462e13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ RUN corepack enable && corepack prepare pnpm@10.31.0 --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY scripts/prepare.mjs scripts/prepare.mjs RUN pnpm install --frozen-lockfile --prod=false # ── Stage 2: Build ───────────────────────────────────────── diff --git a/package.json b/package.json index 490d269eb..c1651b073 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "private": true, "packageManager": "pnpm@10.31.0", "scripts": { - "prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'", + "prepare": "node scripts/prepare.mjs", "dev": "next dev", "predev": "node scripts/generate-i18n-boot.mjs", "prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs", @@ -142,6 +142,9 @@ "@prisma/engines" ], "overrides": { + "dompurify@<=3.4.10": "^3.4.11", + "postcss@<8.5.10": "^8.5.10", + "@hono/node-server@<1.19.13": "^1.19.13", "hono@<4.12.25": "^4.12.25", "picomatch@<4.0.4": "^4.0.4" } diff --git a/playwright.config.ts b/playwright.config.ts index 8987415c2..f4936ad87 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,13 +22,13 @@ export default defineConfig({ expect: { timeout: 10_000 }, fullyParallel: true, forbidOnly: !!process.env.CI, + failOnFlakyTests: !!process.env.CI, // Two retries on CI: under a loaded shared runner, DOM-settle-gated // assertions (wizard step transitions, list refetch → card provenance, // disclosure toggles) intermittently sample a mid-transition frame. A - // rotating single test failed each run while 211 passed; a second retry - // absorbs that transient contention without masking a real failure (a true - // break fails all three attempts). The default expect timeout is also - // lifted from 5s → 10s for the same settle headroom. + // retry distinguishes transient contention from a persistent break, while + // failOnFlakyTests keeps either outcome visible to CI. The default expect + // timeout is lifted from 5s → 10s for the same settle headroom. retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5981cb3c..d4e2fd88c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: + dompurify@<=3.4.10: ^3.4.11 + postcss@<8.5.10: ^8.5.10 + '@hono/node-server@<1.19.13': ^1.19.13 hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 @@ -879,12 +882,6 @@ packages: '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4.12.25 - '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -4006,8 +4003,8 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} @@ -5355,11 +5352,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5733,10 +5725,6 @@ packages: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -7777,10 +7765,6 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.19.11(hono@4.12.25)': - dependencies: - hono: 4.12.25 - '@hono/node-server@1.19.14(hono@4.12.25)': dependencies: hono: 4.12.25 @@ -8538,7 +8522,7 @@ snapshots: '@electric-sql/pglite': 0.4.1 '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) - '@hono/node-server': 1.19.11(hono@4.12.25) + '@hono/node-server': 1.19.14(hono@4.12.25) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 '@prisma/streams-local': 0.1.2 @@ -10760,7 +10744,7 @@ snapshots: dependencies: esutils: 2.0.3 - dompurify@3.3.3: + dompurify@3.4.12: optionalDependencies: '@types/trusted-types': 2.0.7 optional: true @@ -11897,7 +11881,7 @@ snapshots: optionalDependencies: canvg: 3.0.11 core-js: 3.49.0 - dompurify: 3.3.3 + dompurify: 3.4.12 html2canvas: 1.4.1 jsx-ast-utils@3.3.5: @@ -12236,8 +12220,6 @@ snapshots: nanoid@3.3.12: {} - nanoid@3.3.15: {} - nanoid@3.3.16: {} napi-postinstall@0.3.4: {} @@ -12257,7 +12239,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.38 caniuse-lite: 1.0.30001799 - postcss: 8.4.31 + postcss: 8.5.20 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) @@ -12637,12 +12619,6 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.4.31: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.14: dependencies: nanoid: 3.3.12 diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts new file mode 100644 index 000000000..312fb5fb8 --- /dev/null +++ b/scripts/__tests__/ci-determinism.test.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parse } from "yaml"; + +import integrationConfig from "../../vitest.integration.config.mts"; + +function repositoryRoot(): string { + const testPath = expect.getState().testPath; + if (!testPath) throw new Error("Vitest did not expose the current test path"); + return join(dirname(testPath), "../.."); +} + +function readRepoFile(path: string): string { + return readFileSync(join(repositoryRoot(), path), "utf8"); +} + +type WorkflowStep = { + if?: string; + uses?: string; + with?: Record; +}; + +type Workflow = { + jobs: Record; +}; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("Playwright CI determinism", () => { + it("fails CI when a retry resolves a flaky test", async () => { + vi.stubEnv("CI", "true"); + // Re-import after stubbing CI so config conditionals are evaluated as CI. + const { default: config } = await import("../../playwright.config"); + + expect(config.retries).toBe(2); + expect(config.failOnFlakyTests).toBe(true); + expect(config.reporter).toEqual([ + ["github"], + ["html", { open: "never" }], + ]); + }); + + it("retains the Playwright report after every workflow outcome", () => { + const workflow = parse( + readRepoFile(".github/workflows/e2e.yml"), + ) as Workflow; + const upload = workflow.jobs.e2e?.steps?.find((step) => + step.uses?.startsWith("actions/upload-artifact@"), + ); + + expect(upload).toBeDefined(); + expect(upload?.if).toBe("always()"); + expect(upload?.with).toMatchObject({ + name: "playwright-report", + path: "playwright-report/", + "retention-days": 7, + "if-no-files-found": "ignore", + }); + }); +}); + +describe("integration environment isolation", () => { + it("registers a worker setup bridge and authoritative test secrets", () => { + const test = integrationConfig.test; + + expect(test?.setupFiles ?? []).toContain( + "./tests/integration/environment-setup.ts", + ); + expect(test?.env).toMatchObject({ + TZ: "UTC", + ENCRYPTION_KEY: + "0000000000000000000000000000000000000000000000000000000000000000", + ENCRYPTION_KEYS: "", + ENCRYPTION_ACTIVE_KEY_ID: "", + API_TOKEN_HMAC_KEY: "integration-test-hmac-key-32-bytes-minimum", + SESSION_SECRET: "integration-test-session-secret-32-bytes", + }); + expect( + existsSync( + join(repositoryRoot(), "tests/integration/environment-setup.ts"), + ), + ).toBe(true); + }); +}); + +describe("container dependency installation", () => { + it("copies the prepare helper before the Git-free frozen install", () => { + const dockerfile = readRepoFile("Dockerfile"); + + expect(dockerfile).toContain( + "COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" + + "COPY scripts/prepare.mjs scripts/prepare.mjs\n" + + "RUN pnpm install --frozen-lockfile --prod=false", + ); + }); +}); + +describe("production dependency advisory floors", () => { + it("pins vulnerable transitive ranges to compatible patched versions", () => { + const packageJson = JSON.parse(readRepoFile("package.json")) as { + pnpm?: { overrides?: Record }; + }; + + expect(packageJson.pnpm?.overrides).toMatchObject({ + "dompurify@<=3.4.10": "^3.4.11", + "postcss@<8.5.10": "^8.5.10", + "@hono/node-server@<1.19.13": "^1.19.13", + "hono@<4.12.25": "^4.12.25", + }); + }); +}); diff --git a/scripts/__tests__/prepare.test.ts b/scripts/__tests__/prepare.test.ts new file mode 100644 index 000000000..70b45136d --- /dev/null +++ b/scripts/__tests__/prepare.test.ts @@ -0,0 +1,136 @@ +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const path of temporaryDirectories.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +type GitMarker = "absent" | "directory" | "file"; + +type PrepareResult = { + invocationLog: string | null; + result: SpawnSyncReturns; +}; + +function runPrepareFixture( + gitMarker: GitMarker, + lefthookStatus = 0, +): PrepareResult { + const testPath = expect.getState().testPath; + if (!testPath) + throw new Error("Vitest did not expose the current test path"); + const root = join(dirname(testPath), "../.."); + const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-")); + temporaryDirectories.push(directory); + + const packageJson = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), + ) as { + scripts: { prepare: string }; + }; + const prepareHelper = join(root, "scripts/prepare.mjs"); + if (existsSync(prepareHelper)) { + mkdirSync(join(directory, "scripts")); + cpSync(prepareHelper, join(directory, "scripts/prepare.mjs")); + } + + if (gitMarker === "directory") { + mkdirSync(join(directory, ".git")); + } else if (gitMarker === "file") { + writeFileSync( + join(directory, ".git"), + "gitdir: ../shared/.git/worktrees/fixture\n", + ); + } + + const binDirectory = join(directory, "bin"); + const invocationLog = join(directory, "lefthook.log"); + mkdirSync(binDirectory); + const lefthook = join( + binDirectory, + process.platform === "win32" ? "lefthook.cmd" : "lefthook", + ); + writeFileSync( + lefthook, + process.platform === "win32" + ? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n' + : "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FIXTURE_LEFTHOOK_LOG\"\nexit \"$FIXTURE_LEFTHOOK_STATUS\"\n", + ); + if (process.platform !== "win32") chmodSync(lefthook, 0o755); + + const result = spawnSync(packageJson.scripts.prepare, { + cwd: directory, + encoding: "utf8", + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + FIXTURE_LEFTHOOK_LOG: invocationLog, + FIXTURE_LEFTHOOK_STATUS: String(lefthookStatus), + }, + shell: true, + }); + + return { + result, + invocationLog: existsSync(invocationLog) + ? readFileSync(invocationLog, "utf8").replaceAll("\r\n", "\n") + : null, + }; +} + +describe("package prepare hook installation", () => { + it("enables command-shim execution on Windows only", () => { + const testPath = expect.getState().testPath; + if (!testPath) + throw new Error("Vitest did not expose the current test path"); + const source = readFileSync( + join(dirname(testPath), "../prepare.mjs"), + "utf8", + ); + + expect(source).toContain('shell: process.platform === "win32"'); + }); + + it("skips hook installation only when Git metadata is absent", () => { + const { invocationLog, result } = runPrepareFixture("absent"); + + expect(result.status, result.stderr).toBe(0); + expect(invocationLog).toBeNull(); + }); + + it.each(["directory", "file"] as const)( + "installs hooks when .git is a %s", + (gitMarker) => { + const { invocationLog, result } = runPrepareFixture(gitMarker); + + expect(result.status, result.stderr).toBe(0); + expect(invocationLog).toBe("install --force\n"); + }, + ); + + it.each(["directory", "file"] as const)( + "propagates Lefthook failure when .git is a %s", + (gitMarker) => { + const { invocationLog, result } = runPrepareFixture(gitMarker, 42); + + expect(invocationLog).toBe("install --force\n"); + expect(result.status).toBe(42); + }, + ); +}); diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs new file mode 100644 index 000000000..6d6575def --- /dev/null +++ b/scripts/prepare.mjs @@ -0,0 +1,17 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +if (!existsSync(".git")) { + console.log("Skipping Lefthook install: Git metadata is absent"); +} else { + const result = spawnSync("lefthook", ["install", "--force"], { + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.error) { + console.error(`Failed to run Lefthook: ${result.error.message}`); + process.exitCode = 1; + } else { + process.exitCode = result.status ?? 1; + } +} diff --git a/tests/integration/ci-environment.test.ts b/tests/integration/ci-environment.test.ts new file mode 100644 index 000000000..405791f95 --- /dev/null +++ b/tests/integration/ci-environment.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { getPrismaClient } from "./setup"; + +const TEST_ENCRYPTION_KEY = + "0000000000000000000000000000000000000000000000000000000000000000"; +const TEST_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum"; +const TEST_SESSION_SECRET = "integration-test-session-secret-32-bytes"; + +describe("integration worker environment", () => { + it( + "replaces ambient database and encryption configuration with test values", + async () => { + expect(process.env.DATABASE_URL).toContain("healthlog_test"); + expect(process.env.DATABASE_URL).not.toContain("ambient_database"); + expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY); + expect(process.env.ENCRYPTION_KEYS).toBeUndefined(); + expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined(); + expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY); + expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET); + + const rows = await getPrismaClient().$queryRaw< + Array<{ database_name: string }> + >` + SELECT current_database() AS database_name + `; + expect(rows).toEqual([{ database_name: "healthlog_test" }]); + }, + ); +}); diff --git a/tests/integration/environment-setup.ts b/tests/integration/environment-setup.ts new file mode 100644 index 000000000..82c6e8864 --- /dev/null +++ b/tests/integration/environment-setup.ts @@ -0,0 +1,15 @@ +import { inject } from "vitest"; + +declare module "vitest" { + export interface ProvidedContext { + integrationDatabaseUrl: string; + } +} + +process.env.DATABASE_URL = inject("integrationDatabaseUrl"); +process.env.ENCRYPTION_KEY = + "0000000000000000000000000000000000000000000000000000000000000000"; +delete process.env.ENCRYPTION_KEYS; +delete process.env.ENCRYPTION_ACTIVE_KEY_ID; +process.env.API_TOKEN_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum"; +process.env.SESSION_SECRET = "integration-test-session-secret-32-bytes"; diff --git a/tests/integration/global-setup.ts b/tests/integration/global-setup.ts index c460a98eb..5ee724dd9 100644 --- a/tests/integration/global-setup.ts +++ b/tests/integration/global-setup.ts @@ -18,6 +18,7 @@ import { execSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import type { TestProject } from "vitest/node"; import { PostgreSqlContainer, type StartedPostgreSqlContainer, @@ -29,7 +30,9 @@ const PROJECT_ROOT = resolve(__dirname, "..", ".."); let container: StartedPostgreSqlContainer | null = null; -export default async function setup(): Promise<() => Promise> { +export default async function setup( + project: TestProject, +): Promise<() => Promise> { container = await new PostgreSqlContainer("postgres:16-alpine") .withDatabase("healthlog_test") .withUsername("healthlog") @@ -38,10 +41,9 @@ export default async function setup(): Promise<() => Promise> { const url = container.getConnectionUri() + "?schema=public&pgbouncer=false"; process.env.DATABASE_URL = url; + project.provide("integrationDatabaseUrl", url); - // Provided so child processes (e.g. Prisma's CLI) inherit the URL. - // Vitest also forwards process.env to test workers, so the - // application Prisma singleton sees this URL when it loads. + // The explicit child environment keeps Prisma CLI on the same container. execSync("pnpm db:migrate:deploy", { cwd: PROJECT_ROOT, stdio: "inherit", diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 56fdd8094..5ba15dbac 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -1,39 +1,11 @@ /** * Per-test helpers for the integration suite. The Postgres testcontainer - * is started ONCE in `global-setup.ts` and torn down at the end of the - * run, so test files only need: - * - * import { getPrismaClient, truncateAllTables } from "./setup"; - * - * beforeEach(async () => { await truncateAllTables(getPrismaClient()); }); - * - * No beforeAll(startTestDb) / afterAll(stopTestDb) is needed — the - * container is alive whenever the test file is loaded, and Vitest's - * worker inherits `process.env.DATABASE_URL` from globalSetup so the - * application's Prisma singleton (`src/lib/db.ts`) connects to the - * testcontainer transparently. + * is started once in global-setup.ts and bridged into each worker by + * environment-setup.ts before application modules load. */ import { prisma } from "@/lib/db"; import type { PrismaClient } from "@/generated/prisma/client"; -// v1.23 — every integration test imports this module. The free-text health-note -// columns (mood + measurement) are now AES-256-GCM at rest, so any test that -// writes/reads a note needs an encryption key. Crypto reads the key lazily on -// first `encrypt()`, so a `??=` default here covers every test without touching -// each file (individual tests that set their own key still win — `??=`). -process.env.ENCRYPTION_KEY ??= - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -// v1.30.32 — session cookies now carry a CSPRNG secret whose HMAC is what -// lands in `sessions.token_hash`, so `createSession` needs this key exactly -// like token issuance already did. It is a Core-required variable in -// `scripts/env-manifest.json` (the app refuses to boot without it), so -// defaulting it here matches production rather than papering over a gap. -// Same `??=` treatment as the encryption key above: files that set their own -// still win. -process.env.API_TOKEN_HMAC_KEY ??= - "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; - /** * The application's Prisma singleton. Tests use this so any code * imported via `await import("@/lib/...")` shares the exact same client diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 3a36a7989..a6175a20e 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -15,7 +15,15 @@ export default defineConfig({ environment: "node", // Same host-timezone pin as the unit config — CI runs UTC, so the // integration contracts must be read against the same clock. - env: { TZ: "UTC" }, + env: { + TZ: "UTC", + ENCRYPTION_KEY: + "0000000000000000000000000000000000000000000000000000000000000000", + ENCRYPTION_KEYS: "", + ENCRYPTION_ACTIVE_KEY_ID: "", + API_TOKEN_HMAC_KEY: "integration-test-hmac-key-32-bytes-minimum", + SESSION_SECRET: "integration-test-session-secret-32-bytes", + }, include: ["tests/integration/**/*.test.ts"], // Container boot + migration apply is slow; give each test a generous // budget and beforeAll/afterAll twice that. @@ -39,6 +47,9 @@ export default defineConfig({ // expensive boot cost is unaffected. isolate: true, // One container for the whole run; tests truncate between them. + // Bridge the global Testcontainers URL into each isolated worker before + // application modules read DATABASE_URL at import time. + setupFiles: ["./tests/integration/environment-setup.ts"], globalSetup: ["./tests/integration/global-setup.ts"], }, resolve: {