The in-app AI chat hardcoded type='page' and the shared createComment swallowed anchoring failures silently, so agent comments never got a text anchor/highlight. - Forbid page-type comments for the agent: top-level comments are always inline and require an exact `selection`; replies inherit the parent anchor (stored as the historical `page` type). - Throw and roll back the just-created comment when the selection cannot be anchored, instead of leaving an orphan unanchored comment. - Add comment-anchor module: text normalization (smart quotes, dashes, nbsp, collapsed whitespace) and matching across adjacent text nodes within a block, so selections crossing inline-code/bold/link anchor. - Update create_comment (MCP) and createComment (ai-chat) tool schemas and descriptions; add unit + mock-HTTP orchestration tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
211 lines
7.5 KiB
JavaScript
211 lines
7.5 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
normalizeForMatch,
|
|
findAnchorInBlock,
|
|
canAnchorInDoc,
|
|
applyAnchorInDoc,
|
|
} from "../../build/lib/comment-anchor.js";
|
|
|
|
const COMMENT_ID = "cmt-123";
|
|
|
|
/** Find the (single) comment mark on a node, or null. */
|
|
function commentMark(node) {
|
|
const marks = Array.isArray(node.marks) ? node.marks : [];
|
|
return marks.find((m) => m && m.type === "comment") || null;
|
|
}
|
|
|
|
/** Build a one-paragraph doc with the given inline content array. */
|
|
function paragraphDoc(content) {
|
|
return { type: "doc", content: [{ type: "paragraph", content }] };
|
|
}
|
|
|
|
test("normalizeForMatch maps a normalized char to its first raw index in a whitespace run", () => {
|
|
const { norm, map } = normalizeForMatch("a b"); // two spaces collapse to one
|
|
assert.equal(norm, "a b");
|
|
// norm[1] is the single space; it maps to the FIRST raw whitespace (index 1).
|
|
assert.equal(map[1], 1);
|
|
assert.equal(map[2], 3); // 'b' is at raw index 3
|
|
});
|
|
|
|
test("simple single-text-node match inserts the comment mark with correct id", () => {
|
|
const doc = paragraphDoc([{ type: "text", text: "Hello brave world" }]);
|
|
const ok = applyAnchorInDoc(doc, "brave", COMMENT_ID);
|
|
assert.equal(ok, true);
|
|
|
|
const parts = doc.content[0].content;
|
|
// "Hello " | "brave" | " world"
|
|
assert.equal(parts.length, 3);
|
|
assert.equal(parts[0].text, "Hello ");
|
|
assert.equal(commentMark(parts[0]), null);
|
|
assert.equal(parts[1].text, "brave");
|
|
const m = commentMark(parts[1]);
|
|
assert.ok(m, "marked fragment carries a comment mark");
|
|
assert.equal(m.attrs.commentId, COMMENT_ID);
|
|
assert.equal(m.attrs.resolved, false);
|
|
assert.equal(parts[2].text, " world");
|
|
assert.equal(commentMark(parts[2]), null);
|
|
});
|
|
|
|
test("match spanning two adjacent plain text nodes preserves base marks", () => {
|
|
const doc = paragraphDoc([
|
|
{ type: "text", text: "запуска ", marks: [{ type: "italic" }] },
|
|
{ type: "text", text: "перед блоком", marks: [{ type: "italic" }] },
|
|
]);
|
|
const ok = applyAnchorInDoc(doc, "запуска перед", COMMENT_ID);
|
|
assert.equal(ok, true);
|
|
|
|
const parts = doc.content[0].content;
|
|
// "запуска " (marked) | "перед" (marked) | " блоком" (after)
|
|
assert.equal(parts.length, 3);
|
|
assert.equal(parts[0].text, "запуска ");
|
|
assert.equal(parts[1].text, "перед");
|
|
assert.equal(parts[2].text, " блоком");
|
|
|
|
// Marked fragments keep the italic base mark AND get exactly one comment mark.
|
|
for (const p of [parts[0], parts[1]]) {
|
|
assert.ok(p.marks.some((m) => m.type === "italic"));
|
|
const cm = p.marks.filter((m) => m.type === "comment");
|
|
assert.equal(cm.length, 1);
|
|
assert.equal(cm[0].attrs.commentId, COMMENT_ID);
|
|
}
|
|
// The trailing fragment keeps its italic mark and has no comment mark.
|
|
assert.ok(parts[2].marks.some((m) => m.type === "italic"));
|
|
assert.equal(commentMark(parts[2]), null);
|
|
});
|
|
|
|
test("match across an inline-code boundary preserves the code mark on the middle fragment", () => {
|
|
const doc = paragraphDoc([
|
|
{ type: "text", text: "run " },
|
|
{ type: "text", text: "qemu", marks: [{ type: "code" }] },
|
|
{ type: "text", text: " now" },
|
|
]);
|
|
const ok = applyAnchorInDoc(doc, "run qemu now", COMMENT_ID);
|
|
assert.equal(ok, true);
|
|
|
|
const parts = doc.content[0].content;
|
|
// All three nodes are fully inside the match -> three marked fragments.
|
|
assert.equal(parts.length, 3);
|
|
assert.equal(parts[0].text, "run ");
|
|
assert.equal(parts[1].text, "qemu");
|
|
assert.equal(parts[2].text, " now");
|
|
|
|
// Every fragment carries exactly one comment mark.
|
|
for (const p of parts) {
|
|
const cm = p.marks.filter((m) => m.type === "comment");
|
|
assert.equal(cm.length, 1);
|
|
assert.equal(cm[0].attrs.commentId, COMMENT_ID);
|
|
}
|
|
// The middle fragment retains its code mark.
|
|
assert.ok(parts[1].marks.some((m) => m.type === "code"));
|
|
});
|
|
|
|
test("normalization matches smart quotes / em-dash / nbsp / collapsed spaces", () => {
|
|
// Document uses « », an em-dash, a non-breaking space, and a double space.
|
|
const docText = "He said «hello world» — done";
|
|
const doc = paragraphDoc([{ type: "text", text: docText }]);
|
|
|
|
// Selection typed with ASCII quotes, single spaces and a hyphen.
|
|
const selection = '"hello world" - done';
|
|
assert.equal(canAnchorInDoc(doc, selection), true);
|
|
|
|
const ok = applyAnchorInDoc(doc, selection, COMMENT_ID);
|
|
assert.equal(ok, true);
|
|
|
|
const parts = doc.content[0].content;
|
|
const marked = parts.filter((p) => commentMark(p));
|
|
assert.equal(marked.length, 1);
|
|
// The marked raw text starts at the « and ends at the trailing "done".
|
|
assert.ok(marked[0].text.startsWith("«hello"));
|
|
assert.ok(marked[0].text.endsWith("done"));
|
|
});
|
|
|
|
test("canAnchorInDoc/applyAnchorInDoc fail (and do not mutate) when selection absent", () => {
|
|
const doc = paragraphDoc([{ type: "text", text: "Hello brave world" }]);
|
|
const snapshot = JSON.stringify(doc);
|
|
|
|
assert.equal(canAnchorInDoc(doc, "missing text"), false);
|
|
assert.equal(applyAnchorInDoc(doc, "missing text", COMMENT_ID), false);
|
|
// Document is unchanged after a failed apply.
|
|
assert.equal(JSON.stringify(doc), snapshot);
|
|
});
|
|
|
|
test("before/after fragments retain original marks; marked has exactly one comment mark", () => {
|
|
const doc = paragraphDoc([
|
|
{ type: "text", text: "abc def ghi", marks: [{ type: "bold" }] },
|
|
]);
|
|
const ok = applyAnchorInDoc(doc, "def", COMMENT_ID);
|
|
assert.equal(ok, true);
|
|
|
|
const parts = doc.content[0].content;
|
|
assert.equal(parts.length, 3);
|
|
// before "abc " and after " ghi" keep the bold mark, no comment mark.
|
|
assert.deepEqual(parts[0].marks, [{ type: "bold" }]);
|
|
assert.deepEqual(parts[2].marks, [{ type: "bold" }]);
|
|
// marked "def" keeps bold and has exactly one comment mark.
|
|
assert.ok(parts[1].marks.some((m) => m.type === "bold"));
|
|
assert.equal(parts[1].marks.filter((m) => m.type === "comment").length, 1);
|
|
});
|
|
|
|
test("findAnchorInBlock returns child/offset descriptor for a multi-node run", () => {
|
|
const blockContent = [
|
|
{ type: "text", text: "ab" },
|
|
{ type: "text", text: "cdef" },
|
|
];
|
|
const match = findAnchorInBlock(blockContent, "bcd");
|
|
assert.deepEqual(match, {
|
|
startChild: 0,
|
|
startOffset: 1,
|
|
endChild: 1,
|
|
endOffset: 2,
|
|
});
|
|
});
|
|
|
|
test("a pre-existing comment mark on matched text is replaced (single comment mark)", () => {
|
|
const doc = paragraphDoc([
|
|
{
|
|
type: "text",
|
|
text: "Hello world",
|
|
marks: [{ type: "comment", attrs: { commentId: "old", resolved: false } }],
|
|
},
|
|
]);
|
|
const ok = applyAnchorInDoc(doc, "Hello world", COMMENT_ID);
|
|
assert.equal(ok, true);
|
|
const parts = doc.content[0].content;
|
|
assert.equal(parts.length, 1);
|
|
const cm = parts[0].marks.filter((m) => m.type === "comment");
|
|
assert.equal(cm.length, 1);
|
|
assert.equal(cm[0].attrs.commentId, COMMENT_ID);
|
|
});
|
|
|
|
test("anchoring works inside a nested block (e.g. list item) via DFS recursion", () => {
|
|
const doc = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "bulletList",
|
|
content: [
|
|
{
|
|
type: "listItem",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "nested target here" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
assert.equal(canAnchorInDoc(doc, "target"), true);
|
|
const ok = applyAnchorInDoc(doc, "target", COMMENT_ID);
|
|
assert.equal(ok, true);
|
|
const para =
|
|
doc.content[0].content[0].content[0].content;
|
|
const marked = para.filter((p) => commentMark(p));
|
|
assert.equal(marked.length, 1);
|
|
assert.equal(marked[0].text, "target");
|
|
});
|