test(coverage): add regression tests for issues #192, #206, #204

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:
claude code agent 227
2026-06-27 06:15:55 +03:00
parent 22852be2e2
commit 2d36641f28
13 changed files with 1438 additions and 1 deletions

View File

@@ -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);
});
});

View File

@@ -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");
},
);
});

View 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]);
});
});

View 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]]);
});

View 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");
});