server/commands/documentUpdater.test.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from "node:crypto";
import * as Y from "yjs";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import {
TextEditMode,
MentionType,
type ProsemirrorData,
type ProsemirrorMark,
} from "@shared/types";
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
import { Document, Event } from "@server/models";
import { parser, schema } from "@server/editor";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import {
buildCollection,
buildDocument,
buildUser,
} from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import documentUpdater from "./documentUpdater";
describe("documentUpdater", () => {
it.each(Object.values(TextEditMode))(
"should attribute new mentions and preserve existing actors in %s mode",
async (editMode) => {
const user = await buildUser();
const originalActor = await buildUser({ teamId: user.teamId });
const mentionId = randomUUID();
const originalMention = `@[Original](mention://${mentionId}/user/${originalActor.id})`;
const initialDoc = ProsemirrorHelper.assignMentionActors(
parser.parse(`${originalMention} replace me`),
originalActor.id
);
let document = await buildDocument({
teamId: user.teamId,
content: initialDoc.toJSON(),
state: Buffer.from(
Y.encodeStateAsUpdate(ProsemirrorHelper.toYDoc(initialDoc.toJSON()))
),
});
const newMention = `@[Writer](mention://user/${user.id})`;
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
document,
text:
editMode === TextEditMode.Replace
? `${originalMention} ${newMention}`
: newMention,
editMode,
findText: editMode === TextEditMode.Patch ? "replace me" : undefined,
})
);
document = await Document.findByPk(document.id, {
includeState: true,
rejectOnEmpty: true,
});
expect(
DocumentHelper.parseMentions(document, { type: MentionType.User })
).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: mentionId, actorId: originalActor.id }),
expect.objectContaining({ modelId: user.id, actorId: user.id }),
])
);
if (!document.state) {
throw new Error("Expected collaborative state to be persisted");
}
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, document.state);
expect(yDocToProsemirrorJSON(ydoc, "default")).toEqual(document.content);
}
);
it("should leave retained mentions without attribution unchanged", async () => {
const user = await buildUser();
const mention = `@[Writer](mention://${randomUUID()}/user/${user.id})`;
let document = await buildDocument({
teamId: user.teamId,
content: parser.parse(mention).toJSON(),
});
const originalMentions = DocumentHelper.parseMentions(document);
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, { document, text: `${mention} updated` })
);
await document.reload();
expect(DocumentHelper.parseMentions(document)).toEqual(originalMentions);
});
it("should change lastModifiedById", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Changed",
document,
})
);
const event = await Event.findLatest({
teamId: user.teamId,
});
expect(document.lastModifiedById).toEqual(user.id);
expect(event!.name).toEqual("documents.update");
expect(event!.documentId).toEqual(document.id);
});
it("should change lastModifiedById when republishing an already published document", async () => {
const user = await buildUser();
const creator = await buildUser({ teamId: user.teamId });
let document = await buildDocument({
teamId: user.teamId,
userId: creator.id,
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Changed",
publish: true,
document,
})
);
expect(document.createdById).toEqual(creator.id);
expect(document.lastModifiedById).toEqual(user.id);
});
it("should not change lastModifiedById or generate event if nothing changed", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
title: document.title,
document,
})
);
expect(document.lastModifiedById).not.toEqual(user.id);
});
it("should notify collaboration server when text changes", async () => {
const notifyUpdateSpy = vi
.spyOn(APIUpdateExtension, "notifyUpdate")
.mockResolvedValue(undefined);
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "Initial text",
});
// Create initial collaborative state (simulating an active collaboration session)
const ydoc = ProsemirrorHelper.toYDoc("Initial text");
document.state = Buffer.from(Y.encodeStateAsUpdate(ydoc));
await document.save();
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Changed content",
document,
})
);
expect(notifyUpdateSpy).toHaveBeenCalledWith(document.id, user.id);
notifyUpdateSpy.mockRestore();
});
it("should not notify collaboration server when only title changes", async () => {
const notifyUpdateSpy = vi
.spyOn(APIUpdateExtension, "notifyUpdate")
.mockResolvedValue(undefined);
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
title: "New Title",
document,
})
);
expect(notifyUpdateSpy).not.toHaveBeenCalled();
notifyUpdateSpy.mockRestore();
});
it("should keep the loaded collection when the title changes", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const { id } = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const document = await Document.findByPk(id, {
userId: user.id,
rejectOnEmpty: true,
});
const loadedCollection = document.collection;
const result = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
title: "New Title",
document,
})
);
expect(result).toBe(document);
expect(result.collection).toBe(loadedCollection);
expect(result.collection?.documentStructure?.[0].title).toEqual(
"New Title"
);
});
describe("replace", () => {
it("should update document content when changing text", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Changed",
document,
})
);
expect(document.text).toEqual("Changed");
expect(document.content).toEqual({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Changed",
},
],
},
],
});
});
});
describe("append", () => {
it("should append document content when requested", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "Initial",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Appended",
document,
editMode: TextEditMode.Append,
})
);
expect(document.text).toEqual("InitialAppended");
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "InitialAppended" }],
},
],
});
});
it("should preserve rich content when appending", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "**Bold**",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Appended",
document,
editMode: TextEditMode.Append,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
marks: [{ type: "strong" }],
text: "Bold",
},
{
type: "text",
text: "Appended",
},
],
},
],
});
});
it("should preserve rich content from JSON when appending", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const id = randomUUID();
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
marks: [{ type: "comment", attrs: { id, userId: id } }],
text: "Italic",
},
],
},
],
};
await document.save();
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Appended",
document,
editMode: TextEditMode.Append,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
marks: [{ type: "comment", attrs: { id, userId: id } }],
text: "Italic",
},
{
type: "text",
text: "Appended",
},
],
},
],
});
});
it("should create new paragraph when appending with newline", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "Initial",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "\n\nAppended",
document,
editMode: TextEditMode.Append,
})
);
expect(document.text).toEqual("Initial\n\nAppended");
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Initial" }],
},
{
type: "paragraph",
content: [{ type: "text", text: "Appended" }],
},
],
});
});
});
describe("prepend", () => {
it("should prepend document content when requested", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "Existing",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Prepended",
document,
editMode: TextEditMode.Prepend,
})
);
expect(document.text).toEqual("PrependedExisting");
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "PrependedExisting" }],
},
],
});
});
it("should preserve rich content when prepending", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "**Bold**",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Prepended",
document,
editMode: TextEditMode.Prepend,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Prepended",
},
{
type: "text",
marks: [{ type: "strong" }],
text: "Bold",
},
],
},
],
});
});
it("should create new paragraph when prepending with newline", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "Existing",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Prepended\n\n",
document,
editMode: TextEditMode.Prepend,
})
);
expect(document.text).toEqual("Prepended\n\nExisting");
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Prepended" }],
},
{
type: "paragraph",
content: [{ type: "text", text: "Existing" }],
},
],
});
});
});
describe("patch", () => {
const listItem = (text: string, marks?: ProsemirrorMark[]) => ({
type: "list_item",
content: [
{
type: "paragraph",
content: [{ type: "text", text, ...(marks && { marks }) }],
},
],
});
/** The serialized markdown line of a document that contains the needle. */
const lineContaining = async (doc: Document, needle: string) =>
(await DocumentHelper.toMarkdown(doc, { includeTitle: false }))
.split("\n")
.find((line) => line.includes(needle))!;
it("should patch specific text in document content", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "Hello world\n\nThis is a test",
});
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Hello earth",
TextEditMode.Patch,
"Hello world"
);
const content = result.content!.content!;
expect(content).toHaveLength(2);
expect(content[0]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "Hello earth" }],
});
expect(content[1]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "This is a test" }],
});
});
it("should patch text retrieved via toMarkdown containing smart quotes and brackets", async () => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "the cat’s “meow” [loud]" }],
},
],
},
});
// findText is the exact text a client receives from retrieval.
const findText = await DocumentHelper.toMarkdown(document, {
includeTitle: false,
});
expect(findText).toBe("the cat’s “meow” [loud]");
const result = DocumentHelper.applyMarkdownToDocument(
document,
"quiet",
TextEditMode.Patch,
findText
);
expect(result.content!.content![0]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "quiet" }],
});
});
it("should throw when findText is not found in document", async () => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
text: "Hello world",
});
await expect(
withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "replacement",
findText: "nonexistent text",
document,
editMode: TextEditMode.Patch,
})
)
).rejects.toThrow("The specified text was not found in the document");
});
it("should handle dollar signs in replacement text literally", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "The price is TBD",
});
const result = DocumentHelper.applyMarkdownToDocument(
document,
"$100 & $200",
TextEditMode.Patch,
"TBD"
);
const content = result.content!.content!;
expect(content).toHaveLength(1);
expect(content[0]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "The price is $100 & $200" }],
});
});
it("should delete matched text when replacement is empty string", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "Hello beautiful world",
});
const result = DocumentHelper.applyMarkdownToDocument(
document,
"",
TextEditMode.Patch,
"beautiful "
);
const content = result.content!.content!;
expect(content).toHaveLength(1);
expect(content[0]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "Hello world" }],
});
});
it("should handle multi-block replacement in a single paragraph", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Replace this text here" }],
},
],
};
await document.save();
// Replace with content that parses to multiple blocks
const result = DocumentHelper.applyMarkdownToDocument(
document,
"First paragraph\n\nSecond paragraph",
TextEditMode.Patch,
"Replace this text here"
);
const content = result.content!.content!;
// Should produce two paragraphs, not silently drop the second
expect(content.length).toBeGreaterThanOrEqual(2);
expect(content[0]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "First paragraph" }],
});
expect(content[1]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "Second paragraph" }],
});
});
it("should patch multi-block content", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "# Heading\n\nOld content\n\nKeep this",
});
const result = DocumentHelper.applyMarkdownToDocument(
document,
"# New Heading\n\nNew content",
TextEditMode.Patch,
"# Heading\n\nOld content"
);
const content = result.content!.content!;
expect(content).toHaveLength(3);
expect(content[0]).toMatchObject({
type: "heading",
content: [{ type: "text", text: "New Heading" }],
});
expect(content[1]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "New content" }],
});
expect(content[2]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "Keep this" }],
});
});
it("should patch the middle item in a list", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "- First item\n- Second item\n- Third item",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "* Updated item",
findText: "* Second item",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "bullet_list",
content: [
listItem("First item"),
listItem("Updated item"),
listItem("Third item"),
],
},
{ type: "paragraph" },
],
});
});
it("should keep a notice nested in its list item when patching another item", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "1. Alpha one.\n2. Bravo two:\n\n :::info\n A note inside item two.\n :::\n3. Charlie three.",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "Amber",
findText: "Alpha",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "ordered_list",
content: [
listItem("Amber one."),
{
type: "list_item",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Bravo two:" }],
},
{
type: "container_notice",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "A note inside item two." },
],
},
],
},
],
},
listItem("Charlie three."),
],
},
{ type: "paragraph" },
],
});
});
it("should append new blocks after a table when patching the last row", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "## Sizes\n\n| Quantity | Value |\n|---|---|\n| alpha | 1 |\n| beta | 2 |\n| gamma | 3 |\n",
});
const lastRow = await lineContaining(document, "gamma");
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: `${lastRow}\n\n## New\n\nbody text`,
findText: lastRow,
document,
editMode: TextEditMode.Patch,
})
);
const content = document.content as ProsemirrorData;
expect(content.content?.map((n) => n.type)).toEqual([
"heading",
"table",
"heading",
"paragraph",
]);
expect(content.content?.[1].content).toHaveLength(4);
expect(document.text).toContain("gamma");
expect(document.text).toContain("## New");
expect(document.text).toContain("body text");
});
it("should not drop later rows when patching a middle table row with new blocks", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "| Quantity | Value |\n|---|---|\n| alpha | 1 |\n| beta | 2 |\n| gamma | 3 |\n",
});
const middleRow = await lineContaining(document, "beta");
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: `${middleRow}\n\n## New`,
findText: middleRow,
document,
editMode: TextEditMode.Patch,
})
);
expect(document.text).toContain("beta");
expect(document.text).toContain("## New");
expect(document.text).toContain("gamma");
});
it("should keep list items when the replacement uses a different bullet marker", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "* alpha\n* beta\n* gamma",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "- beta EDITED",
findText: "* beta",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "bullet_list",
content: [
listItem("alpha"),
listItem("beta EDITED"),
listItem("gamma"),
],
},
{ type: "paragraph" },
],
});
});
it("should keep later blockquote paragraphs when one is replaced with plain text", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "> alpha\n>\n> beta\n>\n> gamma\n\n## Notes",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "beta EDITED",
findText: "> beta",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.text).toContain("alpha");
expect(document.text).toContain("beta EDITED");
expect(document.text).toContain("gamma");
expect(document.text).toContain("## Notes");
});
it("should preserve comment marks on earlier rows when appending a row", async () => {
const user = await buildUser();
const commentId = randomUUID();
let document = await buildDocument({
teamId: user.teamId,
text: "| A | B |\n|---|---|\n| one | 1 |\n| two | 2 |",
});
// Attach a comment mark to the first body row cell text
const content = document.content as ProsemirrorData;
const table = content.content![0];
const firstRow = table.content![1];
const cellPara = firstRow.content![0].content![0];
cellPara.content![0].marks = [
{ type: "comment", attrs: { id: commentId, userId: user.id } },
];
document.content = content;
document.changed("content", true);
await document.save();
const lastRow = await lineContaining(document, "two");
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: `${lastRow}\n${lastRow.replace("two", "three").replace("2", "3")}`,
findText: lastRow,
document,
editMode: TextEditMode.Patch,
})
);
const result = document.content as ProsemirrorData;
const resultTable = result.content![0];
expect(resultTable.type).toBe("table");
expect(resultTable.content).toHaveLength(4);
const resultCellPara = resultTable.content![1].content![0].content![0];
expect(resultCellPara.content![0].marks).toMatchObject([
{ type: "comment", attrs: { id: commentId, userId: user.id } },
]);
expect(document.text).toContain("three");
});
it("should preserve comment marks on both neighbours when editing a middle item and adding another", async () => {
const user = await buildUser();
const commentId = randomUUID();
const commentMark = {
type: "comment",
attrs: { id: commentId, userId: user.id },
};
let document = await buildDocument({
teamId: user.teamId,
content: {
type: "doc",
content: [
{
type: "bullet_list",
content: [
listItem("alpha", [commentMark]),
listItem("beta"),
listItem("gamma", [commentMark]),
],
},
],
},
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "* beta EDITED\n* delta",
findText: "* beta",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "bullet_list",
content: [
listItem("alpha", [commentMark]),
listItem("beta EDITED"),
listItem("delta"),
listItem("gamma", [commentMark]),
],
},
{ type: "paragraph" },
],
});
});
it("should preserve comment marks on later items when removing an earlier item", async () => {
const user = await buildUser();
const commentId = randomUUID();
const commentMark = {
type: "comment",
attrs: { id: commentId, userId: user.id },
};
let document = await buildDocument({
teamId: user.teamId,
content: {
type: "doc",
content: [
{
type: "bullet_list",
content: [
listItem("alpha"),
listItem("beta"),
listItem("gamma", [commentMark]),
],
},
],
},
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "",
findText: "* alpha\n",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "bullet_list",
content: [listItem("beta"), listItem("gamma", [commentMark])],
},
{ type: "paragraph" },
],
});
});
it("should replace a whole list with a paragraph", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "# Title\n\n* only item\n\nafter",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "plain text now",
findText: "* only item",
document,
editMode: TextEditMode.Patch,
})
);
const content = document.content as ProsemirrorData;
expect(content.content?.map((n) => n.type)).toEqual([
"heading",
"paragraph",
"paragraph",
]);
expect(document.text).toContain("plain text now");
expect(document.text).not.toContain("only item");
expect(document.text).toContain("after");
});
it("should keep all items when a bullet item is changed to an ordered item", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "* alpha\n* beta\n* gamma",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "1. beta EDITED",
findText: "* beta",
document,
editMode: TextEditMode.Patch,
})
);
const content = document.content as ProsemirrorData;
expect(content.content?.map((n) => n.type)).toEqual([
"bullet_list",
"ordered_list",
"bullet_list",
"paragraph",
]);
expect(document.text).toContain("alpha");
expect(document.text).toContain("beta EDITED");
expect(document.text).toContain("gamma");
});
it("should keep one ordered list when the replacement uses a different delimiter", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "1. one\n2. two\n3. three",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "2) two EDITED",
findText: "2. two",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "ordered_list",
attrs: { order: 1 },
content: [
listItem("one"),
listItem("two EDITED"),
listItem("three"),
],
},
{ type: "paragraph" },
],
});
});
it("should not join adjacent ordered lists with different list styles", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "a. alpha",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "a. alpha\n1) numeric",
findText: "a. alpha",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "ordered_list",
attrs: { listStyle: "lower-alpha" },
content: [listItem("alpha")],
},
{
type: "ordered_list",
attrs: { listStyle: "number" },
content: [listItem("numeric")],
},
{ type: "paragraph" },
],
});
});
it("should keep nested items when a nested bullet marker changes", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "* alpha\n * one\n * two\n * three\n* beta",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: " - two EDITED",
findText: " * two",
document,
editMode: TextEditMode.Patch,
})
);
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "bullet_list",
content: [
{
type: "list_item",
content: [
{ type: "paragraph" },
{
type: "bullet_list",
content: [
listItem("one"),
listItem("two EDITED"),
listItem("three"),
],
},
],
},
listItem("beta"),
],
},
{ type: "paragraph" },
],
});
});
it("should append a new section after a blockquote that ends the document", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "> alpha\n>\n> beta",
});
document = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
text: "> beta\n\n## New\n\nbody",
findText: "> beta",
document,
editMode: TextEditMode.Patch,
})
);
const content = document.content as ProsemirrorData;
expect(content.content?.map((n) => n.type)).toEqual([
"blockquote",
"heading",
"paragraph",
]);
expect(content.content?.[0].content).toHaveLength(2);
expect(document.text).toContain("## New");
expect(document.text).toContain("body");
});
it("should preserve comment marks on untouched blocks when patching", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const commentId = randomUUID();
// Build content with a comment mark (not representable in markdown)
// on the LAST paragraph, then patch the FIRST paragraph.
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "First paragraph" }],
},
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "comment",
attrs: { id: commentId, userId: commentId },
},
],
text: "Commented text",
},
],
},
{
type: "paragraph",
content: [{ type: "text", text: "Third paragraph" }],
},
],
};
await document.save();
// Capture the ProseMirror JSON of untouched blocks BEFORE patching
// (with schema defaults already applied via toProsemirror → toJSON)
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const secondParaBefore = beforeDoc.content[1];
const thirdParaBefore = beforeDoc.content[2];
// Call DocumentHelper directly to inspect the ProseMirror result
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Updated first",
TextEditMode.Patch,
"First paragraph"
);
const content = result.content!.content!;
// Verify untouched blocks are byte-for-byte identical
expect(content[1]).toEqual(secondParaBefore);
expect(content[2]).toEqual(thirdParaBefore);
// Verify the patched block was actually updated
expect(content[0]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "Updated first" }],
});
// Verify we still have exactly 3 blocks
expect(content).toHaveLength(3);
});
it("should preserve comment marks when patching a later block", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const commentId = randomUUID();
// Comment mark on the FIRST paragraph, patch the THIRD.
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "comment",
attrs: { id: commentId, userId: commentId },
},
],
text: "Commented text",
},
],
},
{
type: "paragraph",
content: [{ type: "text", text: "Middle paragraph" }],
},
{
type: "paragraph",
content: [{ type: "text", text: "Last paragraph" }],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const firstParaBefore = beforeDoc.content[0];
const secondParaBefore = beforeDoc.content[1];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Updated last",
TextEditMode.Patch,
"Last paragraph"
);
const content = result.content!.content!;
// Both untouched blocks must be identical
expect(content[0]).toEqual(firstParaBefore);
expect(content[1]).toEqual(secondParaBefore);
// Patched block updated
expect(content[2]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "Updated last" }],
});
expect(content).toHaveLength(3);
});
it("should preserve comment marks in complex document when patching middle content", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const commentId = randomUUID();
// Complex document: heading, paragraph with comment, bullet list, paragraph to patch
document.content = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Section Title" }],
},
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "comment",
attrs: { id: commentId, userId: commentId },
},
],
text: "This has a comment",
},
],
},
{
type: "bullet_list",
content: [
{
type: "list_item",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Item one" }],
},
],
},
{
type: "list_item",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Item two" }],
},
],
},
],
},
{
type: "paragraph",
content: [{ type: "text", text: "Final paragraph to edit" }],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Edited final paragraph",
TextEditMode.Patch,
"Final paragraph to edit"
);
const content = result.content!.content!;
// All three untouched blocks must be byte-for-byte identical
expect(content[0]).toEqual(beforeDoc.content[0]);
expect(content[1]).toEqual(beforeDoc.content[1]);
expect(content[2]).toEqual(beforeDoc.content[2]);
// Patched block updated
expect(content[3]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "Edited final paragraph" }],
});
expect(content).toHaveLength(4);
});
it("should preserve comment mark when patching adjacent text in the same paragraph", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const commentId = randomUUID();
// Paragraph: "Hello world " + "commented"(with comment mark) + " end"
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Hello world " },
{
type: "text",
marks: [
{
type: "comment",
attrs: { id: commentId, userId: commentId },
},
],
text: "commented",
},
{ type: "text", text: " end" },
],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const commentNode = beforeDoc.content[0].content[1];
const endNode = beforeDoc.content[0].content[2];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Goodbye world ",
TextEditMode.Patch,
"Hello world "
);
const content = result.content!.content!;
expect(content).toHaveLength(1);
// Comment mark and trailing text must be preserved exactly
expect(content[0]).toMatchObject({
type: "paragraph",
content: [
{ type: "text", text: "Goodbye world " },
commentNode,
endNode,
],
});
});
it("should preserve rich content in other checklist items when patching one item", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const commentId = randomUUID();
// Checklist with 3 items; second item has a comment mark
document.content = {
type: "doc",
content: [
{
type: "checkbox_list",
content: [
{
type: "checkbox_item",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [{ type: "text", text: "First task" }],
},
],
},
{
type: "checkbox_item",
attrs: { checked: true },
content: [
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "comment",
attrs: { id: commentId, userId: commentId },
},
],
text: "Commented task",
},
],
},
],
},
{
type: "checkbox_item",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Third task" }],
},
],
},
],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const secondItem = beforeDoc.content[0].content[1];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Updated task",
TextEditMode.Patch,
"First task"
);
const list = result.content!.content![0];
// The second checklist item with its comment mark must be preserved
expect(list.content![1]).toEqual(secondItem);
// The first item should be updated
expect(list.content![0].content![0].content![0].text).toEqual(
"Updated task"
);
// All three items should still exist
expect(list.content).toHaveLength(3);
});
it("should patch checklist item checked state while preserving rich content in siblings", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const commentId = randomUUID();
document.content = {
type: "doc",
content: [
{
type: "checkbox_list",
content: [
{
type: "checkbox_item",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Buy groceries" }],
},
],
},
{
type: "checkbox_item",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "comment",
attrs: { id: commentId, userId: commentId },
},
],
text: "Review PR",
},
],
},
],
},
],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const secondItem = beforeDoc.content[0].content[1];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"- [x] Buy groceries",
TextEditMode.Patch,
"- [ ] Buy groceries"
);
const list = result.content!.content![0];
// Checked state should be updated
expect(list.content![0].attrs!.checked).toBe(true);
// Text should remain the same
expect(list.content![0].content![0].content![0].text).toEqual(
"Buy groceries"
);
// Second item with comment mark must be preserved exactly
expect(list.content![1]).toEqual(secondItem);
});
it("should uncheck a checkbox item while preserving rich content in siblings", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const commentId = randomUUID();
// First item is checked, second has a comment mark
document.content = {
type: "doc",
content: [
{
type: "checkbox_list",
content: [
{
type: "checkbox_item",
attrs: { checked: true },
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Buy groceries" }],
},
],
},
{
type: "checkbox_item",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "comment",
attrs: { id: commentId, userId: commentId },
},
],
text: "Review PR",
},
],
},
],
},
],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const secondItem = beforeDoc.content[0].content[1];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"- [ ] Buy groceries",
TextEditMode.Patch,
"- [x] Buy groceries"
);
const list = result.content!.content![0];
// Checked state should be updated to false
expect(list.content![0].attrs!.checked).toBe(false);
// Text should remain the same
expect(list.content![0].content![0].content![0].text).toEqual(
"Buy groceries"
);
// Second item with comment mark must be preserved exactly
expect(list.content![1]).toEqual(secondItem);
});
it("should preserve mention node when patching adjacent text in the same paragraph", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const mentionId = randomUUID();
// Paragraph: "Hello " + @mention + " please review this"
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Hello " },
{
type: "mention",
attrs: {
type: "user",
label: "Tom",
modelId: mentionId,
actorId: null,
id: mentionId,
},
},
{ type: "text", text: " please review this" },
],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const mentionNode = beforeDoc.content[0].content[1];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"please approve this",
TextEditMode.Patch,
"please review this"
);
const content = result.content!.content!;
expect(content).toHaveLength(1);
expect(content[0].type).toEqual("paragraph");
// The mention node must be preserved
expect(content[0].content![1]).toEqual(mentionNode);
});
it("should preserve table colwidth when patching a different cell", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
// Table with colwidth set on cells (not representable in markdown)
document.content = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tr",
content: [
{
type: "th",
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [150],
alignment: null,
},
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Header A" }],
},
],
},
{
type: "th",
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [250],
alignment: null,
},
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Header B" }],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [150],
alignment: null,
},
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Cell to edit" }],
},
],
},
{
type: "td",
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [250],
alignment: null,
},
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Keep this" }],
},
],
},
],
},
],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
// Second cell in second row should retain colwidth
const unchangedCell = beforeDoc.content[0].content[1].content[1];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Updated cell",
TextEditMode.Patch,
"Cell to edit"
);
const table = result.content!.content![0];
const secondRow = table.content![1];
// The unchanged cell must preserve its colwidth attr
expect(secondRow.content![1]).toEqual(unchangedCell);
expect(secondRow.content![1].attrs!.colwidth).toEqual([250]);
});
it("should preserve trailing whitespace in checklist items when patching", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
// Checklist where an item ends with a hard break (trailing spaces matter)
document.content = {
type: "doc",
content: [
{
type: "checkbox_list",
content: [
{
type: "checkbox_item",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Edit me" }],
},
],
},
{
type: "checkbox_item",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Line one" },
{ type: "br" },
{ type: "text", text: "Line two" },
],
},
],
},
],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const secondItem = beforeDoc.content[0].content[1];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Edited",
TextEditMode.Patch,
"Edit me"
);
const list = result.content!.content![0];
// The second item with its hard break must be preserved exactly
expect(list.content![1]).toEqual(secondItem);
});
it("should preserve rich content in blockquote when patching", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
const commentId = randomUUID();
// Blockquote with two paragraphs; second has a comment mark
document.content = {
type: "doc",
content: [
{
type: "blockquote",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Edit this line" }],
},
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "comment",
attrs: { id: commentId, userId: commentId },
},
],
text: "Keep this comment",
},
],
},
],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const secondPara = beforeDoc.content[0].content[1];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Edited line",
TextEditMode.Patch,
"Edit this line"
);
const blockquote = result.content!.content![0];
// Patched paragraph updated
expect(blockquote.content![0].content![0].text).toEqual("Edited line");
// Second paragraph with comment mark preserved exactly
expect(blockquote.content![1]).toEqual(secondPara);
});
it("should preserve a blockquote containing a table when patching a later link", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
const fillerParagraphs: ProsemirrorData[] = Array.from(
{ length: 242 },
(_, index) => ({
type: "paragraph",
content: [{ type: "text", text: `Quoted paragraph ${index + 1}` }],
})
);
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Before quote" }],
},
{
type: "blockquote",
content: [
{
type: "heading",
attrs: { level: 1 },
content: [{ type: "text", text: "Quoted source" }],
},
{
type: "table",
content: [
{
type: "tr",
content: [
{
type: "th",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Key" }],
},
],
},
{
type: "th",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Value" }],
},
],
},
],
},
{
type: "tr",
content: [
{
type: "td",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Alpha" }],
},
],
},
{
type: "td",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Keep this row" }],
},
],
},
],
},
],
},
...fillerParagraphs,
{
type: "paragraph",
content: [
{ type: "text", text: "See " },
{
type: "text",
marks: [
{
type: "link",
attrs: { href: "https://example.com/reference" },
},
],
text: "Reference",
},
{ type: "text", text: "." },
],
},
{
type: "paragraph",
content: [{ type: "text", text: "Tail sentinel." }],
},
],
},
{
type: "paragraph",
content: [{ type: "text", text: "After quote" }],
},
],
};
await document.save();
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const expected = structuredClone(beforeDoc);
expected.content[1].content[244].content[1].marks[0].attrs.href =
"https://example.com/reference#h-target";
const result = DocumentHelper.applyMarkdownToDocument(
document,
"> See [Reference](https://example.com/reference#h-target).",
TextEditMode.Patch,
"> See [Reference](https://example.com/reference)."
);
expect(result.content).toEqual(expected);
});
it("should always patch the first occurrence when findText appears multiple times", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "TODO item\n\nSome details\n\nTODO item",
});
const result = DocumentHelper.applyMarkdownToDocument(
document,
"DONE item",
TextEditMode.Patch,
"TODO item"
);
const content = result.content!.content!;
expect(content).toHaveLength(3);
// First occurrence replaced
expect(content[0]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "DONE item" }],
});
// Middle paragraph unchanged
expect(content[1]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "Some details" }],
});
// Second occurrence untouched
expect(content[2]).toMatchObject({
type: "paragraph",
content: [{ type: "text", text: "TODO item" }],
});
});
it("should patch text containing inline formatting", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
text: "This is **bold** text",
});
const result = DocumentHelper.applyMarkdownToDocument(
document,
"This is **strong** text",
TextEditMode.Patch,
"This is **bold** text"
);
const content = result.content!.content!;
expect(content).toHaveLength(1);
expect(content[0]).toMatchObject({
type: "paragraph",
content: [
{ type: "text", text: "This is " },
{
type: "text",
marks: [{ type: "strong" }],
text: "strong",
},
{ type: "text", text: " text" },
],
});
});
it("should insert replacement text verbatim inside a code block", async () => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
text: "```bash\nfirst line\necho start\n echo indented\nlast line\n```",
});
const result = DocumentHelper.applyMarkdownToDocument(
document,
"echo `date`\n echo indented",
TextEditMode.Patch,
"echo start\n echo indented"
);
const content = result.content!.content!;
expect(content).toHaveLength(2);
expect(content[1].type).toBe("paragraph");
expect(content[0]).toMatchObject({
type: "code_block",
content: [
{
type: "text",
text: "first line\necho `date`\n echo indented\nlast line",
},
],
});
expect(content[0].content![0].marks).toBeUndefined();
expect(() => schema.nodeFromJSON(result.content!).check()).not.toThrow();
});
it("should not apply marks when patching a single line in a code block", async () => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
text: "```python\nclass A:\n def init(self):\n pass\n```",
});
const result = DocumentHelper.applyMarkdownToDocument(
document,
"def __init__(self):",
TextEditMode.Patch,
"def init(self):"
);
const content = result.content!.content!;
expect(content).toHaveLength(2);
expect(content[1].type).toBe("paragraph");
expect(content[0]).toMatchObject({
type: "code_block",
content: [
{
type: "text",
text: "class A:\n def __init__(self):\n pass",
},
],
});
expect(content[0].content![0].marks).toBeUndefined();
});
it("should preserve ordered list container attrs when patching an item", async () => {
const user = await buildUser();
let document = await buildDocument({
teamId: user.teamId,
});
// Ordered list starting at 3 with lower-alpha style
document.content = {
type: "doc",
content: [
{
type: "ordered_list",
attrs: { order: 3, listStyle: "lower-alpha" },
content: [
{
type: "list_item",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "First item" }],
},
],
},
{
type: "list_item",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Second item" }],
},
],
},
],
},
],
};
await document.save();
const result = DocumentHelper.applyMarkdownToDocument(
document,
"Updated item",
TextEditMode.Patch,
"First item"
);
const list = result.content!.content![0];
// Container attrs must be preserved
expect(list.attrs!.order).toEqual(3);
expect(list.attrs!.listStyle).toEqual("lower-alpha");
// Patched item updated
expect(list.content![0].content![0].content![0].text).toEqual(
"Updated item"
);
// Unchanged item preserved
expect(list.content![1].content![0].content![0].text).toEqual(
"Second item"
);
});
it("should apply a link when wrapping existing table cell text", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser
.parse("| Name | Notes |\n|------|-------|\n| Alpha | see |\n")
.toJSON();
await document.save();
const result = DocumentHelper.applyMarkdownToDocument(
document,
"[see](https://example.com/docs)",
TextEditMode.Patch,
"see"
);
// The cell text is unchanged but should now carry a link mark — it must
// not be silently dropped during the merge.
const cellText =
result.content!.content![0].content![1].content![1].content![0]
.content![0];
expect(cellText.text).toEqual("see");
expect(cellText.marks).toEqual([
expect.objectContaining({
type: "link",
attrs: expect.objectContaining({ href: "https://example.com/docs" }),
}),
]);
});
it("should preserve other table cells when adding a link to one cell", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser
.parse(
"| Name | Notes |\n|------|-------|\n| Alpha | see |\n| Beta | other |\n"
)
.toJSON();
await document.save();
// Capture the untouched (Beta) row BEFORE patching so we can assert its
// structure and attrs are preserved exactly, not just its text.
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
const untouchedRow = beforeDoc.content[0].content[2];
const result = DocumentHelper.applyMarkdownToDocument(
document,
"see [docs](https://example.com/d)",
TextEditMode.Patch,
"see"
);
// The patched cell gained the link
expect(result.text).toContain("[docs](https://example.com/d)");
// The untouched row node must remain identical
expect(result.content!.content![0].content![2]).toEqual(untouchedRow);
});
it("should apply a mark when wrapping existing list item text", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
document.content = parser.parse("- clickme\n- other\n").toJSON();
await document.save();
const result = DocumentHelper.applyMarkdownToDocument(
document,
"[clickme](https://example.com)",
TextEditMode.Patch,
"clickme"
);
expect(result.text).toContain("[clickme](https://example.com)");
expect(result.text).toContain("other");
});
});
describe("block ids", () => {
const paragraph = (text: string) => ({
type: "paragraph",
content: [{ type: "text", text }],
});
const listId = randomUUID();
const toggleId = randomUUID();
const content: ProsemirrorData = {
type: "doc",
content: [
{
type: "checkbox_list",
attrs: { id: listId },
content: ["one", "two"].map((text) => ({
type: "checkbox_item",
attrs: { checked: false },
content: [paragraph(text)],
})),
},
{
type: "container_toggle",
attrs: { id: toggleId },
content: [paragraph("Title"), paragraph("Body")],
},
],
};
const blockIds = (document: Document) =>
(document.content?.content ?? [])
.filter((node) => node.type !== "paragraph")
.map((node) => node.attrs?.id);
it("assigns ids and a trailing paragraph to markdown written through the API", async () => {
const user = await buildUser();
const document = await buildDocument({ teamId: user.teamId });
const result = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
document,
text: "- [ ] one\n\n+++ Title\nBody\n+++",
})
);
const [checklist, toggle] = blockIds(result);
expect(checklist).toEqual(expect.any(String));
expect(toggle).toEqual(expect.any(String));
expect(result.content?.content?.at(-1)?.type).toBe("paragraph");
expect(result.text.trim().endsWith("+++")).toBe(true);
});
it.each([
["checkbox list", "two", "three"],
["toggle block", "Body", "Changed body"],
])("keeps the id of a patched %s", async (_name, findText, replacement) => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
content,
});
const result = await withAPIContext(user, (ctx) =>
documentUpdater(ctx, {
document,
text: replacement,
editMode: TextEditMode.Patch,
findText,
})
);
expect(result.text).toContain(replacement);
expect(blockIds(result)).toEqual([listId, toggleId]);
});
});
});