diff --git a/.changeset/safe-test-database.md b/.changeset/safe-test-database.md new file mode 100644 index 0000000000..785e5c36bf --- /dev/null +++ b/.changeset/safe-test-database.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Isolate automated tests and global test-mode runs from the normal Fusion database. +category: fix +dev: Adds dedicated FUSION_TEST_DATABASE_URL routing with a separate embedded test cluster fallback. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a855c2f3f7..72b5533365 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1746,6 +1746,10 @@ Activation UX/settings affordances are handled separately in FN-5204. `testMode?: boolean` exists at both global and project scopes. Project `testMode: true` takes precedence and forces planning, executor, reviewer/validator, mission validation, merger, and heartbeat to `mock/scripted` regardless of per-task or per-lane overrides. The dashboard surfaces this with the Settings Modal "Enable test mode" toggle and the shell banner: "Test mode — no real AI calls". +When Fusion starts with global test mode enabled, it also isolates persistence from the normal database. The normal `DATABASE_URL` is ignored: Fusion uses the dedicated external URL in `FUSION_TEST_DATABASE_URL` (and optional `FUSION_TEST_DATABASE_MIGRATION_URL`) or, when no test URL is configured, the separate embedded cluster at `~/.fusion/embedded-postgres/test`. Database selection happens at process startup, so restart Fusion after changing global test mode. `FUSION_TEST_MODE=1` provides the same startup behavior for scripted runs. + +Automated test commands likewise remove inherited `DATABASE_URL` and `DATABASE_MIGRATION_URL` values before starting test workers. PostgreSQL integration suites continue to use their isolated `FUSION_PG_TEST_*` databases. + ## Per-task token budget precedence 1. `task.tokenBudgetOverride` diff --git a/packages/core/src/__test-utils__/vitest-setup.ts b/packages/core/src/__test-utils__/vitest-setup.ts index ea53e03acc..46e214b327 100644 --- a/packages/core/src/__test-utils__/vitest-setup.ts +++ b/packages/core/src/__test-utils__/vitest-setup.ts @@ -74,6 +74,13 @@ function installWarningFilter(): void { installWarningFilter(); +// Never let a DATABASE_URL exported for the operator's normal Fusion runtime +// leak into a Vitest worker. Tests that exercise external PostgreSQL construct +// an isolated database through FUSION_PG_TEST_* and set DATABASE_URL explicitly +// inside the test after this setup file has run. +delete process.env.DATABASE_URL; +delete process.env.DATABASE_MIGRATION_URL; + const TEST_HOME_PREFIX = "fn-test-home-"; const WORKER_ROOT_OWNER_FILE = ".fusion-test-worker-root-owner"; const FUSION_TEST_RUN_TOKEN_ENV = "FUSION_TEST_RUN_TOKEN"; diff --git a/packages/core/src/__tests__/postgres/startup-factory.test.ts b/packages/core/src/__tests__/postgres/startup-factory.test.ts index daf133278b..35a36e1e6c 100644 --- a/packages/core/src/__tests__/postgres/startup-factory.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory.test.ts @@ -20,7 +20,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -31,6 +31,7 @@ import { isEmbeddedPgOptedOut, EMBEDDED_PG_ENV, NO_EMBEDDED_PG_ENV, + resolveStartupDatabaseOptions, } from "../../postgres/startup-factory.js"; import { resolveBackend } from "../../postgres/backend-resolver.js"; @@ -129,6 +130,65 @@ describe("startup-factory: shouldUsePostgresBackend", () => { }); }); +describe("startup-factory: test database selection", () => { + let globalSettingsDir: string; + + beforeEach(async () => { + globalSettingsDir = await mkdtemp(join(tmpdir(), "startup-test-database-")); + }); + + afterEach(async () => { + await rm(globalSettingsDir, { recursive: true, force: true }); + }); + + it("removes an inherited production target before Vitest modules execute", () => { + expect(process.env.DATABASE_URL).toBeUndefined(); + expect(process.env.DATABASE_MIGRATION_URL).toBeUndefined(); + }); + + it("ignores the production DATABASE_URL when persisted global test mode is enabled", async () => { + await writeFile(join(globalSettingsDir, "settings.json"), JSON.stringify({ testMode: true })); + + const resolved = await resolveStartupDatabaseOptions({ + globalSettingsDir, + env: { DATABASE_URL: "postgresql://operator:secret@production.example/fusion" }, + }); + + expect(resolved.testDatabaseMode).toBe(true); + expect(resolved.backend.mode).toBe("embedded"); + expect(resolved.backend.runtimeUrl).toBeNull(); + expect(resolved.embeddedDataDir).toBe(join(globalSettingsDir, "embedded-postgres", "test")); + }); + + it("uses only the explicit test URL when test mode targets external PostgreSQL", async () => { + const testUrl = "postgresql://localhost:5432/fusion_test"; + const resolved = await resolveStartupDatabaseOptions({ + globalSettingsDir, + env: { + FUSION_TEST_MODE: "1", + DATABASE_URL: "postgresql://operator:secret@production.example/fusion", + FUSION_TEST_DATABASE_URL: testUrl, + }, + }); + + expect(resolved.testDatabaseMode).toBe(true); + expect(resolved.backend.runtimeUrl).toBe(testUrl); + expect(resolved.backend.migrationUrl).toBe(testUrl); + }); + + it("keeps the normal production target when test mode is disabled", async () => { + const productionUrl = "postgresql://operator:secret@production.example/fusion"; + const resolved = await resolveStartupDatabaseOptions({ + globalSettingsDir, + env: { DATABASE_URL: productionUrl }, + }); + + expect(resolved.testDatabaseMode).toBe(false); + expect(resolved.backend.runtimeUrl).toBe(productionUrl); + expect(resolved.embeddedDataDir).toBeUndefined(); + }); +}); + describe("startup-factory: createTaskStoreForBackend resolution (no real boot)", () => { let rootDir: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1851fde8c2..4c2293848c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2408,6 +2408,9 @@ export { isEmbeddedPgOptedOut, EMBEDDED_PG_ENV, NO_EMBEDDED_PG_ENV, + TEST_MODE_ENV, + TEST_DATABASE_URL_ENV, + TEST_DATABASE_MIGRATION_URL_ENV, } from "./postgres/index.js"; export type { BackendMode, diff --git a/packages/core/src/postgres/index.ts b/packages/core/src/postgres/index.ts index d6b945be22..7c1f5a77e4 100644 --- a/packages/core/src/postgres/index.ts +++ b/packages/core/src/postgres/index.ts @@ -215,6 +215,9 @@ export { isEmbeddedPgOptedOut, EMBEDDED_PG_ENV, NO_EMBEDDED_PG_ENV, + TEST_MODE_ENV, + TEST_DATABASE_URL_ENV, + TEST_DATABASE_MIGRATION_URL_ENV, type BackendBootResult, type CentralBackendLayerResult, type CreateTaskStoreForBackendOptions, diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index e2281977d6..b8ae44134d 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -45,6 +45,7 @@ import { createLogger } from "../logger.js"; import { TaskStore } from "../store.js"; import { resolveBackend, + resolveBackendWithOptions, describeBackendForLog, type ResolvedBackend, } from "./backend-resolver.js"; @@ -136,6 +137,18 @@ export const EMBEDDED_PG_ENV = "FUSION_EMBEDDED_PG"; */ export const NO_EMBEDDED_PG_ENV = "FUSION_NO_EMBEDDED_PG"; +/** Explicit process-level opt-in for starting Fusion against its test database. */ +export const TEST_MODE_ENV = "FUSION_TEST_MODE"; +/** External PostgreSQL target used only while Fusion test mode is active. */ +export const TEST_DATABASE_URL_ENV = "FUSION_TEST_DATABASE_URL"; +/** Optional direct migration target paired with FUSION_TEST_DATABASE_URL. */ +export const TEST_DATABASE_MIGRATION_URL_ENV = "FUSION_TEST_DATABASE_MIGRATION_URL"; + +function isTruthyEnvValue(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} + /** * Return true when the embedded PostgreSQL backend should be used in embedded * mode (DATABASE_URL unset). @@ -160,8 +173,7 @@ export function isEmbeddedPgRequested(env: NodeJS.ProcessEnv = process.env): boo * Detect obsolete FUSION_NO_EMBEDDED_PG configuration for diagnostics. */ export function isEmbeddedPgOptedOut(env: NodeJS.ProcessEnv = process.env): boolean { - const raw = (env[NO_EMBEDDED_PG_ENV] ?? "").trim().toLowerCase(); - return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; + return isTruthyEnvValue(env[NO_EMBEDDED_PG_ENV]); } /** @@ -443,6 +455,72 @@ async function bootSchemaBackendOnce( } } +export interface StartupDatabaseOptions { + readonly testDatabaseMode: boolean; + readonly backend: ResolvedBackend; + readonly embeddedDataDir?: string; +} + +/** + * Resolve the database target before any schema, registry, or project write. + * + * Test mode is a storage boundary, not only a model-selection flag. A persisted + * global `testMode: true` (or FUSION_TEST_MODE=1) therefore ignores the normal + * DATABASE_URL. Operators may provide FUSION_TEST_DATABASE_URL for a dedicated + * external database; otherwise Fusion uses a separate embedded cluster under + * `/embedded-postgres/test`. + * + * An explicit `backend` or `embeddedDataDir` remains authoritative for focused + * tests and programmatic embeddings. + */ +export async function resolveStartupDatabaseOptions( + options: Pick = {}, +): Promise { + const env = options.env ?? process.env; + let globalTestMode = false; + + // Vitest callers without a threaded temp global dir intentionally avoid the + // real operator settings file. Production and callers with an explicit dir + // can safely read the file-backed bootstrap setting before PostgreSQL opens. + const runningVitest = process.env.VITEST === "true"; + if (options.globalSettingsDir || !runningVitest) { + const { GlobalSettingsStore } = await import("../global-settings.js"); + globalTestMode = (await new GlobalSettingsStore(options.globalSettingsDir).getSettings()).testMode === true; + } + + const testDatabaseMode = isTruthyEnvValue(env[TEST_MODE_ENV]) || globalTestMode; + if (!testDatabaseMode) { + return { + testDatabaseMode: false, + backend: options.backend ?? resolveBackend(env), + ...(options.embeddedDataDir ? { embeddedDataDir: options.embeddedDataDir } : {}), + }; + } + + const backend = options.backend ?? resolveBackendWithOptions({ + databaseUrl: env[TEST_DATABASE_URL_ENV] ?? null, + databaseMigrationUrl: env[TEST_DATABASE_MIGRATION_URL_ENV] ?? null, + }); + if (backend.mode === "external") { + return { + testDatabaseMode: true, + backend, + ...(options.embeddedDataDir ? { embeddedDataDir: options.embeddedDataDir } : {}), + }; + } + + let globalDir = options.globalSettingsDir; + if (!globalDir) { + const { resolveGlobalDir } = await import("../global-settings.js"); + globalDir = resolveGlobalDir(); + } + return { + testDatabaseMode: true, + backend, + embeddedDataDir: options.embeddedDataDir ?? join(globalDir, "embedded-postgres", "test"), + }; +} + /** * Open an unscoped PostgreSQL layer for the central project/node registry. * @@ -456,7 +534,12 @@ async function bootSchemaBackendOnce( export async function createCentralBackendLayer( options: Pick = {}, ): Promise { - const boot = await bootSchemaBackend(options, true); + const databaseOptions = await resolveStartupDatabaseOptions(options); + const boot = await bootSchemaBackend({ + ...options, + backend: databaseOptions.backend, + ...(databaseOptions.embeddedDataDir ? { embeddedDataDir: databaseOptions.embeddedDataDir } : {}), + }, true); const { backend: resolvedBackend, connections, @@ -486,7 +569,7 @@ export async function createCentralBackendLayer( connections.migration, CENTRAL_SQLITE_MIGRATION_KEY, ); - if (!centralMigrationComplete && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) { + if (!databaseOptions.testDatabaseMode && !centralMigrationComplete && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) { const report = await migrateSqliteToPostgres(connections.migration, [{ sqlitePath: legacyCentralPath, pgSchema: "central", @@ -633,7 +716,13 @@ export async function createTaskStoreForBackend( options: CreateTaskStoreForBackendOptions, ): Promise { const env = options.env ?? process.env; - const backend = options.backend ?? resolveBackend(env); + const databaseOptions = await resolveStartupDatabaseOptions(options); + const backend = databaseOptions.backend; + const effectiveOptions: CreateTaskStoreForBackendOptions = { + ...options, + backend, + ...(databaseOptions.embeddedDataDir ? { embeddedDataDir: databaseOptions.embeddedDataDir } : {}), + }; const embeddedRequested = options.embeddedPgRequested ?? isEmbeddedPgRequested(env); /* @@ -667,7 +756,7 @@ export async function createTaskStoreForBackend( let boot: SchemaBackendBootResult; try { const schemaT0 = Date.now(); - boot = await bootSchemaBackend(options); + boot = await bootSchemaBackend(effectiveOptions); log.log(`startup phase backend.schemaBackend: ${Date.now() - schemaT0}ms`); } catch (err) { const chain = describeErrorChain(err); @@ -724,7 +813,9 @@ export async function createTaskStoreForBackend( is not registered (legacy/unregistered single-project setups stay unbound, matching their unfiltered readers). */ - if (rootDir) { + // A test database starts clean by definition. Never seed it from the + // operator's retained production SQLite files. + if (rootDir && !databaseOptions.testDatabaseMode) { try { const fusionDir = join(rootDir, ".fusion"); const legacySqlitePath = join(fusionDir, "fusion.db"); diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 9682b1f4e0..40feb6a0e6 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -24,6 +24,7 @@ import { shouldRunIsolationGuard, defaultTestWorkerBudget, createIsolatedHomeEnv, + createTestProcessEnv, cleanupIsolatedHomePath, knownIsolatedHomeBasenames, __setCleanupRmSyncForTests, @@ -1071,6 +1072,23 @@ test("createIsolatedHomeEnv: returns temp HOME/USERPROFILE pair without mutating rmSync(isolatedHome, { recursive: true, force: true }); }); +test("createTestProcessEnv: never forwards production database targets into tests", () => { + const input = { + NODE_ENV: "production", + DATABASE_URL: "postgresql://operator:secret@production.example/fusion", + DATABASE_MIGRATION_URL: "postgresql://operator:secret@production.example/fusion", + FUSION_PG_TEST_URL_BASE: "postgresql://localhost:5432", + }; + + const env = createTestProcessEnv(input); + + assert.equal(env.NODE_ENV, "test"); + assert.equal(env.DATABASE_URL, undefined); + assert.equal(env.DATABASE_MIGRATION_URL, undefined); + assert.equal(env.FUSION_PG_TEST_URL_BASE, input.FUSION_PG_TEST_URL_BASE); + assert.equal(input.DATABASE_URL, "postgresql://operator:secret@production.example/fusion"); +}); + test("createIsolatedHomeEnv: preserves a stable COREPACK_HOME outside the isolated HOME", () => { const baseEnv = { diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 741708d282..de708ac489 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1235,10 +1235,17 @@ export function createTestProcessEnv(env = process.env) { FNXC:TestInfrastructure 2026-06-17-17:02: Developer shells and release scripts can export NODE_ENV=production, but Vitest must resolve React, Testing Library, and Vite transforms through their test/development paths. Normalize spawned test processes here so pnpm test cannot inherit production React test-utils and stall/fail jsdom lanes. */ - return { + const nextEnv = { ...env, NODE_ENV: "test", }; + // A developer may legitimately export DATABASE_URL for their normal Fusion + // runtime. Test commands must never inherit that production target. PostgreSQL + // integration tests use the dedicated FUSION_PG_TEST_* harness variables or + // set a per-test DATABASE_URL after process setup. + delete nextEnv.DATABASE_URL; + delete nextEnv.DATABASE_MIGRATION_URL; + return nextEnv; } const fullSuiteEnv = {