Compare commits

..

2 Commits

Author SHA1 Message Date
claude code agent 227
cce539e8e2 fix(collab): hoist intentional-clear consume out of the store retry loop (#251)
The store-side empty-guard consumed the per-document intentional-clear flag
INSIDE the bounded retry loop. consumeIntentionalClear always deletes the
in-memory Map entry, but a tx rollback cannot un-delete it: attempt 1
consumed the flag then updatePage threw a transient error and rolled back;
attempt 2 re-read the page non-empty, saw the flag gone, and the empty-guard
silently BLOCKED the write — dropping the user's deliberate clear and
defeating the retry guarantee for clears.

Hoist the decision out of the loop (like consumeContributors /
consumeAgentTouched): consume once into `allowIntentionalClear` before the
`for`, and only read that boolean on the empty-over-non-empty branch. The
single hoisted consume still drops a pending flag for a non-empty store
(the "cleared then retyped" case), since every store consumes regardless of
incoming emptiness.

Add a regression test: arm via the real onStateless transport, updatePage
throws once then succeeds, assert it is called twice and the retry writes the
empty doc (the clear survives). It fails on the old consume-in-loop ordering
(updatePage called once) and passes after the hoist.

Document the known fail-safe limitation near the TTL constant: if document
ownership transfers / a node crashes between the stateless signal and the
debounced store, the in-memory flag is lost and the clear is silently not
applied (the doc reloads non-empty) — fail-safe, content is never destroyed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:17:41 +03:00
claude code agent 227
3fdb1e05a4 feat(collab): persist a deliberate page clear via an intentional-clear signal (#251)
The #248 store-side empty-guard (onStoreDocument) unconditionally refuses to
overwrite non-empty persisted content with an empty document, because a
momentarily-empty live Y.Doc is indistinguishable from a real clear at the
store layer. That correctly blocks glitches/bad-merges, but also blocks a user
who genuinely wants to empty a page. This re-introduces a WORKING, narrow,
non-spoofable exception (the dead context.intentionalClear hatch #248 removed
never had a real channel).

Definition of an intentional clear (client, IntentionalClear editor extension):
a LOCAL user transaction (docChanged, NOT a remote y-sync change — filtered via
isChangeOrigin) that reduces a non-empty doc to the empty single-paragraph
shape. This is exactly the select-all + Delete/Backspace keystroke path.

Transport (option b — hocuspocus stateless message): on that transition the
client sends a `{type:'intentional-clear'}` stateless message. The server
(PersistenceExtension.onStateless) records a short-lived (TTL 60s > 45s
maxDebounce), single-use "pending clear" flag keyed by the connection's
document. The next debounced onStoreDocument consumes it on the empty-guard
branch to let that one empty write through.

Why this is the right channel and non-spoofable:
- Yjs transaction origin/metadata does not survive to the server store; awareness
  is per-connection and racy. A stateless message ties the signal to a specific
  clear, survives the debounce, and rides the authenticated connection.
- The document is taken from the connection, never the payload, so a client
  cannot target another page.
- The flag is read ONLY on the empty-over-non-empty branch, so the worst a forged
  signal can do is clear a page the connection may already edit; it can never
  force or alter a non-empty write. Read-only connections cannot arm it. Every
  non-empty store drops a pending flag, so "cleared then retyped" leaves nothing
  usable; the flag is single-use and TTL-bounded.

NOTE: #248 is not yet on develop, so the empty-guard block is included here as
the foundation this exception extends. If #248 lands first this rebases cleanly
(the guard logic is identical; the #251-unique additions are the exception,
onStateless, the pending-flag state, and the client extension).

Tests:
- Server (real transport path, not a hand-poke): onStateless sets the flag with
  the exact client payload, then the debounced onStoreDocument persists the empty
  doc; plus single-use consumption, read-only rejection, non-empty-store drops
  the flag, and the unchanged #248 guard tests (empty-over-non-empty blocked,
  empty-over-empty allowed).
- Client: a real Editor + the actual selectAll+deleteSelection command emits the
  signal; typing / non-emptying edits / already-empty docs do not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:06:39 +03:00
25 changed files with 568 additions and 2154 deletions

View File

@@ -123,6 +123,7 @@ import { countWords } from "alfaaz";
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
import { IntentionalClear } from "@/features/editor/extensions/intentional-clear.ts";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -486,4 +487,10 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
// #251 — emit an intentional-clear signal to the server when the user
// deliberately empties the page, so the #248 store-side empty-guard lets that
// one clear through while still blocking accidental empties.
IntentionalClear.configure({
provider,
}),
];

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import {
IntentionalClear,
INTENTIONAL_CLEAR_MESSAGE_TYPE,
} from "./intentional-clear";
/**
* #251 — the intentional-clear signal is driven through the REAL editor path:
* a fresh Editor with the IntentionalClear extension, a fake provider that
* records sendStateless, and the actual select-all + delete command the user's
* keystroke runs. No hand-poke of any flag.
*/
describe("IntentionalClear extension", () => {
let sendStateless: ReturnType<typeof vi.fn>;
const makeEditor = (content: unknown) =>
new Editor({
extensions: [
Document,
Paragraph,
Text,
IntentionalClear.configure({
// Minimal provider stand-in: only sendStateless is exercised.
provider: { sendStateless } as any,
}),
],
content: content as any,
});
beforeEach(() => {
sendStateless = vi.fn();
});
it("emits the clear signal when a user empties a non-empty doc (select-all + delete)", () => {
const editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "hello world" }] },
],
});
// The exact command path a select-all + Delete keystroke dispatches.
editor.chain().selectAll().deleteSelection().run();
expect(sendStateless).toHaveBeenCalledTimes(1);
const payload = JSON.parse(sendStateless.mock.calls[0][0]);
expect(payload).toEqual({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE });
editor.destroy();
});
it("does NOT emit when typing into an empty doc (no non-empty → empty transition)", () => {
const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });
editor.chain().insertContent("typed text").run();
expect(sendStateless).not.toHaveBeenCalled();
editor.destroy();
});
it("does NOT emit on an edit that leaves the doc non-empty", () => {
const editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "keep me" }] },
],
});
editor.chain().insertContent(" more").run();
expect(sendStateless).not.toHaveBeenCalled();
editor.destroy();
});
it("does NOT emit when the doc was already empty", () => {
const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });
// Selecting all + delete on an already-empty doc is a no-op transition.
editor.chain().selectAll().deleteSelection().run();
expect(sendStateless).not.toHaveBeenCalled();
editor.destroy();
});
});

View File

@@ -0,0 +1,94 @@
import { Extension } from "@tiptap/core";
import { isChangeOrigin } from "@tiptap/extension-collaboration";
import type { Node as PMNode } from "@tiptap/pm/model";
import type { HocuspocusProvider } from "@hocuspocus/provider";
/**
* Stateless message type sent to the server when a user deliberately clears a
* page to empty. Kept in one place so the client emitter and the server
* consumer (PersistenceExtension.onStateless) agree on the wire format.
*/
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = "intentional-clear";
export interface IntentionalClearOptions {
/** The collab provider used to send the stateless clear signal. */
provider: HocuspocusProvider | null;
}
/**
* A "document is empty" check that mirrors the server's `isEmptyParagraphDoc`
* (collaboration.util.ts): exactly one top-level paragraph with no inline
* content. After a select-all + delete TipTap leaves precisely this shape, so
* matching it here keeps the client signal aligned with the server guard that
* consumes it.
*/
function isEmptyParagraphDoc(doc: PMNode): boolean {
if (doc.childCount !== 1) return false;
const child = doc.firstChild;
return (
child !== null &&
child !== undefined &&
child.type.name === "paragraph" &&
child.content.size === 0
);
}
/**
* #251 — intentional-clear signal.
*
* The server's #248 store-side empty-guard unconditionally refuses to overwrite
* non-empty persisted content with an empty document, because a momentarily
* empty live Y.Doc (a glitch, a bad merge, an emptying transclusion) is
* indistinguishable from a real clear *at the store layer*. That protection is
* correct, but it also blocks a user who genuinely wants to empty the page.
*
* This extension supplies the missing distinction. It watches LOCAL, user-driven
* transactions and, the moment one reduces a non-empty document to the empty
* single-paragraph shape, it sends a hocuspocus stateless message to the server.
* The server records a short-lived, single-use "intentional clear pending" flag
* for this document that the next (debounced) onStoreDocument consumes to let
* that one empty write through the guard.
*
* What counts as an intentional clear (precise definition):
* - the transaction actually changed the document (`docChanged`), AND
* - it is a LOCAL user edit, not a remote collab application — remote y-sync
* transactions are tagged and filtered out via `isChangeOrigin`, so an
* emptiness that arrives from another client / a merge never emits a signal,
* AND
* - the document was non-empty before the transaction and is the empty
* single-paragraph doc after it.
*
* This is exactly the select-all + Delete / Backspace (or any local command that
* empties the doc, e.g. clearContent) keystroke path. A transient/programmatic
* empty serialization that the server might see on the wire does NOT come with
* this signal, so the guard still blocks it.
*/
export const IntentionalClear = Extension.create<IntentionalClearOptions>({
name: "intentionalClear",
addOptions() {
return {
provider: null,
};
},
onTransaction({ transaction }) {
if (!transaction.docChanged) return;
// Only react to local user edits. Remote collaboration steps (and other
// y-sync-applied changes) carry the change origin and must never be treated
// as an intentional clear, otherwise a remote/merge-induced emptiness would
// punch through the server guard.
if (isChangeOrigin(transaction)) return;
const becameEmpty =
!isEmptyParagraphDoc(transaction.before) &&
isEmptyParagraphDoc(transaction.doc);
if (!becameEmpty) return;
// The server reads the originating document from the connection, so the
// payload only needs to declare intent — it cannot target another document.
this.options.provider?.sendStateless(
JSON.stringify({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE }),
);
},
});

View File

@@ -1,7 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import i18n from "@/i18n.ts";
import {
formatRelativeTime,
getTimeGroup,
groupNotificationsByTime,
} from "@/features/notification/notification.utils.ts";
@@ -134,59 +132,3 @@ describe("groupNotificationsByTime", () => {
expect(groupNotificationsByTime([], labels)).toEqual([]);
});
});
describe("formatRelativeTime — relative buckets and absolute-date fallback", () => {
// Distinct fixed clock for the relative formatter (uses Date.now via `new
// Date()`), so the bucket boundaries are deterministic under fake timers.
const NOW = new Date("2026-06-15T12:00:00.000Z");
const MIN = 60_000;
beforeEach(() => {
vi.setSystemTime(NOW);
});
// ISO string `ms` milliseconds before NOW.
function ago(ms: number): string {
return new Date(NOW.getTime() - ms).toISOString();
}
it("returns the i18n 'now' label for anything under a minute", () => {
expect(formatRelativeTime(ago(0))).toBe(i18n.t("now"));
expect(formatRelativeTime(ago(59_000))).toBe(i18n.t("now"));
});
it("crosses into the minutes bucket exactly at 1 minute", () => {
expect(formatRelativeTime(ago(MIN - 1000))).toBe(i18n.t("now"));
expect(formatRelativeTime(ago(MIN))).toBe("1m");
expect(formatRelativeTime(ago(5 * MIN))).toBe("5m");
expect(formatRelativeTime(ago(59 * MIN))).toBe("59m");
});
it("crosses into the hours bucket exactly at 60 minutes", () => {
expect(formatRelativeTime(ago(60 * MIN - 1000))).toBe("59m");
expect(formatRelativeTime(ago(HOUR))).toBe("1h");
expect(formatRelativeTime(ago(23 * HOUR))).toBe("23h");
});
it("crosses into the days bucket exactly at 24 hours", () => {
expect(formatRelativeTime(ago(24 * HOUR - 1000))).toBe("23h");
expect(formatRelativeTime(ago(DAY))).toBe("1d");
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d");
});
it("falls back to an absolute short date once >= 7 days old", () => {
// 6d -> still relative; 7d -> absolute date (no longer N[mhd], and equal to
// the localized short-date of the source timestamp).
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d");
const sevenDaysAgo = ago(7 * DAY);
const result = formatRelativeTime(sevenDaysAgo);
expect(result).not.toMatch(/^\d+[mhd]$/);
expect(result).not.toBe(i18n.t("now"));
const expected = new Intl.DateTimeFormat(i18n.language, {
month: "short",
day: "numeric",
}).format(new Date(sevenDaysAgo));
expect(result).toBe(expected);
});
});

View File

@@ -1,79 +0,0 @@
import { describe, it, expect } from "vitest";
import { findBreadcrumbPath } from "./utils";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
// findBreadcrumbPath walks the live, SHARED sidebar tree. The high-value
// invariant: when a node has no usable name it must surface "Untitled" ONLY on
// the returned breadcrumb chain via a shallow copy — never by mutating the input
// node (which would silently rename the node in the sidebar). Also covers normal
// ancestor-chain resolution, the not-found case, and nested children.
function node(id: string, over: Partial<SpaceTreeNode> = {}): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: false,
children: [],
...over,
};
}
describe("findBreadcrumbPath", () => {
it("does NOT mutate the input tree when a node has an empty/whitespace name", () => {
// A whitespace-only-named node nested under a blank-named root.
const target = node("target", { name: " " });
const root = node("root", { name: "", hasChildren: true, children: [target] });
const tree = [root];
const result = findBreadcrumbPath(tree, "target");
expect(result).not.toBeNull();
// The RETURNED chain shows "Untitled" for both blank nodes.
expect(result!.map((n) => n.name)).toEqual(["Untitled", "Untitled"]);
// The original input nodes are untouched (still blank).
expect(root.name).toBe("");
expect(target.name).toBe(" ");
// The renamed breadcrumb entries are fresh copies, not the input objects.
expect(result![0]).not.toBe(root);
expect(result![1]).not.toBe(target);
});
it("returns the SAME node reference (no copy) when the name is non-empty", () => {
// No rename needed -> the node is passed through by reference (cheap path).
const target = node("target", { name: "Real Title" });
const result = findBreadcrumbPath([target], "target");
expect(result![0]).toBe(target);
expect(result![0].name).toBe("Real Title");
});
it("resolves the full ancestor chain ending at the target", () => {
const target = node("c");
const mid = node("b", { hasChildren: true, children: [target] });
const root = node("a", { hasChildren: true, children: [mid] });
const result = findBreadcrumbPath([root], "c");
expect(result!.map((n) => n.id)).toEqual(["a", "b", "c"]);
});
it("finds a target nested under a deeper sibling branch", () => {
// Two root branches; the target lives inside the second branch's child.
const target = node("deep");
const branch2 = node("r2", {
hasChildren: true,
children: [node("x"), node("y", { hasChildren: true, children: [target] })],
});
const branch1 = node("r1", { hasChildren: true, children: [node("z")] });
const result = findBreadcrumbPath([branch1, branch2], "deep");
expect(result!.map((n) => n.id)).toEqual(["r2", "y", "deep"]);
});
it("returns null when the page id is not present in the tree", () => {
const root = node("root", { hasChildren: true, children: [node("child")] });
expect(findBreadcrumbPath([root], "missing")).toBeNull();
expect(findBreadcrumbPath([], "anything")).toBeNull();
});
});

View File

@@ -8,8 +8,6 @@ import {
closeIds,
mergeRootTrees,
loadedOpenBranchIds,
sortPositionKeys,
pageToTreeNode,
} from "./utils";
import type { IPage } from "@/features/page/types/page.types.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -62,82 +60,6 @@ function treeNode(id: string, children: SpaceTreeNode[] = []): SpaceTreeNode {
};
}
describe("sortPositionKeys", () => {
it("orders items ascending by their fractional `position` string", () => {
const items = [
{ id: "c", position: "a5" },
{ id: "a", position: "a1" },
{ id: "b", position: "a3" },
];
expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["a", "b", "c"]);
});
it("is a stable sort: equal positions keep their input order", () => {
const items = [
{ id: "x", position: "a1" },
{ id: "y", position: "a1" },
{ id: "z", position: "a1" },
];
expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["x", "y", "z"]);
});
});
describe("pageToTreeNode", () => {
function pageRow(over: Partial<IPage> = {}): IPage {
return {
id: "p1",
slugId: "slug-p1",
title: "My Page",
icon: "📄",
position: "a1",
hasChildren: true,
spaceId: "space-1",
parentPageId: null as unknown as string,
...over,
} as IPage;
}
it("maps page.title -> node.name and copies the core fields", () => {
const node = pageToTreeNode(pageRow());
// The non-trivial transform: a page's `title` becomes the tree node's `name`.
expect(node.name).toBe("My Page");
expect(node.id).toBe("p1");
expect(node.slugId).toBe("slug-p1");
expect(node.icon).toBe("📄");
expect(node.position).toBe("a1");
expect(node.spaceId).toBe("space-1");
expect(node.hasChildren).toBe(true);
// Always materialized with an empty children array.
expect(node.children).toEqual([]);
});
it("derives canEdit from page.permissions.canEdit when the flat field is absent", () => {
const node = pageToTreeNode(
pageRow({ canEdit: undefined, permissions: { canEdit: true } } as Partial<IPage>),
);
expect(node.canEdit).toBe(true);
});
it("prefers the flat page.canEdit over permissions.canEdit", () => {
const node = pageToTreeNode(
pageRow({ canEdit: false, permissions: { canEdit: true } } as Partial<IPage>),
);
expect(node.canEdit).toBe(false);
});
it("carries temporaryExpiresAt straight off the page", () => {
const expiresAt = "2026-06-27T21:00:00.000Z";
expect(pageToTreeNode(pageRow({ temporaryExpiresAt: expiresAt })).temporaryExpiresAt).toBe(
expiresAt,
);
});
it("applies overrides on top of the mapped fields (e.g. optimistic blank name)", () => {
const node = pageToTreeNode(pageRow(), { name: "" });
expect(node.name).toBe("");
});
});
describe("buildTree", () => {
it("builds one node per unique page", () => {
const tree = buildTree([page("a", "a1"), page("b", "a2")]);

View File

@@ -70,22 +70,18 @@ export function findBreadcrumbPath(
path: SpaceTreeNode[] = [],
): SpaceTreeNode[] | null {
for (const node of tree) {
// Never mutate the input tree (it is the live, shared sidebar tree state).
// When a node has no usable name, surface "Untitled" via a shallow copy that
// only the returned breadcrumb chain sees — the source node stays untouched.
const displayNode: SpaceTreeNode =
!node.name || node.name.trim() === ""
? { ...node, name: "Untitled" }
: node;
if (!node.name || node.name.trim() === "") {
node.name = "Untitled";
}
if (node.id === pageId) {
return [...path, displayNode];
return [...path, node];
}
if (node.children) {
const newPath = findBreadcrumbPath(node.children, pageId, [
...path,
displayNode,
node,
]);
if (newPath) {
return newPath;

View File

@@ -3,7 +3,6 @@ import {
applyAddTreeNode,
applyMoveTreeNode,
applyDeleteTreeNode,
applyUpdateOne,
} from "./tree-socket-reducers";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -339,76 +338,3 @@ describe("applyAddTreeNode", () => {
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
});
});
describe("applyUpdateOne", () => {
// A loaded two-level tree so we can patch both a root and a nested node.
const buildTree = (): SpaceTreeNode[] => [
node("root", {
position: "a0",
name: "Root",
icon: "📁",
hasChildren: true,
children: [node("child", { position: "a1", parentPageId: "root", name: "Child", icon: "📄" })],
}),
];
// Build the UpdateEvent envelope; only `id`/`payload` matter to the reducer.
const ev = (id: string, payload: Record<string, unknown>) =>
({
operation: "updateOne",
spaceId: "space-1",
entity: ["pages"],
id,
payload,
}) as unknown as Parameters<typeof applyUpdateOne>[1];
it("applies a title-only update to the node's name (icon untouched)", () => {
const tree = buildTree();
const next = applyUpdateOne(tree, ev("child", { title: "Renamed" }));
const child = treeModel.find(next, "child");
expect(child?.name).toBe("Renamed");
// Icon is left as it was.
expect(child?.icon).toBe("📄");
});
it("applies an icon-only update to the node's icon (name untouched)", () => {
const tree = buildTree();
const next = applyUpdateOne(tree, ev("root", { icon: "🔥" }));
const root = treeModel.find(next, "root");
expect(root?.icon).toBe("🔥");
expect(root?.name).toBe("Root");
});
it("applies a combined title + icon update", () => {
const tree = buildTree();
const next = applyUpdateOne(tree, ev("child", { title: "Both", icon: "⭐" }));
const child = treeModel.find(next, "child");
expect(child?.name).toBe("Both");
expect(child?.icon).toBe("⭐");
});
it("returns prev UNCHANGED (same reference) when the id is not loaded", () => {
const tree = buildTree();
const next = applyUpdateOne(tree, ev("ghost", { title: "Nope" }));
expect(next).toBe(tree);
});
it("returns prev UNCHANGED (same reference) for a no-op payload (no title/icon)", () => {
// The node exists, but the payload carries neither title nor icon -> nothing
// to patch, so the reducer must hand back the same array reference.
const tree = buildTree();
const next = applyUpdateOne(tree, ev("child", {}));
expect(next).toBe(tree);
});
it("treats an explicit null icon/title as a value to apply (undefined check, not truthiness)", () => {
// The reducer guards on `!== undefined`, so a clearing null IS applied.
const tree = buildTree();
const next = applyUpdateOne(tree, ev("child", { title: "", icon: null }));
const child = treeModel.find(next, "child");
expect(child?.name).toBe("");
expect(child?.icon).toBeNull();
// And it did change something -> a fresh reference, not prev.
expect(next).not.toBe(tree);
});
});

View File

@@ -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

View File

@@ -3,6 +3,7 @@ import {
Extension,
onChangePayload,
onLoadDocumentPayload,
onStatelessPayload,
onStoreDocumentPayload,
} from '@hocuspocus/server';
import * as Y from 'yjs';
@@ -41,6 +42,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.
*
@@ -96,6 +126,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,
@@ -180,6 +217,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
@@ -210,6 +260,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 || [];
@@ -345,6 +435,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;
@@ -368,6 +489,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[] {
@@ -385,6 +507,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,

View File

@@ -1,278 +0,0 @@
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);
});
});

View File

@@ -1,166 +0,0 @@
import { McpClientsService } from './mcp-clients.service';
/**
* Unit tests for the two security-critical surfaces of McpClientsService that the
* sibling specs (ssrf-guard / validate-resolved-addresses / lease) do NOT cover:
*
* 1. `decryptHeaders` (private) — FAIL-OPEN behavior. A decrypt/parse failure
* (e.g. APP_SECRET rotated, tampered blob) must NEVER throw and must NEVER
* log the blob: it returns `undefined` so the connect proceeds WITHOUT the
* now-unreadable auth headers (which then 401s and the server is skipped),
* rather than crashing the whole turn.
*
* 2. `this.guardedFetch` (private, bound to the SSRF-pinned dispatcher) — the
* per-request DNS-rebinding guard. A blocked host (private/loopback/metadata
* IP literal, or an unparseable URL) must REJECT before any socket is opened;
* a public host is allowed through to the real `fetch` with the pinned
* dispatcher attached.
*
* No network and no DB: the repo + secretBox deps are stubbed, and global `fetch`
* is mocked for the single allow-path assertion.
*/
// Build the service with a SecretBoxService stub whose decryptSecret is supplied
// per-test. The repo dep is unused by the methods under test.
function buildService(decryptSecret: (blob: string) => string) {
const secretBox = { decryptSecret: jest.fn(decryptSecret) };
const service = new McpClientsService({} as never, secretBox as never);
return { service, secretBox };
}
describe('McpClientsService.decryptHeaders', () => {
// Reach the private method via the as-any pattern common in these NestJS specs.
const callDecrypt = (
service: McpClientsService,
blob: string | null,
): Record<string, string> | undefined =>
(
service as unknown as {
decryptHeaders: (b: string | null) => Record<string, string> | undefined;
}
).decryptHeaders(blob);
it('returns undefined for a null blob without decrypting', () => {
const { service, secretBox } = buildService(() => '{}');
expect(callDecrypt(service, null)).toBeUndefined();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
it('decrypts a valid blob and keeps only string-valued headers', () => {
const { service } = buildService(() =>
JSON.stringify({
Authorization: 'Bearer abc',
'X-Api-Key': 'k',
// Non-string values must be dropped, not coerced.
count: 5,
flag: true,
nested: { a: 1 },
}),
);
expect(callDecrypt(service, 'cipher')).toEqual({
Authorization: 'Bearer abc',
'X-Api-Key': 'k',
});
});
it('returns undefined when the decrypted object has no string headers', () => {
const { service } = buildService(() => JSON.stringify({ count: 5 }));
// No usable headers -> undefined (connect with no auth header), not {}.
expect(callDecrypt(service, 'cipher')).toBeUndefined();
});
it('FAILS OPEN: a decrypt error returns undefined instead of throwing', () => {
const { service } = buildService(() => {
throw new Error('Failed to decrypt secret — APP_SECRET may have changed');
});
const warnSpy = jest
.spyOn(
(service as unknown as { logger: { warn: (...a: unknown[]) => void } })
.logger,
'warn',
)
.mockImplementation(() => undefined);
let result: unknown;
expect(() => {
result = callDecrypt(service, 'tampered-blob');
}).not.toThrow();
expect(result).toBeUndefined();
// It warns (so ops sees degradation) but never logs the blob itself.
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain('tampered-blob');
});
it('FAILS OPEN: malformed JSON (decrypts to non-JSON) returns undefined', () => {
const { service } = buildService(() => 'not-json{');
jest
.spyOn(
(service as unknown as { logger: { warn: (...a: unknown[]) => void } })
.logger,
'warn',
)
.mockImplementation(() => undefined);
expect(callDecrypt(service, 'cipher')).toBeUndefined();
});
});
describe('McpClientsService.guardedFetch (SSRF per-request guard)', () => {
// The bound guardedFetch closure lives on the instance as a private field.
const guardedFetchOf = (service: McpClientsService) =>
(service as unknown as { guardedFetch: typeof fetch }).guardedFetch;
let fetchSpy: jest.SpiedFunction<typeof fetch>;
beforeEach(() => {
// Any reachable real fetch would be a network call; assert per-test that the
// blocked paths never reach it, and stub a Response for the allow path.
fetchSpy = jest
.spyOn(global, 'fetch')
.mockResolvedValue(new Response('ok', { status: 200 }));
});
afterEach(() => {
jest.restoreAllMocks();
});
const blocked: Array<[string, string]> = [
['loopback IPv4', 'http://127.0.0.1/mcp'],
['private 10/8', 'http://10.0.0.5/mcp'],
['private 192.168/16', 'http://192.168.1.1/mcp'],
['cloud metadata link-local', 'http://169.254.169.254/latest/meta-data/'],
['loopback IPv6 (bracketed)', 'http://[::1]:8080/mcp'],
];
it.each(blocked)(
'rejects a request to %s without opening a socket',
async (_label, url) => {
const { service } = buildService(() => '{}');
await expect(guardedFetchOf(service)(url)).rejects.toThrow(
/blocked request/,
);
expect(fetchSpy).not.toHaveBeenCalled();
},
);
it('rejects an unparseable URL as a blocked request', async () => {
const { service } = buildService(() => '{}');
await expect(
guardedFetchOf(service)('::: not a url :::'),
).rejects.toThrow('blocked request: invalid URL');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('allows a public IP literal and forwards through the pinned dispatcher', async () => {
const { service } = buildService(() => '{}');
const res = await guardedFetchOf(service)('http://8.8.8.8/mcp');
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledTimes(1);
// The init MUST carry the SSRF-pinned undici dispatcher (the rebinding pin);
// dropping it would let undici do a second, unchecked DNS resolution.
const init = fetchSpy.mock.calls[0][1] as RequestInit & {
dispatcher?: unknown;
};
expect(init.dispatcher).toBeDefined();
});
});

View File

@@ -1,124 +0,0 @@
import { z } from 'zod';
import { AiChatToolsService } from './ai-chat-tools.service';
import * as loader from './docmost-client.loader';
import type { DocmostClientLike } from './docmost-client.loader';
// The real zod-agnostic registry, imported from source so the contract is checked
// against exactly what the @docmost/mcp package ships (no hand-stub).
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
/**
* CONTRACT: SHARED_TOOL_SPECS <-> in-app tool wiring parity.
*
* `packages/mcp/src/tool-specs.ts` is the single source of truth for the tools
* that are intentionally IDENTICAL across the standalone MCP server (zod v3) and
* the in-app AI-SDK service (zod v4). The in-app service builds each one via
* `sharedTool(sharedToolSpecs.<key>, execute)`, keyed by the spec's `inAppKey`.
*
* This test fails the build if a spec is added to the registry but never wired
* in-app, if an `inAppKey` is renamed without updating the service, if the
* description drifts between the registry and the exposed tool, if the
* snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the
* exposed tool's input-schema keys diverge from the spec's `buildShape`.
*
* It does NOT need @docmost/mcp built: the registry is imported from TS source,
* and the ESM loader is mocked so `forUser()` never dynamically imports the
* package.
*/
describe('SHARED_TOOL_SPECS contract parity', () => {
// Empty fake client: no tool is executed here — every assertion is on tool
// presence / metadata / schema, so the client methods are never called.
const fakeClient: Partial<DocmostClientLike> = {};
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
};
let tools: Record<string, unknown>;
beforeAll(async () => {
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
DocmostClient: function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
// Feed the service the SAME registry this test asserts against.
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<
string,
loader.SharedToolSpec
>,
});
const service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }) } as never,
);
tools = (await service.forUser(
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
'session-1',
'ws-1',
'chat-1',
)) as unknown as Record<string, unknown>;
});
afterAll(() => jest.restoreAllMocks());
// camelCase -> snake_case, matching the registry's mcpName convention.
const toSnake = (s: string) =>
s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
// above otherwise narrows to a union where some members lack buildShape.
const specEntries = Object.entries(SHARED_TOOL_SPECS) as Array<
[string, loader.SharedToolSpec]
>;
// Sanity: the registry is non-empty, so the per-spec table below is not vacuous.
it('registry is non-empty', () => {
expect(specEntries.length).toBeGreaterThan(0);
});
describe.each(specEntries)('spec "%s"', (registryKey, spec) => {
it('registry key equals its inAppKey', () => {
// The service indexes the registry by property name; a key != inAppKey
// would wire the wrong (or no) tool.
expect(spec.inAppKey).toBe(registryKey);
});
it('mcpName is the snake_case form of inAppKey', () => {
expect(spec.mcpName).toBe(toSnake(spec.inAppKey));
});
it('is exposed in-app under its inAppKey', () => {
// Fails if a spec is added to the registry but never wired in forUser().
expect(tools[spec.inAppKey]).toBeDefined();
});
it("exposed tool's description matches the registry description", () => {
const tool = tools[spec.inAppKey] as { description: string };
expect(tool.description).toBe(spec.description);
});
it("exposed tool's input-schema keys match buildShape (incl. required)", () => {
const tool = tools[spec.inAppKey] as {
inputSchema: { jsonSchema: { properties?: Record<string, unknown>; required?: string[] } };
};
const json = tool.inputSchema.jsonSchema;
const actualKeys = Object.keys(json.properties ?? {}).sort();
// Derive the spec's declared shape with THIS layer's zod (v4) — the same
// call the service makes — then compare key sets and required-ness.
const shape = spec.buildShape ? spec.buildShape(z) : {};
const expectedKeys = Object.keys(shape).sort();
expect(actualKeys).toEqual(expectedKeys);
// A non-.optional() field must surface as required in the advertised schema.
const expectedRequired = Object.entries(shape)
.filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.())
.map(([k]) => k)
.sort();
expect((json.required ?? []).slice().sort()).toEqual(expectedRequired);
});
});
});

View File

@@ -1,110 +1,18 @@
import { Readable } from 'stream';
import { StorageService } from './storage.service';
import type { StorageDriver } from './interfaces';
/**
* StorageService is a thin facade over the injected StorageDriver: each public
* method must forward to the driver with the SAME arguments and return/await the
* driver's result unchanged (the read paths return it; the write paths await it).
* A mock driver lets us assert that delegation exactly, with no real S3/disk IO.
*/
describe('StorageService delegation', () => {
// Every driver method is a jest mock so we can assert call args + return passing.
function buildDriver(): jest.Mocked<StorageDriver> {
return {
upload: jest.fn().mockResolvedValue(undefined),
uploadStream: jest.fn().mockResolvedValue(undefined),
copy: jest.fn().mockResolvedValue(undefined),
read: jest.fn(),
readStream: jest.fn(),
readRangeStream: jest.fn(),
exists: jest.fn(),
getUrl: jest.fn(),
getSignedUrl: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
getDriver: jest.fn(),
getDriverName: jest.fn(),
getConfig: jest.fn(),
} as unknown as jest.Mocked<StorageDriver>;
}
let driver: jest.Mocked<StorageDriver>;
// Direct instantiation with a stub driver. The Test.createTestingModule form
// failed to resolve the STORAGE_DRIVER_TOKEN at compile(); this smoke test only
// needs the service to construct.
describe('StorageService', () => {
let service: StorageService;
beforeEach(() => {
driver = buildDriver();
service = new StorageService(driver as unknown as StorageDriver);
});
it('upload forwards path + content to the driver', async () => {
const buf = Buffer.from('data');
await service.upload('a/b.png', buf);
expect(driver.upload).toHaveBeenCalledWith('a/b.png', buf);
});
it('uploadStream forwards path, stream and options', async () => {
const stream = Readable.from(['x']);
await service.uploadStream('a/b.bin', stream, { recreateClient: true });
expect(driver.uploadStream).toHaveBeenCalledWith('a/b.bin', stream, {
recreateClient: true,
});
});
it('copy forwards both paths', async () => {
await service.copy('from.txt', 'to.txt');
expect(driver.copy).toHaveBeenCalledWith('from.txt', 'to.txt');
});
it('read returns the driver buffer unchanged', async () => {
const buf = Buffer.from('content');
driver.read.mockResolvedValue(buf);
await expect(service.read('f.txt')).resolves.toBe(buf);
expect(driver.read).toHaveBeenCalledWith('f.txt');
});
it('readStream returns the driver stream unchanged', async () => {
const stream = Readable.from(['y']);
driver.readStream.mockResolvedValue(stream);
await expect(service.readStream('f.bin')).resolves.toBe(stream);
expect(driver.readStream).toHaveBeenCalledWith('f.bin');
});
it('readRangeStream forwards the range object and returns the stream', async () => {
const stream = Readable.from(['z']);
driver.readRangeStream.mockResolvedValue(stream);
const range = { start: 0, end: 99 };
await expect(service.readRangeStream('f.bin', range)).resolves.toBe(stream);
expect(driver.readRangeStream).toHaveBeenCalledWith('f.bin', range);
});
it('exists returns the driver boolean', async () => {
driver.exists.mockResolvedValue(false);
await expect(service.exists('missing')).resolves.toBe(false);
expect(driver.exists).toHaveBeenCalledWith('missing');
});
it('getSignedUrl forwards path + expiry and returns the signed url', async () => {
driver.getSignedUrl.mockResolvedValue('https://signed/url');
await expect(service.getSignedUrl('f.png', 600)).resolves.toBe(
'https://signed/url',
service = new StorageService(
{} as any, // storageDriver
);
expect(driver.getSignedUrl).toHaveBeenCalledWith('f.png', 600);
});
it('getUrl returns the driver url synchronously', () => {
driver.getUrl.mockReturnValue('https://cdn/f.png');
expect(service.getUrl('f.png')).toBe('https://cdn/f.png');
expect(driver.getUrl).toHaveBeenCalledWith('f.png');
});
it('delete forwards the path', async () => {
await service.delete('old.txt');
expect(driver.delete).toHaveBeenCalledWith('old.txt');
});
it('getDriverName returns the driver name', () => {
driver.getDriverName.mockReturnValue('s3');
expect(service.getDriverName()).toBe('s3');
expect(driver.getDriverName).toHaveBeenCalledTimes(1);
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,133 +0,0 @@
import { describe, it, expect } from "vitest";
import { schema } from "@tiptap/pm/schema-basic";
import type { Node as PMNode } from "@tiptap/pm/model";
import { Transform } from "@tiptap/pm/transform";
import { recreateTransform } from "./recreateTransform";
/**
* recreateTransform diffs two documents and produces ProseMirror steps that turn
* `fromDoc` into `toDoc`. It is the backbone of collaborative/version diffing, so
* THE invariant that matters is: replaying the produced steps on `fromDoc` must
* reproduce `toDoc` exactly. Every test below re-applies the steps onto a fresh
* Transform seeded from `fromDoc` (not just trusting `tr.doc`) and asserts node
* equality with `.eq()`. If a regression makes any step wrong, the round-trip
* breaks and the test fails.
*/
// Real ProseMirror schema (the standard basic schema) with paragraph/heading +
// strong/em marks — the same primitives the editor diffs in production.
const doc = (...c: PMNode[]) => schema.node("doc", null, c);
const p = (...c: PMNode[]) =>
schema.node("paragraph", null, c.length ? c : undefined);
const h = (level: number, ...c: PMNode[]) =>
schema.node("heading", { level }, c);
const t = (text: string, ...marks: any[]) =>
schema.text(text, marks.length ? marks : undefined);
const strong = schema.marks.strong.create();
const em = schema.marks.em.create();
// Replay the diff's steps onto a fresh Transform built from `fromDoc`. This is
// the faithful "apply(diff) == target" check — it exercises the actual Step
// objects rather than the transform's internal accumulated doc.
function applyDiff(fromDoc: PMNode, toDoc: PMNode, options?: any): PMNode {
const tr = recreateTransform(fromDoc, toDoc, options);
const replay = new Transform(fromDoc);
tr.steps.forEach((s) => {
const result = replay.maybeStep(s);
if (result.failed) throw new Error(`step failed: ${result.failed}`);
});
return replay.doc;
}
describe("recreateTransform round-trip (apply(diff) == target)", () => {
it("reconstructs the target on plain text insertion", () => {
// Inserting " world" must yield exactly the target paragraph.
const from = doc(p(t("hello")));
const to = doc(p(t("hello world")));
expect(applyDiff(from, to).eq(to)).toBe(true);
});
it("reconstructs the target on text deletion", () => {
// Deleting a trailing word is the inverse of insertion and must round-trip.
const from = doc(p(t("hello world")));
const to = doc(p(t("hello")));
expect(applyDiff(from, to).eq(to)).toBe(true);
});
it("reconstructs the target when a word is replaced mid-string", () => {
// A char-level replace in the middle must not corrupt the surrounding text.
const from = doc(p(t("the quick brown fox")));
const to = doc(p(t("the slow brown fox")));
expect(applyDiff(from, to).eq(to)).toBe(true);
});
it("reconstructs the target when a mark is added (complexSteps path)", () => {
// Mark-only changes are diffed in a separate pass; the bolded run must match.
const from = doc(p(t("hello")));
const to = doc(p(t("hello", strong)));
const out = applyDiff(from, to);
expect(out.eq(to)).toBe(true);
// Sanity: the produced doc actually carries the strong mark.
expect(out.firstChild!.firstChild!.marks.length).toBe(1);
});
it("reconstructs the target when a mark is removed", () => {
// Removing the only mark must leave the same text with no marks.
const from = doc(p(t("hello", strong)));
const to = doc(p(t("hello")));
const out = applyDiff(from, to);
expect(out.eq(to)).toBe(true);
expect(out.firstChild!.firstChild!.marks.length).toBe(0);
});
it("reconstructs the target on a paragraph split into two blocks", () => {
// Structural change (one block -> two) must replay as valid replace steps.
const from = doc(p(t("hello world")));
const to = doc(p(t("hello")), p(t("world")));
const out = applyDiff(from, to);
expect(out.eq(to)).toBe(true);
expect(out.childCount).toBe(2);
});
it("reconstructs the target on a node-type change (paragraph -> heading)", () => {
// Type/attrs changes drive the setNodeMarkup branch; the node must become a
// heading while keeping its text.
const from = doc(p(t("hello")));
const to = doc(h(1, t("hello")));
const out = applyDiff(from, to);
expect(out.eq(to)).toBe(true);
expect(out.firstChild!.type.name).toBe("heading");
});
it("reconstructs a combined structural + mark change", () => {
// Several diff kinds at once (new block + italic run) still round-trips.
const from = doc(p(t("alpha")));
const to = doc(p(t("alpha")), p(t("beta", em)));
const out = applyDiff(from, to);
expect(out.eq(to)).toBe(true);
});
it("produces an empty step list for identical documents", () => {
// No diff => no work; spurious steps would mean wasted/incorrect history.
const from = doc(p(t("same")));
const to = doc(p(t("same")));
const tr = recreateTransform(from, to);
expect(tr.steps.length).toBe(0);
expect(tr.doc.eq(to)).toBe(true);
});
it("round-trips with complexSteps:false (marks diffed as replaces)", () => {
// With complexSteps off, mark changes are folded into replace steps rather
// than dedicated mark steps — the result must still equal the target.
const from = doc(p(t("hello")));
const to = doc(p(t("hello", strong)));
expect(applyDiff(from, to, { complexSteps: false }).eq(to)).toBe(true);
});
it("round-trips with wordDiffs:true (whole-word text diffing)", () => {
// wordDiffs changes the granularity of the text diff, not the outcome.
const from = doc(p(t("the quick brown fox")));
const to = doc(p(t("the quick red fox")));
expect(applyDiff(from, to, { wordDiffs: true }).eq(to)).toBe(true);
});
});

View File

@@ -1,108 +0,0 @@
import { describe, it, expect } from "vitest";
import { Schema } from "@tiptap/pm/model";
import type { Node as PMNode } from "@tiptap/pm/model";
import { tableNodes } from "@tiptap/pm/tables";
import { EditorState, Selection } from "@tiptap/pm/state";
import { getSelectionRangeInColumn } from "./get-selection-range-in-column";
/**
* getSelectionRangeInColumn computes the rectangular column range (the set of
* column indexes, plus anchor/head cell positions) that a drag-reorder or
* column-select operation should act on, accounting for merged (colspan) cells.
* It keys off the table found from the current selection, so we drive it with a
* real EditorState whose selection sits inside the table.
*/
// Real ProseMirror table schema (same primitives the editor uses) so TableMap /
// cellsInRect behave exactly as in production.
const tNodes = tableNodes({
tableGroup: "block",
cellContent: "inline*",
cellAttributes: {},
});
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
text: { group: "inline" },
...tNodes,
},
marks: {},
});
const cell = (txt: string, attrs?: Record<string, unknown>): PMNode =>
schema.nodes.table_cell.createChecked(attrs ?? null, schema.text(txt));
const row = (...cells: PMNode[]): PMNode =>
schema.nodes.table_row.createChecked(null, cells);
const table = (...rows: PMNode[]): PMNode =>
schema.nodes.table.createChecked(null, rows);
const doc = (...content: PMNode[]): PMNode =>
schema.nodes.doc.createChecked(null, content);
// Build a transaction whose selection is inside the table (the function locates
// the table via `tr.selection.$from`).
const trFor = (d: PMNode) =>
EditorState.create({ doc: d, selection: Selection.atStart(d) }).tr;
// A 2-row x 3-col grid; each column is identifiable by its top-row letter.
const grid3x2 = () =>
doc(
table(
row(cell("a"), cell("b"), cell("c")),
row(cell("d"), cell("e"), cell("f")),
),
);
describe("getSelectionRangeInColumn", () => {
it("returns a single-column range for a single index", () => {
// Asking for column 1 yields exactly indexes [1].
const tr = trFor(grid3x2());
const range = getSelectionRangeInColumn(tr, 1);
expect(range).toBeTruthy();
expect(range!.indexes).toEqual([1]);
});
it("anchor/head resolve to the top and bottom cells OF the requested column", () => {
// $head must point at the column's first (top) cell and $anchor at its last
// (bottom) cell — pinning that the returned positions belong to column 1,
// not some other column.
const tr = trFor(grid3x2());
const range = getSelectionRangeInColumn(tr, 1)!;
expect(tr.doc.nodeAt(range.$head.pos)?.textContent).toBe("b"); // top of col 1
expect(tr.doc.nodeAt(range.$anchor.pos)?.textContent).toBe("e"); // bottom of col 1
});
it("returns the inclusive span of columns for a multi-column request", () => {
// A 0..2 request must enumerate every covered column, in order.
const tr = trFor(grid3x2());
const range = getSelectionRangeInColumn(tr, 0, 2);
expect(range!.indexes).toEqual([0, 1, 2]);
});
it("returns a two-column span for an adjacent pair", () => {
const tr = trFor(grid3x2());
const range = getSelectionRangeInColumn(tr, 1, 2);
expect(range!.indexes).toEqual([1, 2]);
});
it("expands the range to cover a horizontally merged (colspan) cell", () => {
// Row 0 col 0 spans 2 columns. Requesting just column 0 must pull column 1
// into the range because they are merged together in the top row.
const d = doc(
table(
row(cell("ab", { colspan: 2 }), cell("c")),
row(cell("d"), cell("e"), cell("f")),
),
);
const tr = trFor(d);
const range = getSelectionRangeInColumn(tr, 0);
expect(range!.indexes).toEqual([0, 1]);
});
it("throws when the requested column is entirely out of range", () => {
// No cells exist at column 5 of a 3-wide table, so the function cannot pick
// an anchor cell and dereferences undefined — pin this as the current
// (caller-guarded) contract so a silent behavior change is caught.
const tr = trFor(grid3x2());
expect(() => getSelectionRangeInColumn(tr, 5)).toThrow();
});
});

View File

@@ -1,156 +0,0 @@
import { describe, it, expect } from "vitest";
import { Schema } from "@tiptap/pm/model";
import type { Node as PMNode } from "@tiptap/pm/model";
import { tableNodes, CellSelection } from "@tiptap/pm/tables";
import { EditorState, Selection } from "@tiptap/pm/state";
import { moveColumn } from "./move-column";
import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows";
import { findTable } from "./query";
/**
* moveColumn reorders whole columns of a real ProseMirror table by mutating a
* Transaction (transpose -> move row -> transpose back -> replace). The invariant
* is that after the call each column appears at its new position with every
* cell's content preserved and nothing dropped or duplicated.
*/
const tNodes = tableNodes({
tableGroup: "block",
cellContent: "inline*",
cellAttributes: {},
});
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
text: { group: "inline" },
...tNodes,
},
marks: {},
});
const cell = (txt: string): PMNode =>
schema.nodes.table_cell.createChecked(null, schema.text(txt));
const row = (...cells: PMNode[]): PMNode =>
schema.nodes.table_row.createChecked(null, cells);
const table = (...rows: PMNode[]): PMNode =>
schema.nodes.table.createChecked(null, rows);
const doc = (...content: PMNode[]): PMNode =>
schema.nodes.doc.createChecked(null, content);
const grid = (tr: any): string[][] => {
const t = findTable(tr.doc.resolve(tr.selection.from))!;
return convertTableNodeToArrayOfRows(t.node).map((r) =>
r.map((c) => (c ? c.textContent : "")),
);
};
// 2-row x 3-col table; column k is (rowX-col-k). Columns: 0=(a,d) 1=(b,e) 2=(c,f).
const grid3x2 = () =>
doc(
table(
row(cell("a"), cell("b"), cell("c")),
row(cell("d"), cell("e"), cell("f")),
),
);
const stateFor = (d: PMNode) =>
EditorState.create({ doc: d, selection: Selection.atStart(d) });
describe("moveColumn", () => {
it("moves the first column to the last index, preserving column content", () => {
// origin 0 -> target 2 sends column (a,d) to the right: cols become 1,2,0.
const state = stateFor(grid3x2());
const tr = state.tr;
const ok = moveColumn({
tr,
originIndex: 0,
targetIndex: 2,
select: false,
pos: state.selection.from,
});
expect(ok).toBe(true);
expect(grid(tr)).toEqual([
["b", "c", "a"],
["e", "f", "d"],
]);
});
it("moves a later column to the first index", () => {
// origin 2 -> target 0 pulls column (c,f) to the front: cols become 2,0,1.
const state = stateFor(grid3x2());
const tr = state.tr;
const ok = moveColumn({
tr,
originIndex: 2,
targetIndex: 0,
select: false,
pos: state.selection.from,
});
expect(ok).toBe(true);
expect(grid(tr)).toEqual([
["c", "a", "b"],
["f", "d", "e"],
]);
});
it("never drops or duplicates cells when reordering columns", () => {
const state = stateFor(grid3x2());
const tr = state.tr;
moveColumn({
tr,
originIndex: 1,
targetIndex: 2,
select: false,
pos: state.selection.from,
});
expect(grid(tr).flat().sort()).toEqual(
["a", "b", "c", "d", "e", "f"].sort(),
);
expect(grid(tr)[0].length).toBe(3);
});
it("returns false (no-op) when target equals origin", () => {
const state = stateFor(grid3x2());
const tr = state.tr;
const before = grid(tr);
const ok = moveColumn({
tr,
originIndex: 1,
targetIndex: 1,
select: false,
pos: state.selection.from,
});
expect(ok).toBe(false);
expect(grid(tr)).toEqual(before);
});
it("returns false when pos is not inside a table", () => {
const d = doc(
schema.nodes.paragraph.createChecked(null, schema.text("plain")),
);
const state = stateFor(d);
const tr = state.tr;
const ok = moveColumn({
tr,
originIndex: 0,
targetIndex: 1,
select: false,
pos: state.selection.from,
});
expect(ok).toBe(false);
});
it("installs a CellSelection on the moved column when select is true", () => {
const state = stateFor(grid3x2());
const tr = state.tr;
const ok = moveColumn({
tr,
originIndex: 0,
targetIndex: 2,
select: true,
pos: state.selection.from,
});
expect(ok).toBe(true);
expect(tr.selection instanceof CellSelection).toBe(true);
});
});

View File

@@ -1,167 +0,0 @@
import { describe, it, expect } from "vitest";
import { Schema } from "@tiptap/pm/model";
import type { Node as PMNode } from "@tiptap/pm/model";
import { tableNodes, CellSelection } from "@tiptap/pm/tables";
import { EditorState, Selection } from "@tiptap/pm/state";
import { moveRow } from "./move-row";
import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows";
import { findTable } from "./query";
/**
* moveRow reorders whole rows of a real ProseMirror table by mutating a
* Transaction: it locates the table, computes origin/target row ranges, rebuilds
* the table with rows reordered, and replaces it in the doc. The invariant is
* that after the call the table's rows appear in the new order with every cell's
* content preserved, and no rows are dropped or duplicated.
*/
const tNodes = tableNodes({
tableGroup: "block",
cellContent: "inline*",
cellAttributes: {},
});
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
text: { group: "inline" },
...tNodes,
},
marks: {},
});
const cell = (txt: string): PMNode =>
schema.nodes.table_cell.createChecked(null, schema.text(txt));
const row = (...cells: PMNode[]): PMNode =>
schema.nodes.table_row.createChecked(null, cells);
const table = (...rows: PMNode[]): PMNode =>
schema.nodes.table.createChecked(null, rows);
const doc = (...content: PMNode[]): PMNode =>
schema.nodes.doc.createChecked(null, content);
// Read the table's content as a grid of cell texts (rows x cols) from whatever
// table currently lives in `tr.doc`.
const grid = (tr: any): string[][] => {
const t = findTable(tr.doc.resolve(tr.selection.from))!;
return convertTableNodeToArrayOfRows(t.node).map((r) =>
r.map((c) => (c ? c.textContent : "")),
);
};
// 3-row x 2-col table; each row identifiable by its cells.
const grid2x3 = () =>
doc(
table(
row(cell("r0a"), cell("r0b")),
row(cell("r1a"), cell("r1b")),
row(cell("r2a"), cell("r2b")),
),
);
const stateFor = (d: PMNode) =>
EditorState.create({ doc: d, selection: Selection.atStart(d) });
describe("moveRow", () => {
it("moves the first row down to the last index, preserving content", () => {
// origin 0 -> target 2 makes row 0 land after the other rows: [r1, r2, r0].
const state = stateFor(grid2x3());
const tr = state.tr;
const ok = moveRow({
tr,
originIndex: 0,
targetIndex: 2,
select: false,
pos: state.selection.from,
});
expect(ok).toBe(true);
expect(grid(tr)).toEqual([
["r1a", "r1b"],
["r2a", "r2b"],
["r0a", "r0b"],
]);
});
it("moves a lower row up to an earlier index", () => {
// origin 2 -> target 0 lifts the last row above the rest: [r2, r0, r1].
const state = stateFor(grid2x3());
const tr = state.tr;
const ok = moveRow({
tr,
originIndex: 2,
targetIndex: 0,
select: false,
pos: state.selection.from,
});
expect(ok).toBe(true);
expect(grid(tr)).toEqual([
["r2a", "r2b"],
["r0a", "r0b"],
["r1a", "r1b"],
]);
});
it("never drops or duplicates rows when reordering", () => {
// The full multiset of cell texts is invariant under any valid move.
const state = stateFor(grid2x3());
const tr = state.tr;
moveRow({
tr,
originIndex: 1,
targetIndex: 2,
select: false,
pos: state.selection.from,
});
const flat = grid(tr).flat().sort();
expect(flat).toEqual(
["r0a", "r0b", "r1a", "r1b", "r2a", "r2b"].sort(),
);
expect(grid(tr).length).toBe(3);
});
it("returns false (no-op) when target equals origin", () => {
// Moving a row onto itself is rejected and leaves the table unchanged.
const state = stateFor(grid2x3());
const tr = state.tr;
const before = grid(tr);
const ok = moveRow({
tr,
originIndex: 1,
targetIndex: 1,
select: false,
pos: state.selection.from,
});
expect(ok).toBe(false);
expect(grid(tr)).toEqual(before);
});
it("returns false when pos is not inside a table", () => {
// Without a table at `pos`, the function bails out instead of throwing.
const d = doc(
schema.nodes.paragraph.createChecked(null, schema.text("plain")),
);
const state = stateFor(d);
const tr = state.tr;
const ok = moveRow({
tr,
originIndex: 0,
targetIndex: 1,
select: false,
pos: state.selection.from,
});
expect(ok).toBe(false);
});
it("installs a CellSelection on the moved row when select is true", () => {
// With select:true the moved row at the target index is selected.
const state = stateFor(grid2x3());
const tr = state.tr;
const ok = moveRow({
tr,
originIndex: 0,
targetIndex: 2,
select: true,
pos: state.selection.from,
});
expect(ok).toBe(true);
expect(tr.selection instanceof CellSelection).toBe(true);
});
});

View File

@@ -100,51 +100,4 @@ describe("addUniqueIdsToDoc", () => {
const [id] = ids(out);
expect(id).toBeTruthy();
});
it("only assigns ids to configured node types, not to others", () => {
// `types` is ["heading","paragraph"]; a codeBlock is NOT addressed, so it
// must come back without an id while the sibling paragraph is filled. (The
// UniqueID attribute only exists on configured types in the schema.)
const doc = {
type: "doc",
content: [
{ type: "codeBlock", content: [{ type: "text", text: "x = 1" }] },
para(undefined, "after"),
],
};
const out = addUniqueIdsToDoc(doc, extensions);
const [codeId, paraId] = ids(out);
expect(codeId).toBeUndefined();
expect(paraId).toBeTruthy();
});
it("assigns ids to target nodes nested inside non-target containers", () => {
// findChildren walks the whole tree: a paragraph inside a blockquote still
// gets an id, while the (non-target) blockquote wrapper does not.
const doc = {
type: "doc",
content: [
{ type: "blockquote", content: [para(undefined, "quoted")] },
],
};
const out = addUniqueIdsToDoc(doc, extensions) as any;
const blockquote = out.content[0];
const nestedPara = blockquote.content[0];
expect(blockquote.attrs?.id).toBeUndefined();
expect(nestedPara.attrs.id).toBeTruthy();
});
it("is idempotent: a second pass keeps every already-unique id unchanged", () => {
// Once ids are assigned and unique, re-running must be a fixed point — no
// churn that would invalidate stored MCP anchors on every save.
const doc = {
type: "doc",
content: [para(undefined, "a"), para(undefined, "b"), para(undefined, "c")],
};
const once = addUniqueIdsToDoc(doc, extensions);
const twice = addUniqueIdsToDoc(once, extensions);
expect(ids(twice)).toEqual(ids(once));
// And all three are distinct, so the second pass had real ids to preserve.
expect(new Set(ids(once)).size).toBe(3);
});
});

View File

@@ -29,41 +29,6 @@ export async function getCollabToken(baseUrl, apiToken) {
throw error;
}
}
/**
* Pure cookie-parsing helper extracted from `performLogin` so the parsing logic
* can be unit-tested without performing the login network request. Given the
* raw `Set-Cookie` header array from the login response, return the `authToken`
* cookie's value.
*
* Behavior (kept identical to the original inline logic):
* - throws if there is no Set-Cookie header at all;
* - matches the cookie NAME exactly (`authToken`), so a future
* `authTokenRefresh=...` cookie is NOT picked up (a `startsWith` would be);
* - returns everything after the FIRST `=` up to the first `;`, so a base64
* value containing `=` padding is preserved (a naive `split("=")` would
* truncate it);
* - cookie attributes after the first `;` (Path, HttpOnly, Expires, …) are
* ignored;
* - throws if no `authToken` cookie is present.
*/
export function extractAuthTokenFromSetCookie(cookies) {
if (!cookies) {
throw new Error("No Set-Cookie header found in login response");
}
// Match the cookie name exactly to avoid matching a future
// authTokenRefresh cookie (startsWith would catch it).
const authCookie = cookies.find((c) => {
const kv = c.split(";")[0];
return kv.slice(0, kv.indexOf("=")) === "authToken";
});
if (!authCookie) {
throw new Error("No authToken cookie found in login response");
}
// Take everything after the FIRST "=" up to the first ";".
// Splitting on "=" would truncate base64 values containing "=" padding.
const kv = authCookie.split(";")[0];
return kv.slice(kv.indexOf("=") + 1);
}
export async function performLogin(baseUrl, email, password) {
try {
const response = await axios.post(`${baseUrl}/auth/login`, {
@@ -71,7 +36,24 @@ export async function performLogin(baseUrl, email, password) {
password,
});
// Extract token from Set-Cookie header
return extractAuthTokenFromSetCookie(response.headers["set-cookie"]);
const cookies = response.headers["set-cookie"];
if (!cookies) {
throw new Error("No Set-Cookie header found in login response");
}
// Match the cookie name exactly to avoid matching a future
// authTokenRefresh cookie (startsWith would catch it).
const authCookie = cookies.find((c) => {
const kv = c.split(";")[0];
return kv.slice(0, kv.indexOf("=")) === "authToken";
});
if (!authCookie) {
throw new Error("No authToken cookie found in login response");
}
// Take everything after the FIRST "=" up to the first ";".
// Splitting on "=" would truncate base64 values containing "=" padding.
const kv = authCookie.split(";")[0];
const token = kv.slice(kv.indexOf("=") + 1);
return token;
}
catch (error) {
// Avoid leaking the full server response body by default; log only the

View File

@@ -38,45 +38,6 @@ export async function getCollabToken(
}
}
/**
* Pure cookie-parsing helper extracted from `performLogin` so the parsing logic
* can be unit-tested without performing the login network request. Given the
* raw `Set-Cookie` header array from the login response, return the `authToken`
* cookie's value.
*
* Behavior (kept identical to the original inline logic):
* - throws if there is no Set-Cookie header at all;
* - matches the cookie NAME exactly (`authToken`), so a future
* `authTokenRefresh=...` cookie is NOT picked up (a `startsWith` would be);
* - returns everything after the FIRST `=` up to the first `;`, so a base64
* value containing `=` padding is preserved (a naive `split("=")` would
* truncate it);
* - cookie attributes after the first `;` (Path, HttpOnly, Expires, …) are
* ignored;
* - throws if no `authToken` cookie is present.
*/
export function extractAuthTokenFromSetCookie(
cookies: string[] | undefined,
): string {
if (!cookies) {
throw new Error("No Set-Cookie header found in login response");
}
// Match the cookie name exactly to avoid matching a future
// authTokenRefresh cookie (startsWith would catch it).
const authCookie = cookies.find((c: string) => {
const kv = c.split(";")[0];
return kv.slice(0, kv.indexOf("=")) === "authToken";
});
if (!authCookie) {
throw new Error("No authToken cookie found in login response");
}
// Take everything after the FIRST "=" up to the first ";".
// Splitting on "=" would truncate base64 values containing "=" padding.
const kv = authCookie.split(";")[0];
return kv.slice(kv.indexOf("=") + 1);
}
export async function performLogin(
baseUrl: string,
email: string,
@@ -89,7 +50,25 @@ export async function performLogin(
});
// Extract token from Set-Cookie header
return extractAuthTokenFromSetCookie(response.headers["set-cookie"]);
const cookies = response.headers["set-cookie"];
if (!cookies) {
throw new Error("No Set-Cookie header found in login response");
}
// Match the cookie name exactly to avoid matching a future
// authTokenRefresh cookie (startsWith would catch it).
const authCookie = cookies.find((c: string) => {
const kv = c.split(";")[0];
return kv.slice(0, kv.indexOf("=")) === "authToken";
});
if (!authCookie) {
throw new Error("No authToken cookie found in login response");
}
// Take everything after the FIRST "=" up to the first ";".
// Splitting on "=" would truncate base64 values containing "=" padding.
const kv = authCookie.split(";")[0];
const token = kv.slice(kv.indexOf("=") + 1);
return token;
} catch (error: any) {
// Avoid leaking the full server response body by default; log only the
// HTTP status. Log the verbose body only when DEBUG is set.

View File

@@ -1,93 +0,0 @@
// Cookie parsing for the login flow.
//
// `performLogin` in auth-utils.ts does a real network POST and then extracts the
// auth token from the response's Set-Cookie header. The cookie-parsing logic was
// extracted into the pure, exported helper `extractAuthTokenFromSetCookie` so it
// can be tested without network I/O; `performLogin` now delegates to it, so these
// tests cover the exact parsing path the login uses.
import { test } from "node:test";
import assert from "node:assert/strict";
import { extractAuthTokenFromSetCookie } from "../../build/lib/auth-utils.js";
// ---------------------------------------------------------------------------
// Happy path: a single authToken cookie with attributes.
// ---------------------------------------------------------------------------
test("extracts the authToken value, ignoring trailing attributes", () => {
const cookies = [
"authToken=abc123; Path=/; HttpOnly; Secure; SameSite=Lax",
];
assert.equal(extractAuthTokenFromSetCookie(cookies), "abc123");
});
// ---------------------------------------------------------------------------
// A base64/JWT value containing "=" padding must NOT be truncated: only the
// FIRST "=" separates name from value.
// ---------------------------------------------------------------------------
test("preserves an '=' inside the value (base64 padding is not truncated)", () => {
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0=";
const cookies = [`authToken=${jwt}; Path=/`];
assert.equal(extractAuthTokenFromSetCookie(cookies), jwt);
});
// ---------------------------------------------------------------------------
// Exact-name match: a different cookie whose name merely STARTS WITH "authToken"
// (e.g. authTokenRefresh) must not be picked up; the real authToken wins.
// ---------------------------------------------------------------------------
test("matches the cookie name exactly, not by prefix (authTokenRefresh ignored)", () => {
const cookies = [
"authTokenRefresh=refreshvalue; Path=/; HttpOnly",
"authToken=realtoken; Path=/; HttpOnly",
];
assert.equal(extractAuthTokenFromSetCookie(cookies), "realtoken");
});
// ---------------------------------------------------------------------------
// Picks the authToken out of several unrelated cookies regardless of order.
// ---------------------------------------------------------------------------
test("selects authToken among multiple unrelated cookies", () => {
const cookies = [
"session=xyz; Path=/",
"authToken=tok-7; Path=/; HttpOnly",
"theme=dark",
];
assert.equal(extractAuthTokenFromSetCookie(cookies), "tok-7");
});
// ---------------------------------------------------------------------------
// An empty value is valid and returns "".
// ---------------------------------------------------------------------------
test("returns an empty string when authToken has an empty value", () => {
assert.equal(extractAuthTokenFromSetCookie(["authToken=; Path=/"]), "");
});
// ---------------------------------------------------------------------------
// Missing Set-Cookie header -> documented error.
// ---------------------------------------------------------------------------
test("throws when there is no Set-Cookie header", () => {
assert.throws(
() => extractAuthTokenFromSetCookie(undefined),
/No Set-Cookie header/,
);
});
// ---------------------------------------------------------------------------
// Set-Cookie present but no authToken cookie -> documented error.
// ---------------------------------------------------------------------------
test("throws when no authToken cookie is present", () => {
assert.throws(
() => extractAuthTokenFromSetCookie(["session=xyz; Path=/", "theme=dark"]),
/No authToken cookie/,
);
});
// ---------------------------------------------------------------------------
// An empty cookie array also yields the "no authToken" error (header exists but
// is empty), distinct from the "no Set-Cookie header" case above.
// ---------------------------------------------------------------------------
test("throws 'no authToken' (not 'no header') for an empty cookie array", () => {
assert.throws(
() => extractAuthTokenFromSetCookie([]),
/No authToken cookie/,
);
});

View File

@@ -1,111 +0,0 @@
// applyAnchorInDoc — first-match / ambiguity / boundary behavior.
//
// comment-anchor.test.mjs already covers the core apply paths (single-node
// match, spanning adjacent text nodes, code/italic boundary mark preservation,
// smart-quote normalization, no-match-no-mutation, pre-existing comment mark
// replacement, nested-list DFS). This file focuses on the SELECTION/RESOLUTION
// behavior those tests don't pin down: which occurrence/block wins when a
// selection appears more than once, sub-word ranges, and the run boundary
// created by a non-text inline node.
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyAnchorInDoc, canAnchorInDoc } from "../../build/lib/comment-anchor.js";
const commentMark = (node) =>
(Array.isArray(node.marks) ? node.marks : []).find((m) => m && m.type === "comment") || null;
const paragraphDoc = (content) => ({ type: "doc", content: [{ type: "paragraph", content }] });
// ---------------------------------------------------------------------------
// Document order: when two separate blocks both contain the selection, only the
// FIRST block (DFS document order) is anchored; the second is left untouched.
// ---------------------------------------------------------------------------
test("anchors only the FIRST block when the selection occurs in two blocks", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first target here" }] },
{ type: "paragraph", content: [{ type: "text", text: "second target here" }] },
],
};
assert.equal(applyAnchorInDoc(doc, "target", "C"), true);
const marked0 = doc.content[0].content.filter((p) => commentMark(p));
const marked1 = doc.content[1].content.filter((p) => commentMark(p));
assert.equal(marked0.length, 1, "first block is anchored");
assert.equal(marked0[0].text, "target");
assert.equal(marked1.length, 0, "second block is left untouched");
});
// ---------------------------------------------------------------------------
// Ambiguity within one block: indexOf finds the FIRST occurrence, so only the
// first "ab" is marked; the later occurrences stay in one unmarked fragment.
// ---------------------------------------------------------------------------
test("anchors only the FIRST occurrence within a block (ambiguous selection)", () => {
const doc = paragraphDoc([{ type: "text", text: "ab ab ab" }]);
assert.equal(applyAnchorInDoc(doc, "ab", "C"), true);
const parts = doc.content[0].content;
assert.equal(parts.length, 2, "split into [marked, rest]");
assert.equal(parts[0].text, "ab");
assert.ok(commentMark(parts[0]), "first occurrence is marked");
assert.equal(parts[1].text, " ab ab");
assert.equal(commentMark(parts[1]), null, "later occurrences are not marked");
});
// ---------------------------------------------------------------------------
// Sub-word range: a selection that is a substring inside a single text node is
// spliced into before / marked / after, marking exactly the matched characters.
// ---------------------------------------------------------------------------
test("anchors a sub-word range inside a single text node", () => {
const doc = paragraphDoc([{ type: "text", text: "Hello" }]);
assert.equal(applyAnchorInDoc(doc, "ell", "C"), true);
const parts = doc.content[0].content;
assert.deepEqual(parts.map((p) => p.text), ["H", "ell", "o"]);
assert.equal(commentMark(parts[0]), null);
assert.ok(commentMark(parts[1]), "only the matched substring is marked");
assert.equal(commentMark(parts[2]), null);
});
// ---------------------------------------------------------------------------
// A non-text inline node (hardBreak) breaks the matching run: a selection that
// would span the break cannot match, but one wholly inside a run still does.
// ---------------------------------------------------------------------------
test("a non-text inline node breaks the run: cross-break selection does not match", () => {
const make = () =>
paragraphDoc([
{ type: "text", text: "foo" },
{ type: "hardBreak" },
{ type: "text", text: "bar" },
]);
// "foobar" straddles the hardBreak -> no match, no mutation.
const docA = make();
const before = JSON.stringify(docA);
assert.equal(canAnchorInDoc(docA, "foobar"), false);
assert.equal(applyAnchorInDoc(docA, "foobar", "C"), false);
assert.equal(JSON.stringify(docA), before, "failed match must not mutate");
// "foo" lives entirely in the first run -> matches and is marked; the
// hardBreak node is preserved untouched.
const docB = make();
assert.equal(applyAnchorInDoc(docB, "foo", "C"), true);
const parts = docB.content[0].content;
assert.equal(parts[0].text, "foo");
assert.ok(commentMark(parts[0]));
assert.equal(parts[1].type, "hardBreak", "the inline atom is preserved");
assert.equal(parts[2].text, "bar");
assert.equal(commentMark(parts[2]), null);
});
// ---------------------------------------------------------------------------
// A whitespace-only selection normalizes to empty and never anchors.
// ---------------------------------------------------------------------------
test("a whitespace-only selection does not anchor and does not mutate", () => {
const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
const before = JSON.stringify(doc);
assert.equal(canAnchorInDoc(doc, " "), false);
assert.equal(applyAnchorInDoc(doc, " ", "C"), false);
assert.equal(JSON.stringify(doc), before);
});

View File

@@ -1,135 +0,0 @@
// Extra media round-trip coverage (issue #244), complementing
// media-roundtrip.test.mjs.
//
// The existing media-roundtrip.test.mjs already asserts that video, youtube,
// embed, excalidraw, audio and pdf SURVIVE a PM -> markdown -> PM round-trip and
// keeps their identifying src / provider / name / attachmentId. It does NOT,
// however, exercise:
// * the `drawio` node (a distinct schema node that shares the excalidraw
// converter case) — not covered at all;
// * the dimension / layout attributes (width, height, align) that ride in
// data-* attributes — exactly where a converter<->schema mismatch silently
// drops a value while the node itself survives;
// * attribute escaping for a src containing `"` (escapeAttr) — a malformed
// value here would either break the round-trip or inject HTML.
//
// These are the gaps this file locks down.
import { test } from "node:test";
import assert from "node:assert/strict";
import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
import { markdownToProseMirror } from "../../build/lib/collaboration.js";
const doc = (...content) => ({ type: "doc", content });
const findAll = (node, type, acc = []) => {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
for (const c of node.content || []) findAll(c, type, acc);
return acc;
};
// PM node -> markdown -> PM; return both the markdown and the matching nodes.
const roundtrip = async (node, type) => {
const md = convertProseMirrorToMarkdown(doc(node));
const pm = await markdownToProseMirror(md);
return { md, found: findAll(pm, type) };
};
// ---------------------------------------------------------------------------
// drawio: a separate schema node sharing the excalidraw converter case. Not
// covered by the existing file at all, so guard its full round-trip here.
// ---------------------------------------------------------------------------
test("round-trip: drawio diagram survives with src, title, dimensions, align, attachmentId", async () => {
const { md, found } = await roundtrip(
{
type: "drawio",
attrs: {
src: "/api/files/d.drawio",
title: "Flow",
width: 400,
height: 300,
align: "left",
attachmentId: "dz1",
},
},
"drawio",
);
// The converter must emit the schema-matching div[data-type="drawio"].
assert.match(md, /data-type="drawio"/);
assert.equal(found.length, 1, "drawio node must survive the round-trip");
const a = found[0].attrs;
assert.equal(a.src, "/api/files/d.drawio");
assert.equal(a.title, "Flow");
assert.equal(a.attachmentId, "dz1");
assert.equal(a.align, "left");
// Numeric dimensions come back as strings via the schema parseHTML.
assert.equal(String(a.width), "400");
assert.equal(String(a.height), "300");
});
// ---------------------------------------------------------------------------
// Dimension + align attrs ride in data-* (or width/height) attributes. The
// existing file checks only src/provider/name/attachmentId, so a dropped
// width/height/align would pass there but fail here.
// ---------------------------------------------------------------------------
test("round-trip: youtube preserves width/height/align (data-* attrs)", async () => {
const { found } = await roundtrip(
{ type: "youtube", attrs: { src: "https://youtube.com/watch?v=x", width: 560, height: 315, align: "left" } },
"youtube",
);
assert.equal(found.length, 1);
const a = found[0].attrs;
assert.equal(String(a.width), "560");
assert.equal(String(a.height), "315");
assert.equal(a.align, "left");
});
test("round-trip: embed preserves provider, width/height and align", async () => {
const { found } = await roundtrip(
{ type: "embed", attrs: { src: "https://e.com/x", provider: "iframe", width: 600, height: 480, align: "right" } },
"embed",
);
assert.equal(found.length, 1);
const a = found[0].attrs;
assert.equal(a.provider, "iframe");
assert.equal(String(a.width), "600");
assert.equal(String(a.height), "480");
assert.equal(a.align, "right");
});
test("round-trip: video preserves width/height and align (data-align)", async () => {
const { found } = await roundtrip(
{ type: "video", attrs: { src: "/api/files/v.mp4", attachmentId: "att1", width: 640, height: 360, align: "right" } },
"video",
);
assert.equal(found.length, 1);
const a = found[0].attrs;
assert.equal(String(a.width), "640");
assert.equal(String(a.height), "360");
assert.equal(a.align, "right");
});
test("round-trip: pdf preserves width/height (standard attrs) plus name", async () => {
const { found } = await roundtrip(
{ type: "pdf", attrs: { src: "/api/files/x.pdf", name: "x.pdf", attachmentId: "a4", width: 700, height: 900 } },
"pdf",
);
assert.equal(found.length, 1);
const a = found[0].attrs;
assert.equal(a.name, "x.pdf");
assert.equal(String(a.width), "700");
assert.equal(String(a.height), "900");
});
// ---------------------------------------------------------------------------
// Escaping: a src containing a double quote must survive the attribute-quoted
// HTML emission (escapeAttr) and re-parse to the exact original value, with no
// node loss and no HTML injection.
// ---------------------------------------------------------------------------
test("round-trip: a src containing a double quote is escaped and recovered intact", async () => {
const tricky = 'https://e.com/x?a="b"&c=1';
const { found } = await roundtrip({ type: "youtube", attrs: { src: tricky } }, "youtube");
assert.equal(found.length, 1, "node must survive a quote-bearing src");
assert.equal(found[0].attrs.src, tricky, "the exact src is recovered");
});

View File

@@ -1,139 +0,0 @@
// CONTRACT / DRIFT GUARD: mcp diff vs the vendored editor-ext recreate-transform.
//
// packages/mcp/src/lib/diff.ts computes its document diff with
// `recreateTransform` from the published @fellow/prosemirror-recreate-transform
// package. Docmost's in-app history editor computes the SAME diff with its own
// vendored copy at
// packages/editor-ext/src/lib/recreate-transform/recreateTransform.ts.
// diff.ts's header comment claims the two are "identical" — if they ever drift,
// the headless mcp diff would stop matching what a user sees in the app.
//
// This test guards that claim two ways, on representative doc pairs, using the
// EXACT options diff.ts passes (complexSteps:false, wordDiffs:true,
// simplifyDiff:true):
// 1. invariant: each implementation's transform reproduces the target doc
// (apply(diff) == target);
// 2. cross-copy parity: both implementations emit the SAME step sequence, so a
// behavioral divergence between the two copies fails this test.
//
// The vendored copy is TypeScript, so it is transpiled to CommonJS at test time
// and required directly — the test runs the ACTUAL vendored source, not a stand-in.
import { test, before } from "node:test";
import assert from "node:assert/strict";
import ts from "typescript";
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { recreateTransform as fellowRecreate } from "@fellow/prosemirror-recreate-transform";
import { Node } from "@tiptap/pm/model";
import { docmostSchema } from "../../build/lib/docmost-schema.js";
const require = createRequire(import.meta.url);
const HERE = path.dirname(fileURLToPath(import.meta.url));
// .../packages/mcp/test/unit -> repo packages root.
const PACKAGES = path.resolve(HERE, "..", "..", "..");
const VENDOR_SRC = path.join(
PACKAGES,
"editor-ext",
"src",
"lib",
"recreate-transform",
);
// Emit transpiled CJS under mcp/build so Node resolves the hoisted deps
// (@tiptap/pm, rfc6902, diff) up the directory tree exactly as diff.js does.
const VENDOR_OUT = path.resolve(HERE, "..", "..", "build", "_vendored_editor_ext");
// The exact options the mcp diff pipeline uses (diff.ts).
const DIFF_OPTS = { complexSteps: false, wordDiffs: true, simplifyDiff: true };
let vendoredRecreate;
before(() => {
assert.ok(
fs.existsSync(VENDOR_SRC),
`vendored recreate-transform sources missing at ${VENDOR_SRC}`,
);
fs.rmSync(VENDOR_OUT, { recursive: true, force: true });
fs.mkdirSync(VENDOR_OUT, { recursive: true });
// Mark the output as CommonJS so relative `require("./x")` resolves to x.js.
fs.writeFileSync(
path.join(VENDOR_OUT, "package.json"),
JSON.stringify({ type: "commonjs" }),
);
for (const f of fs.readdirSync(VENDOR_SRC)) {
if (!f.endsWith(".ts")) continue;
const code = fs.readFileSync(path.join(VENDOR_SRC, f), "utf8");
const out = ts.transpileModule(code, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
});
fs.writeFileSync(path.join(VENDOR_OUT, f.replace(/\.ts$/, ".js")), out.outputText);
}
vendoredRecreate = require(path.join(VENDOR_OUT, "index.js")).recreateTransform;
assert.equal(typeof vendoredRecreate, "function", "vendored recreateTransform loaded");
});
// ---------------------------------------------------------------------------
// Builders + representative doc pairs covering the diff shapes diff.ts handles.
// ---------------------------------------------------------------------------
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
const para = (...c) => ({ type: "paragraph", content: c });
const doc = (...c) => ({ type: "doc", content: c });
const PAIRS = [
// word inserted mid-sentence
["insert word", doc(para(t("Hello world"))), doc(para(t("Hello brave world")))],
// whole block deleted
["delete block", doc(para(t("keep this")), para(t("remove this"))), doc(para(t("keep this")))],
// word removed mid-sentence
["delete word", doc(para(t("one two three"))), doc(para(t("one three")))],
// pure mark addition (complexSteps:false treats it as a content step)
["add mark", doc(para(t("plain"))), doc(para(t("plain", [{ type: "bold" }])))],
// two blocks swapped (reorder)
["reorder blocks", doc(para(t("a")), para(t("b"))), doc(para(t("b")), para(t("a")))],
// structural insert: an image node appears
[
"insert image",
doc(para(t("caption"))),
doc(para(t("caption")), { type: "image", attrs: { src: "/api/files/a.png", attachmentId: "i1" } }),
],
];
const stepsJSON = (tr) => JSON.stringify(tr.steps.map((s) => s.toJSON()));
for (const [label, fromJSON, toJSON] of PAIRS) {
test(`invariant: @fellow recreateTransform reproduces the target (${label})`, () => {
const from = Node.fromJSON(docmostSchema, fromJSON);
const to = Node.fromJSON(docmostSchema, toJSON);
const tr = fellowRecreate(from, to, DIFF_OPTS);
// apply(diff) == target, comparing schema-normalized JSON on both sides.
assert.equal(JSON.stringify(tr.doc.toJSON()), JSON.stringify(to.toJSON()));
});
test(`drift: @fellow and vendored editor-ext emit identical steps (${label})`, () => {
const mk = () => [
Node.fromJSON(docmostSchema, fromJSON),
Node.fromJSON(docmostSchema, toJSON),
];
const [fA, tA] = mk();
const [fB, tB] = mk();
const trFellow = fellowRecreate(fA, tA, DIFF_OPTS);
const trVendor = vendoredRecreate(fB, tB, DIFF_OPTS);
// Both must reach the same target...
const target = JSON.stringify(tA.toJSON());
assert.equal(JSON.stringify(trFellow.doc.toJSON()), target, "fellow reaches target");
assert.equal(JSON.stringify(trVendor.doc.toJSON()), target, "vendored reaches target");
// ...and, critically, via the SAME step sequence. A divergence in the two
// recreate-transform copies' algorithm would change the steps and fail here.
assert.equal(
stepsJSON(trVendor),
stepsJSON(trFellow),
`vendored editor-ext drifted from @fellow on "${label}"`,
);
});
}