mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
fix(test): isolate test-run data from contributor history (#1473)
Closes #1455. Resolves the data directory per call in shared storage and sandboxes PLANNOTATOR_DATA_DIR for every bun test via the preload, so test runs can no longer write into a contributor's real ~/.plannotator. Claude-Session: https://claude.ai/code/session_019GV6EKtzh8Pf9GA2rrBLNf
This commit is contained in:
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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 = "<!doctype html><html><body>test</body></html>";
|
||||
@@ -169,42 +166,36 @@ async function verifyDisabledServers(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runInIsolatedDataDirectory(): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34,7 +34,8 @@ function cleanTestHome() {
|
||||
|
||||
async function runScript(script: string): Promise<string> {
|
||||
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",
|
||||
|
||||
@@ -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 }> = [];
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user