← back to Kickbacks Ai Upstream

test/extension.test.ts

368 lines

import { describe, it, expect, vi } from "vitest";

// Mute dlog so test-driven activate() calls don't append to the developer's
// real ~/.vibe-ads/debug.log. Same pattern auth.test.ts and commands.test.ts
// use. Without this, every test run leaves "build dev" / "9.9.9" preflight
// noise interleaved with the user's real extension events, which was
// misdiagnosed once as an extension restart loop.
vi.mock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
  dlogRaw: () => {}, codexEnabled: () => false, codexDisabled: () => false,
  codexCliEnabled: () => false, testHooksEnabled: () => false,
  debugIconDataUri: () => "",
  LOG_PATH: "/tmp/test-log" }));

// Hermetic ~/.claude/settings.json: activate() wires the claude-cli
// statusline adapter at the developer's REAL settings file — on the normal
// path via cliSync, and (audit #22) on the incompatible early-return path.
// Mock the adapter so test-driven activations never touch that file;
// `cliRestore` records restore() calls for the #22 regression below.
const cliRestore = vi.hoisted(() =>
  vi.fn(() => ({ ok: true, restored: true })));
vi.mock("../src/adapters/claude-cli/adapter", () => ({
  resolveStatuslineAsset: () => "",
  ClaudeCliStatuslineAdapter: class {
    name = "claude-cli-statusline";
    spinnerVerbsSupported = true;
    version() { return "cli"; }
    preflight() { return { ok: true, compatible: true, version: "cli" }; }
    applyPatch() { return { ok: true }; }
    restore = cliRestore;
  },
}));

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { activate, deactivate, __wireForTest } from "../src/extension";
import { makeContext, _warned, commands } from "./mocks/vscode";
import { ImpressionDedupe } from "../src/metrics/dedupe";

/** Redirect HOME/USERPROFILE to a fresh temp dir for one test: hermetic
 *  ~/.vibe-ads/boot.canary (a lingering real-home canary < 90s old reads as
 *  a crash and suspends auto-enable) and a guaranteed-empty extension scan
 *  for the codex strand-restore's locateCodexTarget(). */
function tempHome() {
  const home = mkdtempSync(join(tmpdir(), "kb-ext-home-"));
  const prevHome = process.env.HOME;
  const prevUser = process.env.USERPROFILE;
  process.env.HOME = home;
  process.env.USERPROFILE = home;   // os.homedir() reads this on Windows
  return () => {
    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 { /* ok */ }
  };
}

it("loopback impression path dedupes per adId (one bill per ad)", () => {
  const d = new ImpressionDedupe();
  const sent: string[] = [];
  const onEvent = (k: string, adId: string) => {
    if (d.shouldSend(k, adId)) sent.push(k + ":" + adId);
  };
  onEvent("impression_rendered", "adX");
  onEvent("impression_rendered", "adX");
  expect(sent).toEqual(["impression_rendered:adX"]);
});

describe("extension orchestration", { timeout: 15_000 }, () => {
  it("incompatible target -> no patch, status incompatible, never throws", async () => {
    const adapter = {
      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 })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    __wireForTest({ adapter, statusBar: sb });
    await expect(activate(makeContext() as never)).resolves.toBeUndefined();
    expect(adapter.applyPatch).not.toHaveBeenCalled();
    expect(sb.set).toHaveBeenCalledWith(expect.objectContaining({ kind: "incompatible" }));
    // A non-structural reason ("x") must NOT pop the incompat warning — that's
    // reserved for a genuine "verb array not found" miss. (Match the message,
    // not a count: unrelated warnings — e.g. the boot canary — share the mock.)
    expect(_warned.some((t) => t.includes("spinner hook"))).toBe(false);
    await deactivate();
  });

  it("audit #22: incompatible/missing Claude Code still cleans the CLI"
    + " settings surface and leaves it restorable at deactivate", async () => {
    const adapter = {
      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 })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    __wireForTest({ adapter, statusBar: sb });
    cliRestore.mockClear();
    await activate(makeContext() as never);
    // Pre-fix the early return ran before ANY claude-cli adapter existed, so
    // a stale statusLine/spinnerVerbs patch from a prior session (crash /
    // CC uninstalled-but-terminal-CLI-kept) was stranded forever. Now it is
    // cleaned once on this path…
    expect(cliRestore,
      "incompatible path must run the key-scoped CLI restore")
      .toHaveBeenCalled();
    // …and actx.cliStatus stays wired, so deactivate()'s (previously
    // null-guarded-away) CLI restore still runs.
    cliRestore.mockClear();
    await deactivate();
    expect(cliRestore,
      "deactivate must still be able to restore the CLI surface")
      .toHaveBeenCalled();
  });

  it("genuine verb-array miss -> warns the user (wiring), status incompatible", async () => {
    const adapter = {
      name: "claude-code",
      preflight: () => ({ ok: true, compatible: false, version: "2.1.161",
        reason: "verb array not found (incompatible build)" }),
      version: () => "2.1.161",
      isPatched: () => false,
      applyPatch: vi.fn(() => ({ ok: true })),
      restore: vi.fn(() => ({ ok: true, restored: false })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    __wireForTest({ adapter, statusBar: sb });
    await activate(makeContext() as never);
    expect(adapter.applyPatch).not.toHaveBeenCalled();
    expect(_warned.some((t) =>
      t.includes("couldn't find Claude Code 2.1.161's spinner hook")))
      .toBe(true);
    await deactivate();
  });

  it("reload watcher is wired even when debug is OFF", async () => {
    const adapter = {
      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 })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    const watched: string[] = [];
    __wireForTest({ adapter, statusBar: sb,
      watchFileFn: ((p: unknown) => { watched.push(String(p)); }) as never });
    await activate(makeContext() as never);
    expect(watched.some((p) => p.endsWith("reload"))).toBe(true);
    await deactivate();
  });

  it("kill -> restore() called and status killed", async () => {
    const adapter = {
      name: "claude-code",
      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 })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    __wireForTest({ adapter, statusBar: sb, killed: true });
    await activate(makeContext() as never);
    await new Promise((r) => setTimeout(r, 10));
    expect(adapter.restore).toHaveBeenCalled();
    expect(sb.set).toHaveBeenCalledWith(expect.objectContaining({ kind: "killed" }));
    await deactivate();
  });

  it("S9: kill restores the Codex target too (alongside CC)", async () => {
    const adapter = {
      name: "claude-code",
      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 })),
    };
    const codexAdapter = {
      name: "codex",
      preflight: () => ({ ok: true, compatible: true, version: "26.513.21555" }),
      version: () => "26.513.21555",
      applyPatch: vi.fn(() => ({ ok: true })),
      restore: vi.fn(() => ({ ok: true, restored: true })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    __wireForTest({ adapter, codexAdapter, statusBar: sb, killed: true });
    await activate(makeContext() as never);
    await new Promise((r) => setTimeout(r, 10));
    expect(codexAdapter.restore).toHaveBeenCalled();
    await deactivate();
  });

  it("wave-2P-F02: deactivate skips restore when K_ON=true (no pristine flash on reload)", async () => {
    // Regression for the "no ad after window reload" symptom. Pre-fix,
    // deactivate() always restored CC's index.js to pristine. The next
    // ext-host activation re-applied the patch, but CC's webview could read
    // pristine in between and the user saw default spinner verbs until a
    // second reload or a 60s reassert tick. Post-fix: when K_ON=true (user
    // opted in via the debug menu) deactivate leaves the patch in place.
    const adapter = {
      name: "claude-code",
      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 })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    __wireForTest({ adapter, statusBar: sb });
    const ctx = makeContext();
    // Seed K_ON=true so debugCtl.on() returns true at deactivate time.
    // Canonical current key is "kickbacks.debug.on"; the "vibe-ads.debug.on"
    // legacy key is read-through-only (see debug.ts on()). Legacy key parity
    // is covered by debug.test.ts.
    await ctx.globalState.update("kickbacks.debug.on", true);
    await activate(ctx as never);
    adapter.restore.mockClear();
    await deactivate();
    expect(adapter.restore, "deactivate must NOT restore when K_ON=true")
      .not.toHaveBeenCalled();
  });

  it("wave-2P-F02: deactivate still restores when K_ON=false (uninstall hygiene)", async () => {
    // The K_ON-gated skip applies when the user is opted in. The
    // earlier auto-enable change means activate() flips K_ON to true
    // by default, so this test forces K_ON=false AFTER activate
    // (simulating a user who explicitly disabled via the menu) to
    // exercise the legacy "never leave a user VISIBLY patched on
    // disable/uninstall" branch — deactivate must restore in that
    // case.
    const adapter = {
      name: "claude-code",
      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 })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    __wireForTest({ adapter, statusBar: sb });
    const ctx = makeContext();
    await activate(ctx as never);
    // Simulate the user disabling via the menu BEFORE shutdown.
    await ctx.globalState.update("kickbacks.debug.on", false);
    adapter.restore.mockClear();
    await deactivate();
    expect(adapter.restore, "deactivate must restore when K_ON is false")
      .toHaveBeenCalled();
  });

  it("codex-only: activation proceeds, K_ON persists, codex/<ver> on the wire,"
    + " never labeled incompatible", async () => {
    // The bug a public-mirror PR reported (kickbacks.ai#1): Claude Code
    // absent (preflight "target not found") + a compatible Codex install
    // used to hit the claude-only preflight early-return — red
    // "incompatible" bar, no sign-in command, no serving, K_ON never
    // persisted. Activation must now proceed on the codex leg alone.
    const restoreHome = tempHome();
    const adapter = {
      name: "claude-code",
      preflight: () => ({ ok: true, compatible: false, version: null,
        reason: "target not found" }),
      version: () => null,
      applyPatch: vi.fn(() => ({ ok: false, reason: "target not found" })),
      restore: vi.fn(() => ({ ok: true, restored: false })),
    };
    const codexAdapter = {
      name: "codex",
      preflight: () => ({ ok: true, compatible: true, version: "26.513.21555" }),
      version: () => "26.513.21555",
      isPatched: vi.fn(() => true),
      applyPatch: vi.fn(() => ({ ok: true })),
      restore: vi.fn(() => ({ ok: true, restored: true })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    // Benign backend stub: {} reads as no-ad / not-killed / no-consent, and
    // the captured URLs pin the ccVersion wire label below.
    const calls: string[] = [];
    vi.stubGlobal("fetch", vi.fn(async (input: unknown) => {
      calls.push(typeof input === "string" ? input : String(input));
      return { ok: true, status: 200, json: async () => ({}) } as Response;
    }));
    commands._handlers.clear();
    __wireForTest({ adapter, codexAdapter, statusBar: sb });
    const ctx = makeContext();
    try {
      await expect(activate(ctx as never)).resolves.toBeUndefined();
      // Proceeds: the sign-in surface exists and the bar is never relabeled.
      expect(commands._handlers.has("kickbacks.signIn")).toBe(true);
      expect(sb.set).not.toHaveBeenCalledWith(
        expect.objectContaining({ kind: "incompatible" }));
      // bootCanary's widened auto-enable persisted K_ON through the
      // codex-folded DebugController.apply()…
      expect(ctx.globalState.get("kickbacks.debug.on")).toBe(true);
      // …which patched Codex.
      expect(codexAdapter.applyPatch).toHaveBeenCalled();
      // The host-version label travels the wire as codex/<ver> (killswitch
      // poll + demo-portfolio fetch), never as a bare CC-shaped version.
      expect(calls.some((u) => u.includes("codex%2F26.513.21555"))).toBe(true);
    } finally {
      await deactivate();
      vi.unstubAllGlobals();
      restoreHome();
    }
  });

  it("both targets incompatible -> early-return: incompatible label, no"
    + " sign-in, CLI strand restore still runs", async () => {
    const restoreHome = tempHome();
    const adapter = {
      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 })),
    };
    const codexAdapter = {
      name: "codex",
      preflight: () => ({ ok: true, compatible: false, version: null,
        reason: "thinking-shimmer anchors not found" }),
      version: () => null,
      applyPatch: vi.fn(() => ({ ok: true })),
      restore: vi.fn(() => ({ ok: true, restored: false })),
    };
    const sb = { set: vi.fn(), dispose() {} };
    commands._handlers.clear();
    cliRestore.mockClear();
    __wireForTest({ adapter, codexAdapter, statusBar: sb });
    try {
      await expect(activate(makeContext() as never)).resolves.toBeUndefined();
      expect(sb.set).toHaveBeenCalledWith(
        expect.objectContaining({ kind: "incompatible" }));
      expect(commands._handlers.has("kickbacks.signIn")).toBe(false);
      expect(codexAdapter.applyPatch).not.toHaveBeenCalled();
      // Audit #22's CLI strand restore is preserved on the narrower gate.
      expect(cliRestore).toHaveBeenCalled();
    } finally {
      await deactivate();
      restoreHome();
    }
  });

  it("S9: a throwing Codex adapter never blocks CC or activation", async () => {
    const adapter = {
      name: "claude-code",
      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 })),
    };
    const boom = () => { throw new Error("codex exploded"); };
    const codexAdapter = {
      name: "codex",
      preflight: boom as never,
      version: boom as never,
      applyPatch: boom as never,
      restore: boom as never,
    };
    const sb = { set: vi.fn(), dispose() {} };
    __wireForTest({ adapter, codexAdapter, statusBar: sb, killed: true });
    await expect(activate(makeContext() as never)).resolves.toBeUndefined();
    expect(adapter.restore).toHaveBeenCalled();          // CC unaffected by Codex throw
    await expect(deactivate()).resolves.toBeUndefined(); // teardown survives too
  });
});