0.29.1原版
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("/");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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] [&>*: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] [&>*:last-child]:mb-0"> asdas');
|
||||
expect(html).not.toContain("[&_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] [&>*: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] [&>*:last-child]:mb-0"> Northern Lights in Iceland — booking this for winter, <em>finally</em></div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user