test(mcp): media round-trip attrs, cookie parsing, anchor apply, recreate drift
Extract pure extractAuthTokenFromSetCookie from performLogin (behavior-identical) so cookie parsing is unit-testable without a network login. Add round-trip coverage for media attrs (width/height/align/drawio/escaping) the existing suite omitted; applyAnchorInDoc selection/ambiguity/atom-break cases; and a cross-copy drift guard proving the vendored editor-ext recreate-transform and the @fellow npm copy used by diff.ts emit identical steps (apply(diff)==target). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
93
packages/mcp/test/unit/auth-cookie.test.mjs
Normal file
93
packages/mcp/test/unit/auth-cookie.test.mjs
Normal file
@@ -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/,
|
||||
);
|
||||
});
|
||||
111
packages/mcp/test/unit/comment-anchor-apply.test.mjs
Normal file
111
packages/mcp/test/unit/comment-anchor-apply.test.mjs
Normal file
@@ -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);
|
||||
});
|
||||
135
packages/mcp/test/unit/media-roundtrip-attrs.test.mjs
Normal file
135
packages/mcp/test/unit/media-roundtrip-attrs.test.mjs
Normal file
@@ -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");
|
||||
});
|
||||
139
packages/mcp/test/unit/recreate-transform-drift.test.mjs
Normal file
139
packages/mcp/test/unit/recreate-transform-drift.test.mjs
Normal file
@@ -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}"`,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user