Files
outline/server/commands/documentUpdater.test.ts
T
Tom Moor 091346dfe8 chore: Migrate to vitest (#12272)
* wip

* Remove obsolete snapshots

* simplify

* chore(test): Convert mocks to TypeScript and tighten fetch mock types

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Remove unneccessary patches

* Migrate to msw instead of custom fetch mock

* Address PR review comments

- Split chained vi.useFakeTimers().setSystemTime() into separate calls.
- Switch test setup to dynamic imports so EventEmitter.defaultMaxListeners
  assignment runs before module init (static imports were hoisted above it).
- Drop redundant NODE_ENV guard in monkeyPatchSequelizeErrorsForJest; its
  sole caller already gates on env.isTest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:10:51 -04:00

1486 lines
42 KiB
TypeScript

import { randomUUID } from "node:crypto";
import * as Y from "yjs";
import { TextEditMode } from "@shared/types";
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
import { Event } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { buildDocument, buildUser } from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import documentUpdater from "./documentUpdater";
describe("documentUpdater", () => {
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();
});
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", () => {
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 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,
})
);
const listItem = (text: string) => ({
type: "list_item",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
expect(document.content).toMatchObject({
type: "doc",
content: [
{
type: "bullet_list",
content: [
listItem("First item"),
listItem("Updated item"),
listItem("Third item"),
],
},
],
});
});
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 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 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"
);
});
});
});