Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9b58a0e3d | ||
|
|
388894c257 | ||
|
|
e2b7ff10d9 | ||
|
|
683a62a547 |
@@ -1,5 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import i18n from "@/i18n.ts";
|
||||||
import {
|
import {
|
||||||
|
formatRelativeTime,
|
||||||
getTimeGroup,
|
getTimeGroup,
|
||||||
groupNotificationsByTime,
|
groupNotificationsByTime,
|
||||||
} from "@/features/notification/notification.utils.ts";
|
} from "@/features/notification/notification.utils.ts";
|
||||||
@@ -132,3 +134,59 @@ describe("groupNotificationsByTime", () => {
|
|||||||
expect(groupNotificationsByTime([], labels)).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
closeIds,
|
closeIds,
|
||||||
mergeRootTrees,
|
mergeRootTrees,
|
||||||
loadedOpenBranchIds,
|
loadedOpenBranchIds,
|
||||||
|
sortPositionKeys,
|
||||||
|
pageToTreeNode,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@@ -60,6 +62,82 @@ 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", () => {
|
describe("buildTree", () => {
|
||||||
it("builds one node per unique page", () => {
|
it("builds one node per unique page", () => {
|
||||||
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
|
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
|
||||||
|
|||||||
@@ -70,18 +70,22 @@ export function findBreadcrumbPath(
|
|||||||
path: SpaceTreeNode[] = [],
|
path: SpaceTreeNode[] = [],
|
||||||
): SpaceTreeNode[] | null {
|
): SpaceTreeNode[] | null {
|
||||||
for (const node of tree) {
|
for (const node of tree) {
|
||||||
if (!node.name || node.name.trim() === "") {
|
// Never mutate the input tree (it is the live, shared sidebar tree state).
|
||||||
node.name = "Untitled";
|
// 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.id === pageId) {
|
if (node.id === pageId) {
|
||||||
return [...path, node];
|
return [...path, displayNode];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
const newPath = findBreadcrumbPath(node.children, pageId, [
|
const newPath = findBreadcrumbPath(node.children, pageId, [
|
||||||
...path,
|
...path,
|
||||||
node,
|
displayNode,
|
||||||
]);
|
]);
|
||||||
if (newPath) {
|
if (newPath) {
|
||||||
return newPath;
|
return newPath;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
applyAddTreeNode,
|
applyAddTreeNode,
|
||||||
applyMoveTreeNode,
|
applyMoveTreeNode,
|
||||||
applyDeleteTreeNode,
|
applyDeleteTreeNode,
|
||||||
|
applyUpdateOne,
|
||||||
} from "./tree-socket-reducers";
|
} from "./tree-socket-reducers";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@@ -338,3 +339,76 @@ describe("applyAddTreeNode", () => {
|
|||||||
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
278
apps/server/src/collaboration/yjs.util.spec.ts
Normal file
278
apps/server/src/collaboration/yjs.util.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { getSchema } from '@tiptap/core';
|
||||||
|
import {
|
||||||
|
initProseMirrorDoc,
|
||||||
|
absolutePositionToRelativePosition,
|
||||||
|
prosemirrorJSONToYDoc,
|
||||||
|
} from '@tiptap/y-tiptap';
|
||||||
|
import { tiptapExtensions } from './collaboration.util';
|
||||||
|
import {
|
||||||
|
setYjsMark,
|
||||||
|
removeYjsMarkByAttribute,
|
||||||
|
updateYjsMarkAttribute,
|
||||||
|
type YjsSelection,
|
||||||
|
} from './yjs.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the server-side Yjs mark helpers used by the collaboration
|
||||||
|
* handler to set/resolve/delete comment marks directly on the shared Y.Doc
|
||||||
|
* (collaboration.handler.ts: setCommentMark / resolveCommentMark).
|
||||||
|
*
|
||||||
|
* The fragment shape mirrors production exactly: a `default` XmlFragment whose
|
||||||
|
* children are block XmlElements (paragraph) holding XmlText runs. For setYjsMark
|
||||||
|
* the selection is a pair of Yjs RelativePosition JSONs (what the client sends);
|
||||||
|
* we synthesize them from known ProseMirror absolute positions via
|
||||||
|
* absolutePositionToRelativePosition so the marked range is deterministic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const schema = getSchema(tiptapExtensions);
|
||||||
|
|
||||||
|
// Build a real Y.Doc from ProseMirror JSON (same path the collab handler uses
|
||||||
|
// via TiptapTransformer) and return the doc + its `default` fragment.
|
||||||
|
function buildFromPm(pmJson: unknown) {
|
||||||
|
const ydoc = prosemirrorJSONToYDoc(
|
||||||
|
schema,
|
||||||
|
pmJson as never,
|
||||||
|
'default',
|
||||||
|
) as unknown as Y.Doc;
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
return { ydoc, fragment };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a YjsSelection (anchor/head RelativePosition JSON) for two ProseMirror
|
||||||
|
// absolute positions in `fragment`.
|
||||||
|
function selectionFor(
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
anchorPos: number,
|
||||||
|
headPos: number,
|
||||||
|
): YjsSelection {
|
||||||
|
const { mapping } = initProseMirrorDoc(fragment, schema);
|
||||||
|
const anchor = absolutePositionToRelativePosition(
|
||||||
|
anchorPos,
|
||||||
|
fragment as never,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
const head = absolutePositionToRelativePosition(
|
||||||
|
headPos,
|
||||||
|
fragment as never,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
anchor: Y.relativePositionToJSON(anchor),
|
||||||
|
head: Y.relativePositionToJSON(head),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The XmlText run of the i-th top-level paragraph.
|
||||||
|
function paragraphText(fragment: Y.XmlFragment, index = 0): Y.XmlText {
|
||||||
|
const para = fragment.get(index) as Y.XmlElement;
|
||||||
|
return para.get(0) as Y.XmlText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- raw fragment builder for the remove/update tests (no schema needed) ---
|
||||||
|
//
|
||||||
|
// removeYjsMarkByAttribute / updateYjsMarkAttribute only read item.toDelta() and
|
||||||
|
// call item.format(); they never touch the ProseMirror schema. Build the runs
|
||||||
|
// directly so we control which segment carries which comment attrs.
|
||||||
|
function buildWithComments(
|
||||||
|
segments: Array<{
|
||||||
|
text: string;
|
||||||
|
comment?: { commentId: string; resolved: boolean };
|
||||||
|
}>,
|
||||||
|
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
const para = new Y.XmlElement('paragraph');
|
||||||
|
fragment.insert(0, [para]);
|
||||||
|
const text = new Y.XmlText();
|
||||||
|
para.insert(0, [text]);
|
||||||
|
let offset = 0;
|
||||||
|
for (const seg of segments) {
|
||||||
|
text.insert(offset, seg.text);
|
||||||
|
if (seg.comment) {
|
||||||
|
text.format(offset, seg.text.length, { comment: seg.comment });
|
||||||
|
}
|
||||||
|
offset += seg.text.length;
|
||||||
|
}
|
||||||
|
return { fragment, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setYjsMark', () => {
|
||||||
|
it('applies the mark over exactly the selected sub-range (PM pos 1..6 = "Hello")', () => {
|
||||||
|
const { ydoc, fragment } = buildFromPm({
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// PM pos 1 = start of the paragraph text; pos 6 = just after "Hello".
|
||||||
|
const sel = selectionFor(fragment, 1, 6);
|
||||||
|
|
||||||
|
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||||
|
commentId: 'c1',
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The run splits: "Hello" carries the comment mark, " world" stays clean.
|
||||||
|
expect(paragraphText(fragment).toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'Hello',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||||
|
},
|
||||||
|
{ insert: ' world' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a reversed selection (head before anchor) to the same range', () => {
|
||||||
|
const { ydoc, fragment } = buildFromPm({
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// anchor=6, head=1 — reversed; setYjsMark takes min/max so it marks "Hello".
|
||||||
|
const sel = selectionFor(fragment, 6, 1);
|
||||||
|
|
||||||
|
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||||
|
commentId: 'c2',
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(paragraphText(fragment).toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'Hello',
|
||||||
|
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||||
|
},
|
||||||
|
{ insert: ' world' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks across two paragraphs (range spans an element boundary)', () => {
|
||||||
|
const { ydoc, fragment } = buildFromPm({
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{ type: 'paragraph', content: [{ type: 'text', text: 'aaa' }] },
|
||||||
|
{ type: 'paragraph', content: [{ type: 'text', text: 'bbb' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// PM positions: "aaa" = 1..4; the </p><p> boundary consumes pos 4 and 5, so
|
||||||
|
// "bbb" starts at pos 6 (chars at 6,7,8). Select pos 2 (inside "aaa") to pos
|
||||||
|
// 8 (after the second "b").
|
||||||
|
const sel = selectionFor(fragment, 2, 8);
|
||||||
|
|
||||||
|
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||||
|
commentId: 'c3',
|
||||||
|
resolved: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First paragraph: "a" clean, "aa" marked.
|
||||||
|
expect(paragraphText(fragment, 0).toDelta()).toEqual([
|
||||||
|
{ insert: 'a' },
|
||||||
|
{
|
||||||
|
insert: 'aa',
|
||||||
|
attributes: { comment: { commentId: 'c3', resolved: false } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Second paragraph: "bb" marked, "b" clean.
|
||||||
|
expect(paragraphText(fragment, 1).toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'bb',
|
||||||
|
attributes: { comment: { commentId: 'c3', resolved: false } },
|
||||||
|
},
|
||||||
|
{ insert: 'b' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeYjsMarkByAttribute', () => {
|
||||||
|
it('removes only the run whose attribute value matches, leaving others', () => {
|
||||||
|
const { fragment, text } = buildWithComments([
|
||||||
|
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
{ text: 'BBB', comment: { commentId: 'c2', resolved: false } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
|
||||||
|
|
||||||
|
// c1's run loses the mark; c2's run is untouched.
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{ insert: 'AAA' },
|
||||||
|
{
|
||||||
|
insert: 'BBB',
|
||||||
|
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no run carries the requested value (no-match branch)', () => {
|
||||||
|
const { fragment, text } = buildWithComments([
|
||||||
|
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
]);
|
||||||
|
const before = text.toDelta();
|
||||||
|
|
||||||
|
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'does-not-exist');
|
||||||
|
|
||||||
|
expect(text.toDelta()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a different mark type alone', () => {
|
||||||
|
// A run carrying only `bold` must survive a comment removal pass.
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
const para = new Y.XmlElement('paragraph');
|
||||||
|
fragment.insert(0, [para]);
|
||||||
|
const text = new Y.XmlText();
|
||||||
|
para.insert(0, [text]);
|
||||||
|
text.insert(0, 'XYZ');
|
||||||
|
text.format(0, 3, { bold: true });
|
||||||
|
|
||||||
|
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
|
||||||
|
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{ insert: 'XYZ', attributes: { bold: true } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateYjsMarkAttribute', () => {
|
||||||
|
it('merges new attributes into the matching run, preserving the rest', () => {
|
||||||
|
const { fragment, text } = buildWithComments([
|
||||||
|
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
{ text: 'BBB', comment: { commentId: 'c2', resolved: false } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateYjsMarkAttribute(
|
||||||
|
fragment,
|
||||||
|
'comment',
|
||||||
|
{ name: 'commentId', value: 'c1' },
|
||||||
|
{ resolved: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// c1's run flips resolved=true (commentId preserved via merge); c2 untouched.
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'AAA',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: 'BBB',
|
||||||
|
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no run matches (no-match branch)', () => {
|
||||||
|
const { fragment, text } = buildWithComments([
|
||||||
|
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||||
|
]);
|
||||||
|
const before = text.toDelta();
|
||||||
|
|
||||||
|
updateYjsMarkAttribute(
|
||||||
|
fragment,
|
||||||
|
'comment',
|
||||||
|
{ name: 'commentId', value: 'nope' },
|
||||||
|
{ resolved: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(text.toDelta()).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,110 @@
|
|||||||
|
import { Readable } from 'stream';
|
||||||
import { StorageService } from './storage.service';
|
import { StorageService } from './storage.service';
|
||||||
|
import type { StorageDriver } from './interfaces';
|
||||||
|
|
||||||
// Direct instantiation with a stub driver. The Test.createTestingModule form
|
/**
|
||||||
// failed to resolve the STORAGE_DRIVER_TOKEN at compile(); this smoke test only
|
* StorageService is a thin facade over the injected StorageDriver: each public
|
||||||
// needs the service to construct.
|
* method must forward to the driver with the SAME arguments and return/await the
|
||||||
describe('StorageService', () => {
|
* 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>;
|
||||||
let service: StorageService;
|
let service: StorageService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new StorageService(
|
driver = buildDriver();
|
||||||
{} as any, // storageDriver
|
service = new StorageService(driver as unknown as StorageDriver);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('upload forwards path + content to the driver', async () => {
|
||||||
expect(service).toBeDefined();
|
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',
|
||||||
|
);
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
156
packages/editor-ext/src/lib/table/utils/move-column.test.ts
Normal file
156
packages/editor-ext/src/lib/table/utils/move-column.test.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
167
packages/editor-ext/src/lib/table/utils/move-row.test.ts
Normal file
167
packages/editor-ext/src/lib/table/utils/move-row.test.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -100,4 +100,51 @@ describe("addUniqueIdsToDoc", () => {
|
|||||||
const [id] = ids(out);
|
const [id] = ids(out);
|
||||||
expect(id).toBeTruthy();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,14 +29,24 @@ export async function getCollabToken(baseUrl, apiToken) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function performLogin(baseUrl, email, password) {
|
/**
|
||||||
try {
|
* Pure cookie-parsing helper extracted from `performLogin` so the parsing logic
|
||||||
const response = await axios.post(`${baseUrl}/auth/login`, {
|
* can be unit-tested without performing the login network request. Given the
|
||||||
email,
|
* raw `Set-Cookie` header array from the login response, return the `authToken`
|
||||||
password,
|
* cookie's value.
|
||||||
});
|
*
|
||||||
// Extract token from Set-Cookie header
|
* Behavior (kept identical to the original inline logic):
|
||||||
const cookies = response.headers["set-cookie"];
|
* - 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) {
|
if (!cookies) {
|
||||||
throw new Error("No Set-Cookie header found in login response");
|
throw new Error("No Set-Cookie header found in login response");
|
||||||
}
|
}
|
||||||
@@ -52,8 +62,16 @@ export async function performLogin(baseUrl, email, password) {
|
|||||||
// Take everything after the FIRST "=" up to the first ";".
|
// Take everything after the FIRST "=" up to the first ";".
|
||||||
// Splitting on "=" would truncate base64 values containing "=" padding.
|
// Splitting on "=" would truncate base64 values containing "=" padding.
|
||||||
const kv = authCookie.split(";")[0];
|
const kv = authCookie.split(";")[0];
|
||||||
const token = kv.slice(kv.indexOf("=") + 1);
|
return kv.slice(kv.indexOf("=") + 1);
|
||||||
return token;
|
}
|
||||||
|
export async function performLogin(baseUrl, email, password) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${baseUrl}/auth/login`, {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
// Extract token from Set-Cookie header
|
||||||
|
return extractAuthTokenFromSetCookie(response.headers["set-cookie"]);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
// Avoid leaking the full server response body by default; log only the
|
// Avoid leaking the full server response body by default; log only the
|
||||||
|
|||||||
@@ -38,19 +38,26 @@ export async function getCollabToken(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function performLogin(
|
/**
|
||||||
baseUrl: string,
|
* Pure cookie-parsing helper extracted from `performLogin` so the parsing logic
|
||||||
email: string,
|
* can be unit-tested without performing the login network request. Given the
|
||||||
password: string,
|
* raw `Set-Cookie` header array from the login response, return the `authToken`
|
||||||
): Promise<string> {
|
* cookie's value.
|
||||||
try {
|
*
|
||||||
const response = await axios.post(`${baseUrl}/auth/login`, {
|
* Behavior (kept identical to the original inline logic):
|
||||||
email,
|
* - throws if there is no Set-Cookie header at all;
|
||||||
password,
|
* - 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
|
||||||
// Extract token from Set-Cookie header
|
* value containing `=` padding is preserved (a naive `split("=")` would
|
||||||
const cookies = response.headers["set-cookie"];
|
* 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) {
|
if (!cookies) {
|
||||||
throw new Error("No Set-Cookie header found in login response");
|
throw new Error("No Set-Cookie header found in login response");
|
||||||
}
|
}
|
||||||
@@ -67,8 +74,22 @@ export async function performLogin(
|
|||||||
// Take everything after the FIRST "=" up to the first ";".
|
// Take everything after the FIRST "=" up to the first ";".
|
||||||
// Splitting on "=" would truncate base64 values containing "=" padding.
|
// Splitting on "=" would truncate base64 values containing "=" padding.
|
||||||
const kv = authCookie.split(";")[0];
|
const kv = authCookie.split(";")[0];
|
||||||
const token = kv.slice(kv.indexOf("=") + 1);
|
return kv.slice(kv.indexOf("=") + 1);
|
||||||
return token;
|
}
|
||||||
|
|
||||||
|
export async function performLogin(
|
||||||
|
baseUrl: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${baseUrl}/auth/login`, {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract token from Set-Cookie header
|
||||||
|
return extractAuthTokenFromSetCookie(response.headers["set-cookie"]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Avoid leaking the full server response body by default; log only the
|
// Avoid leaking the full server response body by default; log only the
|
||||||
// HTTP status. Log the verbose body only when DEBUG is set.
|
// HTTP status. Log the verbose body only when DEBUG is set.
|
||||||
|
|||||||
93
packages/mcp/test/unit/auth-cookie.test.mjs
Normal file
93
packages/mcp/test/unit/auth-cookie.test.mjs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// 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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
111
packages/mcp/test/unit/comment-anchor-apply.test.mjs
Normal file
111
packages/mcp/test/unit/comment-anchor-apply.test.mjs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
135
packages/mcp/test/unit/media-roundtrip-attrs.test.mjs
Normal file
135
packages/mcp/test/unit/media-roundtrip-attrs.test.mjs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
139
packages/mcp/test/unit/recreate-transform-drift.test.mjs
Normal file
139
packages/mcp/test/unit/recreate-transform-drift.test.mjs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// 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}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user