← back to Kickbacks Ai Upstream
src/testHooks.ts
355 lines
import * as vscode from "vscode";
import { randomBytes } from "node:crypto";
import { MetricsClient, newMetricEventUuid, type AdSurface,
type MetricEvent } from "./metrics/client";
import { PortfolioClient, type PatchAd } from "./portfolio/client";
import { EarningsClient } from "./earnings/client";
import { dlog, testHooksEnabled } from "./log";
import { errMsg } from "./util/errMsg";
/** Live context the test hooks read at fire-time. The activate path captures
* these as closures over its locals so a hook always reflects the latest
* ad / loopback / kill state without having to thread mutable refs. */
export interface TestHooksContext {
ad: PatchAd | null;
signedIn: boolean;
killed: boolean;
ccVersion: string;
viewThresholdMs: number;
loopback: { port: number; base: string } | null;
}
export interface FireArgs {
/** Override surface; defaults to "overlay". */
surface?: AdSurface;
/** Override visibleMs on view-family events. */
visibleMs?: number;
/** Override ad id when no live ad is loaded. campaignId is paired. */
adId?: string;
campaignId?: string;
/** Override session token when firing with adId/campaignId override. */
sessionToken?: string;
/** Optional explicit correlation id; otherwise the hook mints one. */
corr?: string;
/** Click only: optional click token to relay (defaults to "test"). */
ct?: string;
}
export interface FireResult {
ok: boolean;
reason?: string;
/** What the hook actually sent to the backend (post-resolution). */
sent?: {
event: MetricEvent;
eventUuid: string;
adId: string;
campaignId: string;
surface: AdSurface;
corr: string;
visibleMs?: number;
};
}
export interface StateSnapshot {
enabled: boolean;
signedIn: boolean;
killed: boolean;
ccVersion: string;
viewThresholdMs: number;
ad: PatchAd | null;
loopback: { port: number; base: string } | null;
lastEvents: FireResult["sent"][];
}
const LOG_RING_CAP = 64;
/** E2E driver for ledger / billing / telemetry. Exposes
* `kickbacks.test.*` VS Code commands that fire impression / view / click
* events through the SAME MetricsClient the loopback uses, so an end-to-end
* test exercises the real backend path. Production-default OFF (sentinel
* + env gate, see testHooksEnabled()). Hooks always honor the gate so an
* enable-then-disable mid-session can't keep forging events. */
export class TestHooks {
private readonly ring: FireResult["sent"][] = [];
constructor(
private readonly metrics: MetricsClient,
private readonly portfolio: PortfolioClient,
private readonly earnings: EarningsClient,
private readonly getContext: () => TestHooksContext,
private readonly onBillableEvent: (() => void) | null = null,
) {}
private scheduleRefreshFor(event: MetricEvent): void {
if (event !== "view_threshold_met" && event !== "click"
&& event !== "impression_viewable" && event !== "error_impression") {
return;
}
try { this.onBillableEvent?.(); } catch { /* test hooks must not disrupt */ }
}
private resolveAd(args: FireArgs): { adId: string; campaignId: string; sessionToken: string } | null {
if (args.adId && args.campaignId) {
return { adId: args.adId, campaignId: args.campaignId, sessionToken: args.sessionToken || "" };
}
const ad = this.getContext().ad;
if (ad) return { adId: ad.adId, campaignId: ad.campaignId, sessionToken: ad.sessionToken };
return null;
}
private mintCorr(adId: string, args: FireArgs): string {
if (args.corr) return args.corr;
return `${adId}.test.${randomBytes(3).toString("hex")}`;
}
private async fireMetric(
event: MetricEvent,
args: FireArgs,
payload: { visibleMs?: number; viewable?: boolean; viewPct?: number;
viewMs?: number } = {},
): Promise<FireResult> {
if (!testHooksEnabled()) {
return { ok: false, reason: "test hooks disabled" };
}
const resolved = this.resolveAd(args);
if (!resolved) {
return { ok: false, reason: "no ad loaded and no adId/campaignId override" };
}
const surface: AdSurface = args.surface || "overlay";
const corr = this.mintCorr(resolved.adId, args);
const eventUuid = newMetricEventUuid();
const ctx = this.getContext();
const visibleMs = typeof args.visibleMs === "number"
? args.visibleMs : payload.visibleMs;
try {
await this.metrics.send(event, {
adId: resolved.adId,
campaignId: resolved.campaignId,
ccVersion: ctx.ccVersion,
corr,
eventUuid,
surface,
sessionToken: resolved.sessionToken,
...(typeof visibleMs === "number" ? { visibleMs } : {}),
...(typeof payload.viewable === "boolean"
? { viewable: payload.viewable } : {}),
...(typeof payload.viewPct === "number"
? { viewPct: payload.viewPct } : {}),
...(typeof payload.viewMs === "number" ? { viewMs: payload.viewMs } : {}),
});
} catch (e) {
const reason = errMsg(e, 200);
dlog("ext", "testhook.send.err", { event, reason });
return { ok: false, reason };
}
const sent = {
event,
eventUuid,
adId: resolved.adId,
campaignId: resolved.campaignId,
surface,
corr,
...(typeof visibleMs === "number" ? { visibleMs } : {}),
};
this.recordSent(sent);
dlog("ext", "testhook.fire", { event, eventUuid,
adId: resolved.adId, surface, corr });
this.scheduleRefreshFor(event);
return { ok: true, sent };
}
fireImpressionRendered(args: FireArgs = {}): Promise<FireResult> {
return this.fireMetric("impression_rendered", args);
}
fireImpressionViewable(args: FireArgs = {}): Promise<FireResult> {
return this.fireMetric("impression_viewable", args, {
viewable: true, viewPct: 100,
});
}
fireViewTick(args: FireArgs = {}): Promise<FireResult> {
return this.fireMetric("view_tick", args);
}
fireViewThresholdMet(args: FireArgs = {}): Promise<FireResult> {
const ctx = this.getContext();
const visibleMs = typeof args.visibleMs === "number"
? args.visibleMs : ctx.viewThresholdMs;
return this.fireMetric("view_threshold_met",
{ ...args, visibleMs },
{ viewable: true, viewPct: 100, viewMs: visibleMs });
}
/** Fire the MAX_SESSION_MS safety-net event. Lets e2e harnesses drive
* the 5 s stuck-session path without waiting on real wall-clock. */
fireErrorImpression(args: FireArgs = {}): Promise<FireResult> {
const visibleMs = typeof args.visibleMs === "number"
? args.visibleMs : 5000;
return this.fireMetric("error_impression",
{ ...args, visibleMs },
{ viewable: true, viewPct: 100, viewMs: visibleMs });
}
async fireClick(args: FireArgs = {}): Promise<FireResult> {
if (!testHooksEnabled()) {
return { ok: false, reason: "test hooks disabled" };
}
const resolved = this.resolveAd(args);
if (!resolved) {
return { ok: false, reason: "no ad loaded and no adId/campaignId override" };
}
const surface: AdSurface = args.surface || "overlay";
const corr = this.mintCorr(resolved.adId, args);
const eventUuid = newMetricEventUuid();
const ctx = this.getContext();
try {
await this.metrics.send("click", {
adId: resolved.adId,
campaignId: resolved.campaignId,
ccVersion: ctx.ccVersion,
corr,
eventUuid,
surface,
sessionToken: resolved.sessionToken,
});
} catch (e) {
const reason = errMsg(e, 200);
dlog("ext", "testhook.click.err", { reason });
return { ok: false, reason };
}
const sent = {
event: "click" as MetricEvent,
eventUuid,
adId: resolved.adId,
campaignId: resolved.campaignId,
surface,
corr,
};
this.recordSent(sent);
dlog("ext", "testhook.click", { ct: args.ct || "test", eventUuid,
adId: resolved.adId, surface, corr });
this.scheduleRefreshFor("click");
return { ok: true, sent };
}
async refreshPortfolio(): Promise<PatchAd | null> {
if (!testHooksEnabled()) return null;
const ctx = this.getContext();
const r = await this.portfolio.fetchPortfolio(ctx.ccVersion);
return r?.ad ?? null;
}
async refreshEarnings(): Promise<{ lifetimeUsd: string;
todayUsd: string } | null> {
if (!testHooksEnabled()) return null;
return this.earnings.fetch();
}
getState(): StateSnapshot {
const ctx = this.getContext();
return {
enabled: testHooksEnabled(),
signedIn: ctx.signedIn,
killed: ctx.killed,
ccVersion: ctx.ccVersion,
viewThresholdMs: ctx.viewThresholdMs,
ad: ctx.ad,
loopback: ctx.loopback,
lastEvents: [...this.ring],
};
}
clearEventLog(): void { this.ring.length = 0; }
/** Dispatch a loopback /test/<name> route to the corresponding method.
* Parses query-string args into FireArgs (visibleMs is numeric; everything
* else is a string). Self-gated by testHooksEnabled() so a sentinel
* removal mid-session disables the routes on the very next call (403). */
async handleTestRoute(name: string, params: URLSearchParams):
Promise<{ status: number; body: unknown }> {
if (!testHooksEnabled()) {
return { status: 403,
body: { ok: false, reason: "test hooks disabled" } };
}
const args: FireArgs = {};
const surface = params.get("surface");
if (surface) args.surface = surface as AdSurface;
const vm = params.get("visibleMs");
if (vm !== null) {
const n = Number(vm);
if (Number.isFinite(n)) args.visibleMs = n;
}
const adId = params.get("adId");
if (adId) args.adId = adId;
const campaignId = params.get("campaignId");
if (campaignId) args.campaignId = campaignId;
const corr = params.get("corr");
if (corr) args.corr = corr;
const ct = params.get("ct");
if (ct) args.ct = ct;
try {
switch (name) {
case "fireImpressionRendered":
return { status: 200, body: await this.fireImpressionRendered(args) };
case "fireImpressionViewable":
return { status: 200, body: await this.fireImpressionViewable(args) };
case "fireViewTick":
return { status: 200, body: await this.fireViewTick(args) };
case "fireViewThresholdMet":
return { status: 200, body: await this.fireViewThresholdMet(args) };
case "fireErrorImpression":
return { status: 200, body: await this.fireErrorImpression(args) };
case "fireClick":
return { status: 200, body: await this.fireClick(args) };
case "refreshPortfolio":
return { status: 200, body: await this.refreshPortfolio() };
case "refreshEarnings":
return { status: 200, body: await this.refreshEarnings() };
case "getState":
return { status: 200, body: this.getState() };
case "clearEventLog":
this.clearEventLog();
return { status: 200, body: { ok: true } };
default:
return { status: 404,
body: { ok: false, reason: `unknown route "${name}"` } };
}
} catch (e) {
const reason = errMsg(e, 200);
dlog("ext", "testhook.route.err", { name, reason });
return { status: 500, body: { ok: false, reason } };
}
}
private recordSent(sent: NonNullable<FireResult["sent"]>): void {
this.ring.push(sent);
while (this.ring.length > LOG_RING_CAP) this.ring.shift();
}
/** Wire VS Code commands. Only call when testHooksEnabled() is true; the
* gate is also re-checked inside each fire method so a sentinel removal
* mid-session disables the hooks immediately (next fire returns ok:false).
* Commands accept an optional first arg `FireArgs` object so callers can
* do `executeCommand("kickbacks.test.fireClick", { surface: "banner" })`. */
registerCommands(ctx: vscode.ExtensionContext): void {
const reg = (id: string, fn: (a?: FireArgs) => unknown) =>
ctx.subscriptions.push(vscode.commands.registerCommand(id, fn));
reg("kickbacks.test.fireImpressionRendered",
(a) => this.fireImpressionRendered(a || {}));
reg("kickbacks.test.fireImpressionViewable",
(a) => this.fireImpressionViewable(a || {}));
reg("kickbacks.test.fireViewTick", (a) => this.fireViewTick(a || {}));
reg("kickbacks.test.fireViewThresholdMet",
(a) => this.fireViewThresholdMet(a || {}));
reg("kickbacks.test.fireErrorImpression",
(a) => this.fireErrorImpression(a || {}));
reg("kickbacks.test.fireClick", (a) => this.fireClick(a || {}));
reg("kickbacks.test.refreshPortfolio", () => this.refreshPortfolio());
reg("kickbacks.test.refreshEarnings", () => this.refreshEarnings());
reg("kickbacks.test.getState", () => this.getState());
reg("kickbacks.test.clearEventLog", () => this.clearEventLog());
dlog("ext", "testhook.registered", {});
}
}