From 05656ce0665f26f9360b0fd563e5f7b38d4ec1fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 13:12:15 +0000 Subject: [PATCH] Split backend integration tests into seeded and snapshot groups The seeded group (vitest.config.ts, src/tests/graph/) runs against the system graph seeded once per run by globalSetup and must never wipe it. The snapshot group (vitest.snapshot.config.ts, src/tests/subgraph/) wipes the graph and restores standalone snapshots, so test:integration runs it as a separate vitest invocation after the seeded group. A runtime guard in admin-server.ts makes resetGraph/restoreSnapshot refuse to run outside the snapshot group, keyed off the HASH_TEST_GROUP marker set by the snapshot config. --- tests/hash-backend-integration/package.json | 2 +- .../src/tests/admin-server.ts | 29 ++++++ tests/hash-backend-integration/tsconfig.json | 7 +- .../hash-backend-integration/vitest.config.ts | 90 +++++++++---------- .../vitest.snapshot.config.ts | 44 +++++++++ 5 files changed, 121 insertions(+), 51 deletions(-) create mode 100644 tests/hash-backend-integration/vitest.snapshot.config.ts diff --git a/tests/hash-backend-integration/package.json b/tests/hash-backend-integration/package.json index 8df50911c8a..3eb829df0b1 100644 --- a/tests/hash-backend-integration/package.json +++ b/tests/hash-backend-integration/package.json @@ -10,7 +10,7 @@ "fix:eslint": "eslint --fix .", "lint:eslint": "eslint --report-unused-disable-directives .", "lint:tsc": "tsc --noEmit", - "test:integration": "vitest --run" + "test:integration": "vitest --run && vitest --run --config vitest.snapshot.config.ts" }, "dependencies": { "@apps/hash-api": "workspace:*", diff --git a/tests/hash-backend-integration/src/tests/admin-server.ts b/tests/hash-backend-integration/src/tests/admin-server.ts index 5ae6dbbefe1..d884473f3e4 100644 --- a/tests/hash-backend-integration/src/tests/admin-server.ts +++ b/tests/hash-backend-integration/src/tests/admin-server.ts @@ -6,6 +6,25 @@ import { StatusCode } from "@local/status"; import type { GraphStatus } from "@rust/hash-graph-type-defs/typescript/status"; +/** + * Throw unless running in the snapshot group of the backend integration + * tests. + * + * Destructive graph operations wipe the shared system graph that the seeded + * group (`vitest.config.ts`) seeds once per run via `globalSetup`, so they + * may only run in the snapshot group (`vitest.snapshot.config.ts`), which + * runs as a separate vitest invocation after the seeded group and owns the + * wipe. The snapshot group's config sets the `HASH_TEST_GROUP` marker checked + * here. + */ +const assertRunningInSnapshotGroup = (operation: string) => { + if (process.env.HASH_TEST_GROUP !== "snapshot") { + throw new Error( + `\`${operation}\` wipes the graph and may only run in the snapshot group of the backend integration tests, which runs as a separate vitest invocation after the seeded group so it cannot destroy the system graph seeded by \`globalSetup\`. To make a test file destructive, place it under \`src/tests/subgraph/\` so that \`vitest.snapshot.config.ts\` picks it up.`, + ); + } +}; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const port = process.env.HASH_GRAPH_ADMIN_PORT || "4001"; @@ -96,8 +115,13 @@ export const deleteEntities = async () => { /** * Restore a snapshot from a file. + * + * May only be called from the snapshot group of the backend integration + * tests (`vitest.snapshot.config.ts`). */ export const restoreSnapshot = async (snapshotPath: string) => { + assertRunningInSnapshotGroup("restoreSnapshot"); + await fetch(`http://127.0.0.1:${port}/snapshot`, { method: "POST", body: createReadStream(snapshotPath), @@ -137,8 +161,13 @@ export const deleteUser = async ( * Reset the Graph. * * This is a convenience function for deleting all entities, entity types, property types, data types, and accounts. + * + * May only be called from the snapshot group of the backend integration + * tests (`vitest.snapshot.config.ts`). */ export const resetGraph = async () => { + assertRunningInSnapshotGroup("resetGraph"); + await deleteEntities(); await deleteEntityTypes(); await deletePropertyTypes(); diff --git a/tests/hash-backend-integration/tsconfig.json b/tests/hash-backend-integration/tsconfig.json index 8ff51e490d6..31ae08d6037 100644 --- a/tests/hash-backend-integration/tsconfig.json +++ b/tests/hash-backend-integration/tsconfig.json @@ -1,4 +1,9 @@ { "extends": "@local/tsconfig/legacy-base-tsconfig-to-refactor.json", - "include": ["./src/", "codegen.config.ts", "vitest.config.ts"] + "include": [ + "./src/", + "codegen.config.ts", + "vitest.config.ts", + "vitest.snapshot.config.ts" + ] } diff --git a/tests/hash-backend-integration/vitest.config.ts b/tests/hash-backend-integration/vitest.config.ts index 83ada709987..9339a6e9bcd 100644 --- a/tests/hash-backend-integration/vitest.config.ts +++ b/tests/hash-backend-integration/vitest.config.ts @@ -1,29 +1,48 @@ /// import { defineConfig } from "vitest/config"; -import { BaseSequencer } from "vitest/node"; -import type { TestSpecification } from "vitest/node"; +import type { TestUserConfig } from "vitest/config"; /** - * The subgraph tests reset the graph and restore standalone snapshots, - * destroying the shared system graph seeded once per run by `globalSetup`. - * Sort them after all other test files so that they cannot break tests which - * rely on the shared seed – the next run's `globalSetup` re-seeds the graph - * from scratch. + * The backend integration tests are split into two groups which run as + * separate, sequential vitest invocations (see the `test:integration` script + * in `package.json`): + * + * - the seeded group (this config, `src/tests/graph/`): runs against the + * shared system graph seeded once per run by `globalSetup` and must never + * wipe it, and + * - the snapshot group (`vitest.snapshot.config.ts`, `src/tests/subgraph/`): + * wipes the graph and restores standalone snapshots, so it runs after the + * seeded group. + * + * These test options are shared between the two configs. */ -class DestructiveTestsLastSequencer extends BaseSequencer { - override async sort(files: TestSpecification[]) { - const isDestructive = (file: TestSpecification) => - file.moduleId.includes("/src/tests/subgraph/"); - - const sorted = await super.sort(files); - - return [ - ...sorted.filter((file) => !isDestructive(file)), - ...sorted.filter(isDestructive), - ]; - } -} +export const sharedTestConfig = { + coverage: { + enabled: process.env.TEST_COVERAGE === "true", + provider: "istanbul", + reporter: ["lcov", "text"], + include: ["**/*.{c,m,}{j,t}s{x,}"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, + setupFiles: [ + "@local/hash-backend-utils/environment", + "./src/tests/setup-opentelemetry.ts", + ], + environment: "node", + testTimeout: 60_000, + hookTimeout: 120_000, + sequence: { + hooks: "list", + }, + /** + * These integration tests share a single graph instance, so running files + * in parallel causes graph state races. + */ + fileParallelism: false, + maxWorkers: 1, + maxConcurrency: 1, +} satisfies TestUserConfig; export default defineConfig({ plugins: [], @@ -31,35 +50,8 @@ export default defineConfig({ target: "esnext", }, test: { - coverage: { - enabled: process.env.TEST_COVERAGE === "true", - provider: "istanbul", - reporter: ["lcov", "text"], - include: ["**/*.{c,m,}{j,t}s{x,}"], - exclude: ["**/node_modules/**", "**/dist/**"], - }, + ...sharedTestConfig, globalSetup: ["./src/tests/global-setup.ts"], - setupFiles: [ - "@local/hash-backend-utils/environment", - "./src/tests/setup-opentelemetry.ts", - ], - include: [ - "src/tests/graph/**/*.test.ts", - "src/tests/subgraph/**/*.test.ts", - ], - environment: "node", - testTimeout: 60_000, - hookTimeout: 120_000, - sequence: { - hooks: "list", - sequencer: DestructiveTestsLastSequencer, - }, - /** - * These integration tests share a single graph instance, so running files - * in parallel causes graph state races. - */ - fileParallelism: false, - maxWorkers: 1, - maxConcurrency: 1, + include: ["src/tests/graph/**/*.test.ts"], }, }); diff --git a/tests/hash-backend-integration/vitest.snapshot.config.ts b/tests/hash-backend-integration/vitest.snapshot.config.ts new file mode 100644 index 00000000000..c3f6530a690 --- /dev/null +++ b/tests/hash-backend-integration/vitest.snapshot.config.ts @@ -0,0 +1,44 @@ +/// +import { defineConfig } from "vitest/config"; + +import { sharedTestConfig } from "./vitest.config"; + +/** + * Config for the snapshot group of the backend integration tests: the test + * files under `src/tests/subgraph/` wipe the graph via `resetGraph` and + * restore standalone snapshots via `restoreSnapshot`, so they cannot share + * the system graph seeded by the seeded group's `globalSetup`. + * + * The `test:integration` script in `package.json` runs this config as a + * separate vitest invocation after the seeded group (`vitest.config.ts`), so + * the seeded group structurally cannot lose its seed to these tests. There is + * deliberately no `globalSetup` here – each test file restores the snapshot + * it needs, and the next seeded run re-seeds the graph from scratch. + * + * To make a test file destructive, place it under `src/tests/subgraph/` so + * this config picks it up. Destructive graph operations refuse to run outside + * this group: `resetGraph`/`restoreSnapshot` in `src/tests/admin-server.ts` + * throw unless the `HASH_TEST_GROUP` marker below is set. + */ +export default defineConfig({ + plugins: [], + build: { + target: "esnext", + }, + test: { + ...sharedTestConfig, + coverage: { + ...sharedTestConfig.coverage, + /** + * Written next to the seeded group's `./coverage` – vitest cleans its + * reports directory at the start of a run, so sharing one directory + * would discard the seeded group's report. + */ + reportsDirectory: "./coverage-snapshot", + }, + include: ["src/tests/subgraph/**/*.test.ts"], + env: { + HASH_TEST_GROUP: "snapshot", + }, + }, +});