diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..c1398b7d 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 unit tests + working-directory: apps/web + run: bun 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/(app)/onboarding/page.test.tsx b/apps/web/app/(app)/onboarding/page.test.tsx new file mode 100644 index 00000000..ab7d367d --- /dev/null +++ b/apps/web/app/(app)/onboarding/page.test.tsx @@ -0,0 +1,319 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test" +import type { ReactNode } from "react" +import { Window } from "happy-dom" +import { onboardingDraftKey } from "@/lib/brain-onboarding-draft" + +type AboutValues = { + name: string + about: string + workspaceName: string + workspaceDomain: string +} + +type AboutProps = { + values: AboutValues + onChange: (values: AboutValues) => void + onContinue: () => void | Promise +} + +type CreateOrganizationInput = { + name: string + slug: string + metadata: Record & { brainAbout?: string } +} + +const browserWindow = new Window({ + url: "https://app.supermemory.ai/onboarding", +}) +const browserGlobals = [ + "window", + "document", + "navigator", + "Node", + "HTMLElement", + "Event", + "MutationObserver", + "localStorage", +] as const +const originalDescriptors = new Map( + browserGlobals.map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), +) +const originalActEnvironment = Object.getOwnPropertyDescriptor( + globalThis, + "IS_REACT_ACT_ENVIRONMENT", +) + +for (const name of browserGlobals) { + Object.defineProperty(globalThis, name, { + configurable: true, + value: browserWindow[name], + }) +} +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + configurable: true, + value: true, +}) + +const React = await import("react") + +const accountA = { + id: "user-a", + name: "Alice A", + email: "alice@gmail.com", + image: null, +} +const accountB = { + id: "user-b", + name: "Bob B", + email: "bob@gmail.com", + image: null, +} +let currentUser = accountA +let latestAboutProps: AboutProps | null = null + +const router = { push: mock(), replace: mock() } +const searchParams = new URLSearchParams() +const setActiveOrg = mock(async () => {}) +const refetchOrganizations = mock(async () => {}) +const createOrganization = mock(async (_input: CreateOrganizationInput) => ({ + data: { slug: "created-workspace" }, + error: null, +})) +const updateUser = mock(async () => ({ data: {}, error: null })) + +mock.module("next/navigation", () => ({ + useRouter: () => router, + useSearchParams: () => searchParams, +})) + +mock.module("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: mock() }), +})) + +mock.module("sonner", () => ({ + toast: { error: mock(), success: mock() }, +})) + +mock.module("@lib/auth-context", () => ({ + useAuth: () => ({ + user: currentUser, + session: { id: `session-${currentUser.id}` }, + isSessionPending: false, + isRestoring: false, + org: null, + organizations: [], + setActiveOrg, + refetchOrganizations, + }), +})) + +mock.module("@lib/auth", () => ({ + authClient: { + organization: { + create: createOrganization, + inviteMember: mock(async () => ({ data: {}, error: null })), + }, + updateUser, + }, +})) + +mock.module("@lib/constants", () => ({ + SHARED_TEAM_BRAIN_TAG: "sm_org_shared", +})) + +mock.module("@/lib/analytics", () => ({ + analytics: new Proxy({} as Record void>, { + get: () => () => {}, + }), +})) + +mock.module("@/lib/company-brain-entry", () => ({ + resolveCompanyBrainEntry: () => ({ action: "create" }), +})) + +mock.module("@/components/onboarding-brain/shell", () => ({ + BrainShell: ({ children }: { children: ReactNode }) => + React.createElement("main", null, children), +})) + +mock.module("@/components/onboarding-brain/step-about", () => ({ + StepAbout: (props: AboutProps) => { + latestAboutProps = props + return React.createElement("output", null, JSON.stringify(props.values)) + }, +})) + +mock.module("@/components/onboarding-brain/step-sources", () => ({ + StepSources: () => null, +})) +mock.module("@/components/onboarding-brain/step-ingest", () => ({ + StepIngest: () => null, +})) +mock.module("@/components/onboarding-brain/step-team", () => ({ + StepTeam: () => null, +})) +mock.module("@/components/onboarding-brain/company-brain-onboarding", () => ({ + CompanyBrainOnboarding: () => null, +})) + +const [{ act }, { createRoot }, { default: BrainOnboardingPage }] = + await Promise.all([ + import("react"), + import("react-dom/client"), + import("./page"), + ]) + +let root: ReturnType | null = null +let container: HTMLElement | null = null + +function aboutProps(): AboutProps { + if (!latestAboutProps) throw new Error("StepAbout was not rendered") + return latestAboutProps +} + +async function renderPage() { + await act(async () => { + root?.render(React.createElement(BrainOnboardingPage)) + await Promise.resolve() + }) +} + +beforeEach(() => { + currentUser = accountA + latestAboutProps = null + localStorage.clear() + for (const key of [...searchParams.keys()]) searchParams.delete(key) + createOrganization.mockClear() + updateUser.mockClear() + router.push.mockClear() + router.replace.mockClear() + + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root?.unmount()) + container?.remove() + root = null + container = null +}) + +afterAll(() => { + for (const name of browserGlobals) { + const descriptor = originalDescriptors.get(name) + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) + } + if (originalActEnvironment) { + Object.defineProperty( + globalThis, + "IS_REACT_ACT_ENVIRONMENT", + originalActEnvironment, + ) + } else { + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT") + } + void browserWindow.close() +}) + +describe("onboarding draft ownership", () => { + it("isolates drafts and mutations across an A to B to A account switch", async () => { + localStorage.setItem( + "supermemory-brain-onboarding-v1", + JSON.stringify({ about: { name: "Unowned legacy draft" } }), + ) + await renderPage() + + const aliceDraft: AboutValues = { + name: "Alice Secret", + about: "Confidential acquisition planning", + workspaceName: "Alice Private Brain", + workspaceDomain: "alice.example", + } + + await act(async () => { + aboutProps().onChange(aliceDraft) + await Promise.resolve() + }) + expect(aboutProps().values).toEqual(aliceDraft) + + currentUser = accountB + await renderPage() + + const bobValues = aboutProps().values + expect(bobValues.name).toBe("Bob B") + expect(bobValues.about).toBe("") + expect(bobValues.workspaceName).not.toBe(aliceDraft.workspaceName) + expect(JSON.stringify(bobValues)).not.toContain("Alice") + + await act(async () => { + await aboutProps().onContinue() + }) + + const createPayload = createOrganization.mock.calls[0]?.[0] + expect(createPayload?.name).toBe(bobValues.workspaceName) + expect(createPayload?.metadata.brainAbout).toBeUndefined() + expect(JSON.stringify(createPayload)).not.toContain("Alice") + expect(updateUser).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Bob B", + displayUsername: "Bob B", + }), + ) + + currentUser = accountA + await renderPage() + + expect(aboutProps().values).toEqual(aliceDraft) + expect(localStorage.getItem("supermemory-brain-onboarding-v1")).toBeNull() + }) + + it("starts a fresh user-owned draft when new workspace mode is forced", async () => { + await renderPage() + const existingDraft: AboutValues = { + name: "Alice Secret", + about: "Existing private draft", + workspaceName: "Existing Workspace", + workspaceDomain: "existing.example", + } + await act(async () => { + aboutProps().onChange(existingDraft) + await Promise.resolve() + }) + + searchParams.set("new", "1") + searchParams.set("name", "Fresh Workspace") + await renderPage() + + expect(aboutProps().values).toEqual({ + name: "Alice A", + about: "", + workspaceName: "Fresh Workspace", + workspaceDomain: "", + }) + const persisted = localStorage.getItem(onboardingDraftKey(accountA.id)) + expect(persisted).not.toBeNull() + expect(persisted).not.toContain("Existing private draft") + + searchParams.delete("new") + searchParams.delete("name") + await renderPage() + expect(aboutProps().values.about).toBe("") + expect(aboutProps().values.workspaceName).not.toBe( + existingDraft.workspaceName, + ) + }) +}) diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 1cde19cf..56257eb4 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -9,6 +9,13 @@ import { authClient } from "@lib/auth" import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants" import { analytics } from "@/lib/analytics" import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry" +import { + clearOnboardingDraft, + discardLegacyOnboardingDraft, + getOnboardingDraftStorage, + readOnboardingDraft, + writeOnboardingDraft, +} from "@/lib/brain-onboarding-draft" import { BrainShell } from "@/components/onboarding-brain/shell" import { StepAbout, @@ -39,10 +46,16 @@ import { type CompanyBrainConfirmResult, } from "@/components/onboarding-brain/types" -const STORAGE_KEY = "supermemory-brain-onboarding-v1" const BACKEND = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" +type BrainOnboardingDraft = { + mode?: BrainMode + about?: AboutValues + sources?: SourcesValues + team?: TeamValues +} + const countsAsConnectedSource = (state: unknown) => state === "connected" || state === "waitlist" @@ -71,6 +84,12 @@ const getWorkspaceCreationErrorCopy = (message: string) => { } export default function BrainOnboardingPage() { + const { user } = useAuth() + if (!user?.id) return null + return +} + +function BrainOnboardingContent() { const router = useRouter() const params = useSearchParams() const queryClient = useQueryClient() @@ -122,33 +141,67 @@ export default function BrainOnboardingPage() { visibility: "team-private", suggestChanges: false, }) + const [hydratedDraftUserId, setHydratedDraftUserId] = useState( + null, + ) useEffect(() => { - if (forceCreate) return - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return - const cached = JSON.parse(raw) as { - mode?: BrainMode - about?: AboutValues - sources?: SourcesValues - team?: TeamValues - } - if (cached.mode && !modeParam) setMode(cached.mode) - if (cached.about) setAbout((a) => ({ ...a, ...cached.about })) - if (cached.sources) setSources((s) => ({ ...s, ...cached.sources })) - if (cached.team) setTeam((t) => ({ ...t, ...cached.team })) - } catch {} - }, [forceCreate, modeParam]) + const userId = user?.id + if (!userId) { + setHydratedDraftUserId(null) + return + } + + const storage = getOnboardingDraftStorage() + if (storage) discardLegacyOnboardingDraft(storage) + const cached = + forceCreate || !storage + ? null + : readOnboardingDraft(storage, userId) + + setMode(cached?.mode && !modeParam ? cached.mode : detectedMode) + setAbout({ + name: user.name ?? "", + about: "", + workspaceName: nameParam || suggestedWorkspaceName, + workspaceDomain: domain ?? "", + ...cached?.about, + }) + setSources({ + connected: {}, + driveScope: "selective", + ...cached?.sources, + }) + setTeam({ + invites: [], + visibility: "team-private", + suggestChanges: false, + ...cached?.team, + }) + setHydratedDraftUserId(userId) + }, [ + user?.id, + user?.name, + forceCreate, + modeParam, + detectedMode, + nameParam, + suggestedWorkspaceName, + domain, + ]) useEffect(() => { - try { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ mode, about, sources, team }), - ) - } catch {} - }, [mode, about, sources, team]) + const userId = user?.id + if (!userId || hydratedDraftUserId !== userId) return + const storage = getOnboardingDraftStorage() + if (!storage) return + writeOnboardingDraft(storage, userId, { + mode, + about, + sources, + team, + }) + }, [user?.id, hydratedDraftUserId, mode, about, sources, team]) const navTrigger = useRef<"user" | "auto">("auto") const startedRef = useRef(false) @@ -226,16 +279,18 @@ export default function BrainOnboardingPage() { ).length, invites_sent: team.invites.filter((i) => i.email.trim()).length, }) - try { - localStorage.removeItem(STORAGE_KEY) - } catch {} + const storage = getOnboardingDraftStorage() + if (storage) { + if (user?.id) clearOnboardingDraft(storage, user.id) + discardLegacyOnboardingDraft(storage) + } // Extra org from settings: hard-reload so org-scoped caches don't show the previous org's data. if (forcedCreateRef.current) { window.location.href = "/?onboarded=1" return } router.push("/?onboarded=1") - }, [router, mode, sources, team]) + }, [router, mode, sources, team, user?.id]) const goNext = useCallback(() => { const idx = steps.indexOf(step) diff --git a/apps/web/lib/brain-onboarding-draft.test.ts b/apps/web/lib/brain-onboarding-draft.test.ts new file mode 100644 index 00000000..e0567608 --- /dev/null +++ b/apps/web/lib/brain-onboarding-draft.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import { + clearOnboardingDraft, + discardLegacyOnboardingDraft, + getOnboardingDraftStorage, + onboardingDraftKey, + readOnboardingDraft, + writeOnboardingDraft, +} from "./brain-onboarding-draft" + +function createStorage() { + const values = new Map() + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + values, + } +} + +describe("brain onboarding draft storage", () => { + test("isolates drafts belonging to different users", () => { + const storage = createStorage() + const accountADraft = { + about: { workspaceName: "Account A workspace" }, + team: { invites: [{ email: "private-a@example.com" }] }, + } + + writeOnboardingDraft(storage, "account-a", accountADraft) + + expect(readOnboardingDraft(storage, "account-a")).toEqual(accountADraft) + expect(readOnboardingDraft(storage, "account-b")).toBeNull() + expect(onboardingDraftKey("account-a")).not.toBe( + onboardingDraftKey("account-b"), + ) + expect(onboardingDraftKey("account/a")).toBe( + "supermemory-brain-onboarding-v2:account%2Fa", + ) + }) + + test("clears only the current user's draft", () => { + const storage = createStorage() + writeOnboardingDraft(storage, "account-a", { mode: "team" }) + writeOnboardingDraft(storage, "account-b", { mode: "personal" }) + + clearOnboardingDraft(storage, "account-a") + + expect(readOnboardingDraft(storage, "account-a")).toBeNull() + expect(readOnboardingDraft(storage, "account-b")).toEqual({ + mode: "personal", + }) + }) + + test("discards unattributable legacy drafts without migrating them", () => { + const storage = createStorage() + storage.setItem( + "supermemory-brain-onboarding-v1", + JSON.stringify({ about: { name: "Previous account" } }), + ) + + discardLegacyOnboardingDraft(storage) + + expect(storage.getItem("supermemory-brain-onboarding-v1")).toBeNull() + expect(readOnboardingDraft(storage, "current-account")).toBeNull() + }) + + test("ignores corrupt and non-object drafts", () => { + const storage = createStorage() + storage.setItem(onboardingDraftKey("account-a"), "not-json") + storage.setItem(onboardingDraftKey("account-b"), "[]") + + expect(readOnboardingDraft(storage, "account-a")).toBeNull() + expect(readOnboardingDraft(storage, "account-b")).toBeNull() + }) + + test("treats a blocked browser storage getter as unavailable", () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage") + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + get: () => { + throw new Error("blocked") + }, + }) + + try { + expect(getOnboardingDraftStorage()).toBeNull() + } finally { + if (original) { + Object.defineProperty(globalThis, "localStorage", original) + } else { + Reflect.deleteProperty(globalThis, "localStorage") + } + } + }) +}) diff --git a/apps/web/lib/brain-onboarding-draft.ts b/apps/web/lib/brain-onboarding-draft.ts new file mode 100644 index 00000000..2bbb4e84 --- /dev/null +++ b/apps/web/lib/brain-onboarding-draft.ts @@ -0,0 +1,65 @@ +const LEGACY_ONBOARDING_DRAFT_KEY = "supermemory-brain-onboarding-v1" +const ONBOARDING_DRAFT_KEY_PREFIX = "supermemory-brain-onboarding-v2" + +export type OnboardingDraftStorage = Pick< + Storage, + "getItem" | "setItem" | "removeItem" +> + +export function getOnboardingDraftStorage(): OnboardingDraftStorage | null { + try { + return globalThis.localStorage ?? null + } catch { + return null + } +} + +export function onboardingDraftKey(userId: string): string { + return `${ONBOARDING_DRAFT_KEY_PREFIX}:${encodeURIComponent(userId)}` +} + +export function readOnboardingDraft( + storage: OnboardingDraftStorage, + userId: string, +): T | null { + try { + const raw = storage.getItem(onboardingDraftKey(userId)) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null + } + return parsed as T + } catch { + return null + } +} + +export function writeOnboardingDraft( + storage: OnboardingDraftStorage, + userId: string, + draft: object, +): void { + try { + storage.setItem(onboardingDraftKey(userId), JSON.stringify(draft)) + } catch {} +} + +export function clearOnboardingDraft( + storage: OnboardingDraftStorage, + userId: string, +): void { + try { + storage.removeItem(onboardingDraftKey(userId)) + } catch {} +} + +// Legacy drafts cannot be attributed to an account safely. Discard them +// instead of risking that the next signed-in user sees another user's data. +export function discardLegacyOnboardingDraft( + storage: OnboardingDraftStorage, +): void { + try { + storage.removeItem(LEGACY_ONBOARDING_DRAFT_KEY) + } catch {} +} diff --git a/apps/web/package.json b/apps/web/package.json index 63c4acee..97152e9d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -126,6 +126,7 @@ "@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"