Review of #154 (Request changes) — all clean follow-ups, no defect in the fix: 1. Single source of the ProseMirror schema: export `docmostSchema` from docmost-schema.ts (next to docmostExtensions); diff.ts and collaboration.ts import it instead of each calling getSchema(docmostExtensions) — the schema can no longer drift between call sites. Removed both local builds + the now unused getSchema imports. 2. Doc fix: assertYjsEncodable's docstring and the client.ts comment no longer claim "the same encoder as apply" — apply uses updateYFragment, the dry-run uses toYdoc; both reject the same unstorable attrs but are NOT byte-identical. Reworded to "independent encodability gate". 3+4+5. Extracted `unstorableYjsError(safe, label, e)` — buildYDoc and applyDocToFragment now share one message template (label kept for diagnostics: toYdoc vs updateYFragment), so the wording can't drift between dry-run/apply. 6. Test for applyDocToFragment's catch branch: an unknown node type makes the schema-validated PMNode.fromJSON throw, and the function must re-throw it wrapped with the (updateYFragment) diagnostic. build/ rebuilt for the three changed lib modules; 293 package tests green. (Left build/client.js untouched: rebuilding it would pull in a pre-existing, unrelated src/build drift — a listSidebarPages slugId fix never rebuilt on develop — and my client.ts change there is comment-only.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
3.8 KiB
JavaScript
94 lines
3.8 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import * as Y from "yjs";
|
|
import { applyDocToFragment } from "../../build/lib/collaboration.js";
|
|
|
|
// Regression for issue #152: agent writes (comment anchoring especially) must
|
|
// NOT yank the open editor's cursor to the end of the document. The cursor is a
|
|
// Yjs RelativePosition anchored to node ids; the old write-back deleted the whole
|
|
// fragment and rebuilt it, destroying every id, so the position no longer
|
|
// resolved. `applyDocToFragment` uses `updateYFragment` (the editor's own diff),
|
|
// which keeps unchanged nodes' ids — so a RelativePosition still resolves.
|
|
|
|
const para = (text, marks) => ({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text, ...(marks ? { marks } : {}) }],
|
|
});
|
|
const doc = (...paras) => ({ type: "doc", content: paras });
|
|
|
|
/** The XmlText of the Nth paragraph in the live fragment. */
|
|
function paragraphText(ydoc, n) {
|
|
const el = ydoc.getXmlFragment("default").get(n); // <paragraph> XmlElement
|
|
return el.get(0); // its XmlText child
|
|
}
|
|
|
|
test("an UNCHANGED node keeps its Yjs identity across an edit (cursor survives)", () => {
|
|
const ydoc = new Y.Doc();
|
|
applyDocToFragment(ydoc, doc(para("Hello world"), para("Second")));
|
|
|
|
// Anchor a cursor at offset 5 inside the FIRST (soon-to-be-unchanged) paragraph.
|
|
const relPos = Y.createRelativePositionFromTypeIndex(paragraphText(ydoc, 0), 5);
|
|
|
|
// Edit only the SECOND paragraph; the first is untouched.
|
|
applyDocToFragment(ydoc, doc(para("Hello world"), para("Second edited")));
|
|
|
|
const abs = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
|
|
assert.notEqual(abs, null, "the cursor's relative position must still resolve");
|
|
assert.equal(abs.index, 5, "the cursor must stay at the same offset");
|
|
// And the edit actually landed.
|
|
assert.equal(paragraphText(ydoc, 1).toString(), "Second edited");
|
|
});
|
|
|
|
test("anchoring a comment mark keeps the cursor in the marked text (issue #152)", () => {
|
|
const ydoc = new Y.Doc();
|
|
applyDocToFragment(ydoc, doc(para("Hello world")));
|
|
|
|
// The user's cursor sits inside the text that is about to be commented.
|
|
const relPos = Y.createRelativePositionFromTypeIndex(paragraphText(ydoc, 0), 3);
|
|
|
|
// Agent anchors a comment over "Hello" — text is identical, only a mark added.
|
|
applyDocToFragment(
|
|
ydoc,
|
|
doc({
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Hello",
|
|
marks: [
|
|
{ type: "comment", attrs: { commentId: "c1", resolved: false } },
|
|
],
|
|
},
|
|
{ type: "text", text: " world" },
|
|
],
|
|
}),
|
|
);
|
|
|
|
// The text is intact (the mark splits "Hello" / " world" but reads the same).
|
|
const para0 = ydoc.getXmlFragment("default").get(0);
|
|
assert.equal(para0.toString().replace(/<[^>]*>/g, ""), "Hello world");
|
|
|
|
// ...and the cursor anchored before the write still resolves (did not jump to
|
|
// the document end as it did with the destructive full-replace).
|
|
const abs = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
|
|
assert.notEqual(abs, null, "comment anchoring must not destroy the cursor anchor");
|
|
});
|
|
|
|
// The diagnostic catch branch of applyDocToFragment (#154 review): a doc that
|
|
// cannot be hydrated/encoded must be re-thrown wrapped with the stage label, not
|
|
// leak the raw ProseMirror/Yjs error. An unknown node type makes
|
|
// PMNode.fromJSON (against the docmost schema) throw — a reliable trigger
|
|
// (sanitizeForYjs only strips `undefined`, so an undefined attr would be removed
|
|
// before it could fail).
|
|
test("applyDocToFragment wraps an encode/build failure with the (updateYFragment) diagnostic", () => {
|
|
const ydoc = new Y.Doc();
|
|
const bad = {
|
|
type: "doc",
|
|
content: [{ type: "totally_unknown_node_xyz_12345" }],
|
|
};
|
|
assert.throws(
|
|
() => applyDocToFragment(ydoc, bad),
|
|
/Failed to encode document to Yjs \(updateYFragment\)/,
|
|
);
|
|
});
|