import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; const extensionPath = fileURLToPath(new URL("../index.ts", import.meta.url)); // The fixture inherits the full ambient env; clear every bak variable so each // case starts from a clean per-test slate. const CLEAR_BAK_ENV: Record = { PI_BAK_S3_ENDPOINT: undefined, PI_BAK_S3_BUCKET: undefined, PI_BAK_S3_REGION: undefined, PI_BAK_S3_ACCESS_KEY_ID: undefined, PI_BAK_S3_SECRET_ACCESS_KEY: undefined, PI_BAK_AUTO_BACKUP: undefined, PI_BAK_DEBUG: undefined, }; const CREDENTIALS = { PI_BAK_S3_ACCESS_KEY_ID: "id", PI_BAK_S3_SECRET_ACCESS_KEY: "secret", }; const INIT_WARNING = "warning: bak: run /bak init "; const settingWarnings = (notes: string[]) => notes.filter((note) => note.startsWith("warning") && note !== INIT_WARNING); type Case = { user?: unknown; project?: unknown; untrusted?: boolean; env?: Record; }; // Records notify calls through a UI context installed on the runner, capturing // the extension's notify calls. The extension only touches // `notify`, `setStatus`, and `theme.fg`; the rest are inert stubs. function recordingUI() { const calls: Array<{ message: string; type?: string | undefined }> = []; const ui = { notify: (message: string, type?: "info" | "warning" | "error") => { calls.push({ message, type }); }, setStatus: () => undefined, theme: { fg: (_level: string, text: string) => text }, } as unknown as ExtensionUIContext; const notes = () => [ ...new Set(calls.map((call) => `${call.type}: ${call.message}`)), ]; return { ui, notes }; } describe("bak settings", () => { let session: PiSession | undefined; let root: string | undefined; afterEach(async () => { await session?.dispose(); session = undefined; if (root) rmSync(root, { recursive: true, force: true }); root = undefined; vi.unstubAllGlobals(); }); async function start(input: Case) { root = mkdtempSync(path.join(tmpdir(), "pi-ext-bak-settings-")); if ("project" in input) { const dir = path.join(root, ".pi"); mkdirSync(dir, { recursive: true }); writeFileSync( path.join(dir, "settings.json"), JSON.stringify({ bak: input.project }), ); } const requests: Request[] = []; vi.stubGlobal( "fetch", vi.fn(async (request: Request) => { requests.push(request); return new Response(null, { status: 404 }); }), ); const { ui, notes } = recordingUI(); session = await createPiSession({ cwd: root, extensionPaths: [extensionPath], env: { ...CLEAR_BAK_ENV, ...CREDENTIALS, ...input.env }, ...("user" in input ? { userSettings: { bak: input.user } } : {}), }); // The fixture's bindExtensions already emitted the initial session_start // against a no-op UI, consuming the extension's once-only startup warnings. // Persist the recording UI through bindExtensions, set project trust, then // reload so a fresh extension instance re-runs session_start under this UI. if (input.untrusted) session.session.settingsManager.setProjectTrusted(false); session.session.bindExtensions({ uiContext: ui, mode: "print" }); await session.session.reload(); const runner = session.session.extensionRunner; const ctx = runner.createCommandContext(); const bak = runner.getCommand("bak"); if (!bak) throw new Error("missing bak command"); return { notes, run: (args: string) => bak.handler(args, ctx), settle: async () => { await runner.emit({ type: "agent_settled", aborted: false }); }, target: () => { const request = requests[0]; if (!request) return undefined; const region = /Credential=id\/\d+\/([^/]+)\//.exec( request.headers.get("authorization") ?? "", )?.[1]; return `${new URL(request.url).origin}${new URL(request.url).pathname.split("/").slice(0, 2).join("/")} ${region}`; }, }; } it("keeps missing-endpoint default", async () => { const { notes } = await start({}); expect(notes()).toEqual([ "warning: bak: disabled; missing PI_BAK_S3_ENDPOINT", ]); }); it.each([ [ "env only with defaults", { env: { PI_BAK_S3_ENDPOINT: "https://env.test" } }, "https://env.test/pi-bak auto", ], [ "user settings", { user: { s3: { endpoint: "https://user.test", bucket: "user-bucket", region: "eu", }, }, }, "https://user.test/user-bucket eu", ], [ "env over settings", { user: { s3: { endpoint: "https://user.test", bucket: "user-bucket" } }, env: { PI_BAK_S3_ENDPOINT: "https://env.test", PI_BAK_S3_BUCKET: "env-bucket", PI_BAK_S3_REGION: "us", }, }, "https://env.test/env-bucket us", ], [ "trusted project merged over user", { user: { s3: { endpoint: "https://user.test", region: "eu" } }, project: { s3: { bucket: "project-bucket" } }, }, "https://user.test/project-bucket eu", ], [ "untrusted project ignored", { user: { s3: { endpoint: "https://user.test" } }, project: { s3: { endpoint: "https://project.test" } }, untrusted: true, }, "https://user.test/pi-bak auto", ], ] satisfies [string, Case, string][])( "applies %s", async (_name, input, expected) => { const { notes, run, target } = await start(input); await run("refresh"); expect(target()).toBe(expected); expect(settingWarnings(notes())).toEqual([]); }, ); it.each([ [ "non-string user bucket", { user: { s3: { endpoint: "https://user.test", bucket: 7 } }, env: {}, }, "Invalid bak.s3.bucket from user settings: expected a non-empty string", "https://user.test/pi-bak auto", ], [ "empty env region over valid user region", { user: { s3: { endpoint: "https://user.test", region: "eu" } }, env: { PI_BAK_S3_REGION: " " }, }, "Invalid bak.s3.region from environment variable PI_BAK_S3_REGION: expected a non-empty string", "https://user.test/pi-bak auto", ], [ "empty project bucket over valid user bucket", { user: { s3: { endpoint: "https://user.test", bucket: "user-bucket" } }, project: { s3: { bucket: "" } }, }, "Invalid bak.s3.bucket from project settings: expected a non-empty string", "https://user.test/pi-bak auto", ], ] satisfies [string, Case, string, string][])( "rejects %s without falling through", async (_name, input, error, expected) => { const { notes, run, target } = await start(input); await run("refresh"); expect(target()).toBe(expected); expect(settingWarnings(notes())).toEqual([ `warning: bak: ${error}; using default`, ]); }, ); it("rejects invalid endpoint and stays disabled", async () => { const { notes } = await start({ user: { s3: { endpoint: "https://user.test" } }, env: { PI_BAK_S3_ENDPOINT: "" }, }); expect(notes()).toEqual([ "warning: bak: Invalid bak.s3.endpoint from environment variable PI_BAK_S3_ENDPOINT: expected a non-empty string; using default", "warning: bak: disabled; missing PI_BAK_S3_ENDPOINT", ]); }); const configured = { s3: { endpoint: "https://user.test" } }; it.each([ ["default on", { user: configured }, true], ["user false", { user: { ...configured, autoBackup: false } }, false], [ "env over user", { user: { ...configured, autoBackup: false }, env: { PI_BAK_AUTO_BACKUP: "1" }, }, true, ], [ "trusted project over user", { user: configured, project: { autoBackup: false } }, false, ], [ "untrusted project ignored", { user: configured, project: { autoBackup: false }, untrusted: true }, true, ], ] satisfies [string, Case, boolean][])( "autoBackup %s", async (_name, input, automatic) => { const { notes, settle } = await start(input); await settle(); await vi.waitFor( () => expect(notes()).toEqual(automatic ? [INIT_WARNING] : []), { timeout: 5_000 }, ); }, ); it.each([ [ "string user", { user: { ...configured, autoBackup: "false" } }, "Invalid bak.autoBackup from user settings", ], [ "non-boolean env", { user: { ...configured, autoBackup: false }, env: { PI_BAK_AUTO_BACKUP: "off" }, }, "Invalid bak.autoBackup from environment variable PI_BAK_AUTO_BACKUP", ], ] satisfies [string, Case, string][])( "autoBackup rejects %s and keeps default on", async (_name, input, error) => { const { notes, settle } = await start(input); await settle(); await vi.waitFor( () => expect(notes()).toEqual([ `warning: bak: ${error}; using default`, INIT_WARNING, ]), { timeout: 5_000 }, ); }, ); it("keeps explicit backup when autoBackup is off", async () => { const { notes, run, settle } = await start({ user: { ...configured, autoBackup: false }, }); await settle(); expect(notes()).toEqual([]); await run("backup"); expect(notes()).toContain("info: bak: backed up 0; 0 indexed"); }); });