← back to Kickbacks Ai Upstream
test/cc-spinner-detect.test.ts
170 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 audit 2026-06-09 finding #28: findSpinner returned
// the FIRST non-empty `.spinnerRow_` element in document order. CC can leave
// a STALE prior row frozen mid-glyph at turn end (the asset's own GRACE_MS
// comment documents this) while keeping the node mounted — so in turn N+1
// the live animating row mounts BELOW the stale one, the stale row shadows
// it, the global freshness signature never changes, and the ad is suppressed
// for the entire turn. The fix prefers the LAST non-empty row among the rows
// the EXISTING selector already matches (the transcript appends, so the live
// row is the latest). PRIME DIRECTIVE: the selector and the observation
// scope must NOT grow — these tests also pin the single-row / zero-row /
// emptied-row behaviors unchanged.
//
// Harness: same shape as cc-viewtimer.test.ts — the block runs in JSDOM on
// its real 80ms/250ms intervals, the realm's Date.now is test-controlled, so
// freshness (GRACE_MS) advances only when the test says so.
const ASSET = readFileSync(
join(__dirname, "..", "src", "adapters", "claude-code", "block.asset.js"),
"utf8");
function preparedAsset(): string {
const subs: Record<string, string> = {
__VIBE_ADS_TIER__: "3",
__VIBE_ADS_AD__: JSON.stringify("Acme deploys faster than your CI"),
__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("https://acme.example/lp"),
__VIBE_ADS_BANNER_ON__: "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;
}
function makeHarness(): 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;
};
const pings: string[] = [];
win.fetch = ((url: string) => {
const u = String(url);
// /ad must keep serving a REAL payload: the no-serve teardown (wave-4
// fix 3) must never engage in these detection-only tests.
if (u.endsWith("/ad")) {
return Promise.resolve({ json: async () => ({
adText: "Acme deploys faster than your CI",
clickUrl: "https://acme.example/lp", iconUrl: "",
adId: "adA", campaignId: "c1" }) });
}
if (u.endsWith("/activity")) {
return Promise.resolve({ json: async () => ({}) });
}
pings.push(u);
return Promise.resolve({ json: async () => ({}) });
}) as unknown as typeof fetch;
let t = 1_700_000_000_000;
(win as unknown as { Date: DateConstructor }).Date.now = () => t;
win.eval(preparedAsset());
return { dom, doc: dom.window.document, pings,
advance: (ms: number) => { t += ms; } };
}
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…";
} };
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const overlayOf = (doc: Document) =>
doc.querySelector('[data-vibe-ads-overlay="1"]');
const count = (pings: string[], re: RegExp) =>
pings.filter((u) => re.test(u)).length;
const OVERLAY_TICK = /\/view_tick\?surface=overlay&/;
describe("CC spinner detection — last non-empty row wins (audit #28)", () => {
it("a stale frozen row ABOVE must not shadow the live row BELOW: the "
+ "live (latest) row is chosen, the ad mounts and bills the turn",
async () => {
const h = makeHarness();
// Turn N's leftover: a non-empty row frozen mid-glyph (✻ never changes),
// still mounted at the TOP of the transcript.
const stale = h.doc.createElement("div");
stale.className = "spinnerRow_ab12c";
stale.textContent = "✻ Baking… (12s)";
h.doc.body.appendChild(stale);
await sleep(300);
// The frozen row alone looks fresh on first sighting, then goes stale
// past GRACE_MS → idle → dock → no composer in jsdom → drop.
h.advance(2_000);
await sleep(400);
expect(overlayOf(h.doc)).toBeNull();
// Turn N+1: the LIVE row mounts BELOW the stale one and animates
// (initial glyph ✢ ≠ the frozen ✻).
const live = addSpinner(h.doc);
await sleep(400);
// OLD behavior: findSpinner returned the FIRST non-empty row (the
// frozen one), its signature never changed, paint() never ran — the ad
// stayed suppressed for the whole turn. NEW: the LAST non-empty row is
// the live one; the overlay mounts.
expect(overlayOf(h.doc)).toBeTruthy();
// …and the turn bills normally (the live row's session is counting).
h.advance(5_100); live.spin();
await sleep(500);
expect(count(h.pings, OVERLAY_TICK)).toBe(1);
h.dom.window.close();
}, 20000);
it("single-row case unchanged: one live row shows the ad; frozen past "
+ "GRACE it goes idle (dock-miss → drop in jsdom)", async () => {
const h = makeHarness();
addSpinner(h.doc);
await sleep(300);
expect(overlayOf(h.doc)).toBeTruthy();
h.advance(2_000); // glyph never rotated → stale
await sleep(400);
expect(overlayOf(h.doc)).toBeNull();
h.dom.window.close();
}, 20000);
it("zero-row and emptied-row cases unchanged: no spinnerRow (or only "
+ "CC's emptied `<div></div>` turn-end row) never paints", async () => {
const h = makeHarness();
await sleep(300);
expect(overlayOf(h.doc)).toBeNull();
// CC's turn-end state: the row stays mounted but emptied — NOT a
// candidate (whitespace-only is "no spinner").
const emptied = h.doc.createElement("div");
emptied.className = "spinnerRow_ab12c";
emptied.textContent = "";
h.doc.body.appendChild(emptied);
await sleep(300);
expect(overlayOf(h.doc)).toBeNull();
expect(count(h.pings, /impression_rendered/)).toBe(0);
h.dom.window.close();
}, 20000);
});