← back to Kickbacks Ai Upstream
test/loopbackTest.test.ts
234 lines
// /test/<name> driver routes on the loopback: lets external scripts (curl,
// CI runners) fire every kickbacks.test.* hook against a running extension
// without going through VS Code's command dispatcher. The extension wires
// these up in activate() (extension.ts) by passing `onTestRoute` to the
// Loopback constructor; this suite drives the contract directly against a
// real Loopback + TestHooks pair so it stays hermetic.
import { describe, it, expect, vi, afterEach } from "vitest";
import { Loopback } from "../src/loopback";
import { TestHooks, type TestHooksContext } from "../src/testHooks";
import { MetricsClient } from "../src/metrics/client";
import { PortfolioClient, type PatchAd } from "../src/portfolio/client";
import { EarningsClient } from "../src/earnings/client";
const AD: PatchAd = { adId: "ad-route", campaignId: "c-route",
adText: "x", iconRef: "i", iconUrl: "", clickUrl: "https://x", bannerEnabled: false,
sessionToken: "test-token" };
let lb: Loopback | null = null;
afterEach(async () => { if (lb) { await lb.stop(); lb = null; } });
function wire(opts: { adOverride?: PatchAd | null;
viewThresholdMs?: number } = {}) {
const calls: { url: string; method: string;
body?: unknown; headers: Record<string, string> }[] = [];
const fakeFetch = vi.fn(async (url: string, init?: { method?: string;
body?: string; headers?: Record<string, string> }) => {
const method = (init?.method || "GET").toUpperCase();
const body = init?.body ? (() => {
try { return JSON.parse(init.body!); } catch { return init.body; } })() : undefined;
calls.push({ url, method, body, headers: init?.headers || {} });
if (url.includes("/v1/portfolio")) {
return { ok: true, status: 200, json: async () => ({
ttl_seconds: 30, view_threshold_seconds: 18,
ads: [{ ad_id: "ad-fresh", campaign_id: "c-fresh",
title_text: "x", icon_ref: "i", click_url: "https://x" }],
}) } as Response;
}
if (url.includes("/v1/earnings")) {
return { ok: true, status: 200, json: async () =>
({ lifetime_usd: "5.00", today_usd: "0.50" }) } as Response;
}
return { ok: true, status: 204, json: async () => ({}) } as Response;
});
const metrics = new MetricsClient("http://b", () => "tok",
() => "cid", "0.1.0", fakeFetch as never);
const portfolio = new PortfolioClient("http://b", () => "tok",
fakeFetch as never);
const earnings = new EarningsClient("http://b", () => "tok",
fakeFetch as never);
const ctx: TestHooksContext = {
ad: opts.adOverride === undefined ? AD : opts.adOverride,
signedIn: true,
killed: false,
ccVersion: "2.1.143",
viewThresholdMs: opts.viewThresholdMs ?? 15000,
loopback: null,
};
const hooks = new TestHooks(metrics, portfolio, earnings, () => ctx);
return { hooks, calls };
}
async function makeServer(hooks: TestHooks): Promise<string> {
lb = new Loopback({
onEvent: () => {},
onClick: () => {},
getActivity: () => ({}),
getCurrentAd: () => null,
onTestRoute: (n, p) => hooks.handleTestRoute(n, p),
});
const { port, token } = await lb.start();
return `http://127.0.0.1:${port}/vibe-ads/${token}`;
}
describe("loopback /test/<name> driver routes", () => {
it("fireClick returns 200 with FireResult JSON and POSTs /v1/metrics",
async () => {
const { hooks, calls } = wire();
const base = await makeServer(hooks);
const r = await fetch(`${base}/test/fireClick?surface=banner&ct=mytok`);
expect(r.status).toBe(200);
const body = await r.json() as { ok: boolean; sent: { event: string; surface: string; adId: string } };
expect(body.ok).toBe(true);
expect(body.sent.event).toBe("click");
expect(body.sent.surface).toBe("banner");
expect(body.sent.adId).toBe("ad-route");
const metricsPosts = calls.filter((c) => c.url.endsWith("/v1/metrics"));
expect(metricsPosts).toHaveLength(1);
expect(metricsPosts[0].body).toMatchObject({
event_type: "click", ad_id: "ad-route", campaign_id: "c-route",
surface: "banner",
});
});
it("fireImpressionRendered / fireImpressionViewable / fireViewTick /"
+ " fireViewThresholdMet each round-trip through the route and into"
+ " MetricsClient (every event type exercised over HTTP)", async () => {
const { hooks, calls } = wire({ viewThresholdMs: 20000 });
const base = await makeServer(hooks);
const r1 = await (await fetch(`${base}/test/fireImpressionRendered`)).json();
const r2 = await (await fetch(`${base}/test/fireImpressionViewable?visibleMs=8000`)).json();
const r3 = await (await fetch(`${base}/test/fireViewTick?visibleMs=5000`)).json();
const r4 = await (await fetch(`${base}/test/fireViewThresholdMet`)).json();
expect((r1 as { ok: boolean }).ok).toBe(true);
expect((r2 as { ok: boolean }).ok).toBe(true);
expect((r3 as { ok: boolean }).ok).toBe(true);
expect((r4 as { ok: boolean }).ok).toBe(true);
const posts = calls.filter((c) => c.url.endsWith("/v1/metrics"));
expect(posts.map((p) => (p.body as { event_type: string }).event_type))
.toEqual([
"impression_rendered", "impression_viewable",
"view_tick", "view_threshold_met",
]);
// view_threshold_met defaults visibleMs to viewThresholdMs from context.
const last = posts[3].body as { visible_ms: number; view_ms: number };
expect(last.visible_ms).toBe(20000);
expect(last.view_ms).toBe(20000);
});
it("adId/campaignId/corr query params override the live ad context",
async () => {
const { hooks, calls } = wire({ adOverride: null });
const base = await makeServer(hooks);
const url = `${base}/test/fireImpressionRendered`
+ `?surface=codex_overlay&adId=X&campaignId=Y&corr=my.fixed.corr`;
const r = await (await fetch(url)).json() as { ok: boolean };
expect(r.ok).toBe(true);
const post = calls.find((c) => c.url.endsWith("/v1/metrics"))!;
expect(post.body).toMatchObject({
event_type: "impression_rendered", ad_id: "X",
campaign_id: "Y", surface: "codex_overlay",
});
expect(post.headers["X-Vibe-Corr"]).toBe("my.fixed.corr");
});
it("refreshPortfolio + refreshEarnings + getState + clearEventLog "
+ "read-only / state routes", async () => {
const { hooks } = wire();
const base = await makeServer(hooks);
// refreshPortfolio returns the new ad
const port = await (await fetch(`${base}/test/refreshPortfolio`)).json() as
{ adId: string };
expect(port.adId).toBe("ad-fresh");
// refreshEarnings returns parsed payload
const earn = await (await fetch(`${base}/test/refreshEarnings`)).json() as
{ lifetimeUsd: string; todayUsd: string };
expect(earn).toEqual({ lifetimeUsd: "5.00", todayUsd: "0.50" });
// getState reflects ring state
await fetch(`${base}/test/fireImpressionRendered`);
const s1 = await (await fetch(`${base}/test/getState`)).json() as
{ lastEvents: { event: string }[] };
expect(s1.lastEvents).toHaveLength(1);
// clearEventLog wipes the ring
await fetch(`${base}/test/clearEventLog`);
const s2 = await (await fetch(`${base}/test/getState`)).json() as
{ lastEvents: unknown[] };
expect(s2.lastEvents).toHaveLength(0);
});
it("unknown route returns 404 JSON instead of crashing", async () => {
const { hooks } = wire();
const base = await makeServer(hooks);
const r = await fetch(`${base}/test/notARealHook`);
expect(r.status).toBe(404);
const body = await r.json() as { ok: boolean; reason: string };
expect(body.ok).toBe(false);
expect(body.reason).toMatch(/unknown route/);
});
it("/test/* is invisible without an onTestRoute handler (default Loopback"
+ " falls through to 404 — confirms routes are opt-in per host)",
async () => {
lb = new Loopback({
onEvent: () => {},
onClick: () => {},
getActivity: () => ({}),
getCurrentAd: () => null,
});
const { port, token } = await lb.start();
const r = await fetch(
`http://127.0.0.1:${port}/vibe-ads/${token}/test/fireClick`);
expect(r.status).toBe(404);
});
it("CORS preflight: OPTIONS /test/* gets a 204 with the loopback headers",
async () => {
const { hooks } = wire();
const base = await makeServer(hooks);
const r = await fetch(`${base}/test/fireClick`, { method: "OPTIONS" });
expect(r.status).toBe(204);
expect(r.headers.get("access-control-allow-origin")).toBe("*");
});
});
describe("loopback /test/* gated off", () => {
afterEach(async () => { if (lb) { await lb.stop(); lb = null; } });
it("returns 403 when testHooksEnabled() is false at request time",
async () => {
vi.resetModules();
vi.doMock("../src/log", () => ({
debugEnabled: () => false, dlog: () => {}, dlogRaw: () => {},
codexEnabled: () => false, testHooksEnabled: () => false,
LOG_PATH: "/tmp/test-log",
}));
const { Loopback: L } = await import("../src/loopback");
const { TestHooks: TH } = await import("../src/testHooks");
const { MetricsClient: M } = await import("../src/metrics/client");
const { PortfolioClient: P } = await import("../src/portfolio/client");
const { EarningsClient: E } = await import("../src/earnings/client");
const fakeFetch = vi.fn(async () =>
({ ok: true, status: 204, json: async () => ({}) } as Response));
const hooks = new TH(
new M("http://b", () => "tok", () => "cid", "0.1.0", fakeFetch as never),
new P("http://b", () => "tok", fakeFetch as never),
new E("http://b", () => "tok", fakeFetch as never),
() => ({ ad: AD, signedIn: true, killed: false,
ccVersion: "v", viewThresholdMs: 15000, loopback: null }));
lb = new L({
onEvent: () => {}, onClick: () => {}, getActivity: () => ({}),
getCurrentAd: () => null,
onTestRoute: (n, p) => hooks.handleTestRoute(n, p),
});
const { port, token } = await lb.start();
const r = await fetch(
`http://127.0.0.1:${port}/vibe-ads/${token}/test/fireClick`);
expect(r.status).toBe(403);
const body = await r.json() as { ok: boolean; reason: string };
expect(body.ok).toBe(false);
expect(body.reason).toMatch(/disabled/);
vi.doUnmock("../src/log");
});
});