← back to Kickbacks Ai Upstream
test/localVsixWatcher.test.ts
206 lines
// Local-VSIX update path: when ~/.vibe-ads/config.json sets localVsixPath,
// the extension mtime-watches the file and installs it on change via the
// SAME installer the manifest path uses. Hermetic: a temp HOME with a
// config.json containing localVsixPath, a watchFileFn spy that captures the
// listener, then synthetic stat events drive the install pipe.
import { describe, it, expect, vi, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync }
from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Under cold-start worker-pool load (npx vitest run after the .vitest cache
// was wiped) the `writeFileSync(config.json) → vi.resetModules() → await
// import("../src/extension")` chain hit a rare Windows-FS visibility race:
// the freshly-written config.json wasn't readable yet when the imported
// module's top-level `const CFG = readConfig()` fired, so CFG.localVsixPath
// was empty, no watchFile was registered, and `listeners.get(vsixPath)`
// returned undefined → TypeError on the listener invocation in test 2.
// This helper read-backs until the file is visible (cheap; capped at ~1 s)
// before we hand control to the import chain. Reproduced 1× across ~5
// runs, never in isolation; the read-back closes the window cleanly.
function ensureReadable(path: string, timeoutMs = 1000): void {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
try { readFileSync(path); return; }
catch { /* not yet — try again next loop tick */ }
}
// Final attempt that THROWS if still unreadable, so the test fails
// loudly with a precise message instead of TypeErroring downstream.
readFileSync(path);
}
// NOTE: this file intentionally does NOT static-import the vscode mock —
// every test calls vi.resetModules() before importing the extension, which
// re-instantiates the aliased mock. Reading assertions through a stale
// static import would target the wrong instance. Each test re-imports
// "./mocks/vscode" inside the resetModules window via `vsc = await import(...)`.
type StatListener = (curr: { mtimeMs: number }) => void;
function stubFetch() {
const calls: string[] = [];
const f = vi.fn(async (input: unknown) => {
const url = typeof input === "string" ? input : String(input);
calls.push(url);
return { ok: true, status: 200, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", f);
return { f, calls };
}
const mkAdapter = () => ({
name: "claude-code" as const,
preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
version: () => "2.1.143",
applyPatch: vi.fn(() => ({ ok: true })),
restore: vi.fn(() => ({ ok: true, restored: true })),
});
afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
describe("local VSIX watcher", () => {
it("installs the file and restarts the ext host when its mtime advances"
+ " (the local dev rig path — no manifest server required)", async () => {
// Stage HOME so readConfig() picks up localVsixPath at module load.
const home = mkdtempSync(join(tmpdir(), "kb-lvw-"));
const prevHome = process.env.HOME;
const prevUser = process.env.USERPROFILE;
process.env.HOME = home;
process.env.USERPROFILE = home;
mkdirSync(join(home, ".vibe-ads"), { recursive: true });
const vsixPath = join(home, "kickbacks.vsix");
writeFileSync(vsixPath, Buffer.from("FAKE-VSIX-BYTES-v1"));
const configFile = join(home, ".vibe-ads", "config.json");
writeFileSync(configFile,
JSON.stringify({ localVsixPath: vsixPath }), "utf8");
ensureReadable(configFile); // close the cold-start FS race
// Capture the listener handed to watchFile(localVsixPath, …).
const listeners = new Map<string, StatListener>();
const watchFileFn = ((p: unknown, _o: unknown, l: StatListener) => {
listeners.set(String(p), l);
}) as unknown as typeof import("node:fs").watchFile;
// Re-import the extension after staging HOME so CFG is read fresh.
// CRITICAL: vi.resetModules() also re-instantiates the aliased `vscode`
// mock for the freshly imported extension. The vscode `commands` array
// captured at this file's static `import` line is now a STALE instance —
// the new extension writes into a different Map. So we re-import the
// mock through the same resetModules window and read assertions from
// *that* instance. (test/setup.ts's vi.mock for ../src/log survives.)
vi.resetModules();
const { activate, deactivate, __wireForTest } =
await import("../src/extension");
const vsc = await import("./mocks/vscode");
const adapter = mkAdapter();
const statusBar = { set: vi.fn(), dispose: vi.fn() };
__wireForTest({ adapter, statusBar, watchFileFn });
stubFetch();
const ctx = vsc.makeContext();
await ctx.secrets.store("kickbacks.access", "AT");
try {
await activate(ctx as never);
// The listener for the staged vsix path must be installed.
expect(listeners.has(vsixPath)).toBe(true);
// Pre-fire baseline: no install attempted yet.
expect(vsc.commands._executed.some(
(c) => c.id === "workbench.extensions.installExtension")).toBe(false);
// Bump the file bytes + invoke the listener with a fresh mtime.
writeFileSync(vsixPath, Buffer.from("FAKE-VSIX-BYTES-v2"));
const fire = listeners.get(vsixPath)!;
fire({ mtimeMs: Date.now() + 5000 });
// The listener uses void installVsix(...); installVsix itself is async
// (writes a temp file, awaits the install command, awaits the restart).
// Yield ticks so its awaits land before we assert.
await new Promise((r) => setTimeout(r, 50));
const installCall = vsc.commands._executed.find(
(c) => c.id === "workbench.extensions.installExtension");
expect(installCall).toBeDefined();
const arg = installCall!.args[0] as { toString(): string; fsPath?: string };
const fsPath = arg.fsPath || arg.toString();
expect(fsPath).toMatch(/\.vsix$/);
// After install we no longer auto-restart the ext host — we
// prompt for a full window reload via showInformationMessage
// (empirically the ext-host restart wasn't enough to invalidate
// the cached webview module of the patched CC page; only a
// window reload reliably picks up the new build). Assert the
// toast fired, not a silent restart. The mock collects all
// showInformationMessage calls into `_shown` (see mocks/vscode.ts).
// The toast is fired via a void IIFE so we wait one more tick
// for its await chain to land before checking.
await new Promise((r) => setTimeout(r, 50));
expect((vsc._shown as { kind: string; text: string }[])
.some((t) => /Kickbacks updated/i.test(t.text))).toBe(true);
expect(vsc.commands._executed.some(
(c) => c.id === "workbench.action.restartExtensionHost")).toBe(false);
} finally {
await deactivate();
if (prevHome !== undefined) process.env.HOME = prevHome;
else delete process.env.HOME;
if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
else delete process.env.USERPROFILE;
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
}
});
it("a stale mtime is ignored (no install storm on activation)", async () => {
const home = mkdtempSync(join(tmpdir(), "kb-lvw2-"));
const prevHome = process.env.HOME;
const prevUser = process.env.USERPROFILE;
process.env.HOME = home;
process.env.USERPROFILE = home;
mkdirSync(join(home, ".vibe-ads"), { recursive: true });
const vsixPath = join(home, "kickbacks.vsix");
writeFileSync(vsixPath, Buffer.from("v1"));
const configFile2 = join(home, ".vibe-ads", "config.json");
writeFileSync(configFile2,
JSON.stringify({ localVsixPath: vsixPath }), "utf8");
ensureReadable(configFile2);
const listeners = new Map<string, StatListener>();
const watchFileFn = ((p: unknown, _o: unknown, l: StatListener) => {
listeners.set(String(p), l);
}) as unknown as typeof import("node:fs").watchFile;
vi.resetModules();
const { activate, deactivate, __wireForTest } =
await import("../src/extension");
const vsc = await import("./mocks/vscode");
const adapter = mkAdapter();
const statusBar = { set: vi.fn(), dispose: vi.fn() };
__wireForTest({ adapter, statusBar, watchFileFn });
stubFetch();
const ctx = vsc.makeContext();
try {
await activate(ctx as never);
// Surface the cold-start FS race here too: if config.json wasn't
// visible at module-load, CFG.localVsixPath was empty and watchFile
// was never registered → listeners.get(vsixPath) returns undefined
// and invoking it crashes with a confusing TypeError. Assert
// presence FIRST so the failure message points at the actual cause.
expect(listeners.has(vsixPath)).toBe(true);
const fire = listeners.get(vsixPath)!;
// mtime === 0 → must NOT trigger an install (the !curr.mtimeMs guard).
fire({ mtimeMs: 0 });
await new Promise((r) => setTimeout(r, 50));
expect(vsc.commands._executed.some(
(c) => c.id === "workbench.extensions.installExtension")).toBe(false);
} finally {
await deactivate();
if (prevHome !== undefined) process.env.HOME = prevHome;
else delete process.env.HOME;
if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
else delete process.env.USERPROFILE;
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
}
});
});