Additive test coverage across server, editor-ext, client and mcp. #192 — AiChatService.stream integration (Section 3, against real Postgres): - new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real streamText through a seeded ai/test MockLanguageModelV3 and a real Node ServerResponse, covering: onError persists an assistant error record (status 'error' + partial answer + provider cause in metadata); external MCP client closed exactly once on BOTH onFinish and onError; anti-tamper — history is rebuilt from the DB transcript, not from body.messages. #206 — red-team findings (most already fixed+tested in #212): - mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that pageBreak / transclusionReference / mention are silently dropped on Markdown export (characterization + it.fails for the desired survive-export contract). - persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing documenting that a momentarily-empty live doc overwrites non-empty content (left unfixed — a store-side empty-guard is a behaviour change). #204 — test-strategy plan, highest-priority subset: - Phase 1: mcp-clients.lease.spec.ts covers the external MCP client lease/refcount/eviction lifecycle (leak / premature-close / double-close). - Phase 2 data-integrity pure functions: editor-ext table-utils (transpose/moveRow/convert round-trip) and math tokenizer false-positive guard; client emoji-menu (+ it.fails for the unguarded localStorage JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/ pageBreak markdown data-loss + footnote-diff; server export getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { markdownToHtml } from "./marked.utils";
|
||||
|
||||
/**
|
||||
* Data-integrity regression (issue #204, Phase 2): plain prose that mentions
|
||||
* prices like `$5 and $6` must NOT be misread as inline math. The inline-math
|
||||
* tokenizer mutates a global `marked` singleton at import time
|
||||
* (`marked.utils.ts`), so math behaviour can only be exercised safely through
|
||||
* the public `markdownToHtml`; importing the tokenizer in isolation would give
|
||||
* a different, non-representative result. These assertions therefore drive the
|
||||
* real conversion path.
|
||||
*/
|
||||
function html(md: string): string {
|
||||
const out = markdownToHtml(md);
|
||||
if (typeof out !== "string") throw new Error("expected sync string output");
|
||||
return out;
|
||||
}
|
||||
|
||||
const MATH_MARKERS = ['data-type="mathInline"', 'data-katex="true"'];
|
||||
|
||||
function hasInlineMath(out: string): boolean {
|
||||
return MATH_MARKERS.some((m) => out.includes(m));
|
||||
}
|
||||
|
||||
describe("markdownToHtml: inline-math false positives", () => {
|
||||
it("does not treat prices `$5 and $6` as inline math", () => {
|
||||
const out = html("It costs $5 and $6 today.");
|
||||
expect(hasInlineMath(out)).toBe(false);
|
||||
// The text survives verbatim (no katex span swallowing it).
|
||||
expect(out).toContain("$5 and $6");
|
||||
});
|
||||
|
||||
it("does not treat a single trailing price `$5` as inline math", () => {
|
||||
const out = html("Lunch was $5.");
|
||||
expect(hasInlineMath(out)).toBe(false);
|
||||
expect(out).toContain("$5");
|
||||
});
|
||||
|
||||
it("does not treat `$5, $6, $7` (multiple prices) as inline math", () => {
|
||||
const out = html("Choose $5, $6, $7 plans.");
|
||||
expect(hasInlineMath(out)).toBe(false);
|
||||
});
|
||||
|
||||
it("STILL converts a genuine inline-math expression `$x + y$`", () => {
|
||||
// Guard the positive path so the false-positive guard above can't be
|
||||
// satisfied by simply disabling math entirely.
|
||||
const out = html("The sum $x + y$ is shown.");
|
||||
expect(hasInlineMath(out)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { htmlToMarkdown } from "./turndown.utils";
|
||||
|
||||
/**
|
||||
* #206 mdrt-2 — Markdown export must never SILENTLY drop a block.
|
||||
*
|
||||
* `htmlToMarkdown` (turndown) only registers rules for a fixed set of custom
|
||||
* nodes (callout, taskItem, details, math, iframe, htmlEmbed, image, video,
|
||||
* footnote). Any other custom node — `transclusionReference`, `pageBreak`,
|
||||
* `mention`, `status` — falls through to turndown's default handling: an empty
|
||||
* wrapper is "blank" and removed, so the block disappears from the exported
|
||||
* Markdown with no trace. The invariant "never silently lose a block" is broken.
|
||||
*
|
||||
* The `it.fails` cases assert the DESIRED contract (the block survives export in
|
||||
* SOME form) and are RED today: they document the unfixed data loss and flip to
|
||||
* green the moment a turndown rule (real syntax or a lossless HTML-comment
|
||||
* placeholder) is added. A normal characterization `it` pins the exact current
|
||||
* lossy output so the regression is unambiguous.
|
||||
*/
|
||||
describe("htmlToMarkdown — custom nodes without a turndown rule (#206 mdrt-2)", () => {
|
||||
const wrap = (inner: string) =>
|
||||
`<p>before</p>${inner}<p>after</p>`;
|
||||
|
||||
it("CURRENTLY drops a pageBreak entirely (data loss)", () => {
|
||||
const md = htmlToMarkdown(
|
||||
wrap('<div data-type="pageBreak" class="page-break"></div>'),
|
||||
);
|
||||
// The page break vanishes: only the two paragraphs remain, nothing between.
|
||||
expect(md).toContain("before");
|
||||
expect(md).toContain("after");
|
||||
expect(md).not.toMatch(/page-?break/i);
|
||||
expect(md).not.toContain("---"); // not even a horizontal-rule fallback
|
||||
});
|
||||
|
||||
it("CURRENTLY drops a transclusionReference entirely (data loss)", () => {
|
||||
const md = htmlToMarkdown(
|
||||
wrap('<div data-type="transclusionReference" data-id="abc"></div>'),
|
||||
);
|
||||
expect(md).toContain("before");
|
||||
expect(md).toContain("after");
|
||||
// The data-id (the only thing that gives the reference identity) is gone.
|
||||
expect(md).not.toContain("abc");
|
||||
});
|
||||
|
||||
it.fails(
|
||||
"should NOT lose a pageBreak block on Markdown export",
|
||||
() => {
|
||||
const md = htmlToMarkdown(
|
||||
wrap('<div data-type="pageBreak" class="page-break"></div>'),
|
||||
);
|
||||
// Desired: the break survives in some form (e.g. a `---` rule or marker).
|
||||
expect(md).toMatch(/(-{3,}|page-?break)/i);
|
||||
},
|
||||
);
|
||||
|
||||
it.fails(
|
||||
"should NOT lose a transclusionReference's identity on Markdown export",
|
||||
() => {
|
||||
const md = htmlToMarkdown(
|
||||
wrap('<div data-type="transclusionReference" data-id="abc"></div>'),
|
||||
);
|
||||
// Desired: the referenced id survives so the block can be rebuilt.
|
||||
expect(md).toContain("abc");
|
||||
},
|
||||
);
|
||||
|
||||
it.fails(
|
||||
"should NOT lose a mention's data-id on Markdown export",
|
||||
() => {
|
||||
const md = htmlToMarkdown(
|
||||
'<p>hi <span data-type="mention" data-id="u1" data-label="Bob">@Bob</span> there</p>',
|
||||
);
|
||||
// Desired: the mention keeps its stable identity (data-id), not just text.
|
||||
expect(md).toContain("u1");
|
||||
},
|
||||
);
|
||||
});
|
||||
173
packages/editor-ext/src/lib/table/utils/table-utils.test.ts
Normal file
173
packages/editor-ext/src/lib/table/utils/table-utils.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { tableNodes, TableMap } from "@tiptap/pm/tables";
|
||||
import { transpose } from "./transpose";
|
||||
import { moveRowInArrayOfRows } from "./move-row-in-array-of-rows";
|
||||
import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows";
|
||||
import { convertArrayOfRowsToTableNode } from "./convert-array-of-rows-to-table-node";
|
||||
|
||||
/**
|
||||
* Unit tests for the pure table data-transformation utilities. These functions
|
||||
* drive every drag-to-reorder row/column operation, so a regression here
|
||||
* silently corrupts table content. We test them in isolation against a real
|
||||
* ProseMirror table schema (the same primitives the editor uses).
|
||||
*/
|
||||
|
||||
// Minimal schema containing real ProseMirror table nodes so TableMap behaves
|
||||
// exactly as it does in the editor (merged cells, colspan, etc.).
|
||||
const tNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "inline*",
|
||||
cellAttributes: {},
|
||||
});
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
|
||||
text: { group: "inline" },
|
||||
...tNodes,
|
||||
},
|
||||
marks: {},
|
||||
});
|
||||
|
||||
const cell = (txt: string, attrs?: Record<string, unknown>): PMNode =>
|
||||
schema.nodes.table_cell.createChecked(attrs ?? null, schema.text(txt));
|
||||
const row = (...cells: PMNode[]): PMNode =>
|
||||
schema.nodes.table_row.createChecked(null, cells);
|
||||
const table = (...rows: PMNode[]): PMNode =>
|
||||
schema.nodes.table.createChecked(null, rows);
|
||||
|
||||
// Read the text content of each (non-null) cell so we can compare structure
|
||||
// without depending on ProseMirror node identity.
|
||||
const textGrid = (rows: (PMNode | null)[][]): (string | null)[][] =>
|
||||
rows.map((r) => r.map((c) => (c ? c.textContent : null)));
|
||||
|
||||
const tableTextGrid = (t: PMNode): (string | null)[][] =>
|
||||
textGrid(convertTableNodeToArrayOfRows(t));
|
||||
|
||||
describe("transpose", () => {
|
||||
it("is its own inverse on a non-square (2x3) matrix", () => {
|
||||
const arr = [
|
||||
["a1", "a2", "a3"],
|
||||
["b1", "b2", "b3"],
|
||||
];
|
||||
const once = transpose(arr);
|
||||
// 2x3 -> 3x2
|
||||
expect(once.length).toBe(3);
|
||||
expect(once[0].length).toBe(2);
|
||||
const twice = transpose(once);
|
||||
expect(twice).toEqual(arr);
|
||||
});
|
||||
|
||||
it("inverts indices: transpose(arr)[j][i] === arr[i][j]", () => {
|
||||
const arr = [
|
||||
["a1", "a2", "a3"],
|
||||
["b1", "b2", "b3"],
|
||||
];
|
||||
const t = transpose(arr);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = 0; j < arr[0].length; j++) {
|
||||
expect(t[j][i]).toBe(arr[i][j]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveRowInArrayOfRows", () => {
|
||||
// Helper: the function mutates `rows` in place (it uses splice), so always
|
||||
// pass a fresh copy and read the returned array.
|
||||
const move = (
|
||||
rows: string[],
|
||||
origin: number[],
|
||||
target: number[],
|
||||
dir: -1 | 0 | 1,
|
||||
): string[] => moveRowInArrayOfRows([...rows], origin, target, dir);
|
||||
|
||||
it("moves a single row downward to a later index", () => {
|
||||
const result = move(["A", "B", "C", "D"], [0], [2], 0);
|
||||
// A starts at 0, target index 2 -> A lands after C.
|
||||
expect(result).toEqual(["B", "C", "A", "D"]);
|
||||
});
|
||||
|
||||
it("moves a single row upward to an earlier index", () => {
|
||||
const result = move(["A", "B", "C", "D"], [3], [1], 0);
|
||||
expect(result).toEqual(["A", "D", "B", "C"]);
|
||||
});
|
||||
|
||||
it("never drops or duplicates rows (set is preserved) for any pair", () => {
|
||||
const base = ["A", "B", "C", "D", "E"];
|
||||
for (let from = 0; from < base.length; from++) {
|
||||
for (let to = 0; to < base.length; to++) {
|
||||
if (from === to) continue;
|
||||
const result = move(base, [from], [to], 0);
|
||||
expect(result.length).toBe(base.length);
|
||||
expect([...result].sort()).toEqual([...base].sort());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("moves an even-sized block (2 rows) preserving block order and full set", () => {
|
||||
// Move the [B,C] block (origin indexes 1,2) toward target index 3 (D,E region).
|
||||
const result = move(["A", "B", "C", "D", "E"], [1, 2], [3], 0);
|
||||
expect(result.length).toBe(5);
|
||||
expect([...result].sort()).toEqual(["A", "B", "C", "D", "E"]);
|
||||
// Block stays contiguous and in original internal order.
|
||||
const bi = result.indexOf("B");
|
||||
expect(result[bi + 1]).toBe("C");
|
||||
});
|
||||
|
||||
it("moves an odd-sized block (3 rows) without dropping rows", () => {
|
||||
const result = move(["A", "B", "C", "D", "E"], [0, 1, 2], [4], 0);
|
||||
expect(result.length).toBe(5);
|
||||
expect([...result].sort()).toEqual(["A", "B", "C", "D", "E"]);
|
||||
// The 3-row block keeps its internal A,B,C order.
|
||||
const ai = result.indexOf("A");
|
||||
expect(result.slice(ai, ai + 3)).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert round-trip: TableNode <-> arrayOfRows", () => {
|
||||
it("preserves a simple 2x3 grid's text content and dimensions", () => {
|
||||
const t = table(
|
||||
row(cell("a1"), cell("b1"), cell("c1")),
|
||||
row(cell("a2"), cell("b2"), cell("c2")),
|
||||
);
|
||||
const before = tableTextGrid(t);
|
||||
expect(before).toEqual([
|
||||
["a1", "b1", "c1"],
|
||||
["a2", "b2", "c2"],
|
||||
]);
|
||||
|
||||
const arr = convertTableNodeToArrayOfRows(t);
|
||||
const rebuilt = convertArrayOfRowsToTableNode(t, arr);
|
||||
|
||||
// Structure (text content + shape) survives the round-trip.
|
||||
expect(tableTextGrid(rebuilt)).toEqual(before);
|
||||
expect(rebuilt.childCount).toBe(t.childCount);
|
||||
const mapA = TableMap.get(t);
|
||||
const mapB = TableMap.get(rebuilt);
|
||||
expect([mapB.width, mapB.height]).toEqual([mapA.width, mapA.height]);
|
||||
});
|
||||
|
||||
it("represents a horizontally merged cell as a null placeholder, and round-trips it", () => {
|
||||
// First cell of row 1 spans 2 columns -> the array form has a null where
|
||||
// the covered column would be.
|
||||
const t = table(
|
||||
row(cell("merged", { colspan: 2 }), cell("c1")),
|
||||
row(cell("a2"), cell("b2"), cell("c2")),
|
||||
);
|
||||
|
||||
const arr = convertTableNodeToArrayOfRows(t);
|
||||
// Row 0: [merged, null, c1] — the null marks the colspan-covered slot.
|
||||
expect(arr[0][0]?.textContent).toBe("merged");
|
||||
expect(arr[0][1]).toBeNull();
|
||||
expect(arr[0][2]?.textContent).toBe("c1");
|
||||
|
||||
const rebuilt = convertArrayOfRowsToTableNode(t, arr);
|
||||
// The merged cell (and its null placeholder) is reconstructed identically.
|
||||
expect(tableTextGrid(rebuilt)).toEqual(tableTextGrid(t));
|
||||
const map = TableMap.get(rebuilt);
|
||||
expect([map.width, map.height]).toEqual([3, 2]);
|
||||
});
|
||||
});
|
||||
86
packages/mcp/test/unit/footnote-diff.test.mjs
Normal file
86
packages/mcp/test/unit/footnote-diff.test.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
// Footnote-marker extraction in the integrity diff (diff.ts `footnoteMarkers`,
|
||||
// surfaced via diffDocs(...).integrity.footnoteMarkers).
|
||||
//
|
||||
// The existing diff.test.mjs covers the basic legacy `[N]` body markers and the
|
||||
// default notes-heading split. These add the cases it does not:
|
||||
// - real footnoteReference nodes take precedence over legacy `[N]` text,
|
||||
// - the notesHeading parameter is configurable,
|
||||
// - footnoteReference nodes are numbered 1..n by reading position.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { diffDocs } from "../../build/lib/diff.js";
|
||||
|
||||
// Builders.
|
||||
const doc = (...content) => ({ type: "doc", content });
|
||||
const para = (...content) => ({ type: "paragraph", content });
|
||||
const t = (text) => ({ type: "text", text });
|
||||
const heading = (level, text) => ({ type: "heading", attrs: { level }, content: [t(text)] });
|
||||
const fref = () => ({ type: "footnoteReference" });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// footnoteReference nodes take precedence over legacy [N] text markers.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("footnoteReference nodes are numbered 1..n by reading position", () => {
|
||||
const d = doc(para(t("a"), fref(), t(" b "), fref(), t(" c "), fref()));
|
||||
const r = diffDocs(d, d);
|
||||
// Three refs -> [1, 2, 3] regardless of any stored number.
|
||||
assert.deepEqual(r.integrity.footnoteMarkers, [[1, 2, 3], [1, 2, 3]]);
|
||||
});
|
||||
|
||||
test("when real footnoteReference nodes exist, legacy [N] text markers are ignored", () => {
|
||||
// Body has TWO footnoteReference nodes AND a literal "[9]" text marker.
|
||||
// The refs win: the literal [9] must NOT contribute a marker.
|
||||
const d = doc(para(t("intro "), fref(), t(" middle [9] tail "), fref()));
|
||||
const r = diffDocs(d, d);
|
||||
assert.deepEqual(
|
||||
r.integrity.footnoteMarkers,
|
||||
[[1, 2], [1, 2]],
|
||||
"literal [9] is dropped when footnoteReference nodes are present",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The notesHeading split is configurable; the body/notes boundary follows it.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("a custom notesHeading splits body from notes for legacy markers", () => {
|
||||
const d = doc(
|
||||
para(t("body [1] [2]")),
|
||||
heading(2, "Notes"),
|
||||
para(t("note text [1] inside notes")),
|
||||
);
|
||||
// With notesHeading="Notes" only the body markers [1],[2] are counted; the
|
||||
// [1] under the heading is excluded.
|
||||
const r = diffDocs(d, d, "Notes");
|
||||
assert.deepEqual(r.integrity.footnoteMarkers, [[1, 2], [1, 2]]);
|
||||
});
|
||||
|
||||
test("a notesHeading that does not match any heading counts the whole doc", () => {
|
||||
const d = doc(
|
||||
para(t("body [1] [2]")),
|
||||
heading(2, "Notes"),
|
||||
para(t("note text [1] inside notes")),
|
||||
);
|
||||
// The default heading ("Примечания переводчика") does not match "Notes", so
|
||||
// there is no body/notes split and ALL three markers are counted in order.
|
||||
const r = diffDocs(d, d);
|
||||
assert.deepEqual(r.integrity.footnoteMarkers, [[1, 2, 1], [1, 2, 1]]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy markers preserve their literal value and reading order; the diff
|
||||
// surfaces added/removed markers between two docs.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("legacy [N] markers keep their literal numbers in reading order", () => {
|
||||
// Out-of-sequence literal numbers must be preserved verbatim (not renumbered).
|
||||
const d = doc(para(t("see [3] then [1] then [10]")));
|
||||
const r = diffDocs(d, d);
|
||||
assert.deepEqual(r.integrity.footnoteMarkers, [[3, 1, 10], [3, 1, 10]]);
|
||||
});
|
||||
|
||||
test("a dropped legacy marker shows up as an [old,new] difference", () => {
|
||||
const oldDoc = doc(para(t("a [1] b [2] c [3]")));
|
||||
const newDoc = doc(para(t("a [1] b [3]")));
|
||||
const r = diffDocs(oldDoc, newDoc);
|
||||
assert.deepEqual(r.integrity.footnoteMarkers, [[1, 2, 3], [1, 3]]);
|
||||
});
|
||||
144
packages/mcp/test/unit/media-roundtrip.test.mjs
Normal file
144
packages/mcp/test/unit/media-roundtrip.test.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
// Markdown-export coverage for atom/media block nodes.
|
||||
//
|
||||
// The existing schema.test.mjs only exercises the Yjs (fromYdoc/toYdoc) path.
|
||||
// These tests exercise the SEPARATE markdown-export path
|
||||
// (convertProseMirrorToMarkdown) and the full PM -> markdown -> PM round-trip
|
||||
// (markdownToProseMirror), which is where a missing converter case silently
|
||||
// drops a whole block.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
|
||||
import { markdownToProseMirror } from "../../build/lib/collaboration.js";
|
||||
|
||||
// Builders.
|
||||
const doc = (...content) => ({ type: "doc", content });
|
||||
const para = (...content) => ({ type: "paragraph", content });
|
||||
const text = (t) => ({ type: "text", text: t });
|
||||
|
||||
// Recursively collect every descendant node (and self) of the given type.
|
||||
const findAll = (node, type, acc = []) => {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
for (const c of node.content || []) findAll(c, type, acc);
|
||||
return acc;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DATA-LOSS: atom block nodes with no converter case serialize to "" and the
|
||||
// whole block disappears from markdown export.
|
||||
//
|
||||
// markdown-converter.ts has a `default` branch (~line 601) that renders a node
|
||||
// as `nodeContent.map(processNode).join("")`. For a leaf/atom node (no
|
||||
// content) that yields "" — so the node (and ALL its attributes) is dropped.
|
||||
// `htmlEmbed` and `pageBreak` are both block atoms in docmost-schema.ts with no
|
||||
// case in the converter, so they vanish on markdown export.
|
||||
//
|
||||
// These tests assert the CURRENT (buggy) behavior and name it, so that when a
|
||||
// converter case is added the failing assertion flags the test for an update.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("DATA-LOSS: an htmlEmbed block is silently dropped from markdown export (no converter case)", () => {
|
||||
const input = doc(
|
||||
para(text("before")),
|
||||
{ type: "htmlEmbed", attrs: { source: "<b>hi</b>", height: 200 } },
|
||||
para(text("after")),
|
||||
);
|
||||
const md = convertProseMirrorToMarkdown(input);
|
||||
|
||||
// BUG: the htmlEmbed block, including its `source` and `height` attrs, is
|
||||
// gone — only the surrounding paragraphs survive. If a future fix adds an
|
||||
// htmlEmbed case, update this test to assert the block (or a placeholder)
|
||||
// survives instead.
|
||||
assert.equal(md, "before\n\n\n\nafter", "htmlEmbed currently disappears");
|
||||
assert.ok(!md.includes("<b>hi</b>"), "the embed source is NOT preserved (data-loss)");
|
||||
});
|
||||
|
||||
test("DATA-LOSS: an htmlEmbed does NOT round-trip (PM -> markdown -> PM loses the node)", async () => {
|
||||
const input = doc(
|
||||
para(text("x")),
|
||||
{ type: "htmlEmbed", attrs: { source: "<i>raw</i>", height: 120 } },
|
||||
);
|
||||
const out = await markdownToProseMirror(convertProseMirrorToMarkdown(input));
|
||||
assert.equal(
|
||||
findAll(out, "htmlEmbed").length,
|
||||
0,
|
||||
"htmlEmbed is lost across a markdown round-trip (known data-loss gap)",
|
||||
);
|
||||
});
|
||||
|
||||
test("DATA-LOSS: a pageBreak block is silently dropped from markdown export (no converter case)", () => {
|
||||
const input = doc(para(text("a")), { type: "pageBreak" }, para(text("b")));
|
||||
const md = convertProseMirrorToMarkdown(input);
|
||||
// BUG: pageBreak (a block atom with no converter case) disappears.
|
||||
assert.equal(md, "a\n\n\n\nb", "pageBreak currently disappears");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media block nodes that DO have converter cases must survive markdown export
|
||||
// AND a full PM -> markdown -> PM round-trip. The schema.test.mjs Yjs path does
|
||||
// not exercise the converter, so these lock in the converter+schema pairing.
|
||||
// (Numeric width/height come back as strings via the schema parseHTML; we
|
||||
// assert survival + the identifying src/ids rather than exact attr types.)
|
||||
// ---------------------------------------------------------------------------
|
||||
const roundtrip = async (node, type) =>
|
||||
findAll(await markdownToProseMirror(convertProseMirrorToMarkdown(doc(node))), type);
|
||||
|
||||
test("round-trip: video node survives markdown export with src + attachmentId", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "video", attrs: { src: "/api/files/v.mp4", width: 640, height: 360, attachmentId: "att1" } },
|
||||
"video",
|
||||
);
|
||||
assert.equal(found.length, 1, "video node should survive");
|
||||
assert.equal(found[0].attrs?.src, "/api/files/v.mp4");
|
||||
assert.equal(found[0].attrs?.attachmentId, "att1");
|
||||
});
|
||||
|
||||
test("round-trip: youtube node survives markdown export with src", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "youtube", attrs: { src: "https://youtube.com/watch?v=x", width: 560, height: 315 } },
|
||||
"youtube",
|
||||
);
|
||||
assert.equal(found.length, 1, "youtube node should survive");
|
||||
assert.equal(found[0].attrs?.src, "https://youtube.com/watch?v=x");
|
||||
});
|
||||
|
||||
test("round-trip: embed node survives markdown export with src + provider", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "embed", attrs: { src: "https://e.com/x", provider: "iframe", width: 600 } },
|
||||
"embed",
|
||||
);
|
||||
assert.equal(found.length, 1, "embed node should survive");
|
||||
assert.equal(found[0].attrs?.src, "https://e.com/x");
|
||||
assert.equal(found[0].attrs?.provider, "iframe");
|
||||
});
|
||||
|
||||
test("round-trip: excalidraw node survives markdown export with src + attachmentId", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "excalidraw", attrs: { src: "/api/files/d.excalidraw", title: "D", attachmentId: "a2" } },
|
||||
"excalidraw",
|
||||
);
|
||||
assert.equal(found.length, 1, "excalidraw node should survive");
|
||||
assert.equal(found[0].attrs?.src, "/api/files/d.excalidraw");
|
||||
assert.equal(found[0].attrs?.attachmentId, "a2");
|
||||
});
|
||||
|
||||
test("round-trip: audio node survives markdown export with src + attachmentId", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "audio", attrs: { src: "/api/files/a.mp3", attachmentId: "a3" } },
|
||||
"audio",
|
||||
);
|
||||
assert.equal(found.length, 1, "audio node should survive");
|
||||
assert.equal(found[0].attrs?.src, "/api/files/a.mp3");
|
||||
assert.equal(found[0].attrs?.attachmentId, "a3");
|
||||
});
|
||||
|
||||
test("round-trip: pdf node survives markdown export with src + name + attachmentId", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "pdf", attrs: { src: "/api/files/x.pdf", name: "x.pdf", attachmentId: "a4" } },
|
||||
"pdf",
|
||||
);
|
||||
assert.equal(found.length, 1, "pdf node should survive");
|
||||
assert.equal(found[0].attrs?.src, "/api/files/x.pdf");
|
||||
assert.equal(found[0].attrs?.name, "x.pdf");
|
||||
assert.equal(found[0].attrs?.attachmentId, "a4");
|
||||
});
|
||||
Reference in New Issue
Block a user