751d55e9db
Round-1 review found two issues: - [regressions] Inserting a transcript line VERBATIM as a paragraph is unsafe on the git-sync doc->markdown->doc round-trip: the paragraph serializer emits text with no block-escape, so a line starting at col 0 with `- `/`> `/`# `/`1. `/```` ``` ````/`|`/ `> [!info]` silently re-parses into a list/quote/heading/code/table/callout (the last hits the #359 callout machinery). Safe under the host contract (every line prefixed `You:`/`Speaker N:`, which starts with a letter) but the helper didn't enforce it, and leading-whitespace / stray lines exist. Fix: trim each kept line (drops the indent leak), and if it STILL begins with a col-0 markdown block trigger, prepend an invisible zero-width space (U+200B) so the trigger isn't at col 0 — the round-trip keeps it a paragraph. (Backslash-escape was rejected: `marked` consumes the `\` on re-import, so it would render visibly then vanish. ZWSP is invisible and never markdown-escaped.) The serializer's missing block-escape is the pre-existing root cause; this is the boundary defense. Prefixed transcript lines never match the trigger regex → left byte-exact. - [test-coverage] The "inserted as TEXT, not HTML/markdown" contract wasn't locked (all test strings were plain alphabet). Added a test that inserts `<b>bold</b> <script>alert(1)</script> and *stars* and [link](x)` (with Bold/Italic/Link marks registered so an insertContent(html) regression would parse them) and asserts one verbatim text node, no marks, and getHTML() has no live tags. Verified: apps/client tsc --noEmit 0 errors; gitmost bridge tests 4 passed; a NEW prosemirror-markdown round-trip test (real convertProseMirrorToMarkdown -> markdownToProseMirror) proves bare triggers corrupt while the ZWSP-neutralized form stays a single byte-preserved paragraph and normal You:/Speaker N: lines round-trip byte-exact — 3 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
4.0 KiB
TypeScript
105 lines
4.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
|
|
// collaboration.ts and mutates global DOM at import time).
|
|
import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
|
|
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
|
|
|
|
/**
|
|
* gitmost #377 (round-1 review, finding #1) — proof, against the REAL
|
|
* converter, that the transcript-insert boundary defense survives git-sync.
|
|
*
|
|
* The web bridge (apps/client .../gitmost/gitmost-recording.ts,
|
|
* `gitmostInsertTranscriptIntoEditor`) appends each transcript line as a
|
|
* PARAGRAPH text node. The paragraph serializer here (`case "paragraph"`) emits
|
|
* that text VERBATIM with no block-escape, so a line whose text begins with a
|
|
* col-0 markdown block trigger would, on the doc -> markdown -> doc git-sync
|
|
* cycle, silently re-parse into a heading / list / quote / callout / code block.
|
|
* That missing block-escape is the pre-existing root cause; the bridge's
|
|
* boundary defense prepends an invisible zero-width space (U+200B) to a line
|
|
* that begins with such a trigger, shifting it off column 0.
|
|
*
|
|
* This test keeps a COPY of the bridge's trigger regex (the bridge is in a
|
|
* different package and can't be imported here) and asserts:
|
|
* 1. bare trigger lines DO corrupt (documents the root cause), and
|
|
* 2. the ZWSP-neutralized form round-trips as a single PARAGRAPH with the
|
|
* text byte-preserved.
|
|
*/
|
|
|
|
const ZWSP = ""; // U+200B
|
|
|
|
// MUST stay in sync with GITMOST_MD_BLOCK_TRIGGER_RE in the client bridge.
|
|
const MD_BLOCK_TRIGGER_RE =
|
|
/^(#{1,6}(\s|$)|[-*+](\s|$)|>|\d+[.)](\s|$)|```|~~~|\|)/;
|
|
|
|
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
|
|
const para = (t: string) => ({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: t }],
|
|
});
|
|
|
|
const roundtrip = async (text: string) => {
|
|
const md = convertProseMirrorToMarkdown(doc(para(text)));
|
|
const back = await markdownToProseMirror(md);
|
|
return back.content as any[];
|
|
};
|
|
|
|
describe("gitmost transcript neutralization (git-sync round-trip)", () => {
|
|
// Lines that, at column 0, the serializer's missing block-escape would let
|
|
// git-sync re-parse into a non-paragraph block.
|
|
const triggerLines = [
|
|
"- dash",
|
|
"* star",
|
|
"+ plus",
|
|
"> quote",
|
|
"# hash",
|
|
"1. one",
|
|
"1) one",
|
|
"> [!info] note",
|
|
"```js",
|
|
"~~~",
|
|
];
|
|
|
|
it("BARE trigger lines corrupt into non-paragraph blocks (root cause)", async () => {
|
|
for (const line of triggerLines) {
|
|
const blocks = await roundtrip(line);
|
|
// At least one produced block is NOT a paragraph — i.e. corruption.
|
|
const allParagraphs = blocks.every((b) => b.type === "paragraph");
|
|
expect(
|
|
allParagraphs,
|
|
`expected "${line}" to corrupt when inserted bare`,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("ZWSP-neutralized trigger lines round-trip as a single paragraph, text preserved", async () => {
|
|
for (const line of triggerLines) {
|
|
// The regex must actually classify each as a trigger.
|
|
expect(MD_BLOCK_TRIGGER_RE.test(line), `regex missed "${line}"`).toBe(
|
|
true,
|
|
);
|
|
const neutralized = ZWSP + line;
|
|
const blocks = await roundtrip(neutralized);
|
|
|
|
expect(blocks).toHaveLength(1);
|
|
expect(blocks[0].type).toBe("paragraph");
|
|
// Text is byte-preserved (ZWSP + original line), so the display is the
|
|
// original line with only an invisible leading character.
|
|
expect(blocks[0].content[0].text).toBe(neutralized);
|
|
}
|
|
});
|
|
|
|
it("normal host-prefixed lines never match the trigger regex and round-trip byte-exact", async () => {
|
|
for (const line of [
|
|
"You: hello there",
|
|
"Speaker 1: - and then a dash mid-line",
|
|
"Speaker 2: 1. not a list",
|
|
]) {
|
|
expect(MD_BLOCK_TRIGGER_RE.test(line)).toBe(false);
|
|
const blocks = await roundtrip(line);
|
|
expect(blocks).toHaveLength(1);
|
|
expect(blocks[0].type).toBe("paragraph");
|
|
expect(blocks[0].content[0].text).toBe(line);
|
|
}
|
|
});
|
|
});
|