fix(ci): harden test determinism and dependencies

This commit is contained in:
Marc-André Bombeck committed 2026-07-21 00:36:40 +02:00
1 parent 9adeb8fc19
commit 3ce6726646
13 files changed
+354 -75

No files matched your search

+3 -2
View File
@@ -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
+1
View File
@@ -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 ─────────────────────────────────────────
+4 -1
View File
@@ -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"
}
+4 -4
View File
@@ -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
+9 -33
View File
@@ -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
+115
View File
@@ -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<string, string | number>;
};
type Workflow = {
jobs: Record<string, { steps?: WorkflowStep[] }>;
};
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<string, string> };
};
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",
});
});
});
+136
View File
@@ -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<string>;
};
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);
},
);
});
+17
View File
@@ -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;
}
}
+30
View File
@@ -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" }]);
},
);
});
+15
View File
@@ -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";
+6 -4
View File
@@ -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<void>> {
export default async function setup(
project: TestProject,
): Promise<() => Promise<void>> {
container = await new PostgreSqlContainer("postgres:16-alpine")
.withDatabase("healthlog_test")
.withUsername("healthlog")
@@ -38,10 +41,9 @@ export default async function setup(): Promise<() => Promise<void>> {
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",
+2 -30
View File
@@ -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
+12 -1
View File
@@ -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: {