//===========================================
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
//===========================================
// @vitest-environment jsdom
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
import { Result } from "@hexclave/shared/dist/utils/results";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EventTracker } from "./event-tracker";
async function advancePastFlush() {
await vi.advanceTimersByTimeAsync(10_000);
await Promise.resolve();
}
function getSentEventTypes(sentBodies: string[]) {
const [body] = sentBodies;
const payload = JSON.parse(body);
if (typeof payload !== "object" || payload === null || !("events" in payload) || !Array.isArray(payload.events)) {
throw new Error("Expected analytics batch payload to include an events array.");
}
return (payload.events as { event_type: string }[]).map((event) => event.event_type);
}
describe("EventTracker", () => {
afterEach(() => {
vi.useRealTimers();
});
it("captures events when browser globals are exposed as accessor descriptors", async () => {
vi.useFakeTimers();
document.body.innerHTML = "";
const screenDescriptor = Object.getOwnPropertyDescriptor(window, "screen");
const historyDescriptor = Object.getOwnPropertyDescriptor(window, "history");
expect(screenDescriptor?.value).toBeUndefined();
expect(historyDescriptor?.value).toBeUndefined();
expect(screenDescriptor?.get).toBeTypeOf("function");
expect(historyDescriptor?.get).toBeTypeOf("function");
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
document.querySelector("button")?.dispatchEvent(new MouseEvent("click", {
bubbles: true,
clientX: 12,
clientY: 34,
}));
await advancePastFlush();
// Dead-click classification marks the buffered $click in place —
// exactly one click event either way.
expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(`
[
"$page-view",
"$click",
]
`);
} finally {
tracker.stop();
}
});
it("emits a PostHog-style elements_chain plus scaled pointer coords for $click", async () => {
vi.useFakeTimers();
document.body.innerHTML = `
`;
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const button = document.querySelector("#save-btn");
if (button == null) throw new Error("button missing");
button.dispatchEvent(new MouseEvent("click", {
bubbles: true,
clientX: 100,
clientY: 200,
}));
await advancePastFlush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, data: Record }[] };
const click = payload.events.find((event) => event.event_type === "$click");
if (click == null) throw new Error("no $click event captured");
// elements_chain encodes the target leaf plus a few ancestors. Leaf is
// first; segments are `;`-delimited. Assert against substrings rather
// than the full string so jsdom layout quirks don't make this flaky.
const chain = click.data.elements_chain;
expect(typeof chain).toBe("string");
expect(chain).toContain('button');
expect(chain).toContain('attr__id="save-btn"');
expect(chain).toContain('attr__data-testid="save"');
expect(chain).toContain('attr__aria-label="Save project"');
expect(chain).toContain('text="Save changes"');
// Ancestor section is in the chain too.
expect(chain).toContain("section");
// Pre-scaled coords land in clickmap_events.pointer_*. SCALE_FACTOR=16.
expect(click.data.x_scaled).toBe(Math.round(100 / 16));
expect(click.data.y_scaled).toBe(Math.round(200 / 16));
expect(click.data.client_y_scaled).toBe(Math.round(200 / 16));
expect(click.data.scale_factor).toBe(16);
expect(click.data.pointer_relative_x).toBeCloseTo(100 / window.innerWidth, 4);
expect(click.data.pointer_target_fixed).toBe(0);
// Legacy CSS selector still emitted for back-compat. The builder prefers
// data-testid over id, so we assert against that anchor rather than #id.
expect(click.data.selector).toContain('data-testid="save"');
expect(click.data.tag_name).toBe("button");
} finally {
tracker.stop();
}
});
it("ignores clicks inside the Hexclave dev tool", async () => {
vi.useFakeTimers();
document.body.innerHTML = `
`;
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
document.querySelector("button")?.dispatchEvent(new MouseEvent("click", {
bubbles: true,
clientX: 100,
clientY: 200,
}));
await advancePastFlush();
expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(`
[
"$page-view",
]
`);
} finally {
tracker.stop();
}
});
it("flags pointer_target_fixed when the target sits under a fixed-position ancestor", async () => {
vi.useFakeTimers();
document.body.innerHTML = `
`;
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
document.querySelector("#cta")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await advancePastFlush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, data: Record }[] };
const click = payload.events.find((event) => event.event_type === "$click");
expect(click?.data.pointer_target_fixed).toBe(1);
} finally {
tracker.stop();
}
});
it("flags a click with no observable effect as dead on its single $click event", async () => {
vi.useFakeTimers();
document.body.innerHTML = "";
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const clickAtMs = Date.now();
document.querySelector("#dead")?.dispatchEvent(new MouseEvent("click", {
bubbles: true,
clientX: 10,
clientY: 20,
}));
await advancePastFlush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, event_at_ms: number, data: Record }[] };
const clicks = payload.events.filter((event) => event.event_type === "$click");
expect(clicks).toHaveLength(1);
const click = clicks[0];
// One event per physical click: the buffered $click is marked dead in
// place, still timestamped at the original click rather than at
// classification time (~3s later).
expect(click.data.dead).toBe(1);
expect(click.event_at_ms).toBe(clickAtMs);
} finally {
tracker.stop();
}
});
it("does not flag a click as dead when it mutates the DOM", async () => {
vi.useFakeTimers();
document.body.innerHTML = "";
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const button = document.querySelector("#live");
if (button == null) throw new Error("button missing");
button.addEventListener("click", () => {
document.querySelector("#out")?.appendChild(document.createElement("p"));
});
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// Let the MutationObserver microtask run so the mutation is recorded
// before the dead-click sweeps start.
await Promise.resolve();
await advancePastFlush();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, data: Record }[] };
const clicks = payload.events.filter((event) => event.event_type === "$click");
expect(clicks).toHaveLength(1);
expect(clicks[0].data.dead).toBeUndefined();
} finally {
tracker.stop();
}
});
it("drains held clicks as alive on pagehide so navigation clicks are never lost", async () => {
vi.useFakeTimers();
document.body.innerHTML = "Pricing";
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
const clickAtMs = Date.now();
document.querySelector("#nav")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// Navigation fires pagehide well before any classification sweep — the
// keepalive flush ships the still-unclassified click as a plain (alive)
// $click.
window.dispatchEvent(new Event("pagehide"));
await Promise.resolve();
await Promise.resolve();
const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, event_at_ms: number, data: Record }[] };
const clicks = payload.events.filter((event) => event.event_type === "$click");
expect(clicks).toHaveLength(1);
expect(clicks[0].data.dead).toBeUndefined();
expect(clicks[0].event_at_ms).toBe(clickAtMs);
} finally {
tracker.stop();
}
});
it("holds an unclassified click out of a flush and ships it on the next one", async () => {
vi.useFakeTimers();
document.body.innerHTML = "";
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
// Click 500ms before the 10s flush tick: classification cannot finish
// in time, so the flush must hold the click back rather than send it
// unclassified.
await vi.advanceTimersByTimeAsync(9_500);
document.querySelector("#late")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.advanceTimersByTimeAsync(500);
expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(`
[
"$page-view",
]
`);
// By the next flush the sweep has classified it (dead — nothing
// observable happened) and it ships marked.
await vi.advanceTimersByTimeAsync(10_000);
const second = JSON.parse(sentBodies[1] ?? "{}") as { events: { event_type: string, data: Record }[] };
expect(second.events.map((event) => event.event_type)).toMatchInlineSnapshot(`
[
"$click",
]
`);
expect(second.events[0].data.dead).toBe(1);
} finally {
tracker.stop();
}
});
it("captures client-side navigations when history is exposed as an accessor descriptor", async () => {
vi.useFakeTimers();
const historyDescriptor = Object.getOwnPropertyDescriptor(window, "history");
expect(historyDescriptor?.value).toBeUndefined();
expect(historyDescriptor?.get).toBeTypeOf("function");
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response());
},
});
try {
tracker.start();
window.history.pushState({}, "", "/projects/test-project");
await advancePastFlush();
expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(`
[
"$page-view",
"$page-view",
]
`);
} finally {
tracker.stop();
}
});
it("silently ignores network errors caused by ad blockers", async () => {
vi.useFakeTimers();
document.body.innerHTML = "";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.error(new TypeError("Failed to fetch"));
},
});
try {
tracker.start();
await advancePastFlush();
expect(sentBodies).toHaveLength(1);
expect(warnSpy).not.toHaveBeenCalled();
// Unlike ANALYTICS_NOT_ENABLED, ad blocker errors do NOT disable the
// tracker — subsequent flushes continue attempting delivery.
document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await advancePastFlush();
expect(sentBodies).toHaveLength(2);
expect(warnSpy).not.toHaveBeenCalled();
} finally {
tracker.stop();
warnSpy.mockRestore();
}
});
it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
vi.useFakeTimers();
document.body.innerHTML = "";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const sentBodies: string[] = [];
const tracker = new EventTracker({
projectId: "internal",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.error(new KnownErrors.AnalyticsNotEnabled());
},
});
try {
tracker.start();
await advancePastFlush();
expect(sentBodies).toHaveLength(1);
expect(warnSpy).not.toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect((tracker as any)._flushTimer).toBeNull();
document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await advancePastFlush();
expect(sentBodies).toHaveLength(1);
} finally {
tracker.stop();
warnSpy.mockRestore();
}
});
});