mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 89fb8f57f0 into 3f7b9667c6
This commit is contained in:
commit
08152b86e5
5 changed files with 389 additions and 51 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
308
apps/web/app/(auth)/login/page.test.tsx
Normal file
308
apps/web/app/(auth)/login/page.test.tsx
Normal file
|
|
@ -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<string, unknown>
|
||||
type SignInImplementation = (options: SignInOptions) => Promise<SignInResult>
|
||||
|
||||
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+$/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
<div className={cn("max-w-sm text-center", className)}>
|
||||
|
|
@ -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)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -119,12 +120,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"
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -244,12 +244,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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue