← back to Kickbacks Ai Upstream

test/codex-findrow.test.ts

190 lines

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { JSDOM } from "jsdom";

// Regression coverage for the codex-e2e row 03 + row 08 failures: Codex 26.x
// leaves a hidden "Thinking 1.2s" summary chip in chat history after a turn
// completes, with the same `text-size-chat truncate select-none` class trio
// the live shimmer uses. The old findRow() matched the stale chip and
// isThinkingRow() agreed → overlay never released at idle (row 03) and stuck
// to turn 1 on multi-turn prompts (row 08, "glued to turn 1").
//
// Fix is in extension/src/adapters/codex/block.asset.js: findRow() now also
// requires `loading-shimmer` in the className AND a non-zero bounding rect.

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

function preparedAsset(): string {
  // Mirror CodexAdapter.renderBlock placeholder substitution (we only need
  // the IIFE body to run inside JSDOM; markers stripped since the bare asset
  // is itself a valid IIFE expression).
  const subs: Record<string, string> = {
    __VIBE_ADS_AD__: JSON.stringify("Ramp - Save time & money"),
    __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("ct"),
    __VIBE_ADS_CLICKURL__: JSON.stringify("https://ramp.example/lp"),
    __VIBE_ADS_CORR__: JSON.stringify("test.codex"),
    __VIBE_ADS_DEBUG__: "false",
    __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
    __VIBE_ADS_ARG__: "e",        // unused by overlay path
    __VIBE_ADS_JSX__: "d",        // unused by overlay path
  };
  let src = ASSET;
  for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
  return src;
}

function makeDom(): { dom: JSDOM; doc: Document; mc: HTMLElement } {
  const dom = new JSDOM(
    `<body><div id="mc" class="chatpanel"></div></body>`,
    { runScripts: "outside-only", pretendToBeVisual: true });
  // Stub fetch — the asset best-effort-pings the loopback; we just no-op.
  (dom.window as unknown as { fetch: typeof fetch }).fetch =
    (() => Promise.resolve(new (dom.window as unknown as { Response: typeof Response }).Response("", { status: 204 }))) as typeof fetch;
  // Force rects: jsdom returns 0×0 for layout. We patch getBoundingClientRect
  // on a per-element basis below to model live vs hidden states.
  const doc = dom.window.document;
  return { dom, doc, mc: doc.getElementById("mc") as HTMLElement };
}

function bootAsset(dom: JSDOM): void {
  // eval inside the JSDOM window ctx — same pattern as test/e2e.test.ts.
  // A <script>-tag inject would require runScripts:"dangerously" and is
  // strictly slower because jsdom rebuilds the parser path.
  (dom.window as unknown as { eval: (src: string) => unknown })
    .eval(preparedAsset());
}

// Helper: stamp a fixed rect on an element so the asset's
// getBoundingClientRect() visibility filter sees the shape we want.
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 sleep(ms: number): Promise<void> {
  return new Promise((res) => setTimeout(res, ms));
}

// Real codex 26.x shimmer markup, copied from a captured codex.show debug
// line: class="loading-shimmer-pure-text _cadencedShimmer_1bpr9_1 text-
// size-chat leading-[1.5] select-none truncate". textContent renders as
// "ThinkingThinking" because the shimmer stacks two spans (base + sweep).
function makeLiveShimmer(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";
  // Two stacked spans → textContent = "ThinkingThinking"
  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;
}

// The post-turn "Thinking 1.2s" summary chip Codex leaves in chat history
// after streaming ends. Same class trio (text-size-chat / truncate /
// select-none) BUT no loading-shimmer-* class and (typically) display:none
// → zero rect.
function makeStaleSummaryChip(doc: Document): HTMLElement {
  const el = doc.createElement("span");
  el.className = "text-size-chat leading-[1.5] select-none truncate";
  el.textContent = "Thinking 1.2s";
  setRect(el, { x: 0, y: 0, w: 0, h: 0 });    // display:none-equivalent
  return el;
}

describe("S9 codex findRow — live shimmer only, never stale chip", () => {
  it("paints overlay on a live shimmer row", async () => {
    const { dom, doc, mc } = makeDom();
    mc.appendChild(makeLiveShimmer(doc));
    bootAsset(dom);
    await sleep(200);                          // > 80ms interval tick
    const overlay = doc.querySelector('[data-vibe-ads="codex"]');
    expect(overlay).toBeTruthy();
    expect(overlay!.querySelector('[data-vibe-ads-ad]')).toBeTruthy();
    dom.window.close();
  });

  it("never paints on a post-turn 'Thinking 1.2s' summary chip alone",
    async () => {
    const { dom, doc, mc } = makeDom();
    mc.appendChild(makeStaleSummaryChip(doc));   // only the stale chip
    bootAsset(dom);
    await sleep(200);
    const overlay = doc.querySelector('[data-vibe-ads="codex"]');
    expect(overlay).toBeNull();
    dom.window.close();
  });

  it("picks the LIVE shimmer when both a stale chip AND a live shimmer "
    + "exist (row 08: 'glued to turn 1' regression guard)", async () => {
    const { dom, doc, mc } = makeDom();
    // Stale chip comes FIRST in DOM order — the bug had findRow returning
    // it because it matched the class trio. With the fix, the live
    // shimmer (which carries loading-shimmer + non-zero rect) wins.
    const stale = makeStaleSummaryChip(doc);
    const live = makeLiveShimmer(doc);
    mc.appendChild(stale);
    mc.appendChild(live);
    bootAsset(dom);
    await sleep(200);
    const overlay = doc.querySelector('[data-vibe-ads="codex"]') as
      HTMLElement | null;
    expect(overlay).toBeTruthy();
    // Overlay rect must align with the LIVE shimmer, not the stale chip.
    // The asset reads getBoundingClientRect().left/top off lastRow and
    // pins overlay.style.left/top to it; live rect is x=100,y=200.
    expect(overlay!.style.left).toBe("100px");
    expect(overlay!.style.top).toBe("200px");
    dom.window.close();
  });

  it("drops the overlay when the live shimmer becomes hidden "
    + "(row 03: idle-release regression guard)", async () => {
    const { dom, doc, mc } = makeDom();
    const live = makeLiveShimmer(doc);
    mc.appendChild(live);
    bootAsset(dom);
    await sleep(200);
    expect(doc.querySelector('[data-vibe-ads="codex"]')).toBeTruthy();
    // Simulate Codex finishing the turn: rect collapses to 0×0
    // (Codex applies display:none on the shimmer). The current row
    // class trio (text-size-chat/truncate/select-none) survives but
    // loading-shimmer-* drops or the rect drops to zero. Either way,
    // findRow() must release.
    setRect(live, { x: 0, y: 0, w: 0, h: 0 });
    // Walk past GRACE_MS=1500 so the idle-drop branch fires.
    await sleep(1800);
    expect(doc.querySelector('[data-vibe-ads="codex"]')).toBeNull();
    dom.window.close();
  });

  it("does NOT paint on a live-shimmer-class row that's visibility:hidden "
    + "(Codex 26 post-stream invisible-but-still-mounted state — row 03 "
    + "second-line regression guard)", async () => {
    const { dom, doc, mc } = makeDom();
    const live = makeLiveShimmer(doc);
    // Codex 26 transitional state: live shimmer remains mounted with the
    // sweep class on, has a non-zero rect (box reserved), but is invisible
    // because the chat panel marked it visibility:hidden. The old rect-
    // only filter would happily paint on this and the dot-cycle would
    // keep going past the harness's "idle confirmed" gate, false-FAILing
    // row 03 even after the class+rect filters were added.
    live.style.visibility = "hidden";
    mc.appendChild(live);
    bootAsset(dom);
    await sleep(200);
    expect(doc.querySelector('[data-vibe-ads="codex"]')).toBeNull();
    dom.window.close();
  });
});