Files
software-mansion__argent/packages/tool-server/test/preview-opened-telemetry.test.ts
Hubert Gancarczyk f002fc9540 feat(telemetry): track Argent Lens design-review funnel (#478)
## What

Adds three privacy-safe telemetry events for the Argent Lens
design-review flow, capturing the human-decision funnel that the generic
`tool:*` path can't see (submission is an HTTP POST, and the
`await_user_selection` tool is hidden in `argent lens` CLI sessions):

- **`lens:preview_opened`** — a human loaded the preview UI (`GET
/preview/`), deduped per round.
- **`lens:round_completed`** — the human submitted their picks (once per
round, never on a resubmit).
- **`lens:round_abandoned`** — a staged round was discarded before
submit (window closed, `argent lens` exited mid-review, or the round
superseded).

## Privacy

Payloads carry only aggregate counts, booleans, durations, and the
device `platform` enum — never element names, comment text, variant
code, file paths, or raw device ids. The store emits privacy-safe
aggregate structs that are decoupled from the telemetry package;
structural assignability to the event props is enforced at the `track()`
call site (drift is a compile error).

## Event fields

- **`preview_opened`**: `round, element_count, variant_count,
is_cli_session, platform`
- **`round_completed`**: `round, element_count, variant_count,
annotation_count, element_comment_count, skipped_comment_count,
has_global_comment, is_cli_session, had_parked_await, round_duration_ms,
platform`
- **`round_abandoned`**: `round, element_count, variant_count,
had_parked_await, is_cli_session, platform`

## Testing

- Unit tests for all three events: per-round dedup, exactly-once
completion (no resubmit re-emit), abandoned vs happy-path roll, CLI
up-front open, and content-agnostic privacy assertions.
- Verified end-to-end against a live tool-server: all three events fire
with correct aggregate values and zero content leakage through the real
sanitize pipeline.

---------

Co-authored-by: Ignacy Łątka <latkaignacy@gmail.com>
2026-07-13 16:56:49 +02:00

324 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import type { Registry } from "@argent/registry";
// Capture telemetry without touching PostHog: keep every real export (other
// modules in the router's import graph read them at load) and spy only `track`.
vi.mock("@argent/telemetry", async (importOriginal) => {
const actual = await importOriginal<typeof import("@argent/telemetry")>();
return { ...actual, track: vi.fn() };
});
import { createPreviewRouter } from "../src/preview";
import { variantProposalStore } from "../src/utils/variant-proposals";
import { track } from "@argent/telemetry";
const mockTrack = vi.mocked(track);
function app() {
const registry = { invokeTool: vi.fn() } as unknown as Registry;
const a = express();
// Mirror the real server (http.ts mounts express.json app-wide before the
// preview router) so POST bodies to /variants/selection are parsed. Harmless
// for the GET cases (no body).
a.use(express.json());
a.use(createPreviewRouter(registry));
return a;
}
const previewOpenedCalls = () =>
mockTrack.mock.calls.filter(([event]) => event === "lens:preview_opened");
beforeEach(() => {
mockTrack.mockClear();
// The store is a process singleton; clear any round state a prior test left so
// each case starts from an empty, uncompleted round. `reset()` deliberately
// does NOT clear `cliSession`, so drop a leaked CLI session too — otherwise a
// prior CLI test could make a "bare load, no CLI" case emit via the cliSession
// disjunct under --sequence.shuffle. Neither call routes through `track`, so
// the mockClear above stays effective.
variantProposalStore.setCliSession(false, []);
variantProposalStore.reset();
});
describe("POST /preview/opened — lens:preview_opened telemetry", () => {
// `lens:preview_opened` is emitted only from an explicit client trigger — the
// UI posts `/opened` when it renders a round in a VISIBLE window. The server
// reads counts/platform from its own snapshot (the body's `round` is a trigger
// only), and dedups per round. These cases pin the server side of that signal;
// the visibility gate itself is client-side (see index.html reportPreviewOpened).
it("emits once when the client reports a live proposal round", async () => {
const a = app();
variantProposalStore.proposeVariant({
element: "Foo",
variant: { name: "Bold", summary: "s" },
});
const round = variantProposalStore.snapshot().round;
const res = await request(a).post("/opened").send({ round });
expect(res.status).toBe(200);
const calls = previewOpenedCalls();
expect(calls).toHaveLength(1);
const payload = calls[0]![1] as Record<string, unknown>;
// No udid bound here, but the store `device` is a process singleton NOT cleared
// by reset(), so `platform` can carry over from an earlier test under
// --sequence.shuffle. Assert the controlled fields, pin the exact key set, and
// check platform is a safe enum or undefined — never leaked content.
expect(payload).toMatchObject({
round,
element_count: 1,
variant_count: 1,
is_cli_session: false,
});
expect(Object.keys(payload).sort()).toEqual([
"element_count",
"is_cli_session",
"platform",
"round",
"variant_count",
]);
expect(payload.platform === undefined || typeof payload.platform === "string").toBe(true);
});
it("reports variant_count and platform from the bound device", async () => {
const a = app();
variantProposalStore.proposeVariant({
element: "Foo",
udid: "chromium-cdp-9222",
variant: { name: "Bold", summary: "s" },
});
variantProposalStore.proposeVariant({
element: "Foo",
variant: { name: "Ghost", summary: "s" },
});
const round = variantProposalStore.snapshot().round;
await request(a).post("/opened").send({ round });
const calls = previewOpenedCalls();
expect(calls).toHaveLength(1);
expect(calls[0]![1]).toEqual({
round,
element_count: 1,
variant_count: 2,
is_cli_session: false,
platform: "chromium",
});
});
it("dedups within the same round (repeated triggers count once)", async () => {
const a = app();
variantProposalStore.proposeVariant({
element: "Foo",
variant: { name: "Bold", summary: "s" },
});
const round = variantProposalStore.snapshot().round;
await request(a).post("/opened").send({ round });
await request(a).post("/opened").send({ round }); // e.g. a second tab, same round
expect(previewOpenedCalls()).toHaveLength(1);
});
it("re-emits on a NEW round (dedup is per-round, not once-ever)", async () => {
const a = app();
variantProposalStore.proposeVariant({
element: "Foo",
variant: { name: "Bold", summary: "s" },
});
const roundN = variantProposalStore.snapshot().round;
await request(a).post("/opened").send({ round: roundN });
expect(previewOpenedCalls()).toHaveLength(1);
// Finalize round N; the next propose auto-rolls into a fresh round N+1.
const foo = variantProposalStore.snapshot().proposals[0]!;
variantProposalStore.submitSelection({
selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }],
});
variantProposalStore.proposeVariant({
element: "Bar",
variant: { name: "Ghost", summary: "s" },
});
const roundNext = variantProposalStore.snapshot().round;
expect(roundNext).toBe(roundN + 1);
// The client rendering the NEW round reports it — a second event. A regression
// to a fire-once guard would wrongly suppress this and leave the count at 1.
await request(a).post("/opened").send({ round: roundNext });
const calls = previewOpenedCalls();
expect(calls).toHaveLength(2);
expect(calls[1]![1]).toMatchObject({
round: roundNext,
element_count: 1,
variant_count: 1,
is_cli_session: false,
});
});
it("does NOT emit when nothing is staged and no CLI session (a stray trigger)", async () => {
const a = app();
const res = await request(a).post("/opened").send({ round: 1 });
expect(res.status).toBe(200);
expect(previewOpenedCalls()).toHaveLength(0);
});
it("emits for a CLI up-front open with zero proposals, OMITTING the (stale) platform", async () => {
const a = app();
// Bind a device via an earlier round, then roll it away: `device` survives
// reset() on the store singleton, so it is still set with zero proposals.
variantProposalStore.proposeVariant({
element: "Foo",
udid: "chromium-cdp-9222",
variant: { name: "Bold", summary: "s" },
});
variantProposalStore.reset();
// An `argent lens` session opens the window up front — zero proposals staged.
variantProposalStore.setCliSession(true, []);
const round = variantProposalStore.snapshot().round;
expect(variantProposalStore.snapshot().device).toBe("chromium-cdp-9222"); // still bound
await request(a).post("/opened").send({ round });
const calls = previewOpenedCalls();
expect(calls).toHaveLength(1);
// element_count 0 (CLI up-front) AND platform OMITTED: a zero-count open must
// not inherit a prior flow's device platform (the store's `device` survives
// reset()). This pins the "platform tied to proposals presence" fix.
expect(calls[0]![1]).toEqual({
round,
element_count: 0,
variant_count: 0,
is_cli_session: true,
});
variantProposalStore.setCliSession(false, []); // don't leak session state
});
});
describe("GET /preview/ and /preview/variants — no longer emit preview_opened", () => {
// The page load and the poll are NOT proof a human is looking (a reused CLI
// window loads `/` once for a whole session; a browser tab keeps polling
// /variants while backgrounded). Emission moved to the visibility-gated client
// trigger, so these surfaces must stay silent — a regression guard.
it("GET / does not emit even with a live round", async () => {
const a = app();
variantProposalStore.proposeVariant({
element: "Foo",
variant: { name: "Bold", summary: "s" },
});
const res = await request(a).get("/");
expect(res.status).toBe(200);
expect(previewOpenedCalls()).toHaveLength(0);
});
it("GET /variants does not emit even with a live round", async () => {
const a = app();
variantProposalStore.proposeVariant({
element: "Foo",
variant: { name: "Bold", summary: "s" },
});
const res = await request(a).get("/variants");
expect(res.status).toBe(200);
expect(previewOpenedCalls()).toHaveLength(0);
});
});
describe("POST /preview/variants/selection — inspector/offscreen usage flags", () => {
it("threads the UI's inspectorUsed / offscreenRevealed booleans to lens:round_completed", async () => {
const a = app();
variantProposalStore.proposeVariant({
element: "Foo",
variant: { name: "Bold", summary: "s" },
});
const foo = variantProposalStore.snapshot().proposals[0]!;
// roundCompleted is the store event index.ts relays to lens:round_completed;
// capture it to prove the POST body's flags reach the aggregate (not `track`,
// which the router only calls for lens:preview_opened).
const stats: Array<{ inspector_used: boolean; offscreen_revealed: boolean }> = [];
const onCompleted = (x: { inspector_used: boolean; offscreen_revealed: boolean }) =>
stats.push(x);
variantProposalStore.events.on("roundCompleted", onCompleted);
const res = await request(a)
.post("/variants/selection")
.send({
selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }],
inspectorUsed: true,
offscreenRevealed: true,
});
variantProposalStore.events.off("roundCompleted", onCompleted);
expect(res.status).toBe(200);
expect(stats).toHaveLength(1);
expect(stats[0]!.inspector_used).toBe(true);
expect(stats[0]!.offscreen_revealed).toBe(true);
});
it("coerces a missing/non-boolean flag to false (unauthenticated route)", async () => {
const a = app();
variantProposalStore.proposeVariant({
element: "Foo",
variant: { name: "Bold", summary: "s" },
});
const foo = variantProposalStore.snapshot().proposals[0]!;
const stats: Array<{ inspector_used: boolean; offscreen_revealed: boolean }> = [];
const onCompleted = (x: { inspector_used: boolean; offscreen_revealed: boolean }) =>
stats.push(x);
variantProposalStore.events.on("roundCompleted", onCompleted);
// inspectorUsed sent as a non-boolean, offscreenRevealed omitted entirely.
const res = await request(a)
.post("/variants/selection")
.send({
selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }],
inspectorUsed: "yes",
});
variantProposalStore.events.off("roundCompleted", onCompleted);
expect(res.status).toBe(200);
expect(stats).toHaveLength(1);
expect(stats[0]!.inspector_used).toBe(false);
expect(stats[0]!.offscreen_revealed).toBe(false);
});
it("forwards the round token so a stale cross-round submit is rejected (no phantom completion)", async () => {
const a = app();
// Round N: propose + submit (matching token) → completes round N. (The store
// is a process singleton; round numbers accumulate across tests, so capture
// them dynamically rather than assuming 1/2.)
variantProposalStore.proposeVariant({ element: "Foo", variant: { name: "A", summary: "s" } });
const foo = variantProposalStore.snapshot().proposals[0]!;
const roundN = variantProposalStore.snapshot().round;
await request(a)
.post("/variants/selection")
.send({ round: roundN, selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }] });
// Agent proposes again → auto-roll to round N+1.
variantProposalStore.proposeVariant({ element: "Bar", variant: { name: "B", summary: "s" } });
expect(variantProposalStore.snapshot().round).toBe(roundN + 1);
const completed: number[] = [];
const onCompleted = (x: { round: number }) => completed.push(x.round);
variantProposalStore.events.on("roundCompleted", onCompleted);
// A stale click from the round-N tab: round token N, current round is N+1. The
// route must forward the token so the store rejects it (regression guard —
// the route previously dropped `round`, letting the phantom through).
const res = await request(a)
.post("/variants/selection")
.send({ round: roundN, selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }] });
variantProposalStore.events.off("roundCompleted", onCompleted);
expect(res.status).toBe(200);
expect(res.body.stale).toBe(true);
expect(res.body.resolved).toBe(0);
expect(completed).toEqual([]); // no phantom lens:round_completed for round 2
expect(variantProposalStore.snapshot().completed).toBe(false);
});
});