← back to Kickbacks Ai Upstream

test/click-telemetry.test.ts

285 lines

import { describe, it, expect, beforeEach, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { JSDOM, VirtualConsole } from "jsdom";
import { Loopback } from "../src/loopback";

// Click-through telemetry is the revenue-bearing signal in the Kickbacks
// auction. The CC block tries sendBeacon FIRST (Electron lifecycle-safe
// during the host's external-navigation tear-down), then falls back to
// fetch+keepalive. Until tonight only the impression beacon path had
// coverage; the click path's sendBeacon-vs-fetch dual-fallback and the
// surface-routing (overlay vs banner) walk-up were untested. A silent drop
// in either codepath would burn live click revenue without any signal —
// the worst-class regression for a billing pipeline.
//
// These tests load the CC block.asset.js in JSDOM, mock navigator.sendBeacon
// + fetch on the JSDOM window, dispatch synthetic click events, and assert
// the EXACT loopback URL the ping plumbing emits.

const ASSET = readFileSync(
  join(__dirname, "..", "src", "adapters", "claude-code", "block.asset.js"),
  "utf8");

const SUBS: Record<string, string> = {
  __VIBE_ADS_TIER__: "3",
  __VIBE_ADS_AD__: JSON.stringify("Ramp - Save time & money"),
  __VIBE_ADS_ICON__: JSON.stringify("icon.r"),
  __VIBE_ADS_PORT__: "5555",
  __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
  __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ctok-abc123"),
  __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
  __VIBE_ADS_DEBUG__: "false",
  __VIBE_ADS_CLICKURL__: JSON.stringify("https://ramp.example/lp?utm=ck"),
  __VIBE_ADS_BANNER_ON__: "true",
  __VIBE_ADS_CORR__: JSON.stringify("corr.xyz789"),
  __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
  __VIBE_ADS_ICON_URL__: JSON.stringify(""),
};

function preparedAsset(): string {
  let src = ASSET;
  for (const [k, v] of Object.entries(SUBS)) src = src.split(k).join(v);
  return src;
}

type BeaconCall = { url: string; data: Blob | string | null };
type FetchCall = { url: string; init: RequestInit | undefined };
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

function makeHarness(opts: { beacon: boolean | "reject" } = { beacon: true }) {
  // Anchor click triggers a real-href navigation attempt jsdom can't carry
  // out; without filtering this floods stderr with "Not implemented:
  // navigation" warnings on every test. We only care about beacon/fetch
  // outcomes here, so swallow that one warning class.
  const vc = new VirtualConsole();
  vc.on("jsdomError", (e: Error) => {
    if (!/navigation/i.test(String(e?.message || ""))) throw e;
  });
  const dom = new JSDOM(`<body></body>`,
    { runScripts: "outside-only", virtualConsole: vc });
  const win = dom.window as unknown as Window & typeof globalThis & {
    eval: (s: string) => unknown;
    navigator: Navigator & { sendBeacon?: unknown };
    fetch: typeof fetch;
  };
  const beacons: BeaconCall[] = [];
  const fetches: FetchCall[] = [];
  // sendBeacon: configurable (present + true, present + returns false, or
  // absent). The block's ping() falls through to fetch on either of the
  // latter two — the cases we MUST not silently drop.
  if (opts.beacon !== false) {
    (win.navigator as unknown as { sendBeacon: (u: string, d?: Blob | string) => boolean })
      .sendBeacon = (u: string, d?: Blob | string): boolean => {
        beacons.push({ url: u, data: d ?? null });
        return opts.beacon === true;
      };
  } else {
    try { delete (win.navigator as unknown as { sendBeacon?: unknown }).sendBeacon; }
    catch { /* jsdom navigator getter; the absence is what matters */ }
    (win.navigator as unknown as { sendBeacon: undefined }).sendBeacon = undefined;
  }
  win.fetch = ((url: string, init?: RequestInit) => {
    fetches.push({ url: String(url), init });
    return Promise.resolve(new (win as unknown as { Response: typeof Response })
      .Response("", { status: 204 }));
  }) as typeof fetch;
  return { dom, win, beacons, fetches };
}

function bootBlock(dom: JSDOM): void {
  // Strip the module.exports early-return branch so the IIFE runs the
  // DOM-attached path (event listeners + render loop). jsdom defines no
  // `module` global in the browser ctx, so the existing gate already
  // skips the early-return there — but be explicit for forward safety.
  (dom.window as unknown as { eval: (s: string) => unknown })
    .eval(preparedAsset());
}

function makeAd(doc: Document, opts: { surface?: "overlay" | "banner" } = {}):
  HTMLAnchorElement {
  const wrap = doc.createElement("div");
  if (opts.surface === "banner") wrap.setAttribute("data-vibe-ads-banner", "1");
  else if (opts.surface === "overlay") wrap.setAttribute("data-vibe-ads-overlay", "1");
  const a = doc.createElement("a");
  a.setAttribute("data-vibe-ads-ad", "1");
  a.setAttribute("href", "https://ramp.example/lp?utm=ck");
  a.textContent = "Ramp - Save time & money";
  wrap.appendChild(a);
  doc.body.appendChild(wrap);
  return a;
}

async function flushMicrotasks(): Promise<void> {
  for (let i = 0; i < 5; i++) await Promise.resolve();
}

describe("CC click-through telemetry — sendBeacon-first, fetch fallback",
  () => {
  beforeEach(() => { vi.useRealTimers(); });

  it("sendBeacon path: clicks emit a single beacon to the loopback /click "
    + "endpoint with ct, corr, surface=overlay", async () => {
    const h = makeHarness({ beacon: true });
    bootBlock(h.dom);
    const a = makeAd(h.dom.window.document, { surface: "overlay" });
    a.click();
    await flushMicrotasks();
    expect(h.beacons).toHaveLength(1);
    expect(h.fetches).toHaveLength(0);     // fallback NOT used
    const u = new URL(h.beacons[0].url);
    expect(u.pathname).toBe("/vibe-ads/lt/click");
    expect(u.searchParams.get("ct")).toBe("ctok-abc123");
    expect(u.searchParams.get("corr")).toBe("corr.xyz789");
    expect(u.searchParams.get("surface")).toBe("overlay");
    expect(u.searchParams.get("event_uuid")).toMatch(UUID_RE);
  });

  it("sendBeacon UNAVAILABLE: falls back to fetch+keepalive POST with the "
    + "SAME url shape (silent-drop guard)", async () => {
    const h = makeHarness({ beacon: false });
    bootBlock(h.dom);
    makeAd(h.dom.window.document, { surface: "overlay" }).click();
    await flushMicrotasks();
    expect(h.beacons).toHaveLength(0);
    expect(h.fetches).toHaveLength(1);
    expect(h.fetches[0].init?.method).toBe("POST");
    expect((h.fetches[0].init as unknown as { keepalive: boolean }).keepalive)
      .toBe(true);
    const u = new URL(h.fetches[0].url);
    expect(u.pathname).toBe("/vibe-ads/lt/click");
    expect(u.searchParams.get("ct")).toBe("ctok-abc123");
    expect(u.searchParams.get("surface")).toBe("overlay");
    expect(u.searchParams.get("event_uuid")).toMatch(UUID_RE);
  });

  it("sendBeacon REJECTS (returns false): falls back to fetch — the "
    + "Electron quirk where beacon present but the browser refuses the "
    + "specific request", async () => {
    const h = makeHarness({ beacon: "reject" });
    bootBlock(h.dom);
    makeAd(h.dom.window.document, { surface: "overlay" }).click();
    await flushMicrotasks();
    expect(h.beacons).toHaveLength(1);     // attempted
    expect(h.fetches).toHaveLength(1);     // and fell through
    expect(new URL(h.fetches[0].url).pathname).toBe("/vibe-ads/lt/click");
  });

  it("surface=banner when the click target is INSIDE a data-vibe-ads-banner "
    + "wrapper (ledger attribution must distinguish spinner vs banner)",
    async () => {
    const h = makeHarness({ beacon: true });
    bootBlock(h.dom);
    makeAd(h.dom.window.document, { surface: "banner" }).click();
    await flushMicrotasks();
    expect(h.beacons).toHaveLength(1);
    expect(new URL(h.beacons[0].url).searchParams.get("surface"))
      .toBe("banner");
  });

  it("surface defaults to overlay when no wrapper found (defensive)",
    async () => {
    const h = makeHarness({ beacon: true });
    bootBlock(h.dom);
    // Ad anchor with no banner/overlay wrapper at all.
    const a = h.dom.window.document.createElement("a");
    a.setAttribute("data-vibe-ads-ad", "1");
    a.setAttribute("href", "https://x.example");
    h.dom.window.document.body.appendChild(a);
    a.click();
    await flushMicrotasks();
    expect(new URL(h.beacons[0].url).searchParams.get("surface"))
      .toBe("overlay");
  });

  it("clicks on a NON-ad element emit zero pings (no listener leak)",
    async () => {
    const h = makeHarness({ beacon: true });
    bootBlock(h.dom);
    const div = h.dom.window.document.createElement("div");
    div.textContent = "unrelated chrome";
    h.dom.window.document.body.appendChild(div);
    div.click();
    await flushMicrotasks();
    expect(h.beacons).toHaveLength(0);
    expect(h.fetches).toHaveLength(0);
  });

  it("does NOT preventDefault — the anchor's real href must reach the host "
    + "for the external-open (CSP-exempt navigation is the actual click-out)",
    async () => {
    const h = makeHarness({ beacon: true });
    bootBlock(h.dom);
    const a = makeAd(h.dom.window.document, { surface: "overlay" });
    let defaultPrevented = false;
    a.addEventListener("click", (ev) => {
      defaultPrevented = ev.defaultPrevented;
    }, false);
    a.click();
    await flushMicrotasks();
    expect(defaultPrevented).toBe(false);
  });

  it("click ping carries the ad= attribution CLAIM (wave-4 fix 4): the same "
    + "param the view-event pings send — the pollAd-adopted identifier the "
    + "block keys its _vt sessions on (the ad TEXT) — and a REAL loopback "
    + "lifts it into onClick's claimedAdId", async () => {
    const h = makeHarness({ beacon: true });
    bootBlock(h.dom);
    makeAd(h.dom.window.document, { surface: "overlay" }).click();
    await flushMicrotasks();
    expect(h.beacons).toHaveLength(1);
    const u = new URL(h.beacons[0].url);
    // Without this claim the host's recent-ads registry (audit #17) cannot
    // resolve a click landing in the ≤10s /ad poll-lag window after a
    // rotation — it would bill the freshly-rotated campaign instead.
    expect(u.searchParams.get("ad")).toBe("Ramp - Save time & money");

    // End-to-end: replay the EXACT query the block emitted against a live
    // loopback server and assert the host parses the same param name into
    // onClick's claimedAdId (loopback.ts click route → `ad=` →
    // webviewInjection.resolveAttribution upstream).
    const clicks: Array<{ ct: string; claimedAdId?: string }> = [];
    const lb = new Loopback({
      onEvent: () => { /* not under test */ },
      onClick: (ct, _surface, _visibleMs, _eventUuid, claimedAdId) => {
        clicks.push({ ct, claimedAdId });
      },
      getActivity: () => ({}),
      getCurrentAd: () => null,
    });
    try {
      const { port, token } = await lb.start();
      expect(port).toBeGreaterThan(0);
      const res = await fetch(
        `http://127.0.0.1:${port}/vibe-ads/${token}/click${u.search}`,
        { method: "POST" });
      expect(res.status).toBe(204);
    } finally {
      await lb.stop();
    }
    expect(clicks).toHaveLength(1);
    expect(clicks[0].ct).toBe("ctok-abc123");
    expect(clicks[0].claimedAdId).toBe("Ramp - Save time & money");
  });

  it("clicks on a CHILD element of the ad anchor still attribute correctly "
    + "(the favicon SVG, animated dots span — every child must walk up to "
    + "the [data-vibe-ads-ad] ancestor)", async () => {
    const h = makeHarness({ beacon: true });
    bootBlock(h.dom);
    const a = makeAd(h.dom.window.document, { surface: "overlay" });
    // Simulate the favicon SVG or dots-span child the real ad renders.
    const dot = h.dom.window.document.createElement("span");
    dot.setAttribute("data-va-dots", "1");
    dot.textContent = " ..";
    a.appendChild(dot);
    dot.dispatchEvent(new h.dom.window.MouseEvent("click",
      { bubbles: true, cancelable: true }));
    await flushMicrotasks();
    expect(h.beacons).toHaveLength(1);
    expect(new URL(h.beacons[0].url).searchParams.get("ct"))
      .toBe("ctok-abc123");
  });
});