Merge remote-tracking branch 'gitea/develop' into HEAD

# Conflicts:
#	CHANGELOG.md
#	packages/mcp/build/index.js
#	packages/mcp/build/lib/auth-utils.js
#	packages/mcp/build/lib/docmost-schema.js
#	packages/mcp/build/lib/markdown-converter.js
This commit is contained in:
claude code agent 227
2026-06-30 02:43:04 +03:00
81 changed files with 5839 additions and 399 deletions
@@ -33,6 +33,11 @@ export class CollaborationGateway {
// @ts-ignore
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
null;
// Source ioredis client that RedisSyncExtension duplicates into its pub/sub
// pair. The extension's onDestroy only disconnects those duplicates, so we
// keep a reference here and disconnect the source ourselves on shutdown
// (otherwise the socket leaks and jest never exits in e2e).
private redisClient: RedisClient | null = null;
private readonly withRedis: boolean;
constructor(
@@ -57,16 +62,17 @@ export class CollaborationGateway {
});
if (this.withRedis) {
this.redisClient = new RedisClient({
host: this.redisConfig.host,
port: this.redisConfig.port,
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
});
// @ts-ignore
this.redisSync = new RedisSyncExtension({
redis: new RedisClient({
host: this.redisConfig.host,
port: this.redisConfig.port,
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
}),
redis: this.redisClient,
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
prefix: 'collab',
pack,
@@ -223,5 +229,10 @@ export class CollaborationGateway {
});
await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus });
// RedisSyncExtension.onDestroy (run via the hook above) disconnects only the
// duplicated pub/sub clients; the source client created here is ours to close.
this.redisClient?.disconnect();
this.redisClient = null;
}
}
@@ -36,6 +36,7 @@ import {
Mention,
Subpages,
Highlight,
Spoiler,
Indent,
UniqueID,
Columns,
@@ -82,6 +83,7 @@ export const tiptapExtensions = [
Superscript,
SubScript,
Highlight,
Spoiler,
Typography,
TrailingNode,
TextStyle,
@@ -205,31 +205,203 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
expect(historyQueue.add).toHaveBeenCalledTimes(1);
});
// #206 persist-6 — RED (it.failing): a momentarily-empty live Y.Doc must not
// overwrite non-empty persisted content. `onStoreDocument` empty-guards the
// LOAD path but not the STORE path, so today an empty doc (a client/agent
// glitch, a bad merge, an emptying transclusion) is written straight over the
// page and the content is wiped silently. A store-side empty-guard is a real
// behaviour change (a deliberate "select-all + delete" is also empty), so it
// is left UNFIXED pending a product decision; this documents the data-loss
// path and flips to a normal passing test the moment the guard lands.
it.failing(
'does NOT overwrite non-empty content with a momentarily-empty live doc (persist-6)',
async () => {
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
// #206 persist-6 / #248 — a momentarily-empty live Y.Doc must not overwrite
// non-empty persisted content. The store-side empty-guard blocks an empty doc
// (a client/agent glitch, a bad merge, an emptying transclusion) from wiping
// the page silently when NO intentional-clear signal is present.
it('does NOT overwrite non-empty content with a momentarily-empty live doc (persist-6)', async () => {
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
await ext.onStoreDocument(buildData(document, 'user') as any);
await ext.onStoreDocument(buildData(document, 'user') as any);
// Desired contract: the empty incoming doc is rejected and the rich page
// survives. Today updatePage is called with the empty content (data loss).
expect(pageRepo.updatePage).not.toHaveBeenCalled();
},
);
// The empty incoming doc is rejected and the rich page survives.
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
// #248 — an empty-over-empty store is allowed (nothing to lose); the guard
// only protects non-empty persisted content.
it('allows an empty store over already-empty content (#248)', async () => {
const liveEmptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(liveEmptyDoc);
// Stored content is empty per isEmptyParagraphDoc (paragraph with content:[])
// but NOT deep-equal to the normalized live doc, so the unchanged
// short-circuit is skipped and the empty-guard is genuinely reached.
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: { type: 'doc', content: [{ type: 'paragraph', content: [] }] },
});
await ext.onStoreDocument(buildData(document, 'user') as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
});
// #251 — REAL-PATH regression test. The intentional-clear signal is set via
// the actual transport seam (ext.onStateless with the exact stateless payload
// the client's IntentionalClear extension sends), NOT a hand-injected
// context.intentionalClear poke. We then run the debounced store with an empty
// live doc over non-empty persisted content and assert the empty write goes
// through — i.e. the clear persists.
it('persists an intentional clear signalled via the real stateless transport (#251)', async () => {
const documentName = `page.${PAGE_ID}`;
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
// The client signalled a deliberate clear over the live connection.
await ext.onStateless({
connection: { readOnly: false } as any,
documentName,
document: document as any,
payload: JSON.stringify({ type: 'intentional-clear' }),
} as any);
await ext.onStoreDocument(buildData(document, 'user') as any);
// The empty doc was written (the clear persisted). The persisted content is
// the Y.Doc round-trip of the empty doc (attrs normalized), so compare
// against fromYdoc rather than the raw literal.
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const expectedEmpty = TiptapTransformer.fromYdoc(document, 'default');
expect(pageRepo.updatePage.mock.calls[0][0].content).toEqual(expectedEmpty);
});
// #251 — retry correctness: a transient DB failure on the FIRST attempt must
// not silently drop the clear. The intentional-clear flag is consumed ONCE
// before the retry loop, so when attempt 1's updatePage throws (tx rolls back,
// but the in-memory flag delete cannot roll back) the retry on attempt 2 still
// sees the clear as allowed and writes the empty doc. On the pre-fix code
// (consumeIntentionalClear called INSIDE the loop) attempt 1 consumed the flag,
// attempt 2 re-read it as absent and the empty-guard BLOCKED the write — so
// updatePage would be called once and the clear would be lost. This test fails
// on that ordering and passes after the hoist.
it('persists an intentional clear even when the first store attempt fails transiently (#251)', async () => {
const documentName = `page.${PAGE_ID}`;
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
// The page stays non-empty in the DB across both attempts (the rolled-back
// first attempt never changed it), exactly the failure scenario the WARNING
// describes.
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
let attempts = 0;
pageRepo.updatePage.mockImplementation(async () => {
attempts += 1;
if (attempts === 1) throw new Error('deadlock detected'); // transient
callOrder.push('updatePage');
});
// The client signalled a deliberate clear over the live connection.
await ext.onStateless({
connection: { readOnly: false } as any,
documentName,
document: document as any,
payload: JSON.stringify({ type: 'intentional-clear' }),
} as any);
await ext.onStoreDocument(buildData(document, 'user') as any);
// First attempt failed and rolled back; the retry still honoured the clear
// and wrote the empty doc (the clear survived the retry).
expect(pageRepo.updatePage).toHaveBeenCalledTimes(2);
const expectedEmpty = TiptapTransformer.fromYdoc(document, 'default');
expect(pageRepo.updatePage.mock.calls[1][0].content).toEqual(expectedEmpty);
});
// #251 — the signal is single-use: it is consumed by the first empty store,
// so a SECOND accidental empty (no fresh signal) is still blocked.
it('consumes the intentional-clear signal once; a later empty is blocked (#251)', async () => {
const documentName = `page.${PAGE_ID}`;
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
await ext.onStateless({
connection: { readOnly: false } as any,
documentName,
document: ydocFor(emptyDoc) as any,
payload: JSON.stringify({ type: 'intentional-clear' }),
} as any);
// First empty store consumes the signal and writes.
await ext.onStoreDocument(buildData(ydocFor(emptyDoc), 'user') as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
// Re-arm findById to non-empty (as if content came back) and fire another
// empty store WITHOUT a new signal — the guard must block it.
pageRepo.updatePage.mockClear();
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
await ext.onStoreDocument(buildData(ydocFor(emptyDoc), 'user') as any);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
// #251 — a read-only connection cannot arm the clear, so its empty store is
// still blocked (defends the guard against a read-only spoof).
it('ignores an intentional-clear signal from a read-only connection (#251)', async () => {
const documentName = `page.${PAGE_ID}`;
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
await ext.onStateless({
connection: { readOnly: true } as any,
documentName,
document: document as any,
payload: JSON.stringify({ type: 'intentional-clear' }),
} as any);
await ext.onStoreDocument(buildData(document, 'user') as any);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
// #251 — a non-empty store between the signal and the empty store drops the
// pending flag ("cleared then retyped" can't leave a usable signal behind).
it('drops a pending clear when a non-empty store intervenes (#251)', async () => {
const documentName = `page.${PAGE_ID}`;
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
await ext.onStateless({
connection: { readOnly: false } as any,
documentName,
document: ydocFor(emptyDoc) as any,
payload: JSON.stringify({ type: 'intentional-clear' }),
} as any);
// A non-empty store lands first → consumes/drops the stale flag.
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN TEXT'));
await ext.onStoreDocument(
buildData(ydocFor(doc('NEW HUMAN TEXT')), 'user') as any,
);
pageRepo.updatePage.mockClear();
// Now an empty store with no fresh signal must be blocked.
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
await ext.onStoreDocument(buildData(ydocFor(emptyDoc), 'user') as any);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
// persist-1 — when every attempt fails the hook must NOT report a phantom
// success: no "page.updated" badge broadcast and no history snapshot for
@@ -4,6 +4,7 @@ import {
onChangePayload,
onDisconnectPayload,
onLoadDocumentPayload,
onStatelessPayload,
onStoreDocumentPayload,
} from '@hocuspocus/server';
import * as Y from 'yjs';
@@ -42,6 +43,35 @@ import {
} from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
/**
* #251 — wire format of the client→server stateless message that signals a
* deliberate page clear. The client (IntentionalClear editor extension) sends
* `{ type: INTENTIONAL_CLEAR_MESSAGE_TYPE }`; the document is taken from the
* connection, not the payload, so the signal cannot be aimed at another page.
*/
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
/**
* #251 — how long an intentional-clear signal stays "pending" before it is
* ignored. The signal is set on the clearing keystroke but consumed by the
* DEBOUNCED onStoreDocument, so the TTL must comfortably exceed the collab
* store debounce window (hocuspocus is configured with maxDebounce = 45s in
* collaboration.gateway.ts). 60s leaves a margin while keeping the window for a
* stale flag small; on top of the TTL, any non-empty store immediately drops a
* pending flag (see onStoreDocument), so a "cleared then retyped" sequence can
* never leave a usable flag behind.
*
* Known fail-safe limitation: the flag lives only in this node's process memory.
* If document ownership transfers to another node, or this node crashes/restarts,
* between the stateless signal (set on node A) and the debounced store, the
* in-memory flag is lost and the clear is silently NOT applied — the store-side
* empty-guard then reloads the document non-empty from the DB. This is
* deliberately fail-safe (a lost flag preserves content rather than destroying
* it), but it is a documented limitation, not a guarantee that every deliberate
* clear survives a node handoff.
*/
export const INTENTIONAL_CLEAR_TTL_MS = 60_000;
/**
* Resolve the provenance source for a coalesced snapshot.
*
@@ -107,6 +137,13 @@ export class PersistenceExtension implements Extension {
// coalescing window" per document and OR it across all edits in the window,
// so the snapshot is marked 'agent' regardless of who wrote last.
private agentTouched: Map<string, boolean> = new Map();
// #251 — per-document "intentional clear pending" flags. Keyed by
// documentName, value = expiry timestamp (ms). Set by onStateless when the
// client reports a deliberate clear; consumed once by the next
// onStoreDocument empty-guard branch. This is the per-EDIT channel the
// per-connection context cannot provide (a clear is an edit event, but the
// store is debounced and connection context is fixed at authentication).
private intentionalClear: Map<string, number> = new Map();
constructor(
private readonly pageRepo: PageRepo,
@@ -230,6 +267,19 @@ export class PersistenceExtension implements Extension {
this.consumeAgentTouched(documentName),
context?.actor,
);
// #251 — consume the intentional-clear flag ONCE, BEFORE the retry loop
// (like consumeContributors / consumeAgentTouched above). consumeIntentional-
// Clear ALWAYS deletes the in-memory Map entry, but a tx rollback cannot
// un-delete it. Calling it INSIDE the loop meant: a clear armed for attempt 1
// was consumed there, attempt 1's updatePage threw a transient error and
// rolled back, then attempt 2 re-read non-empty content and saw the flag
// already gone — silently downgrading the retry into a BLOCKED write, so the
// user's deliberate clear was dropped. Hoisting makes the decision stable
// across every attempt. This single call also preserves the "a non-empty
// store drops a pending flag" semantics (the cleared-then-retyped case):
// every store consumes the flag here regardless of incoming emptiness, so a
// subsequent non-empty store can never leave a usable flag behind.
const allowIntentionalClear = this.consumeIntentionalClear(documentName);
// Persist with a small bounded retry. The in-memory Y.Doc is the ONLY copy
// of the latest edit until this hook returns: hocuspocus destroys/unloads the
@@ -260,6 +310,46 @@ export class PersistenceExtension implements Extension {
return;
}
// #206 persist-6 / #248 — store-side empty-guard. A momentarily-empty
// live Y.Doc (a client/agent glitch, a bad merge, a transclusion that
// emptied) must NOT overwrite non-empty persisted content. The LOAD
// path already guards emptiness (onLoadDocument only hydrates from db
// when the live doc isEmpty); the STORE path did not, so an empty
// serialization was written straight over the page, wiping it
// silently.
//
// #251 — the ONE legitimate empty-over-non-empty write is a user who
// deliberately clears the page. That intent arrives out-of-band as a
// stateless message, NOT from the doc content, which is why it cannot
// be spoofed for non-clear writes: the flag is only ever read on this
// empty-incoming branch, so the worst a forged signal can do is clear
// a page the connection may already edit. The flag was consumed ONCE
// before the retry loop (`allowIntentionalClear`) so the decision is
// stable across retries; a non-empty store still drops any pending
// flag via that same hoisted consume (a "cleared then retyped"
// sequence can't leave a usable one behind).
const incomingEmpty = isEmptyParagraphDoc(tiptapJson as any);
if (
incomingEmpty &&
page.content &&
!isEmptyParagraphDoc(page.content as any)
) {
if (allowIntentionalClear) {
this.logger.debug(
`Intentional clear for ${pageId}: persisting empty doc over ` +
`non-empty content (user-signalled)`,
);
// fall through — the empty write is allowed exactly once.
} else {
this.logger.warn(
`Skipping store for ${pageId}: empty live doc would overwrite ` +
`non-empty persisted content`,
);
page = null;
return;
}
}
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
@@ -404,6 +494,37 @@ export class PersistenceExtension implements Extension {
}
}
/**
* #251 — receive the client's deliberate-clear signal. Records a short-lived,
* single-use pending flag for the originating document so the next
* onStoreDocument may let one empty-over-non-empty write through the guard.
*
* Hardening: read-only connections cannot arm the flag, and the document is
* taken from the connection (`data.documentName`), never the payload, so a
* client cannot target a page it isn't editing. The flag only ever RELAXES
* the guard for an empty write (a clear); it can never force or alter a
* non-empty write, so it is not a guard bypass for normal content.
*/
async onStateless(data: onStatelessPayload) {
const { connection, documentName, payload } = data;
if (connection?.readOnly) return;
let message: { type?: string } | undefined;
try {
message = JSON.parse(payload);
} catch {
return; // unrelated / malformed stateless message
}
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
this.intentionalClear.set(
documentName,
Date.now() + INTENTIONAL_CLEAR_TTL_MS,
);
}
async onChange(data: onChangePayload) {
const documentName = data.documentName;
const userId = data.context?.user?.id;
@@ -427,6 +548,7 @@ export class PersistenceExtension implements Extension {
const documentName = data.documentName;
this.contributors.delete(documentName);
this.agentTouched.delete(documentName);
this.intentionalClear.delete(documentName);
}
private consumeContributors(documentName: string): string[] {
@@ -444,6 +566,18 @@ export class PersistenceExtension implements Extension {
return touched;
}
/**
* #251 — read and clear the intentional-clear flag for this document. Returns
* true only if a flag was pending AND still within its TTL. Always deletes the
* entry so the signal is strictly single-use (one clear → one allowed empty
* write); an expired flag is treated as absent (guard still blocks).
*/
private consumeIntentionalClear(documentName: string): boolean {
const expiry = this.intentionalClear.get(documentName);
this.intentionalClear.delete(documentName);
return expiry !== undefined && Date.now() < expiry;
}
private async enqueuePageHistory(
page: Page,
lastUpdatedSource: string,
@@ -0,0 +1,278 @@
import * as Y from 'yjs';
import { getSchema } from '@tiptap/core';
import {
initProseMirrorDoc,
absolutePositionToRelativePosition,
prosemirrorJSONToYDoc,
} from '@tiptap/y-tiptap';
import { tiptapExtensions } from './collaboration.util';
import {
setYjsMark,
removeYjsMarkByAttribute,
updateYjsMarkAttribute,
type YjsSelection,
} from './yjs.util';
/**
* Unit tests for the server-side Yjs mark helpers used by the collaboration
* handler to set/resolve/delete comment marks directly on the shared Y.Doc
* (collaboration.handler.ts: setCommentMark / resolveCommentMark).
*
* The fragment shape mirrors production exactly: a `default` XmlFragment whose
* children are block XmlElements (paragraph) holding XmlText runs. For setYjsMark
* the selection is a pair of Yjs RelativePosition JSONs (what the client sends);
* we synthesize them from known ProseMirror absolute positions via
* absolutePositionToRelativePosition so the marked range is deterministic.
*/
const schema = getSchema(tiptapExtensions);
// Build a real Y.Doc from ProseMirror JSON (same path the collab handler uses
// via TiptapTransformer) and return the doc + its `default` fragment.
function buildFromPm(pmJson: unknown) {
const ydoc = prosemirrorJSONToYDoc(
schema,
pmJson as never,
'default',
) as unknown as Y.Doc;
const fragment = ydoc.getXmlFragment('default');
return { ydoc, fragment };
}
// Make a YjsSelection (anchor/head RelativePosition JSON) for two ProseMirror
// absolute positions in `fragment`.
function selectionFor(
fragment: Y.XmlFragment,
anchorPos: number,
headPos: number,
): YjsSelection {
const { mapping } = initProseMirrorDoc(fragment, schema);
const anchor = absolutePositionToRelativePosition(
anchorPos,
fragment as never,
mapping,
);
const head = absolutePositionToRelativePosition(
headPos,
fragment as never,
mapping,
);
return {
anchor: Y.relativePositionToJSON(anchor),
head: Y.relativePositionToJSON(head),
};
}
// The XmlText run of the i-th top-level paragraph.
function paragraphText(fragment: Y.XmlFragment, index = 0): Y.XmlText {
const para = fragment.get(index) as Y.XmlElement;
return para.get(0) as Y.XmlText;
}
// --- raw fragment builder for the remove/update tests (no schema needed) ---
//
// removeYjsMarkByAttribute / updateYjsMarkAttribute only read item.toDelta() and
// call item.format(); they never touch the ProseMirror schema. Build the runs
// directly so we control which segment carries which comment attrs.
function buildWithComments(
segments: Array<{
text: string;
comment?: { commentId: string; resolved: boolean };
}>,
): { fragment: Y.XmlFragment; text: Y.XmlText } {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
let offset = 0;
for (const seg of segments) {
text.insert(offset, seg.text);
if (seg.comment) {
text.format(offset, seg.text.length, { comment: seg.comment });
}
offset += seg.text.length;
}
return { fragment, text };
}
describe('setYjsMark', () => {
it('applies the mark over exactly the selected sub-range (PM pos 1..6 = "Hello")', () => {
const { ydoc, fragment } = buildFromPm({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
],
});
// PM pos 1 = start of the paragraph text; pos 6 = just after "Hello".
const sel = selectionFor(fragment, 1, 6);
setYjsMark(ydoc as never, fragment, sel, 'comment', {
commentId: 'c1',
resolved: false,
});
// The run splits: "Hello" carries the comment mark, " world" stays clean.
expect(paragraphText(fragment).toDelta()).toEqual([
{
insert: 'Hello',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
{ insert: ' world' },
]);
});
it('normalizes a reversed selection (head before anchor) to the same range', () => {
const { ydoc, fragment } = buildFromPm({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
],
});
// anchor=6, head=1 — reversed; setYjsMark takes min/max so it marks "Hello".
const sel = selectionFor(fragment, 6, 1);
setYjsMark(ydoc as never, fragment, sel, 'comment', {
commentId: 'c2',
resolved: false,
});
expect(paragraphText(fragment).toDelta()).toEqual([
{
insert: 'Hello',
attributes: { comment: { commentId: 'c2', resolved: false } },
},
{ insert: ' world' },
]);
});
it('marks across two paragraphs (range spans an element boundary)', () => {
const { ydoc, fragment } = buildFromPm({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'aaa' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'bbb' }] },
],
});
// PM positions: "aaa" = 1..4; the </p><p> boundary consumes pos 4 and 5, so
// "bbb" starts at pos 6 (chars at 6,7,8). Select pos 2 (inside "aaa") to pos
// 8 (after the second "b").
const sel = selectionFor(fragment, 2, 8);
setYjsMark(ydoc as never, fragment, sel, 'comment', {
commentId: 'c3',
resolved: false,
});
// First paragraph: "a" clean, "aa" marked.
expect(paragraphText(fragment, 0).toDelta()).toEqual([
{ insert: 'a' },
{
insert: 'aa',
attributes: { comment: { commentId: 'c3', resolved: false } },
},
]);
// Second paragraph: "bb" marked, "b" clean.
expect(paragraphText(fragment, 1).toDelta()).toEqual([
{
insert: 'bb',
attributes: { comment: { commentId: 'c3', resolved: false } },
},
{ insert: 'b' },
]);
});
});
describe('removeYjsMarkByAttribute', () => {
it('removes only the run whose attribute value matches, leaving others', () => {
const { fragment, text } = buildWithComments([
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
{ text: 'BBB', comment: { commentId: 'c2', resolved: false } },
]);
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
// c1's run loses the mark; c2's run is untouched.
expect(text.toDelta()).toEqual([
{ insert: 'AAA' },
{
insert: 'BBB',
attributes: { comment: { commentId: 'c2', resolved: false } },
},
]);
});
it('does nothing when no run carries the requested value (no-match branch)', () => {
const { fragment, text } = buildWithComments([
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
]);
const before = text.toDelta();
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'does-not-exist');
expect(text.toDelta()).toEqual(before);
});
it('leaves a different mark type alone', () => {
// A run carrying only `bold` must survive a comment removal pass.
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, 'XYZ');
text.format(0, 3, { bold: true });
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
expect(text.toDelta()).toEqual([
{ insert: 'XYZ', attributes: { bold: true } },
]);
});
});
describe('updateYjsMarkAttribute', () => {
it('merges new attributes into the matching run, preserving the rest', () => {
const { fragment, text } = buildWithComments([
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
{ text: 'BBB', comment: { commentId: 'c2', resolved: false } },
]);
updateYjsMarkAttribute(
fragment,
'comment',
{ name: 'commentId', value: 'c1' },
{ resolved: true },
);
// c1's run flips resolved=true (commentId preserved via merge); c2 untouched.
expect(text.toDelta()).toEqual([
{
insert: 'AAA',
attributes: { comment: { commentId: 'c1', resolved: true } },
},
{
insert: 'BBB',
attributes: { comment: { commentId: 'c2', resolved: false } },
},
]);
});
it('does nothing when no run matches (no-match branch)', () => {
const { fragment, text } = buildWithComments([
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
]);
const before = text.toDelta();
updateYjsMarkAttribute(
fragment,
'comment',
{ name: 'commentId', value: 'nope' },
{ resolved: true },
);
expect(text.toDelta()).toEqual(before);
});
});