0.29.1原版

This commit is contained in:
anian
2026-07-02 19:14:14 +08:00
commit d94008f0fb
947 changed files with 174905 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
import { render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { TILE_SPRITES } from "@/components/Placeholder/tileSprites";
import About from "@/pages/About";
describe("<About>", () => {
afterEach(() => {
document.documentElement.removeAttribute("data-theme");
});
it("renders the product story and current bird sprites", () => {
render(<About />);
expect(screen.getByRole("heading", { name: "Memos" })).toBeInTheDocument();
expect(screen.getByText(/Capture first/i)).toBeInTheDocument();
expect(screen.getByText(/quick capture/i)).toBeInTheDocument();
const sponsors = screen.getByRole("region", { name: "Sponsors" });
expect(within(sponsors).getByRole("link", { name: /CodeRabbit/i })).toHaveAttribute("href", "https://coderabbit.link/usememos");
expect(within(sponsors).getByRole("link", { name: /Warp/i })).toHaveAttribute("href", "https://go.warp.dev/memos");
expect(within(sponsors).getByText(/Cut code review time/i)).toBeInTheDocument();
expect(within(sponsors).getByText(/agentic development environment/i)).toBeInTheDocument();
expect(within(sponsors).getByAltText("CodeRabbit").closest("a")).toHaveAttribute("href", "https://coderabbit.link/usememos");
expect(
within(sponsors)
.getByText(/Cut code review time/i)
.closest("a"),
).toHaveAttribute("href", "https://coderabbit.link/usememos");
expect(within(sponsors).getByAltText("Warp").closest("a")).toHaveAttribute("href", "https://go.warp.dev/memos");
expect(
within(sponsors)
.getByText(/agentic development environment/i)
.closest("a"),
).toHaveAttribute("href", "https://go.warp.dev/memos");
const birds = screen.getByRole("region", { name: "Birds" });
expect(within(birds).getAllByTestId("about-bird-sprite")).toHaveLength(TILE_SPRITES.length);
for (const sprite of TILE_SPRITES) {
expect(within(birds).getByText(sprite.name)).toBeInTheDocument();
}
});
it("uses dark sponsor logos when the app theme is dark", () => {
document.documentElement.setAttribute("data-theme", "default-dark");
render(<About />);
expect(screen.getByAltText("CodeRabbit")).toHaveAttribute(
"src",
"https://victorious-bubble-f69a016683.media.strapiapp.com/White_Typemark_79b9189d19.svg",
);
expect(screen.getByAltText("Warp")).toHaveAttribute(
"src",
"https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-White.png",
);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { getTooltipText } from "@/components/ActivityCalendar/utils";
// Minimal stub for the i18n translate fn — returns a deterministic string we can assert on.
const t = ((key: string, vars?: Record<string, unknown>) => {
if (!vars) return key;
const parts = Object.entries(vars).map(([k, v]) => `${k}=${String(v)}`);
return `${key}|${parts.join(",")}`;
}) as Parameters<typeof getTooltipText>[2];
describe("getTooltipText", () => {
it("returns just the date when count is 0", () => {
expect(getTooltipText(0, "2026-05-02", t)).toBe("2026-05-02");
});
it("uses the created-tooltip key for create_time basis (default)", () => {
const out = getTooltipText(3, "2026-05-02", t);
expect(out.toLowerCase()).toContain("memo.count-memos-in-date");
expect(out.toLowerCase()).not.toContain("updated");
});
it("uses the updated-tooltip key for update_time basis", () => {
const out = getTooltipText(3, "2026-05-02", t, "update_time");
expect(out.toLowerCase()).toContain("memo.count-memos-updated-in-date");
});
});
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/auth-state", () => ({
clearAccessToken: vi.fn(),
}));
import { clearAccessToken } from "@/auth-state";
import { redirectOnAuthFailure } from "@/utils/auth-redirect";
const mockedClearAccessToken = vi.mocked(clearAccessToken);
type NavigationStub = { replace: ReturnType<typeof vi.fn>; href: string };
function installLocation(href: string): NavigationStub {
const url = new URL(href);
const replace = vi.fn((next: string) => {
// Mirror real navigation: update the mutable href on subsequent inspection.
location.href = new URL(next, url).toString();
});
const location: NavigationStub = { replace, href: url.toString() };
Object.defineProperty(window, "location", {
configurable: true,
value: {
get href() {
return location.href;
},
set href(value: string) {
location.href = value;
},
pathname: url.pathname,
search: url.search,
hash: url.hash,
origin: url.origin,
replace,
},
});
return location;
}
describe("redirectOnAuthFailure", () => {
let originalLocation: Location;
beforeEach(() => {
originalLocation = window.location;
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
});
it("does nothing when the user is already on an /auth page", () => {
const nav = installLocation("http://localhost/auth?foo=bar");
redirectOnAuthFailure();
expect(nav.replace).not.toHaveBeenCalled();
expect(mockedClearAccessToken).not.toHaveBeenCalled();
});
it("does nothing on a public route by default", () => {
const nav = installLocation("http://localhost/explore");
redirectOnAuthFailure();
expect(nav.replace).not.toHaveBeenCalled();
expect(mockedClearAccessToken).not.toHaveBeenCalled();
});
it("clears the token and redirects to /auth on a protected route", () => {
const nav = installLocation("http://localhost/home?tab=pins#latest");
redirectOnAuthFailure();
expect(mockedClearAccessToken).toHaveBeenCalledTimes(1);
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest");
});
it("honours forceRedirect even on a public route", () => {
const nav = installLocation("http://localhost/explore");
redirectOnAuthFailure(true);
expect(mockedClearAccessToken).toHaveBeenCalledTimes(1);
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fexplore");
});
it("embeds the reason parameter when provided", () => {
const nav = installLocation("http://localhost/home");
redirectOnAuthFailure(false, { reason: "protected-memo" });
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome&reason=protected-memo");
});
it("prefers an explicitly provided redirect target over the current location", () => {
const nav = installLocation("http://localhost/home");
redirectOnAuthFailure(false, { redirect: "/setting" });
expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fsetting");
});
it("drops an unsafe redirect target silently", () => {
const nav = installLocation("http://localhost/home");
redirectOnAuthFailure(false, { redirect: "//evil.example/phish" });
expect(nav.replace).toHaveBeenCalledWith("/auth");
});
});
@@ -0,0 +1,50 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CalendarCell } from "@/components/ActivityCalendar/CalendarCell";
import type { CalendarDayCell } from "@/components/ActivityCalendar/types";
const makeDay = (overrides: Partial<CalendarDayCell> = {}): CalendarDayCell => ({
date: "2025-05-01",
label: "1",
count: 0,
isCurrentMonth: true,
isToday: false,
isSelected: false,
...overrides,
});
describe("CalendarCell empty-day clickability", () => {
it("fires onClick for an in-month day with count=0", () => {
const onClick = vi.fn();
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
const button = screen.getByRole("button", { name: /May 1, 2025/ });
fireEvent.click(button);
expect(onClick).toHaveBeenCalledWith("2025-05-01");
});
it("renders an empty in-month day as interactive (tabIndex 0, not aria-disabled)", () => {
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
const button = screen.getByRole("button", { name: /May 1, 2025/ });
expect(button).toHaveAttribute("tabindex", "0");
expect(button).toHaveAttribute("aria-disabled", "false");
});
it("still renders a populated in-month day as interactive", () => {
const onClick = vi.fn();
render(<CalendarCell day={makeDay({ count: 3 })} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
fireEvent.click(screen.getByRole("button", { name: /May 1, 2025/ }));
expect(onClick).toHaveBeenCalledWith("2025-05-01");
});
it("does not render out-of-month days as interactive (no role=button)", () => {
render(
<CalendarCell day={makeDay({ isCurrentMonth: false })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />,
);
expect(screen.queryByRole("button")).toBeNull();
});
});
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import type { MemoFilter } from "@/contexts/MemoFilterContext";
describe("deriveDefaultCreateTimeFromFilters", () => {
const now = new Date(2026, 4, 2, 14, 32, 10); // 2026-05-02 14:32:10 local
it("returns undefined when no filters are set", () => {
expect(deriveDefaultCreateTimeFromFilters([], now)).toBeUndefined();
});
it("returns undefined when no displayTime filter is present", () => {
const filters: MemoFilter[] = [
{ factor: "tagSearch", value: "work" },
{ factor: "pinned", value: "true" },
];
expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined();
});
it("merges the displayTime date with the current local hh:mm:ss", () => {
const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }];
const result = deriveDefaultCreateTimeFromFilters(filters, now);
expect(result).toBeDefined();
expect(result!.getFullYear()).toBe(2025);
expect(result!.getMonth()).toBe(4); // May (0-indexed)
expect(result!.getDate()).toBe(1);
expect(result!.getHours()).toBe(14);
expect(result!.getMinutes()).toBe(32);
expect(result!.getSeconds()).toBe(10);
});
it("ignores extra non-displayTime filters", () => {
const filters: MemoFilter[] = [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2025-05-01" },
{ factor: "pinned", value: "true" },
];
const result = deriveDefaultCreateTimeFromFilters(filters, now);
expect(result?.getDate()).toBe(1);
});
it("returns undefined for a malformed YYYY-MM-DD value", () => {
const cases: MemoFilter[][] = [
[{ factor: "displayTime", value: "not-a-date" }],
[{ factor: "displayTime", value: "2025-13-40" }],
[{ factor: "displayTime", value: "" }],
[{ factor: "displayTime", value: "2025-5-1" }], // single-digit month/day
];
for (const filters of cases) {
expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined();
}
});
it("uses real `new Date()` when `now` is omitted", () => {
const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }];
const before = new Date();
const result = deriveDefaultCreateTimeFromFilters(filters);
const after = new Date();
expect(result).toBeDefined();
// Date components must come from the filter, not from `now` — guards
// against an impl that silently returns `new Date()` and ignores filters.
expect(result!.getFullYear()).toBe(2025);
expect(result!.getMonth()).toBe(4); // May (0-indexed)
expect(result!.getDate()).toBe(1);
// Time-of-day should fall between before and after (within 1s tolerance).
const resultTimeOnly = result!.getHours() * 3600 + result!.getMinutes() * 60 + result!.getSeconds();
const beforeTimeOnly = before.getHours() * 3600 + before.getMinutes() * 60 + before.getSeconds();
const afterTimeOnly = after.getHours() * 3600 + after.getMinutes() * 60 + after.getSeconds();
// Handle midnight rollover by allowing any value if before > after.
if (beforeTimeOnly <= afterTimeOnly) {
expect(resultTimeOnly).toBeGreaterThanOrEqual(beforeTimeOnly);
expect(resultTimeOnly).toBeLessThanOrEqual(afterTimeOnly);
}
});
});
+109
View File
@@ -0,0 +1,109 @@
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock dependencies BEFORE importing the hook under test.
vi.mock("@/hooks/useUserQueries", () => ({
useAllUserStats: vi.fn(),
useUserStats: vi.fn(),
}));
vi.mock("@/hooks/useMemoQueries", () => ({
useMemos: () => ({ data: undefined, isLoading: false }),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/test", id: 1 }),
}));
const mockUseView = vi.fn();
vi.mock("@/contexts/ViewContext", async () => {
const actual = await vi.importActual<typeof import("@/contexts/ViewContext")>("@/contexts/ViewContext");
return {
...actual,
useView: () => mockUseView(),
};
});
import { useAllUserStats, useUserStats } from "@/hooks/useUserQueries";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
const wrapper = ({ children }: { children: ReactNode }) => children as never;
const ts = (year: number, month: number, day: number) => ({
seconds: BigInt(Math.floor(Date.UTC(year, month - 1, day) / 1000)),
nanos: 0,
});
describe("useFilteredMemoStats", () => {
beforeEach(() => {
vi.mocked(useAllUserStats).mockReturnValue({
data: [],
isLoading: false,
} as ReturnType<typeof useAllUserStats>);
vi.mocked(useUserStats).mockReturnValue({
data: {
memoCreatedTimestamps: [ts(2026, 5, 1), ts(2026, 5, 1), ts(2026, 5, 2)],
memoUpdatedTimestamps: [ts(2026, 5, 3), ts(2026, 5, 3), ts(2026, 5, 3)],
tagCount: {},
},
isLoading: false,
} as ReturnType<typeof useUserStats>);
});
afterEach(() => {
vi.clearAllMocks();
});
it("aggregates by created timestamps when timeBasis is create_time", () => {
mockUseView.mockReturnValue({
timeBasis: "create_time",
orderByTimeAsc: false,
toggleSortOrder: vi.fn(),
setTimeBasis: vi.fn(),
});
const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper });
expect(result.current.statistics.activityStats).toEqual({ "2026-05-01": 2, "2026-05-02": 1 });
expect(result.current.statistics.timeBasis).toBe("create_time");
});
it("aggregates by updated timestamps when timeBasis is update_time", () => {
mockUseView.mockReturnValue({
timeBasis: "update_time",
orderByTimeAsc: false,
toggleSortOrder: vi.fn(),
setTimeBasis: vi.fn(),
});
const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper });
expect(result.current.statistics.activityStats).toEqual({ "2026-05-03": 3 });
expect(result.current.statistics.timeBasis).toBe("update_time");
});
it("falls back to created timestamps when updated array is empty (old server)", () => {
// Old servers that don't know about the new field deserialize it as [].
// Length divergence (created non-empty, updated empty) is the signal.
vi.mocked(useUserStats).mockReturnValue({
data: {
memoCreatedTimestamps: [ts(2026, 5, 1)],
memoUpdatedTimestamps: [],
tagCount: {},
},
isLoading: false,
} as ReturnType<typeof useUserStats>);
mockUseView.mockReturnValue({
timeBasis: "update_time",
orderByTimeAsc: false,
toggleSortOrder: vi.fn(),
setTimeBasis: vi.fn(),
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { result } = renderHook(() => useFilteredMemoStats({ userName: "users/test" }), { wrapper });
expect(result.current.statistics.activityStats).toEqual({ "2026-05-01": 1 });
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
+192
View File
@@ -0,0 +1,192 @@
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/hooks/useCurrentUser", () => ({
__esModule: true,
default: vi.fn(),
}));
import useCurrentUser from "@/hooks/useCurrentUser";
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
const mockedUseCurrentUser = vi.mocked(useCurrentUser);
// Minimal User-like stand-in — guards only check truthiness on the value.
const fakeUser = { name: "users/steven" } as unknown as ReturnType<typeof useCurrentUser>;
const LocationProbe = () => {
const location = useLocation();
return <div data-testid="location">{`${location.pathname}${location.search}${location.hash}`}</div>;
};
const renderAt = (initialEntry: string, children: ReactNode) =>
render(<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>);
describe("LandingRoute", () => {
it("renders the nested home page for an authenticated visitor at /", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/",
<Routes>
<Route path="/" element={<LandingRoute />}>
<Route index element={<div data-testid="home">home</div>} />
</Route>
<Route path="/explore" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("home")).toHaveTextContent("home");
});
it("sends an unauthenticated visitor from the entry to /explore", () => {
mockedUseCurrentUser.mockReturnValue(undefined);
renderAt(
"/",
<Routes>
<Route path="/" element={<LandingRoute />}>
<Route index element={<div data-testid="home">home</div>} />
</Route>
<Route path="/explore" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/explore");
});
it("preserves the query string and hash when redirecting an unauthenticated visitor", () => {
// Covers the regression in issue #5846: bookmarks pointing at `/?filter=...`
// must not drop their params on the trip through the landing redirect.
mockedUseCurrentUser.mockReturnValue(undefined);
renderAt(
"/?filter=tag:work#latest",
<Routes>
<Route path="/" element={<LandingRoute />}>
<Route index element={<div data-testid="home">home</div>} />
</Route>
<Route path="/explore" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/explore?filter=tag:work#latest");
});
});
describe("RequireAuthRoute", () => {
it("renders the protected content for authenticated users", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/setting",
<Routes>
<Route element={<RequireAuthRoute />}>
<Route path="/setting" element={<div data-testid="protected">secret</div>} />
</Route>
</Routes>,
);
expect(screen.getByTestId("protected")).toHaveTextContent("secret");
});
it("redirects unauthenticated users to /auth with the preserved location", () => {
mockedUseCurrentUser.mockReturnValue(undefined);
renderAt(
"/setting?tab=pins#latest",
<Routes>
<Route element={<RequireAuthRoute />}>
<Route path="/setting" element={<div data-testid="protected">secret</div>} />
</Route>
<Route path="/auth" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/auth?redirect=%2Fsetting%3Ftab%3Dpins%23latest");
});
});
describe("RequireGuestRoute", () => {
it("renders the auth page when no user is present", () => {
mockedUseCurrentUser.mockReturnValue(undefined);
renderAt(
"/auth",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div data-testid="sign-in">sign in</div>} />
</Route>
</Routes>,
);
expect(screen.getByTestId("sign-in")).toHaveTextContent("sign in");
});
it("redirects already-authenticated users to / by default", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/auth",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div>sign in</div>} />
</Route>
<Route path="/" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/");
});
it("honours a safe redirect target from the query string", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/auth?redirect=%2Fsetting",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div>sign in</div>} />
</Route>
<Route path="/setting" element={<LocationProbe />} />
<Route path="/" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/setting");
});
it("ignores an auth-family redirect target and falls back to /", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/auth?redirect=%2Fauth%2Fcallback",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div>sign in</div>} />
</Route>
<Route path="/" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/");
});
it("ignores an external redirect target and falls back to /", () => {
mockedUseCurrentUser.mockReturnValue(fakeUser);
renderAt(
"/auth?redirect=%2F%2Fevil.example%2Fphish",
<Routes>
<Route element={<RequireGuestRoute />}>
<Route path="/auth" element={<div>sign in</div>} />
</Route>
<Route path="/" element={<LocationProbe />} />
</Routes>,
);
expect(screen.getByTestId("location").textContent).toBe("/");
});
});
+194
View File
@@ -0,0 +1,194 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDebouncedEffect, useLocalStorage } from "@/hooks";
const localStorageMock = (() => {
let store = new Map<string, string>();
let shouldThrowOnSet = false;
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
if (shouldThrowOnSet) {
throw new Error("localStorage setItem unavailable");
}
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store = new Map<string, string>();
},
setShouldThrowOnSet: (value: boolean) => {
shouldThrowOnSet = value;
},
};
})();
Object.defineProperty(window, "localStorage", {
configurable: true,
value: localStorageMock,
});
describe("useLocalStorage", () => {
beforeEach(() => {
localStorageMock.setShouldThrowOnSet(false);
window.localStorage.clear();
});
afterEach(() => {
localStorageMock.setShouldThrowOnSet(false);
window.localStorage.clear();
});
it("uses the default value when storage is empty", () => {
const { result } = renderHook(() => useLocalStorage("hook-test-empty", false));
expect(result.current[0]).toBe(false);
});
it("reads and writes JSON values", () => {
window.localStorage.setItem("hook-test-existing", JSON.stringify(true));
const { result } = renderHook(() => useLocalStorage("hook-test-existing", false));
expect(result.current[0]).toBe(true);
act(() => {
result.current[1](false);
});
expect(result.current[0]).toBe(false);
expect(window.localStorage.getItem("hook-test-existing")).toBe("false");
});
it("supports updater functions", () => {
const { result } = renderHook(() => useLocalStorage("hook-test-updater", false));
act(() => {
result.current[1]((current) => !current);
});
expect(result.current[0]).toBe(true);
expect(window.localStorage.getItem("hook-test-updater")).toBe("true");
});
it("falls back to the default value for malformed storage", () => {
window.localStorage.setItem("hook-test-malformed", "{bad json");
const { result } = renderHook(() => useLocalStorage("hook-test-malformed", true));
expect(result.current[0]).toBe(true);
});
it("keeps in-memory state when the default object reference changes and persistence is unavailable", () => {
localStorageMock.setShouldThrowOnSet(true);
const initialDefaultValue = { enabled: false };
const updatedValue = { enabled: true };
const { result, rerender } = renderHook(({ defaultValue }) => useLocalStorage("hook-test-object", defaultValue), {
initialProps: { defaultValue: initialDefaultValue },
});
expect(result.current[0]).toBe(initialDefaultValue);
act(() => {
result.current[1](updatedValue);
});
expect(result.current[0]).toBe(updatedValue);
rerender({ defaultValue: { enabled: false } });
expect(result.current[0]).toBe(updatedValue);
});
});
describe("useDebouncedEffect", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("runs the latest callback after the delay", () => {
const calls: string[] = [];
const { rerender } = renderHook(
({ value }) => {
useDebouncedEffect(
() => {
calls.push(value);
},
100,
[value],
);
},
{ initialProps: { value: "first" } },
);
act(() => {
vi.advanceTimersByTime(99);
});
expect(calls).toEqual([]);
rerender({ value: "second" });
act(() => {
vi.advanceTimersByTime(100);
});
expect(calls).toEqual(["second"]);
});
it("uses the latest callback when unrelated props rerender before the delay", () => {
const calls: string[] = [];
const { rerender } = renderHook(
({ dependency, value }) => {
useDebouncedEffect(
() => {
calls.push(value);
},
100,
[dependency],
);
},
{ initialProps: { dependency: "same", value: "first" } },
);
act(() => {
vi.advanceTimersByTime(50);
});
expect(calls).toEqual([]);
rerender({ dependency: "same", value: "second" });
act(() => {
vi.advanceTimersByTime(50);
});
expect(calls).toEqual(["second"]);
});
it("clears the pending timeout on unmount", () => {
const calls: string[] = [];
const { unmount } = renderHook(() => {
useDebouncedEffect(
() => {
calls.push("called");
},
100,
[],
);
});
unmount();
act(() => {
vi.advanceTimersByTime(100);
});
expect(calls).toEqual([]);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import LocationPicker from "@/components/map/LocationPicker";
const setView = vi.fn();
const locate = vi.fn();
const zoomIn = vi.fn();
const zoomOut = vi.fn();
const eventMap = { setView, locate };
const controlMap = { zoomIn, zoomOut };
vi.mock("leaflet", () => {
class LatLng {
lat: number;
lng: number;
constructor(lat: number, lng: number) {
this.lat = lat;
this.lng = lng;
}
}
class Control {
addTo() {
return this;
}
remove() {}
}
return {
default: {
Control,
DomUtil: {
create: () => ({ style: {} }),
},
DomEvent: {
disableClickPropagation: () => {},
disableScrollPropagation: () => {},
},
},
LatLng,
};
});
vi.mock("react-leaflet", () => ({
MapContainer: ({ children }: { children: ReactNode }) => <div data-testid="map">{children}</div>,
Marker: ({ position }: { position: { lat: number; lng: number } }) => <div data-testid="marker">{`${position.lat},${position.lng}`}</div>,
useMap: () => controlMap,
useMapEvents: () => eventMap,
}));
vi.mock("@/components/map/map-utils", () => ({
defaultMarkerIcon: {},
ThemedTileLayer: () => <div data-testid="tile-layer" />,
}));
describe("LocationPicker", () => {
it("does not recenter when rerendered with the same coordinates", () => {
const { rerender } = render(<LocationPicker latlng={{ lat: 1, lng: 2 }} />);
expect(setView).toHaveBeenCalledTimes(1);
rerender(<LocationPicker latlng={{ lat: 1, lng: 2 }} />);
expect(setView).toHaveBeenCalledTimes(1);
rerender(<LocationPicker latlng={{ lat: 3, lng: 4 }} />);
expect(setView).toHaveBeenCalledTimes(2);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions";
describe("checkAllTasks", () => {
it("checks every unchecked task while preserving source formatting", () => {
const markdown = ["Intro", "- [ ] first", "* [x] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n");
expect(checkAllTasks(markdown)).toBe(["Intro", "- [x] first", "* [x] second", " + [x] nested", "1. [x] ordered", "Outro"].join("\n"));
});
it("returns the original string when no checkbox markers need changing", () => {
const markdown = ["Intro", "- [x] first", "Outro"].join("\n");
expect(checkAllTasks(markdown)).toBe(markdown);
});
});
describe("uncheckAllTasks", () => {
it("unchecks every checked task while preserving source formatting", () => {
const markdown = ["Intro", "- [x] first", "* [X] second", " + [ ] nested", "1. [x] ordered", "Outro"].join("\n");
expect(uncheckAllTasks(markdown)).toBe(["Intro", "- [ ] first", "* [ ] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n"));
});
it("returns the original string when no checkbox markers need changing", () => {
const markdown = ["Intro", "- [ ] first", "Outro"].join("\n");
expect(uncheckAllTasks(markdown)).toBe(markdown);
});
it("ignores task-looking text inside fenced and inline code", () => {
const markdown = ["```", "- [x] not a task", "```", "", "Inline `- [x] not a task` text", "", "- [x] real task"].join("\n");
expect(uncheckAllTasks(markdown)).toBe(
["```", "- [x] not a task", "```", "", "Inline `- [x] not a task` text", "", "- [ ] real task"].join("\n"),
);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { List, ListItem } from "@/components/MemoContent/markdown";
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "@/components/MemoContent/constants";
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
import { describe, expect, it } from "vitest";
const renderListContent = (content: string): string =>
renderToStaticMarkup(
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkSplitMixedTaskLists]}
components={{
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
}}
>
{content}
</ReactMarkdown>,
);
describe("memo content lists", () => {
it("keeps bullets on regular items in mixed task and bullet lists", () => {
const html = renderListContent("- [ ] pickup package\n- [ ] library returns\n\n- milk\n- eggs\n- bread");
const listOpenTags = html.match(/<ul class="[^"]*"/g) ?? [];
expect(listOpenTags).toHaveLength(2);
expect(listOpenTags[0]).toContain(TASK_LIST_CLASS);
expect(listOpenTags[0]).toContain("list-none");
expect(listOpenTags[0]).not.toContain("pl-6");
expect(listOpenTags[1]).not.toContain(TASK_LIST_CLASS);
expect(listOpenTags[1]).toContain("pl-6");
expect(listOpenTags[1]).toContain("list-disc");
expect(html).toContain('<li class="mt-0.5 leading-6">milk</li>');
expect(html).not.toContain('<li class="mt-0.5 leading-6">\n<p>milk</p>');
expect(html).toContain(TASK_LIST_ITEM_CLASS);
expect(html).toContain("grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2");
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&amp;&gt;*:last-child]:mb-0"> pickup package</div>');
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
});
it("keeps compact styling for pure task lists", () => {
const html = renderListContent("- [ ] pickup package\n- [ ] library returns");
expect(html).toMatch(/<ul class="[^"]*\blist-none\b[^"]*"/);
expect(html).not.toMatch(/<ul class="[^"]*\blist-disc\b[^"]*"/);
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
});
it("keeps nested task lists on their own row", () => {
const html = renderListContent("- [ ] asdas\n - [ ] zzzz");
expect(html).toContain("grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2");
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&amp;&gt;*:last-child]:mb-0"> asdas');
expect(html).not.toContain("[&amp;_ul.contains-task-list]:ml-6");
expect(html).toContain("zzzz");
});
it("keeps loose task list paragraphs while aligning the first line", () => {
const html = renderListContent("- [ ] plan\n\n keep details\n\n- [ ] zzzz");
expect(html).toMatch(/<li class="[^"]*task-list-item[^"]*">\s*<input type="checkbox" disabled=""\/>/);
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&amp;&gt;*:last-child]:mb-0">');
expect(html).toContain("<p> plan</p>");
expect(html).toContain("<p>keep details</p>");
expect(html).toContain("zzzz");
});
it("keeps inline task markdown in the task body", () => {
const html = renderListContent("- [ ] Northern Lights in Iceland — booking this for winter, *finally*");
expect(html).toContain('<input type="checkbox" disabled=""/>');
expect(html).toContain(
'<div class="min-w-0 [overflow-wrap:anywhere] [&amp;&gt;*:last-child]:mb-0"> Northern Lights in Iceland — booking this for winter, <em>finally</em></div>',
);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { describe, expect, it } from "vitest";
import { getSingleLinkHref } from "@/components/MemoContent/markdown/Paragraph";
const collectSingleLinkHrefs = (content: string): Array<string | undefined> => {
const hrefs: Array<string | undefined> = [];
renderToStaticMarkup(
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children, node }) => {
hrefs.push(getSingleLinkHref(node));
return <p>{children}</p>;
},
}}
>
{content}
</ReactMarkdown>,
);
return hrefs;
};
describe("memo content paragraph links", () => {
it("treats only bare single-link paragraphs as single link hrefs", () => {
expect(collectSingleLinkHrefs("https://www.bilibili.com/\n\n[bilibili](https://www.bilibili.com/)")).toEqual([
"https://www.bilibili.com/",
undefined,
]);
});
});
+87
View File
@@ -0,0 +1,87 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { describe, expect, it } from "vitest";
import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "@/components/MemoContent/constants";
type IframeProps = React.ComponentProps<"iframe">;
const TrustedIframe = (props: IframeProps) => {
if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) {
return null;
}
return <iframe {...props} />;
};
const renderMemoContent = (content: string): string =>
renderToStaticMarkup(
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], [rehypeKatex, { throwOnError: false, strict: false }]]}
components={{ iframe: TrustedIframe }}
>
{content}
</ReactMarkdown>,
);
const renderGfmContent = (content: string): string =>
renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[[rehypeSanitize, SANITIZE_SCHEMA]]}>
{content}
</ReactMarkdown>,
);
describe("memo content sanitization", () => {
it("strips user-controlled inline styles from raw HTML spans", () => {
const html = renderMemoContent('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
expect(html).toMatch(/<span>overlay<\/span>/);
expect(html).not.toMatch(/style=/);
expect(html).not.toMatch(/position:fixed/);
});
it("still renders KaTeX output after sanitizing math marker classes", () => {
const html = renderMemoContent("$L$");
expect(html).toMatch(/class="katex"/);
expect(html).toMatch(/class="katex-html"/);
});
it("preserves checked state for GFM task list items", () => {
const html = renderGfmContent("- [x] Done\n- [ ] Todo");
const inputs = html.match(/<input[^>]+\/>/g) ?? [];
expect(inputs).toHaveLength(2);
expect(inputs[0]).toContain('checked=""');
expect(inputs[1]).not.toContain('checked=""');
});
});
describe("trusted iframe providers", () => {
it("accepts trusted providers only", () => {
expect(isTrustedIframeSrc("https://www.youtube.com/embed/abc123")).toBe(true);
expect(isTrustedIframeSrc("https://www.youtube-nocookie.com/embed/abc123?si=test")).toBe(true);
expect(isTrustedIframeSrc("https://player.vimeo.com/video/123456")).toBe(true);
expect(isTrustedIframeSrc("https://open.spotify.com/embed/track/123456")).toBe(true);
expect(isTrustedIframeSrc("https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/123456")).toBe(true);
expect(isTrustedIframeSrc("https://www.loom.com/embed/123456")).toBe(true);
expect(isTrustedIframeSrc("https://www.google.com/maps/embed?pb=test")).toBe(true);
expect(isTrustedIframeSrc("https://app.diagrams.net/?embed=1")).toBe(true);
expect(isTrustedIframeSrc("https://www.draw.io/?embed=1")).toBe(true);
expect(isTrustedIframeSrc("https://evil.example/embed/abc123")).toBe(false);
});
it("drops untrusted iframe embeds during rendering", () => {
const trusted = renderMemoContent('<iframe src="https://www.youtube.com/embed/abc123" title="demo"></iframe>');
const untrusted = renderMemoContent('<iframe src="https://evil.example/embed/abc123" title="demo"></iframe>');
expect(trusted).toMatch(/<iframe/);
expect(trusted).toMatch(/youtube\.com\/embed\/abc123/);
expect(untrusted).not.toMatch(/<iframe/);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cacheService } from "@/components/MemoEditor/services/cacheService";
describe("memo editor cache", () => {
beforeEach(() => {
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => storage.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => storage.set(key, value)),
removeItem: vi.fn((key: string) => storage.delete(key)),
});
cacheService.clearAll();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("stores draft content", () => {
const key = cacheService.key("users/steven", "home-memo-editor");
cacheService.saveNow(key, "- [x] Draft task");
expect(cacheService.load(key)).toBe("- [x] Draft task");
});
it("removes empty draft content instead of caching it", () => {
const key = cacheService.key("users/steven", "home-memo-editor");
cacheService.saveNow(key, "");
expect(cacheService.load(key)).toBe("");
});
it("loads content from previously structured draft entries", () => {
const key = cacheService.key("users/steven", "home-memo-editor");
localStorage.setItem(key, JSON.stringify({ kind: "memos.editor-cache", version: 1, content: "- [ ] migrated task" }));
expect(cacheService.load(key)).toBe("- [ ] migrated task");
});
it("keeps raw JSON markdown drafts intact", () => {
const key = cacheService.key("users/steven", "home-memo-editor");
const jsonDraft = '{"content":"not a cache envelope"}';
localStorage.setItem(key, jsonDraft);
expect(cacheService.load(key)).toBe(jsonDraft);
});
it("keeps structured-looking drafts without a supported version intact", () => {
const key = cacheService.key("users/steven", "home-memo-editor");
const jsonDraft = JSON.stringify({ kind: "memos.editor-cache", content: "not a supported envelope" });
localStorage.setItem(key, jsonDraft);
expect(cacheService.load(key)).toBe(jsonDraft);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import Editor from "@/components/MemoEditor/Editor";
vi.mock("@/components/MemoEditor/Editor/TagSuggestions", () => ({
default: () => null,
}));
vi.mock("@/components/MemoEditor/Editor/SlashCommands", () => ({
default: () => null,
}));
function pastePlainText(textarea: HTMLTextAreaElement, text: string) {
fireEvent.paste(textarea, {
clipboardData: {
getData: (type: string) => (type === "text/plain" || type === "text" ? text : ""),
},
});
}
function renderEditor(initialContent: string) {
const onPaste = vi.fn();
render(
<Editor
className=""
initialContent={initialContent}
placeholder="memo"
onContentChange={vi.fn()}
onPaste={onPaste}
/>,
);
return {
onPaste,
textarea: screen.getByPlaceholderText("memo") as HTMLTextAreaElement,
};
}
describe("memo editor paste handling", () => {
it("wraps selected text with a pasted URL", () => {
const { onPaste, textarea } = renderEditor("read the docs");
textarea.setSelectionRange(9, 13);
pastePlainText(textarea, "https://example.com");
expect(textarea).toHaveValue("read the [docs](https://example.com)");
expect(textarea.selectionStart).toBe("read the [docs](https://example.com)".length);
expect(textarea.selectionEnd).toBe("read the [docs](https://example.com)".length);
expect(onPaste).not.toHaveBeenCalled();
});
it("delegates non-URL text paste", () => {
const { onPaste, textarea } = renderEditor("read the docs");
textarea.setSelectionRange(9, 13);
pastePlainText(textarea, "not a url");
expect(textarea).toHaveValue("read the docs");
expect(onPaste).toHaveBeenCalledOnce();
});
it("delegates pasted URLs when no text is selected", () => {
const { onPaste, textarea } = renderEditor("read the docs");
textarea.setSelectionRange(13, 13);
pastePlainText(textarea, "https://example.com");
expect(textarea).toHaveValue("read the docs");
expect(onPaste).toHaveBeenCalledOnce();
});
it("delegates pasted URLs when the selected text is already a URL", () => {
const { onPaste, textarea } = renderEditor("https://memos.example");
textarea.setSelectionRange(0, "https://memos.example".length);
pastePlainText(textarea, "https://example.com");
expect(textarea).toHaveValue("https://memos.example");
expect(onPaste).toHaveBeenCalledOnce();
});
});
+134
View File
@@ -0,0 +1,134 @@
import { create } from "@bufbuild/protobuf";
import { describe, expect, it } from "vitest";
import {
buildMemoShareImageFileName,
getMemoShareDialogWidth,
getMemoSharePreviewAvatarUrl,
getMemoSharePreviewWidth,
getMemoShareRenderWidth,
} from "@/components/MemoActionMenu/memoShareImage";
import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/memoShareImagePreviewModel";
import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
const buildMemo = (overrides: Partial<Memo> = {}) =>
create(MemoSchema, {
name: "memos/test",
content: "hello",
tags: [],
attachments: [],
...overrides,
});
const buildAttachment = (overrides: Partial<Attachment>) =>
create(AttachmentSchema, {
name: "attachments/test",
filename: "test.bin",
type: "application/octet-stream",
...overrides,
});
const buildPreviewModel = (memo: Memo) =>
buildMemoShareImagePreviewModel({
memo,
fallbackDisplayName: "Memo",
locale: "en-US",
});
describe("memo share image preview model", () => {
it("does not create footer chips for memo tags already rendered in content", () => {
const memo = buildMemo({
content: "Investigate #bug",
tags: ["bug"],
});
const model = buildPreviewModel(memo);
expect(model.footerBadges).toEqual([]);
});
it("keeps non-visual attachments visible as a footer summary", () => {
const memo = buildMemo({
attachments: [
buildAttachment({
name: "attachments/doc",
filename: "doc.pdf",
type: "application/pdf",
}),
],
});
const model = buildPreviewModel(memo);
expect(model.visualItems).toEqual([]);
expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 1 }]);
});
it("keeps visual attachments in the media grid without adding a footer summary", () => {
const memo = buildMemo({
attachments: [
buildAttachment({
name: "attachments/image",
filename: "image.png",
type: "image/png",
}),
],
});
const model = buildPreviewModel(memo);
expect(model.visualItems).toHaveLength(1);
expect(model.visualItems[0]?.posterUrl).toContain("/file/attachments/image/image.png?thumbnail=true");
expect(model.footerBadges).toEqual([]);
});
it("counts mixed visual and non-visual attachments in the summary", () => {
const memo = buildMemo({
attachments: [
buildAttachment({
name: "attachments/image",
filename: "image.png",
type: "image/png",
}),
buildAttachment({
name: "attachments/archive",
filename: "archive.zip",
type: "application/zip",
}),
],
});
const model = buildPreviewModel(memo);
expect(model.visualItems).toHaveLength(1);
expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 2 }]);
});
});
describe("memo share image utilities", () => {
it("builds filenames from memo resource names", () => {
expect(buildMemoShareImageFileName("memos/abc123")).toBe("memo-abc123.png");
expect(buildMemoShareImageFileName("")).toBe("memo-memo.png");
});
it("clamps preview and dialog widths", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
expect(getMemoSharePreviewWidth(100)).toBe(260);
expect(getMemoSharePreviewWidth(800)).toBe(520);
expect(getMemoShareDialogWidth(520)).toBe(600);
expect(getMemoShareRenderWidth(520, 600)).toBe(560);
});
it("uses the viewport when no card width is available", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 400 });
expect(getMemoSharePreviewWidth(0)).toBe(317);
});
it("keeps only exportable avatar URLs", () => {
expect(getMemoSharePreviewAvatarUrl("/avatars/a.png")).toBe("/avatars/a.png");
expect(getMemoSharePreviewAvatarUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc");
expect(getMemoSharePreviewAvatarUrl("https://example.com/avatar.png")).toBeUndefined();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MermaidBlock } from "@/components/MemoContent/MermaidBlock";
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({
userGeneralSetting: { theme: "default" },
}),
}));
const renderMermaid = vi.fn(async () => ({ svg: '<svg data-testid="diagram"></svg>' }));
const initializeMermaid = vi.fn();
vi.mock("mermaid", () => ({
default: {
initialize: initializeMermaid,
render: renderMermaid,
},
}));
const codeElement = (content: string) => <code className="language-mermaid">{content}</code>;
describe("MermaidBlock", () => {
it("clears rendered output when code content becomes empty", async () => {
const { container, rerender } = render(<MermaidBlock>{codeElement("graph TD; A-->B")}</MermaidBlock>);
await waitFor(() => expect(container.querySelector(".mermaid-diagram")).not.toBeNull());
rerender(<MermaidBlock>{codeElement("")}</MermaidBlock>);
await waitFor(() => expect(container.querySelector(".mermaid-diagram")).toBeNull());
});
});
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { storeOAuthState, validateOAuthState } from "@/utils/oauth";
describe("oauth state", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
it("round-trips the linking user for link flows", async () => {
const { state } = await storeOAuthState("identity-providers/google", "link", "/settings", "users/alice");
expect(validateOAuthState(state)).toEqual({
identityProviderName: "identity-providers/google",
flowMode: "link",
returnUrl: "/settings",
linkingUserName: "users/alice",
codeVerifier: expect.any(String),
});
});
it("defaults older states to signin without a linking user", () => {
sessionStorage.setItem(
"oauth_state",
JSON.stringify({
state: "legacy-state",
identityProviderName: "identity-providers/google",
timestamp: Date.now(),
returnUrl: "/auth",
}),
);
expect(validateOAuthState("legacy-state")).toEqual({
identityProviderName: "identity-providers/google",
flowMode: "signin",
returnUrl: "/auth",
linkingUserName: undefined,
codeVerifier: undefined,
});
});
});
+49
View File
@@ -0,0 +1,49 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PagedMemoList from "@/components/PagedMemoList";
vi.mock("@/hooks/useMemoQueries", () => ({
useInfiniteMemos: () => ({
data: { pages: [{ memos: [], nextPageToken: "" }] },
fetchNextPage: vi.fn(async () => undefined),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
useMemoFilterContext: () => ({ filters: [] }),
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => (key === "message.no-data" ? "No data found." : key),
}));
vi.mock("@/components/MemoContent/MentionResolutionContext", () => ({
MentionResolutionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/MemoFilters", () => ({
default: () => <div data-testid="memo-filters" />,
}));
vi.mock("@/components/MemoEditor", () => ({
default: () => <div data-testid="memo-editor" />,
}));
describe("<PagedMemoList>", () => {
it("uses the tile sprite Placeholder for the empty state", () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<PagedMemoList renderer={() => <div />} />
</QueryClientProvider>,
);
expect(screen.getByText("No data found.")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
});
+85
View File
@@ -0,0 +1,85 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Placeholder from "@/components/Placeholder";
import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
describe("<Placeholder>", () => {
it("renders the default message for variant=empty", () => {
render(<Placeholder variant="empty" />);
expect(screen.getByText(DEFAULT_MESSAGES.empty)).toBeInTheDocument();
});
it("renders the default message for variant=loading", () => {
render(<Placeholder variant="loading" />);
expect(screen.getByText(DEFAULT_MESSAGES.loading)).toBeInTheDocument();
});
it("renders the default message for variant=noResults", () => {
render(<Placeholder variant="noResults" />);
expect(screen.getByText(DEFAULT_MESSAGES.noResults)).toBeInTheDocument();
});
it("renders the default message for variant=notFound", () => {
render(<Placeholder variant="notFound" />);
expect(screen.getByText(DEFAULT_MESSAGES.notFound)).toBeInTheDocument();
});
it("overrides the default message when `message` prop is passed", () => {
render(<Placeholder variant="empty" message="Custom copy goes here" />);
expect(screen.getByText("Custom copy goes here")).toBeInTheDocument();
expect(screen.queryByText(DEFAULT_MESSAGES.empty)).not.toBeInTheDocument();
});
it("renders a 32px sprite tileset at a crisp 2x display scale", () => {
const { container } = render(<Placeholder variant="empty" />);
const viewport = screen.getByTestId("placeholder-sprite");
const strip = viewport.firstElementChild;
expect(viewport).toHaveAttribute("aria-hidden", "true");
expect(viewport).toHaveStyle({
width: "64px",
height: "64px",
overflow: "hidden",
});
expect(strip).toHaveAttribute("src", expect.stringMatching(/(\.svg|data:image\/svg\+xml)/));
expect(strip).toHaveAttribute("width", expect.stringMatching(/^(128|160|192)$/));
expect(strip).toHaveAttribute("height", "32");
expect(["256px", "320px", "384px"]).toContain((strip as HTMLElement).style.width);
expect(["steps(4)", "steps(5)", "steps(6)"]).toContain((strip as HTMLElement).style.animationTimingFunction);
expect(strip).toHaveStyle({
height: "64px",
imageRendering: "pixelated",
});
expect(container.firstChild).toHaveClass("max-w-md");
});
it("does not render registry credit strings in the UI", () => {
render(<Placeholder variant="empty" />);
expect(screen.queryByText(/Memos original ASCII art/i)).not.toBeInTheDocument();
expect(screen.queryByText(/jgs|Joan Stark/i)).not.toBeInTheDocument();
});
it('applies role="status" and aria-live="polite" ONLY when variant=loading', () => {
const { rerender, container } = render(<Placeholder variant="empty" />);
expect(container.querySelector('[role="status"]')).toBeNull();
rerender(<Placeholder variant="loading" />);
const live = container.querySelector('[role="status"]');
expect(live).not.toBeNull();
expect(live).toHaveAttribute("aria-live", "polite");
});
it("renders children below the message when provided", () => {
render(
<Placeholder variant="notFound">
<button type="button">Go home</button>
</Placeholder>,
);
expect(screen.getByRole("button", { name: "Go home" })).toBeInTheDocument();
});
it("merges a custom className onto the outer wrapper", () => {
const { container } = render(<Placeholder variant="empty" className="custom-test-class" />);
expect(container.firstChild).toHaveClass("custom-test-class");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { TILE_SPRITES, pickTileSprite } from "@/components/Placeholder/tileSprites";
import { DEFAULT_MESSAGES, type PlaceholderVariant } from "@/components/Placeholder/messages";
describe("TILE_SPRITES integrity", () => {
it("registers 32px by 32px sprite strips with animation-specific frame counts", () => {
expect(TILE_SPRITES.map((sprite) => sprite.name)).toEqual(["OwlBlink", "EagleIdle", "ToucanIdle"]);
expect(TILE_SPRITES.map((sprite) => [sprite.name, sprite.frames])).toEqual([
["OwlBlink", 5],
["EagleIdle", 4],
["ToucanIdle", 4],
]);
for (const sprite of TILE_SPRITES) {
expect(sprite.name).toMatch(/^[A-Z][A-Za-z]+(Idle|Hop|Blink|Drift|Flutter|Hover|Peck)$/);
expect(sprite.frameWidth).toBe(32);
expect(sprite.frameHeight).toBe(32);
expect(sprite.frames).toBeGreaterThanOrEqual(2);
expect(sprite.src).toMatch(/(\.svg|data:image\/svg\+xml)/);
}
});
it("returns a registered tile sprite from the pool", () => {
const sprite = pickTileSprite();
expect(TILE_SPRITES).toContain(sprite);
});
});
describe("DEFAULT_MESSAGES", () => {
it("provides a non-empty message for every variant", () => {
for (const variant of Object.keys(DEFAULT_MESSAGES) as PlaceholderVariant[]) {
expect(DEFAULT_MESSAGES[variant], `variant=${variant}`).toBeTruthy();
expect(DEFAULT_MESSAGES[variant].trim().length).toBeGreaterThan(0);
}
});
});
+118
View File
@@ -0,0 +1,118 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PreviewImageDialog from "@/components/PreviewImageDialog";
vi.mock("@/hooks/useMediaQuery", () => ({
__esModule: true,
default: () => false,
}));
describe("<PreviewImageDialog>", () => {
it("provides a dialog description without Radix accessibility warnings", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
render(
<PreviewImageDialog
open
onOpenChange={vi.fn()}
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
/>,
);
await waitFor(() => {
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Missing `Description`"));
});
});
it("keeps hook order stable when preview items appear after an empty render", () => {
const { rerender } = render(<PreviewImageDialog open onOpenChange={vi.fn()} items={[]} />);
expect(() => {
rerender(
<PreviewImageDialog
open
onOpenChange={vi.fn()}
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
/>,
);
}).not.toThrow();
expect(screen.getByAltText("Preview image 1 of 1")).toBeInTheDocument();
});
it("shows zoom controls for image previews", () => {
render(
<PreviewImageDialog
open
onOpenChange={vi.fn()}
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
/>,
);
expect(screen.getByRole("button", { name: /zoom in/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /zoom out/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reset zoom/i })).toBeInTheDocument();
expect(screen.getByText("100%")).toBeInTheDocument();
});
it("toggles image zoom on double click", () => {
render(
<PreviewImageDialog
open
onOpenChange={vi.fn()}
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
/>,
);
const image = screen.getByAltText("Preview image 1 of 1");
fireEvent.doubleClick(image);
expect(image).toHaveStyle({ transform: "translate3d(0px, 0px, 0) scale(2)" });
expect(screen.getByText("200%")).toBeInTheDocument();
});
it("zooms image previews with the wheel", () => {
render(
<PreviewImageDialog
open
onOpenChange={vi.fn()}
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
/>,
);
fireEvent.wheel(screen.getByTestId("preview-zoom-surface"), { deltaY: -100 });
expect(screen.getByText("120%")).toBeInTheDocument();
});
it("does not show zoom controls for video previews", () => {
render(
<PreviewImageDialog
open
onOpenChange={vi.fn()}
items={[{ id: "video-1", kind: "video", sourceUrl: "/video.mp4", posterUrl: "/poster.jpg", filename: "video.mp4" }]}
/>,
);
expect(screen.queryByRole("button", { name: /zoom in/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /zoom out/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /reset zoom/i })).not.toBeInTheDocument();
});
it("keeps previous and next controls available for mobile image galleries", () => {
render(
<PreviewImageDialog
open
onOpenChange={vi.fn()}
items={[
{ id: "image-1", kind: "image", sourceUrl: "/image-1.jpg", posterUrl: "/image-1.jpg", filename: "image-1.jpg" },
{ id: "image-2", kind: "image", sourceUrl: "/image-2.jpg", posterUrl: "/image-2.jpg", filename: "image-2.jpg" },
]}
/>,
);
expect(screen.getByRole("button", { name: /previous item/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /next item/i })).toBeInTheDocument();
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath, isPublicRoute } from "@/utils/redirect-safety";
describe("getSafeRedirectPath", () => {
it("accepts safe same-origin internal paths", () => {
expect(getSafeRedirectPath("/home")).toBe("/home");
expect(getSafeRedirectPath("/setting")).toBe("/setting");
expect(getSafeRedirectPath("/memos/abc")).toBe("/memos/abc");
expect(getSafeRedirectPath("/explore?foo=1")).toBe("/explore?foo=1");
expect(getSafeRedirectPath("/explore#anchor")).toBe("/explore#anchor");
});
it("rejects empty and non-string input", () => {
expect(getSafeRedirectPath(undefined)).toBeUndefined();
expect(getSafeRedirectPath(null)).toBeUndefined();
expect(getSafeRedirectPath("")).toBeUndefined();
});
it("rejects non-internal targets", () => {
expect(getSafeRedirectPath("//evil.example")).toBeUndefined();
expect(getSafeRedirectPath("https://evil.example")).toBeUndefined();
expect(getSafeRedirectPath("http://evil.example/home")).toBeUndefined();
expect(getSafeRedirectPath("javascript:alert(1)")).toBeUndefined();
expect(getSafeRedirectPath("home")).toBeUndefined();
});
it("rejects auth-family targets", () => {
expect(getSafeRedirectPath("/auth")).toBeUndefined();
expect(getSafeRedirectPath("/auth/callback")).toBeUndefined();
expect(getSafeRedirectPath("/auth/signup")).toBeUndefined();
expect(getSafeRedirectPath("/auth?code=abc")).toBeUndefined();
});
it("does not false-match auth-like paths", () => {
expect(getSafeRedirectPath("/authors")).toBe("/authors");
});
});
describe("buildAuthRoute", () => {
it("embeds only safe redirect targets", () => {
expect(buildAuthRoute({ redirect: "/home" })).toBe("/auth?redirect=%2Fhome");
expect(buildAuthRoute({ redirect: "//evil.example" })).toBe("/auth");
expect(buildAuthRoute({ redirect: "/auth/callback" })).toBe("/auth");
expect(buildAuthRoute({ redirect: null })).toBe("/auth");
});
it("preserves the reason parameter", () => {
expect(buildAuthRoute({ reason: "protected-memo" })).toBe("/auth?reason=protected-memo");
expect(buildAuthRoute({ redirect: "/memos/abc", reason: "protected-memo" })).toBe(
"/auth?redirect=%2Fmemos%2Fabc&reason=protected-memo",
);
});
it("exposes the canonical redirect query key", () => {
expect(AUTH_REDIRECT_PARAM).toBe("redirect");
});
});
describe("isPublicRoute", () => {
it("identifies anonymous-accessible page prefixes", () => {
expect(isPublicRoute("/auth")).toBe(true);
expect(isPublicRoute("/auth/signup")).toBe(true);
expect(isPublicRoute("/about")).toBe(true);
expect(isPublicRoute("/explore")).toBe(true);
expect(isPublicRoute("/memos/abc")).toBe(true);
expect(isPublicRoute("/memos/shares/abc")).toBe(true);
expect(isPublicRoute("/u/steven")).toBe(true);
});
it("treats authenticated-only pages as non-public", () => {
expect(isPublicRoute("/home")).toBe(false);
expect(isPublicRoute("/setting")).toBe(false);
expect(isPublicRoute("/inbox")).toBe(false);
expect(isPublicRoute("/attachments")).toBe(false);
expect(isPublicRoute("/archived")).toBe(false);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { isValidElement } from "react";
import type { RouteObject } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { routeConfig, ROUTES } from "@/router";
import { RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
// Walk the nested route config and find the first route with the given path,
// starting from the provided roots. Returns undefined if nothing matches.
function findByPath(routes: RouteObject[], path: string): RouteObject | undefined {
for (const route of routes) {
if (route.path === path) return route;
const hit = route.children ? findByPath(route.children, path) : undefined;
if (hit) return hit;
}
return undefined;
}
function elementType(route: RouteObject | undefined): unknown {
if (!route?.element || !isValidElement(route.element)) return undefined;
return route.element.type;
}
function hasAncestorOfType(routes: RouteObject[], path: string, guardType: unknown): boolean {
const walk = (subtree: RouteObject[], ancestorGuards: unknown[]): boolean => {
for (const route of subtree) {
const nextAncestors = [...ancestorGuards];
const type = elementType(route);
if (type) nextAncestors.push(type);
if (route.path === path) {
return nextAncestors.includes(guardType);
}
if (route.children && walk(route.children, nextAncestors)) {
return true;
}
}
return false;
};
return walk(routes, []);
}
describe("router configuration", () => {
it("keeps /auth/callback outside the guest-only guard", () => {
// Regression guard for issue #5846 follow-up: an authenticated tab elsewhere
// must not short-circuit the OAuth callback via RequireGuestRoute.
expect(hasAncestorOfType(routeConfig, "callback", RequireGuestRoute)).toBe(false);
});
it("wraps the remaining /auth children in RequireGuestRoute", () => {
for (const path of ["", "admin", "signup"]) {
expect(hasAncestorOfType(routeConfig, path, RequireGuestRoute)).toBe(true);
}
});
it("wraps authenticated-only pages in RequireAuthRoute", () => {
for (const path of [ROUTES.ARCHIVED, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(true);
}
});
it("leaves public pages outside RequireAuthRoute", () => {
for (const path of [ROUTES.ABOUT, ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) {
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(false);
}
});
it("exposes an accessible /auth/callback route definition", () => {
expect(findByPath(routeConfig, "callback")).toBeTruthy();
});
});
+42
View File
@@ -0,0 +1,42 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// With `globals: false`, @testing-library/react does not auto-register a
// cleanup hook, so unmount rendered trees between tests explicitly. This keeps
// `screen.getByTestId` from seeing DOM from prior tests in the same file.
afterEach(() => {
cleanup();
});
// Defensive shim: `@/auth-state` constructs a BroadcastChannel at module load
// to coordinate token refreshes across tabs. jsdom historically has not shipped
// BroadcastChannel, so any future test that transitively imports auth-state
// would otherwise throw. Current tests avoid that import path on purpose, but
// installing the shim keeps authoring new tests frictionless. No-op when jsdom
// already provides an implementation.
if (typeof globalThis.BroadcastChannel === "undefined") {
class NoopBroadcastChannel {
readonly name: string;
onmessage: ((event: MessageEvent) => void) | null = null;
constructor(name: string) {
this.name = name;
}
postMessage(_data: unknown): void {}
close(): void {}
addEventListener(): void {}
removeEventListener(): void {}
dispatchEvent(): boolean {
return true;
}
}
// @ts-expect-error — attach the shim to the global scope for tests.
globalThis.BroadcastChannel = NoopBroadcastChannel;
}
+47
View File
@@ -0,0 +1,47 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import VideoPoster from "@/components/VideoPoster";
describe("<VideoPoster>", () => {
it("renders a mobile-friendly video fallback before a frame is captured", () => {
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
const video = screen.getByTestId("video-poster-fallback");
expect(video).toHaveAttribute("preload", "auto");
expect(video).toHaveAttribute("playsinline");
expect((video as HTMLVideoElement).muted).toBe(true);
expect(video).toHaveClass("object-cover");
});
it("renders a captured frame as an image poster", async () => {
const drawImage = vi.fn();
const toDataURL = vi.fn(() => "data:image/jpeg;base64,poster");
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string, options?: ElementCreationOptions) => {
if (tagName === "canvas") {
return {
width: 0,
height: 0,
getContext: vi.fn(() => ({ drawImage })),
toDataURL,
} as unknown as HTMLCanvasElement;
}
return originalCreateElement(tagName, options);
});
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
const video = screen.getByTestId("video-poster-fallback") as HTMLVideoElement;
Object.defineProperty(video, "videoWidth", { configurable: true, value: 640 });
Object.defineProperty(video, "videoHeight", { configurable: true, value: 360 });
fireEvent.loadedData(video);
await waitFor(() => {
expect(screen.getByRole("img", { name: "clip.mp4" })).toHaveAttribute("src", "data:image/jpeg;base64,poster");
});
expect(drawImage).toHaveBeenCalledWith(video, 0, 0, 640, 360);
});
});