← back to Kickbacks Ai Upstream

test/loopbackSingleton.test.ts

139 lines

/** Audit #7 (wave 3): ONE loopback server per extension host.
 *
 *  Pre-fix, every normal boot ran TWO loopbacks: the boot-canary debug
 *  apply() bound the persisted stable port P first and held it all session,
 *  so the production loopback EADDRINUSE'd onto P+1 and OVERWROTE the
 *  "stable" port — the persisted port crept +1 per session, and a stale
 *  webview from the prior session (whose patch baked port P) reconnected to
 *  the DEBUG server whose wiring drops demo billing and blinds the desync
 *  watchdog. bootLoopback() now shares the first successfully-bound server:
 *  later callers get the SAME port/token/base, and the production (primary)
 *  caller takes the routes over via a live handler swap. The /ad
 *  canServeAds gating (wave 2) lives in the HANDLER closures themselves
 *  (debug.ts / webviewInjection.ts), so it rides along with whichever
 *  handler set is live — pinned in servingGate.test.ts and debug.test.ts. */
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Loopback, type LoopbackHandlers } from "../src/loopback";
import { bootLoopback, resetSharedLoopbackForTest }
  from "../src/util/loopbackBoot";
import { makeContext } from "./mocks/vscode";

const PORT_KEY = "kickbacks.loopback.port";

function mkHandlers(tag: string, log: string[]): LoopbackHandlers {
  return {
    onEvent: (k) => log.push(`${tag}:${k}`),
    onClick: () => log.push(`${tag}:click`),
    getActivity: () => ({ tag }),
    getCurrentAd: () => ({ adText: `${tag}-ad`, clickUrl: "https://x.test",
      iconUrl: "", adId: tag, campaignId: tag }),
  };
}

let live: Loopback[] = [];
const track = (lb: Loopback): Loopback => { live.push(lb); return lb; };

beforeEach(() => { resetSharedLoopbackForTest(); });
afterEach(async () => {
  for (const lb of live) await lb.stop();
  live = [];
  resetSharedLoopbackForTest();
});

describe("bootLoopback shared server (audit #7)", () => {
  it("two sequential consumers get the SAME port and ONE server; the "
    + "production (primary) wiring takes over /ad from the debug stub", async () => {
    const ctx = makeContext();
    const log: string[] = [];
    // Boot order of a normal session: debug stub first (boot canary)…
    const debugLb = track(new Loopback(mkHandlers("debug", log)));
    const a = await bootLoopback(debugLb, ctx as never, { secondary: true });
    expect(a.port).toBeGreaterThan(0);
    // …then the production loopback. Pre-fix this EADDRINUSE'd onto port+1.
    const prodLb = track(new Loopback(mkHandlers("prod", log)));
    const b = await bootLoopback(prodLb, ctx as never);
    expect(b.port).toBe(a.port);
    expect(b.token).toBe(a.token);
    expect(b.base).toBe(a.base);
    // ONE server: production shares the debug-owned bind, no second listen.
    expect(debugLb.isRunning()).toBe(true);
    expect(prodLb.isRunning()).toBe(false);
    // The production handlers now serve the shared routes (the takeover):
    // a stale webview reconnecting to the stable port reaches PRODUCTION
    // wiring, not the debug stub.
    const ad = await (await fetch(`${b.base}/ad`)).json();
    expect(ad.adId).toBe("prod");
    await fetch(`${b.base}/view_tick?surface=overlay&visible_ms=5000`,
      { method: "POST" });
    expect(log).toContain("prod:view_tick");
    expect(log.filter((l) => l.startsWith("debug:"))).toEqual([]);
    // The persisted stable port was NOT overwritten with port+1.
    expect(ctx.globalState.get<number>(PORT_KEY)).toBe(a.port);
  });

  it("a later SECONDARY (debug) registrant never displaces the primary's "
    + "handlers — but still shares the port", async () => {
    const ctx = makeContext();
    const log: string[] = [];
    // K_ON=false boot: production boots first…
    const prodLb = track(new Loopback(mkHandlers("prod", log)));
    const a = await bootLoopback(prodLb, ctx as never);
    expect(a.port).toBeGreaterThan(0);
    // …then the user enables Kickbacks → debug apply() boots its stub.
    const debugLb = track(new Loopback(mkHandlers("debug", log)));
    const b = await bootLoopback(debugLb, ctx as never, { secondary: true });
    expect(b.port).toBe(a.port);
    expect(debugLb.isRunning()).toBe(false);
    // Production wiring (the billing authority) keeps the routes.
    const ad = await (await fetch(`${a.base}/ad`)).json();
    expect(ad.adId).toBe("prod");
  });

  it("restart simulation: the persisted stable port does NOT creep across "
    + "sessions (debug-then-production each session re-binds the SAME port)",
  async () => {
    const ctx = makeContext();   // ONE globalState across both "sessions"
    const log: string[] = [];
    // ── Session 1: normal boot order, both consumers.
    const s1debug = track(new Loopback(mkHandlers("debug", log)));
    const s1a = await bootLoopback(s1debug, ctx as never, { secondary: true });
    const s1prod = track(new Loopback(mkHandlers("prod", log)));
    const s1b = await bootLoopback(s1prod, ctx as never);
    const P = s1a.port;
    expect(P).toBeGreaterThan(0);
    expect(s1b.port).toBe(P);
    expect(ctx.globalState.get<number>(PORT_KEY)).toBe(P);
    // ── Host restart: server torn down, module state rebuilt.
    await s1debug.stop(); await s1prod.stop();
    resetSharedLoopbackForTest();
    // ── Session 2: same boot order against the SAME persisted state.
    const s2debug = track(new Loopback(mkHandlers("debug", log)));
    const s2a = await bootLoopback(s2debug, ctx as never, { secondary: true });
    const s2prod = track(new Loopback(mkHandlers("prod", log)));
    const s2b = await bootLoopback(s2prod, ctx as never);
    // Pre-fix: session 1 persisted P+1, so session 2 bound P+1 / P+2 — the
    // creep that broke last session's baked-in patch URL every reload.
    expect(s2a.port).toBe(P);
    expect(s2b.port).toBe(P);
    expect(ctx.globalState.get<number>(PORT_KEY)).toBe(P);
  });

  it("a stopped owner doesn't poison the share: the next consumer re-binds "
    + "fresh on the same stable port", async () => {
    const ctx = makeContext();
    const log: string[] = [];
    const lb1 = track(new Loopback(mkHandlers("one", log)));
    const a = await bootLoopback(lb1, ctx as never);
    expect(a.port).toBeGreaterThan(0);
    await lb1.stop();
    // The shared record now points at a dead server → fresh bind, not the
    // stale cached result.
    const lb2 = track(new Loopback(mkHandlers("two", log)));
    const b = await bootLoopback(lb2, ctx as never);
    expect(lb2.isRunning()).toBe(true);
    expect(b.port).toBe(a.port);   // preferred-port reuse, same stable port
    const ad = await (await fetch(`${b.base}/ad`)).json();
    expect(ad.adId).toBe("two");
  });
});