Compare commits

..

2 Commits

Author SHA1 Message Date
claude code agent 227
82b042209e fix(ws): make redis adapter error handlers actually log (were noop)
The pub/sub error handlers were `(err) => () => {}` — a noop returning an
inner arrow that never runs, so socket.io redis client errors were silently
swallowed. Log them via Nest Logger. Adjacent pre-existing bug surfaced in
review of #255.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:32:34 +03:00
claude code agent 227
a0f4c86a74 fix(ws): disconnect socket.io redis adapter pub/sub clients on shutdown
The WsRedisIoAdapter creates two ioredis clients (pubClient/subClient) for
@socket.io/redis-adapter but never closed them, leaking their TCP handles on
application shutdown (#255). The redis-adapter does not own these clients'
lifecycle, and the adapter is instantiated from main.ts (not a DI provider),
so no Nest lifecycle hook applied to it.

Keep references to both clients and override dispose(), which Nest's
SocketModule.close() invokes exactly once during shutdown after all socket.io
servers are closed. Use disconnect(false) to mirror the sibling pub/sub pair
in collaboration/extensions/redis-sync (onDestroy): immediate close, no QUIT
round-trip, no auto-reconnect. Refs are nulled to guard against double-close.
Runtime behavior is unchanged; only the shutdown path is added.

Verified with a script that boots connectToRedis() against a real Redis:
2 sockets to :6379 open after connect, 0 remain after dispose().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:28:56 +03:00
21 changed files with 83 additions and 2133 deletions

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

@@ -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,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { ServerOptions } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
@@ -9,8 +10,11 @@ import {
} from '../../common/helpers';
export class WsRedisIoAdapter extends IoAdapter {
private readonly logger = new Logger(WsRedisIoAdapter.name);
private adapterConstructor: ReturnType<typeof createAdapter>;
private redisConfig: RedisConfig;
private pubClient: Redis;
private subClient: Redis;
async connectToRedis(): Promise<void> {
this.redisConfig = parseRedisUrl(process.env.REDIS_URL);
@@ -23,8 +27,13 @@ export class WsRedisIoAdapter extends IoAdapter {
const pubClient = new Redis(process.env.REDIS_URL, options);
const subClient = new Redis(process.env.REDIS_URL, options);
pubClient.on('error', (err) => () => {});
subClient.on('error', (err) => () => {});
pubClient.on('error', (err) => this.logger.error('socket.io redis pub client error', err));
subClient.on('error', (err) => this.logger.error('socket.io redis sub client error', err));
// Hold references so the pub/sub connections can be torn down on shutdown
// (see dispose()); otherwise these ioredis sockets leak as active handles.
this.pubClient = pubClient;
this.subClient = subClient;
this.adapterConstructor = createAdapter(pubClient, subClient);
}
@@ -34,4 +43,26 @@ export class WsRedisIoAdapter extends IoAdapter {
server.adapter(this.adapterConstructor);
return server;
}
/**
* Called once by Nest's SocketModule during application shutdown, after every
* socket.io server has been closed. The @socket.io/redis-adapter never owns
* the lifecycle of the ioredis pub/sub clients it is handed, so we close them
* here to avoid leaking their TCP handles on shutdown (see issue #255).
*
* Uses disconnect(false) to mirror the sibling pub/sub pair in
* collaboration/extensions/redis-sync (redis-sync.extension.ts onDestroy):
* an immediate close with no graceful QUIT round-trip and no auto-reconnect,
* which is what we want for idle adapter clients during teardown.
*/
async dispose(): Promise<void> {
await super.dispose();
// dispose() is invoked once per shutdown; null the refs so a second call
// (or any post-shutdown path) cannot act on already-closed clients.
this.pubClient?.disconnect(false);
this.subClient?.disconnect(false);
this.pubClient = undefined;
this.subClient = undefined;
}
}

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}"`,
);
});
}