← back to Kickbacks Ai Upstream

test/debug.test.ts

420 lines

import { describe, it, expect, vi, beforeEach } from "vitest";
import { DebugController } from "../src/debug";
import { Loopback } from "../src/loopback";
import { bootLoopback, resetSharedLoopbackForTest }
  from "../src/util/loopbackBoot";
import { resetServingGate, setKillPosture } from "../src/servingGate";
import { makeContext, window, commands, _opened } from "./mocks/vscode";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

// Hermetic: the shared-loopback record and the serving gate are module-level
// singletons (audit #7 / wave 2) — never leak one test's state into the next.
beforeEach(() => { resetServingGate(); resetSharedLoopbackForTest(); });

function authHook(signedIn: boolean) {
  return {
    signedIn: () => signedIn,
    storageInfo: () => ({ scheme: "file" }),
    signOut: vi.fn(async () => {}),
  };
}

function mkAdapter() {
  return {
    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 })),
  };
}

describe("DebugController", () => {
  it("defaults to off with a default message", () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    expect(d.on()).toBe(false);
    expect(d.text()).toMatch(/kickbacks/i);
  });

  it("setOn(true) patches with the custom text and reports state", async () => {
    const adapter = mkAdapter();
    const ctx = makeContext() as never;
    const onState = vi.fn();
    const d = new DebugController(adapter, ctx, onState);
    await d.setText("Hello from debug");
    await d.setOn(true);
    expect(d.on()).toBe(true);
    expect(adapter.applyPatch).toHaveBeenCalledWith(
      expect.objectContaining({ adText: "Hello from debug", tier: 3 }));
    expect(onState).toHaveBeenCalledWith(true);
    await d.dispose();
  });

  it("setOn(false) restores and reports state", async () => {
    const adapter = mkAdapter();
    const onState = vi.fn();
    const d = new DebugController(adapter, makeContext() as never, onState);
    await d.setOn(false);
    expect(adapter.restore).toHaveBeenCalled();
    expect(onState).toHaveBeenCalledWith(false);
  });

  it("reassertTick re-applies when ON but the patch drifted (Bug B)", async () => {
    const adapter = { ...mkAdapter(), isPatched: vi.fn(() => false) };
    const d = new DebugController(adapter, makeContext() as never, () => {});
    await d.setOn(true);                  // persists ON (applies once)
    adapter.applyPatch.mockClear();
    await d.reassertTick();
    expect(adapter.applyPatch).toHaveBeenCalled();   // self-healed, no toggle
    await d.dispose();
  });

  it("reassertTick is a no-op when ON and the patch is still present", async () => {
    const adapter = { ...mkAdapter(), isPatched: vi.fn(() => true) };
    const d = new DebugController(adapter, makeContext() as never, () => {});
    await d.setOn(true);
    adapter.applyPatch.mockClear();
    await d.reassertTick();
    expect(adapter.applyPatch).not.toHaveBeenCalled(); // healthy => no churn
    await d.dispose();
  });

  it("reassertTick is a no-op when injection is OFF", async () => {
    const adapter = { ...mkAdapter(), isPatched: vi.fn(() => false) };
    const d = new DebugController(adapter, makeContext() as never, () => {});
    await d.reassertTick();              // never turned on
    expect(adapter.applyPatch).not.toHaveBeenCalled();
  });

  it("auth row is 'Sign in' when signed out (right under GET PAID OUT); runs the signIn command", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(false));
    let captured: { id: string; label: string }[] = [];
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) => {
        captured = items as { id: string; label: string }[];
        return captured.find((i) => i.id === "signin");
      });
    const exec = vi.spyOn(commands, "executeCommand");
    await d.openMenu();
    // GET PAID OUT $$$ owns the very top row; the auth flip sits directly
    // under it.
    expect(captured[0].id).toBe("getpaid");
    expect(captured[1].id).toBe("signin");
    expect(captured[1].label).toMatch(/sign in/i);
    expect(captured.some((i) => i.id === "signout")).toBe(false);
    expect(exec).toHaveBeenCalledWith("kickbacks.signIn");
    qp.mockRestore(); exec.mockRestore();
  });

  it("auth row flips between Sign in / Sign out by auth state and dispatches the right command", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    let captured: { id: string; label: string }[] = [];
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) => {
        captured = items as { id: string; label: string }[];
        return captured.find((i) => i.id === "signout");
      });
    const exec = vi.spyOn(commands, "executeCommand");
    await d.openMenu();
    // Auth row is the signed-in user's Sign out action — the identity
    // appears INLINE (label or description), not as a separate row.
    // signin must NOT appear when already signed in.
    expect(captured[0].id).toBe("getpaid");
    expect(captured[1].id).toBe("signout");
    expect(captured[1].label).toMatch(/sign out/i);
    expect(captured.some((i) => i.id === "signin")).toBe(false);
    expect(captured.some((i) => i.id === "__identity")).toBe(false);
    expect(exec).toHaveBeenCalledWith("kickbacks.signOut");
    qp.mockRestore(); exec.mockRestore();
  });

  it("GET PAID OUT $$$ is the menu's first row and opens the earnings portal", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    _opened.length = 0;
    let captured: { id: string; label: string }[] = [];
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) => {
        captured = items as { id: string; label: string }[];
        return captured.find((i) => i.id === "getpaid");
      });
    await d.openMenu();
    expect(captured[0].id).toBe("getpaid");
    expect(captured[0].label).toMatch(/GET PAID OUT \$\$\$/);
    expect(_opened).toContain("https://kickbacks.ai/me");
    qp.mockRestore();
  });

  it("GET PAID OUT $$$ stays on top even when auth never initialised", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    let captured: { id: string }[] = [];
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) => { captured = items as { id: string }[]; return undefined; });
    await d.openMenu();
    expect(captured[0].id).toBe("getpaid");
    qp.mockRestore();
  });

  it("W2 menu shape: required items present, deprecated ones absent", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    let captured: { id?: string; label?: string }[] = [];
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) => {
        captured = items as { id?: string; label?: string }[]; return undefined;
      });
    await d.openMenu();
    const ids = captured.map((i) => i.id);
    // Required
    expect(ids).toContain("toggle");
    expect(ids).toContain("config");
    expect(ids).toContain("reapply");
    expect(ids).toContain("reload");
    expect(ids).toContain("restore");
    expect(ids).toContain("openlog");
    expect(ids).toContain("builtinfo");
    // Removed: msg (rolled into config.json), plus W2-era wv/cli/diag/banner/status
    expect(ids).not.toContain("msg");
    expect(ids).not.toContain("wv");
    expect(ids).not.toContain("cli");
    expect(ids).not.toContain("diag");
    expect(ids).not.toContain("banner");
    expect(ids).not.toContain("status");
    // Toggle label reflects state
    const toggleLabel = captured.find((i) => i.id === "toggle")?.label || "";
    expect(toggleLabel).toMatch(/enable|disable/i);
    expect(toggleLabel).toMatch(/kickbacks/i);
    qp.mockRestore();
  });

  it("consolidated re-apply fires BOTH CC reassert and Codex reassert", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    const ccReassert = vi.fn();
    const codexReassert = vi.fn();
    d.setReassert(ccReassert);
    d.setReassertCodex(codexReassert);
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) =>
        (items as { id?: string }[]).find((i) => i.id === "reapply"));
    await d.openMenu();
    expect(ccReassert).toHaveBeenCalled();
    expect(codexReassert).toHaveBeenCalled();
    qp.mockRestore();
  });

  it("menu omits the auth item entirely when auth is unavailable", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    let captured: { id: string }[] = [];
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) => { captured = items as { id: string }[]; return undefined; });
    await d.openMenu();
    expect(captured.some((i) => i.id === "signin" || i.id === "signout")).toBe(false);
    qp.mockRestore();
  });

  it("setText live re-applies only while injection is on", async () => {
    const adapter = mkAdapter();
    const d = new DebugController(adapter, makeContext() as never, () => {});
    await d.setText("off-text");
    expect(adapter.applyPatch).not.toHaveBeenCalled();
    await d.setOn(true);
    await d.setText("on-text");
    expect(adapter.applyPatch).toHaveBeenLastCalledWith(
      expect.objectContaining({ adText: "on-text" }));
    await d.dispose();
  });

  // ─── Tiered auto-enable across sign-out → sign-in ─────────────────────
  // Regression: signing out forces K_ON=false; pre-fix the sign-in gate only
  // re-enabled on neverToggled(), so once you'd signed out you stayed disabled
  // forever. doSignOut() now remembers the pre-sign-out state so the next
  // sign-in can restore it — while still respecting a deliberate disable.
  it("shouldAutoEnableOnSignIn: Tier 1 — true for a first-run user (never toggled)", () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    expect(d.neverToggled()).toBe(true);
    expect(d.shouldAutoEnableOnSignIn()).toBe(true);
  });

  it("shouldAutoEnableOnSignIn: Tier 2 — true after signing out while injection was ON", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    await d.setOn(true);            // user is running ads
    await d.doSignOut();            // forces K_ON=false, remembers it was ON
    expect(d.on()).toBe(false);
    expect(d.neverToggled()).toBe(false);          // K_ON is defined now
    expect(d.shouldAutoEnableOnSignIn()).toBe(true); // …but Tier 2 fires
    await d.dispose();
  });

  it("shouldAutoEnableOnSignIn: false when the user deliberately disabled, then signed out", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    await d.setOn(true);
    await d.setOn(false);          // deliberate "Disable Kickbacks"
    await d.doSignOut();           // captures the OFF intent
    expect(d.shouldAutoEnableOnSignIn()).toBe(false); // stays off — respected
    await d.dispose();
  });

  it("setOn(false) clears the sign-out memory: a deliberate disable sticks across the next sign-in", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    await d.setOn(true);
    await d.doSignOut();             // K_PRESIGNOUT=true (was ON)
    expect(d.shouldAutoEnableOnSignIn()).toBe(true);
    await d.setOn(false);            // deliberate disable AFTER the sign-out
    expect(d.shouldAutoEnableOnSignIn()).toBe(false); // stale memory cleared
    await d.dispose();
  });

  it("menu Restore is a deliberate disable: doRestore clears the sign-out memory too", async () => {
    // Audit fix-up (wave 2): doRestore set K_ON=false but left a stale
    // K_PRESIGNOUT=true, so the serving gate's enabled() input stayed true
    // and the 60s reassert re-patched the just-restored install.
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    await d.setOn(true);
    await d.doSignOut();             // stale K_PRESIGNOUT=true
    await (d as unknown as { doRestore: () => Promise<void> }).doRestore();
    expect(d.on()).toBe(false);
    expect(d.shouldAutoEnableOnSignIn()).toBe(false);
    await d.dispose();
  });

  it("clearSignOutMemory consumes the one-shot intent", async () => {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    await d.setOn(true);
    await d.doSignOut();
    expect(d.shouldAutoEnableOnSignIn()).toBe(true);
    await d.clearSignOutMemory();
    // Flag gone; only the (still-false) K_ON remains, so no auto-enable.
    expect(d.shouldAutoEnableOnSignIn()).toBe(false);
    await d.dispose();
  });

  it("bannerOverride cycles server → on → off → server (modes sentinel)", async () => {
    const RH = process.env.HOME, RU = process.env.USERPROFILE;
    const tmp = mkdtempSync(join(tmpdir(), "vibe-dbg-"));
    process.env.HOME = tmp; process.env.USERPROFILE = tmp;
    try {
      const ctl = new DebugController(mkAdapter(), makeContext() as never, () => {});
      expect(ctl.bannerOverride()).toBe("server");
      await ctl.cycleBannerOverride();
      expect(ctl.bannerOverride()).toBe("on");
      expect(readFileSync(join(tmp, ".vibe-ads", "banner.mode"), "utf8").trim()).toBe("on");
      await ctl.cycleBannerOverride();
      expect(ctl.bannerOverride()).toBe("off");
      await ctl.cycleBannerOverride();
      expect(ctl.bannerOverride()).toBe("server");
      expect(existsSync(join(tmp, ".vibe-ads", "banner.mode"))).toBe(false);
    } finally {
      if (RH !== undefined) process.env.HOME = RH; else delete process.env.HOME;
      if (RU !== undefined) process.env.USERPROFILE = RU; else delete process.env.USERPROFILE;
      rmSync(tmp, { recursive: true, force: true });
    }
  });
});

// ─── Audit #7 (wave 3): one loopback server per extension host ────────────
describe("DebugController — shared loopback (audit #7)", () => {
  it("apply() reuses ONE Loopback across re-applies — no stop/re-mint that "
    + "would tear down the shared server", async () => {
    const adapter = mkAdapter();
    const d = new DebugController(adapter, makeContext() as never, () => {});
    await d.setOn(true);
    const lb1 = (d as unknown as { lb: Loopback | null }).lb;
    expect(lb1).toBeTruthy();
    expect(lb1!.isRunning()).toBe(true);
    await d.setText("re-applied text");      // live re-apply while ON
    const lb2 = (d as unknown as { lb: Loopback | null }).lb;
    expect(lb2, "pre-fix: a fresh Loopback was minted per apply").toBe(lb1);
    expect(lb1!.isRunning()).toBe(true);     // the bound server survived
    await d.dispose();
  });

  it("apply() shares an existing production loopback (same port, no "
    + "EADDRINUSE→port+1) and never displaces its handlers; dispose() leaves "
    + "the production server running", async () => {
    const ctx = makeContext();
    // The production loopback boots first (K_ON=false boot)…
    const prodLb = new Loopback({
      onEvent: () => {}, onClick: () => {}, getActivity: () => ({}),
      getCurrentAd: () => ({ adText: "prod-ad", clickUrl: "https://p.test",
        iconUrl: "", adId: "prod", campaignId: "prod" }),
    });
    const prod = await bootLoopback(prodLb, ctx as never);
    expect(prod.port).toBeGreaterThan(0);
    // …then the user enables Kickbacks → debug apply().
    const adapter = mkAdapter();
    const d = new DebugController(adapter, ctx as never, () => {});
    await d.setOn(true);
    const calls = adapter.applyPatch.mock.calls as unknown as
      [{ loopbackPort: number; loopbackBase: string }][];
    const params = calls[calls.length - 1][0];
    expect(params.loopbackPort, "pre-fix: bound prod.port + 1")
      .toBe(prod.port);
    expect(params.loopbackBase).toBe(prod.base);
    // Production wiring (the billing authority) keeps the routes.
    const ad = await (await fetch(`${prod.base}/ad`)).json();
    expect(ad.adId).toBe("prod");
    // The debug controller never owned the server — its dispose must not
    // tear down production traffic.
    await d.dispose();
    expect(prodLb.isRunning()).toBe(true);
    await prodLb.stop();
  });

  it("the debug loopback's /ad stays canServeAds-gated (wave-2 contract "
    + "preserved in the sharing design)", async () => {
    const adapter = mkAdapter();
    const d = new DebugController(adapter, makeContext() as never, () => {});
    d.setPortfolioAd("Debug ad", "https://d.test");
    await d.setOn(true);
    const calls = adapter.applyPatch.mock.calls as unknown as
      [{ loopbackBase: string }][];
    const params = calls[calls.length - 1][0];
    const served = await (await fetch(`${params.loopbackBase}/ad`)).json();
    expect(served.adText).toBe("Debug ad");
    setKillPosture("confirmed");             // confirmed kill ⇒ stop serving
    const gated = await (await fetch(`${params.loopbackBase}/ad`)).json();
    expect(gated).toEqual({});
    await d.dispose();
  });
});

describe("DebugController — restart/open-log routing (W2 menu)", () => {
  function mkDbg() {
    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
    d.setAuth(authHook(true));
    return d;
  }

  it("reload entry restarts the extension host", async () => {
    const d = mkDbg();
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) =>
        (items as { id?: string }[]).find((i) => i.id === "reload"));
    const exec = vi.spyOn(commands, "executeCommand");
    await d.openMenu();
    expect(exec).toHaveBeenCalledWith("workbench.action.restartExtensionHost");
    qp.mockRestore(); exec.mockRestore();
  });

  it("openlog entry opens the debug log file", async () => {
    const d = mkDbg();
    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
      async (items: unknown) =>
        (items as { id?: string }[]).find((i) => i.id === "openlog"));
    const exec = vi.spyOn(commands, "executeCommand");
    await d.openMenu();
    expect(exec).toHaveBeenCalledWith("vscode.open", expect.anything());
    qp.mockRestore(); exec.mockRestore();
  });
});