Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
51 changes: 21 additions & 30 deletions packages/server/ai-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -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>";
Expand Down Expand Up @@ -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 });
}
}

Expand Down
12 changes: 3 additions & 9 deletions packages/server/call-flow-install-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions packages/server/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
213 changes: 212 additions & 1 deletion packages/shared/data-dir.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
3 changes: 2 additions & 1 deletion packages/shared/improvement-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/prompts-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading