Files
software-mansion__argent/packages/tool-server/test/lens-relay-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

121 lines
4.5 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
// Verifies the RUNTIME wiring between the variant-proposal store's events and the
// telemetry events they are relayed as in index.ts's `start()`. Typos in an event
// name are compile-caught, but two failure classes are NOT: (a) dropping one of
// the six `variantProposalStore.events.on(...)` registrations, and (b) cross-
// wiring — e.g. `RoundAbandonedStats` is structurally assignable to
// `LensPreviewOpenedProps`, so relaying `roundAbandoned` as `"lens:preview_opened"`
// would type-check and silently mislabel the funnel. This test emits each store
// event and asserts it lands as the correct telemetry event name.
const telemetryMock = vi.hoisted(() => ({
init: vi.fn(),
attachRegistryTelemetry: vi.fn(() => ({
detach: vi.fn(),
recordInvocation: vi.fn(),
getTotalToolCalls: vi.fn(() => 0),
})),
track: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
warmTelemetryIdentity: vi.fn().mockResolvedValue(undefined),
aiTelemetryFromMeta: vi.fn(() => ({})),
}));
const registryMock = vi.hoisted(() => ({
dispose: vi.fn().mockResolvedValue(undefined),
}));
const httpHandleMock = vi.hoisted(() => ({
dispose: vi.fn(),
app: { listen: vi.fn() },
}));
vi.mock("@argent/telemetry", () => telemetryMock);
vi.mock("@argent/registry", async (importOriginal) => {
const actual = await importOriginal<typeof import("@argent/registry")>();
return { ...actual, attachRegistryLogger: vi.fn() };
});
vi.mock("../src/utils/setup-registry", () => ({
createRegistry: vi.fn(() => registryMock),
}));
vi.mock("../src/http", () => ({ createHttpApp: vi.fn(() => httpHandleMock) }));
vi.mock("../src/utils/update-checker", () => ({
startUpdateChecker: vi.fn(() => ({ dispose: vi.fn() })),
}));
// The watcher's `ready` never settles, so `start()` attaches the store-event
// relays and then parks: the HTTP server never binds and shutdown never runs, so
// the relays stay registered for the duration of the test with no exit race.
vi.mock("../src/utils/simulator-watcher", () => ({
startSimulatorWatcher: vi.fn(() => ({
stop: vi.fn(),
ready: new Promise<void>(() => {}),
})),
}));
describe("tool-server Lens telemetry relay wiring", () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
});
afterEach(() => {
exitSpy.mockRestore();
});
it("relays each store event to its matching telemetry event name (no drop, no cross-wire)", async () => {
const { start } = await import("../src/index");
const { variantProposalStore } = await import("../src/utils/variant-proposals");
// Clean slate: no relays from a prior import of this singleton in-process.
variantProposalStore.events.removeAllListeners();
start(); // attaches the relays synchronously; watcher never settles.
telemetryMock.track.mockClear();
const completedStats = {
round: 1,
element_count: 2,
variant_count: 3,
annotation_count: 0,
element_comment_count: 0,
skipped_comment_count: 0,
has_global_comment: false,
inspector_used: false,
offscreen_revealed: false,
is_cli_session: false,
had_parked_await: true,
round_duration_ms: 42,
} as const;
const abandonedStats = {
round: 2,
element_count: 1,
variant_count: 1,
had_parked_await: false,
is_cli_session: true,
} as const;
const cliStartedStats = { agent_choice_count: 2 } as const;
variantProposalStore.events.emit("roundCompleted", completedStats);
variantProposalStore.events.emit("roundAbandoned", abandonedStats);
variantProposalStore.events.emit("cliSessionStarted", cliStartedStats);
const lensCalls = telemetryMock.track.mock.calls.filter(([name]) =>
String(name).startsWith("lens:")
);
// Exactly one relay per emit — a dropped registration makes its event absent,
// a cross-wire makes it land under the wrong name (both fail these).
expect(lensCalls).toContainEqual(["lens:round_completed", completedStats]);
expect(lensCalls).toContainEqual(["lens:round_abandoned", abandonedStats]);
expect(lensCalls).toContainEqual(["lens:cli_session_started", cliStartedStats]);
expect(lensCalls).toHaveLength(3);
// Explicit cross-wire guard: the abandoned stats (assignable to
// LensPreviewOpenedProps) must NOT have been relayed as an opened event.
expect(lensCalls.some(([name]) => name === "lens:preview_opened")).toBe(false);
});
});