← back to Kickbacks Ai Upstream
test/cc-pollad.test.ts
345 lines
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { JSDOM, VirtualConsole } from "jsdom";
// Regression coverage for the wave-4 pollAd fixes:
//
// Audit #27 — the DOCKED idle overlay never repainted: paint() only runs in
// the active branch, so a host-side rotation while docked left a STALE
// creative on screen (clicks unbilled at the host's 15s floor, or
// misattributed). pollAd now retargets the docked line's EXISTING stable
// child nodes in place — the ad-text TEXT NODE's nodeValue and the anchor's
// href ONLY. The anchor is NEVER re-created (rewriting innerHTML of a live
// clickable element detaches it mid-click — a shipped bug, fixed once), the
// docked state stays NON-billing (persist-at-idle), and the next thaw's
// viewShow attributes to the NEW ad.
//
// Wave-2 carry-over — the EMPTY /ad payload (the host's serving-gate signal
// on kill/disable) was a no-op, leaving the last creative painted until
// idle/reload. pollAd now distinguishes payload shapes: a fetch ERROR keeps
// keep-last-ad (transient network); TWO consecutive successful-but-empty
// responses (debounce) drop the overlay/banner via the existing drop paths
// and END every view session. A served payload re-arms.
//
// Harness: cc-viewtimer-style JSDOM + test-controlled Date.now, plus a
// setInterval capture so the 10s pollAd can be driven deterministically, and
// a rect-stubbed <textarea> composer so the idle DOCK path (not the
// dock-miss drop) engages.
const ASSET = readFileSync(
join(__dirname, "..", "src", "adapters", "claude-code", "block.asset.js"),
"utf8");
const AD_A = "Acme deploys faster than your CI";
const URL_A = "https://acme.example/lp";
const AD_B = "Bolt ships 2x faster";
const URL_B = "https://bolt.example/lp";
const PAYLOAD_A = { adText: AD_A, clickUrl: URL_A, iconUrl: "",
adId: "adA", campaignId: "c1" };
const PAYLOAD_B = { adText: AD_B, clickUrl: URL_B, iconUrl: "",
adId: "adB", campaignId: "c2" };
function preparedAsset(opts: { bannerOn: boolean }): string {
const subs: Record<string, string> = {
__VIBE_ADS_TIER__: "3",
__VIBE_ADS_AD__: JSON.stringify(AD_A),
__VIBE_ADS_ICON__: JSON.stringify("icon.a"),
__VIBE_ADS_PORT__: "5555",
__VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
__VIBE_ADS_CLICKTOKEN__: JSON.stringify("ck"),
__VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
__VIBE_ADS_DEBUG__: "false",
__VIBE_ADS_ICON_URL__: JSON.stringify(""),
__VIBE_ADS_CLICKURL__: JSON.stringify(URL_A),
__VIBE_ADS_BANNER_ON__: opts.bannerOn ? "true" : "false",
__VIBE_ADS_CORR__: JSON.stringify("adA.abcd"),
__VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
};
let src = ASSET;
for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
return src;
}
interface Harness {
dom: JSDOM;
doc: Document;
pings: string[];
advance: (ms: number) => void;
/** Drive the block's 10s /ad poll once, deterministically. */
pollAd: () => Promise<void>;
setAd: (p: Record<string, unknown> | null) => void;
setAdError: (on: boolean) => void;
}
function makeHarness(opts: { bannerOn: boolean }): Harness {
const vc = new VirtualConsole();
vc.on("jsdomError", () => { /* irrelevant here */ });
const dom = new JSDOM(`<body></body>`,
{ runScripts: "outside-only", pretendToBeVisual: true,
virtualConsole: vc });
const win = dom.window as unknown as Window & typeof globalThis & {
eval: (s: string) => unknown; fetch: typeof fetch;
setInterval: typeof setInterval;
};
const pings: string[] = [];
let adPayload: Record<string, unknown> | null = PAYLOAD_A;
let adError = false;
win.fetch = ((url: string) => {
const u = String(url);
if (u.endsWith("/ad")) {
if (adError) return Promise.reject(new Error("net down"));
return Promise.resolve({ json: async () => (adPayload ?? {}) });
}
if (u.endsWith("/activity")) {
return Promise.resolve({ json: async () => ({}) });
}
pings.push(u);
return Promise.resolve({ json: async () => ({}) });
}) as unknown as typeof fetch;
// Capture interval callbacks BEFORE boot so the test can invoke pollAd
// (the unique 10_000ms interval) directly instead of waiting 10 real
// seconds. The real timers keep running underneath, same as production.
const intervals: Array<{ fn: () => void; ms: number }> = [];
const origSetInterval = win.setInterval.bind(win);
(win as unknown as { setInterval: unknown }).setInterval =
((fn: () => void, ms: number) => {
intervals.push({ fn, ms });
return origSetInterval(fn, ms);
}) as unknown as typeof setInterval;
let t = 1_700_000_000_000;
(win as unknown as { Date: DateConstructor }).Date.now = () => t;
win.eval(preparedAsset(opts));
const pollFn = intervals.find((i) => i.ms === 10_000)?.fn;
if (!pollFn) throw new Error("pollAd interval (10s) not registered");
return {
dom, doc: dom.window.document, pings,
advance: (ms: number) => { t += ms; },
pollAd: async () => {
pollFn();
for (let i = 0; i < 8; i++) await Promise.resolve();
},
setAd: (p) => { adPayload = p; },
setAdError: (on) => { adError = on; },
};
}
const GLYPHS = ["✢", "✶", "✻", "✽"];
function addSpinner(doc: Document): { el: HTMLElement; spin: () => void } {
const el = doc.createElement("div");
el.className = "spinnerRow_ab12c";
let g = 0;
el.textContent = GLYPHS[0] + " Reticulating…";
doc.body.appendChild(el);
return { el, spin: () => {
g = (g + 1) % GLYPHS.length;
el.textContent = GLYPHS[g] + " Reticulating…";
} };
}
// A composer the idle DOCK can park above. jsdom rects are all-zero, which
// findComposer treats as "not visible" (→ dock-miss drop, the path
// cc-viewtimer.test.ts exercises); stub a real rect so docking succeeds.
function addComposer(doc: Document): HTMLElement {
const el = doc.createElement("textarea");
(el as unknown as { getBoundingClientRect: () => unknown })
.getBoundingClientRect = () => ({
width: 400, height: 60, top: 500, left: 10, right: 410, bottom: 560,
x: 10, y: 500, toJSON: () => ({}) });
doc.body.appendChild(el);
return el;
}
function addBanner(doc: Document): HTMLElement {
const el = doc.createElement("div");
el.textContent =
"You've used 71% of your weekly limit · resets in 4d · View usage";
doc.body.appendChild(el);
return el;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const count = (pings: string[], re: RegExp) =>
pings.filter((u) => re.test(u)).length;
const OVERLAY_TICK = /\/view_tick\?surface=overlay&/;
const BANNER_TICK = /\/view_tick\?surface=banner&/;
const ERROR = /\/error_impression\?/;
const overlayOf = (doc: Document) =>
doc.querySelector('[data-vibe-ads-overlay="1"]');
const anchorOf = (doc: Document) =>
doc.querySelector('[data-vibe-ads-overlay="1"] a[data-vibe-ads-ad]') as
HTMLAnchorElement | null;
describe("CC pollAd — docked-rotation repaint (audit #27)", () => {
it("a rotation adopted while DOCKED retargets the SAME anchor in place "
+ "(text node + href only), stays non-billing, and the thaw attributes "
+ "to the NEW ad", async () => {
const h = makeHarness({ bannerOn: false });
addComposer(h.doc);
const sp = addSpinner(h.doc);
await sleep(300); // active paint → overlay + ad A
const a1 = anchorOf(h.doc);
expect(a1).toBeTruthy();
expect(a1!.textContent).toContain(AD_A);
expect(a1!.getAttribute("href")).toBe(URL_A);
h.advance(5_100); sp.spin(); // accrue one billable tick
await sleep(500);
expect(count(h.pings, OVERLAY_TICK)).toBe(1);
// Turn ends; glyph freezes → idle → DOCK (composer present, no drop).
h.advance(2_000);
await sleep(400);
expect(overlayOf(h.doc)).toBeTruthy(); // persists at idle (docked)
// Host rotates to ad B while docked; the webview's poll adopts it.
h.setAd(PAYLOAD_B);
await h.pollAd();
const a2 = anchorOf(h.doc);
// OLD behavior: the docked line still showed ad A's text + href.
expect(a2).toBe(a1); // anchor NEVER re-created
expect(a2!.textContent).toContain(AD_B);
expect(a2!.textContent).not.toContain(AD_A);
expect(a2!.getAttribute("href")).toBe(URL_B);
// Stable children survive the retarget (no innerHTML rewrite).
expect(a2!.querySelector("[data-va-dots]")).toBeTruthy();
// Docked stays NON-billing after the swap (persist-at-idle): the old
// session ended, no live session started for B.
h.advance(20_000);
await sleep(400);
expect(count(h.pings, OVERLAY_TICK)).toBe(1);
expect(count(h.pings, ERROR)).toBe(0);
// Next turn thaws → the NEW ad's session opens fresh and bills as B.
sp.spin();
await sleep(300);
expect(overlayOf(h.doc)).toBeTruthy();
h.advance(5_100); sp.spin();
await sleep(500);
const ticks = h.pings.filter((u) => OVERLAY_TICK.test(u));
expect(ticks.length).toBe(2);
expect(ticks[1]).toContain("&ad=" + encodeURIComponent(AD_B));
expect(ticks[1]).toContain("&visible_ms=5000");
h.dom.window.close();
}, 20000);
});
describe("CC pollAd — empty payload is the no-serve signal (wave-2 "
+ "carry-over)", () => {
it("TWO consecutive empty polls drop the overlay and END every session — "
+ "nothing repaints even with a live spinner; a served payload re-arms",
async () => {
const h = makeHarness({ bannerOn: false });
const sp = addSpinner(h.doc);
await sleep(300);
expect(overlayOf(h.doc)).toBeTruthy();
h.advance(5_100); sp.spin();
await sleep(500);
expect(count(h.pings, OVERLAY_TICK)).toBe(1);
h.setAd(null); // host gate: /ad returns {}
await h.pollAd();
// Debounce: ONE empty read never tears down (could race a rotation).
expect(overlayOf(h.doc)).toBeTruthy();
await h.pollAd(); // second consecutive empty
await sleep(300);
// OLD behavior: the empty payload was a no-op — the last creative
// stayed painted (and at idle would persist) until reload.
expect(overlayOf(h.doc)).toBeNull();
// Sessions are ENDED and the evaluator stays down even though the
// spinner row is still live: no ticks, no error_impressions, no repaint.
h.advance(20_000); sp.spin();
await sleep(500);
expect(overlayOf(h.doc)).toBeNull();
expect(count(h.pings, OVERLAY_TICK)).toBe(1);
expect(count(h.pings, ERROR)).toBe(0);
// Host serves again → re-arm: the overlay re-mounts on the next active
// paint with the new creative.
h.setAd(PAYLOAD_B);
await h.pollAd();
sp.spin();
await sleep(300);
expect(overlayOf(h.doc)).toBeTruthy();
expect(anchorOf(h.doc)!.textContent).toContain(AD_B);
h.dom.window.close();
}, 20000);
it("ONE empty poll followed by a real ad does NOT drop (single-race "
+ "debounce; the counter resets on any served payload)", async () => {
const h = makeHarness({ bannerOn: false });
const sp = addSpinner(h.doc);
await sleep(300);
expect(overlayOf(h.doc)).toBeTruthy();
h.setAd(null);
await h.pollAd(); // empty #1
expect(overlayOf(h.doc)).toBeTruthy();
h.setAd(PAYLOAD_A);
await h.pollAd(); // served → counter resets
h.setAd(null);
await h.pollAd(); // empty #1 again (not #2)
await sleep(200);
expect(overlayOf(h.doc)).toBeTruthy();
sp.spin(); // still serving normally
await sleep(200);
expect(overlayOf(h.doc)).toBeTruthy();
h.dom.window.close();
}, 20000);
it("fetch ERRORS never drop: transient network keeps the last ad and its "
+ "session keeps billing", async () => {
const h = makeHarness({ bannerOn: false });
const sp = addSpinner(h.doc);
await sleep(300);
expect(overlayOf(h.doc)).toBeTruthy();
h.setAdError(true);
await h.pollAd();
await h.pollAd();
await h.pollAd(); // 3 consecutive FAILURES
expect(overlayOf(h.doc)).toBeTruthy(); // keep-last-ad preserved
h.advance(5_100); sp.spin();
await sleep(500);
expect(count(h.pings, OVERLAY_TICK)).toBe(1); // session alive
h.dom.window.close();
}, 20000);
it("the BANNER surface is hidden and its session ENDED by no-serve at "
+ "idle, and repaints when the host serves again", async () => {
const h = makeHarness({ bannerOn: true });
addBanner(h.doc);
await sleep(1_300); // banner loop is 1s
const bEl = h.doc.querySelector(
'[data-vibe-ads-banner="1"]') as HTMLElement;
expect(bEl).toBeTruthy();
h.advance(5_100);
await sleep(500);
expect(count(h.pings, BANNER_TICK)).toBe(1); // visible-at-idle billing
h.setAd(null);
await h.pollAd();
await h.pollAd();
await sleep(1_200); // ≥1 banner loop tick
// OLD behavior: the banner kept the stale creative visible and ticking.
expect(bEl.style.display).toBe("none");
h.advance(10_000);
await sleep(600);
expect(count(h.pings, BANNER_TICK)).toBe(1);
// Host resumes (same creative): banner un-hides, repaints, and a FRESH
// session starts at 0.
h.setAd(PAYLOAD_A);
await h.pollAd();
await sleep(1_300);
expect(bEl.style.display).not.toBe("none");
h.advance(5_100);
await sleep(500);
const ticks = h.pings.filter((u) => BANNER_TICK.test(u));
expect(ticks.length).toBe(2);
expect(/[?&]visible_ms=5000(&|$)/.test(ticks[1])).toBe(true);
h.dom.window.close();
}, 20000);
});