From 89fb8f57f0476a1a15d15ee9f09a32a13bd14f46 Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:52:36 +0530 Subject: [PATCH] fix(web): handle fulfilled external login errors --- .github/workflows/ci.yml | 4 + apps/web/app/(auth)/login/page.test.tsx | 308 ++++++++++++++++++++++++ apps/web/app/(auth)/login/page.tsx | 123 ++++++---- apps/web/package.json | 3 + bun.lock | 2 + 5 files changed, 389 insertions(+), 51 deletions(-) create mode 100644 apps/web/app/(auth)/login/page.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..ed31b8cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,9 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Run web tests + working-directory: apps/web + run: bun run test + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx new file mode 100644 index 00000000..5476fbef --- /dev/null +++ b/apps/web/app/(auth)/login/page.test.tsx @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { GlobalWindow } from "happy-dom" +import React from "react" + +const browserWindow = new GlobalWindow({ + url: "https://app.supermemory.ai/login", +}) + +Object.assign(globalThis, { + HTMLElement: browserWindow.HTMLElement, + MutationObserver: browserWindow.MutationObserver, + Node: browserWindow.Node, + document: browserWindow.document, + getComputedStyle: browserWindow.getComputedStyle.bind(browserWindow), + localStorage: browserWindow.localStorage, + navigator: browserWindow.navigator, + window: browserWindow, +}) + +type SignInResult = { + data: unknown + error: unknown +} + +type SignInOptions = Record +type SignInImplementation = (options: SignInOptions) => Promise + +const successResult: SignInResult = { + data: { redirect: true, url: "https://accounts.example.com" }, + error: null, +} + +let socialImplementation: SignInImplementation = async () => successResult +let oauth2Implementation: SignInImplementation = async () => successResult + +const socialSignIn = mock((options: SignInOptions) => + socialImplementation(options), +) +const oauth2SignIn = mock((options: SignInOptions) => + oauth2Implementation(options), +) +const capture = mock(() => {}) + +mock.module("@lib/auth", () => ({ + signIn: { + email: mock(async () => successResult), + magicLink: mock(async () => successResult), + oauth2: oauth2SignIn, + social: socialSignIn, + }, + useSession: () => ({ data: null, isPending: false }), +})) + +mock.module("@lib/posthog", () => ({ + usePostHog: () => ({ capture }), +})) + +mock.module("next/navigation", () => ({ + useRouter: () => ({ push: mock(() => {}) }), + useSearchParams: () => new URLSearchParams(), +})) + +mock.module("motion/react", () => ({ + motion: { + div: ({ + animate: _animate, + children, + initial: _initial, + transition: _transition, + ...props + }: React.ComponentProps<"div"> & { + animate?: unknown + initial?: unknown + transition?: unknown + }) => React.createElement("div", props, children), + }, +})) + +mock.module("@/components/initial-header", () => ({ + InitialHeader: () => React.createElement("header"), +})) + +mock.module("@/components/login-tools-panel", () => ({ + LoginToolsPanel: () => React.createElement("aside"), +})) + +mock.module("@/lib/fonts", () => ({ + dmSansClassName: () => "", +})) + +process.env.NEXT_PUBLIC_HOST_ID = "supermemory" + +const { act, cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" +) +const LoginPage = (await import("./page")).default + +const pendingMethodKey = "supermemory-pending-login-method" +const pendingTimestampKey = "supermemory-pending-login-timestamp" + +function renderLoginPage() { + return render(React.createElement(LoginPage)) +} + +beforeEach(() => { + browserWindow.localStorage.clear() + socialSignIn.mockClear() + oauth2SignIn.mockClear() + capture.mockClear() + socialImplementation = async () => successResult + oauth2Implementation = async () => successResult +}) + +afterEach(() => { + cleanup() +}) + +describe("external provider sign-in", () => { + const providerCases = [ + { + buttonName: "Continue with Google", + expectedOptions: { provider: "google" }, + label: "Google", + method: "google", + useOauth2: false, + }, + { + buttonName: "Continue with Github", + expectedOptions: { provider: "github" }, + label: "GitHub", + method: "github", + useOauth2: false, + }, + { + buttonName: "Continue with AgentID", + expectedOptions: { providerId: "agentid" }, + label: "AgentID", + method: "agentid", + useOauth2: true, + }, + ] as const + + for (const providerCase of providerCases) { + test(`handles a fulfilled ${providerCase.label} error`, async () => { + const providerError = { + message: `${providerCase.label} is temporarily unavailable`, + status: 503, + statusText: "Service Unavailable", + } + const implementation: SignInImplementation = async () => ({ + data: null, + error: providerError, + }) + + if (providerCase.useOauth2) { + oauth2Implementation = implementation + } else { + socialImplementation = implementation + } + + renderLoginPage() + fireEvent.click( + screen.getByRole("button", { + name: new RegExp(providerCase.buttonName, "i"), + }), + ) + + const alert = await screen.findByText(providerError.message) + expect(alert.getAttribute("role")).toBe("alert") + expect(screen.queryByText(/Redirecting/)).toBeNull() + expect(browserWindow.localStorage.getItem(pendingMethodKey)).toBeNull() + expect(browserWindow.localStorage.getItem(pendingTimestampKey)).toBeNull() + + const providerSignIn = providerCase.useOauth2 + ? oauth2SignIn + : socialSignIn + expect(providerSignIn).toHaveBeenCalledTimes(1) + expect(providerSignIn.mock.calls[0]?.[0]).toMatchObject({ + callbackURL: "https://app.supermemory.ai/?extension-auth-success=true", + ...providerCase.expectedOptions, + }) + }) + } + + const fulfilledNetworkCases = [ + { + buttonName: "Continue with Google", + label: "Google social", + useOauth2: false, + }, + { + buttonName: "Continue with AgentID", + label: "AgentID OAuth2", + useOauth2: true, + }, + ] as const + + for (const networkCase of fulfilledNetworkCases) { + test(`normalizes a fulfilled status-0 ${networkCase.label} error`, async () => { + const implementation: SignInImplementation = async () => ({ + data: null, + error: { status: 0, statusText: "" }, + }) + + if (networkCase.useOauth2) { + oauth2Implementation = implementation + } else { + socialImplementation = implementation + } + + renderLoginPage() + const button = screen.getByRole("button", { + name: new RegExp(networkCase.buttonName, "i"), + }) + fireEvent.click(button) + + const alert = await screen.findByText( + "Network error. Please check your connection and try again.", + ) + expect(alert.getAttribute("role")).toBe("alert") + expect(screen.queryByText(/Redirecting/)).toBeNull() + expect(button.hasAttribute("disabled")).toBe(false) + expect(browserWindow.localStorage.getItem(pendingMethodKey)).toBeNull() + expect(browserWindow.localStorage.getItem(pendingTimestampKey)).toBeNull() + + const providerSignIn = networkCase.useOauth2 ? oauth2SignIn : socialSignIn + expect(providerSignIn).toHaveBeenCalledTimes(1) + }) + } + + test("keeps a nonzero provider error whose message looks network-like", async () => { + const providerMessage = "Failed to fetch GitHub account details" + socialImplementation = async () => ({ + data: null, + error: { + message: providerMessage, + status: 503, + statusText: "Service Unavailable", + }, + }) + + renderLoginPage() + const button = screen.getByRole("button", { + name: /Continue with Github/i, + }) + fireEvent.click(button) + + const alert = await screen.findByText(providerMessage) + expect(alert.getAttribute("role")).toBe("alert") + expect( + screen.queryByText( + "Network error. Please check your connection and try again.", + ), + ).toBeNull() + expect(button.hasAttribute("disabled")).toBe(false) + expect(browserWindow.localStorage.getItem(pendingMethodKey)).toBeNull() + expect(browserWindow.localStorage.getItem(pendingTimestampKey)).toBeNull() + }) + + test("normalizes a rejected network error and clears pending state", async () => { + socialImplementation = async () => { + throw new TypeError("Failed to fetch") + } + + renderLoginPage() + fireEvent.click( + screen.getByRole("button", { name: /Continue with Google/i }), + ) + + const alert = await screen.findByText( + "Network error. Please check your connection and try again.", + ) + expect(alert.getAttribute("role")).toBe("alert") + expect(screen.queryByText(/Redirecting/)).toBeNull() + expect(browserWindow.localStorage.getItem(pendingMethodKey)).toBeNull() + expect(browserWindow.localStorage.getItem(pendingTimestampKey)).toBeNull() + }) + + test("keeps loading and pending state after a successful handoff", async () => { + let resolveSignIn: ((result: SignInResult) => void) | undefined + socialImplementation = () => + new Promise((resolve) => { + resolveSignIn = resolve + }) + + renderLoginPage() + fireEvent.click( + screen.getByRole("button", { name: /Continue with Google/i }), + ) + + expect(await screen.findByText(/Redirecting/)).toBeTruthy() + expect(browserWindow.localStorage.getItem(pendingMethodKey)).toBe("google") + expect(browserWindow.localStorage.getItem(pendingTimestampKey)).toMatch( + /^\d+$/, + ) + + await act(async () => { + resolveSignIn?.(successResult) + await Promise.resolve() + }) + + expect(screen.getByText(/Redirecting/)).toBeTruthy() + expect(browserWindow.localStorage.getItem(pendingMethodKey)).toBe("google") + expect(browserWindow.localStorage.getItem(pendingTimestampKey)).toMatch( + /^\d+$/, + ) + }) +}) diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 442ded03..4061aa4c 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -53,6 +53,13 @@ function buildMcpAuthorizeResumeUrl( return `${backend}/api/auth/oauth2/authorize?${p.toString()}` } +function clearPendingLoginMethod() { + try { + localStorage.removeItem("supermemory-pending-login-method") + localStorage.removeItem("supermemory-pending-login-timestamp") + } catch {} +} + function LoginHeadline({ className }: { className?: string }) { return (
@@ -204,9 +211,32 @@ export default function LoginPage() { } catch {} } + function getErrorText(error: unknown): string | null { + if (error instanceof Error) return error.message + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) { + return error.message + } + return null + } + + function getPlainObjectStatus(error: unknown): number | null { + if (typeof error !== "object" || error === null) return null + const prototype = Object.getPrototypeOf(error) + if (prototype !== Object.prototype && prototype !== null) return null + if (!("status" in error) || typeof error.status !== "number") return null + return error.status + } + function isNetworkError(error: unknown): boolean { - if (!(error instanceof Error)) return false - const message = error.message.toLowerCase() + const status = getPlainObjectStatus(error) + if (status !== null) return status === 0 + const message = getErrorText(error)?.toLowerCase() + if (!message) return false return ( message.includes("load failed") || message.includes("networkerror") || @@ -219,19 +249,40 @@ export default function LoginPage() { if (isNetworkError(error)) { return "Network error. Please check your connection and try again." } - if (error instanceof Error) { - return error.message + return ( + getErrorText(error) || "An unexpected error occurred. Please try again." + ) + } + + async function handleExternalSignIn( + provider: "agentid" | "github" | "google", + startSignIn: () => Promise<{ error?: unknown }>, + ) { + if (loadingMessage) return + setError(null) + setIsLoading(true) + posthog.capture("login_attempt", { + method: "social", + provider, + }) + setPendingLoginMethod(provider) + + try { + const result = await startSignIn() + if (!result.error) return + setError(getErrorMessage(result.error)) + } catch (error) { + setError(getErrorMessage(error)) } - return "An unexpected error occurred. Please try again." + + setIsLoading(false) + clearPendingLoginMethod() } // If we land back on this page with an error, clear any pending marker useEffect(() => { if (params.get("error")) { - try { - localStorage.removeItem("supermemory-pending-login-method") - localStorage.removeItem("supermemory-pending-login-timestamp") - } catch {} + clearPendingLoginMethod() } }, [params]) @@ -512,22 +563,12 @@ export default function LoginPage() { className="w-full" disabled={Boolean(loadingMessage)} onClick={() => { - if (loadingMessage) return - setIsLoading(true) - posthog.capture("login_attempt", { - method: "social", - provider: "google", - }) - setPendingLoginMethod("google") - signIn - .social({ + void handleExternalSignIn("google", () => + signIn.social({ callbackURL: getCallbackURL(), provider: "google", - }) - .catch((err: unknown) => { - setError(getErrorMessage(err)) - setIsLoading(false) - }) + }), + ) }} />
@@ -571,22 +612,12 @@ export default function LoginPage() { className="w-full" disabled={Boolean(loadingMessage)} onClick={() => { - if (loadingMessage) return - setIsLoading(true) - posthog.capture("login_attempt", { - method: "social", - provider: "github", - }) - setPendingLoginMethod("github") - signIn - .social({ + void handleExternalSignIn("github", () => + signIn.social({ callbackURL: getCallbackURL(), provider: "github", - }) - .catch((err: unknown) => { - setError(getErrorMessage(err)) - setIsLoading(false) - }) + }), + ) }} /> @@ -644,22 +675,12 @@ export default function LoginPage() { className="w-full" disabled={Boolean(loadingMessage)} onClick={() => { - if (loadingMessage) return - setIsLoading(true) - posthog.capture("login_attempt", { - method: "social", - provider: "agentid", - }) - setPendingLoginMethod("agentid") - signIn - .oauth2({ + void handleExternalSignIn("agentid", () => + signIn.oauth2({ callbackURL: getCallbackURL(), providerId: "agentid", - }) - .catch((err: unknown) => { - setError(getErrorMessage(err)) - setIsLoading(false) - }) + }), + ) }} /> diff --git a/apps/web/package.json b/apps/web/package.json index 63c4acee..3d101ac1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "check-types": "tsc --noEmit", "start": "next start", "lint": "biome check --write", + "test": "bun test", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload", @@ -120,12 +121,14 @@ "@biomejs/biome": "^2.2.2", "@sentry/cli": "^2.52.0", "@tailwindcss/postcss": "^4.1.11", + "@testing-library/react": "^16.3.2", "@total-typescript/tsconfig": "^1.0.4", "@types/canvas-confetti": "^1.9.0", "@types/is-hotkey": "^0.1.10", "@types/node": "^24.0.4", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", + "happy-dom": "^20.9.0", "tailwindcss": "^4.1.11", "typescript": "^5.8.3", "wrangler": "^4.26.0" diff --git a/bun.lock b/bun.lock index d55c8237..17e5232e 100644 --- a/bun.lock +++ b/bun.lock @@ -245,12 +245,14 @@ "@biomejs/biome": "^2.2.2", "@sentry/cli": "^2.52.0", "@tailwindcss/postcss": "^4.1.11", + "@testing-library/react": "^16.3.2", "@total-typescript/tsconfig": "^1.0.4", "@types/canvas-confetti": "^1.9.0", "@types/is-hotkey": "^0.1.10", "@types/node": "^24.0.4", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", + "happy-dom": "^20.9.0", "tailwindcss": "^4.1.11", "typescript": "^5.8.3", "wrangler": "^4.26.0",