← back to Kickbacks Ai Upstream

test/extension-lifecycle.test.ts

215 lines

// Lifecycle regressions for extension.ts orchestration (audit 2026-06-09).
//
// #35 — the config-file watcher must NOT restart the extension host on file
//        CREATION (ensureConfigFile materializing the template on the first
//        "Edit Vibe-Ads config…" click) nor on a no-op save/touch; only a
//        genuine CONTENT edit may restart.
// #36 — deactivate() must perform the irreversible user-file restores BEFORE
//        stopping the loopbacks, and each stop must be time-bounded so a hung
//        http.Server.close can never exhaust VS Code's deactivation budget
//        and strand CC/Codex/settings.json patched after uninstall.

import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir, tmpdir } from "node:os";

// Hermetic home (wave-3 review): activate()'s incompatible-CC path now runs a
// REAL key-scoped CLI restore against ~/.claude/settings.json (audit #22), so
// these activation tests must never see the developer's live install. Also
// shields the boot-canary read from suites that write the real
// ~/.vibe-ads/boot.canary in parallel workers.
const restoreEnv = (k: string, v: string | undefined): void => {
  if (v === undefined) delete process.env[k]; else process.env[k] = v;
};
const REAL_HOME = process.env.HOME;
const REAL_USERPROFILE = process.env.USERPROFILE;
let tmpHome = "";
beforeAll(() => {
  tmpHome = mkdtempSync(join(tmpdir(), "kb-lifecycle-home-"));
  process.env.HOME = tmpHome;
  process.env.USERPROFILE = tmpHome;
});
afterAll(() => {
  restoreEnv("HOME", REAL_HOME);
  restoreEnv("USERPROFILE", REAL_USERPROFILE);
  try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ok */ }
});

// Mute dlog (same pattern as extension.test.ts) so test-driven activate()
// calls don't append to the developer's real ~/.vibe-ads/debug.log.
vi.mock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
  dlogRaw: () => {}, codexEnabled: () => false, codexDisabled: () => false,
  codexCliEnabled: () => false,
  testHooksEnabled: () => false, debugIconDataUri: () => "",
  LOG_PATH: "/tmp/test-log" }));

// Redirect the watched config file to a per-run temp path so the watcher
// tests can create/touch/edit it without touching the real ~/.vibe-ads.
vi.mock("../src/config", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../src/config")>();
  const { join: j } = await import("node:path");
  const { tmpdir } = await import("node:os");
  const p = j(tmpdir(), `kb-lifecycle-${process.pid}`, "config.json");
  return { ...actual, configPath: () => p };
});

// Controllable in-memory Loopback: stop() resolves instantly until a test
// flips `hangStops`, then hangs forever — simulating http.Server.close()
// waiting on an in-flight webview request (the #36 hazard). `order` records
// the cross-component sequencing deactivate() must respect.
const lb = vi.hoisted(() => ({ hangStops: false, order: [] as string[] }));
vi.mock("../src/loopback", () => {
  class Loopback {
    constructor(_handlers: unknown) {}
    start(_opts?: unknown): Promise<{ port: number; token: string }> {
      return Promise.resolve({ port: 43217, token: "deadbeefdeadbeef" });
    }
    stop(): Promise<void> {
      lb.order.push("loopback.stop");
      return lb.hangStops
        ? new Promise<void>(() => { /* hung close — never resolves */ })
        : Promise.resolve();
    }
  }
  return {
    Loopback,
    resolveLoopbackBase: async (port: number, token: string) =>
      `http://127.0.0.1:${port}/vibe-ads/${token}`,
  };
});

import { activate, deactivate, __wireForTest } from "../src/extension";
import { makeContext, commands } from "./mocks/vscode";
import { configPath } from "../src/config";

const RESTART = "workbench.action.restartExtensionHost";
const restartCount = (): number =>
  commands._executed.filter((e) => e.id === RESTART).length;

type WatchCb = (curr: { mtimeMs: number }) => void;

// Incompatible preflight → activate() early-returns right after the watchers
// are armed, keeping these tests off the network/auth/portfolio paths.
const incompatAdapter = () => ({
  name: "claude-code",
  preflight: () => ({ ok: true, compatible: false, version: "9.9.9", reason: "x" }),
  version: () => "9.9.9",
  applyPatch: vi.fn(() => ({ ok: true })),
  restore: vi.fn(() => ({ ok: true, restored: false })),
});

describe("config-file watcher (audit #35)", { timeout: 15_000 }, () => {
  it("creation + no-op touch never restart the host; a genuine edit does", async () => {
    const cfgPath = configPath();
    rmSync(cfgPath, { force: true });           // start absent, like a fresh install
    const watchers = new Map<string, WatchCb>();
    __wireForTest({
      adapter: incompatAdapter() as never,
      statusBar: { set: vi.fn(), dispose() {} },
      watchFileFn: ((p: unknown, _o: unknown, cb: WatchCb) => {
        watchers.set(String(p), cb);
      }) as never,
    });
    await activate(makeContext() as never);
    const cb = watchers.get(cfgPath);
    expect(cb, "config watcher must be armed").toBeTruthy();
    const before = restartCount();

    // 1. CREATION (mtime 0 → T): pre-fix this restarted the ENTIRE extension
    //    host within 2s of the user's first "Edit Vibe-Ads config…" click,
    //    before they typed a single character.
    mkdirSync(dirname(cfgPath), { recursive: true });
    writeFileSync(cfgPath, '{\n  "debugMode": false\n}\n', "utf8");
    cb!({ mtimeMs: 1_000 });
    expect(restartCount(), "creation must not restart").toBe(before);

    // 2. No-op save / touch: mtime changes, content identical.
    cb!({ mtimeMs: 2_000 });
    expect(restartCount(), "no-op touch must not restart").toBe(before);

    // 3. Genuine content edit: the watcher's purpose — apply config edits.
    writeFileSync(cfgPath, '{\n  "debugMode": true\n}\n', "utf8");
    cb!({ mtimeMs: 3_000 });
    expect(restartCount(), "a real edit must restart").toBe(before + 1);

    await deactivate();
    rmSync(cfgPath, { force: true });
  });

  it("pre-existing config: touches don't restart, edits still do", async () => {
    const cfgPath = configPath();
    mkdirSync(dirname(cfgPath), { recursive: true });
    writeFileSync(cfgPath, '{\n  "updatePollIntervalMs": 90000\n}\n', "utf8");
    const watchers = new Map<string, WatchCb>();
    __wireForTest({
      adapter: incompatAdapter() as never,
      statusBar: { set: vi.fn(), dispose() {} },
      watchFileFn: ((p: unknown, _o: unknown, cb: WatchCb) => {
        watchers.set(String(p), cb);
      }) as never,
    });
    await activate(makeContext() as never);
    const cb = watchers.get(cfgPath)!;
    const before = restartCount();
    cb({ mtimeMs: 1_000 });                     // touch — same content
    expect(restartCount(), "touch must not restart").toBe(before);
    writeFileSync(cfgPath, '{\n  "updatePollIntervalMs": 60000\n}\n', "utf8");
    cb({ mtimeMs: 2_000 });                     // real edit
    expect(restartCount(), "edit must restart").toBe(before + 1);
    await deactivate();
    rmSync(cfgPath, { force: true });
  });
});

describe("deactivate ordering + stop budget (audit #36)", { timeout: 20_000 }, () => {
  it("restores user files BEFORE loopback stops and completes even when a close hangs", async () => {
    const adapter = {
      name: "claude-code",
      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
      version: () => "2.1.143",
      isPatched: () => true,
      applyPatch: vi.fn(() => ({ ok: true })),
      restore: vi.fn(() => {
        lb.order.push("cc.restore");
        return { ok: true, restored: true };
      }),
    };
    __wireForTest({ adapter: adapter as never,
      statusBar: { set: vi.fn(), dispose() {} }, killed: true });
    const ctx = makeContext();
    // Guarantee the clean-boot canary branch so the debug auto-enable path
    // mints a (mocked) loopback — dispose() at deactivate then has a server
    // to stop. A recent canary from a parallel test worker would skip it.
    // NOTE bootCanary captures its canary path at MODULE LOAD (before this
    // suite's temp-home swap), so the canary the earlier activations wrote
    // lives under the REAL home — clear both locations.
    const realHome = REAL_USERPROFILE || REAL_HOME || homedir();
    rmSync(join(realHome, ".vibe-ads", "boot.canary"), { force: true });
    rmSync(join(homedir(), ".vibe-ads", "boot.canary"), { force: true });
    await activate(ctx as never);
    // Simulate the user disabling via the menu BEFORE shutdown so the
    // uninstall-hygiene restore branch runs (same as extension.test.ts).
    await ctx.globalState.update("kickbacks.debug.on", false);
    adapter.restore.mockClear();
    lb.order.length = 0;
    lb.hangStops = true;          // every server close now hangs forever
    const t0 = Date.now();
    // Pre-fix: deactivate awaited the hung stop FIRST and never resolved —
    // no restore ever ran (this test then fails by timeout).
    await deactivate();
    const elapsed = Date.now() - t0;
    lb.hangStops = false;
    expect(adapter.restore, "CC must be restored despite the hung close")
      .toHaveBeenCalledWith({ keepCsp: true });
    const restoreIdx = lb.order.indexOf("cc.restore");
    const stopIdx = lb.order.indexOf("loopback.stop");
    expect(restoreIdx, "CC restore must run").toBeGreaterThanOrEqual(0);
    expect(stopIdx, "loopback stop must still be attempted")
      .toBeGreaterThanOrEqual(0);
    expect(restoreIdx, "files restored BEFORE loopback stop")
      .toBeLessThan(stopIdx);
    expect(elapsed, "deactivate bounded by the stop budget").toBeLessThan(8_000);
  });
});