test(integrations/client/packages): batch 2-4 unit coverage + zip-slip guard extraction
Batch 2-4 of the test-strategy rollout. Test-only except one minimal, behaviour-preserving extraction in file.utils.ts. All suites green: server 82 suites/836+1todo, editor-ext 86, mcp 270, client (new files) 86. integrations (server): - file.utils.ts: extract pure `isEntryPathSafe(entryName, targetDir)` from extractZipInternal so the zip-slip/path-traversal guard is unit-testable; call site rerouted, behaviour identical (only a warn-message string merged). - file.utils.zip-safety.spec.ts: traversal/strip/__MACOSX/prefix-confusion cases (mutation-resistant: fails if containment loses the path.sep). - import-formatter / import.utils / table-utils / export utils / import.service extractTitleAndRemoveHeading: pure import/export transforms, Notion/XWiki formatting, table colspan widths (idempotent), slug/link rewriting. client: - safeRedirectPath: open-redirect guard, every reject branch independently. - buildChatMarkdown (fence anti-breakout), label-colors, normalize-label, share tree build, page URL builders, notification time-grouping (fake clock). packages: - editor-ext: deriveFootnoteId golden table, parseHtmlEmbedHeight crafted values, orphan footnote extraction. - mcp: deriveFootnoteId parity (drift guard vs editor-ext), applyTextEdits idempotency + cross-block replaceAll, diffDocs/summarizeChange on reorder. Reviewed (APPROVE): extraction behaviour-preserving, assertions mutation-resistant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
317
apps/client/src/features/ai-chat/utils/chat-markdown.test.ts
Normal file
317
apps/client/src/features/ai-chat/utils/chat-markdown.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Tests for the client-only Markdown export builder. The output embeds a live
|
||||
* `new Date().toISOString()` export timestamp; we never assert that value, only
|
||||
* the deterministic structure (headings, numbering, fenced blocks, totals).
|
||||
*
|
||||
* A pass-through translator keeps role/tool labels predictable so the
|
||||
* structural assertions are stable without an i18n runtime.
|
||||
*/
|
||||
const t = (key: string, values?: Record<string, unknown>): string => {
|
||||
if (values && typeof values.name === "string") {
|
||||
return key.replace("{{name}}", values.name);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
function row(partial: Partial<IAiChatMessageRow>): IAiChatMessageRow {
|
||||
return {
|
||||
id: partial.id ?? "id",
|
||||
role: partial.role ?? "user",
|
||||
content: partial.content ?? null,
|
||||
metadata: partial.metadata ?? null,
|
||||
createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildChatMarkdown — structure", () => {
|
||||
it("emits the title heading, chat id and message count", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "My chat",
|
||||
chatId: "chat-123",
|
||||
rows: [],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("# My chat");
|
||||
expect(md).toContain("- Chat ID: `chat-123`");
|
||||
expect(md).toContain("- Messages: 0");
|
||||
expect(md).toContain("- Exported:"); // timestamp present, value not asserted
|
||||
});
|
||||
|
||||
it("falls back to the translated 'Untitled chat' for empty/blank titles", () => {
|
||||
expect(
|
||||
buildChatMarkdown({ title: null, chatId: "c", rows: [], t }),
|
||||
).toContain("# Untitled chat");
|
||||
expect(
|
||||
buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }),
|
||||
).toContain("# Untitled chat");
|
||||
});
|
||||
|
||||
it("numbers rows sequentially with role headings", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({ role: "user", content: "hi" }),
|
||||
row({ role: "assistant", content: "hello" }),
|
||||
row({ role: "user", content: "again" }),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("## 1. You");
|
||||
expect(md).toContain("## 2. AI agent");
|
||||
expect(md).toContain("## 3. You");
|
||||
// Heading numbering is strictly index+1, not e.g. role-relative.
|
||||
expect(md).not.toContain("## 0.");
|
||||
});
|
||||
|
||||
it("renders the per-row text content from `content` when no metadata.parts", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "plain body" })],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("plain body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — text parts", () => {
|
||||
it("skips empty / whitespace-only text parts", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "ignored-content",
|
||||
metadata: {
|
||||
parts: [
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "kept line" },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any,
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("kept line");
|
||||
// Whitespace-only part contributed no block of its own.
|
||||
expect(md).not.toContain(" \n\n");
|
||||
// When metadata.parts exists, the plain `content` fallback is NOT used.
|
||||
expect(md).not.toContain("ignored-content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — tool parts", () => {
|
||||
it("renders a tool label, name, state and fenced Input/Output blocks", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: { pageId: "p1" },
|
||||
output: { id: "p1", title: "Home" },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
// Known tool name maps to its label key; raw name in backticks; done state.
|
||||
expect(md).toContain("**Tool: Read page** (`getPage`) — done");
|
||||
expect(md).toContain("Input:");
|
||||
expect(md).toContain("Output:");
|
||||
// Fenced JSON blocks contain the stringified payloads.
|
||||
expect(md).toContain('"pageId": "p1"');
|
||||
expect(md).toContain('"title": "Home"');
|
||||
expect(md).toContain("```json");
|
||||
});
|
||||
|
||||
it("renders the generic label for an unknown tool and surfaces errorText", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-mysteryTool",
|
||||
state: "output-error",
|
||||
input: { a: 1 },
|
||||
errorText: "boom",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error");
|
||||
expect(md).toContain("**Error:** boom");
|
||||
});
|
||||
|
||||
it("does not throw on a circular tool input (falls back to String)", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const circular: any = {};
|
||||
circular.self = circular;
|
||||
expect(() =>
|
||||
buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "input-available",
|
||||
input: circular,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — fence anti-breakout", () => {
|
||||
it("lengthens the delimiter so embedded ``` cannot break out of the block", () => {
|
||||
// Tool input whose stringified string form contains a literal ``` run.
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
// A bare string passes through stringify() verbatim.
|
||||
input: "before ``` after",
|
||||
output: "x",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
// The fence around the 3-backtick content must use at least 4 backticks so
|
||||
// the embedded ``` run cannot terminate the block.
|
||||
expect(md).toContain("````json\nbefore ``` after\n````");
|
||||
// Robust anti-breakout check: the opening fence delimiter is strictly
|
||||
// longer than the longest backtick run inside the wrapped content. (A naive
|
||||
// `not.toContain("```json...")` is a false negative — a 4-backtick fence
|
||||
// textually contains the 3-backtick substring.)
|
||||
const open = md.match(/(`{3,})json\nbefore/);
|
||||
expect(open).not.toBeNull();
|
||||
expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content
|
||||
});
|
||||
|
||||
it("uses a 5-backtick fence when the content has a 4-backtick run", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: "a ```` b",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("`````json\na ```` b\n`````");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — token totals", () => {
|
||||
it("prints the total-tokens line only when the summed usage is > 0", () => {
|
||||
const withTokens = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(withTokens).toContain("- Total tokens: 15");
|
||||
// Per-row usage footer too.
|
||||
expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
||||
});
|
||||
|
||||
it("omits the total-tokens line when the sum is 0 / usage absent", () => {
|
||||
const noTokens = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({ role: "user", content: "hi" }),
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 0, outputTokens: 0 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(noTokens).not.toContain("- Total tokens:");
|
||||
});
|
||||
|
||||
it("uses totalTokens when present rather than summing in/out", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("- Total tokens: 99");
|
||||
});
|
||||
});
|
||||
93
apps/client/src/features/label/utils/label-colors.test.ts
Normal file
93
apps/client/src/features/label/utils/label-colors.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
|
||||
|
||||
/**
|
||||
* Tests for the deterministic label-color hashing. `hashName` is not exported,
|
||||
* so we exercise it through `getLabelColor`. We assert determinism, that light
|
||||
* and dark schemes resolve to the SAME palette key (so a label's "blue" stays
|
||||
* "blue" across themes), that the returned color is always a real palette
|
||||
* entry, and that a realistic sample of names does not all collapse into one
|
||||
* bucket (guards the murmur fmix finalizer that de-clusters the % bucket).
|
||||
*/
|
||||
|
||||
// The 8 distinct light-scheme bg colors, used to recover a name's bucket index.
|
||||
const LIGHT_BGS = [
|
||||
"#eef1f5", // slate
|
||||
"#e6f0ff", // blue
|
||||
"#e3f5ea", // green
|
||||
"#fbf0d9", // amber
|
||||
"#fde6e6", // red
|
||||
"#efe9fb", // purple
|
||||
"#fce6ee", // pink
|
||||
"#daf1ee", // teal
|
||||
];
|
||||
|
||||
const DARK_BGS = [
|
||||
"#2a3140",
|
||||
"#152a52",
|
||||
"#143b27",
|
||||
"#3d2c0e",
|
||||
"#401a1a",
|
||||
"#2a1f4d",
|
||||
"#3c1a2a",
|
||||
"#103633",
|
||||
];
|
||||
|
||||
describe("getLabelColor — determinism", () => {
|
||||
it("returns the same color object shape for the same name", () => {
|
||||
const a = getLabelColor("bug");
|
||||
const b = getLabelColor("bug");
|
||||
expect(a).toEqual(b);
|
||||
expect(a).toMatchObject({
|
||||
bg: expect.any(String),
|
||||
fg: expect.any(String),
|
||||
dot: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("is stable across many repeated calls", () => {
|
||||
const first = getLabelColor("enhancement");
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(getLabelColor("enhancement")).toEqual(first);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLabelColor — scheme parity", () => {
|
||||
it("light and dark resolve to the SAME palette key for a given name", () => {
|
||||
const names = ["bug", "enhancement", "wontfix", "duplicate", "p1", "docs"];
|
||||
for (const name of names) {
|
||||
const lightIdx = LIGHT_BGS.indexOf(getLabelColor(name, "light").bg);
|
||||
const darkIdx = DARK_BGS.indexOf(getLabelColor(name, "dark").bg);
|
||||
expect(lightIdx).toBeGreaterThanOrEqual(0); // it is a real palette entry
|
||||
expect(darkIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(darkIdx).toBe(lightIdx); // same bucket across themes
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to the light scheme", () => {
|
||||
expect(getLabelColor("bug")).toEqual(getLabelColor("bug", "light"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLabelColor — index bounds & distribution", () => {
|
||||
it("always returns a color whose bg is one of the 8 palette entries", () => {
|
||||
const names = Array.from({ length: 200 }, (_, i) => `label-${i}`);
|
||||
for (const name of names) {
|
||||
expect(LIGHT_BGS).toContain(getLabelColor(name).bg);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles the empty string without crashing and within bounds", () => {
|
||||
expect(LIGHT_BGS).toContain(getLabelColor("").bg);
|
||||
});
|
||||
|
||||
it("a sample of distinct names does not all collide into one bucket", () => {
|
||||
const names = Array.from({ length: 64 }, (_, i) => `name-${i}-${i * 7}`);
|
||||
const buckets = new Set(names.map((n) => getLabelColor(n).bg));
|
||||
// The fmix finalizer should spread these across multiple buckets, not 1.
|
||||
expect(buckets.size).toBeGreaterThan(1);
|
||||
// Realistically a 64-name sample lands in most/all of the 8 buckets.
|
||||
expect(buckets.size).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
47
apps/client/src/features/label/utils/normalize-label.test.ts
Normal file
47
apps/client/src/features/label/utils/normalize-label.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
|
||||
|
||||
/**
|
||||
* `normalizeLabelName` = trim + collapse ALL whitespace runs to a single hyphen
|
||||
* + lowercase. Used to canonicalize label names so "Bug Fix" and " bug fix "
|
||||
* map to the same key.
|
||||
*/
|
||||
describe("normalizeLabelName", () => {
|
||||
it("trims leading and trailing whitespace", () => {
|
||||
expect(normalizeLabelName(" bug ")).toBe("bug");
|
||||
});
|
||||
|
||||
it("lowercases", () => {
|
||||
expect(normalizeLabelName("BUG")).toBe("bug");
|
||||
expect(normalizeLabelName("MixedCase")).toBe("mixedcase");
|
||||
});
|
||||
|
||||
it("collapses internal whitespace runs to a single hyphen", () => {
|
||||
expect(normalizeLabelName("bug fix")).toBe("bug-fix");
|
||||
expect(normalizeLabelName("a b c")).toBe("a-b-c");
|
||||
});
|
||||
|
||||
it("combines trim + collapse + lowercase", () => {
|
||||
expect(normalizeLabelName(" Bug Fix ")).toBe("bug-fix");
|
||||
});
|
||||
|
||||
it("treats tab and newline as whitespace", () => {
|
||||
expect(normalizeLabelName("bug\tfix")).toBe("bug-fix");
|
||||
expect(normalizeLabelName("bug\nfix")).toBe("bug-fix");
|
||||
expect(normalizeLabelName("bug\r\nfix")).toBe("bug-fix");
|
||||
});
|
||||
|
||||
it("treats unicode whitespace (no-break space) as a separator", () => {
|
||||
// U+00A0 NO-BREAK SPACE is matched by the \s class.
|
||||
expect(normalizeLabelName("bug fix")).toBe("bug-fix");
|
||||
});
|
||||
|
||||
it("leaves an already-normalized name unchanged", () => {
|
||||
expect(normalizeLabelName("bug-fix")).toBe("bug-fix");
|
||||
});
|
||||
|
||||
it("returns empty string for whitespace-only input", () => {
|
||||
expect(normalizeLabelName(" ")).toBe("");
|
||||
expect(normalizeLabelName("")).toBe("");
|
||||
});
|
||||
});
|
||||
134
apps/client/src/features/notification/notification.utils.test.ts
Normal file
134
apps/client/src/features/notification/notification.utils.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
getTimeGroup,
|
||||
groupNotificationsByTime,
|
||||
} from "@/features/notification/notification.utils.ts";
|
||||
import type { INotification } from "@/features/notification/types/notification.types.ts";
|
||||
|
||||
/**
|
||||
* `getTimeGroup` classifies a timestamp into today / yesterday / this_week /
|
||||
* older using LOCAL-time day boundaries derived from `now`. To stay timezone-
|
||||
* independent, the boundary anchors are computed exactly the way the SUT does
|
||||
* (local midnight of today, minus 1 day, minus 7 days) and inputs are offset
|
||||
* from those anchors by a safe margin. `groupNotificationsByTime` buckets a
|
||||
* list, drops empty groups, and preserves input order within each group, in the
|
||||
* fixed order today -> yesterday -> this_week -> older.
|
||||
*/
|
||||
const FIXED_NOW = new Date("2026-06-21T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Local midnight of "today" relative to the frozen clock.
|
||||
function startOfTodayLocal(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
// An ISO string `offsetMs` away from local midnight of today.
|
||||
function fromTodayStart(offsetMs: number): string {
|
||||
return new Date(startOfTodayLocal().getTime() + offsetMs).toISOString();
|
||||
}
|
||||
|
||||
function notif(id: string, createdAt: string): INotification {
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe("getTimeGroup — boundary classification", () => {
|
||||
it("classifies a time after today's midnight as 'today'", () => {
|
||||
expect(getTimeGroup(fromTodayStart(HOUR))).toBe("today");
|
||||
});
|
||||
|
||||
it("classifies exactly today's midnight as 'today' (inclusive lower bound)", () => {
|
||||
expect(getTimeGroup(fromTodayStart(0))).toBe("today");
|
||||
});
|
||||
|
||||
it("classifies the slice between yesterday-midnight and today-midnight as 'yesterday'", () => {
|
||||
expect(getTimeGroup(fromTodayStart(-HOUR))).toBe("yesterday");
|
||||
expect(getTimeGroup(fromTodayStart(-DAY))).toBe("yesterday"); // start of yesterday, inclusive
|
||||
});
|
||||
|
||||
it("classifies 2..7 days before today as 'this_week'", () => {
|
||||
expect(getTimeGroup(fromTodayStart(-DAY - HOUR))).toBe("this_week");
|
||||
expect(getTimeGroup(fromTodayStart(-7 * DAY))).toBe("this_week"); // start of week, inclusive
|
||||
});
|
||||
|
||||
it("classifies anything before the 7-day window as 'older'", () => {
|
||||
expect(getTimeGroup(fromTodayStart(-7 * DAY - HOUR))).toBe("older");
|
||||
expect(getTimeGroup(fromTodayStart(-30 * DAY))).toBe("older");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupNotificationsByTime", () => {
|
||||
const labels = {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
this_week: "This week",
|
||||
older: "Older",
|
||||
};
|
||||
|
||||
it("returns groups in the order today -> yesterday -> this_week -> older", () => {
|
||||
// Provide rows out of order to prove ordering comes from the group order,
|
||||
// not input order.
|
||||
const result = groupNotificationsByTime(
|
||||
[
|
||||
notif("old", fromTodayStart(-30 * DAY)),
|
||||
notif("today", fromTodayStart(HOUR)),
|
||||
notif("week", fromTodayStart(-3 * DAY)),
|
||||
notif("yest", fromTodayStart(-HOUR)),
|
||||
],
|
||||
labels,
|
||||
);
|
||||
expect(result.map((g) => g.key)).toEqual([
|
||||
"today",
|
||||
"yesterday",
|
||||
"this_week",
|
||||
"older",
|
||||
]);
|
||||
expect(result.map((g) => g.label)).toEqual([
|
||||
"Today",
|
||||
"Yesterday",
|
||||
"This week",
|
||||
"Older",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves input order within a single group", () => {
|
||||
const result = groupNotificationsByTime(
|
||||
[
|
||||
notif("t1", fromTodayStart(HOUR)),
|
||||
notif("t2", fromTodayStart(2 * HOUR)),
|
||||
notif("t3", fromTodayStart(3 * HOUR)),
|
||||
],
|
||||
labels,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe("today");
|
||||
expect(result[0].notifications.map((n) => n.id)).toEqual(["t1", "t2", "t3"]);
|
||||
});
|
||||
|
||||
it("drops empty groups", () => {
|
||||
const result = groupNotificationsByTime(
|
||||
[notif("only-today", fromTodayStart(HOUR))],
|
||||
labels,
|
||||
);
|
||||
expect(result.map((g) => g.key)).toEqual(["today"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for no notifications", () => {
|
||||
expect(groupNotificationsByTime([], labels)).toEqual([]);
|
||||
});
|
||||
});
|
||||
99
apps/client/src/features/page/page.utils.test.ts
Normal file
99
apps/client/src/features/page/page.utils.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildPageUrl, buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
/**
|
||||
* URL builders. A page URL is `${titleSlug}-${slugId}` where the title is
|
||||
* slugified (lowercase, dashed) after truncating to the first 70 chars, and an
|
||||
* empty title becomes "untitled". `buildPageUrl` prefixes `/p/` when no space
|
||||
* name is given and `/s/{space}/p/` otherwise. `buildSharedPageUrl` prefixes
|
||||
* `/share/p/` when no shareId and `/share/{shareId}/p/` otherwise. An anchorId
|
||||
* is appended as `#...`.
|
||||
*/
|
||||
describe("buildPageUrl", () => {
|
||||
it("uses /p/{slug} when spaceName is undefined", () => {
|
||||
expect(buildPageUrl(undefined as unknown as string, "abc123", "Hello World")).toBe(
|
||||
"/p/hello-world-abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses /s/{space}/p/{slug} when spaceName is provided", () => {
|
||||
expect(buildPageUrl("eng", "abc123", "Hello World")).toBe(
|
||||
"/s/eng/p/hello-world-abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("slugifies (lowercases + dashes) the title", () => {
|
||||
expect(buildPageUrl("eng", "id1", "My Cool PAGE!")).toBe(
|
||||
"/s/eng/p/my-cool-page-id1",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses 'untitled' for an empty title", () => {
|
||||
expect(buildPageUrl("eng", "id1", "")).toBe("/s/eng/p/untitled-id1");
|
||||
});
|
||||
|
||||
it("uses 'untitled' when no title is passed at all", () => {
|
||||
expect(buildPageUrl("eng", "id1")).toBe("/s/eng/p/untitled-id1");
|
||||
});
|
||||
|
||||
it("truncates the title to the first 70 chars before slugifying", () => {
|
||||
// 80 'a' then a space then "tail". Only the first 70 chars feed slugify, so
|
||||
// the slug is 70 a's (the space and "tail" past char 70 are dropped).
|
||||
const longTitle = "a".repeat(80) + " tail";
|
||||
const url = buildPageUrl("eng", "id1", longTitle);
|
||||
expect(url).toBe(`/s/eng/p/${"a".repeat(70)}-id1`);
|
||||
expect(url).not.toContain("tail");
|
||||
});
|
||||
|
||||
it("appends the anchorId as a #fragment", () => {
|
||||
expect(buildPageUrl("eng", "id1", "Page", "section-2")).toBe(
|
||||
"/s/eng/p/page-id1#section-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the fragment when no anchorId is given", () => {
|
||||
expect(buildPageUrl("eng", "id1", "Page")).not.toContain("#");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSharedPageUrl", () => {
|
||||
it("uses /share/p/{slug} when shareId is absent", () => {
|
||||
expect(
|
||||
buildSharedPageUrl({ shareId: "", pageSlugId: "id1", pageTitle: "Doc" }),
|
||||
).toBe("/share/p/doc-id1");
|
||||
});
|
||||
|
||||
it("uses /share/{shareId}/p/{slug} when shareId is present", () => {
|
||||
expect(
|
||||
buildSharedPageUrl({ shareId: "s9", pageSlugId: "id1", pageTitle: "Doc" }),
|
||||
).toBe("/share/s9/p/doc-id1");
|
||||
});
|
||||
|
||||
it("falls back to 'untitled' for an empty title", () => {
|
||||
expect(
|
||||
buildSharedPageUrl({ shareId: "s9", pageSlugId: "id1", pageTitle: "" }),
|
||||
).toBe("/share/s9/p/untitled-id1");
|
||||
});
|
||||
|
||||
it("appends the anchorId as a #fragment", () => {
|
||||
expect(
|
||||
buildSharedPageUrl({
|
||||
shareId: "s9",
|
||||
pageSlugId: "id1",
|
||||
pageTitle: "Doc",
|
||||
anchorId: "h1",
|
||||
}),
|
||||
).toBe("/share/s9/p/doc-id1#h1");
|
||||
});
|
||||
|
||||
it("truncates the title to the first 70 chars before slugifying", () => {
|
||||
const longTitle = "b".repeat(80) + " tail";
|
||||
const url = buildSharedPageUrl({
|
||||
shareId: "s9",
|
||||
pageSlugId: "id1",
|
||||
pageTitle: longTitle,
|
||||
});
|
||||
expect(url).toBe(`/share/s9/p/${"b".repeat(70)}-id1`);
|
||||
expect(url).not.toContain("tail");
|
||||
});
|
||||
});
|
||||
122
apps/client/src/features/share/utils.test.ts
Normal file
122
apps/client/src/features/share/utils.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSharedPageTree,
|
||||
isPageInTree,
|
||||
type SharedPageTreeNode,
|
||||
} from "@/features/share/utils.ts";
|
||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
/**
|
||||
* `buildSharedPageTree` nests pages by `parentPageId` (keyed on `page.id`),
|
||||
* promotes orphans (parent absent) to top level, marks `hasChildren`, and sorts
|
||||
* siblings recursively by `position`. `isPageInTree` walks the tree matching on
|
||||
* `slugId`. We build minimal page records (only the fields the builder reads).
|
||||
*/
|
||||
function page(p: Partial<IPage> & { id: string }): IPage {
|
||||
return {
|
||||
id: p.id,
|
||||
slugId: p.slugId ?? `slug-${p.id}`,
|
||||
title: p.title ?? p.id,
|
||||
icon: p.icon ?? "",
|
||||
position: p.position ?? "a0",
|
||||
spaceId: p.spaceId ?? "space-1",
|
||||
parentPageId: p.parentPageId ?? (null as unknown as string),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("buildSharedPageTree — nesting & sorting", () => {
|
||||
it("nests children under their parent and sorts siblings by position", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "root", slugId: "root-s", position: "a0" }),
|
||||
page({ id: "c2", slugId: "c2-s", parentPageId: "root", position: "a2" }),
|
||||
page({ id: "c1", slugId: "c1-s", parentPageId: "root", position: "a1" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
expect(tree).toHaveLength(1);
|
||||
const root = tree[0];
|
||||
expect(root.slugId).toBe("root-s");
|
||||
expect(root.hasChildren).toBe(true);
|
||||
expect(root.children.map((c) => c.slugId)).toEqual(["c1-s", "c2-s"]);
|
||||
});
|
||||
|
||||
it("sorts top-level siblings by position", () => {
|
||||
// Positions: a-s=a1, c-s=a2, b-s=a3 -> sorted order is a1, a2, a3.
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "b", slugId: "b-s", position: "a3" }),
|
||||
page({ id: "a", slugId: "a-s", position: "a1" }),
|
||||
page({ id: "c", slugId: "c-s", position: "a2" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
expect(tree.map((n) => n.slugId)).toEqual(["a-s", "c-s", "b-s"]);
|
||||
});
|
||||
|
||||
it("sorts recursively at depth", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "root", slugId: "root-s", position: "a0" }),
|
||||
page({ id: "mid", slugId: "mid-s", parentPageId: "root", position: "a0" }),
|
||||
page({ id: "g2", slugId: "g2-s", parentPageId: "mid", position: "a5" }),
|
||||
page({ id: "g1", slugId: "g1-s", parentPageId: "mid", position: "a1" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
const mid = tree[0].children[0];
|
||||
expect(mid.slugId).toBe("mid-s");
|
||||
expect(mid.hasChildren).toBe(true);
|
||||
expect(mid.children.map((c) => c.slugId)).toEqual(["g1-s", "g2-s"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSharedPageTree — orphans & flags", () => {
|
||||
it("promotes a page whose parent is absent to a top-level node (no crash)", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "x", slugId: "x-s", parentPageId: "missing-parent" }),
|
||||
page({ id: "y", slugId: "y-s" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
const slugs = tree.map((n) => n.slugId).sort();
|
||||
expect(slugs).toEqual(["x-s", "y-s"]);
|
||||
});
|
||||
|
||||
it("leaves hasChildren false for leaf nodes", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "leaf", slugId: "leaf-s" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
expect(tree[0].hasChildren).toBe(false);
|
||||
expect(tree[0].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses 'untitled' as the label for an empty title", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "z", slugId: "z-s", title: "" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
expect(tree[0].label).toBe("untitled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPageInTree", () => {
|
||||
const tree: SharedPageTreeNode[] = buildSharedPageTree([
|
||||
page({ id: "root", slugId: "root-s", position: "a0" }),
|
||||
page({ id: "child", slugId: "child-s", parentPageId: "root", position: "a1" }),
|
||||
page({ id: "grand", slugId: "grand-s", parentPageId: "child", position: "a1" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
it("returns true for a top-level slugId", () => {
|
||||
expect(isPageInTree(tree, "root-s")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a deeply nested slugId", () => {
|
||||
expect(isPageInTree(tree, "grand-s")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an unknown slugId", () => {
|
||||
expect(isPageInTree(tree, "does-not-exist")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty tree", () => {
|
||||
expect(isPageInTree([], "root-s")).toBe(false);
|
||||
});
|
||||
});
|
||||
BIN
apps/client/src/lib/app-route.safe-redirect.test.ts
Normal file
BIN
apps/client/src/lib/app-route.safe-redirect.test.ts
Normal file
Binary file not shown.
158
apps/server/src/integrations/export/utils.spec.ts
Normal file
158
apps/server/src/integrations/export/utils.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
buildTree,
|
||||
computeLocalPath,
|
||||
getExportExtension,
|
||||
extractPageSlugId,
|
||||
getInternalLinkPageName,
|
||||
INTERNAL_LINK_REGEX,
|
||||
PageExportTree,
|
||||
} from './utils';
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Unit tests for export/utils.ts pure helpers:
|
||||
* - buildTree: groups pages by parentPageId and de-duplicates sibling titles.
|
||||
* - computeLocalPath / getExportExtension: builds the slugId -> file path map.
|
||||
* - extractPageSlugId / INTERNAL_LINK_REGEX: parse the trailing slugId.
|
||||
* - getInternalLinkPageName: derive a page name from a relative file path.
|
||||
*/
|
||||
|
||||
function page(partial: Partial<Page>): Page {
|
||||
return partial as Page;
|
||||
}
|
||||
|
||||
describe('buildTree', () => {
|
||||
it('groups pages by their parentPageId', () => {
|
||||
const pages = [
|
||||
page({ id: 'a', parentPageId: 'root', title: 'A', slugId: 'sa' }),
|
||||
page({ id: 'b', parentPageId: 'root', title: 'B', slugId: 'sb' }),
|
||||
page({ id: 'c', parentPageId: 'a', title: 'C', slugId: 'sc' }),
|
||||
];
|
||||
|
||||
const tree = buildTree(pages);
|
||||
|
||||
expect(Object.keys(tree).sort()).toEqual(['a', 'root']);
|
||||
expect(tree['root'].map((p) => p.id)).toEqual(['a', 'b']);
|
||||
expect(tree['a'].map((p) => p.id)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('suffixes duplicate sibling titles with " (1)", " (2)"', () => {
|
||||
const pages = [
|
||||
page({ id: '1', parentPageId: 'root', title: 'Doc', slugId: 's1' }),
|
||||
page({ id: '2', parentPageId: 'root', title: 'Doc', slugId: 's2' }),
|
||||
page({ id: '3', parentPageId: 'root', title: 'Doc', slugId: 's3' }),
|
||||
];
|
||||
|
||||
const tree = buildTree(pages);
|
||||
|
||||
expect(tree['root'].map((p) => p.title)).toEqual([
|
||||
'Doc',
|
||||
'Doc (1)',
|
||||
'Doc (2)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not collide identical titles across different parents', () => {
|
||||
const pages = [
|
||||
page({ id: '1', parentPageId: 'p1', title: 'Same', slugId: 's1' }),
|
||||
page({ id: '2', parentPageId: 'p2', title: 'Same', slugId: 's2' }),
|
||||
];
|
||||
|
||||
const tree = buildTree(pages);
|
||||
|
||||
expect(tree['p1'][0].title).toBe('Same');
|
||||
expect(tree['p2'][0].title).toBe('Same');
|
||||
});
|
||||
|
||||
it('falls back to "untitled" for empty titles', () => {
|
||||
const pages = [
|
||||
page({ id: '1', parentPageId: 'root', title: '', slugId: 's1' }),
|
||||
];
|
||||
|
||||
const tree = buildTree(pages);
|
||||
|
||||
expect(tree['root'][0].title).toBe('untitled');
|
||||
});
|
||||
|
||||
it('returns an empty object for empty input', () => {
|
||||
expect(buildTree([])).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeLocalPath + getExportExtension', () => {
|
||||
it('builds nested parent/child paths with the markdown extension', () => {
|
||||
const tree: PageExportTree = {
|
||||
// root level uses the literal string 'null' as key only when parentPageId
|
||||
// is null; here we use an explicit top-level key.
|
||||
top: [page({ id: 'parent', title: 'Parent', slugId: 'sp' })],
|
||||
parent: [page({ id: 'child', title: 'Child', slugId: 'sc' })],
|
||||
};
|
||||
const slugIdToPath: Record<string, string> = {};
|
||||
|
||||
computeLocalPath(tree, ExportFormat.Markdown, 'top', '', slugIdToPath);
|
||||
|
||||
expect(slugIdToPath['sp']).toBe('Parent.md');
|
||||
expect(slugIdToPath['sc']).toBe('Parent/Child.md');
|
||||
});
|
||||
|
||||
it('uses the html extension when the format is html', () => {
|
||||
const tree: PageExportTree = {
|
||||
top: [page({ id: 'parent', title: 'Parent', slugId: 'sp' })],
|
||||
};
|
||||
const slugIdToPath: Record<string, string> = {};
|
||||
|
||||
computeLocalPath(tree, ExportFormat.HTML, 'top', '', slugIdToPath);
|
||||
|
||||
expect(slugIdToPath['sp']).toBe('Parent.html');
|
||||
});
|
||||
|
||||
it('getExportExtension returns the right extension and undefined for unknown', () => {
|
||||
expect(getExportExtension(ExportFormat.HTML)).toBe('.html');
|
||||
expect(getExportExtension(ExportFormat.Markdown)).toBe('.md');
|
||||
expect(getExportExtension('pdf')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPageSlugId', () => {
|
||||
it('returns the trailing segment after the last dash', () => {
|
||||
expect(extractPageSlugId('slug-with-dashes-abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('returns the input unchanged when there is no dash (bare slugId)', () => {
|
||||
expect(extractPageSlugId('abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('returns undefined for empty input', () => {
|
||||
expect(extractPageSlugId('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('INTERNAL_LINK_REGEX', () => {
|
||||
it('matches a /s/{space}/p/{slug} url and captures the slug in group 5', () => {
|
||||
const match = '/s/space/p/page-abc123'.match(INTERNAL_LINK_REGEX);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![5]).toBe('page-abc123');
|
||||
expect(extractPageSlugId(match![5])).toBe('abc123');
|
||||
});
|
||||
|
||||
it('does not match a non-internal url', () => {
|
||||
expect('https://example.com/foo/bar'.match(INTERNAL_LINK_REGEX)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInternalLinkPageName', () => {
|
||||
it('strips the file extension and decodes the name', () => {
|
||||
expect(getInternalLinkPageName('Parent/My%20Page.md')).toBe('My Page');
|
||||
});
|
||||
|
||||
it('falls back to the raw name without throwing on malformed encoding', () => {
|
||||
// "%E0%A4" is an incomplete escape; decodeURIComponent throws and the
|
||||
// helper returns the raw (still-encoded) name.
|
||||
let result: string | undefined;
|
||||
expect(() => {
|
||||
result = getInternalLinkPageName('dir/%E0%A4.md', 'current.md');
|
||||
}).not.toThrow();
|
||||
expect(result).toBe('%E0%A4');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// Importing ImportService transitively loads import-formatter.ts, which imports
|
||||
// the ESM-only @sindresorhus/slugify package (not in jest's transform
|
||||
// allowlist). slugify is irrelevant to the method under test, so it is mocked
|
||||
// out to keep the module graph loadable under ts-jest.
|
||||
jest.mock('@sindresorhus/slugify', () => ({
|
||||
__esModule: true,
|
||||
default: (input: string) => String(input),
|
||||
}));
|
||||
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
/**
|
||||
* Unit tests for ImportService.extractTitleAndRemoveHeading — a pure method
|
||||
* (no `this`, no I/O). It pulls a leading level-1 heading out of a ProseMirror
|
||||
* document, returning its text as the title and the remaining content, and
|
||||
* guarantees at least one paragraph remains.
|
||||
*
|
||||
* The method does not touch the injected deps, so the service is constructed
|
||||
* with placeholder dependencies.
|
||||
*/
|
||||
|
||||
function makeService(): ImportService {
|
||||
// The method under test never references `this`/injected deps.
|
||||
return new ImportService({} as any, {} as any, {} as any, {} as any);
|
||||
}
|
||||
|
||||
describe('ImportService.extractTitleAndRemoveHeading', () => {
|
||||
const service = makeService();
|
||||
|
||||
it('extracts a leading H1 as the title and removes the heading from content', () => {
|
||||
const state = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'My Title' }],
|
||||
},
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.extractTitleAndRemoveHeading(state);
|
||||
|
||||
expect(result.title).toBe('My Title');
|
||||
// heading removed, only the paragraph remains
|
||||
expect(result.prosemirrorJson.content).toHaveLength(1);
|
||||
expect(result.prosemirrorJson.content[0].type).toBe('paragraph');
|
||||
expect(result.prosemirrorJson.content[0].content[0].text).toBe('body');
|
||||
// doc type preserved via spread
|
||||
expect(result.prosemirrorJson.type).toBe('doc');
|
||||
});
|
||||
|
||||
it('returns a null title and keeps content when there is no leading H1', () => {
|
||||
const state = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'first' }] },
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Later Heading' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.extractTitleAndRemoveHeading(state);
|
||||
|
||||
expect(result.title).toBeNull();
|
||||
// nothing removed
|
||||
expect(result.prosemirrorJson.content).toHaveLength(2);
|
||||
expect(result.prosemirrorJson.content[0].type).toBe('paragraph');
|
||||
});
|
||||
|
||||
it('does not treat a level-2 heading as a title', () => {
|
||||
const state = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: 'text', text: 'Subheading' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.extractTitleAndRemoveHeading(state);
|
||||
|
||||
expect(result.title).toBeNull();
|
||||
expect(result.prosemirrorJson.content).toHaveLength(1);
|
||||
expect(result.prosemirrorJson.content[0].type).toBe('heading');
|
||||
});
|
||||
|
||||
it('injects one empty paragraph when the content becomes empty', () => {
|
||||
// A document that is just a single H1 -> after removal, content is empty
|
||||
// and one empty paragraph is injected.
|
||||
const state = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Only Title' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.extractTitleAndRemoveHeading(state);
|
||||
|
||||
expect(result.title).toBe('Only Title');
|
||||
expect(result.prosemirrorJson.content).toEqual([
|
||||
{ type: 'paragraph', content: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('injects an empty paragraph for an already-empty document', () => {
|
||||
const state = { type: 'doc', content: [] };
|
||||
|
||||
const result = service.extractTitleAndRemoveHeading(state);
|
||||
|
||||
expect(result.title).toBeNull();
|
||||
expect(result.prosemirrorJson.content).toEqual([
|
||||
{ type: 'paragraph', content: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields a null title when an H1 has no text node', () => {
|
||||
const state = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'heading', attrs: { level: 1 }, content: [] }],
|
||||
};
|
||||
|
||||
const result = service.extractTitleAndRemoveHeading(state);
|
||||
|
||||
expect(result.title).toBeNull();
|
||||
// heading removed, empty paragraph injected
|
||||
expect(result.prosemirrorJson.content).toEqual([
|
||||
{ type: 'paragraph', content: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,52 @@ export function getFileTaskFolderPath(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure path-safety decision for a single ZIP entry (zip-slip / path-traversal guard).
|
||||
*
|
||||
* Reproduces exactly the inline check previously embedded in `extractZipInternal`:
|
||||
* 1. Strip any leading slashes from the entry name.
|
||||
* 2. Reject names that fail `yauzl.validateFileName` (e.g. backslashes,
|
||||
* relative `..` segments, drive letters).
|
||||
* 3. Reject `__MACOSX/` metadata entries.
|
||||
* 4. Resolve the entry against the target directory and require it to stay
|
||||
* strictly inside `targetDir` using a `targetResolved + path.sep` prefix check
|
||||
* (the trailing separator prevents sibling-directory prefix confusion, e.g.
|
||||
* `/tmp/x` must not match `/tmp/x-evil`).
|
||||
*
|
||||
* @param entryName The decoded (UTF-8) entry file name from the archive.
|
||||
* @param targetDir Directory the archive is being extracted into.
|
||||
* @returns `{ safe }` and, when safe, the resolved absolute path of the entry.
|
||||
*/
|
||||
export function isEntryPathSafe(
|
||||
entryName: string,
|
||||
targetDir: string,
|
||||
): { safe: boolean; resolved?: string } {
|
||||
// Strip leading slashes so absolute-looking entries cannot escape the target.
|
||||
const safe = entryName.replace(/^\/+/, '');
|
||||
|
||||
const validationError = yauzl.validateFileName(safe);
|
||||
if (validationError) {
|
||||
return { safe: false };
|
||||
}
|
||||
|
||||
// Skip macOS resource-fork metadata entries.
|
||||
if (safe.startsWith('__MACOSX/')) {
|
||||
return { safe: false };
|
||||
}
|
||||
|
||||
const fullPath = path.join(targetDir, safe);
|
||||
const resolved = path.resolve(fullPath);
|
||||
const targetResolved = path.resolve(targetDir);
|
||||
|
||||
// Containment check: resolved path must live strictly inside the target dir.
|
||||
if (!resolved.startsWith(targetResolved + path.sep)) {
|
||||
return { safe: false };
|
||||
}
|
||||
|
||||
return { safe: true, resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a ZIP archive.
|
||||
*/
|
||||
@@ -103,29 +149,15 @@ function extractZipInternal(
|
||||
const name = entry.fileName.toString('utf8');
|
||||
const safe = name.replace(/^\/+/, '');
|
||||
|
||||
const validationError = yauzl.validateFileName(safe);
|
||||
if (validationError) {
|
||||
console.warn(`Skipping invalid entry (${validationError})`);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (safe.startsWith('__MACOSX/')) {
|
||||
// Zip-slip / path-traversal guard (see isEntryPathSafe).
|
||||
if (!isEntryPathSafe(name, target).safe) {
|
||||
console.warn(`Skipping unsafe entry: ${safe}`);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.join(target, safe);
|
||||
|
||||
const resolved = path.resolve(fullPath);
|
||||
const targetResolved = path.resolve(target);
|
||||
|
||||
if (!resolved.startsWith(targetResolved + path.sep)) {
|
||||
console.warn(`Skipping entry (path outside target): ${safe}`);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle directories
|
||||
if (/\/$/.test(name)) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as path from 'path';
|
||||
import { isEntryPathSafe } from './file.utils';
|
||||
|
||||
/**
|
||||
* Unit tests for isEntryPathSafe: the pure zip-slip / path-traversal guard
|
||||
* extracted from extractZipInternal. The contract reproduced from the
|
||||
* production inline check is, in order:
|
||||
* 1. strip leading slashes from the entry name;
|
||||
* 2. reject names that fail yauzl.validateFileName (relative `..` segments,
|
||||
* backslashes, drive letters, etc.);
|
||||
* 3. reject `__MACOSX/` metadata entries;
|
||||
* 4. resolve the (stripped) entry under the target dir and require it to stay
|
||||
* strictly inside the target via a `targetResolved + path.sep` prefix check.
|
||||
*
|
||||
* The separator in step 4 is the load-bearing detail: it prevents sibling-dir
|
||||
* prefix confusion (e.g. target `/tmp/x` vs `/tmp/x-evil`). The tests below are
|
||||
* written so that weakening that check to a bare `startsWith(targetResolved)`
|
||||
* makes at least one test fail.
|
||||
*/
|
||||
describe('isEntryPathSafe', () => {
|
||||
// Use an absolute target; on the test platform path.sep is '/'.
|
||||
const target = path.resolve('/tmp/x');
|
||||
|
||||
it('accepts a normal nested entry and resolves it inside the target', () => {
|
||||
const result = isEntryPathSafe('a/b/c.png', target);
|
||||
expect(result.safe).toBe(true);
|
||||
expect(result.resolved).toBe(path.join(target, 'a/b/c.png'));
|
||||
// Resolved path must live strictly under the target directory.
|
||||
expect(result.resolved!.startsWith(target + path.sep)).toBe(true);
|
||||
});
|
||||
|
||||
it('strips a single leading slash and then treats the entry as safe', () => {
|
||||
const result = isEntryPathSafe('/a/b/c.png', target);
|
||||
expect(result.safe).toBe(true);
|
||||
expect(result.resolved).toBe(path.join(target, 'a/b/c.png'));
|
||||
});
|
||||
|
||||
it('strips multiple leading slashes and then treats the entry as safe', () => {
|
||||
const result = isEntryPathSafe('///a/b.png', target);
|
||||
expect(result.safe).toBe(true);
|
||||
expect(result.resolved).toBe(path.join(target, 'a/b.png'));
|
||||
});
|
||||
|
||||
it('skips (marks unsafe) __MACOSX metadata entries', () => {
|
||||
const result = isEntryPathSafe('__MACOSX/foo', target);
|
||||
expect(result.safe).toBe(false);
|
||||
expect(result.resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a relative ../../ traversal entry', () => {
|
||||
// yauzl.validateFileName flags this as an "invalid relative path", so it is
|
||||
// rejected before the containment check ever runs. Either way: unsafe.
|
||||
const result = isEntryPathSafe('../../etc/passwd', target);
|
||||
expect(result.safe).toBe(false);
|
||||
expect(result.resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an entry whose resolved path would land in a sibling directory (prefix confusion)', () => {
|
||||
// The classic off-by-one: target `/tmp/x` must NOT contain `/tmp/x-evil`.
|
||||
// Such an escape can only be expressed with a `..` segment, which the guard
|
||||
// rejects. This asserts the guard holds for the sibling-escape attempt.
|
||||
const result = isEntryPathSafe('../x-evil/p', target);
|
||||
expect(result.safe).toBe(false);
|
||||
expect(result.resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an entry that resolves to exactly the target dir (no trailing separator)', () => {
|
||||
// `.` resolves to the target itself. The strict `targetResolved + path.sep`
|
||||
// prefix check rejects it; a weakened `startsWith(targetResolved)` (without
|
||||
// the separator) would WRONGLY accept it. This test is the mutation killer
|
||||
// for the separator: if the separator is dropped, this assertion fails.
|
||||
const result = isEntryPathSafe('.', target);
|
||||
expect(result.safe).toBe(false);
|
||||
expect(result.resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the target/sibling boundary: a bare-prefix sibling is not inside the target', () => {
|
||||
// Direct statement of the invariant the separator protects. The resolved
|
||||
// sibling path shares the target's basename as a prefix but is a different
|
||||
// directory; only the `+ path.sep` form correctly classifies it as outside.
|
||||
const target2 = path.resolve('/tmp/x');
|
||||
const siblingResolved = path.resolve(path.join(target2, '..', 'x-evil', 'p'));
|
||||
expect(siblingResolved.startsWith(target2)).toBe(true); // weak (buggy) check matches
|
||||
expect(siblingResolved.startsWith(target2 + path.sep)).toBe(false); // strict check rejects
|
||||
});
|
||||
|
||||
it('rejects an entry containing a backslash via yauzl.validateFileName', () => {
|
||||
// Backslashes are flagged by yauzl.validateFileName as invalid characters,
|
||||
// so such entries are unsafe regardless of where they would resolve.
|
||||
const result = isEntryPathSafe('a\\b.png', target);
|
||||
expect(result.safe).toBe(false);
|
||||
expect(result.resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a stripped absolute path that lands inside the target', () => {
|
||||
// Documented ACTUAL behaviour: an entry like `/etc/passwd` has its leading
|
||||
// slash stripped to `etc/passwd`, which resolves to <target>/etc/passwd —
|
||||
// strictly inside the target, hence safe. (This is the point of the strip:
|
||||
// an absolute-looking entry is re-anchored under the target rather than
|
||||
// escaping to the filesystem root.)
|
||||
const result = isEntryPathSafe('/etc/passwd', target);
|
||||
expect(result.safe).toBe(true);
|
||||
expect(result.resolved).toBe(path.join(target, 'etc/passwd'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
// @sindresorhus/slugify ships as ESM and is not in jest's transform allowlist,
|
||||
// so it cannot be imported under ts-jest here. Mock it with a deterministic
|
||||
// lowercase/dash slugifier that matches the real output for the simple ASCII
|
||||
// titles used in these tests (e.g. "Real Title" -> "real-title"). This keeps
|
||||
// the test focused on the formatter's own slug-composition logic.
|
||||
jest.mock('@sindresorhus/slugify', () => ({
|
||||
__esModule: true,
|
||||
default: (input: string) =>
|
||||
String(input)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, ''),
|
||||
}));
|
||||
|
||||
import { load, CheerioAPI, Cheerio } from 'cheerio';
|
||||
import {
|
||||
rewriteInternalLinksToMentionHtml,
|
||||
notionFormatter,
|
||||
xwikiFormatter,
|
||||
defaultHtmlFormatter,
|
||||
unwrapFromParagraph,
|
||||
} from './import-formatter';
|
||||
|
||||
/**
|
||||
* Unit tests for import-formatter.ts. These are pure DOM transforms driven by
|
||||
* cheerio. Each test loads a snippet, runs the target function against the
|
||||
* cheerio root, and asserts the mutated markup / return value. Assertions are
|
||||
* written to fail if the corresponding branch were silently removed.
|
||||
*/
|
||||
|
||||
type PageMeta = { id: string; title: string; slugId: string };
|
||||
|
||||
function makeRoot(html: string): { $: CheerioAPI; $root: Cheerio<any> } {
|
||||
const $ = load(html);
|
||||
return { $, $root: $.root() };
|
||||
}
|
||||
|
||||
describe('rewriteInternalLinksToMentionHtml', () => {
|
||||
const creatorId = 'creator-1';
|
||||
const sourcePageId = 'source-page-1';
|
||||
const workspaceId = 'workspace-1';
|
||||
|
||||
it('replaces an internal link whose text equals the page title with a mention span', async () => {
|
||||
const meta: PageMeta = {
|
||||
id: 'target-id-1',
|
||||
title: 'Design Doc',
|
||||
slugId: 'slugABC',
|
||||
};
|
||||
// currentFilePath dir is "docs"; href "./target.md" resolves to "docs/target.md"
|
||||
const map = new Map<string, PageMeta>([['docs/target.md', meta]]);
|
||||
const { $, $root } = makeRoot(
|
||||
'<a href="./target.md">Design Doc</a>',
|
||||
);
|
||||
|
||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
$root,
|
||||
'docs/index.md',
|
||||
map,
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const $mention = $root.find('span[data-type="mention"]');
|
||||
expect($mention.length).toBe(1);
|
||||
expect($mention.attr('data-entity-type')).toBe('page');
|
||||
expect($mention.attr('data-entity-id')).toBe('target-id-1');
|
||||
expect($mention.attr('data-label')).toBe('Design Doc');
|
||||
expect($mention.attr('data-slug-id')).toBe('slugABC');
|
||||
expect($mention.attr('data-creator-id')).toBe(creatorId);
|
||||
expect($mention.attr('data-id')).toBeTruthy();
|
||||
expect($mention.text()).toBe('Design Doc');
|
||||
// original anchor must be gone
|
||||
expect($root.find('a').length).toBe(0);
|
||||
|
||||
expect(backlinks).toEqual([
|
||||
{ sourcePageId, targetPageId: 'target-id-1', workspaceId },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rewrites href to /s/{space}/p/{slug} when text differs from the title', async () => {
|
||||
const meta: PageMeta = {
|
||||
id: 'target-id-2',
|
||||
title: 'Real Title',
|
||||
slugId: 'slug999',
|
||||
};
|
||||
const map = new Map<string, PageMeta>([['docs/target.md', meta]]);
|
||||
const { $, $root } = makeRoot(
|
||||
'<a href="./target.md">click here</a>',
|
||||
);
|
||||
|
||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
$root,
|
||||
'docs/index.md',
|
||||
map,
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
'myspace',
|
||||
);
|
||||
|
||||
// still an anchor, no mention span
|
||||
expect($root.find('span[data-type="mention"]').length).toBe(0);
|
||||
const $a = $root.find('a');
|
||||
expect($a.length).toBe(1);
|
||||
// slugify('Real Title') => 'real-title'
|
||||
expect($a.attr('href')).toBe('/s/myspace/p/real-title-slug999');
|
||||
expect($a.attr('data-internal')).toBe('true');
|
||||
expect($a.text()).toBe('click here');
|
||||
|
||||
expect(backlinks).toEqual([
|
||||
{ sourcePageId, targetPageId: 'target-id-2', workspaceId },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses /p/{slug} when no spaceSlug is provided', async () => {
|
||||
const meta: PageMeta = {
|
||||
id: 'target-id-3',
|
||||
title: 'Other Page',
|
||||
slugId: 'slug777',
|
||||
};
|
||||
const map = new Map<string, PageMeta>([['docs/target.md', meta]]);
|
||||
const { $, $root } = makeRoot('<a href="./target.md">label</a>');
|
||||
|
||||
await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
$root,
|
||||
'docs/index.md',
|
||||
map,
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect($root.find('a').attr('href')).toBe('/p/other-page-slug777');
|
||||
});
|
||||
|
||||
it('leaves external http and /api/ hrefs untouched and records no backlink', async () => {
|
||||
const map = new Map<string, PageMeta>();
|
||||
const { $, $root } = makeRoot(
|
||||
'<a href="https://example.com/page">ext</a><a href="/api/files/x">api</a>',
|
||||
);
|
||||
|
||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
$root,
|
||||
'docs/index.md',
|
||||
map,
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const hrefs = $root
|
||||
.find('a')
|
||||
.map((_, el) => $(el).attr('href'))
|
||||
.get();
|
||||
expect(hrefs).toEqual(['https://example.com/page', '/api/files/x']);
|
||||
expect($root.find('a').first().attr('data-internal')).toBeUndefined();
|
||||
expect(backlinks).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back without throwing on a malformed decodeURIComponent href', async () => {
|
||||
const meta: PageMeta = {
|
||||
id: 'target-id-4',
|
||||
title: 'Broken',
|
||||
slugId: 'slug000',
|
||||
};
|
||||
// The raw (un-decodable) href is what gets joined: "docs/%E0%A4%A.md".
|
||||
const map = new Map<string, PageMeta>([['docs/%E0%A4%A.md', meta]]);
|
||||
const { $, $root } = makeRoot('<a href="%E0%A4%A.md">Broken</a>');
|
||||
|
||||
let backlinks: any;
|
||||
await expect(
|
||||
(async () => {
|
||||
backlinks = await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
$root,
|
||||
'docs/index.md',
|
||||
map,
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
);
|
||||
})(),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Because the raw path matched the map, it still produced a mention.
|
||||
expect($root.find('span[data-type="mention"]').length).toBe(1);
|
||||
expect(backlinks).toEqual([
|
||||
{ sourcePageId, targetPageId: 'target-id-4', workspaceId },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accumulates one backlink per resolved link', async () => {
|
||||
const a: PageMeta = { id: 'id-a', title: 'A', slugId: 's-a' };
|
||||
const b: PageMeta = { id: 'id-b', title: 'B', slugId: 's-b' };
|
||||
const map = new Map<string, PageMeta>([
|
||||
['docs/a.md', a],
|
||||
['docs/b.md', b],
|
||||
]);
|
||||
const { $, $root } = makeRoot(
|
||||
'<a href="./a.md">A</a><a href="./b.md">B</a>',
|
||||
);
|
||||
|
||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
$root,
|
||||
'docs/index.md',
|
||||
map,
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(backlinks).toEqual([
|
||||
{ sourcePageId, targetPageId: 'id-a', workspaceId },
|
||||
{ sourcePageId, targetPageId: 'id-b', workspaceId },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notionFormatter', () => {
|
||||
it('converts a multi-column column-list to data-type="columns" with the right layout', () => {
|
||||
const html =
|
||||
'<div class="column-list">' +
|
||||
'<div class="column"><p>one</p></div>' +
|
||||
'<div class="column"><p>two</p></div>' +
|
||||
'<div class="column"><p>three</p></div>' +
|
||||
'</div>';
|
||||
const { $, $root } = makeRoot(html);
|
||||
|
||||
notionFormatter($, $root);
|
||||
|
||||
const $cols = $root.find('div[data-type="columns"]');
|
||||
expect($cols.length).toBe(1);
|
||||
// 3 columns => COLUMN_LAYOUTS[3] === 'three_equal'
|
||||
expect($cols.attr('data-layout')).toBe('three_equal');
|
||||
expect($root.find('div[data-type="column"]').length).toBe(3);
|
||||
// original column-list wrapper is gone
|
||||
expect($root.find('div.column-list').length).toBe(0);
|
||||
});
|
||||
|
||||
it('uses two_equal layout for exactly two columns', () => {
|
||||
const html =
|
||||
'<div class="column-list">' +
|
||||
'<div class="column"><p>one</p></div>' +
|
||||
'<div class="column"><p>two</p></div>' +
|
||||
'</div>';
|
||||
const { $, $root } = makeRoot(html);
|
||||
|
||||
notionFormatter($, $root);
|
||||
|
||||
expect($root.find('div[data-type="columns"]').attr('data-layout')).toBe(
|
||||
'two_equal',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts figure.equation into a mathBlock with the tex text', () => {
|
||||
const html =
|
||||
'<figure class="equation">' +
|
||||
'<annotation encoding="application/x-tex">E = mc^2</annotation>' +
|
||||
'</figure>';
|
||||
const { $, $root } = makeRoot(html);
|
||||
|
||||
notionFormatter($, $root);
|
||||
|
||||
const $math = $root.find('div[data-type="mathBlock"]');
|
||||
expect($math.length).toBe(1);
|
||||
expect($math.attr('data-katex')).toBe('true');
|
||||
expect($math.text()).toBe('E = mc^2');
|
||||
expect($root.find('figure.equation').length).toBe(0);
|
||||
});
|
||||
|
||||
it('converts ul.to-do-list items to a taskList with data-checked reflecting checkbox-on', () => {
|
||||
const html =
|
||||
'<ul class="to-do-list">' +
|
||||
'<li><div class="checkbox checkbox-on"></div>' +
|
||||
'<span class="to-do-children-checked">done item</span></li>' +
|
||||
'<li><div class="checkbox checkbox-off"></div>' +
|
||||
'<span class="to-do-children-unchecked">open item</span></li>' +
|
||||
'</ul>';
|
||||
const { $, $root } = makeRoot(html);
|
||||
|
||||
notionFormatter($, $root);
|
||||
|
||||
const $list = $root.find('ul[data-type="taskList"]');
|
||||
expect($list.length).toBe(1);
|
||||
const $items = $list.find('li[data-type="taskItem"]');
|
||||
expect($items.length).toBe(2);
|
||||
expect($items.eq(0).attr('data-checked')).toBe('true');
|
||||
expect($items.eq(1).attr('data-checked')).toBe('false');
|
||||
// checked item has a checked input; unchecked does not
|
||||
expect($items.eq(0).find('input[checked]').length).toBe(1);
|
||||
expect($items.eq(1).find('input[checked]').length).toBe(0);
|
||||
// text is carried over
|
||||
expect($items.eq(0).find('p').text()).toBe('done item');
|
||||
expect($items.eq(1).find('p').text()).toBe('open item');
|
||||
});
|
||||
});
|
||||
|
||||
describe('xwikiFormatter', () => {
|
||||
it('replaces the root with the contents of #xwikicontent when present', () => {
|
||||
const html =
|
||||
'<div id="header">junk</div>' +
|
||||
'<div id="xwikicontent"><p>real body</p><h2>heading</h2></div>';
|
||||
const { $, $root } = makeRoot(html);
|
||||
|
||||
xwikiFormatter($, $root);
|
||||
|
||||
expect($root.find('#header').length).toBe(0);
|
||||
expect($root.find('#xwikicontent').length).toBe(0);
|
||||
expect($root.find('p').text()).toBe('real body');
|
||||
expect($root.find('h2').text()).toBe('heading');
|
||||
});
|
||||
|
||||
it('leaves HTML without #xwikicontent unchanged', () => {
|
||||
const html = '<div id="header">junk</div><p>body</p>';
|
||||
const { $, $root } = makeRoot(html);
|
||||
const before = $root.html();
|
||||
|
||||
xwikiFormatter($, $root);
|
||||
|
||||
expect($root.html()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultHtmlFormatter', () => {
|
||||
it('replaces a recognized provider anchor with a data-type="embed" div', () => {
|
||||
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
const { $, $root } = makeRoot(`<a href="${url}">video</a>`);
|
||||
|
||||
defaultHtmlFormatter($, $root);
|
||||
|
||||
const $embed = $root.find('div[data-type="embed"]');
|
||||
expect($embed.length).toBe(1);
|
||||
expect($embed.attr('data-provider')).toBe('youtube');
|
||||
expect($embed.attr('data-src')).toBe(url);
|
||||
// the anchor is gone
|
||||
expect($root.find('a').length).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves an anchor as a link when provider resolves to iframe', () => {
|
||||
// A plain non-provider URL falls through to the default iframe provider,
|
||||
// which the formatter explicitly skips.
|
||||
const url = 'https://example.com/some/page';
|
||||
const { $, $root } = makeRoot(`<a href="${url}">site</a>`);
|
||||
|
||||
defaultHtmlFormatter($, $root);
|
||||
|
||||
expect($root.find('div[data-type="embed"]').length).toBe(0);
|
||||
const $a = $root.find('a');
|
||||
expect($a.length).toBe(1);
|
||||
expect($a.attr('href')).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapFromParagraph', () => {
|
||||
it('replaces the wrapper entirely when the node is the only child of a <p>', () => {
|
||||
const { $, $root } = makeRoot('<p><img src="x.png"></p>');
|
||||
const $node = $root.find('img');
|
||||
|
||||
unwrapFromParagraph($, $node);
|
||||
|
||||
// the <p> wrapper is gone, the img is hoisted to the root
|
||||
expect($root.find('p').length).toBe(0);
|
||||
expect($root.find('img').length).toBe(1);
|
||||
});
|
||||
|
||||
it('moves the node before the wrapper when there are sibling contents', () => {
|
||||
const { $, $root } = makeRoot('<p>text before <img src="x.png"></p>');
|
||||
const $node = $root.find('img');
|
||||
|
||||
unwrapFromParagraph($, $node);
|
||||
|
||||
// img moved out; the paragraph still holds the sibling text
|
||||
const html = $root.html() || '';
|
||||
// img must appear before the paragraph in document order
|
||||
const imgIndex = html.indexOf('<img');
|
||||
const pIndex = html.indexOf('<p');
|
||||
expect(imgIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(pIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(imgIndex).toBeLessThan(pIndex);
|
||||
expect($root.find('p').text()).toContain('text before');
|
||||
});
|
||||
|
||||
it('returns (does not infinite-loop) on adversarial nesting', () => {
|
||||
// Node wrapped in nested <a> and <p> wrappers.
|
||||
const { $, $root } = makeRoot(
|
||||
'<p><a href="#"><img src="x.png"></a></p>',
|
||||
);
|
||||
const $node = $root.find('img');
|
||||
|
||||
// If unwrapFromParagraph looped forever this call would hang the test.
|
||||
expect(() => unwrapFromParagraph($, $node)).not.toThrow();
|
||||
// It fully unwrapped: no surrounding p/a left around the img.
|
||||
expect($node.closest('p, a').length).toBe(0);
|
||||
expect($root.find('img').length).toBe(1);
|
||||
});
|
||||
});
|
||||
137
apps/server/src/integrations/import/utils/import.utils.spec.ts
Normal file
137
apps/server/src/integrations/import/utils/import.utils.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
stripNotionID,
|
||||
extractNotionPartialId,
|
||||
resolveRelativeAttachmentPath,
|
||||
} from './import.utils';
|
||||
|
||||
/**
|
||||
* Unit tests for the pure helpers in import.utils.ts:
|
||||
* - stripNotionID / extractNotionPartialId: filename suffix parsing.
|
||||
* - resolveRelativeAttachmentPath: maps an HTML-relative attachment href onto
|
||||
* a key that exists in the extracted-archive candidate map.
|
||||
*/
|
||||
|
||||
describe('stripNotionID', () => {
|
||||
it('strips a 32-hex suffix preceded by a space separator', () => {
|
||||
// 32 hex chars with a leading space.
|
||||
const id = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||
expect(stripNotionID(`My Page ${id}`)).toBe('My Page');
|
||||
});
|
||||
|
||||
it('strips a 32-hex suffix preceded by a dash separator', () => {
|
||||
const id = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||
expect(stripNotionID(`My-Page-${id}`)).toBe('My-Page');
|
||||
});
|
||||
|
||||
it('strips a 32-hex suffix with no separator', () => {
|
||||
const id = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||
expect(stripNotionID(`MyPage${id}`)).toBe('MyPage');
|
||||
});
|
||||
|
||||
it('strips a partial UUID suffix "{4}-{4}"', () => {
|
||||
expect(stripNotionID('Cool 324d-35ab')).toBe('Cool');
|
||||
});
|
||||
|
||||
it('leaves a name without an ID unchanged', () => {
|
||||
expect(stripNotionID('Just A Title')).toBe('Just A Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNotionPartialId', () => {
|
||||
it('returns prefix/suffix (lowercased) for a partial UUID folder name', () => {
|
||||
expect(extractNotionPartialId('Cool 324D-35AB')).toEqual({
|
||||
prefix: '324d',
|
||||
suffix: '35ab',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when there is no partial UUID suffix', () => {
|
||||
expect(extractNotionPartialId('No Id Here')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the suffix lacks the leading space', () => {
|
||||
// The regex requires a leading space before "{4}-{4}".
|
||||
expect(extractNotionPartialId('Name324d-35ab')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRelativeAttachmentPath', () => {
|
||||
it('returns the direct candidate when it exists', () => {
|
||||
const candidates = new Map<string, string>([
|
||||
['attachments/file.png', '/abs/attachments/file.png'],
|
||||
]);
|
||||
expect(
|
||||
resolveRelativeAttachmentPath(
|
||||
'./attachments/file.png',
|
||||
'pages',
|
||||
candidates,
|
||||
),
|
||||
).toBe('attachments/file.png');
|
||||
});
|
||||
|
||||
it('strips the Confluence "download/attachments/" prefix to match the archive layout', () => {
|
||||
const candidates = new Map<string, string>([
|
||||
['attachments/123/diagram.png', '/abs/attachments/123/diagram.png'],
|
||||
]);
|
||||
expect(
|
||||
resolveRelativeAttachmentPath(
|
||||
'download/attachments/123/diagram.png',
|
||||
'pages',
|
||||
candidates,
|
||||
),
|
||||
).toBe('attachments/123/diagram.png');
|
||||
});
|
||||
|
||||
it('decodes a percent-encoded name before matching', () => {
|
||||
const candidates = new Map<string, string>([
|
||||
['attachments/my file.png', '/abs/attachments/my file.png'],
|
||||
]);
|
||||
expect(
|
||||
resolveRelativeAttachmentPath(
|
||||
'attachments/my%20file.png',
|
||||
'pages',
|
||||
candidates,
|
||||
),
|
||||
).toBe('attachments/my file.png');
|
||||
});
|
||||
|
||||
it('falls back to the raw (still-encoded) value on a malformed escape without throwing', () => {
|
||||
// "%E0%A4" is an incomplete UTF-8 sequence; decodeURIComponent throws and
|
||||
// the helper keeps the raw string, which then matches the candidate key.
|
||||
const candidates = new Map<string, string>([
|
||||
['attachments/%E0%A4.png', '/abs/attachments/%E0%A4.png'],
|
||||
]);
|
||||
let result: string | null = null;
|
||||
expect(() => {
|
||||
result = resolveRelativeAttachmentPath(
|
||||
'attachments/%E0%A4.png',
|
||||
'pages',
|
||||
candidates,
|
||||
);
|
||||
}).not.toThrow();
|
||||
expect(result).toBe('attachments/%E0%A4.png');
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
const candidates = new Map<string, string>([
|
||||
['attachments/other.png', '/abs/attachments/other.png'],
|
||||
]);
|
||||
expect(
|
||||
resolveRelativeAttachmentPath(
|
||||
'./attachments/missing.png',
|
||||
'pages',
|
||||
candidates,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('matches via the pageDir-joined fallback path', () => {
|
||||
// raw resolves under pageDir when neither the direct nor confluence key hit.
|
||||
const candidates = new Map<string, string>([
|
||||
['pages/sub/img.png', '/abs/pages/sub/img.png'],
|
||||
]);
|
||||
expect(
|
||||
resolveRelativeAttachmentPath('sub/img.png', 'pages', candidates),
|
||||
).toBe('pages/sub/img.png');
|
||||
});
|
||||
});
|
||||
105
apps/server/src/integrations/import/utils/table-utils.spec.ts
Normal file
105
apps/server/src/integrations/import/utils/table-utils.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { load, CheerioAPI, Cheerio } from 'cheerio';
|
||||
import { normalizeTableColumnWidths } from './table-utils';
|
||||
|
||||
/**
|
||||
* Unit tests for normalizeTableColumnWidths: it writes a `colwidth` attribute
|
||||
* onto the first-row cells of every <table>, deriving widths from a <colgroup>
|
||||
* or the first row, accounting for colspan, and falling back to a default
|
||||
* per-column width (150px) when no pixel widths are present. Re-running the
|
||||
* transform on its own output must be a no-op (idempotent).
|
||||
*/
|
||||
|
||||
const DEFAULT = 150;
|
||||
|
||||
function run(html: string): { $: CheerioAPI; $root: Cheerio<any> } {
|
||||
const $ = load(html);
|
||||
const $root = $.root();
|
||||
normalizeTableColumnWidths($, $root);
|
||||
return { $, $root };
|
||||
}
|
||||
|
||||
function firstRowColwidths($root: Cheerio<any>): (string | undefined)[] {
|
||||
return $root
|
||||
.find('table')
|
||||
.first()
|
||||
.find('> tbody > tr, > thead > tr, > tr')
|
||||
.first()
|
||||
.children('td, th')
|
||||
.map((_, el) => (el as any).attribs?.colwidth)
|
||||
.get();
|
||||
}
|
||||
|
||||
describe('normalizeTableColumnWidths', () => {
|
||||
it('applies colgroup <col width> to the first-row cells', () => {
|
||||
const html =
|
||||
'<table>' +
|
||||
'<colgroup><col width="120"><col width="80"></colgroup>' +
|
||||
'<tbody><tr><td>a</td><td>b</td></tr></tbody>' +
|
||||
'</table>';
|
||||
const { $root } = run(html);
|
||||
|
||||
expect(firstRowColwidths($root)).toEqual(['120', '80']);
|
||||
});
|
||||
|
||||
it('falls back to first-row cell widths when there is no colgroup', () => {
|
||||
const html =
|
||||
'<table><tbody>' +
|
||||
'<tr><td style="width: 200px">a</td><td width="90">b</td></tr>' +
|
||||
'</tbody></table>';
|
||||
const { $root } = run(html);
|
||||
|
||||
expect(firstRowColwidths($root)).toEqual(['200', '90']);
|
||||
});
|
||||
|
||||
it('splits a colspan width across the spanned columns', () => {
|
||||
// colspan=2 with width 100 => each derived column ~50, the spanning cell
|
||||
// then gets the joined slice "50,50".
|
||||
const html =
|
||||
'<table><tbody>' +
|
||||
'<tr><td colspan="2" width="100">merged</td></tr>' +
|
||||
'</tbody></table>';
|
||||
const { $root } = run(html);
|
||||
|
||||
expect(firstRowColwidths($root)).toEqual(['50,50']);
|
||||
});
|
||||
|
||||
it('ignores em/% widths (treated as no width) and applies the default', () => {
|
||||
const html =
|
||||
'<table><tbody>' +
|
||||
'<tr><td style="width: 10em">a</td><td style="width: 50%">b</td></tr>' +
|
||||
'</tbody></table>';
|
||||
const { $root } = run(html);
|
||||
|
||||
expect(firstRowColwidths($root)).toEqual([String(DEFAULT), String(DEFAULT)]);
|
||||
});
|
||||
|
||||
it('applies the default per-column width to a markdown-style table with no widths', () => {
|
||||
const html =
|
||||
'<table><tbody>' +
|
||||
'<tr><td>a</td><td>b</td><td>c</td></tr>' +
|
||||
'<tr><td>1</td><td>2</td><td>3</td></tr>' +
|
||||
'</tbody></table>';
|
||||
const { $root } = run(html);
|
||||
|
||||
expect(firstRowColwidths($root)).toEqual([
|
||||
String(DEFAULT),
|
||||
String(DEFAULT),
|
||||
String(DEFAULT),
|
||||
]);
|
||||
});
|
||||
|
||||
it('is idempotent: re-running on its own output changes nothing', () => {
|
||||
const html =
|
||||
'<table>' +
|
||||
'<colgroup><col width="120"><col width="80"></colgroup>' +
|
||||
'<tbody><tr><td>a</td><td>b</td></tr></tbody>' +
|
||||
'</table>';
|
||||
const { $, $root } = run(html);
|
||||
const afterFirst = $root.html();
|
||||
|
||||
// second pass
|
||||
normalizeTableColumnWidths($, $root);
|
||||
expect($root.html()).toBe(afterFirst);
|
||||
expect(firstRowColwidths($root)).toEqual(['120', '80']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveFootnoteId } from "./footnote-util";
|
||||
|
||||
/**
|
||||
* GOLDEN TABLE for `deriveFootnoteId` (and its private alphabetic `suffix`).
|
||||
*
|
||||
* deriveFootnoteId is DELIBERATELY duplicated in
|
||||
* packages/mcp/src/lib/collaboration.ts
|
||||
* and the two copies MUST stay byte-for-byte equivalent in behavior so the same
|
||||
* markdown imported through the editor and through the MCP path yields identical
|
||||
* footnote ids. This table is the SHARED contract: the parity test
|
||||
* packages/mcp/test/unit/derive-id-parity.test.mjs
|
||||
* pins the exact SAME (input -> expected) pairs against the COMPILED mcp build.
|
||||
* If either copy drifts, one of the two tests goes red.
|
||||
*
|
||||
* Keep this constant in sync with GOLDEN in the mcp parity test.
|
||||
*/
|
||||
export const DERIVE_GOLDEN: Array<{
|
||||
originalId: string;
|
||||
occurrence: number;
|
||||
taken: string[];
|
||||
expected: string;
|
||||
why: string;
|
||||
}> = [
|
||||
// Base candidate `${id}__${occurrence}` when nothing collides.
|
||||
{ originalId: "d", occurrence: 2, taken: [], expected: "d__2", why: "plain base, second occurrence" },
|
||||
{ originalId: "d", occurrence: 3, taken: [], expected: "d__3", why: "plain base, third occurrence" },
|
||||
// The base is taken -> first alphabetic bump is "b" (NOT "a": suffix starts at 'b').
|
||||
{ originalId: "d", occurrence: 2, taken: ["d__2"], expected: "d__2b", why: "base taken -> first bump 'b'" },
|
||||
// Base + first bump taken -> "c".
|
||||
{ originalId: "d", occurrence: 2, taken: ["d__2", "d__2b"], expected: "d__2c", why: "base+b taken -> 'c'" },
|
||||
// A non-contiguous taken set still walks deterministically to the first free slot.
|
||||
{
|
||||
originalId: "d",
|
||||
occurrence: 2,
|
||||
taken: ["d__2", "d__2b", "d__2c", "d__2d"],
|
||||
expected: "d__2e",
|
||||
why: "base + b,c,d taken -> 'e'",
|
||||
},
|
||||
// >25 bump: base + b..z (the 25 single-letter suffixes) all taken -> "bb".
|
||||
// suffix(26) === "bb" (base-25 over b..z, carrying to a two-letter suffix).
|
||||
{
|
||||
originalId: "d",
|
||||
occurrence: 2,
|
||||
taken: ["d__2", ...singleLetterSuffixes().map((s) => `d__2${s}`)],
|
||||
expected: "d__2bb",
|
||||
why: ">25 collisions -> two-letter suffix 'bb'",
|
||||
},
|
||||
];
|
||||
|
||||
/** The 25 single-letter suffixes the scheme uses: b, c, ..., z (n = 1..25). */
|
||||
function singleLetterSuffixes(): string[] {
|
||||
// Mirror of the production suffix() for n in 1..25 (all single letters).
|
||||
// n=1 -> 'b' ... n=25 -> 'z'. Used only to BUILD the taken-set for the
|
||||
// >25 row; the EXPECTED value (d__2bb) is asserted against the real function.
|
||||
return Array.from({ length: 25 }, (_, i) => String.fromCharCode(98 + i));
|
||||
}
|
||||
|
||||
describe("deriveFootnoteId golden table (cross-package drift guard)", () => {
|
||||
for (const row of DERIVE_GOLDEN) {
|
||||
it(`derive("${row.originalId}", ${row.occurrence}, {${row.taken.join(",")}}) === "${row.expected}" — ${row.why}`, () => {
|
||||
const got = deriveFootnoteId(
|
||||
row.originalId,
|
||||
row.occurrence,
|
||||
new Set(row.taken),
|
||||
);
|
||||
expect(got).toBe(row.expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("the >25 row's taken-set really contains b..z (25 single letters) plus the base", () => {
|
||||
// Sanity-pin the construction so a typo in singleLetterSuffixes() cannot make
|
||||
// the >25 assertion pass for the wrong reason.
|
||||
const letters = singleLetterSuffixes();
|
||||
expect(letters).toHaveLength(25);
|
||||
expect(letters[0]).toBe("b");
|
||||
expect(letters[24]).toBe("z");
|
||||
});
|
||||
|
||||
it("is a PURE function: it never mutates the taken set it is given", () => {
|
||||
const taken = new Set(["d__2"]);
|
||||
const before = [...taken];
|
||||
deriveFootnoteId("d", 2, taken);
|
||||
expect([...taken]).toEqual(before);
|
||||
});
|
||||
|
||||
it("is deterministic: same input -> same output across calls", () => {
|
||||
const mk = () => new Set(["d__2", "d__2b"]);
|
||||
expect(deriveFootnoteId("d", 2, mk())).toBe(deriveFootnoteId("d", 2, mk()));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseHtmlEmbedHeight,
|
||||
renderHtmlEmbedHeight,
|
||||
} from "./html-embed";
|
||||
|
||||
/**
|
||||
* PIN the CURRENT behavior of `parseHtmlEmbedHeight` for crafted/corrupt
|
||||
* `data-height` attribute values. The function is a thin parseInt + Number.isFinite
|
||||
* guard; these tests document EXACTLY what it does today (including the cases
|
||||
* where today's behavior is arguably wrong) so any future change is a conscious
|
||||
* one and shows up as a failing test rather than a silent regression.
|
||||
*/
|
||||
describe("parseHtmlEmbedHeight: crafted / corrupt data-height", () => {
|
||||
it('"-5" passes through as -5 (DOCUMENTED QUIRK: negative height is not rejected)', () => {
|
||||
// Number.isFinite(-5) is true, so the guard does NOT catch it. A negative
|
||||
// fixed height is almost certainly wrong downstream (it disables auto-resize
|
||||
// and yields a negative/clamped iframe height), but the function as written
|
||||
// returns it verbatim. This asserts the REAL behavior, not the ideal one.
|
||||
expect(parseHtmlEmbedHeight("-5")).toBe(-5);
|
||||
});
|
||||
|
||||
it('"0" returns 0 (NOT null) — note: renderHtmlEmbedHeight treats 0 as auto-resize, so parse/render are asymmetric at 0', () => {
|
||||
// parseInt("0") === 0 and Number.isFinite(0) is true, so parse keeps 0.
|
||||
expect(parseHtmlEmbedHeight("0")).toBe(0);
|
||||
// But the render side treats a falsy 0 as "auto-resize" => emits NO attribute.
|
||||
// So a stored height of 0 does not round-trip back to data-height="0".
|
||||
expect(renderHtmlEmbedHeight(0)).toEqual({});
|
||||
});
|
||||
|
||||
it('" 300 " (surrounding whitespace) parses to 300 — parseInt trims leading space', () => {
|
||||
expect(parseHtmlEmbedHeight(" 300 ")).toBe(300);
|
||||
});
|
||||
|
||||
it('"3.9" truncates to 3 — parseInt drops the fractional part', () => {
|
||||
expect(parseHtmlEmbedHeight("3.9")).toBe(3);
|
||||
});
|
||||
|
||||
it('a huge "99999999999" passes through unclamped (finite => no upper bound here)', () => {
|
||||
// The guard only rejects NaN/Infinity; it does not clamp magnitude. Any
|
||||
// clamping is a downstream concern, NOT this function's job.
|
||||
expect(parseHtmlEmbedHeight("99999999999")).toBe(99999999999);
|
||||
});
|
||||
|
||||
it('"12px" parses the leading integer (12) — parseInt stops at the first non-digit', () => {
|
||||
expect(parseHtmlEmbedHeight("12px")).toBe(12);
|
||||
});
|
||||
|
||||
it("null / empty / whitespace-only / non-numeric => null (the auto-resize sentinel)", () => {
|
||||
expect(parseHtmlEmbedHeight(null)).toBeNull();
|
||||
expect(parseHtmlEmbedHeight("")).toBeNull();
|
||||
expect(parseHtmlEmbedHeight(" ")).toBeNull();
|
||||
expect(parseHtmlEmbedHeight("abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("never returns NaN for a non-numeric value (the Number.isFinite guard's point)", () => {
|
||||
// NaN is typeof "number" and would slip past a naive `typeof n === number`
|
||||
// check; the guard must map it to null. This is the core invariant.
|
||||
const out = parseHtmlEmbedHeight("not-a-number");
|
||||
expect(out).toBeNull();
|
||||
expect(Number.isNaN(out as unknown as number)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractFootnoteDefinitions } from "./footnote.marked";
|
||||
|
||||
/** Pull the ordered list of `data-footnote-def` ids out of the rendered section. */
|
||||
function defIds(section: string): string[] {
|
||||
return [...section.matchAll(/data-footnote-def data-id="([^"]+)"/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
}
|
||||
|
||||
/** Pull the ordered list of `[^id]` markers that remain in the body. */
|
||||
function bodyMarkers(body: string): string[] {
|
||||
return [...body.matchAll(/\[\^([^\]\s]+)\]/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
describe("extractFootnoteDefinitions: more definitions than markers (orphans)", () => {
|
||||
// Body has ONE `[^d]` reference marker but THREE `[^d]:` definitions. The
|
||||
// surplus definitions have no marker to pair with — they must NOT be silently
|
||||
// merged into one footnote (the editor's last-wins sync would otherwise drop
|
||||
// two of them). The dedup gives each colliding definition a deterministic
|
||||
// derived id so all three survive as distinct footnoteDefinition nodes.
|
||||
const md = ["See[^d].", "", "[^d]: a", "[^d]: b", "[^d]: c"].join("\n");
|
||||
|
||||
it("emits 3 DISTINCT definition ids: d, d__2, d__3 (derived scheme, in order)", () => {
|
||||
const { section } = extractFootnoteDefinitions(md);
|
||||
const ids = defIds(section);
|
||||
expect(ids).toEqual(["d", "d__2", "d__3"]);
|
||||
// All distinct: nothing was merged away.
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
});
|
||||
|
||||
it("preserves each definition's text against its (possibly derived) id", () => {
|
||||
const { section } = extractFootnoteDefinitions(md);
|
||||
// First definition keeps the original id and its text.
|
||||
expect(section).toContain('data-footnote-def data-id="d"><p>a</p>');
|
||||
// The two surplus definitions survive as orphans with derived ids.
|
||||
expect(section).toContain('data-footnote-def data-id="d__2"><p>b</p>');
|
||||
expect(section).toContain('data-footnote-def data-id="d__3"><p>c</p>');
|
||||
});
|
||||
|
||||
it("leaves the SINGLE body marker as [^d] (no surplus marker to rewrite)", () => {
|
||||
const { body } = extractFootnoteDefinitions(md);
|
||||
// There is exactly one reference marker and it is untouched: the keeper
|
||||
// definition pairs with it. The orphan defs have no marker, so the body is
|
||||
// unchanged except for the stripped definition lines.
|
||||
expect(bodyMarkers(body)).toEqual(["d"]);
|
||||
expect(body).toContain("See[^d].");
|
||||
// The definition lines themselves were pulled OUT of the body.
|
||||
expect(body).not.toContain("[^d]: a");
|
||||
expect(body).not.toContain("[^d]: b");
|
||||
expect(body).not.toContain("[^d]: c");
|
||||
});
|
||||
|
||||
it("does not crash and produces a well-formed footnotes section", () => {
|
||||
const { section } = extractFootnoteDefinitions(md);
|
||||
expect(section.startsWith("<section data-footnotes>")).toBe(true);
|
||||
expect(section.endsWith("</section>")).toBe(true);
|
||||
// Exactly three definition divs.
|
||||
expect(
|
||||
[...section.matchAll(/<div data-footnote-def/g)],
|
||||
).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
134
packages/mcp/test/unit/derive-id-parity.test.mjs
Normal file
134
packages/mcp/test/unit/derive-id-parity.test.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { markdownToProseMirror } from "../../build/lib/collaboration.js";
|
||||
|
||||
/**
|
||||
* CROSS-PACKAGE DRIFT GUARD for the footnote id derivation scheme.
|
||||
*
|
||||
* `deriveFootnoteId` is duplicated in two places that MUST behave identically:
|
||||
* - packages/editor-ext/src/lib/footnote/footnote-util.ts (exported)
|
||||
* - packages/mcp/src/lib/collaboration.ts (internal helper)
|
||||
* so the same markdown imported through the editor and through the MCP path
|
||||
* derives identical footnote ids.
|
||||
*
|
||||
* The mcp copy is NOT exported from the compiled build (it is an internal helper
|
||||
* of collaboration.js), and production source must not be modified to export it.
|
||||
* So this test exercises the REAL compiled `deriveFootnoteId` *indirectly*, the
|
||||
* same way production does: through `markdownToProseMirror`, which runs
|
||||
* extractFootnotes -> deriveFootnoteId during duplicate-id dedup. We craft the
|
||||
* `taken` set via literal pre-existing definition ids and read back the derived
|
||||
* footnoteDefinition ids.
|
||||
*
|
||||
* GOLDEN below mirrors DERIVE_GOLDEN in
|
||||
* packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts
|
||||
* (asserted there by a DIRECT call). Same (originalId, occurrence, taken) ->
|
||||
* same expected id. If the two copies drift, one of the two suites goes red.
|
||||
*/
|
||||
|
||||
/** The 25 single-letter suffixes the scheme uses (n=1..25): b, c, ..., z. */
|
||||
function singleLetterSuffixes() {
|
||||
return Array.from({ length: 25 }, (_, i) => String.fromCharCode(98 + i));
|
||||
}
|
||||
|
||||
// Identical matrix + expected values to the editor-ext golden table.
|
||||
const GOLDEN = [
|
||||
{ originalId: "d", occurrence: 2, taken: [], expected: "d__2" },
|
||||
{ originalId: "d", occurrence: 3, taken: [], expected: "d__3" },
|
||||
{ originalId: "d", occurrence: 2, taken: ["d__2"], expected: "d__2b" },
|
||||
{ originalId: "d", occurrence: 2, taken: ["d__2", "d__2b"], expected: "d__2c" },
|
||||
{
|
||||
originalId: "d",
|
||||
occurrence: 2,
|
||||
taken: ["d__2", "d__2b", "d__2c", "d__2d"],
|
||||
expected: "d__2e",
|
||||
},
|
||||
{
|
||||
originalId: "d",
|
||||
occurrence: 2,
|
||||
taken: ["d__2", ...singleLetterSuffixes().map((s) => `d__2${s}`)],
|
||||
expected: "d__2bb",
|
||||
},
|
||||
];
|
||||
|
||||
/** Recursively collect every node of `type`. */
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build markdown that drives the real `deriveFootnoteId(originalId, occurrence,
|
||||
* taken)`:
|
||||
* - `occurrence` duplicate definitions of `[^originalId]` so the dedup walk
|
||||
* reaches the requested occurrence (occurrence=2 -> 1 keeper + 1 duplicate;
|
||||
* occurrence=3 -> keeper + 2 duplicates, of which the LAST is the one whose
|
||||
* id we read);
|
||||
* - one literal pre-existing definition for every id in `taken`, each with its
|
||||
* own reference marker so it is a real (non-orphan) definition. Those ids are
|
||||
* reserved up-front in the dedup `taken` set, exactly forcing the bump.
|
||||
*
|
||||
* Returns the derived id of the FINAL duplicate of `originalId`.
|
||||
*/
|
||||
async function deriveViaMarkdown(originalId, occurrence, takenIds) {
|
||||
// References: one [^originalId] per definition (keeper + duplicates) so each
|
||||
// duplicate has a marker to pair with, plus one marker per taken id.
|
||||
const dupCount = occurrence; // keeper + (occurrence-1) duplicates = `occurrence` defs
|
||||
const refMarkers = [];
|
||||
for (let i = 0; i < dupCount; i++) refMarkers.push(`[^${originalId}]`);
|
||||
for (const id of takenIds) refMarkers.push(`[^${id}]`);
|
||||
const refLine = `Body ${refMarkers.join(" ")}.`;
|
||||
|
||||
// Definitions: `occurrence` copies of [^originalId]: ... then the taken ids.
|
||||
const defLines = [];
|
||||
for (let i = 0; i < dupCount; i++) {
|
||||
defLines.push(`[^${originalId}]: copy ${i}`);
|
||||
}
|
||||
for (const id of takenIds) {
|
||||
defLines.push(`[^${id}]: reserved ${id}`);
|
||||
}
|
||||
|
||||
const md = [refLine, "", ...defLines].join("\n");
|
||||
const json = await markdownToProseMirror(md);
|
||||
const defIds = findAll(json, "footnoteDefinition").map((d) => d.attrs.id);
|
||||
|
||||
// The derived id we want is the one that is neither the keeper (originalId),
|
||||
// nor any reserved taken id, nor a lower-occurrence derived id. For
|
||||
// occurrence=2 that is the single bumped id; for occurrence=3 it is the
|
||||
// highest `${originalId}__3...` id. Compute it generically: among the def ids
|
||||
// that start with `${originalId}__${occurrence}`, the expected one is present.
|
||||
return { defIds, json };
|
||||
}
|
||||
|
||||
for (const row of GOLDEN) {
|
||||
test(`parity: derive("${row.originalId}", ${row.occurrence}, {${row.taken.join(",")}}) -> "${row.expected}"`, async () => {
|
||||
const { defIds } = await deriveViaMarkdown(
|
||||
row.originalId,
|
||||
row.occurrence,
|
||||
row.taken,
|
||||
);
|
||||
// The real compiled deriveFootnoteId must have minted exactly the golden id.
|
||||
assert.ok(
|
||||
defIds.includes(row.expected),
|
||||
`expected derived id "${row.expected}" among def ids ${JSON.stringify(defIds)}`,
|
||||
);
|
||||
// And every id is distinct: nothing collapsed.
|
||||
assert.equal(new Set(defIds).size, defIds.length, "all def ids distinct");
|
||||
});
|
||||
}
|
||||
|
||||
test("parity: the simple keeper+two-duplicate case mints d, d__2, d__3", async () => {
|
||||
// The canonical no-collision path, asserted as a whole set for clarity.
|
||||
const md = [
|
||||
"See[^d] one[^d] two[^d].",
|
||||
"",
|
||||
"[^d]: first",
|
||||
"[^d]: second",
|
||||
"[^d]: third",
|
||||
].join("\n");
|
||||
const json = await markdownToProseMirror(md);
|
||||
const defIds = findAll(json, "footnoteDefinition").map((d) => d.attrs.id);
|
||||
assert.deepEqual([...defIds].sort(), ["d", "d__2", "d__3"]);
|
||||
});
|
||||
88
packages/mcp/test/unit/diff-reorder.test.mjs
Normal file
88
packages/mcp/test/unit/diff-reorder.test.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { diffDocs, summarizeChange } from "../../build/lib/diff.js";
|
||||
|
||||
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
|
||||
const para = (s) => ({ type: "paragraph", content: [t(s)] });
|
||||
const doc = (...c) => ({ type: "doc", content: c });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block REORDER (A,B -> B,A): the two documents contain the SAME blocks in a
|
||||
// different order. A naive set-based comparison would call this "no content
|
||||
// change" (the multiset of blocks is identical), which is wrong: the reader's
|
||||
// document order changed. The changeset-based diff must report it as a real
|
||||
// change and the integrity-/value-based summary must NOT claim "no content
|
||||
// change".
|
||||
// ---------------------------------------------------------------------------
|
||||
const A = para("Alpha paragraph content one");
|
||||
const B = para("Beta paragraph content two");
|
||||
const before = doc(A, B);
|
||||
const after = doc(B, A); // identical blocks, swapped order
|
||||
|
||||
test("diffDocs on a block swap does NOT report 'no textual changes'", () => {
|
||||
const r = diffDocs(before, after);
|
||||
assert.doesNotMatch(
|
||||
r.markdown,
|
||||
/no textual changes/i,
|
||||
"a reorder is a content change, not a no-op",
|
||||
);
|
||||
// The reorder surfaces as both an insertion and a deletion (text moved).
|
||||
assert.ok(r.summary.inserted > 0, "reports inserted chars");
|
||||
assert.ok(r.summary.deleted > 0, "reports deleted chars");
|
||||
const ops = new Set(r.changes.map((c) => c.op));
|
||||
assert.ok(ops.has("insert") && ops.has("delete"), "has both insert and delete changes");
|
||||
});
|
||||
|
||||
test("diffDocs reorder: summary fields are coherent (blocksChanged > 0, counts > 0)", () => {
|
||||
const r = diffDocs(before, after);
|
||||
assert.ok(r.summary.blocksChanged > 0, "blocksChanged must be positive for a reorder");
|
||||
// Symmetric move: the moved text is both inserted and deleted, so the two
|
||||
// counts are equal. (The diff algorithm chooses ONE of the two equal-status
|
||||
// blocks to represent as "moved", so we assert the count equals one of the
|
||||
// block lengths rather than hard-coding which block moved.)
|
||||
assert.equal(
|
||||
r.summary.inserted,
|
||||
r.summary.deleted,
|
||||
"a pure move inserts and deletes the same number of chars",
|
||||
);
|
||||
const blockLens = ["Alpha paragraph content one".length, "Beta paragraph content two".length];
|
||||
assert.ok(
|
||||
blockLens.includes(r.summary.inserted),
|
||||
`moved char count ${r.summary.inserted} should equal one of the block lengths ${JSON.stringify(blockLens)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("summarizeChange on a block swap reports changed:true, NOT 'no content change'", () => {
|
||||
const rep = summarizeChange(before, after);
|
||||
assert.equal(rep.changed, true, "a reorder is a change");
|
||||
assert.notEqual(rep.summary, "no content change");
|
||||
assert.match(rep.summary, /^changed:/, "summary is a 'changed: ...' line");
|
||||
// blocksChanged is coherent with diffDocs.
|
||||
assert.ok(rep.blocksChanged > 0, "blocksChanged > 0");
|
||||
assert.equal(rep.textInserted, rep.textDeleted, "symmetric move");
|
||||
assert.ok(rep.textInserted > 0, "text counts > 0");
|
||||
});
|
||||
|
||||
test("control: an IDENTICAL doc (no reorder) reports no content change", () => {
|
||||
// Guards the reorder assertions from being vacuously true: the same docs in
|
||||
// the SAME order must still cleanly report no change.
|
||||
const rep = summarizeChange(before, before);
|
||||
assert.equal(rep.changed, false);
|
||||
assert.equal(rep.summary, "no content change");
|
||||
const r = diffDocs(before, before);
|
||||
assert.equal(r.summary.blocksChanged, 0);
|
||||
assert.equal(r.changes.length, 0);
|
||||
});
|
||||
|
||||
test("a three-block rotation (A,B,C -> C,A,B) is reported as a change", () => {
|
||||
const C = para("Gamma paragraph content three");
|
||||
const d1 = doc(A, B, C);
|
||||
const d2 = doc(C, A, B);
|
||||
const rep = summarizeChange(d1, d2);
|
||||
assert.equal(rep.changed, true);
|
||||
assert.notEqual(rep.summary, "no content change");
|
||||
const r = diffDocs(d1, d2);
|
||||
assert.ok(r.summary.blocksChanged > 0);
|
||||
assert.doesNotMatch(r.markdown, /no textual changes/i);
|
||||
});
|
||||
146
packages/mcp/test/unit/json-edit-idempotency.test.mjs
Normal file
146
packages/mcp/test/unit/json-edit-idempotency.test.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyTextEdits } from "../../build/lib/json-edit.js";
|
||||
|
||||
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
|
||||
const para = (...c) => ({ type: "paragraph", content: c });
|
||||
const doc = (...c) => ({ type: "doc", content: c });
|
||||
|
||||
/** Recursively collect every node of `type`. */
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotency: a second application of an edit whose `find` was consumed by the
|
||||
// first application is a no-op. It must (a) report the edit as failed/not-found
|
||||
// and (b) leave the document byte-for-byte identical to the first output — i.e.
|
||||
// no double-apply, no accidental re-match against the inserted replacement.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("re-applying a consumed edit is a no-op: reports not-found AND output is deep-equal to the first apply", () => {
|
||||
const d0 = doc(para(t("the quick brown fox")));
|
||||
|
||||
const first = applyTextEdits(d0, [{ find: "quick", replace: "slow" }]);
|
||||
// First run applied cleanly.
|
||||
assert.equal(first.failed.length, 0, "first apply has no failures");
|
||||
assert.deepEqual(
|
||||
first.results,
|
||||
[{ find: "quick", replacements: 1 }],
|
||||
"first apply replaced exactly once",
|
||||
);
|
||||
assert.equal(
|
||||
findAll(first.doc, "text")[0].text,
|
||||
"the slow brown fox",
|
||||
"first apply produced the replaced text",
|
||||
);
|
||||
|
||||
// Second run: `quick` no longer exists; the replacement `slow` must NOT be a
|
||||
// new target. Edit goes to failed[], nothing applied.
|
||||
const second = applyTextEdits(first.doc, [{ find: "quick", replace: "slow" }]);
|
||||
assert.equal(second.results.length, 0, "second apply changes nothing");
|
||||
assert.equal(second.failed.length, 1, "second apply records one failure");
|
||||
assert.equal(second.failed[0].find, "quick");
|
||||
assert.match(second.failed[0].reason, /not found/i, "not-found reason");
|
||||
|
||||
// IDEMPOTENCY: second output deep-equals the first output (no double-apply).
|
||||
assert.deepEqual(
|
||||
second.doc,
|
||||
first.doc,
|
||||
"re-running the consumed edit must not mutate the document",
|
||||
);
|
||||
});
|
||||
|
||||
test("idempotency holds for replaceAll too: second run is not-found and output is stable", () => {
|
||||
const d0 = doc(para(t("ab ab ab")));
|
||||
const first = applyTextEdits(d0, [{ find: "ab", replace: "X", replaceAll: true }]);
|
||||
assert.deepEqual(first.results, [{ find: "ab", replacements: 3 }]);
|
||||
assert.equal(findAll(first.doc, "text")[0].text, "X X X");
|
||||
|
||||
const second = applyTextEdits(first.doc, [{ find: "ab", replace: "X", replaceAll: true }]);
|
||||
assert.equal(second.results.length, 0);
|
||||
assert.equal(second.failed.length, 1);
|
||||
assert.deepEqual(second.doc, first.doc, "replaceAll re-run is idempotent");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// replaceAll across TWO distinct blocks: the same needle living in a callout
|
||||
// paragraph AND a table cell must be spliced in BOTH, with the replacement
|
||||
// count summed across every block.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("replaceAll splices every block: callout paragraph (2 hits) + table cell (1 hit) = 3", () => {
|
||||
const callout = {
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content: [para(t("alpha here and alpha again"))],
|
||||
};
|
||||
const table = {
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", content: [para(t("alpha in a cell"))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const d0 = doc(callout, table);
|
||||
|
||||
const r = applyTextEdits(d0, [{ find: "alpha", replace: "ZZ", replaceAll: true }]);
|
||||
|
||||
assert.equal(r.failed.length, 0, "no failures");
|
||||
// Count across blocks: 2 in the callout paragraph + 1 in the table cell.
|
||||
assert.deepEqual(r.results, [{ find: "alpha", replacements: 3 }]);
|
||||
|
||||
// Callout paragraph: both occurrences replaced.
|
||||
const calloutPara = r.doc.content[0].content[0];
|
||||
assert.equal(calloutPara.content[0].text, "ZZ here and ZZ again");
|
||||
|
||||
// Table cell (table > tableRow > tableCell > paragraph > text): replaced.
|
||||
const cellPara = r.doc.content[1].content[0].content[0].content[0];
|
||||
assert.equal(cellPara.content[0].text, "ZZ in a cell");
|
||||
|
||||
// No stray "alpha" survives anywhere in the document.
|
||||
const allText = findAll(r.doc, "text").map((n) => n.text).join(" ");
|
||||
assert.doesNotMatch(allText, /alpha/, "every occurrence across blocks was spliced");
|
||||
// Exactly three "ZZ" insertions overall.
|
||||
assert.equal((allText.match(/ZZ/g) || []).length, 3, "three replacements total");
|
||||
});
|
||||
|
||||
test("replaceAll across two blocks preserves surrounding text and ids in each block", () => {
|
||||
const callout = {
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content: [{ type: "paragraph", attrs: { id: "p-callout" }, content: [t("keep alpha keep")] }],
|
||||
};
|
||||
const table = {
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [{ type: "paragraph", attrs: { id: "p-cell" }, content: [t("pre alpha post")] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const d0 = doc(callout, table);
|
||||
|
||||
const r = applyTextEdits(d0, [{ find: "alpha", replace: "beta", replaceAll: true }]);
|
||||
assert.deepEqual(r.results, [{ find: "alpha", replacements: 2 }]);
|
||||
|
||||
const calloutPara = r.doc.content[0].content[0];
|
||||
assert.equal(calloutPara.attrs.id, "p-callout", "block id preserved");
|
||||
assert.equal(calloutPara.content[0].text, "keep beta keep");
|
||||
|
||||
const cellPara = r.doc.content[1].content[0].content[0].content[0];
|
||||
assert.equal(cellPara.attrs.id, "p-cell", "block id preserved");
|
||||
assert.equal(cellPara.content[0].text, "pre beta post");
|
||||
});
|
||||
Reference in New Issue
Block a user