← back to Kickbacks Ai Upstream
test/adapter.test.ts
448 lines
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync }
from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ClaudeCodeAdapter, resolveBlockAsset }
from "../src/adapters/claude-code/adapter";
const FIX = readFileSync(join(__dirname, "fixtures/synthetic-index.js"), "utf8");
function tmpTarget(): string {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-"));
const p = join(d, "index.js");
writeFileSync(p, FIX, "utf8");
return p;
}
const params = {
tier: 3 as const, adText: "Ramp corporate cards & expense mgmt",
iconRef: "icon.r", iconUrl: "", clickToken: "ck", clickUrl: "https://ramp.example/lp", corr: "ad1.tst",
loopbackPort: 5555, loopbackToken: "lt",
loopbackBase: "http://127.0.0.1:5555",
};
describe("ClaudeCodeAdapter", () => {
let target: string;
beforeEach(() => { target = tmpTarget(); });
it("preflight compatible on a fixture with a >=2-elem anchored array", () => {
const a = new ClaudeCodeAdapter(target);
const pf = a.preflight();
expect(pf.compatible).toBe(true);
expect(pf.version).not.toBeNull();
});
it("version(): strips CC's platform-specific packaging suffix", () => {
// CC moved to platform packages (…-win32-x64); the label must read the
// bare semver, not "2.1.161-win32-x64" — across every platform/arch combo.
const v = (dir: string) =>
new ClaudeCodeAdapter(`/x/${dir}/webview/index.js`).version();
expect(v("anthropic.claude-code-2.1.161-win32-x64")).toBe("2.1.161");
expect(v("anthropic.claude-code-2.1.161-darwin-arm64")).toBe("2.1.161");
expect(v("anthropic.claude-code-2.1.180-linux-x64")).toBe("2.1.180");
expect(v("anthropic.claude-code-2.1.143")).toBe("2.1.143"); // legacy, no suffix
});
it("version(): 'unknown' when the path has no claude-code dir segment", () => {
// The no-target sentinel path resolves to no semver — never throw, never
// mislabel.
expect(new ClaudeCodeAdapter("/__vibe_ads_no_target__").version())
.toBe("unknown");
});
it("isPatched(): false pristine, true after applyPatch, false after restore",
() => {
const a = new ClaudeCodeAdapter(target);
expect(a.isPatched()).toBe(false);
a.applyPatch(params);
expect(a.isPatched()).toBe(true);
a.restore();
expect(a.isPatched()).toBe(false);
});
it("diagnose(): compatible fixture reports live array + bare verb present", () => {
const d = new ClaudeCodeAdapter(target).diagnose();
expect(d.compatible).toBe(true);
expect(d.targetExists).toBe(true);
expect(d.live.hasArray).toBe(true);
expect(d.live.bareVerbPresent).toBe(true);
});
it("diagnose(): stripped file reports incompatible, no array, no bare verb (→ reinstall CC)", () => {
writeFileSync(target, 'var V=["Alpha","Bravo"];', "utf8");
const d = new ClaudeCodeAdapter(target).diagnose();
expect(d.compatible).toBe(false);
expect(d.live.hasArray).toBe(false);
expect(d.live.bareVerbPresent).toBe(false);
});
it("diagnose(): verb word present but not in an array (→ bundle-format change)", () => {
writeFileSync(target, 'var msg="Discombobulating the widgets";', "utf8");
const d = new ClaudeCodeAdapter(target).diagnose();
expect(d.compatible).toBe(false);
expect(d.live.hasArray).toBe(false);
expect(d.live.bareVerbPresent).toBe(true);
});
it("findArray is resilient: locates the array via an alternate verb when Discombobulating is gone", () => {
// Simulate a future CC that renamed/removed "Discombobulating" but kept the
// verb array (with other distinctive verbs). Detection must survive.
writeFileSync(target,
'var Gz1=["Working","Flibbertigibbeting","Brewing","Clauding","Cooking"];', "utf8");
expect(new ClaudeCodeAdapter(target).preflight().compatible).toBe(true);
});
it("preflight still incompatible when NONE of the anchor verbs are present", () => {
writeFileSync(target, 'var V=["Alpha","Bravo","Charlie","Delta"];', "utf8");
expect(new ClaudeCodeAdapter(target).preflight().compatible).toBe(false);
});
it("preflight falls back to the live file when the backup is stale (no verb array)", () => {
// Field repro (shipuser machine): a tainted/truncated .kickbacks-backup
// lacks the verb array, so preflight USED to read it and dead-end as
// incompatible even though the live CC build is fine. It must now fall back
// to the live file and report compatible.
const a = new ClaudeCodeAdapter(target);
writeFileSync(target + ".kickbacks-backup", "garbage without the anchor", "utf8");
expect(a.preflight().compatible).toBe(true);
});
it("applyPatch recaptures a clean backup when the existing one is stale", () => {
const a = new ClaudeCodeAdapter(target);
const bak = target + ".kickbacks-backup";
writeFileSync(bak, "garbage without the anchor", "utf8");
const res = a.applyPatch(params);
expect(res.ok).toBe(true);
expect(a.isPatched()).toBe(true);
// The stale backup was replaced by a fresh capture from the live file, so
// the pristine source once again carries the verb array.
expect(readFileSync(bak, "utf8")).toContain('"Discombobulating"');
});
it("preflight incompatible only when BOTH backup and live lack the array", () => {
const a = new ClaudeCodeAdapter(target);
writeFileSync(target, 'var V=["A","B","C"];', "utf8"); // live: no anchor
writeFileSync(target + ".kickbacks-backup", "also no anchor", "utf8"); // backup: no anchor
const pf = a.preflight();
expect(pf.compatible).toBe(false);
expect(pf.reason).toContain("verb array not found");
});
it("preflight incompatible (graceful) when anchor missing — no writes", () => {
writeFileSync(target, 'var V=["A","B","C"];', "utf8");
const a = new ClaudeCodeAdapter(target);
expect(a.preflight().compatible).toBe(false);
expect(readFileSync(target, "utf8")).toBe('var V=["A","B","C"];'); // untouched
});
// Self-heal regression: deactivate() is best-effort, and a *different*
// extension version can leave the file patched. On next load preflight()
// reads the live (already-patched) file whose anchor our own Tier-0 swap
// removed — it must NOT dead-end as "incompatible". A co-located pristine
// backup proves THIS build was compatible, so preflight evaluates against
// it (same source applyPatch() already uses). Without this the only
// recovery is a manual debug-menu off→on toggle.
it("preflight self-heals: compatible when the live file is already-patched but a pristine backup exists", () => {
const a = new ClaudeCodeAdapter(target);
a.applyPatch(params); // live file now patched: block appended after the verb array
const patched = readFileSync(target, "utf8");
// Sanity: CC's native verb dictionary is INTACT now (we no longer
// collapse it to [adText,""]). The visible failure when our block
// fails is then CC's normal "Discombobulating…" cycling — a clear
// signal that the overlay didn't render, instead of a plain-text
// ad that silently masks block.desync.
expect(patched).toContain('"Discombobulating"');
expect(patched).toContain('/* VIBE-ADS-START */');
expect(existsSync(target + ".kickbacks-backup")).toBe(true);
const pf = a.preflight();
expect(pf.compatible).toBe(true);
expect(pf.version).not.toBeNull();
});
it("applyPatch writes a one-time backup, swaps the array, appends one block, idempotent", () => {
const a = new ClaudeCodeAdapter(target);
expect(a.applyPatch(params).ok).toBe(true);
const after1 = readFileSync(target, "utf8");
expect(existsSync(target + ".kickbacks-backup")).toBe(true);
expect(after1).toContain(params.adText);
expect(after1).toContain("/* VIBE-ADS-START */");
expect((after1.match(/VIBE-ADS-START/g) || []).length).toBe(1);
a.applyPatch(params); // idempotent
expect(readFileSync(target, "utf8")).toBe(after1);
});
it("restore is byte-exact and honest", () => {
const a = new ClaudeCodeAdapter(target);
a.applyPatch(params);
const r = a.restore();
expect(r.restored).toBe(true);
expect(readFileSync(target, "utf8")).toBe(FIX); // byte-exact
expect(existsSync(target + ".kickbacks-backup")).toBe(false);
const r2 = a.restore(); // honest: nothing to restore
expect(r2.restored).toBe(false);
expect(r2.reason).toMatch(/no backup/i);
});
// Reversibility across the Vibe-Ads rename: an install patched BEFORE the
// rename carries a ".vibads-backup" pristine file and a /* VIBADS-START */
// block. restore() must still find that backup, and re-apply must strip the
// legacy block instead of stacking a second one.
it("restores a pre-rename install from the legacy .vibads-backup", () => {
const legacyBak = target + ".vibads-backup";
writeFileSync(legacyBak, FIX, "utf8"); // pristine captured pre-rename
writeFileSync(target,
'var V=["x"];/* VIBADS-START */window.__v=1;/* VIBADS-END */', "utf8");
const a = new ClaudeCodeAdapter(target);
const r = a.restore();
expect(r.restored).toBe(true);
expect(readFileSync(target, "utf8")).toBe(FIX); // byte-exact pristine
expect(existsSync(legacyBak)).toBe(false); // legacy backup consumed
});
it("re-apply over a legacy VIBADS block does not stack (single new block)", () => {
writeFileSync(target + ".vibads-backup", FIX, "utf8");
writeFileSync(target,
FIX + '\n/* VIBADS-START */window.__v=1;/* VIBADS-END */\n', "utf8");
const a = new ClaudeCodeAdapter(target);
expect(a.applyPatch(params).ok).toBe(true);
const out = readFileSync(target, "utf8");
expect((out.match(/VIBADS-START/g) || []).length).toBe(0); // legacy stripped
expect((out.match(/VIBE-ADS-START/g) || []).length).toBe(1); // exactly one new
expect(out).toContain(params.adText);
});
it("never throws on a bad path", () => {
const a = new ClaudeCodeAdapter("/no/such/dir/index.js");
expect(a.preflight().compatible).toBe(false);
expect(a.applyPatch(params).ok).toBe(false);
expect(a.restore().restored).toBe(false);
});
it("substitutes __VIBE_ADS_BANNER_ON__ and carries no legacy banner sentinel", () => {
const a = new ClaudeCodeAdapter(target);
a.applyPatch({ ...params, bannerOn: true });
const out = readFileSync(target, "utf8");
expect(out).toContain("var BANNER_ON = true");
const asset = readFileSync(
join(__dirname, "../src/adapters/claude-code/block.asset.js"), "utf8");
expect(asset.includes("__VIBE_ADS_BANNER__")).toBe(false);
expect(asset.includes("var BANNER =")).toBe(false);
});
it("bannerOn false ⇒ var BANNER_ON = false", () => {
const a = new ClaudeCodeAdapter(target);
a.applyPatch({ ...params, bannerOn: false });
expect(readFileSync(target, "utf8")).toContain("var BANNER_ON = false");
});
});
// CSP connect-src persistence. The relaxation must outlive a routine
// deactivate()/reload — Claude Code captures its webview CSP template into
// memory at extension-host load, BEFORE our re-patch, so a CSP reverted on
// every deactivate is never effective for the running session (loopback
// telemetry stays CSP-blocked). Policy: routine restore keeps the (invisible,
// loopback-only) connect-src; explicit restore/kill/sign-out fully reverts.
describe("CSP sibling patch + scoped restore", () => {
// target = <root>/webview/index.js so extTarget() -> <root>/extension.js.
function withSibling(): { target: string; ext: string } {
const root = mkdtempSync(join(tmpdir(), "vibe-ads-csp-"));
mkdirSync(join(root, "webview"), { recursive: true });
const target = join(root, "webview", "index.js");
writeFileSync(target, FIX, "utf8");
const ext = join(root, "extension.js");
// contains the exact CSP anchor the adapter rewrites (literal ${q})
writeFileSync(ext, "globalThis.x=1;var CSP=\"default-src 'none'; ${q}; img-src data:\";", "utf8");
return { target, ext };
}
const MARK = "connect-src http://127.0.0.1:*";
it("applyPatch inserts connect-src into the sibling extension.js + backs it up", () => {
const { target, ext } = withSibling();
expect(new ClaudeCodeAdapter(target).applyPatch(params).ok).toBe(true);
expect(readFileSync(ext, "utf8")).toContain(MARK);
expect(existsSync(ext + ".vibe-ads-backup")).toBe(true);
});
it("restore({keepCsp:true}) reverts index.js but LEAVES the CSP relaxation", () => {
const { target, ext } = withSibling();
const a = new ClaudeCodeAdapter(target);
a.applyPatch(params);
const r = a.restore({ keepCsp: true });
expect(r.restored).toBe(true);
expect(readFileSync(target, "utf8")).toBe(FIX); // visible change gone
expect(readFileSync(ext, "utf8")).toContain(MARK); // CSP persists
expect(existsSync(ext + ".vibe-ads-backup")).toBe(true); // still reversible
});
it("restore() (explicit, no opts) fully reverts BOTH index.js and the CSP", () => {
const { target, ext } = withSibling();
const pristineExt = readFileSync(ext, "utf8");
const a = new ClaudeCodeAdapter(target);
a.applyPatch(params);
const r = a.restore();
expect(r.restored).toBe(true);
expect(readFileSync(target, "utf8")).toBe(FIX);
expect(readFileSync(ext, "utf8")).toBe(pristineExt); // byte-exact
expect(existsSync(ext + ".vibe-ads-backup")).toBe(false);
});
// Regression: CC 2.1.145 renamed the first template variable in the CSP
// template from ${q} to ${U}, which silently broke the CSP patch because
// the patcher's anchor was a literal string. Every webview→loopback fetch
// (clicks, impressions, view-tracking) was CSP-blocked. The fix made
// CSP_ANCHOR_RE match any single-identifier template token; this test
// pins that 2.1.143's ${q}, 2.1.145's ${U}, and a hypothetical future
// ${q2} all patch correctly, while preserving the original variable so the
// rest of the CSP template renders identically.
it("CSP patch tolerates CC template-variable renames (2.1.143/${q},"
+ " 2.1.145/${U}, and arbitrary single-identifier tokens)", () => {
const cases = [
{ label: "2.1.143", token: "${q}" },
{ label: "2.1.145", token: "${U}" },
{ label: "hypothetical", token: "${q2}" },
];
for (const c of cases) {
const root = mkdtempSync(join(tmpdir(), "vibe-ads-csp-ver-"));
mkdirSync(join(root, "webview"), { recursive: true });
const target = join(root, "webview", "index.js");
writeFileSync(target, FIX, "utf8");
const ext = join(root, "extension.js");
const cspLine =
`var CSP="default-src 'none'; ${c.token}; ${"${M}"}; "`
+ `+ "script-src 'nonce-${"${D}"}'";`;
writeFileSync(ext, cspLine, "utf8");
expect(new ClaudeCodeAdapter(target).applyPatch(params).ok).toBe(true);
const patched = readFileSync(ext, "utf8");
expect(patched).toContain(MARK); // relaxed
expect(patched).toContain(c.token); // var preserved
expect(patched).toContain(`'none'; connect-src`); // additive connect-src right after default-src
expect(patched).not.toContain("img-src https://"); // H3: never inject our own img-src
}
});
// H3 regression: real CC builds ship `${M}` = `img-src ${cspSource} data:`.
// The patch must NOT emit a SECOND img-src — per CSP3 only the first is
// honored, so a narrow ad-icon img-src would silently override CC's own
// image policy and break CC's data:/markdown/webview images (prime
// directive). Assert the patched policy has exactly one img-src (CC's).
it("CSP patch does not duplicate img-src (preserves CC's image policy)", () => {
const root = mkdtempSync(join(tmpdir(), "vibe-ads-csp-img-"));
mkdirSync(join(root, "webview"), { recursive: true });
const target = join(root, "webview", "index.js");
writeFileSync(target, FIX, "utf8");
const ext = join(root, "extension.js");
// CC's real shape: first var, then an img-src directive with data:.
writeFileSync(ext,
"var CSP=\"default-src 'none'; ${q}; img-src https://cc.example data:\";",
"utf8");
expect(new ClaudeCodeAdapter(target).applyPatch(params).ok).toBe(true);
const patched = readFileSync(ext, "utf8");
expect(patched).toContain(MARK); // our connect-src added
expect((patched.match(/img-src/g) || []).length).toBe(1); // exactly ONE img-src
expect(patched).toContain("img-src https://cc.example data:"); // and it's CC's, intact
});
// Regression: when the CSP anchor literally can't be found in extension.js,
// applyPatch used to silently skip the relaxation — and the user only
// discovered telemetry was dead by noticing the ledger had no clicks. Pin
// that the visible patch (index.js) still succeeds (prime directive: never
// break the editor), but the CSP miss is observable via the return shape
// so future CC renames are diagnosable without grepping debug.log.
it("applyPatch succeeds even when the CSP anchor isn't present (no-op CSP,"
+ " visible patch still lands — preserves prime directive)", () => {
const root = mkdtempSync(join(tmpdir(), "vibe-ads-csp-no-anchor-"));
mkdirSync(join(root, "webview"), { recursive: true });
const target = join(root, "webview", "index.js");
writeFileSync(target, FIX, "utf8");
const ext = join(root, "extension.js");
// CSP template variable position has been replaced with something the
// regex won't match (e.g. a different shape altogether).
writeFileSync(ext, "var CSP=\"unrelated cspstuff goes here\";", "utf8");
expect(new ClaudeCodeAdapter(target).applyPatch(params).ok).toBe(true);
expect(readFileSync(ext, "utf8")).not.toContain(MARK); // CSP unmodified
// But the visible patch DID land — index.js has the block
expect(readFileSync(target, "utf8")).toContain("/* VIBE-ADS-START */");
});
// prime(): the boot-time "guaranteed startup reassert". Relaxes the CSP so
// the loopback surface is ready BEFORE an ad is in hand, WITHOUT injecting a
// visible block. Same connect-src + backup + reversibility as applyPatch's
// CSP layer, but index.js is never touched.
it("prime() inserts the connect-src + backs up the sibling, WITHOUT injecting a block", () => {
const { target, ext } = withSibling();
const a = new ClaudeCodeAdapter(target);
expect(a.prime().ok).toBe(true);
expect(readFileSync(ext, "utf8")).toContain(MARK); // CSP relaxed
expect(existsSync(ext + ".vibe-ads-backup")).toBe(true); // reversible
expect(readFileSync(target, "utf8")).toBe(FIX); // index.js untouched
expect(a.isPatched()).toBe(false); // no visible block
});
it("prime() is idempotent (second call is an 'already' no-op, no double insert)", () => {
const { target, ext } = withSibling();
const a = new ClaudeCodeAdapter(target);
a.prime();
const after1 = readFileSync(ext, "utf8");
const r2 = a.prime();
expect(r2.ok).toBe(true);
expect(r2.reason).toBe("already");
expect(readFileSync(ext, "utf8")).toBe(after1); // byte-identical
expect((after1.match(/connect-src http:\/\/127\.0\.0\.1/g) || []).length).toBe(1);
});
it("prime() then applyPatch is a no-op on the already-primed CSP; restore({keepCsp:true}) keeps it", () => {
const { target, ext } = withSibling();
const a = new ClaudeCodeAdapter(target);
a.prime();
const primedExt = readFileSync(ext, "utf8");
expect(a.applyPatch(params).ok).toBe(true); // ad arrives
expect(readFileSync(ext, "utf8")).toBe(primedExt); // CSP unchanged by applyPatch
expect(a.isPatched()).toBe(true); // block now present
a.restore({ keepCsp: true });
expect(readFileSync(ext, "utf8")).toContain(MARK); // CSP survives routine restore
});
it("prime() then explicit restore() fully reverts the primed CSP (byte-exact)", () => {
const { target, ext } = withSibling();
const pristineExt = readFileSync(ext, "utf8");
const a = new ClaudeCodeAdapter(target);
a.prime();
expect(readFileSync(ext, "utf8")).toContain(MARK);
a.restore(); // explicit, no opts
expect(readFileSync(ext, "utf8")).toBe(pristineExt); // CSP reverted byte-exact
expect(existsSync(ext + ".vibe-ads-backup")).toBe(false);
});
it("prime() never throws on a bad path", () => {
const a = new ClaudeCodeAdapter("/no/such/dir/webview/index.js");
expect(a.prime().ok).toBe(false); // graceful: no sibling
});
});
// Regression for S3 Wave 1 review CRIT #1: the block asset must resolve in
// BOTH the unbundled (co-located) and the esbuild-bundled (dist/) layouts.
describe("resolveBlockAsset (bundled-vs-unbundled)", () => {
it("finds the asset co-located (unbundled / vitest layout)", () => {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-rb1-"));
writeFileSync(join(d, "block.asset.js"), "x", "utf8");
expect(resolveBlockAsset(d)).toBe(join(d, "block.asset.js"));
});
it("finds the asset at adapters/claude-code (bundled dist/ layout)", () => {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-rb2-"));
mkdirSync(join(d, "adapters", "claude-code"), { recursive: true });
writeFileSync(join(d, "adapters", "claude-code", "block.asset.js"), "x", "utf8");
expect(resolveBlockAsset(d)).toBe(join(d, "adapters", "claude-code", "block.asset.js"));
});
it("esbuild build actually places the asset where the BUNDLED adapter looks", () => {
// Build the real bundle, then assert the asset exists at the path
// resolveBlockAsset() will compute from dirname(dist/extension.js).
execFileSync("node", ["esbuild.mjs"], { cwd: join(__dirname, ".."), stdio: "pipe" });
const distDir = join(__dirname, "..", "dist");
expect(existsSync(join(distDir, "extension.js"))).toBe(true);
const resolved = resolveBlockAsset(distDir); // dirname(__filename) at runtime
expect(existsSync(resolved)).toBe(true);
expect(resolved).toBe(join(distDir, "adapters", "claude-code", "block.asset.js"));
});
});