diff --git a/packages/mcp/build/lib/auth-utils.js b/packages/mcp/build/lib/auth-utils.js index cc61481c..39b91d9d 100644 --- a/packages/mcp/build/lib/auth-utils.js +++ b/packages/mcp/build/lib/auth-utils.js @@ -29,6 +29,41 @@ export async function getCollabToken(baseUrl, apiToken) { throw error; } } +/** + * Pure cookie-parsing helper extracted from `performLogin` so the parsing logic + * can be unit-tested without performing the login network request. Given the + * raw `Set-Cookie` header array from the login response, return the `authToken` + * cookie's value. + * + * Behavior (kept identical to the original inline logic): + * - throws if there is no Set-Cookie header at all; + * - matches the cookie NAME exactly (`authToken`), so a future + * `authTokenRefresh=...` cookie is NOT picked up (a `startsWith` would be); + * - returns everything after the FIRST `=` up to the first `;`, so a base64 + * value containing `=` padding is preserved (a naive `split("=")` would + * truncate it); + * - cookie attributes after the first `;` (Path, HttpOnly, Expires, …) are + * ignored; + * - throws if no `authToken` cookie is present. + */ +export function extractAuthTokenFromSetCookie(cookies) { + if (!cookies) { + throw new Error("No Set-Cookie header found in login response"); + } + // Match the cookie name exactly to avoid matching a future + // authTokenRefresh cookie (startsWith would catch it). + const authCookie = cookies.find((c) => { + const kv = c.split(";")[0]; + return kv.slice(0, kv.indexOf("=")) === "authToken"; + }); + if (!authCookie) { + throw new Error("No authToken cookie found in login response"); + } + // Take everything after the FIRST "=" up to the first ";". + // Splitting on "=" would truncate base64 values containing "=" padding. + const kv = authCookie.split(";")[0]; + return kv.slice(kv.indexOf("=") + 1); +} export async function performLogin(baseUrl, email, password) { try { const response = await axios.post(`${baseUrl}/auth/login`, { @@ -36,24 +71,7 @@ export async function performLogin(baseUrl, email, password) { password, }); // Extract token from Set-Cookie header - const cookies = response.headers["set-cookie"]; - if (!cookies) { - throw new Error("No Set-Cookie header found in login response"); - } - // Match the cookie name exactly to avoid matching a future - // authTokenRefresh cookie (startsWith would catch it). - const authCookie = cookies.find((c) => { - const kv = c.split(";")[0]; - return kv.slice(0, kv.indexOf("=")) === "authToken"; - }); - if (!authCookie) { - throw new Error("No authToken cookie found in login response"); - } - // Take everything after the FIRST "=" up to the first ";". - // Splitting on "=" would truncate base64 values containing "=" padding. - const kv = authCookie.split(";")[0]; - const token = kv.slice(kv.indexOf("=") + 1); - return token; + return extractAuthTokenFromSetCookie(response.headers["set-cookie"]); } catch (error) { // Avoid leaking the full server response body by default; log only the diff --git a/packages/mcp/src/lib/auth-utils.ts b/packages/mcp/src/lib/auth-utils.ts index d677be25..a3a3b652 100644 --- a/packages/mcp/src/lib/auth-utils.ts +++ b/packages/mcp/src/lib/auth-utils.ts @@ -38,6 +38,45 @@ export async function getCollabToken( } } +/** + * Pure cookie-parsing helper extracted from `performLogin` so the parsing logic + * can be unit-tested without performing the login network request. Given the + * raw `Set-Cookie` header array from the login response, return the `authToken` + * cookie's value. + * + * Behavior (kept identical to the original inline logic): + * - throws if there is no Set-Cookie header at all; + * - matches the cookie NAME exactly (`authToken`), so a future + * `authTokenRefresh=...` cookie is NOT picked up (a `startsWith` would be); + * - returns everything after the FIRST `=` up to the first `;`, so a base64 + * value containing `=` padding is preserved (a naive `split("=")` would + * truncate it); + * - cookie attributes after the first `;` (Path, HttpOnly, Expires, …) are + * ignored; + * - throws if no `authToken` cookie is present. + */ +export function extractAuthTokenFromSetCookie( + cookies: string[] | undefined, +): string { + if (!cookies) { + throw new Error("No Set-Cookie header found in login response"); + } + // Match the cookie name exactly to avoid matching a future + // authTokenRefresh cookie (startsWith would catch it). + const authCookie = cookies.find((c: string) => { + const kv = c.split(";")[0]; + return kv.slice(0, kv.indexOf("=")) === "authToken"; + }); + if (!authCookie) { + throw new Error("No authToken cookie found in login response"); + } + + // Take everything after the FIRST "=" up to the first ";". + // Splitting on "=" would truncate base64 values containing "=" padding. + const kv = authCookie.split(";")[0]; + return kv.slice(kv.indexOf("=") + 1); +} + export async function performLogin( baseUrl: string, email: string, @@ -50,25 +89,7 @@ export async function performLogin( }); // Extract token from Set-Cookie header - const cookies = response.headers["set-cookie"]; - if (!cookies) { - throw new Error("No Set-Cookie header found in login response"); - } - // Match the cookie name exactly to avoid matching a future - // authTokenRefresh cookie (startsWith would catch it). - const authCookie = cookies.find((c: string) => { - const kv = c.split(";")[0]; - return kv.slice(0, kv.indexOf("=")) === "authToken"; - }); - if (!authCookie) { - throw new Error("No authToken cookie found in login response"); - } - - // Take everything after the FIRST "=" up to the first ";". - // Splitting on "=" would truncate base64 values containing "=" padding. - const kv = authCookie.split(";")[0]; - const token = kv.slice(kv.indexOf("=") + 1); - return token; + return extractAuthTokenFromSetCookie(response.headers["set-cookie"]); } catch (error: any) { // Avoid leaking the full server response body by default; log only the // HTTP status. Log the verbose body only when DEBUG is set. diff --git a/packages/mcp/test/unit/auth-cookie.test.mjs b/packages/mcp/test/unit/auth-cookie.test.mjs new file mode 100644 index 00000000..92206cf4 --- /dev/null +++ b/packages/mcp/test/unit/auth-cookie.test.mjs @@ -0,0 +1,93 @@ +// Cookie parsing for the login flow. +// +// `performLogin` in auth-utils.ts does a real network POST and then extracts the +// auth token from the response's Set-Cookie header. The cookie-parsing logic was +// extracted into the pure, exported helper `extractAuthTokenFromSetCookie` so it +// can be tested without network I/O; `performLogin` now delegates to it, so these +// tests cover the exact parsing path the login uses. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { extractAuthTokenFromSetCookie } from "../../build/lib/auth-utils.js"; + +// --------------------------------------------------------------------------- +// Happy path: a single authToken cookie with attributes. +// --------------------------------------------------------------------------- +test("extracts the authToken value, ignoring trailing attributes", () => { + const cookies = [ + "authToken=abc123; Path=/; HttpOnly; Secure; SameSite=Lax", + ]; + assert.equal(extractAuthTokenFromSetCookie(cookies), "abc123"); +}); + +// --------------------------------------------------------------------------- +// A base64/JWT value containing "=" padding must NOT be truncated: only the +// FIRST "=" separates name from value. +// --------------------------------------------------------------------------- +test("preserves an '=' inside the value (base64 padding is not truncated)", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0="; + const cookies = [`authToken=${jwt}; Path=/`]; + assert.equal(extractAuthTokenFromSetCookie(cookies), jwt); +}); + +// --------------------------------------------------------------------------- +// Exact-name match: a different cookie whose name merely STARTS WITH "authToken" +// (e.g. authTokenRefresh) must not be picked up; the real authToken wins. +// --------------------------------------------------------------------------- +test("matches the cookie name exactly, not by prefix (authTokenRefresh ignored)", () => { + const cookies = [ + "authTokenRefresh=refreshvalue; Path=/; HttpOnly", + "authToken=realtoken; Path=/; HttpOnly", + ]; + assert.equal(extractAuthTokenFromSetCookie(cookies), "realtoken"); +}); + +// --------------------------------------------------------------------------- +// Picks the authToken out of several unrelated cookies regardless of order. +// --------------------------------------------------------------------------- +test("selects authToken among multiple unrelated cookies", () => { + const cookies = [ + "session=xyz; Path=/", + "authToken=tok-7; Path=/; HttpOnly", + "theme=dark", + ]; + assert.equal(extractAuthTokenFromSetCookie(cookies), "tok-7"); +}); + +// --------------------------------------------------------------------------- +// An empty value is valid and returns "". +// --------------------------------------------------------------------------- +test("returns an empty string when authToken has an empty value", () => { + assert.equal(extractAuthTokenFromSetCookie(["authToken=; Path=/"]), ""); +}); + +// --------------------------------------------------------------------------- +// Missing Set-Cookie header -> documented error. +// --------------------------------------------------------------------------- +test("throws when there is no Set-Cookie header", () => { + assert.throws( + () => extractAuthTokenFromSetCookie(undefined), + /No Set-Cookie header/, + ); +}); + +// --------------------------------------------------------------------------- +// Set-Cookie present but no authToken cookie -> documented error. +// --------------------------------------------------------------------------- +test("throws when no authToken cookie is present", () => { + assert.throws( + () => extractAuthTokenFromSetCookie(["session=xyz; Path=/", "theme=dark"]), + /No authToken cookie/, + ); +}); + +// --------------------------------------------------------------------------- +// An empty cookie array also yields the "no authToken" error (header exists but +// is empty), distinct from the "no Set-Cookie header" case above. +// --------------------------------------------------------------------------- +test("throws 'no authToken' (not 'no header') for an empty cookie array", () => { + assert.throws( + () => extractAuthTokenFromSetCookie([]), + /No authToken cookie/, + ); +}); diff --git a/packages/mcp/test/unit/comment-anchor-apply.test.mjs b/packages/mcp/test/unit/comment-anchor-apply.test.mjs new file mode 100644 index 00000000..46049c02 --- /dev/null +++ b/packages/mcp/test/unit/comment-anchor-apply.test.mjs @@ -0,0 +1,111 @@ +// applyAnchorInDoc — first-match / ambiguity / boundary behavior. +// +// comment-anchor.test.mjs already covers the core apply paths (single-node +// match, spanning adjacent text nodes, code/italic boundary mark preservation, +// smart-quote normalization, no-match-no-mutation, pre-existing comment mark +// replacement, nested-list DFS). This file focuses on the SELECTION/RESOLUTION +// behavior those tests don't pin down: which occurrence/block wins when a +// selection appears more than once, sub-word ranges, and the run boundary +// created by a non-text inline node. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { applyAnchorInDoc, canAnchorInDoc } from "../../build/lib/comment-anchor.js"; + +const commentMark = (node) => + (Array.isArray(node.marks) ? node.marks : []).find((m) => m && m.type === "comment") || null; +const paragraphDoc = (content) => ({ type: "doc", content: [{ type: "paragraph", content }] }); + +// --------------------------------------------------------------------------- +// Document order: when two separate blocks both contain the selection, only the +// FIRST block (DFS document order) is anchored; the second is left untouched. +// --------------------------------------------------------------------------- +test("anchors only the FIRST block when the selection occurs in two blocks", () => { + const doc = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "first target here" }] }, + { type: "paragraph", content: [{ type: "text", text: "second target here" }] }, + ], + }; + assert.equal(applyAnchorInDoc(doc, "target", "C"), true); + + const marked0 = doc.content[0].content.filter((p) => commentMark(p)); + const marked1 = doc.content[1].content.filter((p) => commentMark(p)); + assert.equal(marked0.length, 1, "first block is anchored"); + assert.equal(marked0[0].text, "target"); + assert.equal(marked1.length, 0, "second block is left untouched"); +}); + +// --------------------------------------------------------------------------- +// Ambiguity within one block: indexOf finds the FIRST occurrence, so only the +// first "ab" is marked; the later occurrences stay in one unmarked fragment. +// --------------------------------------------------------------------------- +test("anchors only the FIRST occurrence within a block (ambiguous selection)", () => { + const doc = paragraphDoc([{ type: "text", text: "ab ab ab" }]); + assert.equal(applyAnchorInDoc(doc, "ab", "C"), true); + + const parts = doc.content[0].content; + assert.equal(parts.length, 2, "split into [marked, rest]"); + assert.equal(parts[0].text, "ab"); + assert.ok(commentMark(parts[0]), "first occurrence is marked"); + assert.equal(parts[1].text, " ab ab"); + assert.equal(commentMark(parts[1]), null, "later occurrences are not marked"); +}); + +// --------------------------------------------------------------------------- +// Sub-word range: a selection that is a substring inside a single text node is +// spliced into before / marked / after, marking exactly the matched characters. +// --------------------------------------------------------------------------- +test("anchors a sub-word range inside a single text node", () => { + const doc = paragraphDoc([{ type: "text", text: "Hello" }]); + assert.equal(applyAnchorInDoc(doc, "ell", "C"), true); + + const parts = doc.content[0].content; + assert.deepEqual(parts.map((p) => p.text), ["H", "ell", "o"]); + assert.equal(commentMark(parts[0]), null); + assert.ok(commentMark(parts[1]), "only the matched substring is marked"); + assert.equal(commentMark(parts[2]), null); +}); + +// --------------------------------------------------------------------------- +// A non-text inline node (hardBreak) breaks the matching run: a selection that +// would span the break cannot match, but one wholly inside a run still does. +// --------------------------------------------------------------------------- +test("a non-text inline node breaks the run: cross-break selection does not match", () => { + const make = () => + paragraphDoc([ + { type: "text", text: "foo" }, + { type: "hardBreak" }, + { type: "text", text: "bar" }, + ]); + + // "foobar" straddles the hardBreak -> no match, no mutation. + const docA = make(); + const before = JSON.stringify(docA); + assert.equal(canAnchorInDoc(docA, "foobar"), false); + assert.equal(applyAnchorInDoc(docA, "foobar", "C"), false); + assert.equal(JSON.stringify(docA), before, "failed match must not mutate"); + + // "foo" lives entirely in the first run -> matches and is marked; the + // hardBreak node is preserved untouched. + const docB = make(); + assert.equal(applyAnchorInDoc(docB, "foo", "C"), true); + const parts = docB.content[0].content; + assert.equal(parts[0].text, "foo"); + assert.ok(commentMark(parts[0])); + assert.equal(parts[1].type, "hardBreak", "the inline atom is preserved"); + assert.equal(parts[2].text, "bar"); + assert.equal(commentMark(parts[2]), null); +}); + +// --------------------------------------------------------------------------- +// A whitespace-only selection normalizes to empty and never anchors. +// --------------------------------------------------------------------------- +test("a whitespace-only selection does not anchor and does not mutate", () => { + const doc = paragraphDoc([{ type: "text", text: "hello world" }]); + const before = JSON.stringify(doc); + assert.equal(canAnchorInDoc(doc, " "), false); + assert.equal(applyAnchorInDoc(doc, " ", "C"), false); + assert.equal(JSON.stringify(doc), before); +}); diff --git a/packages/mcp/test/unit/media-roundtrip-attrs.test.mjs b/packages/mcp/test/unit/media-roundtrip-attrs.test.mjs new file mode 100644 index 00000000..37668fe0 --- /dev/null +++ b/packages/mcp/test/unit/media-roundtrip-attrs.test.mjs @@ -0,0 +1,135 @@ +// Extra media round-trip coverage (issue #244), complementing +// media-roundtrip.test.mjs. +// +// The existing media-roundtrip.test.mjs already asserts that video, youtube, +// embed, excalidraw, audio and pdf SURVIVE a PM -> markdown -> PM round-trip and +// keeps their identifying src / provider / name / attachmentId. It does NOT, +// however, exercise: +// * the `drawio` node (a distinct schema node that shares the excalidraw +// converter case) — not covered at all; +// * the dimension / layout attributes (width, height, align) that ride in +// data-* attributes — exactly where a converter<->schema mismatch silently +// drops a value while the node itself survives; +// * attribute escaping for a src containing `"` (escapeAttr) — a malformed +// value here would either break the round-trip or inject HTML. +// +// These are the gaps this file locks down. +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"; + +const doc = (...content) => ({ type: "doc", content }); + +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; +}; + +// PM node -> markdown -> PM; return both the markdown and the matching nodes. +const roundtrip = async (node, type) => { + const md = convertProseMirrorToMarkdown(doc(node)); + const pm = await markdownToProseMirror(md); + return { md, found: findAll(pm, type) }; +}; + +// --------------------------------------------------------------------------- +// drawio: a separate schema node sharing the excalidraw converter case. Not +// covered by the existing file at all, so guard its full round-trip here. +// --------------------------------------------------------------------------- +test("round-trip: drawio diagram survives with src, title, dimensions, align, attachmentId", async () => { + const { md, found } = await roundtrip( + { + type: "drawio", + attrs: { + src: "/api/files/d.drawio", + title: "Flow", + width: 400, + height: 300, + align: "left", + attachmentId: "dz1", + }, + }, + "drawio", + ); + // The converter must emit the schema-matching div[data-type="drawio"]. + assert.match(md, /data-type="drawio"/); + assert.equal(found.length, 1, "drawio node must survive the round-trip"); + const a = found[0].attrs; + assert.equal(a.src, "/api/files/d.drawio"); + assert.equal(a.title, "Flow"); + assert.equal(a.attachmentId, "dz1"); + assert.equal(a.align, "left"); + // Numeric dimensions come back as strings via the schema parseHTML. + assert.equal(String(a.width), "400"); + assert.equal(String(a.height), "300"); +}); + +// --------------------------------------------------------------------------- +// Dimension + align attrs ride in data-* (or width/height) attributes. The +// existing file checks only src/provider/name/attachmentId, so a dropped +// width/height/align would pass there but fail here. +// --------------------------------------------------------------------------- +test("round-trip: youtube preserves width/height/align (data-* attrs)", async () => { + const { found } = await roundtrip( + { type: "youtube", attrs: { src: "https://youtube.com/watch?v=x", width: 560, height: 315, align: "left" } }, + "youtube", + ); + assert.equal(found.length, 1); + const a = found[0].attrs; + assert.equal(String(a.width), "560"); + assert.equal(String(a.height), "315"); + assert.equal(a.align, "left"); +}); + +test("round-trip: embed preserves provider, width/height and align", async () => { + const { found } = await roundtrip( + { type: "embed", attrs: { src: "https://e.com/x", provider: "iframe", width: 600, height: 480, align: "right" } }, + "embed", + ); + assert.equal(found.length, 1); + const a = found[0].attrs; + assert.equal(a.provider, "iframe"); + assert.equal(String(a.width), "600"); + assert.equal(String(a.height), "480"); + assert.equal(a.align, "right"); +}); + +test("round-trip: video preserves width/height and align (data-align)", async () => { + const { found } = await roundtrip( + { type: "video", attrs: { src: "/api/files/v.mp4", attachmentId: "att1", width: 640, height: 360, align: "right" } }, + "video", + ); + assert.equal(found.length, 1); + const a = found[0].attrs; + assert.equal(String(a.width), "640"); + assert.equal(String(a.height), "360"); + assert.equal(a.align, "right"); +}); + +test("round-trip: pdf preserves width/height (standard attrs) plus name", async () => { + const { found } = await roundtrip( + { type: "pdf", attrs: { src: "/api/files/x.pdf", name: "x.pdf", attachmentId: "a4", width: 700, height: 900 } }, + "pdf", + ); + assert.equal(found.length, 1); + const a = found[0].attrs; + assert.equal(a.name, "x.pdf"); + assert.equal(String(a.width), "700"); + assert.equal(String(a.height), "900"); +}); + +// --------------------------------------------------------------------------- +// Escaping: a src containing a double quote must survive the attribute-quoted +// HTML emission (escapeAttr) and re-parse to the exact original value, with no +// node loss and no HTML injection. +// --------------------------------------------------------------------------- +test("round-trip: a src containing a double quote is escaped and recovered intact", async () => { + const tricky = 'https://e.com/x?a="b"&c=1'; + const { found } = await roundtrip({ type: "youtube", attrs: { src: tricky } }, "youtube"); + assert.equal(found.length, 1, "node must survive a quote-bearing src"); + assert.equal(found[0].attrs.src, tricky, "the exact src is recovered"); +}); diff --git a/packages/mcp/test/unit/recreate-transform-drift.test.mjs b/packages/mcp/test/unit/recreate-transform-drift.test.mjs new file mode 100644 index 00000000..b950cdaf --- /dev/null +++ b/packages/mcp/test/unit/recreate-transform-drift.test.mjs @@ -0,0 +1,139 @@ +// CONTRACT / DRIFT GUARD: mcp diff vs the vendored editor-ext recreate-transform. +// +// packages/mcp/src/lib/diff.ts computes its document diff with +// `recreateTransform` from the published @fellow/prosemirror-recreate-transform +// package. Docmost's in-app history editor computes the SAME diff with its own +// vendored copy at +// packages/editor-ext/src/lib/recreate-transform/recreateTransform.ts. +// diff.ts's header comment claims the two are "identical" — if they ever drift, +// the headless mcp diff would stop matching what a user sees in the app. +// +// This test guards that claim two ways, on representative doc pairs, using the +// EXACT options diff.ts passes (complexSteps:false, wordDiffs:true, +// simplifyDiff:true): +// 1. invariant: each implementation's transform reproduces the target doc +// (apply(diff) == target); +// 2. cross-copy parity: both implementations emit the SAME step sequence, so a +// behavioral divergence between the two copies fails this test. +// +// The vendored copy is TypeScript, so it is transpiled to CommonJS at test time +// and required directly — the test runs the ACTUAL vendored source, not a stand-in. +import { test, before } from "node:test"; +import assert from "node:assert/strict"; +import ts from "typescript"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +import { recreateTransform as fellowRecreate } from "@fellow/prosemirror-recreate-transform"; +import { Node } from "@tiptap/pm/model"; +import { docmostSchema } from "../../build/lib/docmost-schema.js"; + +const require = createRequire(import.meta.url); +const HERE = path.dirname(fileURLToPath(import.meta.url)); +// .../packages/mcp/test/unit -> repo packages root. +const PACKAGES = path.resolve(HERE, "..", "..", ".."); +const VENDOR_SRC = path.join( + PACKAGES, + "editor-ext", + "src", + "lib", + "recreate-transform", +); +// Emit transpiled CJS under mcp/build so Node resolves the hoisted deps +// (@tiptap/pm, rfc6902, diff) up the directory tree exactly as diff.js does. +const VENDOR_OUT = path.resolve(HERE, "..", "..", "build", "_vendored_editor_ext"); + +// The exact options the mcp diff pipeline uses (diff.ts). +const DIFF_OPTS = { complexSteps: false, wordDiffs: true, simplifyDiff: true }; + +let vendoredRecreate; + +before(() => { + assert.ok( + fs.existsSync(VENDOR_SRC), + `vendored recreate-transform sources missing at ${VENDOR_SRC}`, + ); + fs.rmSync(VENDOR_OUT, { recursive: true, force: true }); + fs.mkdirSync(VENDOR_OUT, { recursive: true }); + // Mark the output as CommonJS so relative `require("./x")` resolves to x.js. + fs.writeFileSync( + path.join(VENDOR_OUT, "package.json"), + JSON.stringify({ type: "commonjs" }), + ); + for (const f of fs.readdirSync(VENDOR_SRC)) { + if (!f.endsWith(".ts")) continue; + const code = fs.readFileSync(path.join(VENDOR_SRC, f), "utf8"); + const out = ts.transpileModule(code, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }); + fs.writeFileSync(path.join(VENDOR_OUT, f.replace(/\.ts$/, ".js")), out.outputText); + } + vendoredRecreate = require(path.join(VENDOR_OUT, "index.js")).recreateTransform; + assert.equal(typeof vendoredRecreate, "function", "vendored recreateTransform loaded"); +}); + +// --------------------------------------------------------------------------- +// Builders + representative doc pairs covering the diff shapes diff.ts handles. +// --------------------------------------------------------------------------- +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 }); + +const PAIRS = [ + // word inserted mid-sentence + ["insert word", doc(para(t("Hello world"))), doc(para(t("Hello brave world")))], + // whole block deleted + ["delete block", doc(para(t("keep this")), para(t("remove this"))), doc(para(t("keep this")))], + // word removed mid-sentence + ["delete word", doc(para(t("one two three"))), doc(para(t("one three")))], + // pure mark addition (complexSteps:false treats it as a content step) + ["add mark", doc(para(t("plain"))), doc(para(t("plain", [{ type: "bold" }])))], + // two blocks swapped (reorder) + ["reorder blocks", doc(para(t("a")), para(t("b"))), doc(para(t("b")), para(t("a")))], + // structural insert: an image node appears + [ + "insert image", + doc(para(t("caption"))), + doc(para(t("caption")), { type: "image", attrs: { src: "/api/files/a.png", attachmentId: "i1" } }), + ], +]; + +const stepsJSON = (tr) => JSON.stringify(tr.steps.map((s) => s.toJSON())); + +for (const [label, fromJSON, toJSON] of PAIRS) { + test(`invariant: @fellow recreateTransform reproduces the target (${label})`, () => { + const from = Node.fromJSON(docmostSchema, fromJSON); + const to = Node.fromJSON(docmostSchema, toJSON); + const tr = fellowRecreate(from, to, DIFF_OPTS); + // apply(diff) == target, comparing schema-normalized JSON on both sides. + assert.equal(JSON.stringify(tr.doc.toJSON()), JSON.stringify(to.toJSON())); + }); + + test(`drift: @fellow and vendored editor-ext emit identical steps (${label})`, () => { + const mk = () => [ + Node.fromJSON(docmostSchema, fromJSON), + Node.fromJSON(docmostSchema, toJSON), + ]; + const [fA, tA] = mk(); + const [fB, tB] = mk(); + const trFellow = fellowRecreate(fA, tA, DIFF_OPTS); + const trVendor = vendoredRecreate(fB, tB, DIFF_OPTS); + + // Both must reach the same target... + const target = JSON.stringify(tA.toJSON()); + assert.equal(JSON.stringify(trFellow.doc.toJSON()), target, "fellow reaches target"); + assert.equal(JSON.stringify(trVendor.doc.toJSON()), target, "vendored reaches target"); + // ...and, critically, via the SAME step sequence. A divergence in the two + // recreate-transform copies' algorithm would change the steps and fail here. + assert.equal( + stepsJSON(trVendor), + stepsJSON(trFellow), + `vendored editor-ext drifted from @fellow on "${label}"`, + ); + }); +}