← back to Kickbacks Ai Upstream
test/commands.test.ts
430 lines
// End-to-end coverage for every `vscode.commands.registerCommand` call the
// extension makes at activation time. The activate() flow is allowed to be
// big; the contract we lock in here is small: when a user fires a registered
// command id (either from the palette, the status-bar menu, or a keybinding),
// the side effect that the user *sees* — a browser tab, a toast, a cleared
// session, a config doc opening, a restored binary — must actually happen.
//
// Test-only mock surface used:
// - _opened : every vscode.env.openExternal(uri) call (browser tab)
// - _shown : every showInformationMessage / showErrorMessage (toasts)
// - _openedDocs : every workspace.openTextDocument(path) call (config doc)
// - commands._handlers / _executed (dispatch)
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Mock the log module so "signed in" assertions reflect real token state and
// tests never read dev-machine sentinels. Same trick auth.test.ts uses.
vi.mock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
dlogRaw: () => {}, debugIconDataUri: () => "",
codexEnabled: () => false, codexDisabled: () => false,
codexCliEnabled: () => false, testHooksEnabled: () => false,
LOG_PATH: "/tmp/test-log" }));
import { activate, deactivate, __wireForTest } from "../src/extension";
import { setupAdRotation, type AdRotationDeps }
from "../src/activation/adRotation";
import type { PatchAd, PortfolioResponse } from "../src/portfolio/client";
import {
makeContext, secrets, _opened, _shown, _openedDocs, commands, window,
} from "./mocks/vscode";
/** Test-double Claude Code adapter (compatible build). */
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 })),
});
const mkCodex = () => ({
name: "codex" as const,
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 })),
});
/** Global fetch stub. Returns the broker 307+location for sign-in start,
* an immediate access_token for sign-in poll, and a benign 200 for every
* other backend call (portfolio / kill / earnings / consent). Returning
* access_token on the FIRST poll avoids the 1.5s sleep inside signIn(). */
const stubFetch = () => {
const calls: string[] = [];
const f = vi.fn(async (input: unknown, init?: unknown) => {
const url = typeof input === "string" ? input : String(input);
calls.push(url);
if (url.includes("/v1/auth/extension/start")) {
return {
status: 307,
headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://broker.test/auth?state=ST1" : null },
} as unknown as Response;
}
if (url.includes("/v1/auth/extension/poll")) {
return { ok: true, status: 200, json: async () =>
({ access_token: "AT-INT", refresh_token: "RT-INT", expires_in: 3600 }) } as Response;
}
// Any other backend call: keep it benign so activation finishes without
// network noise. ConsentClient.read() returns null on missing
// current_tos_version, KillSwitchClient treats missing fields as
// "not killed", PortfolioClient handles {} as "no ad". Suppresses the
// consent toast that would otherwise pollute _shown.
return { ok: true, status: 200, json: async () => ({}) } as Response;
});
// AuthClient captures `fetch` as the default parameter value at construct
// time, so vi.stubGlobal must happen BEFORE activate(). The cleanup runs
// in afterEach via vi.unstubAllGlobals.
vi.stubGlobal("fetch", f);
return { f, calls };
};
/** Boot the extension hermetically. Returns the cleanup hook. The HOME
* redirect is non-optional: AuthClient writes ~/.kickbacks/auth.json, and
* we will NOT touch the real user's auth file from a test. */
async function boot(opts: { codex?: boolean } = {}) {
const home = mkdtempSync(join(tmpdir(), "kb-cmds-"));
const prevHome = process.env.HOME;
const prevUser = process.env.USERPROFILE;
process.env.HOME = home;
process.env.USERPROFILE = home;
const adapter = mkAdapter();
const codex = opts.codex ? mkCodex() : null;
const statusBar = { set: vi.fn(), dispose: vi.fn() };
__wireForTest({ adapter, codexAdapter: codex, statusBar });
const fetched = stubFetch();
const ctx = makeContext();
await activate(ctx as never);
return {
home, adapter, codex, statusBar, ctx, fetched,
async dispose() {
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 */ }
},
};
}
beforeEach(() => {
// Fresh slate per test. Module-globals (secrets, commands._handlers,
// _opened, _shown, _openedDocs) survive across tests in the same file
// because the vscode mock module is shared.
secrets.clear();
commands._handlers.clear();
commands._executed.length = 0;
_opened.length = 0;
_shown.length = 0;
_openedDocs.length = 0;
__wireForTest({});
});
afterEach(() => { vi.unstubAllGlobals(); });
// ---------------------------------------------------------------------------
// Registration: every contributed command id is wired to a handler.
// Source of truth is package.json — if a new command is added there without
// a registerCommand() call, this fails loudly.
// ---------------------------------------------------------------------------
describe("contributed commands → registered handlers", () => {
const CONTRIBUTED = [
"kickbacks.signIn",
"kickbacks.signOut",
"kickbacks.restore",
"kickbacks.status",
"kickbacks.debugMenu",
"kickbacks.editConfig",
"vibe-ads.signIn",
"vibe-ads.signOut",
"vibe-ads.restore",
"vibe-ads.status",
"vibe-ads.debugMenu",
];
it("registers every contributed command id", async () => {
const t = await boot();
try {
for (const id of CONTRIBUTED) {
expect(commands._handlers.has(id), `missing handler: ${id}`).toBe(true);
}
} finally { await t.dispose(); }
});
it("ccVersion wire label: the CLAUDE version wins when claude is"
+ " compatible (codex present or not)", async () => {
// Pins the codex-only counterpart in extension.test.ts: a compatible
// Claude Code must keep reporting its own version on the killswitch /
// portfolio wire — never the codex/<ver> label.
const t = await boot({ codex: true });
try {
expect(t.fetched.calls.some((u) => u.includes("version=2.1.143")),
"killswitch poll must carry the claude version").toBe(true);
expect(t.fetched.calls.some((u) => u.includes("codex%2F"))).toBe(false);
} finally { await t.dispose(); }
});
it("every legacy vibe-ads.* alias points to the SAME closure as its kickbacks.* twin", async () => {
const t = await boot();
try {
// Shared-closure parity: a user keybinding on a legacy id should fire
// the exact same code path, never a drifted copy. Reference equality
// is the strongest assertion we can make at this layer.
const pairs: [string, string][] = [
["kickbacks.signIn", "vibe-ads.signIn"],
["kickbacks.signOut", "vibe-ads.signOut"],
["kickbacks.restore", "vibe-ads.restore"],
["kickbacks.status", "vibe-ads.status"],
["kickbacks.debugMenu", "vibe-ads.debugMenu"],
];
for (const [a, b] of pairs) {
expect(commands._handlers.get(a), `alias drift: ${a} vs ${b}`)
.toBe(commands._handlers.get(b));
}
} finally { await t.dispose(); }
});
});
// ---------------------------------------------------------------------------
// kickbacks.signIn → opens the broker URL in the system browser AND
// stores the returned tokens. This is the regression class the user is
// most worried about ("did clicking the button actually trigger a browser").
// ---------------------------------------------------------------------------
describe("kickbacks.signIn", () => {
it("opens the broker URL via vscode.env.openExternal", async () => {
const t = await boot();
try {
await commands.executeCommand("kickbacks.signIn");
expect(_opened.some((u) => u.includes("https://broker.test/auth"))).toBe(true);
// And the access token landed in secrets — the round-trip happened.
expect(await t.ctx.secrets.get("kickbacks.access")).toBe("AT-INT");
} finally { await t.dispose(); }
});
it("the legacy vibe-ads.signIn alias also opens the broker URL", async () => {
const t = await boot();
try {
await commands.executeCommand("vibe-ads.signIn");
expect(_opened.some((u) => u.includes("https://broker.test/auth"))).toBe(true);
} finally { await t.dispose(); }
});
});
// ---------------------------------------------------------------------------
// kickbacks.signOut → clears the in-process token, restores Claude Code,
// and shows the "signed out" toast.
// ---------------------------------------------------------------------------
describe("kickbacks.signOut", () => {
it("clears the access token, calls adapter.restore, and toasts", async () => {
const t = await boot();
try {
// First sign in so signOut has something to clear.
await commands.executeCommand("kickbacks.signIn");
expect(await t.ctx.secrets.get("kickbacks.access")).toBe("AT-INT");
t.adapter.restore.mockClear();
await commands.executeCommand("kickbacks.signOut");
expect(await t.ctx.secrets.get("kickbacks.access")).toBeUndefined();
expect(t.adapter.restore).toHaveBeenCalled();
expect(_shown.some((s) =>
s.kind === "info" && /signed out/i.test(s.text))).toBe(true);
// Status bar must reflect the new state — silent failure to update
// the badge is exactly the "button does nothing" symptom we're guarding.
expect(t.statusBar.set).toHaveBeenCalledWith(
expect.objectContaining({ kind: "signed-out" }));
} finally { await t.dispose(); }
});
});
// ---------------------------------------------------------------------------
// Regression (audit #20): sign-out must clear the live ad-rotation state.
// Pre-fix, doSignOut restored the CC files but left the REAL ad in adRef and
// the rotation queue — the status bar kept serving it signed-out, the next
// rotation tick re-patched CC right after the restore, and metrics misrouted
// the real session tokens to /v1/metrics/demo.
// ---------------------------------------------------------------------------
describe("kickbacks.signOut → ad-rotation clear", () => {
const mkAd = (adId: string): PatchAd => ({
adId, campaignId: "c-" + adId, adText: "Ad " + adId, iconRef: "i",
iconUrl: "", clickUrl: "https://x.test", bannerEnabled: false,
sessionToken: "tok-" + adId });
const mkResp = (ads: PatchAd[]): PortfolioResponse => ({
ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null });
it("drops the leftover real ads from the live rotation on sign-out", async () => {
const t = await boot();
const timers: NodeJS.Timeout[] = [];
try {
await commands.executeCommand("kickbacks.signIn");
// Stand up a live rotation holding REAL ads. The hermetic boot serves
// no ad (stub portfolio is empty) so activation never created one —
// this registers as THE live rotation the sign-out command must reach.
const ads = [mkAd("real-1"), mkAd("real-2")];
const adRef = { current: ads[0] as PatchAd | null };
const activeAdRef = { current: ads[0] as PatchAd | null };
const deps = {
adapter: { applyPatch: vi.fn(() => ({ ok: true })) },
portfolio: { fetchPortfolio: async () => null,
fetchDemoPortfolio: async () => null },
auth: { accessToken: () => "tok", clientId: () => "cid" },
debugCtl: { setPortfolioAd: vi.fn() },
session: { set: vi.fn() },
ccVersion: "2.1.167", port: 12345,
patchParams: { adText: "", iconRef: "", iconUrl: "", clickUrl: "" },
activeAdRef, corrRef: { current: "corr" }, adRef,
impDedupe: { reset: vi.fn() }, reapplyCodex: null, timers,
} as unknown as AdRotationDeps;
const handle = setupAdRotation(deps, mkResp(ads));
expect(handle.rotationTimer).not.toBeNull();
await commands.executeCommand("kickbacks.signOut");
// The command path must clear the rotation: queue gone, timer disarmed,
// shared ad refs nulled — no surface can keep serving the real ad
// signed-out and no rotation tick can re-patch CC post-restore.
expect(handle.adQueue).toEqual([]);
expect(handle.rotationTimer).toBeNull();
expect(adRef.current).toBeNull();
expect(activeAdRef.current).toBeNull();
} finally {
timers.forEach((tm) => clearInterval(tm));
await t.dispose();
}
});
});
// ---------------------------------------------------------------------------
// Regression: signing out then back in must RE-ENABLE injection. doSignOut
// forces injection OFF (a signed-out session can't serve ads); pre-fix the
// sign-in path only re-enabled on neverToggled(), so the OFF that sign-out
// wrote stuck forever and the user came back silently disabled. The fix
// remembers the pre-sign-out state and restores it on the next sign-in.
// ---------------------------------------------------------------------------
describe("sign-out → sign-in re-enables injection", () => {
it("re-applies the patch after a sign-out/sign-in cycle", async () => {
const t = await boot();
try {
// First sign-in: first-run default-on patches the binary.
await commands.executeCommand("kickbacks.signIn");
expect(t.adapter.applyPatch).toHaveBeenCalled();
// Sign out: injection forced off, binary restored.
await commands.executeCommand("kickbacks.signOut");
t.adapter.applyPatch.mockClear();
// Sign back in: injection must come back on its own (Tier 2).
await commands.executeCommand("kickbacks.signIn");
expect(t.adapter.applyPatch).toHaveBeenCalled();
} finally { await t.dispose(); }
});
});
// ---------------------------------------------------------------------------
// kickbacks.restore → byte-exact revert of the patched binary(s).
// ---------------------------------------------------------------------------
describe("kickbacks.restore", () => {
it("calls adapter.restore() exactly once", async () => {
const t = await boot();
try {
t.adapter.restore.mockClear();
await commands.executeCommand("kickbacks.restore");
expect(t.adapter.restore).toHaveBeenCalledTimes(1);
} finally { await t.dispose(); }
});
it("also calls codex.restore() when a Codex target is wired", async () => {
const t = await boot({ codex: true });
try {
t.adapter.restore.mockClear();
t.codex!.restore.mockClear();
await commands.executeCommand("kickbacks.restore");
expect(t.adapter.restore).toHaveBeenCalledTimes(1);
expect(t.codex!.restore).toHaveBeenCalled();
} finally { await t.dispose(); }
});
});
// ---------------------------------------------------------------------------
// kickbacks.status → information toast carrying the live state. The exact
// wording is not a contract; the regression we lock in is "the toast fires
// AND mentions the signed-in/out state."
// ---------------------------------------------------------------------------
describe("kickbacks.status", () => {
it("shows an info toast that reflects signed-out state", async () => {
const t = await boot();
try {
_shown.length = 0; // ignore any activation-time toasts
await commands.executeCommand("kickbacks.status");
const last = _shown.filter((s) => s.kind === "info").pop();
expect(last, "no info toast fired").toBeDefined();
expect(last!.text).toMatch(/signed out/i);
expect(last!.text).toMatch(/kickbacks/i);
} finally { await t.dispose(); }
});
it("shows 'signed in' once a token is held", async () => {
const t = await boot();
try {
await commands.executeCommand("kickbacks.signIn");
_shown.length = 0;
await commands.executeCommand("kickbacks.status");
const last = _shown.filter((s) => s.kind === "info").pop();
expect(last!.text).toMatch(/signed in/i);
} finally { await t.dispose(); }
});
});
// ---------------------------------------------------------------------------
// kickbacks.debugMenu → opens the QuickPick. We assert the menu actually
// asks the user for input AND surfaces every must-have row. The individual
// row → command routing is already covered by debug.test.ts.
// ---------------------------------------------------------------------------
describe("kickbacks.debugMenu", () => {
it("opens a QuickPick containing every documented menu row", async () => {
const t = await boot();
try {
let captured: { id?: string; label?: string }[] = [];
const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
async (items: unknown) => {
captured = items as { id?: string }[]; return undefined;
});
await commands.executeCommand("kickbacks.debugMenu");
// Assert BEFORE mockRestore — vitest's restore clears the spy's call
// history alongside removing it (caught the hard way: spy fired but
// the post-restore expect saw zero calls). Lesson preserved in-line
// so future tests in this file don't repeat the trap.
expect(qp).toHaveBeenCalled();
const ids = captured.map((i) => i.id);
for (const required of ["toggle", "config", "reapply", "reload",
"restore", "openlog", "builtinfo"]) {
expect(ids, `missing menu row: ${required}`).toContain(required);
}
qp.mockRestore();
} finally { await t.dispose(); }
});
});
// ---------------------------------------------------------------------------
// kickbacks.editConfig → materialises ~/.vibe-ads/config.json if missing
// and opens it in the editor. The observable side effect we assert on is
// workspace.openTextDocument being called with a config-shaped path.
// ---------------------------------------------------------------------------
describe("kickbacks.editConfig", () => {
it("opens the config file in the editor", async () => {
const t = await boot();
try {
_openedDocs.length = 0;
await commands.executeCommand("kickbacks.editConfig");
// ensureConfigFile() resolves to either ~/.kickbacks/config.json or
// ~/.vibe-ads/config.json depending on which exists; match either.
expect(_openedDocs.some((p) => /config\.json$/.test(p)),
`openTextDocument was not called with a config path; saw: ${JSON.stringify(_openedDocs)}`
).toBe(true);
} finally { await t.dispose(); }
});
});