← back to Kickbacks Ai Upstream

test/codex-pollad.test.ts

277 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 codex "frozen ads" fix (2026-06-11): the baked
// __VIBE_ADS_AD__ was frozen for the life of the webview — rotation re-patched
// the bundle on disk but a running Codex panel never re-read it, so users sat
// on one creative (or the pre-inventory "Your ad here" placeholder) until a
// full VS Code reload. The block now polls the loopback /ad every 10s with
// the SAME semantics as claude-code/block.asset.js pollAd:
//   • changed payload → adopt + RESET all view sessions/impression flags
//     (old creative's accumulated time never bills against the new one)
//   • successful-but-empty ×2 (debounced) → no-serve: drop overlay, end
//     every session, suppress repaint until served again
//   • fetch error → keep last ad (transient network / CSP-blocked loopback)
// Plus: the click ping now carries the `ad=` attribution claim (CC parity).

const ASSET = readFileSync(
  join(__dirname, "..", "src", "adapters", "codex", "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_B = { adText: AD_B, clickUrl: URL_B, iconUrl: "",
  adId: "adB", campaignId: "c2" };

function preparedAsset(): string {
  const subs: Record<string, string> = {
    __VIBE_ADS_AD__: JSON.stringify(AD_A),
    __VIBE_ADS_PORT__: "5555",
    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
    __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ck"),
    __VIBE_ADS_CLICKURL__: JSON.stringify(URL_A),
    __VIBE_ADS_CORR__: JSON.stringify("test.codex.poll"),
    __VIBE_ADS_DEBUG__: "false",
    // Large threshold so only the 5s view_tick cadence drives billing
    // assertions (threshold_met never fires inside these windows).
    __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
    __VIBE_ADS_ARG__: "e",
    __VIBE_ADS_JSX__: "d",
  };
  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(): Harness {
  const vc = new VirtualConsole();
  vc.on("jsdomError", () => { /* anchor-click navigation noise */ });
  const dom = new JSDOM(`<body><div id="mc" class="chatpanel"></div></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 =
    { adText: AD_A, clickUrl: URL_A, iconUrl: "", adId: "adA", campaignId: "c1" };
  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 ?? {}) });
    }
    pings.push(u);
    return Promise.resolve({ json: async () => ({}) });
  }) as unknown as typeof fetch;
  // Capture interval registrations BEFORE boot so the unique 10s pollAd
  // can be driven directly (cc-pollad.test.ts pattern). Real timers keep
  // running underneath (250ms viewTick, 80ms paint loop), same as prod.
  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());
  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; },
  };
}

function setRect(el: HTMLElement,
  r: { x: number; y: number; w: number; h: number }): void {
  (el as unknown as { getBoundingClientRect: () => DOMRect })
    .getBoundingClientRect = () => ({
      x: r.x, y: r.y, left: r.x, top: r.y, right: r.x + r.w,
      bottom: r.y + r.h, width: r.w, height: r.h, toJSON() { return {}; },
    } as DOMRect);
}

function liveShimmer(doc: Document): HTMLElement {
  const el = doc.createElement("span");
  el.className = "loading-shimmer-pure-text _cadencedShimmer_1bpr9_1 "
    + "text-size-chat leading-[1.5] select-none truncate";
  const a = doc.createElement("span"); a.textContent = "Thinking";
  const b = doc.createElement("span"); b.textContent = "Thinking";
  el.appendChild(a); el.appendChild(b);
  setRect(el, { x: 100, y: 200, w: 240, h: 20 });
  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 TICK = /\/view_tick\?/;
const RENDERED = /\/impression_rendered\?/;
const ERROR = /\/error_impression\?/;
const overlayOf = (doc: Document) =>
  doc.querySelector('[data-vibe-ads="codex"]');
const anchorOf = (doc: Document) =>
  doc.querySelector('[data-vibe-ads="codex"] a[data-vibe-ads-ad]') as
    HTMLAnchorElement | null;

describe("codex pollAd — live rotation adoption (frozen-ads fix)", () => {
  it("adopts a rotated ad in place: text + href swap, the old session is "
    + "ENDED (no carry-over billing), fresh impression events fire, and the "
    + "next tick attributes to the NEW ad at visible_ms=5000", async () => {
    const h = makeHarness();
    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
    await sleep(300);                       // active paint → overlay + ad A
    expect(anchorOf(h.doc)).toBeTruthy();
    expect(anchorOf(h.doc)!.textContent).toContain(AD_A);
    expect(anchorOf(h.doc)!.getAttribute("href")).toBe(URL_A);
    expect(count(h.pings, RENDERED)).toBe(1);

    h.advance(5_100);                       // accrue one billable tick on A
    await sleep(400);
    const ticksA = h.pings.filter((u) => TICK.test(u));
    expect(ticksA.length).toBe(1);
    expect(ticksA[0]).toContain("ad=" + encodeURIComponent(AD_A));

    // Host rotates to B (or replaces the patch-time placeholder).
    h.setAd(PAYLOAD_B);
    await h.pollAd();
    await sleep(300);                       // next 80ms paint repaints
    expect(anchorOf(h.doc)!.textContent).toContain(AD_B);
    expect(anchorOf(h.doc)!.textContent).not.toContain(AD_A);
    expect(anchorOf(h.doc)!.getAttribute("href")).toBe(URL_B);
    // Fresh impression for the new creative.
    expect(count(h.pings, RENDERED)).toBe(2);

    // The old ad's 5.1s of accumulated view-time must NOT carry over: the
    // next tick is the NEW ad's FIRST (visible_ms=5000), not a continuation.
    h.advance(5_100);
    await sleep(400);
    const ticks = h.pings.filter((u) => TICK.test(u));
    expect(ticks.length).toBe(2);
    expect(ticks[1]).toContain("ad=" + encodeURIComponent(AD_B));
    expect(ticks[1]).toContain("visible_ms=5000");
    expect(count(h.pings, ERROR)).toBe(0);
    h.dom.window.close();
  }, 15000);

  it("a click after rotation carries the ad= attribution claim for the "
    + "creative actually on screen", async () => {
    const h = makeHarness();
    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
    await sleep(300);
    h.setAd(PAYLOAD_B);
    await h.pollAd();
    await sleep(300);
    const a = anchorOf(h.doc)!;
    expect(a.textContent).toContain(AD_B);
    a.dispatchEvent(new h.dom.window.MouseEvent("click", { bubbles: true }));
    const clicks = h.pings.filter((u) => /\/click\?/.test(u));
    expect(clicks.length).toBe(1);
    expect(clicks[0]).toContain("&ad=" + encodeURIComponent(AD_B));
    h.dom.window.close();
  }, 15000);
});

describe("codex pollAd — empty payload is the no-serve signal", () => {
  it("TWO consecutive empty polls drop the overlay, END every session, and "
    + "suppress repaint even with a live shimmer; a served payload re-arms",
    async () => {
    const h = makeHarness();
    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
    await sleep(300);
    expect(overlayOf(h.doc)).toBeTruthy();
    h.advance(5_100);
    await sleep(400);
    expect(count(h.pings, 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(200);
    expect(overlayOf(h.doc)).toBeNull();

    // Sessions ended + paint suppressed: the shimmer is still live, yet no
    // repaint, no ticks, no error_impressions.
    h.advance(20_000);
    await sleep(400);
    expect(overlayOf(h.doc)).toBeNull();
    expect(count(h.pings, TICK)).toBe(1);
    expect(count(h.pings, ERROR)).toBe(0);

    // Host serves again → re-arm on the next active paint.
    h.setAd(PAYLOAD_B);
    await h.pollAd();
    await sleep(300);
    expect(overlayOf(h.doc)).toBeTruthy();
    expect(anchorOf(h.doc)!.textContent).toContain(AD_B);
    h.dom.window.close();
  }, 15000);

  it("ONE empty poll followed by a served payload does NOT drop (debounce "
    + "counter resets)", async () => {
    const h = makeHarness();
    h.doc.getElementById("mc")!.appendChild(liveShimmer(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({ adText: AD_A, clickUrl: URL_A, iconUrl: "",
      adId: "adA", campaignId: "c1" });
    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();
    h.dom.window.close();
  }, 15000);

  it("fetch ERRORS never drop: the last ad stays painted and keeps billing "
    + "(transient network / CSP-blocked loopback = today's status quo)",
    async () => {
    const h = makeHarness();
    h.doc.getElementById("mc")!.appendChild(liveShimmer(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);
    await sleep(400);
    expect(count(h.pings, TICK)).toBe(1);   // session alive
    h.dom.window.close();
  }, 15000);
});