chore: import upstream snapshot with attribution
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:24 +08:00
commit 4cddfcf2f3
1040 changed files with 200957 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
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 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("does not add nested horizontal page padding on mobile", () => {
const { container } = render(<About />);
const contentWrapper = container.querySelector("section > div");
expect(contentWrapper).toHaveClass("w-full");
expect(contentWrapper).not.toHaveClass("px-4");
});
});
@@ -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();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { columnCountForWidth } from "@/components/ColumnGrid";
// Min column width 260px, gap 12px. A width fits N columns when
// floor((width + gap) / (minColumnWidth + gap)) === N, never below 1.
describe("columnCountForWidth", () => {
it("never returns fewer than one column, even at zero width", () => {
expect(columnCountForWidth(0)).toBe(1);
expect(columnCountForWidth(100)).toBe(1);
expect(columnCountForWidth(259)).toBe(1);
});
it("stays at one column just below the two-column threshold", () => {
// 2*260 + 12 = 532 is the first width that fits two columns.
expect(columnCountForWidth(531)).toBe(1);
});
it("reaches two columns exactly at the threshold (the list-fallback boundary)", () => {
expect(columnCountForWidth(532)).toBe(2);
});
it("scales up with width", () => {
expect(columnCountForWidth(804)).toBe(3);
expect(columnCountForWidth(1152)).toBe(4);
expect(columnCountForWidth(1600)).toBe(5);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { assignColumnsByEstimatedHeight } from "@/components/ColumnGrid";
describe("assignColumnsByEstimatedHeight", () => {
it("assigns each card to the shortest estimated column with deterministic ties", () => {
const columns = assignColumnsByEstimatedHeight({
keys: ["a", "b", "c", "d"],
columnCount: 2,
getEstimatedHeight: (key) => ({ a: 100, b: 80, c: 70, d: 60 })[key] ?? 0,
});
expect(Object.fromEntries(columns)).toEqual({
a: 0,
b: 1,
c: 1,
d: 0,
});
});
it("keeps pinned cards in the first column while balancing later cards", () => {
const columns = assignColumnsByEstimatedHeight({
keys: ["leading", "a", "priority", "b", "c"],
columnCount: 3,
getEstimatedHeight: (key) => ({ leading: 160, a: 90, priority: 80, b: 120, c: 70 })[key] ?? 0,
pinnedKeys: new Set(["leading", "priority"]),
});
expect(Object.fromEntries(columns)).toEqual({
leading: 0,
a: 1,
priority: 0,
b: 2,
c: 1,
});
});
});
+44
View File
@@ -0,0 +1,44 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import ColumnGrid from "@/components/ColumnGrid";
// jsdom has no layout engine (offsetHeight/clientWidth are 0) and no ResizeObserver,
// both of which ColumnGrid guards for — so these assert render structure, not positioning.
interface Item {
id: string;
}
const item = (id: string): Item => ({ id });
const getKey = (i: Item) => i.id;
describe("<ColumnGrid>", () => {
it("renders one card per item", () => {
const { container } = render(
<ColumnGrid items={[item("a"), item("b"), item("c")]} getKey={getKey} renderItem={(i) => <div data-testid="card">{i.id}</div>} />,
);
expect(container.querySelectorAll('[data-testid="card"]')).toHaveLength(3);
});
it("renders the leading node as the first tile, before the items", () => {
const { container, getByTestId } = render(
<ColumnGrid
items={[item("a")]}
getKey={getKey}
renderItem={(i) => <div data-testid={`card-${i.id}`}>{i.id}</div>}
leading={<div data-testid="composer" />}
/>,
);
expect(getByTestId("composer")).toBeInTheDocument();
// The grid is the container's only child; its first wrapper holds the leading node.
const grid = container.firstElementChild as HTMLElement;
expect(grid.children[0].querySelector('[data-testid="composer"]')).not.toBeNull();
});
it("renders nothing for an empty list", () => {
const { container } = render(<ColumnGrid items={[]} getKey={getKey} renderItem={() => <div data-testid="card" />} />);
expect(container.querySelectorAll('[data-testid="card"]')).toHaveLength(0);
});
});
@@ -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);
}
});
});
+32
View File
@@ -0,0 +1,32 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { createController } from "@/components/MemoEditor/Editor/controller";
function view(doc = "") {
return new EditorView({ state: EditorState.create({ doc }) });
}
describe("source editor controller", () => {
it("round-trips markdown verbatim, including previously-lossy inputs", () => {
const v = view();
const c = createController(v, {} as never);
for (const md of ["![a](x.png)", "Title\n===", "1. a\n 1. b\n 1. c", "a&nbsp;b"]) {
c.setMarkdown(md);
expect(c.getMarkdown()).toBe(md);
}
});
it("reports emptiness on whitespace", () => {
const c = createController(view(" \n "), {} as never);
expect(c.isEmpty()).toBe(true);
});
it("inserts markdown as its own block", () => {
const v = view("alpha");
const c = createController(v, {} as never);
v.dispatch({ selection: { anchor: 5 } });
c.insertMarkdown("beta");
expect(c.getMarkdown()).toBe("alpha\n\nbeta");
});
});
+16
View File
@@ -0,0 +1,16 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { tagMentionDecorations } from "@/components/MemoEditor/Editor/tagMentionDecorations";
function countClass(doc: string, cls: string): number {
const view = new EditorView({ state: EditorState.create({ doc, extensions: [tagMentionDecorations] }), parent: document.body });
const n = view.dom.querySelectorAll(`.${cls}`).length;
view.destroy();
return n;
}
describe("tag/mention decorations", () => {
it("decorates #tags", () => expect(countClass("a #todo and #work/sub b", "cm-memo-tag")).toBe(2));
it("decorates @mentions", () => expect(countClass("hi @alice", "cm-memo-mention")).toBe(1));
});
+56
View File
@@ -0,0 +1,56 @@
import { markdown } from "@codemirror/lang-markdown";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { createFormattingController } from "@/components/MemoEditor/Editor/formatting";
function setup(doc: string, from: number, to: number) {
const view = new EditorView({
state: EditorState.create({ doc, extensions: [markdown()], selection: EditorSelection.range(from, to) }),
});
return { view, f: createFormattingController(view, new Set()) };
}
describe("formatting controller", () => {
it("wraps selection in bold and reports active", () => {
const { view, f } = setup("hello world", 0, 5);
f.run("bold");
expect(view.state.doc.toString()).toBe("**hello** world");
view.dispatch({ selection: { anchor: 3 } });
expect(f.getActiveFormats().bold).toBe(true);
});
it("prefixes a heading and reports its level", () => {
const { view, f } = setup("Title", 0, 0);
f.run("heading1");
expect(view.state.doc.toString()).toBe("# Title");
view.dispatch({ selection: { anchor: 3 } });
expect(f.getActiveFormats().headingLevel).toBe(1);
});
it("toggles a bullet list line", () => {
const { view, f } = setup("item", 0, 0);
f.run("bulletList");
expect(view.state.doc.toString()).toBe("- item");
f.run("bulletList");
expect(view.state.doc.toString()).toBe("item");
});
it("unbolds when already bold", () => {
const { view, f } = setup("**hello** world", 4, 4); // cursor inside bold
f.run("bold");
expect(view.state.doc.toString()).toBe("hello world");
});
it("unwraps italic when already italic", () => {
const { view, f } = setup("*hi* there", 2, 2);
f.run("italic");
expect(view.state.doc.toString()).toBe("hi there");
});
it("unwraps inline code when already code", () => {
const { view, f } = setup("`code` here", 3, 3);
f.run("code");
expect(view.state.doc.toString()).toBe("code here");
});
});
@@ -0,0 +1,19 @@
import { markdown } from "@codemirror/lang-markdown";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { headingDecorations } from "@/components/MemoEditor/Editor/headingDecorations";
function hasClass(doc: string, cls: string): boolean {
const view = new EditorView({ state: EditorState.create({ doc, extensions: [markdown(), headingDecorations] }), parent: document.body });
const found = view.dom.querySelector(`.${cls}`) !== null;
view.destroy();
return found;
}
describe("heading line decorations require a space", () => {
it("styles '# Title' as h1", () => expect(hasClass("# Title", "cm-md-h1")).toBe(true));
it("styles '### x' as h3", () => expect(hasClass("### x", "cm-md-h3")).toBe(true));
it("does NOT style a bare '#'", () => expect(hasClass("#", "cm-md-h1")).toBe(false));
it("does NOT style '#tag' (no space)", () => expect(hasClass("#tag", "cm-md-h1")).toBe(false));
});
+125
View File
@@ -0,0 +1,125 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { MemoMarkdownRenderer } from "@/components/MemoContent/MemoMarkdownRenderer";
import { buildEditorExtensions } from "@/components/MemoEditor/Editor/extensions";
function makeView(doc: string, onSubmit: () => void = () => {}) {
return new EditorView({
state: EditorState.create({
doc,
extensions: buildEditorExtensions({
placeholder: "",
onChange: () => {},
onFiles: () => {},
onUpdate: () => {},
onSubmit,
getTags: () => [],
}),
}),
parent: document.body,
});
}
function press(view: EditorView, key: string, opts: KeyboardEventInit = {}) {
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...opts }));
}
/** Place the cursor on the given 1-based line, then press Tab/Shift-Tab. */
function tabOnLine(view: EditorView, lineNumber: number, shiftKey = false) {
const line = view.state.doc.line(lineNumber);
view.dispatch({ selection: { anchor: line.to } });
press(view, "Tab", { shiftKey });
}
describe("editor key bindings", () => {
it("Cmd+Enter submits without inserting a blank line", () => {
let submitted = 0;
const view = makeView("hello", () => {
submitted += 1;
});
view.dispatch({ selection: { anchor: 5 } });
press(view, "Enter", { metaKey: true });
// The save shortcut must outrank defaultKeymap's Mod-Enter (insertBlankLine).
expect(submitted).toBe(1);
expect(view.state.doc.toString()).toBe("hello");
view.destroy();
});
it("Ctrl+Enter also submits without editing the document", () => {
let submitted = 0;
const view = makeView("hello", () => {
submitted += 1;
});
press(view, "Enter", { ctrlKey: true });
expect(submitted).toBe(1);
expect(view.state.doc.toString()).toBe("hello");
view.destroy();
});
it("plain Enter still inserts a newline", () => {
const view = makeView("hello");
view.dispatch({ selection: { anchor: 5 } });
press(view, "Enter");
expect(view.state.doc.toString()).toBe("hello\n");
view.destroy();
});
it("Tab indents a non-list line by two spaces", () => {
const view = makeView("hello");
view.dispatch({ selection: { anchor: 0 } });
press(view, "Tab");
expect(view.state.doc.toString()).toBe(" hello");
view.destroy();
});
it("Escape blurs the editor (keyboard escape hatch)", () => {
const view = makeView("x");
view.focus();
expect(view.hasFocus).toBe(true);
press(view, "Escape");
expect(view.hasFocus).toBe(false);
view.destroy();
});
it("Tab nests an ordered item: marker-aligned indent + renumbered to 1", () => {
const view = makeView("1. a\n2. b");
tabOnLine(view, 2);
// 3-space indent (past "1. ") AND renumbered 2 -> 1 so CommonMark nests it.
expect(view.state.doc.toString()).toBe("1. a\n 1. b");
view.destroy();
});
it("Tab nests a bullet item to two spaces", () => {
const view = makeView("- a\n- b");
tabOnLine(view, 2);
expect(view.state.doc.toString()).toBe("- a\n - b");
view.destroy();
});
it("Shift-Tab outdents a nested list item back to its parent level", () => {
const view = makeView("1. a\n 2. b");
tabOnLine(view, 2, true);
expect(view.state.doc.toString()).toBe("1. a\n2. b");
view.destroy();
});
it("Tab-nested ordered lists render nested in the display (no flat siblings)", () => {
const view = makeView("1. a\n2. b\n3. c");
tabOnLine(view, 2); // nest b under a
tabOnLine(view, 3); // nest c under a ...
tabOnLine(view, 3); // ... then under b
const md = view.state.doc.toString();
view.destroy();
// Each nested level renumbers to 1, which is what CommonMark requires to
// nest an ordered sublist (a 2./3. start can't interrupt the parent line).
expect(md).toBe("1. a\n 1. b\n 1. c");
// remark-gfm (the display) now reads this as three nested ordered lists.
const html = renderToStaticMarkup(
React.createElement(MemoMarkdownRenderer, { content: md, resolvedMentionUsernames: new Set<string>() }),
);
expect((html.match(/<ol/g) ?? []).length).toBe(3);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { CompletionContext } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";
import { describe, expect, it } from "vitest";
import { makeTagCompletionSource } from "@/components/MemoEditor/Editor/tagAutocomplete";
function complete(doc: string, pos: number, tags: string[]) {
const source = makeTagCompletionSource(() => tags);
const state = EditorState.create({ doc });
return source(new CompletionContext(state, pos, false));
}
describe("tag autocomplete", () => {
it("offers known tags after #", () => {
const result = complete("hello #to", 9, ["todo", "today", "work"]);
expect(result?.options.map((o) => o.label)).toEqual(["todo", "today"]);
});
it("returns null on a bare # with nothing typed", () => {
expect(complete("hello #", 7, ["todo"])).toBeNull();
});
it("returns null when not in a tag", () => {
expect(complete("hello world", 11, ["todo"])).toBeNull();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { render } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import Editor from "@/components/MemoEditor/Editor";
import type { EditorController } from "@/components/MemoEditor/types/editorController";
vi.mock("@/hooks/useUserQueries", () => ({
useTagCounts: () => ({ data: {} }),
}));
describe("Editor", () => {
it("loads markdown and serializes it back verbatim", () => {
const ref = createRef<EditorController>();
render(
<Editor
ref={ref}
className="x"
initialContent={"# Title\n\n- a\n 1. b"}
placeholder="memo"
onContentChange={vi.fn()}
onFiles={vi.fn()}
onSubmit={vi.fn()}
/>,
);
expect(ref.current?.getMarkdown()).toBe("# Title\n\n- a\n 1. b");
});
it("emits changes through onContentChange", () => {
const ref = createRef<EditorController>();
const onChange = vi.fn();
render(
<Editor
ref={ref}
className="x"
initialContent=""
placeholder="memo"
onContentChange={onChange}
onFiles={vi.fn()}
onSubmit={vi.fn()}
/>,
);
ref.current?.setMarkdown("hello");
expect(onChange).toHaveBeenCalledWith("hello");
});
it("reconfigures the placeholder when its translation changes", () => {
const props = {
className: "x",
initialContent: "",
onContentChange: vi.fn(),
onFiles: vi.fn(),
onSubmit: vi.fn(),
};
const { container, rerender } = render(<Editor {...props} placeholder="Any thoughts?" />);
expect(container.querySelector(".cm-content")).toHaveAttribute("aria-placeholder", "Any thoughts?");
expect(container.querySelector(".cm-placeholder")).toHaveTextContent("Any thoughts?");
rerender(<Editor {...props} placeholder="有什么想法?" />);
expect(container.querySelector(".cm-content")).toHaveAttribute("aria-placeholder", "有什么想法?");
expect(container.querySelector(".cm-placeholder")).toHaveTextContent("有什么想法?");
});
});
+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();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createRef } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { type ActiveFormatState, EMPTY_ACTIVE_FORMATS } from "@/components/MemoEditor/formatting/commands";
import { FormattingToolbar } from "@/components/MemoEditor/Toolbar/FormattingToolbar";
import type { EditorController } from "@/components/MemoEditor/types/editorController";
// Match the repo convention: t echoes the i18n key (no i18next backend in tests),
// so accessible names below are the keys themselves.
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
// Radix DropdownMenu reaches for layout/pointer APIs jsdom doesn't implement.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
Element.prototype.hasPointerCapture = vi.fn(() => false);
Element.prototype.setPointerCapture = vi.fn();
Element.prototype.releasePointerCapture = vi.fn();
});
function makeController(opts: { active?: Partial<ActiveFormatState>; getSelectedText?: () => string } = {}) {
const run = vi.fn();
const activeFormats: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS, ...opts.active };
const controller: EditorController = {
focus: () => {},
hasFocus: () => false,
isEmpty: () => true,
getMarkdown: () => "",
setMarkdown: () => {},
insertMarkdown: vi.fn(),
scrollToCursor: () => {},
selectAll: () => {},
formatting: {
run,
getActiveFormats: () => activeFormats,
getSelectedText: opts.getSelectedText ?? (() => ""),
subscribe: () => () => {},
},
};
return { controller, run };
}
function renderToolbar(controller: EditorController, onExit = vi.fn()) {
const ref = createRef<EditorController>();
ref.current = controller;
render(<FormattingToolbar controllerRef={ref} onExit={onExit} />);
return { onExit };
}
describe("FormattingToolbar", () => {
it("runs the bold command when the bold button is clicked", () => {
const { controller, run } = makeController();
renderToolbar(controller);
fireEvent.click(screen.getByRole("button", { name: "editor.format.bold" }));
expect(run).toHaveBeenCalledWith("bold");
});
it("runs the heading command when a heading level is chosen", () => {
const { controller, run } = makeController();
renderToolbar(controller);
// Keyboard open is the most reliable path for Radix menus in jsdom.
fireEvent.keyDown(screen.getByRole("button", { name: "editor.format.heading" }), { key: "Enter" });
fireEvent.click(screen.getByRole("menuitem", { name: "editor.format.heading-2" }));
expect(run).toHaveBeenCalledWith("heading2");
});
it("reflects active marks via aria-pressed", () => {
const { controller } = makeController({ active: { bold: true } });
renderToolbar(controller);
expect(screen.getByRole("button", { name: "editor.format.bold" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "editor.format.italic" })).toHaveAttribute("aria-pressed", "false");
});
it("calls onExit when the exit button is clicked", () => {
const { controller } = makeController();
const { onExit } = renderToolbar(controller);
fireEvent.click(screen.getByRole("button", { name: "editor.exit-focus-mode" }));
expect(onExit).toHaveBeenCalledTimes(1);
});
});
+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("/");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { hoistMemoToFront } from "@/hooks/useMemoSorting";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
const memo = (name: string) => ({ name }) as Memo;
describe("hoistMemoToFront", () => {
it("moves the named memo to the front, above everything else", () => {
const list = [memo("memos/pin1"), memo("memos/pin2"), memo("memos/new")];
expect(hoistMemoToFront(list, "memos/new").map((m) => m.name)).toEqual(["memos/new", "memos/pin1", "memos/pin2"]);
});
it("returns the list unchanged when the name is null", () => {
const list = [memo("memos/a"), memo("memos/b")];
expect(hoistMemoToFront(list, null)).toBe(list);
});
it("returns the list unchanged when the name is absent", () => {
const list = [memo("memos/a"), memo("memos/b")];
expect(hoistMemoToFront(list, "memos/missing")).toBe(list);
});
it("returns the list unchanged when the memo is already first", () => {
const list = [memo("memos/a"), memo("memos/b")];
expect(hoistMemoToFront(list, "memos/a")).toBe(list);
});
it("does not mutate the input list", () => {
const list = [memo("memos/a"), memo("memos/new")];
hoistMemoToFront(list, "memos/new");
expect(list.map((m) => m.name)).toEqual(["memos/a", "memos/new"]);
});
});
+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([]);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { getLocaleSearchLabels, localeMatchesSearch, normalizeLocaleSearchText } from "@/utils/i18n";
describe("locale search helpers", () => {
it("normalizes case and diacritics for locale search", () => {
expect(normalizeLocaleSearchText("Português")).toBe("portugues");
});
it("includes locale code, native name, and English name", () => {
const labels = getLocaleSearchLabels("ja", "en");
expect(labels).toContain("ja");
expect(labels).toContain("日本語");
expect(labels).toContain("Japanese");
});
it("matches by code, native display name, English display name, and accent-free text", () => {
expect(localeMatchesSearch("pt-PT", "pt", "en")).toBe(true);
expect(localeMatchesSearch("ja", "日本", "en")).toBe(true);
expect(localeMatchesSearch("de", "german", "en")).toBe(true);
expect(localeMatchesSearch("pt-PT", "portugues", "en")).toBe(true);
expect(localeMatchesSearch("ja", "romanian", "en")).toBe(false);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LocaleSearchList } from "@/components/LocalePicker";
describe("<LocaleSearchList>", () => {
it("links to GitHub feedback when no locale matches the search", async () => {
render(<LocaleSearchList value="en" onChange={() => {}} />);
fireEvent.change(await screen.findByRole("textbox", { name: "Language" }), { target: { value: "klingon" } });
const feedbackLink = screen.getByRole("link", { name: "Language not found? Submit feedback" });
expect(feedbackLink).toHaveAttribute("href", expect.stringContaining("https://github.com/usememos/memos/issues/new"));
});
});
+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"),
);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CLAMP_PREVIEW_HEIGHT_PX, CLAMP_TRIGGER_HEIGHT_PX } from "@/components/ClampedSection";
import MemoBody from "@/components/MemoView/components/MemoBody";
const mockState = vi.hoisted(() => ({
memo: {
name: "memos/1",
content: "",
relations: [],
attachments: [],
reactions: [],
},
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
vi.mock("@/components/MemoContent/MentionResolutionContext", () => ({
useResolvedMentionUsernames: () => new Set<string>(),
}));
vi.mock("@/components/MemoContent/MemoMarkdownRenderer", () => ({
MemoMarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div>,
}));
vi.mock("@/components/MemoMetadata", () => ({
AttachmentListView: () => null,
LocationDisplayView: () => null,
RelationListView: () => null,
}));
vi.mock("@/components/MemoReactionListView", () => ({
MemoReactionListView: () => null,
}));
vi.mock("@/components/MemoView/hooks", () => ({
useMemoHandlers: () => ({
handleMemoContentClick: vi.fn(),
handleMemoContentDoubleClick: vi.fn(),
}),
}));
vi.mock("@/components/MemoView/MemoViewContext", () => ({
useMemoViewContext: () => ({
memo: mockState.memo,
parentPage: "",
showBlurredContent: false,
blurred: false,
readonly: false,
openEditor: vi.fn(),
openPreview: vi.fn(),
toggleBlurVisibility: vi.fn(),
}),
}));
const createMemo = (content: string) => ({
name: "memos/1",
content,
relations: [],
attachments: [],
reactions: [],
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("<MemoBody /> compact body clamp", () => {
it("keeps a buffer between the preview height and the fold trigger", () => {
expect(CLAMP_TRIGGER_HEIGHT_PX).toBeGreaterThan(CLAMP_PREVIEW_HEIGHT_PX);
});
it("folds tall compact bodies and keeps them expanded across content changes", async () => {
// jsdom has no layout; report every element as taller than the fold trigger.
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(CLAMP_TRIGGER_HEIGHT_PX + 100);
mockState.memo = createMemo("line 1\nline 2");
const { rerender } = render(<MemoBody compact={true} />);
await waitFor(() => expect(screen.getByRole("button", { name: /memo\.show-more/ })).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /memo\.show-more/ }));
expect(screen.getByRole("button", { name: /memo\.show-less/ })).toBeInTheDocument();
mockState.memo = createMemo("line 1\nline 2 updated");
rerender(<MemoBody compact={true} />);
expect(screen.getByRole("button", { name: /memo\.show-less/ })).toBeInTheDocument();
});
it("renders no clamp when compact is off", () => {
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(CLAMP_TRIGGER_HEIGHT_PX + 100);
mockState.memo = createMemo("tall content");
render(<MemoBody compact={false} />);
expect(screen.queryByRole("button", { name: /memo\.show-more/ })).toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { create } from "@bufbuild/protobuf";
import { describe, expect, it } from "vitest";
import { estimateMemoCardHeight } from "@/components/PagedMemoList/memoCardHeight";
import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoRelation_MemoSchema, MemoRelation_Type, MemoRelationSchema, MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
const buildAttachment = (overrides: Partial<Attachment>) =>
create(AttachmentSchema, {
name: "attachments/test",
filename: "test.bin",
type: "application/octet-stream",
...overrides,
});
const buildCommentRelation = (memoName: string, index: number) =>
create(MemoRelationSchema, {
type: MemoRelation_Type.COMMENT,
memo: create(MemoRelation_MemoSchema, { name: `memos/comment-${index}` }),
relatedMemo: create(MemoRelation_MemoSchema, { name: memoName }),
});
const buildMemo = (overrides: Partial<Memo> = {}) =>
create(MemoSchema, {
name: "memos/main",
content: "hello",
attachments: [],
relations: [],
...overrides,
});
describe("estimateMemoCardHeight", () => {
it("accounts for visual attachments before images have loaded", () => {
const plain = buildMemo();
const withImage = buildMemo({
attachments: [
buildAttachment({
name: "attachments/image",
filename: "image.png",
type: "image/png",
}),
],
});
expect(estimateMemoCardHeight(withImage, { columnWidth: 320 })).toBeGreaterThan(
estimateMemoCardHeight(plain, { columnWidth: 320 }) + 100,
);
});
it("estimates the visible comment preview height and caps it at three comments", () => {
const oneComment = buildMemo({ relations: [buildCommentRelation("memos/main", 1)] });
const threeComments = buildMemo({
relations: [1, 2, 3].map((index) => buildCommentRelation("memos/main", index)),
});
const fiveComments = buildMemo({
relations: [1, 2, 3, 4, 5].map((index) => buildCommentRelation("memos/main", index)),
});
expect(estimateMemoCardHeight(threeComments, { columnWidth: 320 })).toBeGreaterThan(
estimateMemoCardHeight(oneComment, { columnWidth: 320 }),
);
expect(estimateMemoCardHeight(fiveComments, { columnWidth: 320 })).toBe(estimateMemoCardHeight(threeComments, { columnWidth: 320 }));
});
});
@@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import type { Element } from "hast";
import { describe, expect, it, vi } from "vitest";
import { Paragraph } from "@/components/MemoContent/markdown/Paragraph";
const viewState = vi.hoisted(() => ({ linkPreview: true }));
// Paragraph reads the global preference via this hook.
vi.mock("@/contexts/ViewContext", () => ({
useLinkPreviewEnabled: () => viewState.linkPreview,
}));
// The link-preview card fetches metadata via this hook; stub it so the card would
// render whenever it is allowed to.
vi.mock("@/hooks/useMemoQueries", () => ({
useLinkMetadata: () => ({
data: { url: "https://example.com", title: "Example Title", description: "An example site", image: "" },
isSuccess: true,
}),
}));
// A single bare-link paragraph node, the shape that triggers a preview card.
const singleLinkNode = {
type: "element",
tagName: "p",
properties: {},
children: [
{
type: "element",
tagName: "a",
properties: { href: "https://example.com" },
children: [{ type: "text", value: "https://example.com" }],
},
],
} as unknown as Element;
const renderParagraph = () =>
render(
<Paragraph node={singleLinkNode}>
<a href="https://example.com">https://example.com</a>
</Paragraph>,
);
describe("<Paragraph /> link preview gating", () => {
it("renders the link preview card when the setting is enabled (default)", () => {
viewState.linkPreview = true;
renderParagraph();
expect(screen.getByText("Example Title")).toBeInTheDocument();
});
it("renders the plain link without a card when the setting is disabled", () => {
viewState.linkPreview = false;
renderParagraph();
expect(screen.queryByText("Example Title")).not.toBeInTheDocument();
expect(screen.getByText("https://example.com")).toBeInTheDocument();
});
});
+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);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildEditorExtensions } from "@/components/MemoEditor/Editor/extensions";
describe("MemoEditor CodeMirror extensions", () => {
const views: EditorView[] = [];
afterEach(() => {
for (const view of views.splice(0)) {
view.destroy();
}
document.body.replaceChildren();
});
it("uses CodeMirror's selection and placeholder extensions without enabling multi-cursor selection", () => {
const parent = document.body.appendChild(document.createElement("div"));
const state = EditorState.create({
doc: "",
extensions: buildEditorExtensions({
placeholder: "Any thoughts...",
onChange: vi.fn(),
onFiles: vi.fn(),
onUpdate: vi.fn(),
onSubmit: vi.fn(),
getTags: () => [],
}),
});
const view = new EditorView({ state, parent });
views.push(view);
expect(view.state.facet(EditorState.allowMultipleSelections)).toBe(false);
expect(view.dom.querySelector(".cm-selectionLayer")).not.toBeNull();
expect(view.dom.querySelector(".cm-cursorLayer")).not.toBeNull();
expect(view.contentDOM).toHaveAttribute("aria-placeholder", "Any thoughts...");
expect(view.dom.querySelector(".cm-placeholder")).toHaveTextContent("Any thoughts...");
});
it.each([
["paste", "clipboardData"],
["drop", "dataTransfer"],
] as const)("intercepts files on %s before CodeMirror inserts their contents", (eventType, transferProperty) => {
const onFiles = vi.fn();
const parent = document.body.appendChild(document.createElement("div"));
const view = new EditorView({
state: EditorState.create({
doc: "memo",
extensions: buildEditorExtensions({
placeholder: "",
onChange: vi.fn(),
onFiles,
onUpdate: vi.fn(),
onSubmit: vi.fn(),
getTags: () => [],
}),
}),
parent,
});
views.push(view);
const file = new File(["must not become memo content"], "attachment.txt", { type: "text/plain" });
const transfer =
eventType === "paste"
? { items: [{ kind: "file", getAsFile: () => file }], files: [file] }
: { files: [file], types: ["Files"] };
const event = new Event(eventType, { bubbles: true, cancelable: true });
Object.defineProperty(event, transferProperty, { value: transfer });
view.contentDOM.dispatchEvent(event);
expect(onFiles).toHaveBeenCalledWith([file]);
expect(event.defaultPrevented).toBe(true);
expect(view.state.doc.toString()).toBe("memo");
});
});
+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,
});
});
});
+130
View File
@@ -0,0 +1,130 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import PagedMemoList from "@/components/PagedMemoList";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
const view = vi.hoisted(() => ({ maxColumns: 1 as 0 | 1 | 2 | 3, compactMode: false }));
const feed = vi.hoisted(() => ({ memos: [] as unknown[] }));
vi.mock("@/hooks/useMemoQueries", () => ({
useInfiniteMemos: () => ({
data: { pages: [{ memos: feed.memos, nextPageToken: "" }] },
fetchNextPage: vi.fn(async () => undefined),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
useMemoFilterContext: () => ({ filters: [] }),
}));
vi.mock("@/contexts/ViewContext", () => ({
useView: () => view,
}));
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" />,
}));
const memo = { name: "memos/1", content: "hello", updateTime: undefined } as unknown as Memo;
const renderList = (
renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () => <div />,
options: { showMemoEditor?: boolean } = {},
) =>
render(
<QueryClientProvider client={new QueryClient()}>
<PagedMemoList renderer={renderer} showMemoEditor={options.showMemoEditor} />
</QueryClientProvider>,
);
describe("<PagedMemoList>", () => {
beforeEach(() => {
view.maxColumns = 1;
view.compactMode = false;
feed.memos = [];
});
it("uses the tile sprite Placeholder for the empty state", () => {
renderList();
expect(screen.getByText("No data found.")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
it("shows the empty state below the memo editor", () => {
renderList(undefined, { showMemoEditor: true });
expect(screen.getByTestId("memo-editor")).toBeInTheDocument();
expect(screen.getByText("No data found.")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
it("places the empty state in the first grid column", () => {
view.maxColumns = 0;
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
try {
renderList(undefined, { showMemoEditor: true });
const leadingTile = screen.getByText("No data found.").closest(".absolute");
expect(leadingTile).not.toBeNull();
expect(leadingTile).toContainElement(screen.getByTestId("memo-editor"));
} finally {
widthSpy.mockRestore();
}
});
describe("compact policy", () => {
beforeEach(() => {
feed.memos = [memo];
});
it("threads compact=false at one column with compact mode off", () => {
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
renderList(renderer);
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: false });
});
it("threads compact=true at one column with compact mode on", () => {
view.compactMode = true;
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
renderList(renderer);
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true });
});
it("respects the compact setting in the narrow-width fallback even when columns are allowed", () => {
// jsdom measures 0px, so the flow fallback renders and behaves exactly like maxColumns = 1.
view.maxColumns = 0;
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
renderList(renderer);
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: false });
});
it("forces compact once the width fits the grid", () => {
view.maxColumns = 0;
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
try {
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
renderList(renderer);
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true });
} finally {
widthSpy.mockRestore();
}
});
});
});
+30
View File
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PasswordSignInForm from "@/components/PasswordSignInForm";
vi.mock("@/auth-state", () => ({ setAccessToken: vi.fn() }));
vi.mock("@/connect", () => ({
authServiceClient: { signIn: vi.fn() },
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ initialize: vi.fn() }),
}));
vi.mock("@/hooks/useNavigateTo", () => ({
default: () => vi.fn(),
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
describe("<PasswordSignInForm>", () => {
it("does not prefill seeded demo credentials", () => {
render(<PasswordSignInForm />);
expect(screen.getByPlaceholderText("common.username")).toHaveValue("");
expect(screen.getByPlaceholderText("common.password")).toHaveValue("");
});
});
+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();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
AUTH_REDIRECT_PARAM,
buildAuthRoute,
getSafeRedirectPath,
isPublicRoute,
shouldGatePrivateInstance,
} 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);
});
});
describe("shouldGatePrivateInstance", () => {
it("never gates on an open (public) instance", () => {
expect(shouldGatePrivateInstance({ isPrivateInstance: false, isAuthenticated: false, pathname: "/" })).toBe(false);
expect(shouldGatePrivateInstance({ isPrivateInstance: false, isAuthenticated: false, pathname: "/explore" })).toBe(false);
});
it("never gates an authenticated visitor", () => {
expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: true, pathname: "/explore" })).toBe(false);
});
it("gates anonymous visitors to non-share pages on a private instance", () => {
for (const pathname of ["/", "/explore", "/about", "/memos/abc", "/u/steven", "/setting"]) {
expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: false, pathname })).toBe(true);
}
});
it("keeps share links reachable for anonymous visitors on a private instance", () => {
expect(shouldGatePrivateInstance({ isPrivateInstance: true, isAuthenticated: false, pathname: "/memos/shares/token123" })).toBe(false);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { describe, expect, it } from "vitest";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
const renderMarkdown = (content: string): string =>
renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkGfm, remarkTag]}>
{content}
</ReactMarkdown>,
);
describe("remarkTag", () => {
it("does not turn URL fragments inside autolinks into tags", () => {
const html = renderMarkdown("https://github.com/dmtrKovalenko/fff#pi-agent-extension\n\nProject #memo-tag");
expect(html).toContain('href="https://github.com/dmtrKovalenko/fff#pi-agent-extension"');
expect(html).not.toContain('data-tag="pi-agent-extension"');
expect(html).toContain('data-tag="memo-tag"');
});
it("does not turn link text or reference link fragments into tags", () => {
const html = renderMarkdown(
[
"[release #notes](https://example.com/releases#release-notes)",
"[**section #heading**](https://example.com/docs#section-heading)",
"![preview #image](https://example.com/image#preview)",
"[reference #anchor][docs]",
"",
"[docs]: https://example.com/docs#reference-anchor",
"",
"Outside #memo-tag",
].join("\n"),
);
expect(html).not.toContain('data-tag="notes"');
expect(html).not.toContain('data-tag="heading"');
expect(html).not.toContain('data-tag="image"');
expect(html).not.toContain('data-tag="anchor"');
expect(html).not.toContain('data-tag="release-notes"');
expect(html).not.toContain('data-tag="section-heading"');
expect(html).not.toContain('data-tag="preview"');
expect(html).not.toContain('data-tag="reference-anchor"');
expect(html).toContain('data-tag="memo-tag"');
});
it("continues to turn formatted text outside links into tags", () => {
const html = renderMarkdown("**#urgent** and _#later_");
expect(html).toContain('data-tag="urgent"');
expect(html).toContain('data-tag="later"');
});
it("does not turn a backslash-escaped \\#tag into a tag, but still tags an unescaped one", () => {
const html = renderMarkdown("\\#NAS is my server and a #real tag");
// Escaped: rendered as the literal text "#NAS", never a tag pill.
expect(html).not.toContain('data-tag="NAS"');
expect(html).toContain("#NAS");
// Unescaped neighbour is unaffected.
expect(html).toContain('data-tag="real"');
});
it("escapes only the marked hash when escaped and unescaped tags share a node", () => {
const html = renderMarkdown("\\#first then #second");
expect(html).not.toContain('data-tag="first"');
expect(html).toContain("#first");
expect(html).toContain('data-tag="second"');
});
it("tags a whole word containing combining marks", () => {
// Malayalam കവിത = ka, va, vowel-sign-i (U+0D3F, a spacing combining mark),
// ta. The vowel sign is a \p{M} character, so the tag must not stop at കവ.
const html = renderMarkdown("#കവിത");
expect(html).toContain('data-tag="കവിത"');
expect(html).not.toContain('data-tag="കവ"');
});
it("still tags a hash that shares a text node with an entity reference", () => {
// The source slice ("...&amp;...") differs from the decoded value, so the
// escape-aware path bows out and the tag is detected the original way.
const html = renderMarkdown("Tom &amp; Jerry #cartoon");
expect(html).toContain('data-tag="cartoon"');
expect(html).toContain("Tom &amp; Jerry");
});
});
+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();
});
});
+83
View File
@@ -0,0 +1,83 @@
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();
});
// ProseMirror probes layout APIs jsdom doesn't implement.
if (typeof document !== "undefined") {
if (!document.elementFromPoint) {
document.elementFromPoint = () => null;
}
if (typeof Range !== "undefined" && !Range.prototype.getClientRects) {
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () =>
({ x: 0, y: 0, top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0, toJSON: () => ({}) }) as DOMRect;
}
// CodeMirror probes additional layout APIs jsdom doesn't implement while measuring.
if (typeof Element !== "undefined" && !Element.prototype.getClientRects) {
Element.prototype.getClientRects = () => [] as unknown as DOMRectList;
}
}
// jsdom runs with an opaque origin (no URL set), so its built-in localStorage
// implementation throws SecurityError on every access. Install a Map-backed shim
// so any test that touches localStorage directly can call getItem/setItem/removeItem/clear
// without additional per-file setup. Tests that need special behavior (hooks.test.tsx,
// memo-editor-cache.test.ts) override this via Object.defineProperty / vi.stubGlobal.
if (typeof globalThis.localStorage === "undefined" || typeof globalThis.localStorage.clear !== "function") {
let _store = new Map<string, string>();
const localStorageShim: Storage = {
get length() {
return _store.size;
},
getItem: (key: string) => _store.get(key) ?? null,
setItem: (key: string, value: string) => _store.set(key, value),
removeItem: (key: string) => _store.delete(key),
clear: () => {
_store = new Map<string, string>();
},
key: (index: number) => Array.from(_store.keys())[index] ?? null,
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: localStorageShim,
});
}
// 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;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { COMPACT_TOOLBAR_WIDTH, isCompactWidth } from "@/components/MemoEditor/hooks/useElementWidth";
describe("isCompactWidth", () => {
it("treats an unmeasured (0) width as not compact (full layout default)", () => {
expect(isCompactWidth(0)).toBe(false);
});
it("is compact below the threshold", () => {
expect(isCompactWidth(COMPACT_TOOLBAR_WIDTH - 1)).toBe(true);
});
it("is not compact at or above the threshold", () => {
expect(isCompactWidth(COMPACT_TOOLBAR_WIDTH)).toBe(false);
expect(isCompactWidth(COMPACT_TOOLBAR_WIDTH + 200)).toBe(false);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoSave } from "@/components/MemoEditor/hooks/useAutoSave";
import { cacheService } from "@/components/MemoEditor/services/cacheService";
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
// Probe surfaces the store's dispatch/actions plus the autosave API so tests can
// drive content changes the way the editor does and assert on cache writes.
let api: {
dispatch: ReturnType<typeof useEditorContext>["dispatch"];
actions: ReturnType<typeof useEditorContext>["actions"];
discardDraft: () => void;
};
function Probe({ username, cacheKey, enabled }: { username: string; cacheKey?: string; enabled?: boolean }) {
const { dispatch, actions } = useEditorContext();
const { discardDraft } = useAutoSave(username, cacheKey, enabled);
api = { dispatch, actions, discardDraft };
return null;
}
describe("useAutoSave (store-subscribed)", () => {
let saveSpy: ReturnType<typeof vi.spyOn>;
let saveNowSpy: ReturnType<typeof vi.spyOn>;
let clearSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
saveSpy = vi.spyOn(cacheService, "save").mockImplementation(() => {});
saveNowSpy = vi.spyOn(cacheService, "saveNow").mockImplementation(() => {});
clearSpy = vi.spyOn(cacheService, "clear").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("persists content to the draft cache when content changes", () => {
render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k" enabled />
</EditorProvider>,
);
const key = cacheService.key("users/steven", "k");
saveSpy.mockClear(); // ignore the mount-time persist of the initial empty content
act(() => {
api.dispatch(api.actions.updateContent("hello world"));
});
expect(saveSpy).toHaveBeenCalledWith(key, "hello world");
});
it("does not persist when disabled", () => {
render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k" enabled={false} />
</EditorProvider>,
);
saveSpy.mockClear();
act(() => {
api.dispatch(api.actions.updateContent("ignored"));
});
expect(saveSpy).not.toHaveBeenCalled();
});
it("discardDraft clears the cache and suppresses the unmount flush", () => {
const { unmount } = render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k2" enabled />
</EditorProvider>,
);
const key = cacheService.key("users/steven", "k2");
act(() => {
api.dispatch(api.actions.updateContent("draft body"));
});
act(() => {
api.discardDraft();
});
expect(clearSpy).toHaveBeenCalledWith(key);
saveNowSpy.mockClear();
unmount();
// The just-discarded content equals the latest content, so the unmount
// flush must NOT re-persist it as a stale draft.
expect(saveNowSpy).not.toHaveBeenCalled();
});
});
+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);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { useView, ViewProvider } from "@/contexts/ViewContext";
const LOCAL_STORAGE_KEY = "memos-view-setting";
const wrapper = ({ children }: { children: ReactNode }) => <ViewProvider>{children}</ViewProvider>;
const persisted = () => JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? "{}");
describe("ViewContext maxColumns setting", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to a single column", () => {
const { result } = renderHook(() => useView(), { wrapper });
expect(result.current.maxColumns).toBe(1);
});
it("updates and persists the column ceiling", () => {
const { result } = renderHook(() => useView(), { wrapper });
act(() => result.current.setMaxColumns(0));
expect(result.current.maxColumns).toBe(0);
expect(persisted().maxColumns).toBe(0);
});
it("restores a persisted column count on init", () => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ maxColumns: 2 }));
const { result } = renderHook(() => useView(), { wrapper });
expect(result.current.maxColumns).toBe(2);
});
it("falls back to a single column for an invalid persisted value", () => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ maxColumns: 7 }));
const { result } = renderHook(() => useView(), { wrapper });
expect(result.current.maxColumns).toBe(1);
});
});