diff --git a/.cursor/commands/pre-push.md b/.cursor/commands/pre-push.md index 5c2eb56a0..103f94c10 100644 --- a/.cursor/commands/pre-push.md +++ b/.cursor/commands/pre-push.md @@ -1 +1 @@ -Please compare `dev` to `main` and ensure that all migrations are backwards compatible. In what ways could breakage occur? Report the result to me in detail. +Please compare `dev` to `main` and ensure that all migrations are backwards compatible. In what ways could breakage occur? Report the result to me in detail. Anything else that's scary that could occur, or that we should think about while migrating? 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 000000000..a69dee67c --- /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 04cbe403f..c46a652d2 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;