mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(ci): harden test determinism and dependencies
This commit is contained in:
1 parent
9adeb8fc19
commit
3ce6726646
13 files changed
+354
-75
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+9
-33
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" }]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user