From 0b2af34029b841cefab25e4d27db9f3b32373471 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 18:22:15 +0300 Subject: [PATCH] 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 --- .../ai-chat/utils/chat-markdown.test.ts | 317 ++++++++++++++ .../features/label/utils/label-colors.test.ts | 93 ++++ .../label/utils/normalize-label.test.ts | 47 ++ .../notification/notification.utils.test.ts | 134 ++++++ .../src/features/page/page.utils.test.ts | 99 +++++ apps/client/src/features/share/utils.test.ts | 122 ++++++ .../src/lib/app-route.safe-redirect.test.ts | Bin 0 -> 3375 bytes .../src/integrations/export/utils.spec.ts | 158 +++++++ .../import.service.extract-title.spec.ts | 141 ++++++ .../integrations/import/utils/file.utils.ts | 66 ++- .../utils/file.utils.zip-safety.spec.ts | 105 +++++ .../import/utils/import-formatter.spec.ts | 403 ++++++++++++++++++ .../import/utils/import.utils.spec.ts | 137 ++++++ .../import/utils/table-utils.spec.ts | 105 +++++ .../footnote/footnote-util.derive-id.test.ts | 91 ++++ .../lib/html-embed/html-embed.height.test.ts | 63 +++ .../utils/footnote.marked.orphan.test.ts | 63 +++ .../mcp/test/unit/derive-id-parity.test.mjs | 134 ++++++ packages/mcp/test/unit/diff-reorder.test.mjs | 88 ++++ .../test/unit/json-edit-idempotency.test.mjs | 146 +++++++ 20 files changed, 2495 insertions(+), 17 deletions(-) create mode 100644 apps/client/src/features/ai-chat/utils/chat-markdown.test.ts create mode 100644 apps/client/src/features/label/utils/label-colors.test.ts create mode 100644 apps/client/src/features/label/utils/normalize-label.test.ts create mode 100644 apps/client/src/features/notification/notification.utils.test.ts create mode 100644 apps/client/src/features/page/page.utils.test.ts create mode 100644 apps/client/src/features/share/utils.test.ts create mode 100644 apps/client/src/lib/app-route.safe-redirect.test.ts create mode 100644 apps/server/src/integrations/export/utils.spec.ts create mode 100644 apps/server/src/integrations/import/services/import.service.extract-title.spec.ts create mode 100644 apps/server/src/integrations/import/utils/file.utils.zip-safety.spec.ts create mode 100644 apps/server/src/integrations/import/utils/import-formatter.spec.ts create mode 100644 apps/server/src/integrations/import/utils/import.utils.spec.ts create mode 100644 apps/server/src/integrations/import/utils/table-utils.spec.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts create mode 100644 packages/editor-ext/src/lib/html-embed/html-embed.height.test.ts create mode 100644 packages/editor-ext/src/lib/markdown/utils/footnote.marked.orphan.test.ts create mode 100644 packages/mcp/test/unit/derive-id-parity.test.mjs create mode 100644 packages/mcp/test/unit/diff-reorder.test.mjs create mode 100644 packages/mcp/test/unit/json-edit-idempotency.test.mjs diff --git a/apps/client/src/features/ai-chat/utils/chat-markdown.test.ts b/apps/client/src/features/ai-chat/utils/chat-markdown.test.ts new file mode 100644 index 00000000..57f4c2e4 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/chat-markdown.test.ts @@ -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 => { + if (values && typeof values.name === "string") { + return key.replace("{{name}}", values.name); + } + return key; +}; + +function row(partial: Partial): 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"); + }); +}); diff --git a/apps/client/src/features/label/utils/label-colors.test.ts b/apps/client/src/features/label/utils/label-colors.test.ts new file mode 100644 index 00000000..76ec7f6a --- /dev/null +++ b/apps/client/src/features/label/utils/label-colors.test.ts @@ -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); + }); +}); diff --git a/apps/client/src/features/label/utils/normalize-label.test.ts b/apps/client/src/features/label/utils/normalize-label.test.ts new file mode 100644 index 00000000..3df1c2da --- /dev/null +++ b/apps/client/src/features/label/utils/normalize-label.test.ts @@ -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(""); + }); +}); diff --git a/apps/client/src/features/notification/notification.utils.test.ts b/apps/client/src/features/notification/notification.utils.test.ts new file mode 100644 index 00000000..14d99d0e --- /dev/null +++ b/apps/client/src/features/notification/notification.utils.test.ts @@ -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([]); + }); +}); diff --git a/apps/client/src/features/page/page.utils.test.ts b/apps/client/src/features/page/page.utils.test.ts new file mode 100644 index 00000000..a55054c9 --- /dev/null +++ b/apps/client/src/features/page/page.utils.test.ts @@ -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"); + }); +}); diff --git a/apps/client/src/features/share/utils.test.ts b/apps/client/src/features/share/utils.test.ts new file mode 100644 index 00000000..64661508 --- /dev/null +++ b/apps/client/src/features/share/utils.test.ts @@ -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 & { 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); + }); +}); diff --git a/apps/client/src/lib/app-route.safe-redirect.test.ts b/apps/client/src/lib/app-route.safe-redirect.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e10bbda941d648e31d7f37bae0610ed49de0707b GIT binary patch literal 3375 zcma)9U2fY(5bm>2F=hKe#ggb40h-8F()>6;fYb@%7)4PR@QNIY8!dO4-KAvNFp&4Y z_8h%HA9|NupjYVZE~zl0nm9lZdpSSf%zX0`URFw5xP}Z(s`(6u!0iz5vclBD4dhyt z5G}byW22)z-ijNR;~UJlCb8Gd78}u@62WH)tE!P!wZ*YDq_&sbxw8j%;Pm+E*_#({ zf1QA;P>ytC0OmE**%&UitzJM`8w(n3ttBv!N|#LVE6kiV!%7?}&F5S~uoYf}3BD$lJ|;C=;A+Djup?X{3}cDOg|deRyBHn(Py9Q{39INiLHr zSwFg(-umUjq$8{+gYSpIXE>82P&Z`;C)EFvnrR42CTfJ6G5kX14J^Y;mO9opjr92B z8JT3(+s&!yj`mb6vX}9UI&{;nj-D+Gz1<NrUBV}j;nm{C#90hTRWnIot zyF7S26S#K>H^VQ<3(=8wS^c7XO&y+diCIT!^Y%qeHFI%x)UIhZo%$#zO&d1-LW6c2 zGM1GkE_&%tu|-i&P!S(SLebcXA08guyWjB_B@uN|#GV8eL%XA#xEayUf=LUckx#=q=bAWX za9+}2hU6VvGPeXPJ7KgaYzJ5GTYPuWw~R!uETef(Ab{nTx{tN9v|(J7v`g=i#7jH< zdcYD_f;a^JIrg%EZLyUl9^&1;BfTCHfFhha_~pVx+_$pDh`^Ifd!y!L2yg<6ePJfkxppgG$= zJUDo=ow1J}{^-Mh^6F>{VcD)iGU@!JO1mpKT%Jst6>8eOR4yj{IW`_N7I_Y_j|sk= zPQh)HL3N(`#-XM=SFJD`0zE%) z51}Hx6D<@%EvN{)_P`H=Be&kIvP^(LIVb)(M?S3ZKZRH=#^FnI&iVBbO4msA%BvRb u?MFX(hP9NiO1*SjYB!wwyWu?O!f7n@Fg 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 { + 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 = {}; + + 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 = {}; + + 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'); + }); +}); diff --git a/apps/server/src/integrations/import/services/import.service.extract-title.spec.ts b/apps/server/src/integrations/import/services/import.service.extract-title.spec.ts new file mode 100644 index 00000000..4b136470 --- /dev/null +++ b/apps/server/src/integrations/import/services/import.service.extract-title.spec.ts @@ -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: [] }, + ]); + }); +}); diff --git a/apps/server/src/integrations/import/utils/file.utils.ts b/apps/server/src/integrations/import/utils/file.utils.ts index 6f804210..fa53b88e 100644 --- a/apps/server/src/integrations/import/utils/file.utils.ts +++ b/apps/server/src/integrations/import/utils/file.utils.ts @@ -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 { diff --git a/apps/server/src/integrations/import/utils/file.utils.zip-safety.spec.ts b/apps/server/src/integrations/import/utils/file.utils.zip-safety.spec.ts new file mode 100644 index 00000000..f323278a --- /dev/null +++ b/apps/server/src/integrations/import/utils/file.utils.zip-safety.spec.ts @@ -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 /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')); + }); +}); diff --git a/apps/server/src/integrations/import/utils/import-formatter.spec.ts b/apps/server/src/integrations/import/utils/import-formatter.spec.ts new file mode 100644 index 00000000..6429289a --- /dev/null +++ b/apps/server/src/integrations/import/utils/import-formatter.spec.ts @@ -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 } { + 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([['docs/target.md', meta]]); + const { $, $root } = makeRoot( + 'Design Doc', + ); + + 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([['docs/target.md', meta]]); + const { $, $root } = makeRoot( + 'click here', + ); + + 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([['docs/target.md', meta]]); + const { $, $root } = makeRoot('label'); + + 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(); + const { $, $root } = makeRoot( + 'extapi', + ); + + 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([['docs/%E0%A4%A.md', meta]]); + const { $, $root } = makeRoot('Broken'); + + 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([ + ['docs/a.md', a], + ['docs/b.md', b], + ]); + const { $, $root } = makeRoot( + 'AB', + ); + + 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 = + '
' + + '

one

' + + '

two

' + + '

three

' + + '
'; + 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 = + '
' + + '

one

' + + '

two

' + + '
'; + 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 = + '
' + + 'E = mc^2' + + '
'; + 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 = + '
    ' + + '
  • ' + + 'done item
  • ' + + '
  • ' + + 'open item
  • ' + + '
'; + 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 = + '' + + '

real body

heading

'; + 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 = '

body

'; + 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(`video`); + + 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(`site`); + + 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

', () => { + const { $, $root } = makeRoot('

'); + const $node = $root.find('img'); + + unwrapFromParagraph($, $node); + + // the

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('

text before

'); + 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(' { + // Node wrapped in nested and

wrappers. + const { $, $root } = makeRoot( + '

', + ); + 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); + }); +}); diff --git a/apps/server/src/integrations/import/utils/import.utils.spec.ts b/apps/server/src/integrations/import/utils/import.utils.spec.ts new file mode 100644 index 00000000..86d89f14 --- /dev/null +++ b/apps/server/src/integrations/import/utils/import.utils.spec.ts @@ -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([ + ['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([ + ['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([ + ['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([ + ['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([ + ['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([ + ['pages/sub/img.png', '/abs/pages/sub/img.png'], + ]); + expect( + resolveRelativeAttachmentPath('sub/img.png', 'pages', candidates), + ).toBe('pages/sub/img.png'); + }); +}); diff --git a/apps/server/src/integrations/import/utils/table-utils.spec.ts b/apps/server/src/integrations/import/utils/table-utils.spec.ts new file mode 100644 index 00000000..59f61cf6 --- /dev/null +++ b/apps/server/src/integrations/import/utils/table-utils.spec.ts @@ -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 , deriving widths from a + * 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 } { + const $ = load(html); + const $root = $.root(); + normalizeTableColumnWidths($, $root); + return { $, $root }; +} + +function firstRowColwidths($root: Cheerio): (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 to the first-row cells', () => { + const html = + '
' + + '' + + '' + + '
ab
'; + 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 = + '' + + '' + + '
ab
'; + 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 = + '' + + '' + + '
merged
'; + const { $root } = run(html); + + expect(firstRowColwidths($root)).toEqual(['50,50']); + }); + + it('ignores em/% widths (treated as no width) and applies the default', () => { + const html = + '' + + '' + + '
ab
'; + 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 = + '' + + '' + + '' + + '
abc
123
'; + 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 = + '' + + '' + + '' + + '
ab
'; + const { $, $root } = run(html); + const afterFirst = $root.html(); + + // second pass + normalizeTableColumnWidths($, $root); + expect($root.html()).toBe(afterFirst); + expect(firstRowColwidths($root)).toEqual(['120', '80']); + }); +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts b/packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts new file mode 100644 index 00000000..279c2b8c --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts @@ -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())); + }); +}); diff --git a/packages/editor-ext/src/lib/html-embed/html-embed.height.test.ts b/packages/editor-ext/src/lib/html-embed/html-embed.height.test.ts new file mode 100644 index 00000000..964d3927 --- /dev/null +++ b/packages/editor-ext/src/lib/html-embed/html-embed.height.test.ts @@ -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); + }); +}); diff --git a/packages/editor-ext/src/lib/markdown/utils/footnote.marked.orphan.test.ts b/packages/editor-ext/src/lib/markdown/utils/footnote.marked.orphan.test.ts new file mode 100644 index 00000000..be955793 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/footnote.marked.orphan.test.ts @@ -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">

a

'); + // The two surplus definitions survive as orphans with derived ids. + expect(section).toContain('data-footnote-def data-id="d__2">

b

'); + expect(section).toContain('data-footnote-def data-id="d__3">

c

'); + }); + + 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("
")).toBe(true); + expect(section.endsWith("
")).toBe(true); + // Exactly three definition divs. + expect( + [...section.matchAll(/
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"]); +}); diff --git a/packages/mcp/test/unit/diff-reorder.test.mjs b/packages/mcp/test/unit/diff-reorder.test.mjs new file mode 100644 index 00000000..71bb4eea --- /dev/null +++ b/packages/mcp/test/unit/diff-reorder.test.mjs @@ -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); +}); diff --git a/packages/mcp/test/unit/json-edit-idempotency.test.mjs b/packages/mcp/test/unit/json-edit-idempotency.test.mjs new file mode 100644 index 00000000..a9f371d5 --- /dev/null +++ b/packages/mcp/test/unit/json-edit-idempotency.test.mjs @@ -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"); +});