import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, screen, render as testingLibraryRender } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar";
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
const authState = vi.hoisted(() => ({
currentUser: { name: "users/test" } as { name: string } | undefined,
memoViews: [] as Array<{ name: string; title: string }>,
notifications: [] as Array<{ status: number }>,
}));
const sidebarState = vi.hoisted(() => ({
memoScope: "home" as "home" | "explore",
mobileOpen: false,
}));
const globalEditorState = vi.hoisted(() => ({
canOpen: true,
openEditor: vi.fn(),
}));
const spaceState = vi.hoisted(() => ({
spaces: [] as Array<{ name: string; title: string; description: string }>,
selectedSpace: undefined as { name: string; title: string; description: string } | undefined,
selectedSpaceName: undefined as string | undefined,
memoFilter: undefined as string | undefined,
clearSelectedSpace: vi.fn(),
selectMemos: vi.fn(),
selectSpace: vi.fn(),
}));
const filteredStatsHook = vi.hoisted(() => vi.fn());
const tagsSectionHook = vi.hoisted(() => vi.fn());
vi.mock("@/components/MemosLogo", () => ({
default: () => Memos logo,
}));
vi.mock("@/components/MemoDisplaySettingMenu", () => ({
default: () => ,
}));
vi.mock("@/components/UserMenu", () => ({
default: () =>
User menu
,
}));
vi.mock("@/components/CreateSpaceDialog", () => ({
default: () => null,
}));
vi.mock("@/components/StatisticsView", () => ({
default: () => Calendar
,
}));
vi.mock("@/components/AppSidebar/TagsSection", () => ({
default: (props: unknown) => {
tagsSectionHook(props);
return Tags
;
},
}));
vi.mock("@/contexts/AppSidebarContext", () => ({
useAppSidebar: () => ({
attachmentSection: "all",
setAttachmentSection: vi.fn(),
inboxFilter: "all",
setInboxFilter: vi.fn(),
memoDetail: undefined,
setMemoDetail: vi.fn(),
mobileOpen: sidebarState.mobileOpen,
setMobileOpen: vi.fn(),
quickFindOpen: false,
setQuickFindOpen: vi.fn(),
memoScope: sidebarState.memoScope,
setMemoScope: vi.fn(),
}),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isInitialized: true }),
}));
vi.mock("@/contexts/GlobalMemoEditorContext", () => ({
useGlobalMemoEditor: () => globalEditorState,
}));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ isInitialized: true }),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
stringifyFilters: () => "",
useMemoFilterContext: () => ({
filters: [],
memoView: undefined,
setMemoView: vi.fn(),
}),
}));
vi.mock("@/contexts/SpaceContext", () => ({
useSpaceContext: () => {
const duplicateSpaceTitles = new Set(
spaceState.spaces
.filter((space, index) => spaceState.spaces.findIndex((candidate) => candidate.title === space.title) !== index)
.map((space) => space.title),
);
return {
...spaceState,
duplicateSpaceTitles,
isLoadingSpaces: false,
isSpacesError: false,
};
},
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => authState.currentUser,
}));
vi.mock("@/hooks/useFilteredMemoStats", () => ({
useFilteredMemoStats: (options: unknown) => {
filteredStatsHook(options);
return { statistics: { activityStats: {}, timeBasis: "create_time" }, tags: {} };
},
}));
vi.mock("@/hooks/useAttachmentLibrary", () => ({
useAttachmentLibraryStats: () => ({ stats: { media: 0, documents: 0, audio: 0, unused: 0 } }),
}));
vi.mock("@/hooks/useMediaQuery", () => ({
default: () => true,
}));
vi.mock("@/hooks/useUserQueries", () => ({
userKeys: {
memoViews: (parent?: string) => ["users", "memoViews", parent],
},
useMemoViews: () => ({ data: authState.memoViews }),
useNotifications: () => ({ data: authState.notifications }),
useUser: () => ({ data: undefined }),
}));
vi.mock("@/i18n", () => ({
default: { language: "en" },
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
const render = (ui: Parameters[0]) =>
testingLibraryRender(
{ui},
);
const expectCollapsedNavPill = (pill: HTMLElement, label: string) => {
expect(pill).toHaveClass("h-[30px]", "px-[7px]");
const labelTrack = pill.querySelector('span[aria-hidden="true"]');
expect(labelTrack).toHaveClass("grid-cols-[0fr]", "pl-0");
expect(labelTrack).toHaveTextContent(label);
};
const expectActiveNavPill = (pill: HTMLElement, label: string) => {
expect(pill).toHaveAttribute("aria-current", "page");
expect(pill.querySelector('span[aria-hidden="true"]')).toBeNull();
expect(pill).toHaveTextContent(label);
};
describe("App sidebar logo", () => {
beforeEach(() => {
authState.currentUser = { name: "users/test" };
authState.memoViews = [];
authState.notifications = [];
sidebarState.memoScope = "home";
sidebarState.mobileOpen = false;
globalEditorState.canOpen = true;
globalEditorState.openEditor.mockClear();
spaceState.spaces = [];
spaceState.selectedSpace = undefined;
spaceState.selectedSpaceName = undefined;
spaceState.memoFilter = undefined;
spaceState.clearSelectedSpace.mockClear();
spaceState.selectMemos.mockClear();
spaceState.selectSpace.mockClear();
filteredStatsHook.mockClear();
tagsSectionHook.mockClear();
});
it("shows the context switcher and opens the global memo editor", () => {
render(
,
);
expect(screen.getByRole("button", { name: "space.switch: common.memos" })).toHaveTextContent("Memos logo");
fireEvent.click(screen.getByRole("button", { name: "editor.new-memo" }));
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
expect(screen.queryByText("common.calendar")).not.toBeInTheDocument();
});
it.each([
"/",
"/explore",
"/attachments",
"/Explore/",
"/Attachments/",
])("shows the selected Space only on collection route %s", (path) => {
const product = { name: "spaces/product", title: "Product", description: "" };
spaceState.spaces = [product];
spaceState.selectedSpace = product;
spaceState.selectedSpaceName = product.name;
spaceState.memoFilter = 'space == "spaces/product"';
render(
,
);
expect(screen.getByRole("button", { name: "space.switch: Product" })).toBeInTheDocument();
});
it.each([
"/archived",
"/inbox",
"/u/alice",
"/setting",
"/about",
"/views",
"/memos/123",
"/memos/shares/token",
"/404",
])("uses the instance brand instead of the remembered Space on %s", (path) => {
const product = { name: "spaces/product", title: "Product", description: "" };
spaceState.spaces = [product];
spaceState.selectedSpace = product;
spaceState.selectedSpaceName = product.name;
spaceState.memoFilter = 'space == "spaces/product"';
render(
,
);
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
});
it("scopes collection statistics to the selected Space", () => {
spaceState.selectedSpaceName = "spaces/product";
spaceState.memoFilter = 'space == "spaces/product"';
render(
,
);
expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "explore", filter: 'space == "spaces/product"' }));
});
it("includes authorized Space memos in All Explore statistics", () => {
render(
,
);
expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "explore", filter: undefined }));
});
it("keeps Profile statistics and tag UI state independent of the remembered Space", () => {
spaceState.selectedSpaceName = "spaces/product";
spaceState.memoFilter = 'space == "spaces/product"';
render(
,
);
expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "profile", filter: undefined }));
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "profile" }));
});
it("keeps Archived statistics and tag UI state independent of the remembered Space", () => {
spaceState.selectedSpaceName = "spaces/product";
spaceState.memoFilter = 'space == "spaces/product"';
render(
,
);
expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "archived", filter: undefined }));
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "archived" }));
});
it("keeps Inbox compact while exposing its unread state accessibly", () => {
authState.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
render(
,
);
const inbox = screen.getByRole("link", { name: "common.inbox, 2 inbox.unread" });
expect(inbox).toHaveAttribute("aria-current", "page");
expect(inbox).not.toHaveTextContent("common.inbox");
expect(inbox).not.toHaveTextContent("2");
expect(inbox.querySelector("[data-inbox-unread-indicator]")).not.toBeNull();
expect(inbox.closest("footer")).not.toBeNull();
});
it.each([
["/Attachments/", "common.attachments"],
["/Inbox/", "common.inbox"],
])("keeps %s active after route normalization", (path, label) => {
render(
,
);
expect(screen.getByRole("link", { name: label })).toHaveAttribute("aria-current", "page");
});
it("hides the instance-level unused attachment collection in a Space", () => {
spaceState.selectedSpaceName = "spaces/product";
spaceState.memoFilter = 'space == "spaces/product"';
render(
,
);
expect(screen.queryByRole("button", { name: "attachment-library.labels.unused" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "attachment-library.tabs.media" })).toBeInTheDocument();
});
it("hides Compose when the composer reports it is not available", () => {
globalEditorState.canOpen = false;
render(
,
);
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.search" })).toBeInTheDocument();
});
it("shows the compact public navigation for a guest", () => {
authState.currentUser = undefined;
globalEditorState.canOpen = false;
render(
,
);
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/explore");
expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.explore" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
});
it("does not inherit collection content on a route without a collection", () => {
render(
,
);
expect(screen.queryByText("Calendar")).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "common.statistics" })).not.toBeInTheDocument();
expect(screen.queryByText("common.views")).not.toBeInTheDocument();
expect(screen.queryByText("Tags")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: "common.attachments" })).toHaveAttribute("href", "/attachments");
expect(screen.getByRole("link", { name: "common.inbox" })).toHaveAttribute("href", "/inbox");
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.about" })).not.toBeInTheDocument();
expect(screen.getByText("User menu").closest("footer")).not.toBeNull();
});
it("uses a visitor sidebar for a guest on a route without contextual content", () => {
authState.currentUser = undefined;
render(
,
);
expect(screen.getByRole("link", { name: "common.explore" })).toHaveAttribute("href", "/explore");
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.attachments" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.inbox" })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
});
it.each(["/about", "/About/"])("marks About active for a guest on %s", (path) => {
authState.currentUser = undefined;
render(
,
);
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("aria-current", "page");
expect(screen.queryByText("Calendar")).not.toBeInTheDocument();
});
it("uses a compact scope menu and places views below the calendar", async () => {
render(
,
);
const calendar = screen.getByText("Calendar");
const views = screen.getByText("common.views");
expect(screen.getByRole("region", { name: "common.statistics" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "common.views", level: 2 })).toBeInTheDocument();
expect(calendar.compareDocumentPosition(views) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const viewOptions = screen.getByRole("button", { name: "memo.view-options" });
const createView = screen.getByRole("button", { name: "common.create" });
expect(viewOptions.compareDocumentPosition(createView) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(createView).toHaveClass(...SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" "));
expect(createView.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES);
const tasksView = screen.getByRole("button", { name: "common.tasks" });
expect(tasksView).toHaveTextContent("common.tasks");
expect(tasksView).not.toHaveTextContent("☑️");
expect(screen.queryByRole("button", { name: "common.all" })).not.toBeInTheDocument();
const scopeTrigger = screen.getByRole("button", { name: "common.home" });
fireEvent.click(scopeTrigger);
expect(await screen.findByRole("menuitem", { name: "common.home" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.explore" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "common.archived" })).not.toBeInTheDocument();
});
it("uses compact text-only actions for a saved view", async () => {
authState.memoViews = [{ name: "memoViews/1", title: "testgp" }];
render(
,
);
fireEvent.click(screen.getByRole("button", { name: "common.edit testgp" }));
const menu = await screen.findByRole("menu");
expect(menu).toHaveAttribute("data-size", "sm");
expect(menu).toHaveClass("min-w-24", "p-0.5");
const editItem = screen.getByRole("menuitem", { name: "common.edit" });
const deleteItem = screen.getByRole("menuitem", { name: "common.delete" });
expect(editItem.querySelector("svg")).toBeNull();
expect(deleteItem.querySelector("svg")).toBeNull();
expect(deleteItem).toHaveAttribute("data-variant", "destructive");
});
it("keeps collection navigation together and places Inbox in the user footer", async () => {
render(
,
);
const scopeTrigger = screen.getByRole("button", { name: "common.home" });
expectCollapsedNavPill(scopeTrigger, "common.home");
const inbox = screen.getByRole("link", { name: "common.inbox" });
expect(inbox.closest("footer")).not.toBeNull();
expect(inbox.closest('nav[aria-label="Primary"]')).toBeNull();
const attachments = screen.getByRole("link", { name: "common.attachments" });
expectActiveNavPill(attachments, "common.attachments");
fireEvent.click(scopeTrigger);
expect(await screen.findByText("Calendar")).toBeInTheDocument();
expectActiveNavPill(screen.getByRole("button", { name: "common.home" }), "common.home");
expect(screen.queryByRole("menuitem", { name: "common.explore" })).not.toBeInTheDocument();
});
it.each([
["/attachments", "common.attachments"],
["/inbox", "common.inbox"],
["/setting", "common.basic"],
["/u/test", "common.profile"],
])("labels the sidebar content section on %s", (path, label) => {
render(
,
);
expect(screen.getByRole("heading", { name: label, level: 2 })).toBeInTheDocument();
});
it("keeps the Explore scope available from a global destination", async () => {
sidebarState.memoScope = "explore";
render(
,
);
const scopeTrigger = screen.getByRole("button", { name: "common.explore" });
expectCollapsedNavPill(scopeTrigger, "common.explore");
fireEvent.click(scopeTrigger);
expectActiveNavPill(await screen.findByRole("button", { name: "common.explore", current: "page" }), "common.explore");
});
it("leaves Archived through the remembered primary feed without presenting it as a scope", async () => {
sidebarState.memoScope = "explore";
render(
,
);
const scopeTrigger = screen.getByRole("button", { name: "common.explore" });
expectCollapsedNavPill(scopeTrigger, "common.explore");
fireEvent.click(scopeTrigger);
expectActiveNavPill(await screen.findByRole("button", { name: "common.explore", current: "page" }), "common.explore");
});
it("keeps the mobile header limited to navigation and context", () => {
render(
,
);
expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("data-mobile-navigation-trigger");
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
});
it("exposes Compose in the mobile navigation drawer", () => {
sidebarState.mobileOpen = true;
render(
,
);
expect(screen.getByRole("button", { name: "common.search" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "editor.new-memo" }));
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
});
});