diff --git a/tests/audit.test.ts b/tests/audit.test.ts index cb69731..e7b5bb8 100644 --- a/tests/audit.test.ts +++ b/tests/audit.test.ts @@ -8,6 +8,7 @@ * clear, tail (with corruption defense), count, + the concurrency guarantee * (parallel appends don't interleave or corrupt — T68). */ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, statSync, chmodSync } from "node:fs"; diff --git a/tests/batch.test.ts b/tests/batch.test.ts index c07cd50..3bd3ec7 100644 --- a/tests/batch.test.ts +++ b/tests/batch.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mapWithConcurrency } from "../lib/batch.ts"; diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 9be95f6..6c67177 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, utimesSync, writeFileSync, existsSync } from "node:fs"; diff --git a/tests/capability.test.ts b/tests/capability.test.ts index 81176dc..5f765fb 100644 --- a/tests/capability.test.ts +++ b/tests/capability.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import type { Api, Model } from "@earendil-works/pi-ai"; diff --git a/tests/config.test.ts b/tests/config.test.ts index bf1f974..e16584c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; diff --git a/tests/defaults.test.ts b/tests/defaults.test.ts index 75a7e63..2e298b3 100644 --- a/tests/defaults.test.ts +++ b/tests/defaults.test.ts @@ -8,6 +8,7 @@ * (sorted by `(provider, id)` so the registry's iteration order doesn't * matter). */ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import type { Api, Model } from "@earendil-works/pi-ai"; diff --git a/tests/delegate.test.ts b/tests/delegate.test.ts index 415f25d..e1ad63f 100644 --- a/tests/delegate.test.ts +++ b/tests/delegate.test.ts @@ -1,10 +1,12 @@ +import { isInsideRealPiDir, REAL_PI_DIR } from "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { Api, Model } from "@earendil-works/pi-ai"; -import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { getAgentDir, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { callVisionModel, delegateToVisionModel, @@ -620,8 +622,8 @@ test("delegateToVisionModel: abort → code 'aborted', 0 calls, no fallback", as }); // ── v0.5.0 (SPEC-5) tests: audit log + local-only mode ─────────────────── -import { readFileSync, existsSync, mkdirSync } from "node:fs"; -import { countAuditLog, tailAuditLog, clearAuditLog } from "../lib/audit.ts"; +import { readFileSync, existsSync } from "node:fs"; +import { countAuditLog, clearAuditLog } from "../lib/audit.ts"; /** Helper: a temp dir + an image file + a configured ctx + an audit log path. */ function setupAuditTest() { @@ -881,3 +883,41 @@ test("T47 regression: single-image success with audit on → v0.4.0 behavior pre }); + +// ── Guard: the agent-dir redirect from tests/setup.ts must stay in place ─── +// Tests that leave auditLogPath unset resolve to getAgentDir(). Without the +// redirect, every one of them appends a line to the developer's real +// ~/.pi/agent/vision-audit.log — silently mixing fixture entries (fake +// providers, images that never existed) into the log that answers "where did +// my image bytes actually go?". +test("agent dir is redirected away from the real ~/.pi/agent", () => { + const agentDir = getAgentDir(); + assert.ok( + process.env.PI_CODING_AGENT_DIR, + `agent dir must follow the redirect — is ./setup.ts still the first import?`, + ); + assert.ok( + !isInsideRealPiDir(agentDir), + `agent dir must not be inside ${REAL_PI_DIR}, got ${agentDir}`, + ); +}); + +// The redirect only holds for a file that installs it, and `tsx --test ` +// is the normal single-file loop — so a new test file that forgets the import +// silently writes to the real log. Cheaper to catch here than in the log. +test("every test file installs the agent-dir redirect first", () => { + const testsDir = dirname(fileURLToPath(import.meta.url)); + const files = readdirSync(testsDir).filter((f) => f.endsWith(".test.ts")); + assert.ok(files.length > 1, "expected to find the test files"); + for (const file of files) { + const source = readFileSync(join(testsDir, file), "utf8"); + const firstImport = source + .split("\n") + .find((line) => /^import[\s{]/.test(line)); + assert.match( + firstImport ?? "", + /^import (?:.+ from )?"\.\/setup\.ts";$/, + `${file} must import "./setup.ts" before anything else — otherwise it writes to the real ~/.pi/agent`, + ); + } +}); diff --git a/tests/image.test.ts b/tests/image.test.ts index bfad727..896185d 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 60ff7ed..c2d30d4 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -12,6 +12,7 @@ * reason about the attached image / choose to call describe_image) still * needs a manual fresh-session check — see the session handoff. */ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -25,18 +26,17 @@ import type { ExtensionContext, ToolDefinition, } from "@earendil-works/pi-coding-agent"; - -// Redirect getAgentDir() to a temp dir so /vision commands never touch the -// real ~/.pi/agent/vision.json during this test run. -const TMP_AGENT = mkdtempSync(join(tmpdir(), "vision-eval-agent-")); -process.env.PI_CODING_AGENT_DIR = TMP_AGENT; - import visionFactory from "../extensions/vision.ts"; import pasteFactory from "../extensions/paste.ts"; import { loadConfig, configFilePath } from "../lib/config.ts"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { countAuditLog, tailAuditLog } from "../lib/audit.ts"; +// The redirect away from the real ~/.pi/agent is installed by ./setup.ts, which +// also owns the temp dir's lifetime; this is just the dir the /vision commands +// under test read and write. +const TMP_AGENT = getAgentDir(); + const PNG_1x1_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M8AAAMBEg1+mP0AAAAASUVORK5CYII="; const PNG_BYTES = Buffer.from(PNG_1x1_B64, "base64"); @@ -1982,8 +1982,3 @@ test("T70: v0.5.0 regression gate — config fields + subcommands + auto-detect // Full suite green = T70 passed (if this test runs, the suite compiled + loaded). assert.ok(true, "v0.5.0 surface wired + regression gate passed"); }); - -// Cleanup the temp agent dir after all tests. -test("cleanup", () => { - rmSync(TMP_AGENT, { recursive: true, force: true }); -}); \ No newline at end of file diff --git a/tests/marker.test.ts b/tests/marker.test.ts index 5dbdb62..166137b 100644 --- a/tests/marker.test.ts +++ b/tests/marker.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { diff --git a/tests/normalize.test.ts b/tests/normalize.test.ts index 09168f6..7d36393 100644 --- a/tests/normalize.test.ts +++ b/tests/normalize.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { normalizeImagePaths } from "../extensions/vision.ts"; diff --git a/tests/paste.test.ts b/tests/paste.test.ts index 89e0096..39d530d 100644 --- a/tests/paste.test.ts +++ b/tests/paste.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { findImagePathTokens } from "../extensions/paste.ts"; diff --git a/tests/preview.test.ts b/tests/preview.test.ts index c28cc3d..db9e7d9 100644 --- a/tests/preview.test.ts +++ b/tests/preview.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { diff --git a/tests/resilience.test.ts b/tests/resilience.test.ts index 8239a41..2743b4e 100644 --- a/tests/resilience.test.ts +++ b/tests/resilience.test.ts @@ -1,3 +1,4 @@ +import "./setup.ts"; import { test } from "node:test"; import assert from "node:assert/strict"; import { AbortError, classifyError, sleep, withRetry, type ErrorClass } from "../lib/resilience.ts"; diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..680d20c --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,71 @@ +// Agent-dir redirect for the test suite — imported as the first statement of +// every tests/*.test.ts file, so it takes effect however the suite is started: +// `pnpm test`, a bare `tsx --test`, or a single file. (A meta-test in +// delegate.test.ts enforces that every test file still does so.) +// +// Why: config, cache and the audit log all resolve through getAgentDir(), which +// reads PI_CODING_AGENT_DIR at call time and otherwise falls back to the real +// ~/.pi/agent. Any test that delegates with the default config (auditLog is on) +// would append fixture rows — fake providers, images that never existed — to the +// developer's production vision-audit.log. +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; + +/** The real pi config root — nothing the suite writes may land inside it. */ +export const REAL_PI_DIR = join(homedir(), ".pi"); + +/** Mirror of the tilde handling getAgentDir() applies to the env var, so the + * value we hand on is the one getAgentDir() will return. */ +function expand(dir: string): string { + if (dir === "~") return homedir(); + if (dir.startsWith("~/")) return join(homedir(), dir.slice(2)); + return resolve(dir); +} + +/** True if `dir` is ~/.pi itself or anything below it. Separator-aware: a + * sibling such as ~/.pi-sandbox is a legitimate test dir, not the real one. */ +export function isInsideRealPiDir(dir: string): boolean { + const abs = expand(dir); + return abs === REAL_PI_DIR || abs.startsWith(REAL_PI_DIR + sep); +} + +// Respect a dir supplied from outside (CI, a wrapper script) — but never one +// that points back into ~/.pi, which is exactly the pollution this file exists +// to prevent. Only the dir we created ourselves is ours to delete. +let ownDir: string | undefined; +const inherited = process.env.PI_CODING_AGENT_DIR; + +if (inherited && !isInsideRealPiDir(inherited)) { + process.env.PI_CODING_AGENT_DIR = expand(inherited); + mkdirSync(process.env.PI_CODING_AGENT_DIR, { recursive: true }); +} else { + if (inherited) { + console.warn( + `[tests/setup] ignoring PI_CODING_AGENT_DIR=${inherited} — it resolves inside ${REAL_PI_DIR}; using a temp dir instead`, + ); + } + ownDir = mkdtempSync(join(tmpdir(), "vision-test-agent-")); + process.env.PI_CODING_AGENT_DIR = ownDir; +} + +function cleanup(): void { + if (ownDir) rmSync(ownDir, { recursive: true, force: true }); +} + +process.on("exit", cleanup); + +// `exit` never fires when we are terminated by a signal — a Ctrl-C on the suite +// reaches every per-file child process and would otherwise leak one temp dir +// each. rmSync({ force: true }) is idempotent, so the extra `exit` pass that +// process.exit() triggers is harmless. +for (const [signal, code] of [ + ["SIGINT", 130], + ["SIGTERM", 143], + ["SIGHUP", 129], +] as const) { + process.on(signal, () => { + cleanup(); + process.exit(code); + }); +}