← back to Kickbacks Ai Upstream
test/cc-viewtimer.test.ts
236 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 CC-webview view-timer billing fixes
// (audit 2026-06-09 findings #8, #15, #23) — the same phantom-billing class
// already fixed in the codex block (see codex-viewtimer.test.ts):
// #8 viewHide was a documented no-op: the dropOverlay / banner-hidden
// paths never ended their _vt session, so a failed dock kept emitting
// view_tick + error_impression every 5s forever. viewHide now ENDS
// the session (codex viewEnd semantics); a later viewShow re-arms a
// FRESH session.
// #15 the banner session kept billing while display:none during turns.
// Hide ends the session; un-hide opens a fresh one. (Banner billing
// while VISIBLE at idle is by design and unchanged.)
// #23 laptop suspend was billed as visible time and replayed as a
// synchronous view_tick burst on wake (absolute-epoch elapsed +
// unbounded catch-up loop). viewTick now clamps poll gaps > 2 tick
// intervals: the excess shifts the baseline (never billed) and at
// most ONE catch-up tick fires per wake.
//
// Harness: the block runs in JSDOM on its real 250ms/1s intervals, but the
// webview realm's Date.now is test-controlled, so "elapsed" view-time (and
// an 8h suspend) advance only when the test says so — deterministic billing
// boundaries without 5s real-time sleeps.
const ASSET = readFileSync(
join(__dirname, "..", "src", "adapters", "claude-code", "block.asset.js"),
"utf8");
function preparedAsset(opts: { bannerOn: boolean }): 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__: opts.bannerOn ? "true" : "false",
__VIBE_ADS_CORR__: JSON.stringify("ad1.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(opts: { bannerOn: boolean }): Harness {
const vc = new VirtualConsole();
vc.on("jsdomError", () => { /* anchor navigation etc. — 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) => {
pings.push(String(url));
return Promise.resolve({ json: async () => ({}) });
}) as unknown as typeof fetch;
// Take over the webview realm's wall clock BEFORE booting the block: every
// billing decision in the asset reads Date.now(). The real setInterval
// polls keep firing underneath; only "elapsed" is virtual.
let t = 1_700_000_000_000;
(win as unknown as { Date: DateConstructor }).Date.now = () => t;
win.eval(preparedAsset(opts));
return { dom, doc: dom.window.document, pings,
advance: (ms: number) => { t += ms; } };
}
// A live CC spinner row. The block treats the row as ACTIVE only while its
// leading sparkle glyph keeps CHANGING within GRACE_MS (fake-clock ms), so:
// with the clock frozen it stays active for free; every advance() > GRACE_MS
// must be paired with spin() (same synchronous step) to stay "thinking", or
// left un-spun to go idle.
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…";
} };
}
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 THRESHOLD = /\/view_threshold_met\?/;
function visibleMs(u: string): number {
const m = /[?&]visible_ms=(\d+)/.exec(u);
return m ? Number(m[1]) : -1;
}
describe("CC view-timer billing fixes (audit #8/#15/#23)", () => {
it("#8: a failed dock (composer miss) ENDS the overlay session — no "
+ "view_tick/error_impression keeps firing off-screen; the next turn "
+ "re-arms a FRESH session", async () => {
const h = makeHarness({ bannerOn: false });
const sp = addSpinner(h.doc);
await sleep(400); // evaluate() paints + viewShow
expect(h.doc.querySelector('[data-vibe-ads-overlay="1"]')).toBeTruthy();
h.advance(5_100); sp.spin(); // cross one tick boundary, active
await sleep(500);
expect(count(h.pings, OVERLAY_TICK)).toBe(1);
// Turn ends; jsdom has no visible composer → dock_miss → dropOverlay
// (exactly the finding-#8 failure path). The glyph is NOT rotated so the
// row goes stale past GRACE_MS.
h.advance(2_000);
await sleep(500);
expect(h.doc.querySelector('[data-vibe-ads-overlay="1"]')).toBeNull();
const ticksAtDrop = count(h.pings, OVERLAY_TICK);
const errsAtDrop = count(h.pings, ERROR);
// OLD behavior: the no-op viewHide left the session immortal — this
// jump crossed the 10s and 15s boundaries and kept emitting. An ENDED
// session emits NOTHING.
h.advance(9_900);
await sleep(500);
expect(count(h.pings, OVERLAY_TICK)).toBe(ticksAtDrop);
expect(count(h.pings, ERROR)).toBe(errsAtDrop);
// A new turn re-glues and opens a FRESH session: its first tick reports
// visible_ms=5000, not a carried-over total.
sp.spin();
await sleep(400);
expect(h.doc.querySelector('[data-vibe-ads-overlay="1"]')).toBeTruthy();
h.advance(5_100); sp.spin();
await sleep(500);
const ticks = h.pings.filter((u) => OVERLAY_TICK.test(u));
expect(ticks.length).toBe(ticksAtDrop + 1);
expect(visibleMs(ticks[ticks.length - 1])).toBe(5_000);
h.dom.window.close();
}, 20000);
it("#15: the banner session ENDS while hidden during a turn "
+ "(display:none must not bill); un-hide at idle opens a FRESH session",
async () => {
const h = makeHarness({ bannerOn: true });
addBanner(h.doc);
await sleep(1_300); // banner loop is 1s
expect(h.doc.querySelector('[data-vibe-ads-banner="1"]')).toBeTruthy();
// Visible-at-idle billing is BY DESIGN and must keep working.
h.advance(5_100);
await sleep(500);
expect(count(h.pings, BANNER_TICK)).toBe(1);
// A turn starts → banner hides → its session must END.
const sp = addSpinner(h.doc);
await sleep(1_300); // ≥1 banner tick: hide + end
const bEl = h.doc.querySelector(
'[data-vibe-ads-banner="1"]') as HTMLElement;
expect(bEl.style.display).toBe("none");
const hiddenAt = count(h.pings, BANNER_TICK);
// OLD behavior: the hidden banner's session kept passing the paused
// gate and ticked at the 10s/15s boundaries of this jump.
h.advance(9_900); sp.spin(); // stay in-turn across the jump
await sleep(700);
expect(count(h.pings, BANNER_TICK)).toBe(hiddenAt);
// Turn ends (no composer → overlay drops, _spinnerActive clears) →
// banner un-hides and a FRESH session starts at 0.
h.advance(2_000); // glyph frozen → idle → drop
await sleep(1_500);
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(hiddenAt + 1);
expect(visibleMs(ticks[ticks.length - 1])).toBe(5_000); // fresh baseline
h.dom.window.close();
}, 20000);
it("#23: an 8h suspend is NOT billed as visible time and NOT replayed as "
+ "a tick burst on wake (at most one catch-up tick; session survives)",
async () => {
const h = makeHarness({ bannerOn: true });
addBanner(h.doc);
await sleep(1_300); // banner session live at idle
h.advance(5_100);
await sleep(500);
expect(count(h.pings, BANNER_TICK)).toBe(1);
// Laptop suspends for 8 hours with the session live. OLD behavior:
// the wake poll replayed one view_tick per 5s of sleep (~5760 events)
// each reporting machine-off time as cumulative visible_ms.
h.advance(8 * 3_600_000);
await sleep(700);
const ticks = h.pings.filter((u) => BANNER_TICK.test(u));
expect(ticks.length).toBe(2); // exactly ONE catch-up tick
expect(visibleMs(ticks[1])).toBe(10_000); // sleep excluded, not ~8h
expect(count(h.pings, ERROR)).toBe(0);
expect(count(h.pings, THRESHOLD)).toBe(0);
// Billing continues normally after wake — the baseline was shifted
// past the sleep, not the session torn down.
h.advance(5_000);
await sleep(500);
const after = h.pings.filter((u) => BANNER_TICK.test(u));
expect(after.length).toBe(3);
expect(visibleMs(after[2])).toBe(15_000);
h.dom.window.close();
}, 20000);
});