diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts new file mode 100644 index 0000000000..a69dee67c6 --- /dev/null +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventTracker } from "./event-tracker"; + +async function advancePastAccessTokenRefresh() { + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + 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", + getAccessToken: async () => "access-token", + 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 advancePastAccessTokenRefresh(); + + expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(` + [ + "$page-view", + "$click", + ] + `); + } 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", + getAccessToken: async () => "access-token", + sendBatch: async (body) => { + sentBodies.push(body); + return Result.ok(new Response()); + }, + }); + + try { + tracker.start(); + window.history.pushState({}, "", "/projects/test-project"); + + await advancePastAccessTokenRefresh(); + + expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(` + [ + "$page-view", + "$page-view", + ] + `); + } finally { + tracker.stop(); + } + }); +}); diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 04cbe403f8..c46a652d29 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -62,13 +62,12 @@ export class EventTracker { start() { if (this._started) return; if (!isBrowserLike()) return; - const screenObject = Object.getOwnPropertyDescriptor(window, "screen")?.value; if ( typeof window.addEventListener !== "function" || typeof window.removeEventListener !== "function" || typeof document.addEventListener !== "function" || typeof document.removeEventListener !== "function" - || !hasScreenDimensions(screenObject) + || !hasScreenDimensions(window.screen) ) { return; } @@ -105,7 +104,7 @@ export class EventTracker { } private _capturePageView(entryType: "initial" | "push" | "replace" | "pop") { - const screenObject = Object.getOwnPropertyDescriptor(window, "screen")?.value; + const screenObject = window.screen; if (!hasScreenDimensions(screenObject)) { return; } @@ -134,7 +133,7 @@ export class EventTracker { private _setupPageViewCapture() { // Fire initial page-view this._capturePageView("initial"); - const historyObject = Object.getOwnPropertyDescriptor(window, "history")?.value; + const historyObject = window.history; if (!hasHistoryMethods(historyObject)) { return; } @@ -246,7 +245,7 @@ export class EventTracker { } // Restore history methods - const historyObject = Object.getOwnPropertyDescriptor(window, "history")?.value; + const historyObject = window.history; if (hasHistoryMethods(historyObject)) { if (this._originalPushState) { historyObject.pushState = this._originalPushState;