← back to Kickbacks Ai Upstream
test/e2e.test.ts
267 lines
import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { JSDOM } from "jsdom";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code/adapter";
const FIX = readFileSync(join(__dirname, "fixtures/synthetic-index.js"), "utf8");
// These jsdom e2e cases have multi-second real-time waits (verb idle/GRACE
// timing) and are SLOW — they are NOT part of the standard suite. Run them
// explicitly on demand only: VIBE_ADS_E2E=1 npx vitest run test/e2e.test.ts
const E2E = process.env.VIBE_ADS_E2E === "1";
(E2E ? describe : describe.skip)(
"S3 e2e (fixture only — never a real install)", () => {
// Real CC structure (telemetry-confirmed): a `spinnerRow_<hash>` DIV inside
// `messagesContainer_<hash> stickyMode_<hash>`, with the animated glyph in
// a CHILD (.ccverb here). CC keeps the row for the turn (animating the
// child in place) and REMOVES it when the turn ends; a new turn mounts a
// fresh row. The block must occupy non-destructively (append OUR child,
// hide CC's via visibility:hidden) and pin the ad to the row's presence.
function makeCcDom() {
const dom = new JSDOM(
`<body><div id="mc" class="messagesContainer_X stickyMode_X"></div></body>`,
{ runScripts: "outside-only" });
(dom.window as any).fetch = async () => ({ json: async () => ({}) });
const doc = dom.window.document;
const mc = doc.getElementById("mc")!;
let turn = false;
const tick = dom.window.setInterval(() => {
const row = mc.querySelector('[class*="spinnerRow_"]');
if (turn) {
if (!row) {
const r = doc.createElement("div");
r.className = "spinnerRow_07S1Yg";
const v = doc.createElement("span");
v.className = "ccverb";
v.textContent = "✢._.";
r.appendChild(v);
mc.appendChild(r);
} else { // CC animates child IN PLACE
const v = row.querySelector(".ccverb");
if (v) v.textContent = "✢" + ".".repeat(1 + (Date.now() >> 7) % 3);
}
} else if (row) {
// REAL idle behavior (telemetry-confirmed): CC keeps spinnerRow
// MOUNTED but stops the glyph (no longer ✢-led). The fix must drop
// the overlay on the glyph stopping, not on row removal.
const v = row.querySelector(".ccverb");
if (v) v.textContent = "";
}
}, 120);
return { dom, doc, tick, setTurn: (v: boolean) => { turn = v; } };
}
// The ad is a body-level overlay we own (NOT inside CC's tree).
const adVisible = (doc: Document) => {
const o = doc.querySelector('[data-vibe-ads-overlay="1"]') as
HTMLElement | null;
return !!o && !!o.isConnected && o.parentElement === doc.body &&
(o.textContent || "").includes("Acme deploys faster");
};
// CC's spinner subtree must be byte-untouched: its child still present,
// no data-vibe-ads* injected/added inside the row (we only READ it).
const ccTreeUntouched = (doc: Document) => {
const row = doc.querySelector('[class*="spinnerRow_"]');
if (!row) return true; // row gone (idle) — trivially fine
const v = row.querySelector(".ccverb");
return !!v && v.getAttribute("data-vibe-ads-hid") === null &&
(v as HTMLElement).style.visibility !== "hidden" &&
row.querySelector("[data-vibe-ads-overlay]") === null &&
row.querySelector("[data-vibe-ads-host]") === null;
};
it("patch -> ad renders in a body overlay, CC tree untouched -> restore " +
"byte-exact", async () => {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-e2e-"));
const target = join(d, "index.js");
writeFileSync(target, FIX, "utf8");
const a = new ClaudeCodeAdapter(target);
expect(a.applyPatch({ tier: 3, adText: "Acme deploys faster than your CI now",
iconRef: "icon.a", iconUrl: "", clickToken: "ck", clickUrl: "https://acme.example/lp",
corr: "e2e.tst", loopbackPort: 5555,
loopbackToken: "lt", loopbackBase: "http://127.0.0.1:5555" }).ok).toBe(true);
const patched = readFileSync(target, "utf8");
const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
const dom = new JSDOM(
`<body><div class="messagesContainer_X stickyMode_X">` +
`<div class="spinnerRow_07S1Yg"><span class="ccverb">✢._.</span>` +
`</div></div></body>`, { runScripts: "outside-only" });
(dom.window as any).fetch = async () => ({ json: async () => ({}) });
dom.window.eval(block);
await new Promise((r) => setTimeout(r, 300));
const doc = dom.window.document;
expect(adVisible(doc)).toBe(true); // ad in body overlay
expect(ccTreeUntouched(doc)).toBe(true); // CC subtree untouched
expect(doc.querySelector(".ccverb")).not.toBeNull(); // but NOT destroyed
expect(a.restore().restored).toBe(true);
expect(readFileSync(target, "utf8")).toBe(FIX); // byte-exact
});
it("ad PERSISTS frozen at idle (last ad stays in view), re-glues next " +
"turn, CC tree never mutated", async () => {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-pin-"));
const target = join(d, "index.js");
writeFileSync(target, FIX, "utf8");
new ClaudeCodeAdapter(target).applyPatch({ tier: 3,
adText: "Acme deploys faster than your CI now", iconRef: "icon.a", iconUrl: "",
clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.pin",
loopbackPort: 5555, loopbackToken: "lt",
loopbackBase: "http://127.0.0.1:5555" });
const patched = readFileSync(target, "utf8");
const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
const { dom, doc, tick, setTurn } = makeCcDom();
dom.window.eval(block);
setTurn(true); // ---- turn 1 active
await new Promise((r) => setTimeout(r, 700));
expect(adVisible(doc)).toBe(true);
expect(ccTreeUntouched(doc)).toBe(true); // CC subtree untouched (read-only)
setTurn(false); // turn ends (glyph stops)
await new Promise((r) => setTimeout(r, 4600)); // well > GRACE_MS
// PERSIST-AT-IDLE: the ad is FROZEN in place, NOT dropped — the last
// rendered frame stays on screen indefinitely after the turn ends.
expect(adVisible(doc)).toBe(true);
expect(ccTreeUntouched(doc)).toBe(true); // still read-only
setTurn(true); // ---- brand-new turn
await new Promise((r) => setTimeout(r, 700));
expect(adVisible(doc)).toBe(true); // thaws, re-glues
expect(ccTreeUntouched(doc)).toBe(true);
setTurn(false);
dom.window.clearInterval(tick);
}, 15000);
it("does NOT vanish mid-thinking while CC animates its child in place " +
"(read-only detection keeps the signal)", async () => {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-inplace-"));
const target = join(d, "index.js");
writeFileSync(target, FIX, "utf8");
new ClaudeCodeAdapter(target).applyPatch({ tier: 3,
adText: "Acme deploys faster than your CI now", iconRef: "icon.a", iconUrl: "",
clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.inpl",
loopbackPort: 5555, loopbackToken: "lt",
loopbackBase: "http://127.0.0.1:5555" });
const patched = readFileSync(target, "utf8");
const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
const { dom, doc, tick, setTurn } = makeCcDom();
dom.window.eval(block);
setTurn(true); // row stays; child anim
await new Promise((r) => setTimeout(r, 1500));
expect(adVisible(doc)).toBe(true);
await new Promise((r) => setTimeout(r, 3000)); // well past old GRACE
expect(adVisible(doc)).toBe(true); // STILL shown — no vanish
expect(ccTreeUntouched(doc)).toBe(true);
setTurn(false);
await new Promise((r) => setTimeout(r, 4600));
// PERSIST-AT-IDLE: still shown after the turn ends (frozen, not cleared).
expect(adVisible(doc)).toBe(true);
dom.window.clearInterval(tick);
}, 20000);
it("does NOT bill while frozen at idle (no view_tick / error_impression " +
"pings accrue once the turn ends)", async () => {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-nobill-"));
const target = join(d, "index.js");
writeFileSync(target, FIX, "utf8");
new ClaudeCodeAdapter(target).applyPatch({ tier: 3,
adText: "Acme deploys faster than your CI now", iconRef: "icon.a", iconUrl: "",
clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.nobill",
loopbackPort: 5555, loopbackToken: "lt",
loopbackBase: "http://127.0.0.1:5555" });
const patched = readFileSync(target, "utf8");
const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
const { dom, doc, tick, setTurn } = makeCcDom();
// Record every loopback hit (fetch + sendBeacon) so we can prove the
// view-time accumulator emits NOTHING once the ad freezes at idle.
const pings: string[] = [];
(dom.window as any).fetch = async (u: string) => {
pings.push(String(u)); return { json: async () => ({}) };
};
try {
Object.defineProperty(dom.window.navigator, "sendBeacon", {
configurable: true,
value: (u: string) => { pings.push(String(u)); return true; },
});
} catch { /* jsdom: no sendBeacon → ping() falls back to the fetch above */ }
const billing = () =>
pings.filter((u) => /view_tick|error_impression|view_threshold_met/.test(u));
dom.window.eval(block);
setTurn(true); // active turn (<5s)
await new Promise((r) => setTimeout(r, 700));
expect(adVisible(doc)).toBe(true);
setTurn(false); // turn ends → freeze
await new Promise((r) => setTimeout(r, 2500)); // > GRACE_MS: now frozen
expect(adVisible(doc)).toBe(true); // persisted
const before = billing().length; // no billing yet (<5s)
// Sit idle well past the 5s MAX_SESSION_MS / TICK_MS boundary. If billing
// were NOT paused, the absolute-epoch clock would cross 5s of elapsed and
// fire a view_tick/error_impression during this window.
await new Promise((r) => setTimeout(r, 6000));
expect(adVisible(doc)).toBe(true); // still persisted
expect(billing().length).toBe(before); // …and STILL no billing
setTurn(false);
dom.window.clearInterval(tick);
}, 20000);
it("renders a clickable ad into a real usage banner when bannerOn", async () => {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-bn-"));
const target = join(d, "index.js");
writeFileSync(target, FIX, "utf8");
const a = new ClaudeCodeAdapter(target);
a.applyPatch({ tier: 3, adText: "Acme deploys faster", iconRef: "i", iconUrl: "",
clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.tst",
loopbackPort: 5555,
loopbackToken: "lt", loopbackBase: "http://127.0.0.1:5555", bannerOn: true });
const patched = readFileSync(target, "utf8");
const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
const dom = new JSDOM(
`<body><div id="b">You've used 71% of your weekly limit · resets in 4d · View usage</div></body>`,
{ runScripts: "outside-only" });
(dom.window as any).fetch = async () => ({ json: async () => ({}) });
dom.window.eval(block);
await new Promise((r) => setTimeout(r, 1200)); // banner loop is 1s
const b = dom.window.document.getElementById("b")!;
expect(b.getAttribute("data-vibe-ads-banner")).toBe("1");
expect(b.innerHTML).toContain("Acme deploys faster");
expect(b.innerHTML).toContain('data-vibe-ads-ad="1"');
});
it("leaves the banner untouched when bannerOn is false", async () => {
const d = mkdtempSync(join(tmpdir(), "vibe-ads-bf-"));
const target = join(d, "index.js");
writeFileSync(target, FIX, "utf8");
new ClaudeCodeAdapter(target).applyPatch({ tier: 3, adText: "Acme", iconRef: "i", iconUrl: "",
clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.tst",
loopbackPort: 5555,
loopbackToken: "lt", loopbackBase: "http://127.0.0.1:5555", bannerOn: false });
const patched = readFileSync(target, "utf8");
const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
const orig = "You've used 71% of your weekly limit · resets in 4d · View usage";
const dom = new JSDOM(`<body><div id="b">${orig}</div></body>`,
{ runScripts: "outside-only" });
(dom.window as any).fetch = async () => ({ json: async () => ({}) });
dom.window.eval(block);
await new Promise((r) => setTimeout(r, 1200));
expect(dom.window.document.getElementById("b")!.textContent).toBe(orig);
});
it("the real-install locator is NOT used by tests (guard)", () => {
// extension.ts only resolves the real path inside activate(); no test
// imports or invokes locateClaudeCode against the real FS. Assert the
// fixture-only contract by construction: importing the adapter requires
// an explicit target (no default real path).
// @ts-expect-error constructor requires a target argument
expect(() => new ClaudeCodeAdapter()).toThrow;
});
});