From 97b7d11c15844980ad9857873e8f44ed2fbd4ea4 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Sat, 18 Jul 2026 09:44:00 -0700 Subject: [PATCH 1/9] add performance harness and consolidate auth --- .env.example | 4 + .gitignore | 4 + apps/web/__tests__/api/contributions.test.ts | 3 + apps/web/__tests__/api/profile.test.ts | 5 + .../components/PostHogProvider.test.tsx | 152 ++++++++++++++++ .../flows/privacy-visibility.test.ts | 10 +- .../flows/profile-and-contributions.test.ts | 10 +- apps/web/__tests__/unit/supabase-auth.test.ts | 82 +++++++++ .../unit/supabase-middleware.test.ts | 78 +++++++++ apps/web/app/(app)/feed/page.tsx | 4 +- apps/web/app/(app)/layout.tsx | 49 +++--- apps/web/app/(app)/leaderboard/page.tsx | 4 +- apps/web/app/(app)/messages/page.tsx | 15 +- apps/web/app/(app)/notifications/page.tsx | 9 +- apps/web/app/(app)/post/[id]/page.tsx | 101 ++++------- apps/web/app/(app)/post/new/page.tsx | 12 +- apps/web/app/(app)/prompts/page.tsx | 7 +- apps/web/app/(app)/recap/page.tsx | 9 +- apps/web/app/(app)/token-rich/page.tsx | 4 +- .../components/providers/PostHogProvider.tsx | 56 +++++- apps/web/e2e/perf/auth.setup.ts | 88 ++++++++++ apps/web/e2e/perf/env.ts | 20 +++ apps/web/e2e/perf/pages.perf.spec.ts | 112 ++++++++++++ apps/web/e2e/perf/scorecard.ts | 148 ++++++++++++++++ apps/web/lib/profile-access.ts | 4 +- apps/web/lib/supabase/auth.ts | 61 ++++++- apps/web/lib/supabase/middleware.ts | 36 ++-- apps/web/package.json | 4 +- apps/web/playwright.perf.config.ts | 41 +++++ docs/perf/BASELINE.md | 49 ++++++ docs/perf/PLAN.md | 164 ++++++++++++++++++ docs/perf/RUM.md | 38 ++++ package.json | 2 + 33 files changed, 1243 insertions(+), 142 deletions(-) create mode 100644 apps/web/__tests__/components/PostHogProvider.test.tsx create mode 100644 apps/web/__tests__/unit/supabase-auth.test.ts create mode 100644 apps/web/__tests__/unit/supabase-middleware.test.ts create mode 100644 apps/web/e2e/perf/auth.setup.ts create mode 100644 apps/web/e2e/perf/env.ts create mode 100644 apps/web/e2e/perf/pages.perf.spec.ts create mode 100644 apps/web/e2e/perf/scorecard.ts create mode 100644 apps/web/playwright.perf.config.ts create mode 100644 docs/perf/BASELINE.md create mode 100644 docs/perf/PLAN.md create mode 100644 docs/perf/RUM.md diff --git a/.env.example b/.env.example index 923756d4..d6c2a39a 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,7 @@ NEXT_PUBLIC_POSTHOG_KEY= # Dev (optional) PORTLESS_URL= + +# Perf harness test user (see docs/perf/PLAN.md) +PERF_TEST_EMAIL= +PERF_TEST_PASSWORD= diff --git a/.gitignore b/.gitignore index 984ed5ba..2c74792a 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,7 @@ AGENTS.md .waypoint/docs/code-guide.md .env .env.local + +# perf harness output & auth state +perf-results/ +apps/web/e2e/perf/.auth/ diff --git a/apps/web/__tests__/api/contributions.test.ts b/apps/web/__tests__/api/contributions.test.ts index 525dcc9f..42f1511f 100644 --- a/apps/web/__tests__/api/contributions.test.ts +++ b/apps/web/__tests__/api/contributions.test.ts @@ -40,6 +40,7 @@ describe("GET /api/users/[username]/contributions", () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ data: null, error: null }), getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null, @@ -113,6 +114,7 @@ describe("GET /api/users/[username]/contributions", () => { it("returns 404 for non-existent user", async () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ data: null, error: null }), getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null, @@ -151,6 +153,7 @@ describe("GET /api/users/[username]/contributions", () => { it("returns empty data when user has no usage", async () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ data: null, error: null }), getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null, diff --git a/apps/web/__tests__/api/profile.test.ts b/apps/web/__tests__/api/profile.test.ts index c4a3f6f5..3c6146d2 100644 --- a/apps/web/__tests__/api/profile.test.ts +++ b/apps/web/__tests__/api/profile.test.ts @@ -64,6 +64,10 @@ describe("GET /api/users/[username]", () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ + data: { claims: { sub: "viewer-1" } }, + error: null, + }), getUser: vi.fn().mockResolvedValue({ data: { user: { id: "viewer-1" } }, error: null, @@ -192,6 +196,7 @@ describe("GET /api/users/[username]", () => { it("returns 404 for non-existent username", async () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ data: null, error: null }), getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null, diff --git a/apps/web/__tests__/components/PostHogProvider.test.tsx b/apps/web/__tests__/components/PostHogProvider.test.tsx new file mode 100644 index 00000000..feba0ffd --- /dev/null +++ b/apps/web/__tests__/components/PostHogProvider.test.tsx @@ -0,0 +1,152 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { useReportWebVitals } from "next/web-vitals"; +import { + PostHogClientProvider, + WebVitalsReporter, +} from "@/components/providers/PostHogProvider"; + +const mocks = vi.hoisted(() => { + process.env.NEXT_PUBLIC_POSTHOG_KEY = "ph_test"; + + return { + capture: vi.fn(), + init: vi.fn(), + analyticsConsent: false, + unsubscribe: vi.fn(), + reportWebVitals: undefined as + | Parameters[0] + | undefined, + }; +}); + +vi.mock("next/web-vitals", () => ({ + useReportWebVitals: (reporter: Parameters[0]) => { + mocks.reportWebVitals = reporter; + }, +})); + +vi.mock("posthog-js", () => ({ + default: { + capture: mocks.capture, + identify: vi.fn(), + init: mocks.init, + reset: vi.fn(), + }, +})); + +vi.mock("posthog-js/react", () => ({ + PostHogProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/components/providers/useAnalyticsConsent", () => ({ + useAnalyticsConsent: () => mocks.analyticsConsent, +})); + +vi.mock("@/lib/supabase/client", () => ({ + createClient: () => ({ + auth: { + onAuthStateChange: () => ({ + data: { subscription: { unsubscribe: mocks.unsubscribe } }, + }), + }, + }), +})); + +describe("WebVitalsReporter", () => { + beforeEach(() => { + mocks.capture.mockClear(); + mocks.init.mockClear(); + mocks.analyticsConsent = false; + mocks.reportWebVitals = undefined; + mocks.unsubscribe.mockClear(); + window.__straudePostHogInitialized = undefined; + window.history.replaceState({}, "", "/feed?sort=recent"); + }); + + it("enables built-in web vitals only after analytics consent", async () => { + const { rerender } = render( + +
Content
+
, + ); + + expect(mocks.init).not.toHaveBeenCalled(); + + mocks.analyticsConsent = true; + rerender( + +
Content
+
, + ); + + await waitFor(() => expect(mocks.init).toHaveBeenCalledOnce()); + expect(mocks.init).toHaveBeenCalledWith( + "ph_test", + expect.objectContaining({ + capture_performance: { web_vitals: true }, + }), + ); + }); + + it("buffers TTFB until PostHog is ready and ignores built-in metrics", async () => { + const { rerender } = render(); + + act(() => { + mocks.reportWebVitals?.({ + name: "LCP", + id: "lcp-1", + value: 450, + delta: 450, + rating: "good", + entries: [], + navigationType: "navigate", + }); + mocks.reportWebVitals?.({ + name: "TTFB", + id: "ttfb-1", + value: 180, + delta: 180, + rating: "good", + entries: [], + navigationType: "navigate", + }); + }); + + expect(mocks.capture).not.toHaveBeenCalled(); + + rerender(); + + await waitFor(() => { + expect(mocks.capture).toHaveBeenCalledOnce(); + }); + expect(mocks.capture).toHaveBeenCalledWith("web_vital_ttfb", { + metric_name: "TTFB", + value_ms: 180, + metric_id: "ttfb-1", + rating: "good", + navigation_type: "navigate", + pathname: "/feed", + $current_url: "http://localhost:3000/feed?sort=recent", + }); + }); + + it("does not report the same TTFB metric twice", async () => { + render(); + const metric = { + name: "TTFB" as const, + id: "ttfb-1", + value: 180, + delta: 180, + rating: "good" as const, + entries: [], + navigationType: "navigate" as const, + }; + + act(() => mocks.reportWebVitals?.(metric)); + await waitFor(() => expect(mocks.capture).toHaveBeenCalledOnce()); + act(() => mocks.reportWebVitals?.(metric)); + + expect(mocks.capture).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/__tests__/flows/privacy-visibility.test.ts b/apps/web/__tests__/flows/privacy-visibility.test.ts index aa4d951d..b0bbb89d 100644 --- a/apps/web/__tests__/flows/privacy-visibility.test.ts +++ b/apps/web/__tests__/flows/privacy-visibility.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock Supabase // --------------------------------------------------------------------------- const mockSupabase = { - auth: { getUser: vi.fn() }, + auth: { getUser: vi.fn(), getClaims: vi.fn() }, from: vi.fn(), rpc: vi.fn(), }; @@ -74,6 +74,14 @@ describe("Flow: Privacy and Visibility", () => { mockServiceClient.rpc.mockReset(); mockSupabase.rpc.mockReset(); mockSupabase.from.mockReset(); + mockSupabase.auth.getClaims.mockImplementation(async () => { + const result = await mockSupabase.auth.getUser(); + const subject = result?.data?.user?.id; + return { + data: typeof subject === "string" ? { claims: { sub: subject } } : null, + error: result?.error ?? null, + }; + }); // Default: return array for calculate_streaks_batch (leaderboard), number for calculate_user_streak (profile) mockSupabase.rpc.mockImplementation((_fn: string) => { if (_fn === "calculate_streaks_batch") return Promise.resolve({ data: [] }); diff --git a/apps/web/__tests__/flows/profile-and-contributions.test.ts b/apps/web/__tests__/flows/profile-and-contributions.test.ts index 66d1466a..5349669b 100644 --- a/apps/web/__tests__/flows/profile-and-contributions.test.ts +++ b/apps/web/__tests__/flows/profile-and-contributions.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock Supabase // --------------------------------------------------------------------------- const mockSupabase = { - auth: { getUser: vi.fn() }, + auth: { getUser: vi.fn(), getClaims: vi.fn() }, from: vi.fn(), rpc: vi.fn(), }; @@ -56,6 +56,14 @@ describe("Flow: Profile and Contributions", () => { vi.clearAllMocks(); mockServiceClient.from.mockReset(); mockServiceClient.rpc.mockReset(); + mockSupabase.auth.getClaims.mockImplementation(async () => { + const result = await mockSupabase.auth.getUser(); + const subject = result?.data?.user?.id; + return { + data: typeof subject === "string" ? { claims: { sub: subject } } : null, + error: result?.error ?? null, + }; + }); }); it("sets profile via PATCH /api/users/me", async () => { diff --git a/apps/web/__tests__/unit/supabase-auth.test.ts b/apps/web/__tests__/unit/supabase-auth.test.ts new file mode 100644 index 00000000..2c5bc6ae --- /dev/null +++ b/apps/web/__tests__/unit/supabase-auth.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const getClaims = vi.fn(); + const single = vi.fn(); + const eq = vi.fn(() => ({ single })); + const select = vi.fn(() => ({ eq })); + const from = vi.fn(() => ({ select })); + + return { getClaims, single, eq, select, from }; +}); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(async () => ({ + auth: { getClaims: mocks.getClaims }, + })), +})); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ from: mocks.from })), +})); + +import { getAuthContext, getAuthIdentity } from "@/lib/supabase/auth"; + +describe("Supabase auth context", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getClaims.mockResolvedValue({ + data: { + claims: { + sub: "user-123", + email: "person@example.com", + }, + }, + error: null, + }); + mocks.single.mockResolvedValue({ + data: { + username: "person", + avatar_url: null, + display_name: "Person", + team_url: null, + team_favicon_url: null, + onboarding_completed: true, + streak_freezes: 2, + }, + error: null, + }); + }); + + it("derives a minimal verified identity from JWT claims", async () => { + await expect(getAuthIdentity()).resolves.toEqual({ + id: "user-123", + email: "person@example.com", + }); + }); + + it("loads the shell profile once for the verified subject", async () => { + await expect(getAuthContext()).resolves.toMatchObject({ + identity: { id: "user-123" }, + profile: { username: "person", streak_freezes: 2 }, + }); + + expect(mocks.from).toHaveBeenCalledOnce(); + expect(mocks.from).toHaveBeenCalledWith("users"); + expect(mocks.eq).toHaveBeenCalledWith("id", "user-123"); + expect(mocks.single).toHaveBeenCalledOnce(); + }); + + it("does not query a profile when verified claims have no subject", async () => { + mocks.getClaims.mockResolvedValueOnce({ + data: { claims: {} }, + error: null, + }); + + await expect(getAuthContext()).resolves.toEqual({ + identity: null, + profile: null, + }); + expect(mocks.from).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/supabase-middleware.test.ts b/apps/web/__tests__/unit/supabase-middleware.test.ts new file mode 100644 index 00000000..c065654f --- /dev/null +++ b/apps/web/__tests__/unit/supabase-middleware.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +type CookieToSet = { + name: string; + value: string; + options?: { httpOnly?: boolean; path?: string }; +}; + +const mocks = vi.hoisted(() => ({ + getClaims: vi.fn(), +})); + +vi.mock("@/lib/supabase/env", () => ({ + getMissingSupabaseBrowserEnv: vi.fn(() => []), + formatSupabaseEnvHelp: vi.fn(), +})); + +vi.mock("@supabase/ssr", () => ({ + createServerClient: vi.fn( + ( + _url: string, + _key: string, + options: { cookies: { setAll: (cookies: CookieToSet[]) => void } } + ) => { + options.cookies.setAll([ + { + name: "sb-test-auth-token", + value: "refreshed-token", + options: { httpOnly: true, path: "/" }, + }, + ]); + return { auth: { getClaims: mocks.getClaims } }; + } + ), +})); + +import { updateSession } from "@/lib/supabase/middleware"; + +describe("Supabase middleware auth", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://test.supabase.co"); + vi.stubEnv("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", "test-key"); + }); + + it("uses verified claims and preserves refreshed cookies on redirects", async () => { + mocks.getClaims.mockResolvedValue({ + data: { claims: { sub: "user-123" } }, + error: null, + }); + + const response = await updateSession( + new NextRequest("https://straude.com/") + ); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://straude.com/feed"); + expect(response.cookies.get("sb-test-auth-token")?.value).toBe( + "refreshed-token" + ); + expect(response.headers.get("Server-Timing")).toMatch(/^mw-auth;dur=\d+$/); + expect(mocks.getClaims).toHaveBeenCalledOnce(); + }); + + it("redirects an unverified protected request to login", async () => { + mocks.getClaims.mockResolvedValue({ data: null, error: null }); + + const response = await updateSession( + new NextRequest("https://straude.com/messages") + ); + + expect(response.headers.get("location")).toBe("https://straude.com/login"); + expect(response.cookies.get("sb-test-auth-token")?.value).toBe( + "refreshed-token" + ); + }); +}); diff --git a/apps/web/app/(app)/feed/page.tsx b/apps/web/app/(app)/feed/page.tsx index f3da9dc0..aa2c5e1e 100644 --- a/apps/web/app/(app)/feed/page.tsx +++ b/apps/web/app/(app)/feed/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { createClient } from "@/lib/supabase/server"; -import { getAuthUser } from "@/lib/supabase/auth"; +import { getAuthIdentity } from "@/lib/supabase/auth"; import { FeedList } from "@/components/app/feed/FeedList"; import { enrichFeedPosts, getFeedCursor, getPendingPosts } from "@/lib/feed-enrichment"; import type { FeedPostRow } from "@/types"; @@ -45,7 +45,7 @@ export default async function FeedPage({ searchParams: Promise<{ tab?: string }>; }) { const params = await searchParams; - const user = await getAuthUser(); + const user = await getAuthIdentity(); const supabase = await createClient(); // Unauthenticated visitors can only see the global feed diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 9c16b852..8004f6ba 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,8 +1,11 @@ import { Suspense } from "react"; import Link from "next/link"; import { createClient } from "@/lib/supabase/server"; -import { getServiceClient } from "@/lib/supabase/service"; -import { getAuthUser } from "@/lib/supabase/auth"; +import { + getAuthContext, + getAuthIdentity, + type ShellProfile, +} from "@/lib/supabase/auth"; import { Sidebar } from "@/components/app/shared/Sidebar"; import { LazyRightSidebar } from "@/components/app/shared/RightSidebar"; import { InviteButton } from "@/components/app/profile/InviteButton"; @@ -16,16 +19,6 @@ import { firstRelation } from "@/lib/utils/first-relation"; import { loadUsageTotals } from "@/lib/data/usage-totals"; import type { DailyUsage } from "@/types"; -type ShellProfile = { - username: string | null; - avatar_url: string | null; - display_name: string | null; - team_url: string | null; - team_favicon_url: string | null; - onboarding_completed: boolean | null; - streak_freezes: number | null; -}; - type LatestPostRow = { id: string; title: string | null; @@ -35,6 +28,12 @@ type LatestPostRow = { type SupabaseServerClient = Awaited>; +async function measure(operation: () => Promise): Promise<[T, number]> { + const start = Date.now(); + const result = await operation(); + return [result, Date.now() - start]; +} + function formatLatestPosts(rows: LatestPostRow[]) { return rows .map((row) => { @@ -216,9 +215,10 @@ export default async function AppLayout({ }: { children: React.ReactNode; }) { - const user = await getAuthUser(); + const perfTiming = process.env.PERF_TIMING === "1"; + const [identity, authMs] = await measure(getAuthIdentity); // If not logged in: allow public pages, redirect others to login - if (!user) { + if (!identity) { // This check runs server-side as a safety net alongside proxy.ts // Public pages render with a guest layout below return ( @@ -238,19 +238,13 @@ export default async function AppLayout({ ); } - const db = getServiceClient(); - const { data: profileData } = await db - .from("users") - .select("username, avatar_url, display_name, team_url, team_favicon_url, onboarding_completed, streak_freezes") - .eq("id", user.id) - .single(); + const [{ profile }, profileMs] = await measure(getAuthContext); - const profile = profileData as ShellProfile | null; const onboardingIncomplete = !profile?.onboarding_completed; const leftPanel = ( }> - + ); @@ -258,6 +252,15 @@ export default async function AppLayout({ return ( + {perfTiming && ( +