← back to Kickbacks Ai Upstream

test/servingRetry.test.ts

160 lines

// Serving bring-up retry (audit #5) + the Wave-2 auditor carry-over.
//
// Pre-fix, a backend flap at activation (documented cold-start 502/503),
// momentarily-empty inventory, or a fail-safed kill probe made
// setupWebviewInjection return {lbInfo:null, cycleReassert:null,
// refreshPortfolioNow:null} and NOTHING ever retried — serving was dead
// until a window reload. The carry-over: a persisted-kill boot skipped
// serving, and when checkKill later cleared the posture, showActive painted
// green while nothing could serve. extension.ts now retries the bring-up
// (portfolio fetch + webview injection) on a bounded-exponential-backoff
// loop that skips — but stays alive — while the serving gate is not "write".
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { rmSync } from "node:fs";

// Hermetic ~/.claude/settings.json — activate() wires the claude-cli
// statusline adapter (cliSync + the audit-#22 early-return path) at the
// developer's REAL settings file; mock it so retries never touch disk.
vi.mock("../src/adapters/claude-cli/adapter", () => ({
  resolveStatuslineAsset: () => "",
  ClaudeCliStatuslineAdapter: class {
    name = "claude-cli-statusline";
    spinnerVerbsSupported = true;
    version() { return "cli"; }
    preflight() { return { ok: true, compatible: true, version: "cli" }; }
    applyPatch() { return { ok: true }; }
    restore() { return { ok: true, restored: false }; }
  },
}));
// No real `claude --version` spawn per activation.
vi.mock("../src/adapters/claude-cli/cliVersion", () => ({
  detectClaudeCliSpinnerSupport:
    async () => ({ ok: true, version: "2.1.150", outdated: false }),
}));

import { activate, deactivate, __wireForTest } from "../src/extension";
import { makeContext, secrets, commands } from "./mocks/vscode";
import { setKillPosture } from "../src/servingGate";

// The production webview apply is the one that carries the portfolio ad's
// text — the boot-canary debug cycle patches with the DEFAULT_TEXT
// placeholder (setPortfolioAd is wired after bootCanary), so filtering on
// this string isolates exactly the bring-up under test.
const AD_TEXT = "RETRY-AD …";

const mkAdapter = () => ({
  name: "claude-code" as const,
  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
  version: () => "2.1.143",
  isPatched: () => false,
  applyPatch: vi.fn((_p: unknown) => ({ ok: true })),
  restore: vi.fn(() => ({ ok: true, restored: true })),
});

// Guarantee the clean-boot canary branch (a recent canary from a parallel
// test worker would otherwise suspend serving and gate the retry loop).
const clearBootCanary = (): void => {
  try { rmSync(join(homedir(), ".vibe-ads", "boot.canary"), { force: true }); }
  catch { /* best-effort */ }
};

// Controllable backend: `state.hasAd` decides whether ANY portfolio fetch
// (authed or demo) returns inventory; the killswitch answers killed:false.
function stubFetch(state: { hasAd: boolean }) {
  const f = vi.fn(async (input: unknown) => {
    const url = typeof input === "string" ? input : String(input);
    if (url.includes("/v1/killswitch")) {
      return { ok: true, status: 200,
        json: async () => ({ killed: false }) } as Response;
    }
    if (url.includes("/v1/portfolio")) {
      return { ok: true, status: 200, json: async () => ({
        ttl_seconds: 30, view_threshold_seconds: 15,
        ads: state.hasAd ? [{ ad_id: "ad-retry", campaign_id: "camp-retry",
          title_text: AD_TEXT, icon_ref: "i",
          click_url: "https://example.com" }] : [],
      }) } as Response;
    }
    return { ok: true, status: 204, json: async () => ({}) } as Response;
  });
  vi.stubGlobal("fetch", f);
  return f;
}

const prodApplies = (adapter: ReturnType<typeof mkAdapter>): number =>
  adapter.applyPatch.mock.calls.filter(
    (c) => (c[0] as { adText?: string } | undefined)?.adText === AD_TEXT,
  ).length;

beforeEach(() => {
  secrets.clear();
  commands._handlers.clear();
  commands._executed.length = 0;
  __wireForTest({});
  clearBootCanary();
});

afterEach(() => { vi.unstubAllGlobals(); });

describe("serving bring-up retry (audit #5)", { timeout: 30_000 }, () => {

  it("empty inventory at activation: the retry loop brings serving up once"
    + " ads appear (pre-fix: dead until window reload)", async () => {
    const adapter = mkAdapter();
    const statusBar = { set: vi.fn(), dispose: vi.fn() };
    const state = { hasAd: false };
    stubFetch(state);
    __wireForTest({ adapter, statusBar, servingRetryBaseMs: 25 });
    const ctx = makeContext();
    try {
      await activate(ctx as never);
      expect(prodApplies(adapter),
        "no production apply while inventory is empty").toBe(0);
      // Inventory appears (backend recovered / first block funded).
      state.hasAd = true;
      await vi.waitFor(() => {
        expect(prodApplies(adapter),
          "the retry loop must bring serving up").toBeGreaterThan(0);
      }, { timeout: 15_000, interval: 20 });
      // The loop stopped permanently on success: no further bring-up
      // re-applies within several more would-be retry ticks.
      const n = prodApplies(adapter);
      await new Promise((r) => setTimeout(r, 400));
      expect(prodApplies(adapter), "the loop must stop on success").toBe(n);
    } finally { await deactivate(); }
  });

  it("Wave-2 carry-over: a kill at boot skips serving; the gated retry loop"
    + " stays alive and brings serving up IN-SESSION once the posture"
    + " clears (pre-fix: green status bar, nothing serving)", async () => {
    const adapter = mkAdapter();
    const statusBar = { set: vi.fn(), dispose: vi.fn() };
    const state = { hasAd: true };
    stubFetch(state);
    __wireForTest({ adapter, statusBar, killed: true, servingRetryBaseMs: 25 });
    const ctx = makeContext();
    await ctx.secrets.store("kickbacks.access", "AT-RETRY");
    try {
      await activate(ctx as never);
      // Several gated retry ticks elapse — killed-confirmed must SKIP the
      // attempt (no production apply) without ending the loop.
      await new Promise((r) => setTimeout(r, 150));
      expect(prodApplies(adapter),
        "killed boot must not bring up serving").toBe(0);
      // Recovery: a live 200 killed:false from checkKill clears the posture
      // and the killed flag — simulate exactly that transition (the 30s
      // checkKill interval is too slow for a unit test).
      __wireForTest({ adapter, statusBar, killed: false,
        servingRetryBaseMs: 25 });
      setKillPosture("clear");
      await vi.waitFor(() => {
        expect(prodApplies(adapter),
          "recovery must bring serving up without a reload")
          .toBeGreaterThan(0);
      }, { timeout: 15_000, interval: 20 });
    } finally { await deactivate(); }
  });
});