← back to Kickbacks Ai Upstream

test/cli-statusline.test.ts

797 lines

import { describe, it, expect } from "vitest";
import { ClaudeCliStatuslineAdapter }
  from "../src/adapters/claude-cli/adapter";
import { upsertStatusLine, upsertSpinnerVerbs, parseable, readTopLevel }
  from "../src/adapters/claude-cli/settingsEdit";
import { parseClaudeCliVersion, supportsSpinnerVerbs, gte, SPINNER_VERBS_FLOOR }
  from "../src/adapters/claude-cli/cliVersion";
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, utimesSync,
         existsSync, rmSync }
  from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { writeCliAdCache, readCliAdCache, cliSessionActive, FRESH_MS,
         shouldCountCliImpression, shouldCountSpinnerImpression }
  from "../src/adapters/claude-cli/cliAd";
import { execFileSync } from "node:child_process";

const VAL = '{ "type": "command", "command": "node x", "padding": 0 }';

describe("settingsEdit.parseable", () => {
  it("true for plain JSON", () => {
    expect(parseable('{"a":1}')).toBe(true);
  });
  it("true for JSONC (line+block comments, trailing comma)", () => {
    expect(parseable('{\n // c\n "a":1, /* b */ "z":2,\n}')).toBe(true);
  });
  it("false for genuinely broken text", () => {
    expect(parseable('{ this is not json ')).toBe(false);
  });
});

describe("settingsEdit.upsertStatusLine", () => {
  it("inserts statusLine when absent, preserving other keys/format", () => {
    const src = '{\n  "model": "opus",\n  "theme": "dark"\n}\n';
    const out = upsertStatusLine(src, VAL);
    expect(out).toContain('"statusLine": ' + VAL);
    expect(out).toContain('"model": "opus"');
    expect(out).toContain('"theme": "dark"');
    expect(parseable(out)).toBe(true);
  });

  it("replaces ONLY the existing statusLine value, keeping comments intact", () => {
    const src =
      '{\n  // keep me\n  "statusLine": { "type":"command", "command":"old" },\n'
      + '  "model": "opus" /* and me */\n}\n';
    const out = upsertStatusLine(src, VAL);
    expect(out).toContain('"statusLine": ' + VAL);
    expect(out).not.toContain('"command":"old"');
    expect(out).toContain("// keep me");
    expect(out).toContain("/* and me */");
    expect(out).toContain('"model": "opus"');
    expect(parseable(out)).toBe(true);
  });

  it("idempotent: re-upsert with same value is a no-op", () => {
    const src = '{ "model": "x" }\n';
    const a = upsertStatusLine(src, VAL);
    const b = upsertStatusLine(a, VAL);
    expect(b).toBe(a);
  });

  it("throws on unparseable input (caller treats as ok:false)", () => {
    expect(() => upsertStatusLine("{ broken ", VAL)).toThrow();
  });
});

const SV = '{"mode":"replace","verbs":["Acme - Try Acme.com"]}';

describe("settingsEdit.upsertSpinnerVerbs", () => {
  it("inserts spinnerVerbs when absent, preserving other keys/format", () => {
    const src = '{\n  "model": "opus",\n  "theme": "dark"\n}\n';
    const out = upsertSpinnerVerbs(src, SV);
    expect(out).toContain('"spinnerVerbs": ' + SV);
    expect(out).toContain('"model": "opus"');
    expect(out).toContain('"theme": "dark"');
    expect(parseable(out)).toBe(true);
  });

  it("replaces ONLY the existing spinnerVerbs value, keeping comments intact", () => {
    const src =
      '{\n  // keep me\n  "spinnerVerbs": { "mode":"append", "verbs":["Old"] },\n'
      + '  "model": "opus" /* and me */\n}\n';
    const out = upsertSpinnerVerbs(src, SV);
    expect(out).toContain('"spinnerVerbs": ' + SV);
    expect(out).not.toContain('"Old"');
    expect(out).toContain("// keep me");
    expect(out).toContain("/* and me */");
    expect(out).toContain('"model": "opus"');
    expect(parseable(out)).toBe(true);
  });

  it("idempotent: re-upsert with same value is a no-op", () => {
    const src = '{ "model": "x" }\n';
    const a = upsertSpinnerVerbs(src, SV);
    const b = upsertSpinnerVerbs(a, SV);
    expect(b).toBe(a);
  });

  it("composes cleanly with upsertStatusLine (both keys land, both update)", () => {
    const src = '{\n  "model": "opus"\n}\n';
    let out = upsertStatusLine(src, VAL);
    out = upsertSpinnerVerbs(out, SV);
    expect(out).toContain('"statusLine": ' + VAL);
    expect(out).toContain('"spinnerVerbs": ' + SV);
    expect(out).toContain('"model": "opus"');
    expect(parseable(out)).toBe(true);
    // Replacing only spinnerVerbs leaves the statusLine untouched (no
    // accidental clobber across the two top-level upserts).
    const SV2 = '{"mode":"replace","verbs":["NewAd"]}';
    const out2 = upsertSpinnerVerbs(out, SV2);
    expect(out2).toContain('"statusLine": ' + VAL);
    expect(out2).toContain('"spinnerVerbs": ' + SV2);
    expect(out2).not.toContain('"Acme - Try Acme.com"');
  });
});

describe("settingsEdit.readTopLevel", () => {
  it("returns the parsed value of a top-level key (JSONC tolerated)", () => {
    const src = '{\n  // hud\n  "statusLine": { "type": "command",'
      + ' "command": "node hud.js", "padding": 0 },\n}\n';
    expect(readTopLevel(src, "statusLine"))
      .toEqual({ type: "command", command: "node hud.js", padding: 0 });
  });
  it("returns undefined when the key is absent", () => {
    expect(readTopLevel('{ "model": "x" }', "statusLine")).toBeUndefined();
  });
  it("returns undefined for unparseable text (never throws)", () => {
    expect(readTopLevel("{ broken ", "statusLine")).toBeUndefined();
  });
});

function tmp(): string { return mkdtempSync(join(tmpdir(), "vibe-cli-")); }

describe("cliAd cache", () => {
  it("writeCliAdCache then readCliAdCache round-trips with a ts", () => {
    const home = tmp();
    writeCliAdCache(home, { adText: "Acme", iconRef: "i", iconUrl: "", clickUrl: "https://a/x" });
    const c = readCliAdCache(home);
    expect(c?.adText).toBe("Acme");
    expect(c?.clickUrl).toBe("https://a/x");
    expect(typeof c?.ts).toBe("number");
  });
  it("readCliAdCache returns null when absent", () => {
    expect(readCliAdCache(tmp())).toBeNull();
  });
  it("writeCliAdCache strips control chars (ESC/OSC/BEL/CSI) from every persisted field", () => {
    const home = tmp();
    const ESC = "\u001b", BEL = "\u0007", CSI = "\u009b";
    writeCliAdCache(home, {
      adText: "Acme" + ESC + "]8;;https://evil" + BEL + " deploys" + CSI + "31m",
      iconRef: "i" + ESC, iconUrl: "u" + BEL,
      clickUrl: "https://a/x" + ESC + "]8;;" });
    const c = readCliAdCache(home);
    expect(c?.adText).toBe("Acme]8;;https://evil deploys31m");
    expect(c?.iconRef).toBe("i");
    expect(c?.iconUrl).toBe("u");
    expect(c?.clickUrl).toBe("https://a/x]8;;");
  });
  it("writeCliAdCache is PERMISSIVE: emoji/pipes/unicode/URLs round-trip byte-identical", () => {
    const home = tmp();
    const line = "Déployez 🚀 | ai.dev — détails: https://a/?q=1&r=2";
    writeCliAdCache(home, { adText: line, iconRef: "i", iconUrl: "",
      clickUrl: "https://a/x" });
    expect(readCliAdCache(home)?.adText).toBe(line);
  });
});

// Transcript line tagged with a CC `entrypoint` ("cli" = terminal session,
// "claude-vscode" = the VS Code panel's own; untagged = older CC builds).
const taggedRec = (entrypoint: string): string =>
  JSON.stringify({ type: "user",
    message: { role: "user", content: "hi" }, entrypoint }) + "\n";

describe("cliSessionActive", () => {
  it("true when a recent transcript exists in <projectsRoot>", () => {
    const root = tmp();
    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
    const f = join(proj, "s.jsonl"); writeFileSync(f, "{}");
    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(true);
  });
  it("false when newest transcript is older than the window", () => {
    const root = tmp();
    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
    const f = join(proj, "s.jsonl"); writeFileSync(f, "{}");
    const old = (Date.now() - FRESH_MS - 60_000) / 1000;
    utimesSync(f, old, old);
    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(false);
  });
  it("false when projects root is absent", () => {
    expect(cliSessionActive(Date.now(), FRESH_MS, join(tmp(), "nope"))).toBe(false);
  });
  // Audit #30: the VS Code panel writes the SAME tree (entrypoint
  // "claude-vscode") — editor turns must not count as a live CLI session,
  // else statusline/spinner impressions are recorded for an unrendered surface.
  it("false when the only recent transcript is the VS Code panel's own", () => {
    const root = tmp();
    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
    writeFileSync(join(proj, "s.jsonl"), taggedRec("claude-vscode"));
    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(false);
  });
  it("true for a recent genuine terminal-CLI transcript (entrypoint cli)", () => {
    const root = tmp();
    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
    writeFileSync(join(proj, "s.jsonl"), taggedRec("cli"));
    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(true);
  });
  it("true when a recent CLI transcript coexists with a recent vscode one", () => {
    const root = tmp();
    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
    writeFileSync(join(proj, "vscode.jsonl"), taggedRec("claude-vscode"));
    writeFileSync(join(proj, "cli.jsonl"), taggedRec("cli"));
    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(true);
  });
  it("false when the CLI transcript is stale and only the vscode one is recent", () => {
    const root = tmp();
    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
    writeFileSync(join(proj, "vscode.jsonl"), taggedRec("claude-vscode"));
    const cli = join(proj, "cli.jsonl"); writeFileSync(cli, taggedRec("cli"));
    const old = (Date.now() - FRESH_MS - 60_000) / 1000;
    utimesSync(cli, old, old);
    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(false);
  });
  // 2026-06-11: headless SDK / desktop-agent sessions write the same tree
  // with their own entrypoint tags. They render no statusline — counting
  // them emitted impressions for an unseen surface while the tick loop
  // (strictly cli-or-untagged) stayed silent. Both signals now accept the
  // same set: "cli" | untagged.
  it("false when the only recent transcripts carry other positive tags (sdk/desktop)", () => {
    const root = tmp();
    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
    writeFileSync(join(proj, "sdk.jsonl"), taggedRec("sdk-ts"));
    writeFileSync(join(proj, "desktop.jsonl"), taggedRec("desktop"));
    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(false);
  });
});

function renderScript(cachePath: string, freshMs: number,
                      prevPath = join(tmpdir(), "vibe-prev-absent.json"),
                      chainTimeoutMs = 5000): string {
  const tpl = readFileSync(join(__dirname,
    "../src/adapters/claude-cli/statusline.asset.mjs"), "utf8");
  return tpl
    .split("__VIBE_ADS_CLI_AD_PATH__").join(JSON.stringify(cachePath))
    .split("__VIBE_ADS_CLI_PREV_PATH__").join(JSON.stringify(prevPath))
    .split("__VIBE_ADS_FRESH_MS__").join(String(freshMs))
    .split("__VIBE_ADS_SCRIPT_NAME__")
    .join(JSON.stringify("vibe-ads-statusline.mjs"))
    .split("__VIBE_ADS_CHAIN_TIMEOUT_MS__").join(String(chainTimeoutMs));
}
function runScript(body: string, input?: string): string {
  const d = tmp();
  const p = join(d, "s.mjs");
  writeFileSync(p, body, "utf8");
  return execFileSync(process.execPath, [p],
    input === undefined ? { encoding: "utf8" } : { encoding: "utf8", input });
}

describe("statusline.asset script", () => {
  it("prints a real OSC 8 hyperlink (ESC bytes) + marker + adText for a fresh cache", () => {
    const d = tmp();
    const cache = join(d, "cli-ad.json");
    writeFileSync(cache, JSON.stringify({ adText: "Acme deploys",
      iconRef: "i", iconUrl: "", clickUrl: "https://acme/x", ts: Date.now() }));
    const out = runScript(renderScript(cache, FRESH_MS));
    const ESC = "";
    expect(out).toContain("ad· Acme deploys");                       // visible text + marker
    expect(out).toContain(ESC + "]8;;https://acme/x" + ESC + "\\");  // hyperlink open + ST
    expect(out).toContain(ESC + "]8;;" + ESC + "\\");                // hyperlink close + ST
    expect(out.startsWith(ESC + "]8;;")).toBe(true);                 // begins with the escape
  })
  it("prints nothing for a stale cache", () => {
    const d = tmp();
    const cache = join(d, "cli-ad.json");
    writeFileSync(cache, JSON.stringify({ adText: "Old", iconRef: "i",
      clickUrl: "https://a/x", ts: Date.now() - FRESH_MS - 1000 }));
    expect(runScript(renderScript(cache, FRESH_MS)).trim()).toBe("");
  });
  it("prints nothing when cache missing", () => {
    expect(runScript(renderScript(join(tmp(), "nope.json"),
      FRESH_MS)).trim()).toBe("");
  });
  it("prints nothing (exit 0) for malformed cache", () => {
    const d = tmp();
    const cache = join(d, "cli-ad.json");
    writeFileSync(cache, "{ not json");
    expect(runScript(renderScript(cache, FRESH_MS)).trim()).toBe("");
  });
  it("strips control bytes from adText/clickUrl before the OSC 8 wrap (defense-in-depth vs tampered cache)", () => {
    const d = tmp();
    const cache = join(d, "cli-ad.json");
    const ESC = "\u001b", BEL = "\u0007";
    // Bypass writeCliAdCache: simulate a tampered/legacy cache carrying raw
    // escape bytes in BOTH fields (write boundary defeated).
    writeFileSync(cache, JSON.stringify({
      adText: "Acme" + ESC + "]8;;https://evil" + ESC + "\\spoof" + BEL,
      iconRef: "i", iconUrl: "",
      clickUrl: "https://acme/x" + ESC + "]8;;", ts: Date.now() }));
    const out = runScript(renderScript(cache, FRESH_MS));
    // The ONLY escapes emitted are the script's own 4 OSC 8 framing bytes…
    expect(out.split(ESC).length - 1).toBe(4);
    // …the injected sequences survive only as inert de-escaped text.
    expect(out).toContain("ad· Acme]8;;https://evil\\spoof");
    expect(out).toContain(ESC + "]8;;https://acme/x]8;;" + ESC + "\\");
  });
  it("passes emoji / pipes / unicode through byte-identical (strip is control-chars ONLY)", () => {
    const d = tmp();
    const cache = join(d, "cli-ad.json");
    const line = "Déployez 🚀 | ai.dev — vite";
    writeFileSync(cache, JSON.stringify({ adText: line, iconRef: "i",
      iconUrl: "", clickUrl: "https://acme/x", ts: Date.now() }));
    const out = runScript(renderScript(cache, FRESH_MS));
    expect(out).toContain("ad· " + line);
  });
});

function prevFileWith(cmd: string): string {
  const p = join(tmp(), "cli-prev-statusline.json");
  writeFileSync(p, JSON.stringify(
    { statusLine: { type: "command", command: cmd, padding: 0 } }));
  return p;
}
function freshCache(): string {
  const cache = join(tmp(), "cli-ad.json");
  writeFileSync(cache, JSON.stringify({ adText: "Acme deploys", iconRef: "i",
    iconUrl: "", clickUrl: "https://acme/x", ts: Date.now() }));
  return cache;
}

describe("statusline.asset script — chained previous statusLine", () => {
  it("prints the ad line ABOVE the chained command's output", () => {
    const prev = prevFileWith("echo HUD-LINE");
    const out = runScript(renderScript(freshCache(), FRESH_MS, prev), "");
    const nl = out.indexOf("\n");
    expect(nl).toBeGreaterThan(0);
    expect(out.slice(0, nl)).toContain("ad· Acme deploys");
    expect(out.slice(nl + 1)).toBe("HUD-LINE");
  });
  it("prints ONLY the chained output when the ad cache is stale or missing", () => {
    const prev = prevFileWith("echo HUD-LINE");
    const out = runScript(
      renderScript(join(tmp(), "nope.json"), FRESH_MS, prev), "");
    expect(out).toBe("HUD-LINE");
  });
  it("forwards stdin to the chained command (CC pipes the session JSON)", () => {
    const prev = prevFileWith(
      `node -e "process.stdout.write(require('fs').readFileSync(0,'utf8'))"`);
    const json = JSON.stringify({ model: { display_name: "Opus" } });
    const out = runScript(renderScript(freshCache(), FRESH_MS, prev), json);
    expect(out.slice(out.indexOf("\n") + 1)).toBe(json);
  });
  it("ad-only when the prev file is malformed (never breaks the CLI)", () => {
    const p = join(tmp(), "cli-prev-statusline.json");
    writeFileSync(p, "{ not json");
    const out = runScript(renderScript(freshCache(), FRESH_MS, p), "");
    expect(out).toContain("ad· Acme deploys");
    expect(out).not.toContain("\n");
  });
  it("ad-only when the captured command points at ourselves (self-spawn guard)", () => {
    const prev = prevFileWith("node /x/vibe-ads-statusline.mjs");
    const out = runScript(renderScript(freshCache(), FRESH_MS, prev), "");
    expect(out).toContain("ad· Acme deploys");
    expect(out).not.toContain("\n");
  });
  it("ad-only when the chained command exits nonzero with no output", () => {
    const prev = prevFileWith(`node -e "process.exit(3)"`);
    const out = runScript(renderScript(freshCache(), FRESH_MS, prev), "");
    expect(out).toContain("ad· Acme deploys");
    expect(out).not.toContain("\n");
  });
  it("exits with ad-only when the chained command outlives the deadline " +
     "(wedged HUD must not hang CC)", () => {
    // Chained command sleeps far past the 400ms deadline; the script must
    // exit at the deadline with the ad line, not wait for the child.
    const prev = prevFileWith(`node -e "setTimeout(()=>{}, 30000)"`);
    const t0 = Date.now();
    const out = runScript(
      renderScript(freshCache(), FRESH_MS, prev, 400), "");
    expect(Date.now() - t0).toBeLessThan(10_000);
    expect(out).toContain("ad· Acme deploys");
    expect(out).not.toContain("\n");
  });

  it("exits promptly when a GRANDCHILD squats on the stdout pipe after the " +
     "shell exits (spawnSync-hang regression guard)", () => {
    // Shell spawns a detached long-lived grandchild inheriting stdio, prints
    // one line, and exits. 'close' never fires while the grandchild holds
    // the pipe; the exit-drain grace must still flush the HUD line and exit.
    const grand = "const cp=require('node:child_process');"
      + "cp.spawn(process.execPath,['-e','setTimeout(()=>{},30000)'],"
      + "{detached:true,stdio:'inherit'}).unref();"
      + "console.log('HUD-LINE');";
    const prev = prevFileWith(
      `node -e ${JSON.stringify(grand)}`);
    const t0 = Date.now();
    const out = runScript(
      renderScript(freshCache(), FRESH_MS, prev, 5000), "");
    expect(Date.now() - t0).toBeLessThan(4_000);
    expect(out.split("\n")[1]).toBe("HUD-LINE");
  });

  it("prints nothing when there is no ad AND no captured command", () => {
    const out = runScript(
      renderScript(join(tmp(), "no-cache.json"), FRESH_MS), "");
    expect(out).toBe("");
  });
});

const P = { tier: 0 as const, adText: "Acme", iconRef: "i", iconUrl: "",
  clickToken: "", clickUrl: "https://acme/x", corr: "cli.abc", loopbackPort: 0,
  loopbackToken: "", loopbackBase: "" };

function homeWithClaude(): { home: string; settings: string } {
  const home = tmp();
  mkdirSync(join(home, ".claude"), { recursive: true });
  const settings = join(home, ".claude", "settings.json");
  return { home, settings };
}
function parseableFile(p: string): boolean {
  try { JSON.parse(readFileSync(p, "utf8")); return true; } catch { return false; }
}

describe("ClaudeCliStatuslineAdapter", () => {
  it("preflight: compatible for parseable, absent, JSONC; incompatible for broken", () => {
    const { settings } = homeWithClaude();
    writeFileSync(settings, '{ "model":"x" }');
    expect(new ClaudeCliStatuslineAdapter(settings).preflight().compatible).toBe(true);
    rmSync(settings);
    expect(new ClaudeCliStatuslineAdapter(settings).preflight().compatible).toBe(true);
    writeFileSync(settings, '{ /* c */ "a":1, }');
    expect(new ClaudeCliStatuslineAdapter(settings).preflight().compatible).toBe(true);
    writeFileSync(settings, '{ broken ');
    const pf = new ClaudeCliStatuslineAdapter(settings).preflight();
    expect(pf.compatible).toBe(false);
    expect(pf.reason).toMatch(/not parseable/);
  });

  it("applyPatch installs statusLine + script, backs up pristine, idempotent", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings, '{\n  "model": "opus"\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    expect(a.applyPatch(P).ok).toBe(true);
    const s = readFileSync(settings, "utf8");
    expect(s).toContain('"statusLine"');
    expect(s).toContain('"model": "opus"');
    expect(existsSync(settings + ".vibe-ads-backup")).toBe(true);
    expect(readFileSync(settings + ".vibe-ads-backup", "utf8"))
      .toBe('{\n  "model": "opus"\n}\n');
    expect(existsSync(join(home, ".vibe-ads",
      "vibe-ads-statusline.mjs"))).toBe(true);
    const after1 = readFileSync(settings, "utf8");
    a.applyPatch(P);
    expect(readFileSync(settings, "utf8")).toBe(after1);
  });

  it("applyPatch installs BOTH statusLine AND spinnerVerbs when supported", () => {
    // The CLI adapter writes two surfaces: statusLine (OSC 8 clickable
    // hyperlink) and spinnerVerbs (the ad text in the thinking-shimmer verb
    // slot). spinnerVerbs is gated on `spinnerVerbsSupported`, which defaults
    // to true (fail-open) until async version detection resolves.
    const { settings } = homeWithClaude();
    writeFileSync(settings, '{\n  "model": "opus"\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    expect(a.spinnerVerbsSupported).toBe(true);
    expect(a.applyPatch(P).ok).toBe(true);
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.spinnerVerbs).toBeDefined();
    expect(parsed.spinnerVerbs.mode).toBe("replace");
    expect(parsed.spinnerVerbs.verbs).toEqual(["Acme"]);
    expect(parsed.statusLine?.type).toBe("command");
    expect(parsed.model).toBe("opus");
  });

  it("applyPatch REPLACES any pre-existing spinnerVerbs with current ad", () => {
    const { settings } = homeWithClaude();
    writeFileSync(settings,
      '{\n  "spinnerVerbs": { "mode": "replace", "verbs": ["StaleAd"] }\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    expect(a.applyPatch(P).ok).toBe(true);
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.spinnerVerbs.mode).toBe("replace");
    expect(parsed.spinnerVerbs.verbs).toEqual(["Acme"]);
    expect(parsed.spinnerVerbs.verbs).not.toContain("StaleAd");
    expect(parsed.statusLine?.type).toBe("command");
  });

  it("applyPatch EVICTS spinnerVerbs when the CLI does not support it", () => {
    // On a pre-2.1.143 CLI (spinnerVerbsSupported=false) the key is a dead
    // no-op, so we remove it (and heal any stale entry) — statusLine only.
    const { settings } = homeWithClaude();
    writeFileSync(settings,
      '{\n  "spinnerVerbs": { "mode": "replace", "verbs": ["StaleAd"] }\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.spinnerVerbsSupported = false;
    expect(a.applyPatch(P).ok).toBe(true);
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.spinnerVerbs).toBeUndefined();
    expect(parsed.statusLine?.type).toBe("command");
  });

  it("applyPatch refuses to touch an unparseable settings.json", () => {
    const { settings } = homeWithClaude();
    writeFileSync(settings, "{ broken ");
    const r = new ClaudeCliStatuslineAdapter(settings).applyPatch(P);
    expect(r.ok).toBe(false);
    expect(r.reason).toMatch(/not parseable/);
    expect(readFileSync(settings, "utf8")).toBe("{ broken ");
    expect(existsSync(settings + ".vibe-ads-backup")).toBe(false);
  });

  it("creates settings.json + ~/.claude when absent; restore deletes it", () => {
    const home = tmp();
    const settings = join(home, ".claude", "settings.json");
    const a = new ClaudeCliStatuslineAdapter(settings);
    expect(a.applyPatch(P).ok).toBe(true);
    expect(existsSync(settings)).toBe(true);
    expect(parseableFile(settings)).toBe(true);
    const r = a.restore();
    expect(r.restored).toBe(true);
    expect(existsSync(settings)).toBe(false);
    expect(existsSync(settings + ".vibe-ads-backup")).toBe(false);
  });

  it("restore reverts an existing file byte-exact and removes script", () => {
    const { home, settings } = homeWithClaude();
    const pristine = '{\n  "model": "opus"\n}\n';
    writeFileSync(settings, pristine);
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.applyPatch(P);
    const patched = readFileSync(settings, "utf8");
    expect(patched).toContain('"statusLine"');
    expect(patched).toContain('"spinnerVerbs"');
    expect(a.restore().restored).toBe(true);
    expect(readFileSync(settings, "utf8")).toBe(pristine);
    expect(existsSync(join(home, ".vibe-ads",
      "vibe-ads-statusline.mjs"))).toBe(false);
    expect(existsSync(settings + ".vibe-ads-backup")).toBe(false);
  });

  it("restore is a no-op when no backup present", () => {
    const { settings } = homeWithClaude();
    writeFileSync(settings, "{}");
    const r = new ClaudeCliStatuslineAdapter(settings).restore();
    expect(r.ok).toBe(true);
    expect(r.restored).toBe(false);
    expect(r.reason).toMatch(/no backup/);
  });

  it("spinnerVerbs value reflects the PatchParams adText", () => {
    const { settings } = homeWithClaude();
    writeFileSync(settings, '{\n  "model": "opus"\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    const customP = { ...P, adText: "Try Ramp.com — Free for 30 days" };
    expect(a.applyPatch(customP).ok).toBe(true);
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.spinnerVerbs.mode).toBe("replace");
    expect(parsed.spinnerVerbs.verbs).toEqual(["Try Ramp.com — Free for 30 days"]);
  });

  it("applyPatch is idempotent: second call with same ad does not re-write", () => {
    const { settings } = homeWithClaude();
    writeFileSync(settings, '{\n  "model": "opus"\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.applyPatch(P);
    const after1 = readFileSync(settings, "utf8");
    expect(after1).toContain('"spinnerVerbs"');
    a.applyPatch(P);
    expect(readFileSync(settings, "utf8")).toBe(after1);
  });
});

const HUD = '{ "type": "command", "command": "node /x/hud.js", "padding": 1 }';

describe("ClaudeCliStatuslineAdapter chain-capture", () => {
  const prevFile = (home: string): string =>
    join(home, ".vibe-ads", "cli-prev-statusline.json");

  it("applyPatch captures a pre-existing foreign statusLine", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings,
      '{\n  "model": "opus",\n  "statusLine": ' + HUD + '\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    expect(a.applyPatch(P).ok).toBe(true);
    const prev = JSON.parse(readFileSync(prevFile(home), "utf8"));
    expect(prev.statusLine)
      .toEqual({ type: "command", command: "node /x/hud.js", padding: 1 });
    // The live slot now holds OUR command…
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.statusLine.command).toContain("vibe-ads-statusline.mjs");
    // …and the installed script points at the capture file.
    const script = readFileSync(join(home, ".vibe-ads",
      "vibe-ads-statusline.mjs"), "utf8");
    expect(script).toContain("cli-prev-statusline.json");
  });

  it("re-apply over our own slot leaves the capture untouched (60s tick)", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings, '{ "statusLine": ' + HUD + ' }');
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.applyPatch(P);
    a.applyPatch(P);
    const prev = JSON.parse(readFileSync(prevFile(home), "utf8"));
    expect(prev.statusLine.command).toBe("node /x/hud.js");
  });

  it("writes no capture file when there was no pre-existing statusLine", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings, '{ "model": "opus" }');
    new ClaudeCliStatuslineAdapter(settings).applyPatch(P);
    expect(existsSync(prevFile(home))).toBe(false);
  });

  it("restore puts the captured statusLine BACK instead of dropping the key", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings,
      '{\n  "model": "opus",\n  "statusLine": ' + HUD + '\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.applyPatch(P);
    expect(a.restore().restored).toBe(true);
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.statusLine)
      .toEqual({ type: "command", command: "node /x/hud.js", padding: 1 });
    expect(parsed.model).toBe("opus");
    expect(parsed.spinnerVerbs).toBeUndefined();
    expect(existsSync(prevFile(home))).toBe(false);
  });

  it("KEEPS the capture when the statusLine key vanishes (deleting the ad " +
     "entry must not forget the HUD)", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings, '{ "statusLine": ' + HUD + ' }');
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.applyPatch(P);
    expect(existsSync(prevFile(home))).toBe(true);
    // While installed the slot holds OUR command — a deleted key means the
    // user removed the AD entry, not their own HUD.
    writeFileSync(settings, '{ "model": "opus" }');
    a.applyPatch(P);
    expect(existsSync(prevFile(home))).toBe(true);
    // …so restore still puts their HUD back.
    expect(a.restore().restored).toBe(true);
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.statusLine.command).toBe("node /x/hud.js");
  });

  it("keeps the capture when settings.json is transiently ABSENT at a tick", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings, '{ "statusLine": ' + HUD + ' }');
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.applyPatch(P);
    rmSync(settings);                      // mid-rewrite / sync-tool window
    a.applyPatch(P);
    expect(existsSync(prevFile(home))).toBe(true);
    const prev = JSON.parse(readFileSync(prevFile(home), "utf8"));
    expect(prev.statusLine.command).toBe("node /x/hud.js");
  });

  it("restore falls back to the first-apply snapshot when the capture file " +
     "is missing", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings,
      '{\n  "model": "opus",\n  "statusLine": ' + HUD + '\n}\n');
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.applyPatch(P);
    rmSync(prevFile(home));                // cleared ~/.vibe-ads, AV tooling…
    expect(a.restore().restored).toBe(true);
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.statusLine)
      .toEqual({ type: "command", command: "node /x/hud.js", padding: 1 });
  });

  it("restore leaves a FOREIGN statusLine untouched (user hand-installed a " +
     "newer one after capture)", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings, '{ "statusLine": ' + HUD + ' }');
    const a = new ClaudeCliStatuslineAdapter(settings);
    a.applyPatch(P);
    // User replaces our entry with HUD-B before any re-capture tick runs.
    const hudB = '{ "type": "command", "command": "node /x/hud-b.js" }';
    writeFileSync(settings, '{ "statusLine": ' + hudB + ' }');
    expect(a.restore().restored).toBe(true);
    const parsed = JSON.parse(readFileSync(settings, "utf8"));
    expect(parsed.statusLine.command).toBe("node /x/hud-b.js");
    expect(existsSync(prevFile(home))).toBe(false);   // capture still cleaned
  });

  it("END-TO-END: the installed script stacks the ad above the captured HUD", () => {
    const { home, settings } = homeWithClaude();
    writeFileSync(settings,
      '{ "statusLine": { "type": "command", "command": "echo HUD-LINE" } }');
    new ClaudeCliStatuslineAdapter(settings).applyPatch(P);
    writeCliAdCache(home, { adText: "Acme deploys", iconRef: "i",
      iconUrl: "", clickUrl: "https://acme/x" });
    const out = execFileSync(process.execPath,
      [join(home, ".vibe-ads", "vibe-ads-statusline.mjs")],
      { encoding: "utf8", input: "{}" });
    const nl = out.indexOf("\n");
    expect(out.slice(0, nl)).toContain("ad· Acme deploys");
    expect(out.slice(nl + 1)).toBe("HUD-LINE");
  });
});

describe("shouldCountCliImpression", () => {
  const base = { signedIn: true, haveAd: true, sessionActive: true,
                 adId: "ad1", lastCountedAdId: null as string | null };
  it("true when signed-in + ad + active session + new adId", () => {
    expect(shouldCountCliImpression(base)).toBe(true);
  });
  it("false when not signed in", () => {
    expect(shouldCountCliImpression({ ...base, signedIn: false })).toBe(false);
  });
  it("false when no active CLI session", () => {
    expect(shouldCountCliImpression({ ...base, sessionActive: false })).toBe(false);
  });
  it("false when this adId was already counted", () => {
    expect(shouldCountCliImpression({ ...base, lastCountedAdId: "ad1" }))
      .toBe(false);
  });
  it("true again for a different adId", () => {
    expect(shouldCountCliImpression({ ...base, lastCountedAdId: "ad0" }))
      .toBe(true);
  });
});

describe("shouldCountSpinnerImpression", () => {
  const base = { supportConfirmed: true, signedIn: true, haveAd: true,
                 sessionActive: true, adId: "ad1",
                 lastCountedAdId: null as string | null };
  it("true when support confirmed + signed-in + ad + active + new adId", () => {
    expect(shouldCountSpinnerImpression(base)).toBe(true);
  });
  it("false when support is NOT confirmed, even if all else is satisfied", () => {
    // The over-count guard: the first synchronous activation sync runs before
    // `claude --version` resolves, so spinner billing must stay off until
    // support is positively confirmed — never bill for an unrendered verb.
    expect(shouldCountSpinnerImpression({ ...base, supportConfirmed: false }))
      .toBe(false);
  });
  it("false when not signed in", () => {
    expect(shouldCountSpinnerImpression({ ...base, signedIn: false }))
      .toBe(false);
  });
  it("false when no active CLI session", () => {
    expect(shouldCountSpinnerImpression({ ...base, sessionActive: false }))
      .toBe(false);
  });
  it("false when this adId was already counted (dedup)", () => {
    expect(shouldCountSpinnerImpression({ ...base, lastCountedAdId: "ad1" }))
      .toBe(false);
  });
  it("true again for a different adId", () => {
    expect(shouldCountSpinnerImpression({ ...base, lastCountedAdId: "ad0" }))
      .toBe(true);
  });
  it("is independent of the statusline counter (separate dedup keys)", () => {
    // statusline already counted ad1; spinner counter still null → spinner
    // should still fire for ad1 (two distinct surfaces, two counters).
    expect(shouldCountCliImpression({ signedIn: true, haveAd: true,
      sessionActive: true, adId: "ad1", lastCountedAdId: "ad1" })).toBe(false);
    expect(shouldCountSpinnerImpression({ ...base, lastCountedAdId: null }))
      .toBe(true);
  });
});

describe("cliVersion.parseClaudeCliVersion", () => {
  it("parses a real `claude --version` line", () => {
    expect(parseClaudeCliVersion("2.1.158 (Claude Code)")).toEqual([2, 1, 158]);
  });
  it("parses a bare semver", () => {
    expect(parseClaudeCliVersion("2.1.143")).toEqual([2, 1, 143]);
  });
  it("returns null when no semver is present", () => {
    expect(parseClaudeCliVersion("Claude Code")).toBeNull();
    expect(parseClaudeCliVersion("")).toBeNull();
  });
});

describe("cliVersion.gte / supportsSpinnerVerbs", () => {
  it("floor is 2.1.143", () => {
    expect(SPINNER_VERBS_FLOOR).toEqual([2, 1, 143]);
  });
  it("gte orders by major, minor, patch", () => {
    expect(gte([2, 1, 143], [2, 1, 143])).toBe(true);   // equal
    expect(gte([2, 1, 158], [2, 1, 143])).toBe(true);   // patch higher
    expect(gte([2, 2, 0], [2, 1, 143])).toBe(true);     // minor higher
    expect(gte([3, 0, 0], [2, 1, 143])).toBe(true);     // major higher
    expect(gte([2, 1, 142], [2, 1, 143])).toBe(false);  // patch lower
    expect(gte([2, 0, 999], [2, 1, 143])).toBe(false);  // minor lower
  });
  it("supportsSpinnerVerbs gates on the floor", () => {
    expect(supportsSpinnerVerbs([2, 1, 158])).toBe(true);   // Andrew's CLI
    expect(supportsSpinnerVerbs([2, 1, 143])).toBe(true);   // exact floor
    expect(supportsSpinnerVerbs([2, 1, 142])).toBe(false);  // just below
    expect(supportsSpinnerVerbs([2, 0, 44])).toBe(false);   // 2.0.x
    expect(supportsSpinnerVerbs(null)).toBe(false);         // unparseable
  });
});