import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; import { writeWindowsExecutable } from "../../../test/fixture/windows-executable.ts"; import type { BranchChangeBridge } from "./branch-change-emitter.ts"; const BRIDGE_KEY = "__vcsStatusBranchChangeBridge"; let session: PiSession | undefined; let root: string | undefined; afterEach(async () => { (globalThis as Record)[BRIDGE_KEY] = undefined; await session?.dispose(); session = undefined; if (root) rmSync(root, { recursive: true, force: true }); root = undefined; }); function vcsPath(): string { return fileURLToPath(new URL("../index.ts", import.meta.url)); } function emitterPath(): string { return fileURLToPath(new URL("./branch-change-emitter.ts", import.meta.url)); } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe("vcs-status contract", () => { it("loads through the owned fixture", async () => { const extensionPath = vcsPath(); session = await createPiSession({ extensionPaths: [extensionPath] }); expect(session.errors).toEqual([]); expect(session.extensions).toHaveLength(1); expect(session.extensions[0]!.path).toBe(vcsPath()); }); it("writes debug lifecycle records in its sandbox when enabled", async () => { session = await createPiSession({ extensionPaths: [vcsPath()], env: { PI_VCS_STATUS_DEBUG: "1" }, }); const extension = session.extensions[0]!; const context = session.session.extensionRunner.createCommandContext(); for (const handler of extension.handlers.get("session_start") ?? []) await handler({ type: "session_start" }, context); for (const handler of extension.handlers.get("session_shutdown") ?? []) await handler({ type: "session_shutdown" }, context); const directory = path.join( session.cwd, ".test-state", "pi-ext", "debug", "vcs-status", ); const records = readFileSync( path.join(directory, readdirSync(directory)[0]!), "utf8", ) .trim() .split("\n") .map((line) => JSON.parse(line)); expect(records.map((record) => record.event)).toEqual( expect.arrayContaining(["session.start", "session.shutdown"]), ); }); it("refreshes on footer:branch-change with the latest context", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-vcs-status-branch-")); mkdirSync(path.join(root, ".git")); const headPath = path.join(root, ".git/HEAD"); writeFileSync(headPath, "ref: refs/heads/main\n"); const bin = path.join(root, "bin"); mkdirSync(bin); if (process.platform === "win32") writeWindowsExecutable(bin, "git", "# branch.head main\n"); else { writeFileSync( path.join(bin, "git"), "#!/bin/sh\nprintf '# branch.head main\\n'\n", ); chmodSync(path.join(bin, "git"), 0o755); } const bridge: BranchChangeBridge = {}; (globalThis as Record)[BRIDGE_KEY] = bridge; session = await createPiSession({ cwd: root, extensionPaths: [vcsPath(), emitterPath()], env: { PATH: `${bin}${path.delimiter}${process.env["PATH"]}` }, }); expect(session.errors).toEqual([]); expect(bridge.emit).toBeTypeOf("function"); const vcsLabels: string[] = []; const runner = session.session.extensionRunner; runner.setUIContext( { setStatus: (key: string, text?: string) => { if (key === "vcs" && text) vcsLabels.push(text); }, notify: () => {}, } as never, "print", ); // Before session start there is no context to refresh with. bridge.emit?.({ branch: "main" }); await sleep(50); expect(vcsLabels).toEqual([]); await runner.emit({ type: "session_start", reason: "startup" }); await vi.waitFor(() => expect(vcsLabels.at(-1)).toContain("main"), { timeout: 5000, }); writeFileSync(headPath, "ref: refs/heads/feature\n"); const before = vcsLabels.length; bridge.emit?.({ branch: "feature" }); await vi.waitFor( () => { expect(vcsLabels.length).toBeGreaterThan(before); expect(vcsLabels.at(-1)).toContain("feature"); }, { timeout: 5000 }, ); // After shutdown the event is ignored. await runner.emit({ type: "session_shutdown", reason: "quit" }); const afterShutdown = vcsLabels.length; bridge.emit?.({ branch: "main" }); await sleep(50); expect(vcsLabels.length).toBe(afterShutdown); }); });