diff --git a/bunfig.toml b/bunfig.toml
index f5988b706..c9cafa8c1 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -5,4 +5,4 @@ minimumReleaseAge = 604800 # 7 days in seconds
minimumReleaseAgeExcludes = ["@opencode-ai/ai", "@opencode-ai/client", "@opencode-ai/plugin", "@opencode-ai/protocol", "@opencode-ai/schema", "@pierre/diffs", "@pierre/theme", "@pierre/theming", "@plannotator/atomic-editor", "@plannotator/markdown-editor", "@plannotator/webtui"]
[test]
-preload = ["./packages/ui/test-setup/happy-dom.ts", "./tests/setup/feedback-archive-off.ts"]
+preload = ["./tests/setup/feedback-archive-off.ts", "./packages/ui/test-setup/happy-dom.ts"]
diff --git a/packages/server/ai-disabled.test.ts b/packages/server/ai-disabled.test.ts
index 8210fc2da..b1efc1640 100644
--- a/packages/server/ai-disabled.test.ts
+++ b/packages/server/ai-disabled.test.ts
@@ -1,9 +1,6 @@
import { expect, test } from "bun:test";
import type { PRMetadata } from "@plannotator/shared/pr-types";
import type { WorktreePool } from "@plannotator/shared/worktree-pool";
-import { existsSync, mkdtempSync, rmSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
import { fileURLToPath } from "node:url";
const SPA_HTML = "
test";
@@ -169,42 +166,36 @@ async function verifyDisabledServers(): Promise {
}
async function runInIsolatedDataDirectory(): Promise {
- const dataDir = mkdtempSync(join(tmpdir(), "plannotator-ai-disabled-"));
const childEnv = {
...process.env,
[ISOLATED_CHILD_ENV]: "1",
PLANNOTATOR_AI: "disabled",
- PLANNOTATOR_DATA_DIR: dataDir,
PLANNOTATOR_REMOTE: "0",
};
delete childEnv.PLANNOTATOR_PORT;
- try {
- // storage.ts captures PLANNOTATOR_DATA_DIR at module load, so a child
- // process is required to keep this test isolated regardless of which
- // test files Bun evaluated first in the parent process.
- const child = Bun.spawn(
- [process.execPath, "test", fileURLToPath(import.meta.url)],
- {
- cwd: process.cwd(),
- env: childEnv,
- stdout: "pipe",
- stderr: "pipe",
- },
+ // storage.ts captures PLANNOTATOR_DATA_DIR at module load, so a child
+ // process is required to keep this test isolated regardless of which
+ // test files Bun evaluated first in the parent process. The test preload
+ // owns the child process's data directory and cleans it up on exit.
+ const child = Bun.spawn(
+ [process.execPath, "test", fileURLToPath(import.meta.url)],
+ {
+ cwd: process.cwd(),
+ env: childEnv,
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+ );
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(
+ `Isolated disabled-AI test failed (${exitCode})\n${stdout}\n${stderr}`,
);
- const [exitCode, stdout, stderr] = await Promise.all([
- child.exited,
- new Response(child.stdout).text(),
- new Response(child.stderr).text(),
- ]);
- if (exitCode !== 0) {
- throw new Error(
- `Isolated disabled-AI test failed (${exitCode})\n${stdout}\n${stderr}`,
- );
- }
- expect(existsSync(join(dataDir, "history"))).toBe(true);
- } finally {
- rmSync(dataDir, { recursive: true, force: true });
}
}
diff --git a/packages/server/call-flow-install-endpoint.test.ts b/packages/server/call-flow-install-endpoint.test.ts
index dec2f3399..5e331e218 100644
--- a/packages/server/call-flow-install-endpoint.test.ts
+++ b/packages/server/call-flow-install-endpoint.test.ts
@@ -6,15 +6,9 @@ import { join } from 'node:path';
import type { CallFlowInstallStage, CallFlowNodePreflight, CallFlowRuntimeInstallResult } from '@plannotator/shared/call-flow';
// PLANNOTATOR_DATA_DIR is only ever changed INSIDE tests (boot() below) and
-// restored to its original value after each one. It must never be overridden
-// at module-eval time: bun evaluates every test file's module before running
-// tests in one shared process, and Pi's generated/storage.ts caches its data
-// dir at import time. A module-eval override here makes storage's cached dir
-// and later files' live getPlannotatorDataDir() calls disagree, which is
-// exactly the Pi annotate-history / durable-submit CI failure this comment
-// guards against. Config writes made by these tests target whatever dir the
-// process's config module froze at first import; the snapshot/restore in
-// afterAll below keeps those writes from leaking into a real config.json.
+// restored after each one. Module-eval overrides would leak into other test
+// files because Bun runs the suite in one shared process. The config
+// snapshot/restore in afterAll also protects against shared config state.
const originalDataDir = process.env.PLANNOTATOR_DATA_DIR;
const originalPort = process.env.PLANNOTATOR_PORT;
const originalPath = process.env.PATH;
diff --git a/packages/server/storage.test.ts b/packages/server/storage.test.ts
index be005caa7..8bdee91f2 100644
--- a/packages/server/storage.test.ts
+++ b/packages/server/storage.test.ts
@@ -174,3 +174,39 @@ describe("listVersions", () => {
expect(versions[0].timestamp).toBeTruthy();
});
});
+
+describe("PLANNOTATOR_DATA_DIR", () => {
+ test("isolates plan and history data when the data directory changes after import", () => {
+ const savedDataDir = process.env.PLANNOTATOR_DATA_DIR;
+ const firstDir = makeTempDir();
+ const secondDir = makeTempDir();
+ const project = "data-dir-project";
+ const slug = "data-dir-plan";
+
+ try {
+ process.env.PLANNOTATOR_DATA_DIR = firstDir;
+ savePlan(slug, "# First plan");
+ saveToHistory(project, slug, "# First version");
+ expect(readFileSync(join(firstDir, "plans", `${slug}.md`), "utf-8")).toBe("# First plan");
+ expect(getPlanVersion(project, slug, 1)).toBe("# First version");
+ expect(getVersionCount(project, slug)).toBe(1);
+
+ process.env.PLANNOTATOR_DATA_DIR = secondDir;
+ expect(getPlanVersion(project, slug, 1)).toBeNull();
+ expect(getVersionCount(project, slug)).toBe(0);
+ savePlan(slug, "# Second plan");
+ saveToHistory(project, slug, "# Second version");
+ expect(readFileSync(join(secondDir, "plans", `${slug}.md`), "utf-8")).toBe("# Second plan");
+ expect(getPlanVersion(project, slug, 1)).toBe("# Second version");
+ expect(getVersionCount(project, slug)).toBe(1);
+
+ process.env.PLANNOTATOR_DATA_DIR = firstDir;
+ expect(readFileSync(join(firstDir, "plans", `${slug}.md`), "utf-8")).toBe("# First plan");
+ expect(getPlanVersion(project, slug, 1)).toBe("# First version");
+ expect(getVersionCount(project, slug)).toBe(1);
+ } finally {
+ if (savedDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
+ else process.env.PLANNOTATOR_DATA_DIR = savedDataDir;
+ }
+ });
+});
diff --git a/packages/shared/data-dir.test.ts b/packages/shared/data-dir.test.ts
index 6fa472870..381c82298 100644
--- a/packages/shared/data-dir.test.ts
+++ b/packages/shared/data-dir.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
-import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -81,3 +81,214 @@ describe("getPlannotatorDataDir", () => {
expect(dir).toBe(join(fakeHome, ".plannotator"));
});
});
+
+test("bun test isolates imported stores, inherits runtime writes, and cleans only its owned directory after hooks", async () => {
+ const repoRoot = join(import.meta.dir, "../..");
+ const home = join(fakeHome, "home");
+ const xdg = join(fakeHome, "xdg");
+ const tempRoot = join(fakeHome, "tmp");
+ const contributor = join(fakeHome, "contributor-data");
+ const override = join(fakeHome, "explicit-override");
+ for (const dir of [home, xdg, tempRoot, contributor, override]) mkdirSync(dir);
+ const contributorConfig = JSON.stringify({ displayName: "contributor", feedbackHistory: true });
+ writeFileSync(join(contributor, "config.json"), contributorConfig);
+ writeFileSync(join(override, "keep"), "caller-owned");
+
+ // These fixtures live outside the repository and are run by exact filename:
+ // a nested `bun test` must load the real bunfig, never rediscover this test.
+ const storesFile = join(fakeHome, "stores.ts");
+ const runtimeFile = join(fakeHome, "runtime.ts");
+ const nestedFile = join(fakeHome, "nested.test.ts");
+ const fixtureFile = join(fakeHome, "preload.test.ts");
+ const reportFile = join(fakeHome, "after-all.json");
+ const runtimeReport = join(fakeHome, "runtime.json");
+ const nestedReport = join(fakeHome, "nested.json");
+
+ writeFileSync(storesFile, `
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join, sep } from "node:path";
+// Static imports are essential: storage captures DATA_DIR during evaluation.
+import { saveToHistory, saveAnnotateSubmission } from ${JSON.stringify(join(import.meta.dir, "storage.ts"))};
+import { loadConfig, saveConfig, resolveFeedbackHistory } from ${JSON.stringify(join(import.meta.dir, "config.ts"))};
+import { appendFeedbackRecord } from ${JSON.stringify(join(import.meta.dir, "feedback-archive.ts"))};
+export { loadConfig, saveConfig, resolveFeedbackHistory, appendFeedbackRecord };
+
+export function writeStores(project: string) {
+ const dataDir = process.env.PLANNOTATOR_DATA_DIR!;
+ const history = saveToHistory(project, "plan", "history:" + project).path;
+ const submission = saveAnnotateSubmission(project, "plan", "submission:" + project);
+ for (const path of [history, submission]) assert.ok(path.startsWith(dataDir + sep), path);
+ assert.equal(readFileSync(history, "utf-8"), "history:" + project);
+ assert.equal(readFileSync(submission, "utf-8"), "submission:" + project);
+ saveConfig({ displayName: project });
+ assert.equal(loadConfig().displayName, project);
+ assert.equal(JSON.parse(readFileSync(join(dataDir, "config.json"), "utf-8")).displayName, project);
+ return { dataDir, history, submission };
+}
+
+export async function runChild(args: string[], env = process.env) {
+ const child = Bun.spawn({
+ cmd: [process.execPath, ...args],
+ cwd: ${JSON.stringify(repoRoot)},
+ env,
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const timer = setTimeout(() => child.kill(), 12_000);
+ try {
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ]);
+ assert.equal(exitCode, 0, stdout + stderr);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+`);
+
+ writeFileSync(runtimeFile, `
+import assert from "node:assert/strict";
+import { writeFileSync } from "node:fs";
+import { writeStores, resolveFeedbackHistory } from ${JSON.stringify(storesFile)};
+assert.equal(resolveFeedbackHistory({ feedbackHistory: true }), false);
+writeFileSync(${JSON.stringify(runtimeReport)}, JSON.stringify(writeStores("runtime")));
+`);
+
+ writeFileSync(nestedFile, `
+import { afterAll, test } from "bun:test";
+import assert from "node:assert/strict";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname } from "node:path";
+import { writeStores } from ${JSON.stringify(storesFile)};
+let writes;
+test("a nested test run owns a fresh sandbox rather than its parent's", () => {
+ const dataDir = process.env.PLANNOTATOR_DATA_DIR!;
+ assert.notEqual(dataDir, process.env.PARENT_DATA_DIR);
+ assert.equal(dirname(dataDir), ${JSON.stringify(tempRoot)});
+ writes = writeStores("nested");
+});
+afterAll(() => {
+ assert.equal(readFileSync(writes.history, "utf-8"), "history:nested");
+ assert.ok(existsSync(process.env.PARENT_DATA_DIR!));
+ writeFileSync(${JSON.stringify(nestedReport)}, JSON.stringify(writes));
+});
+`);
+
+ writeFileSync(fixtureFile, `
+import { afterAll, test } from "bun:test";
+import assert from "node:assert/strict";
+import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import {
+ appendFeedbackRecord, loadConfig, resolveFeedbackHistory, runChild, saveConfig, writeStores,
+} from ${JSON.stringify(storesFile)};
+const owned = process.env.PLANNOTATOR_DATA_DIR!;
+const override = ${JSON.stringify(override)};
+let writes;
+
+function assertContributorUntouched() {
+ assert.deepEqual(readdirSync(${JSON.stringify(contributor)}), ["config.json"]);
+ assert.equal(readFileSync(${JSON.stringify(join(contributor, "config.json"))}, "utf-8"), ${JSON.stringify(contributorConfig)});
+}
+
+test("preloading precedes storage imports without disabling explicit overrides", async () => {
+ assert.equal(dirname(owned), ${JSON.stringify(tempRoot)});
+ assert.ok(existsSync(owned));
+ assert.deepEqual(loadConfig(), {});
+ // The contributor explicitly enabled feedback history in both env and config.
+ assert.equal(resolveFeedbackHistory({ feedbackHistory: true }), false);
+ writes = writeStores("parent");
+
+ const savedHistory = process.env.PLANNOTATOR_FEEDBACK_HISTORY!;
+ try {
+ process.env.PLANNOTATOR_DATA_DIR = override;
+ process.env.PLANNOTATOR_FEEDBACK_HISTORY = "1";
+ saveConfig({ displayName: "override" });
+ assert.equal(loadConfig().displayName, "override");
+ assert.equal(resolveFeedbackHistory(loadConfig()), true);
+ const input = { project: "preload", surface: "review", decision: "feedback", feedback: "override feedback" } as const;
+ const overrideIndex = appendFeedbackRecord(input);
+ assert.equal(overrideIndex, join(override, "feedback", "preload", "index.jsonl"));
+ assert.equal(JSON.parse(readFileSync(overrideIndex!, "utf-8")).feedback, "override feedback");
+
+ process.env.PLANNOTATOR_DATA_DIR = owned;
+ assert.equal(loadConfig().displayName, "parent");
+ const restoredIndex = appendFeedbackRecord({ ...input, feedback: "restored feedback" });
+ assert.equal(restoredIndex, join(owned, "feedback", "preload", "index.jsonl"));
+ assert.equal(JSON.parse(readFileSync(restoredIndex!, "utf-8")).feedback, "restored feedback");
+ } finally {
+ process.env.PLANNOTATOR_DATA_DIR = owned;
+ process.env.PLANNOTATOR_FEEDBACK_HISTORY = savedHistory;
+ }
+ assert.equal(resolveFeedbackHistory(loadConfig()), false);
+ assertContributorUntouched();
+
+ await runChild(["test", "--timeout", "10000", ${JSON.stringify(nestedFile)}], {
+ ...process.env, PARENT_DATA_DIR: owned,
+ });
+ const nested = JSON.parse(readFileSync(${JSON.stringify(nestedReport)}, "utf-8"));
+ assert.notEqual(nested.dataDir, owned);
+ assert.equal(existsSync(nested.dataDir), false);
+ assert.equal(readFileSync(writes.history, "utf-8"), "history:parent");
+});
+
+afterAll(async () => {
+ // Read an earlier write before anything can recreate a prematurely removed dir.
+ assert.equal(readFileSync(writes.submission, "utf-8"), "submission:parent");
+ await runChild(["run", ${JSON.stringify(runtimeFile)}]);
+ const runtime = JSON.parse(readFileSync(${JSON.stringify(runtimeReport)}, "utf-8"));
+ assert.equal(runtime.dataDir, owned);
+ assert.equal(readFileSync(runtime.history, "utf-8"), "history:runtime");
+ assertContributorUntouched();
+ writeFileSync(${JSON.stringify(reportFile)}, JSON.stringify(writeStores("after-all")));
+ // Deliberately exit with a caller-owned override selected. Cleanup must use
+ // the preload's captured path, not whichever env value a test leaves behind.
+ process.env.PLANNOTATOR_DATA_DIR = override;
+});
+`);
+
+ const child = Bun.spawn({
+ cmd: [process.execPath, "test", "--timeout", "15000", fixtureFile],
+ cwd: repoRoot,
+ // Do not inherit any real data/home/temp location, even against the unfixed
+ // preload. Runtime descendants then inherit only these controlled values.
+ env: {
+ PATH: process.env.PATH ?? "",
+ HOME: home,
+ USERPROFILE: home,
+ XDG_DATA_HOME: xdg,
+ TMPDIR: tempRoot,
+ TMP: tempRoot,
+ TEMP: tempRoot,
+ PLANNOTATOR_DATA_DIR: contributor,
+ PLANNOTATOR_FEEDBACK_HISTORY: "1",
+ },
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const timer = setTimeout(() => child.kill(), 25_000);
+ try {
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ]);
+ if (exitCode !== 0) throw new Error(`preload regression subprocess failed:\n${stdout}${stderr}`);
+
+ const report = JSON.parse(readFileSync(reportFile, "utf-8"));
+ expect(existsSync(report.dataDir)).toBe(false);
+ expect(readdirSync(contributor)).toEqual(["config.json"]);
+ expect(readFileSync(join(contributor, "config.json"), "utf-8")).toBe(contributorConfig);
+ expect(readFileSync(join(override, "keep"), "utf-8")).toBe("caller-owned");
+ expect(JSON.parse(readFileSync(join(override, "config.json"), "utf-8")).displayName).toBe("override");
+ expect(JSON.parse(readFileSync(join(override, "feedback", "preload", "index.jsonl"), "utf-8")).feedback)
+ .toBe("override feedback");
+ expect(existsSync(join(home, ".plannotator"))).toBe(false);
+ expect(existsSync(join(xdg, "plannotator"))).toBe(false);
+ } finally {
+ clearTimeout(timer);
+ }
+}, 35_000);
diff --git a/packages/shared/improvement-hooks.test.ts b/packages/shared/improvement-hooks.test.ts
index b915441d1..610c1c474 100644
--- a/packages/shared/improvement-hooks.test.ts
+++ b/packages/shared/improvement-hooks.test.ts
@@ -60,7 +60,8 @@ async function runScenario(setup: {
`,
],
{
- env: { ...process.env, HOME: TEST_HOME },
+ // Exercise the fake HOME rather than inheriting the parent test sandbox.
+ env: { ...process.env, HOME: TEST_HOME, USERPROFILE: TEST_HOME, PLANNOTATOR_DATA_DIR: "" },
cwd: join(import.meta.dir, "../.."),
stdout: "pipe",
stderr: "pipe",
diff --git a/packages/shared/prompts-integration.test.ts b/packages/shared/prompts-integration.test.ts
index 1b4bbfc33..69375145a 100644
--- a/packages/shared/prompts-integration.test.ts
+++ b/packages/shared/prompts-integration.test.ts
@@ -34,7 +34,8 @@ function cleanTestHome() {
async function runScript(script: string): Promise {
const proc = Bun.spawn(["bun", "-e", script], {
- env: { ...process.env, HOME: TEST_HOME },
+ // Exercise the fake HOME rather than inheriting the parent test sandbox.
+ env: { ...process.env, HOME: TEST_HOME, USERPROFILE: TEST_HOME, PLANNOTATOR_DATA_DIR: "" },
cwd: PROJECT_ROOT,
stdout: "pipe",
stderr: "pipe",
diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts
index cc4e12f66..5c3662043 100644
--- a/packages/shared/storage.ts
+++ b/packages/shared/storage.ts
@@ -13,8 +13,6 @@ import { sanitizeTag } from "./project";
import { resolveUserPath } from "./resolve-file";
import { getPlannotatorDataDir } from "./data-dir";
-const DATA_DIR = getPlannotatorDataDir();
-
/**
* Get the plan storage directory, creating it if needed.
* Cross-platform: uses os.homedir() for Windows/macOS/Linux compatibility.
@@ -26,7 +24,7 @@ export function getPlanDir(customPath?: string | null): string {
if (customPath?.trim()) {
planDir = resolveUserPath(customPath);
} else {
- planDir = join(DATA_DIR, "plans");
+ planDir = join(getPlannotatorDataDir(), "plans");
}
mkdirSync(planDir, { recursive: true });
@@ -195,7 +193,7 @@ export function readArchivedPlan(filename: string, customPath?: string | null):
* Not affected by the customPath setting (that only affects decision saves).
*/
export function getHistoryDir(project: string, slug: string): string {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
mkdirSync(historyDir, { recursive: true });
return historyDir;
}
@@ -294,7 +292,7 @@ export function getPlanVersion(
slug: string,
version: number
): string | null {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
const fileName = `${String(version).padStart(3, "0")}.md`;
const filePath = join(historyDir, fileName);
@@ -314,7 +312,7 @@ export function getPlanVersionPath(
slug: string,
version: number
): string | null {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
const fileName = `${String(version).padStart(3, "0")}.md`;
const filePath = join(historyDir, fileName);
return existsSync(filePath) ? filePath : null;
@@ -325,7 +323,7 @@ export function getPlanVersionPath(
* Returns 0 if the directory doesn't exist.
*/
export function getVersionCount(project: string, slug: string): number {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
try {
const entries = readdirSync(historyDir);
return entries.filter((e) => /^\d+\.md$/.test(e)).length;
@@ -342,7 +340,7 @@ export function listVersions(
project: string,
slug: string
): Array<{ version: number; timestamp: string }> {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
try {
const entries = readdirSync(historyDir);
const versions: Array<{ version: number; timestamp: string }> = [];
@@ -372,7 +370,7 @@ export function listVersions(
export function listProjectPlans(
project: string
): Array<{ slug: string; versions: number; lastModified: string }> {
- const projectDir = join(DATA_DIR, "history", project);
+ const projectDir = join(getPlannotatorDataDir(), "history", project);
try {
const entries = readdirSync(projectDir, { withFileTypes: true });
const plans: Array<{ slug: string; versions: number; lastModified: string }> = [];
diff --git a/tests/setup/feedback-archive-off.ts b/tests/setup/feedback-archive-off.ts
index 651387fa9..a8cbe674e 100644
--- a/tests/setup/feedback-archive-off.ts
+++ b/tests/setup/feedback-archive-off.ts
@@ -1,22 +1,24 @@
+import { afterAll } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
/**
- * Test-suite default: the durable feedback archive is OFF.
- *
- * The archive is default-on in production and writes to the resolved data
- * directory at decision-settlement time. Most server tests boot a real plan,
- * review, or annotate server WITHOUT redirecting PLANNOTATOR_DATA_DIR (plan
- * and annotate history already go to the real dir because storage.ts captures
- * its data directory at import time), so leaving the archive on would have
- * every one of those tests deposit records in the contributor's own
- * ~/.plannotator/feedback — on CI and on every machine that runs `bun test`.
- * The repo's testing rules forbid touching the real user data dir, so the
- * suite opts out globally here.
- *
- * Set unconditionally rather than only when unset: a stray
- * PLANNOTATOR_FEEDBACK_HISTORY=1 in a contributor's shell must not silently
- * turn the whole suite back into a writer.
- *
- * The archive's own tests opt back in by setting the variable inside their
- * test bodies (restored in afterEach), which is also how they exercise the
- * opt-out path.
+ * Sandbox every test-run store before production modules capture their paths.
+ * Override even a contributor's configured data directory; tests that need a
+ * different directory can set and restore the env var inside their bodies.
*/
+const testDataDir = mkdtempSync(join(tmpdir(), "plannotator-test-"));
+process.env.PLANNOTATOR_DATA_DIR = testDataDir;
+
+// A preload's global afterAll runs after file hooks and their awaited subprocesses.
+// Use the runner lifecycle: bun test does not reliably emit process "exit".
+// Only this process owns this path: never clean up the current env value, which
+// a test may have overridden, or a directory inherited from a parent test run.
+afterAll(() => {
+ rmSync(testDataDir, { recursive: true, force: true });
+});
+
+// Keep the archive off by default, even when enabled in the contributor's shell.
+// Archive tests opt back in inside their bodies and restore it in afterEach.
process.env.PLANNOTATOR_FEEDBACK_HISTORY = "0";