Merge remote-tracking branch 'gitea/develop' into fix/mcp-security-followups
This commit is contained in:
5
.github/workflows/develop.yml
vendored
5
.github/workflows/develop.yml
vendored
@@ -18,7 +18,12 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -19,7 +19,12 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
build:
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
|
||||
40
.github/workflows/test.yml
vendored
Normal file
40
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Required for the client suite, which resolves @docmost/editor-ext via its
|
||||
# dist build (the server suite also rebuilds it through its own pretest).
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm -r test
|
||||
@@ -391,6 +391,13 @@
|
||||
"Toggle block": "Сворачиваемый блок",
|
||||
"Callout": "Выноска",
|
||||
"Insert callout notice.": "Вставить выноску с сообщением.",
|
||||
"Footnote": "Сноска",
|
||||
"Insert a footnote reference.": "Вставить ссылку на сноску.",
|
||||
"Footnotes": "Примечания",
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формулы",
|
||||
|
||||
53
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
53
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeChatError } from "./error-message";
|
||||
|
||||
// Identity translator: assert on the raw English key so the tests do not depend
|
||||
// on the i18n catalog.
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("describeChatError", () => {
|
||||
it('surfaces a provider "402: ..." stream error verbatim', () => {
|
||||
expect(describeChatError("402: Insufficient credits", t)).toBe(
|
||||
"402: Insufficient credits",
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
|
||||
// A provider message mentioning the number 403 must be surfaced verbatim,
|
||||
// never folded into the "AI chat is disabled" gating message.
|
||||
const msg = "429: rate limited after 403 attempts";
|
||||
expect(describeChatError(msg, t)).toBe(msg);
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":403} body to the disabled message', () => {
|
||||
const body = '{"statusCode":403,"message":"Forbidden"}';
|
||||
expect(describeChatError(body, t)).toBe(
|
||||
"AI chat is disabled for this workspace.",
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":503} body to the not-configured message', () => {
|
||||
const body = '{"statusCode":503,"message":"Service Unavailable"}';
|
||||
expect(describeChatError(body, t)).toBe(
|
||||
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic message for "An error occurred."', () => {
|
||||
expect(describeChatError("An error occurred.", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic message for "Internal server error"', () => {
|
||||
expect(describeChatError("Internal server error", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic message for empty input", () => {
|
||||
expect(describeChatError("", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolCitations,
|
||||
toolRunState,
|
||||
type ToolUiPart,
|
||||
} from "./tool-parts";
|
||||
|
||||
describe("toolCitations", () => {
|
||||
it("emits one citation per searchPages item with a /p/{id} href", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-searchPages",
|
||||
state: "output-available",
|
||||
output: [
|
||||
{ id: "p1", title: "First" },
|
||||
{ id: "p2", title: "Second" },
|
||||
],
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "p1", title: "First", href: "/p/p1" },
|
||||
{ pageId: "p2", title: "Second", href: "/p/p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops searchPages items missing an id", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-searchPages",
|
||||
state: "output-available",
|
||||
output: [{ title: "No id here" }, { id: "p2", title: "Kept" }],
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "p2", title: "Kept", href: "/p/p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to input.pageId / input.title for a page-op with only pageId", () => {
|
||||
// The mutating tools echo `pageId` (no `id`); title is taken from the input.
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-updatePageContent",
|
||||
state: "output-available",
|
||||
input: { pageId: "host-1", title: "From input" },
|
||||
output: { pageId: "host-1" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "host-1", title: "From input", href: "/p/host-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers output.id over input.pageId when both exist", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: { pageId: "input-id", title: "Input title" },
|
||||
output: { id: "output-id", title: "Output title" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "output-id", title: "Output title", href: "/p/output-id" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the state is not output-available", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "input-available",
|
||||
output: { id: "p1", title: "Pending" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for a page-op output with no resolvable id", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: {},
|
||||
output: { title: "Only a title" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolRunState", () => {
|
||||
it('maps "output-error" to error', () => {
|
||||
expect(toolRunState("output-error")).toBe("error");
|
||||
});
|
||||
|
||||
it('maps "output-denied" to error', () => {
|
||||
expect(toolRunState("output-denied")).toBe("error");
|
||||
});
|
||||
|
||||
it('maps "output-available" to done', () => {
|
||||
expect(toolRunState("output-available")).toBe("done");
|
||||
});
|
||||
|
||||
it('maps "input-available" to running', () => {
|
||||
expect(toolRunState("input-available")).toBe("running");
|
||||
});
|
||||
|
||||
it("maps undefined to running", () => {
|
||||
expect(toolRunState(undefined)).toBe("running");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFootnoteNumber } from "@docmost/editor-ext";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for a single footnote definition: a decorative number marker, the
|
||||
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
||||
* The number is derived from the document (not stored).
|
||||
*/
|
||||
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
const { node, editor } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
// Read the cached number from the numbering plugin (computed once per doc
|
||||
// change) rather than recomputing the whole map on every render.
|
||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
editor.commands.scrollToReference(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
data-footnote-def=""
|
||||
data-id={id}
|
||||
className={classes.definition}
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
>
|
||||
<span className={classes.definitionMarker} contentEditable={false}>
|
||||
{number}.
|
||||
</span>
|
||||
<NodeViewContent className={classes.definitionContent} />
|
||||
<span
|
||||
className={classes.backLink}
|
||||
contentEditable={false}
|
||||
onClick={handleBack}
|
||||
role="button"
|
||||
aria-label={t("Back to reference")}
|
||||
title={t("Back to reference")}
|
||||
>
|
||||
↩
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
getFootnoteNumber,
|
||||
} from "@docmost/editor-ext";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconArrowDown } from "@tabler/icons-react";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* Read the plain text of the footnote definition with `id` directly from the
|
||||
* editor state. No sub-editor: the popover is read-only.
|
||||
*/
|
||||
function getDefinitionText(editor: NodeViewProps["editor"], id: string): string {
|
||||
let text = "";
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
||||
node.attrs.id === id
|
||||
) {
|
||||
text = node.textContent;
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function FootnoteReferenceView(props: NodeViewProps) {
|
||||
const { node, editor, selected } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Number is derived (not stored). Read it from the numbering plugin's cached
|
||||
// map (computed once per doc change) instead of walking the whole document on
|
||||
// every render — recomputing per NodeView per render was O(n^2) per keystroke.
|
||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||
const defText = open ? getDefinitionText(editor, id) : "";
|
||||
|
||||
const position = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
computePosition(anchor, popup, {
|
||||
placement: "top",
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
popup.style.left = `${x}px`;
|
||||
popup.style.top = `${y}px`;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
|
||||
const cleanup = autoUpdate(anchor, popup, position);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (
|
||||
popup.contains(e.target as Node) ||
|
||||
anchor.contains(e.target as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
};
|
||||
}, [open, position]);
|
||||
|
||||
const handleGoTo = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
editor.commands.scrollToFootnote(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" style={{ display: "inline" }}>
|
||||
<sup
|
||||
ref={(el) => (anchorRef.current = el)}
|
||||
data-footnote-ref=""
|
||||
data-id={id}
|
||||
className={`${classes.reference} ${selected ? classes.selected : ""}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
// The decoration sets --footnote-number; provide a fallback inline.
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
aria-label={t("Footnote {{number}}", { number })}
|
||||
role="button"
|
||||
/>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={classes.popover}
|
||||
role="tooltip"
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className={classes.popoverHeader}>
|
||||
<span className={classes.popoverNumber}>
|
||||
{t("Footnote {{number}}", { number })}
|
||||
</span>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={handleGoTo}
|
||||
aria-label={t("Go to footnote")}
|
||||
>
|
||||
<IconArrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className={classes.popoverBody}>
|
||||
{defText || t("Empty footnote")}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Superscript reference marker. The visible number comes from the numbering
|
||||
plugin decoration which sets the --footnote-number CSS variable. */
|
||||
.reference {
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
font-weight: 500;
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reference::after {
|
||||
content: var(--footnote-number, "");
|
||||
}
|
||||
|
||||
.reference:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reference.selected {
|
||||
background-color: var(--mantine-color-blue-1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Read-only popover shown on hover/click of a reference. */
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
max-width: 360px;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-default-color);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.popoverHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.popoverNumber {
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.popoverBody {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Bottom footnotes container. */
|
||||
.list {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.listHeading {
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.definition {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* Tight number→text spacing (~one space) so it reads like "1. text"
|
||||
instead of leaving a wide gap after the period. */
|
||||
gap: 0.4em;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.definitionMarker {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
/* Right-align within the narrow column so the period sits next to the text
|
||||
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
user-select: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for the bottom footnotes container. Renders a visual separator and a
|
||||
* localized heading, then the editable list of definitions via NodeViewContent.
|
||||
*/
|
||||
export default function FootnotesListView(_props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.list} contentEditable={false}>
|
||||
<div className={classes.listHeading}>{t("Footnotes")}</div>
|
||||
</div>
|
||||
<NodeViewContent />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -15,39 +15,11 @@ import { useAtomValue } from "jotai";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import classes from "./html-embed-view.module.css";
|
||||
|
||||
/**
|
||||
* Inject raw HTML (including <script> tags) into `container`, executing any
|
||||
* scripts.
|
||||
*
|
||||
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
|
||||
* parses that way: the HTML spec marks scripts inserted via innerHTML as
|
||||
* "already started" so they never execute. To get the tracker/analytics
|
||||
* use-case working we walk the freshly-parsed scripts and replace each with a
|
||||
* brand-new <script> element copying its attributes and inline code. A
|
||||
* programmatically created+inserted <script> DOES execute, so this restores
|
||||
* normal script behaviour in the wiki origin (Variant C).
|
||||
*/
|
||||
function renderRawHtml(container: HTMLElement, source: string) {
|
||||
// Clear any previous render (re-render on source change).
|
||||
container.innerHTML = "";
|
||||
if (!source) return;
|
||||
|
||||
container.innerHTML = source;
|
||||
|
||||
const scripts = Array.from(container.querySelectorAll("script"));
|
||||
for (const oldScript of scripts) {
|
||||
const newScript = document.createElement("script");
|
||||
// Copy every attribute (src, type, async, defer, data-*, etc.).
|
||||
for (const attr of Array.from(oldScript.attributes)) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
// Copy inline code.
|
||||
newScript.text = oldScript.textContent ?? "";
|
||||
// Replacing the node in place triggers execution.
|
||||
oldScript.parentNode?.replaceChild(newScript, oldScript);
|
||||
}
|
||||
}
|
||||
import {
|
||||
canEdit as computeCanEdit,
|
||||
renderRawHtml,
|
||||
shouldExecute as computeShouldExecute,
|
||||
} from "./render-raw-html.ts";
|
||||
|
||||
export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -70,7 +42,10 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
// here — we execute exactly the `source` the server chose to serve.
|
||||
// - EDITABLE editor (admin authoring): keep gating on the per-workspace
|
||||
// toggle so an admin sees the inert placeholder when the feature is OFF.
|
||||
const shouldExecute = !editor.isEditable || htmlEmbedEnabled;
|
||||
const shouldExecute = computeShouldExecute(
|
||||
editor.isEditable,
|
||||
htmlEmbedEnabled,
|
||||
);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -104,7 +79,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
// The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||
// (the server strips the node for non-admins anyway), and is offered only when
|
||||
// the workspace feature toggle is ON.
|
||||
const canEdit = editor.isEditable && isAdmin && htmlEmbedEnabled;
|
||||
const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { renderRawHtml, shouldExecute, canEdit } from "./render-raw-html";
|
||||
|
||||
// jsdom does NOT execute <script> nodes unless its instance was created with
|
||||
// `runScripts: "dangerously"`. The whole point of renderRawHtml is to make
|
||||
// re-created scripts run, so the execution tests drive a dedicated script-
|
||||
// running JSDOM and pass it a container from THAT document (renderRawHtml uses
|
||||
// `container.ownerDocument`, so it creates the fresh scripts in the running
|
||||
// instance). The default vitest jsdom (no runScripts) is used for the
|
||||
// structural and policy assertions.
|
||||
describe("renderRawHtml (script execution against a runScripts jsdom)", () => {
|
||||
let dom: JSDOM;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
runScripts: "dangerously",
|
||||
});
|
||||
container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it("re-creates and executes an inline <script> (observable side effect)", () => {
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<div>hello</div><script>window.__htmlEmbedFlag = true;</script>",
|
||||
);
|
||||
// The re-created inline script ran inside the jsdom window.
|
||||
expect((dom.window as unknown as Record<string, unknown>).__htmlEmbedFlag).toBe(
|
||||
true,
|
||||
);
|
||||
// The non-script markup is preserved.
|
||||
expect(container.querySelector("div")?.textContent).toBe("hello");
|
||||
});
|
||||
|
||||
it("copies src/async/defer onto a re-created external <script src>", () => {
|
||||
renderRawHtml(
|
||||
container,
|
||||
'<script src="https://example.com/t.js" async defer></script>',
|
||||
);
|
||||
const script = container.querySelector("script");
|
||||
expect(script).not.toBeNull();
|
||||
expect(script?.getAttribute("src")).toBe("https://example.com/t.js");
|
||||
expect(script?.hasAttribute("async")).toBe(true);
|
||||
expect(script?.hasAttribute("defer")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the container when the source is empty", () => {
|
||||
container.innerHTML = "<p>stale</p>";
|
||||
renderRawHtml(container, "");
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("clears prior content first on a re-render with new source", () => {
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<span id='first'>one</span><script>window.__htmlEmbedCount = 1;</script>",
|
||||
);
|
||||
expect(win.__htmlEmbedCount).toBe(1);
|
||||
expect(container.querySelector("#first")).not.toBeNull();
|
||||
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<span id='second'>two</span><script>window.__htmlEmbedCount = 2;</script>",
|
||||
);
|
||||
// Prior content is gone; only the new render remains.
|
||||
expect(container.querySelector("#first")).toBeNull();
|
||||
expect(container.querySelector("#second")).not.toBeNull();
|
||||
expect(win.__htmlEmbedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldExecute (execution policy)", () => {
|
||||
it("read-only executes regardless of the workspace toggle", () => {
|
||||
// isEditable=false → the server already gated the content.
|
||||
expect(shouldExecute(false, false)).toBe(true);
|
||||
expect(shouldExecute(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("editable + toggle OFF does NOT execute", () => {
|
||||
expect(shouldExecute(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("editable + toggle ON executes", () => {
|
||||
expect(shouldExecute(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEdit (edit policy)", () => {
|
||||
it("a member (non-admin) can never edit", () => {
|
||||
expect(canEdit(true, false, true)).toBe(false);
|
||||
expect(canEdit(false, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("an admin with the toggle OFF cannot edit", () => {
|
||||
expect(canEdit(true, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("an admin with the toggle ON in editable mode can edit", () => {
|
||||
expect(canEdit(true, true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("an admin in read-only mode cannot edit (no edit affordance)", () => {
|
||||
expect(canEdit(false, true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Pure DOM helpers for the HTML embed node view. Kept out of the React
|
||||
* component so the script re-creation/execution mechanism and the execution/
|
||||
* edit policy can be unit-tested against a bare jsdom container with no
|
||||
* Tiptap/Mantine providers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inject raw HTML (including <script> tags) into `container`, executing any
|
||||
* scripts.
|
||||
*
|
||||
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
|
||||
* parses that way: the HTML spec marks scripts inserted via innerHTML as
|
||||
* "already started" so they never execute. To get the tracker/analytics
|
||||
* use-case working we walk the freshly-parsed scripts and replace each with a
|
||||
* brand-new <script> element copying its attributes and inline code. A
|
||||
* programmatically created+inserted <script> DOES execute, so this restores
|
||||
* normal script behaviour in the wiki origin (Variant C).
|
||||
*/
|
||||
export function renderRawHtml(container: HTMLElement, source: string): void {
|
||||
// Clear any previous render (re-render on source change).
|
||||
container.innerHTML = "";
|
||||
if (!source) return;
|
||||
|
||||
container.innerHTML = source;
|
||||
|
||||
// Use the container's own document so the helper works against any document
|
||||
// (the live page or a standalone jsdom instance in tests), not just the
|
||||
// ambient global `document`.
|
||||
const doc = container.ownerDocument;
|
||||
const scripts = Array.from(container.querySelectorAll("script"));
|
||||
for (const oldScript of scripts) {
|
||||
const newScript = doc.createElement("script");
|
||||
// Copy every attribute (src, type, async, defer, data-*, etc.).
|
||||
for (const attr of Array.from(oldScript.attributes)) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
// Copy inline code.
|
||||
newScript.text = oldScript.textContent ?? "";
|
||||
// Replacing the node in place triggers execution.
|
||||
oldScript.parentNode?.replaceChild(newScript, oldScript);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution policy split by editor mode:
|
||||
* - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
* include the embed (it strips htmlEmbed from shared content when the
|
||||
* workspace toggle is OFF). An anonymous viewer has no workspace and thus
|
||||
* reads `featureEnabled` as false, so we must NOT gate execution on it here
|
||||
* — we execute exactly the `source` the server chose to serve.
|
||||
* - EDITABLE editor (admin authoring): keep gating on the per-workspace toggle
|
||||
* so an admin sees the inert placeholder when the feature is OFF.
|
||||
*/
|
||||
export function shouldExecute(
|
||||
isEditable: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return !isEditable || featureEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||
* (the server strips the node for non-admins anyway), and is offered only when
|
||||
* the workspace feature toggle is ON.
|
||||
*/
|
||||
export function canEdit(
|
||||
isEditable: boolean,
|
||||
isAdmin: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return isEditable && isAdmin && featureEnabled;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideEmbedState } from "./decide-embed-state";
|
||||
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
const okResult: PageTemplateLookup = {
|
||||
sourcePageId: "p1",
|
||||
slugId: "slug-p1",
|
||||
title: "Template",
|
||||
icon: null,
|
||||
content: { type: "doc" },
|
||||
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("decideEmbedState", () => {
|
||||
it("returns no_source when sourcePageId is null", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: null,
|
||||
chain: [],
|
||||
hostPageId: null,
|
||||
available: true,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("no_source");
|
||||
});
|
||||
|
||||
it("returns cycle when sourcePageId is already in the ancestor chain", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: ["root", "p1"],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns cycle when sourcePageId equals the host page id (top-level self-embed)", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "host",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns too_deep when chain length reaches PAGE_EMBED_MAX_DEPTH", () => {
|
||||
const chain = Array.from({ length: PAGE_EMBED_MAX_DEPTH }, (_, i) => `a${i}`);
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain,
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("too_deep");
|
||||
});
|
||||
|
||||
it("cycle wins over too_deep when both apply (cycle checked first)", () => {
|
||||
const chain = Array.from(
|
||||
{ length: PAGE_EMBED_MAX_DEPTH },
|
||||
(_, i) => `a${i}`,
|
||||
);
|
||||
chain[0] = "p1"; // also a cycle
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain,
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns unavailable when no lookup context is mounted", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: false,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("returns loading when available but the result is not back yet", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("loading");
|
||||
});
|
||||
|
||||
it("returns no_access when the result status is no_access", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: { sourcePageId: "p1", status: "no_access" },
|
||||
}),
|
||||
).toBe("no_access");
|
||||
});
|
||||
|
||||
it("returns not_found when the result status is not_found", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: { sourcePageId: "p1", status: "not_found" },
|
||||
}),
|
||||
).toBe("not_found");
|
||||
});
|
||||
|
||||
it("returns ok for a resolved template (happy path)", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
/**
|
||||
* The render outcome of a single pageEmbed node, decided BEFORE rendering a
|
||||
* nested editor. Kept pure (no React) so the cycle / depth / access / not-found
|
||||
* branch logic is unit-testable in isolation; the node view maps each outcome
|
||||
* to a placeholder or the embedded content.
|
||||
*/
|
||||
export type EmbedState =
|
||||
| "no_source" // no sourcePageId picked yet
|
||||
| "cycle" // self-embed or an ancestor already shows this page
|
||||
| "too_deep" // nesting depth limit reached
|
||||
| "unavailable" // no lookup context (e.g. public share)
|
||||
| "loading" // context present, result not back yet
|
||||
| "ok" // resolved template content to render
|
||||
| "no_access" // server says the viewer can't see the page
|
||||
| "not_found"; // server says the page no longer exists
|
||||
|
||||
export interface DecideEmbedStateInput {
|
||||
sourcePageId: string | null;
|
||||
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
|
||||
chain: string[];
|
||||
/** Host page id; a top-level self-embed must be caught against it. */
|
||||
hostPageId: string | null;
|
||||
/** Whether a lookup context is mounted (false on public shares in MVP). */
|
||||
available: boolean;
|
||||
/** The lookup result, or null while still loading. */
|
||||
result: PageTemplateLookup | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what a pageEmbed should render. The order matters: cycle and depth
|
||||
* guards run first (before any lookup is even consulted), then availability,
|
||||
* then the resolved result. Mirrors the branch ladder in PageEmbedBody.
|
||||
*/
|
||||
export function decideEmbedState({
|
||||
sourcePageId,
|
||||
chain,
|
||||
hostPageId,
|
||||
available,
|
||||
result,
|
||||
}: DecideEmbedStateInput): EmbedState {
|
||||
if (!sourcePageId) return "no_source";
|
||||
|
||||
// Self-embed or a source already present in the ancestor chain → cycle.
|
||||
const isCycle = chain.includes(sourcePageId) || hostPageId === sourcePageId;
|
||||
if (isCycle) return "cycle";
|
||||
|
||||
if (chain.length >= PAGE_EMBED_MAX_DEPTH) return "too_deep";
|
||||
|
||||
if (!available) return "unavailable";
|
||||
if (!result) return "loading";
|
||||
|
||||
if (!("status" in result)) return "ok";
|
||||
if (result.status === "no_access") return "no_access";
|
||||
return "not_found";
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
} from "./page-embed-ancestry-context";
|
||||
|
||||
// Probe child: renders the current ancestry context value as JSON so the test
|
||||
// can assert on the accumulated chain and host without any Tiptap editor.
|
||||
function Probe({ testId }: { testId: string }) {
|
||||
const ancestry = usePageEmbedAncestry();
|
||||
return <div data-testid={testId}>{JSON.stringify(ancestry)}</div>;
|
||||
}
|
||||
|
||||
function read(el: HTMLElement) {
|
||||
return JSON.parse(el.textContent || "{}") as {
|
||||
chain: string[];
|
||||
hostPageId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
describe("PageEmbedAncestryProvider", () => {
|
||||
it("accumulates the chain in order across nested providers", () => {
|
||||
const { getByTestId } = render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider sourcePageId="b">
|
||||
<PageEmbedAncestryProvider sourcePageId="c">
|
||||
<Probe testId="leaf" />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a", "b", "c"]);
|
||||
expect(value.hostPageId).toBe("host");
|
||||
});
|
||||
|
||||
it("leaves the chain unchanged when sourcePageId is absent, still propagating the host", () => {
|
||||
const { getByTestId } = render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider>
|
||||
<Probe testId="leaf" />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a"]);
|
||||
expect(value.hostPageId).toBe("host");
|
||||
});
|
||||
|
||||
it("keeps the first (top-level) host even if an inner provider passes a different one", () => {
|
||||
const { getByTestId } = render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="top-host">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="inner-host">
|
||||
<Probe testId="leaf" />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a", "b"]);
|
||||
// Inner host is ignored: the top-level host is set once and propagated.
|
||||
expect(value.hostPageId).toBe("top-host");
|
||||
});
|
||||
|
||||
it("defaults to an empty chain and null host with no provider", () => {
|
||||
const { getByTestId } = render(<Probe testId="leaf" />);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual([]);
|
||||
expect(value.hostPageId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
// Mock the API module the provider calls. Hoisted by vitest before the import.
|
||||
const lookupTemplate = vi.fn();
|
||||
vi.mock("@/features/page-embed/services/page-embed-api", () => ({
|
||||
lookupTemplate: (...args: unknown[]) => lookupTemplate(...args),
|
||||
}));
|
||||
|
||||
// Imported AFTER the mock is declared so the provider picks up the mock.
|
||||
import {
|
||||
PageEmbedLookupProvider,
|
||||
usePageEmbedLookup,
|
||||
} from "./page-embed-lookup-context";
|
||||
|
||||
function ok(id: string): PageTemplateLookup {
|
||||
return {
|
||||
sourcePageId: id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `T-${id}`,
|
||||
icon: null,
|
||||
content: { type: "doc" },
|
||||
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
// Probe that subscribes to a sourceId and exposes its latest result + refresh.
|
||||
function Probe({
|
||||
id,
|
||||
sink,
|
||||
}: {
|
||||
id: string;
|
||||
sink: (api: ReturnType<typeof usePageEmbedLookup>) => void;
|
||||
}) {
|
||||
const api = usePageEmbedLookup(id);
|
||||
sink(api);
|
||||
return <div>{api.result ? "loaded" : "pending"}</div>;
|
||||
}
|
||||
|
||||
describe("PageEmbedLookupProvider (batching / dedup / refresh)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
lookupTemplate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("dedups two subscribers for the same id into a single lookup call; both get the result", async () => {
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
let b: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
<Probe id="p1" sink={(x) => (b = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
// Subscriptions run in effects + the 10ms debounce batches them together.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(lookupTemplate).toHaveBeenCalledWith({ sourcePageIds: ["p1"] });
|
||||
expect(a!.result).toEqual(ok("p1"));
|
||||
expect(b!.result).toEqual(ok("p1"));
|
||||
});
|
||||
|
||||
it("batches two distinct ids subscribed within the window into one call", async () => {
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1"), ok("p2")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={() => {}} />
|
||||
<Probe id="p2" sink={() => {}} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(lookupTemplate.mock.calls[0][0]).toEqual({
|
||||
sourcePageIds: ["p1", "p2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() clears the cache and re-fetches", async () => {
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// refresh resolves once the next batch flush completes.
|
||||
await act(async () => {
|
||||
const p = a!.refresh();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await p;
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("a rejected lookup resolves refresh() waiters, clears inFlight, and logs the error (not swallowed)", async () => {
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
// Initial subscription enqueues a lookup that rejects.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
// The error message is surfaced, not swallowed.
|
||||
expect(errSpy.mock.calls[0][0]).toContain("[pageEmbed] template lookup failed");
|
||||
|
||||
// inFlight was cleared on failure, so a refresh re-enqueues and resolves.
|
||||
lookupTemplate.mockResolvedValueOnce({ items: [ok("p1")] });
|
||||
let resolved = false;
|
||||
await act(async () => {
|
||||
const p = a!.refresh().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await p;
|
||||
});
|
||||
expect(resolved).toBe(true);
|
||||
expect(a!.result).toEqual(ok("p1"));
|
||||
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { IconFileText, IconSearch } from "@tabler/icons-react";
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { searchSuggestions } from "@/features/search/services/search-service";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
import { buildPickerQuery, excludeHost } from "./page-embed-picker.utils";
|
||||
|
||||
export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker";
|
||||
|
||||
@@ -43,21 +44,13 @@ export default function PageEmbedPicker() {
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ["page-embed-template-picker", query],
|
||||
queryFn: () =>
|
||||
searchSuggestions({
|
||||
query,
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
}),
|
||||
queryFn: () => searchSuggestions(buildPickerQuery(query)),
|
||||
enabled: opened,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const hostPageId = detailRef.current?.hostPageId;
|
||||
const pages = ((data?.pages ?? []) as IPage[]).filter(
|
||||
(p) => p && p.id !== hostPageId,
|
||||
);
|
||||
const pages = excludeHost((data?.pages ?? []) as IPage[], hostPageId);
|
||||
|
||||
const handleSelect = (page: IPage) => {
|
||||
const detail = detailRef.current;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { excludeHost, buildPickerQuery } from "./page-embed-picker.utils";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
function page(id: string): IPage {
|
||||
return { id, title: id, slugId: `slug-${id}` } as IPage;
|
||||
}
|
||||
|
||||
describe("excludeHost", () => {
|
||||
it("drops the host page from the results (self-embed guard)", () => {
|
||||
const result = excludeHost([page("a"), page("host"), page("b")], "host");
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns all pages when hostPageId is undefined", () => {
|
||||
const result = excludeHost([page("a"), page("b")], undefined);
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("drops null/blank entries", () => {
|
||||
const result = excludeHost(
|
||||
[page("a"), null as unknown as IPage, page("b")],
|
||||
"host",
|
||||
);
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPickerQuery", () => {
|
||||
it("passes onlyTemplates:true with the query and page inclusion", () => {
|
||||
expect(buildPickerQuery("foo")).toEqual({
|
||||
query: "foo",
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an empty query", () => {
|
||||
expect(buildPickerQuery("").query).toBe("");
|
||||
expect(buildPickerQuery("").onlyTemplates).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
import type { SearchSuggestionParams } from "@/features/search/types/search.types";
|
||||
|
||||
/**
|
||||
* Self-embed guard at insertion time: drop the host page (and any null/blank
|
||||
* entries) from the picker results so the current page can't embed itself.
|
||||
*/
|
||||
export function excludeHost(
|
||||
pages: IPage[],
|
||||
hostPageId: string | undefined,
|
||||
): IPage[] {
|
||||
return pages.filter((p) => p && p.id !== hostPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the search-suggestions query for the template picker. Always restricts
|
||||
* to template-flagged pages (`onlyTemplates`) and includes pages, mirroring the
|
||||
* inline query args in PageEmbedPicker.
|
||||
*/
|
||||
export function buildPickerQuery(query: string): SearchSuggestionParams {
|
||||
return {
|
||||
query,
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
};
|
||||
}
|
||||
@@ -21,8 +21,8 @@ import { usePageEmbedLookup } from "./page-embed-lookup-context";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
PAGE_EMBED_MAX_DEPTH,
|
||||
} from "./page-embed-ancestry-context";
|
||||
import { decideEmbedState } from "./decide-embed-state";
|
||||
import PageEmbedContent from "./page-embed-content";
|
||||
|
||||
function Placeholder({
|
||||
@@ -99,13 +99,15 @@ function PageEmbedBody({
|
||||
}
|
||||
};
|
||||
|
||||
// --- Cycle / depth guard (evaluated before any lookup is rendered) ---------
|
||||
// Self-embed or a source already present in the ancestor chain → cycle.
|
||||
const isCycle =
|
||||
!!sourcePageId &&
|
||||
(ancestry.chain.includes(sourcePageId) ||
|
||||
ancestry.hostPageId === sourcePageId);
|
||||
const isTooDeep = ancestry.chain.length >= PAGE_EMBED_MAX_DEPTH;
|
||||
// --- Cycle / depth / availability decision (pure, unit-tested) ------------
|
||||
// Evaluated before any nested editor is rendered.
|
||||
const embedState = decideEmbedState({
|
||||
sourcePageId,
|
||||
chain: ancestry.chain,
|
||||
hostPageId: ancestry.hostPageId,
|
||||
available,
|
||||
result,
|
||||
});
|
||||
|
||||
const sourceTitle =
|
||||
result && !("status" in result) ? result.title : null;
|
||||
@@ -187,28 +189,28 @@ function PageEmbedBody({
|
||||
) : null;
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (!sourcePageId) {
|
||||
if (embedState === "no_source") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconInfoCircle size={18} stroke={1.6} />}
|
||||
label={t("No page selected")}
|
||||
/>
|
||||
);
|
||||
} else if (isCycle) {
|
||||
} else if (embedState === "cycle") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconRepeat size={18} stroke={1.6} />}
|
||||
label={t("Circular embed: this page is already shown above")}
|
||||
/>
|
||||
);
|
||||
} else if (isTooDeep) {
|
||||
} else if (embedState === "too_deep") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconRepeat size={18} stroke={1.6} />}
|
||||
label={t("Embed nesting limit reached")}
|
||||
/>
|
||||
);
|
||||
} else if (!available) {
|
||||
} else if (embedState === "unavailable") {
|
||||
// No lookup context (e.g. public share) → placeholder, no fetch in MVP.
|
||||
body = (
|
||||
<Placeholder
|
||||
@@ -216,9 +218,9 @@ function PageEmbedBody({
|
||||
label={t("Embedded page is not available here")}
|
||||
/>
|
||||
);
|
||||
} else if (!result) {
|
||||
} else if (embedState === "loading") {
|
||||
body = <div style={{ minHeight: 24 }} />;
|
||||
} else if (!("status" in result)) {
|
||||
} else if (embedState === "ok" && result && !("status" in result)) {
|
||||
body = (
|
||||
<PageEmbedAncestryProvider
|
||||
sourcePageId={sourcePageId}
|
||||
@@ -227,7 +229,7 @@ function PageEmbedBody({
|
||||
<PageEmbedContent content={result.content} />
|
||||
</PageEmbedAncestryProvider>
|
||||
);
|
||||
} else if (result.status === "no_access") {
|
||||
} else if (embedState === "no_access") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconEyeOff size={18} stroke={1.6} />}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
IconTag,
|
||||
IconMoodSmile,
|
||||
IconRotate2,
|
||||
IconSuperscript,
|
||||
IconArrowsMaximize,
|
||||
} from "@tabler/icons-react";
|
||||
import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker";
|
||||
@@ -368,6 +369,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setDetails().run(),
|
||||
},
|
||||
{
|
||||
title: "Footnote",
|
||||
description: "Insert a footnote reference.",
|
||||
searchTerms: ["footnote", "note", "reference", "сноска", "примечание"],
|
||||
icon: IconSuperscript,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setFootnote().run(),
|
||||
},
|
||||
{
|
||||
title: "Callout",
|
||||
description: "Insert callout notice.",
|
||||
|
||||
@@ -63,6 +63,9 @@ import {
|
||||
TransclusionReference,
|
||||
PageEmbed,
|
||||
TableView,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -94,6 +97,9 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx";
|
||||
import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx";
|
||||
import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx";
|
||||
import PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
@@ -392,6 +398,19 @@ export const mainExtensions = [
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
FootnoteReference.configure({
|
||||
view: FootnoteReferenceView,
|
||||
// Skip orphan-cleanup on remote/collaboration steps so collaborating
|
||||
// clients never fight over footnote integrity (deterministic numbering
|
||||
// decorations handle the rest).
|
||||
isRemoteTransaction: (tr: any) => isChangeOrigin(tr),
|
||||
}),
|
||||
FootnotesList.configure({
|
||||
view: FootnotesListView,
|
||||
}),
|
||||
FootnoteDefinition.configure({
|
||||
view: FootnoteDefinitionView,
|
||||
}),
|
||||
PageEmbed.configure({
|
||||
view: PageEmbedView,
|
||||
}),
|
||||
|
||||
@@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
);
|
||||
const filteredExtensions = mainExtensions
|
||||
.filter((ext) => ext.name !== "uniqueID")
|
||||
// Read-only must only DECORATE footnotes (numbering), never mutate the
|
||||
// doc. Disable the footnote sync/integrity plugin so a programmatic
|
||||
// setContent on a doc the viewer can't edit is never rewritten.
|
||||
.map((ext) =>
|
||||
ext.name === "footnoteReference"
|
||||
? ext.configure({ enableSync: false })
|
||||
: ext,
|
||||
);
|
||||
|
||||
return [
|
||||
...filteredExtensions,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { render, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// SpaceTree pulls in query hooks, page services, i18n, notifications and two
|
||||
// child render components. The expandAll contract is exercised purely through
|
||||
// the imperative ref, so we mock everything that would otherwise need a real
|
||||
// server / router and stub the visual children to empty renders.
|
||||
|
||||
const getSpaceTreeMock = vi.fn();
|
||||
const notificationsShowMock = vi.fn();
|
||||
|
||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||
getSpaceTree: (...args: unknown[]) => getSpaceTreeMock(...args),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
// No root pages and no further pages — the data-load effect is inert so the
|
||||
// test fully controls the tree through expandAll.
|
||||
useGetRootSidebarPagesQuery: () => ({
|
||||
data: undefined,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
||||
useTreeMutation: () => ({ handleMove: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (...args: unknown[]) => notificationsShowMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useParams: () => ({ pageSlug: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
extractPageSlugId: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/config.ts", () => ({
|
||||
isCompactPageTreeEnabled: () => false,
|
||||
}));
|
||||
|
||||
// Stub the visual children so we don't drag in the full DnD / Mantine stack.
|
||||
vi.mock("./doc-tree", () => ({
|
||||
DocTree: () => null,
|
||||
ROW_HEIGHT_COMPACT: 28,
|
||||
ROW_HEIGHT_STANDARD: 32,
|
||||
}));
|
||||
vi.mock("./space-tree-row", () => ({
|
||||
SpaceTreeRow: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Text: ({ children }: { children?: unknown }) => children ?? null,
|
||||
}));
|
||||
|
||||
// The real openTreeNodesAtom is localStorage-backed (atomWithStorage +
|
||||
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
||||
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
||||
// shape (value OR functional updater) so the component's open-state logic runs
|
||||
// unchanged while staying inside the test store.
|
||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
type OpenMap = Record<string, boolean>;
|
||||
const base = atom<OpenMap>({});
|
||||
const openTreeNodesAtom = atom(
|
||||
(get) => get(base),
|
||||
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
|
||||
const next =
|
||||
typeof update === "function"
|
||||
? (update as (prev: OpenMap) => OpenMap)(get(base))
|
||||
: update;
|
||||
set(base, next);
|
||||
},
|
||||
);
|
||||
return { openTreeNodesAtom };
|
||||
});
|
||||
|
||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// A flat space-tree response (parentPageId pointers) that buildTree +
|
||||
// buildTreeWithChildren nest into a multi-level tree. Depth > 1 lets us assert
|
||||
// expandAll never fans out into per-branch fetches (no N+1).
|
||||
function spaceTreeItems(): SpaceTreeNode[] {
|
||||
const n = (
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): SpaceTreeNode => ({
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id,
|
||||
icon: undefined,
|
||||
position,
|
||||
spaceId: "space-1",
|
||||
parentPageId: parentPageId as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
});
|
||||
return [
|
||||
n("root", null, "a0"),
|
||||
n("branch", "root", "a1"),
|
||||
n("leaf", "branch", "a1"),
|
||||
];
|
||||
}
|
||||
|
||||
function renderTree(store: ReturnType<typeof createStore>) {
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
return ref;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSpaceTreeMock.mockReset();
|
||||
notificationsShowMock.mockReset();
|
||||
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
||||
// fresh jotai store anyway, so cross-test open-state never leaks.
|
||||
try {
|
||||
localStorage.clear?.();
|
||||
} catch {
|
||||
/* ignore — fresh store per test isolates state */
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SpaceTree.expandAll (integration via ref)", () => {
|
||||
it("makes exactly ONE getSpaceTree call regardless of depth (no N+1)", async () => {
|
||||
getSpaceTreeMock.mockResolvedValue(spaceTreeItems());
|
||||
const store = createStore();
|
||||
const ref = renderTree(store);
|
||||
|
||||
await ref.current!.expandAll();
|
||||
|
||||
expect(getSpaceTreeMock).toHaveBeenCalledTimes(1);
|
||||
expect(getSpaceTreeMock).toHaveBeenCalledWith({ spaceId: "space-1" });
|
||||
|
||||
// Every branch node (root, branch) is opened; the leaf needs no entry.
|
||||
const openMap = store.get(openTreeNodesAtom);
|
||||
expect(openMap["root"]).toBe(true);
|
||||
expect(openMap["branch"]).toBe(true);
|
||||
expect(openMap["leaf"]).toBeUndefined();
|
||||
|
||||
// The full tree replaced the current-space nodes.
|
||||
const data = store.get(treeDataAtom);
|
||||
expect(data.map((d) => d.id)).toEqual(["root"]);
|
||||
});
|
||||
|
||||
it("shows a notification and still resets isExpanding when getSpaceTree rejects", async () => {
|
||||
getSpaceTreeMock.mockRejectedValue(new Error("boom"));
|
||||
const store = createStore();
|
||||
const ref = renderTree(store);
|
||||
|
||||
await ref.current!.expandAll();
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: "red" }),
|
||||
);
|
||||
|
||||
// isExpanding must be reset in the finally block even on failure.
|
||||
await waitFor(() => {
|
||||
expect(ref.current!.isExpanding).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts the merge when the space switches mid-flight", async () => {
|
||||
// getSpaceTree resolves only after we flip the tree to a different space,
|
||||
// simulating the user navigating away while the request is in flight.
|
||||
let resolveTree: (v: SpaceTreeNode[]) => void = () => {};
|
||||
getSpaceTreeMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<SpaceTreeNode[]>((resolve) => {
|
||||
resolveTree = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const store = createStore();
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
const { rerender } = render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const promise = ref.current!.expandAll();
|
||||
|
||||
// Switch the space mid-flight: spaceIdRef.current becomes "space-2".
|
||||
rerender(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-2" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Now resolve the in-flight request for the OLD space.
|
||||
resolveTree(spaceTreeItems());
|
||||
await promise;
|
||||
|
||||
// The merge must have been aborted: no tree data written, no branches opened.
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
const openMap = store.get(openTreeNodesAtom);
|
||||
expect(openMap["root"]).toBeUndefined();
|
||||
expect(openMap["branch"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
mergeRootTrees,
|
||||
collectAllIds,
|
||||
collectBranchIds,
|
||||
openBranches,
|
||||
closeIds,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
@@ -236,11 +238,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
// Open every branch node (node with children) of the current space only.
|
||||
const branchIds = collectBranchIds(fullTree);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of branchIds) next[id] = true;
|
||||
return next;
|
||||
});
|
||||
setOpenTreeNodes((prev) => openBranches(prev, branchIds));
|
||||
} catch (err: any) {
|
||||
// Never swallow: log full error + surface the real reason.
|
||||
console.error("[tree] expandAll failed", err);
|
||||
@@ -261,11 +259,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
// other spaces' expanded state is left intact.
|
||||
const ids = collectAllIds(filteredData);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
});
|
||||
setOpenTreeNodes((prev) => closeIds(prev, ids));
|
||||
}, [filteredData, setOpenTreeNodes]);
|
||||
|
||||
useImperativeHandle(
|
||||
|
||||
@@ -196,6 +196,17 @@ describe('treeModel.insertByPosition', () => {
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
|
||||
});
|
||||
|
||||
it('tie-break: a node whose position EQUALS a sibling lands deterministically (strict >)', () => {
|
||||
// The insertion index is the first sibling whose position sorts STRICTLY
|
||||
// after the new node's. An equal sibling is not strictly after, so it is
|
||||
// skipped — the new node lands immediately AFTER every equal-position
|
||||
// sibling and before the first strictly-greater one. This is deterministic:
|
||||
// a tie always resolves the same way on every client.
|
||||
const node: P = { id: 'x', name: 'X', position: 'a2' }; // equals b's position
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'x', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
// addTreeNode idempotency: the receiver early-returns when the node id already
|
||||
@@ -692,4 +703,45 @@ describe('treeModel.move', () => {
|
||||
});
|
||||
expect(out.tree).toBe(fixture);
|
||||
});
|
||||
|
||||
it('cross-parent move does NOT apply the same-parent adjust (no off-by-one)', () => {
|
||||
// Source `x3` sits at index 2 in parent `x`; target `y1` sits at index 0 in
|
||||
// parent `y`. sourceInfo.index (2) > info.index (0) AND the parents differ,
|
||||
// so the `sameParent && source.index < info.index` adjust must be 0 — the
|
||||
// node must land at index 0 in `y`, not at index -1 (which would silently
|
||||
// drop it at a wrong slot / off-by-one).
|
||||
const crossFixture: N[] = [
|
||||
{
|
||||
id: 'x',
|
||||
name: 'X',
|
||||
children: [
|
||||
{ id: 'x1', name: 'X1' },
|
||||
{ id: 'x2', name: 'X2' },
|
||||
{ id: 'x3', name: 'X3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'y',
|
||||
name: 'Y',
|
||||
children: [
|
||||
{ id: 'y1', name: 'Y1' },
|
||||
{ id: 'y2', name: 'Y2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const { tree: t, result } = treeModel.move(crossFixture, 'x3', {
|
||||
kind: 'reorder-before',
|
||||
targetId: 'y1',
|
||||
});
|
||||
expect(result).toEqual({ parentId: 'y', index: 0 });
|
||||
expect(treeModel.find(t, 'y')?.children?.map((n) => n.id)).toEqual([
|
||||
'x3',
|
||||
'y1',
|
||||
'y2',
|
||||
]);
|
||||
expect(treeModel.find(t, 'x')?.children?.map((n) => n.id)).toEqual([
|
||||
'x1',
|
||||
'x2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildTree } from "./utils";
|
||||
import {
|
||||
buildTree,
|
||||
buildTreeWithChildren,
|
||||
collectAllIds,
|
||||
collectBranchIds,
|
||||
openBranches,
|
||||
closeIds,
|
||||
} from "./utils";
|
||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
function page(id: string, position: string): IPage {
|
||||
return {
|
||||
@@ -15,6 +23,44 @@ function page(id: string, position: string): IPage {
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
// Flat SpaceTreeNode factory for buildTreeWithChildren (it consumes a flat list
|
||||
// with parentPageId pointers and nests them).
|
||||
function flatNode(
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position,
|
||||
spaceId: "space-1",
|
||||
parentPageId: parentPageId as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Nested SpaceTreeNode factory for collectAllIds / collectBranchIds.
|
||||
function treeNode(
|
||||
id: string,
|
||||
children: SpaceTreeNode[] = [],
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: children.length > 0,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTree", () => {
|
||||
it("builds one node per unique page", () => {
|
||||
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
|
||||
@@ -38,3 +84,192 @@ describe("buildTree", () => {
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectBranchIds", () => {
|
||||
it("returns every node-with-children id in a multi-level tree", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1")]),
|
||||
treeNode("leaf2"),
|
||||
]),
|
||||
treeNode("root2", [treeNode("leaf3")]),
|
||||
];
|
||||
expect(collectBranchIds(tree).sort()).toEqual([
|
||||
"branch1",
|
||||
"root",
|
||||
"root2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] for a leaf-only tree", () => {
|
||||
const tree = [treeNode("a"), treeNode("b"), treeNode("c")];
|
||||
expect(collectBranchIds(tree)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT include a node whose children is an empty array", () => {
|
||||
// hasChildren-less / empty-children nodes are leaves for expansion purposes.
|
||||
const tree = [treeNode("a", [])];
|
||||
expect(collectBranchIds(tree)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns every ancestor id in a deep single chain", () => {
|
||||
const chain = treeNode("a", [
|
||||
treeNode("b", [treeNode("c", [treeNode("d")])]),
|
||||
]);
|
||||
// a, b, c are branches; d is the leaf.
|
||||
expect(collectBranchIds([chain])).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty tree", () => {
|
||||
expect(collectBranchIds([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectAllIds", () => {
|
||||
it("returns every id (roots, branches, leaves)", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1")]),
|
||||
treeNode("leaf2"),
|
||||
]),
|
||||
treeNode("root2"),
|
||||
];
|
||||
expect(collectAllIds(tree).sort()).toEqual([
|
||||
"branch1",
|
||||
"leaf1",
|
||||
"leaf2",
|
||||
"root",
|
||||
"root2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns every id in a deep chain", () => {
|
||||
const chain = treeNode("a", [
|
||||
treeNode("b", [treeNode("c", [treeNode("d")])]),
|
||||
]);
|
||||
expect(collectAllIds([chain])).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty tree", () => {
|
||||
expect(collectAllIds([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("is a superset of collectBranchIds for the same tree (property)", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1"), treeNode("leaf2")]),
|
||||
treeNode("branch2", [treeNode("leaf3")]),
|
||||
treeNode("leaf4"),
|
||||
]),
|
||||
treeNode("root2", [treeNode("leaf5")]),
|
||||
];
|
||||
const all = new Set(collectAllIds(tree));
|
||||
const branches = collectBranchIds(tree);
|
||||
for (const id of branches) {
|
||||
expect(all.has(id)).toBe(true);
|
||||
}
|
||||
// And the superset is strictly larger (it also has the leaves).
|
||||
expect(all.size).toBeGreaterThan(branches.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTreeWithChildren", () => {
|
||||
it("nests a flat list and sorts siblings by position", () => {
|
||||
// Provided out of position order to prove the sort.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("c2", "root", "a4"),
|
||||
flatNode("c1", "root", "a1"),
|
||||
];
|
||||
const tree = buildTreeWithChildren(flat);
|
||||
expect(tree.map((n) => n.id)).toEqual(["root"]);
|
||||
expect(tree[0].children.map((n) => n.id)).toEqual(["c1", "c2"]);
|
||||
});
|
||||
|
||||
it("recomputes hasChildren to true for nodes that gain children", () => {
|
||||
// Parent ships with hasChildren=false; building must flip it true.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("child", "root", "a1"),
|
||||
];
|
||||
expect(flat[0].hasChildren).toBe(false);
|
||||
const tree = buildTreeWithChildren(flat);
|
||||
expect(tree[0].hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a node whose parentPageId is ABSENT from the list as a root (no crash)", () => {
|
||||
// Permission-trimmed response: `orphan`'s parent `missing` was filtered out
|
||||
// server-side. The function must not throw and must surface the orphan as a
|
||||
// root rather than dropping or crashing on it.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("orphan", "missing", "a2"),
|
||||
];
|
||||
let tree: SpaceTreeNode[] = [];
|
||||
expect(() => {
|
||||
tree = buildTreeWithChildren(flat);
|
||||
}).not.toThrow();
|
||||
expect(tree.map((n) => n.id).sort()).toEqual(["orphan", "root"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openBranches", () => {
|
||||
it("sets all given ids to true", () => {
|
||||
const next = openBranches({}, ["a", "b", "c"]);
|
||||
expect(next).toEqual({ a: true, b: true, c: true });
|
||||
});
|
||||
|
||||
it("preserves pre-existing open ids and other-space ids", () => {
|
||||
const prev = { existing: true, "other-space": true, closed: false };
|
||||
const next = openBranches(prev, ["a"]);
|
||||
expect(next).toEqual({
|
||||
existing: true,
|
||||
"other-space": true,
|
||||
closed: false,
|
||||
a: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input map", () => {
|
||||
const prev = { a: false };
|
||||
const next = openBranches(prev, ["a"]);
|
||||
expect(prev).toEqual({ a: false });
|
||||
expect(next).not.toBe(prev);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = openBranches({ z: true }, ["a", "b"]);
|
||||
const twice = openBranches(once, ["a", "b"]);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeIds", () => {
|
||||
it("flips current-space ids to false while leaving OTHER-space ids untouched", () => {
|
||||
const prev = {
|
||||
"current-1": true,
|
||||
"current-2": true,
|
||||
"other-space": true,
|
||||
};
|
||||
const next = closeIds(prev, ["current-1", "current-2"]);
|
||||
expect(next).toEqual({
|
||||
"current-1": false,
|
||||
"current-2": false,
|
||||
"other-space": true, // untouched
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input map", () => {
|
||||
const prev = { a: true };
|
||||
const next = closeIds(prev, ["a"]);
|
||||
expect(prev).toEqual({ a: true });
|
||||
expect(next).not.toBe(prev);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = closeIds({ keep: true }, ["a", "b"]);
|
||||
const twice = closeIds(once, ["a", "b"]);
|
||||
expect(twice).toEqual(once);
|
||||
expect(twice).toEqual({ keep: true, a: false, b: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,11 +142,17 @@ export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||
// Build the tree array
|
||||
items.forEach((item) => {
|
||||
const node = nodeMap[item.id];
|
||||
if (item.parentPageId !== null) {
|
||||
// A permission-trimmed response can include a node whose `parentPageId` is
|
||||
// not in the list (the parent was filtered out server-side). Treat such an
|
||||
// orphan as a root instead of dereferencing an absent parent and throwing
|
||||
// "Cannot read properties of undefined". Happy-path behaviour is unchanged:
|
||||
// a node whose parent IS present still nests under it.
|
||||
if (item.parentPageId !== null && nodeMap[item.parentPageId]) {
|
||||
// Find the parent node and add the current node to its children
|
||||
nodeMap[item.parentPageId].children.push(node);
|
||||
} else {
|
||||
// If the item has no parent, it's a root node, so add it to the result array
|
||||
// If the item has no parent (or its parent isn't loaded), it's a root
|
||||
// node, so add it to the result array.
|
||||
result.push(node);
|
||||
}
|
||||
});
|
||||
@@ -254,3 +260,30 @@ export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// The open-state map (`openTreeNodesAtom`) is shared across spaces. Pure
|
||||
// next-map helpers for expand/collapse so the merge logic can be unit-tested
|
||||
// without rendering SpaceTree. Both return a fresh map and never mutate the
|
||||
// input — ids not in `ids` (e.g. other spaces) are carried over untouched.
|
||||
|
||||
// Set each id in `ids` to true (open). Pre-existing entries (including other
|
||||
// spaces' open state) are preserved.
|
||||
export function openBranches(
|
||||
prevMap: Record<string, boolean>,
|
||||
ids: string[],
|
||||
): Record<string, boolean> {
|
||||
const next = { ...prevMap };
|
||||
for (const id of ids) next[id] = true;
|
||||
return next;
|
||||
}
|
||||
|
||||
// Set each id in `ids` to false (closed). Entries not listed (e.g. other
|
||||
// spaces' ids) are left exactly as they were.
|
||||
export function closeIds(
|
||||
prevMap: Record<string, boolean>,
|
||||
ids: string[],
|
||||
): Record<string, boolean> {
|
||||
const next = { ...prevMap };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
264
apps/client/src/features/websocket/tree-socket-reducers.test.ts
Normal file
264
apps/client/src/features/websocket/tree-socket-reducers.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyAddTreeNode,
|
||||
applyMoveTreeNode,
|
||||
applyDeleteTreeNode,
|
||||
} from "./tree-socket-reducers";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// Minimal node factory — fills the SpaceTreeNode shape required fields while
|
||||
// letting tests override the bits that matter (position, parentPageId, etc).
|
||||
function node(
|
||||
id: string,
|
||||
overrides: 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: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyMoveTreeNode", () => {
|
||||
// Destination parent `dst` is loaded with three positioned children; the moved
|
||||
// node `src` is a sibling at root with a later position.
|
||||
const buildTree = (): SpaceTreeNode[] => [
|
||||
node("dst", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("c1", { position: "a1", parentPageId: "dst" }),
|
||||
node("c2", { position: "a3", parentPageId: "dst" }),
|
||||
node("c3", { position: "a5", parentPageId: "dst" }),
|
||||
],
|
||||
}),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
|
||||
it("places the node by position in the MIDDLE slot of the destination", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"c1",
|
||||
"c2",
|
||||
"src",
|
||||
"c3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to REMOVING the node when destination parent is not loaded (no leak)", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "not-loaded",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
// The source must not linger at its old place — it is removed entirely.
|
||||
expect(treeModel.find(next, "src")).toBeNull();
|
||||
// Destination children are untouched.
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"c1",
|
||||
"c2",
|
||||
"c3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
|
||||
// src is the only child of `old`; moving it to `dst` empties `old`.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("old", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("src", { position: "a1", parentPageId: "old" })],
|
||||
}),
|
||||
node("dst", { position: "a2", hasChildren: false }),
|
||||
];
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: "old",
|
||||
index: 0,
|
||||
position: "a1",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "old")?.hasChildren).toBe(false);
|
||||
});
|
||||
|
||||
it("flips the NEW parent's hasChildren to true", () => {
|
||||
// dst starts as a childless leaf; moving src into it must flip the chevron.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("dst", { position: "a0", hasChildren: false }),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a1",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "dst")?.hasChildren).toBe(true);
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"src",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the source node is not found", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "ghost",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("applies authoritative pageData (title/icon/hasChildren) to the moved node", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: { title: "Renamed", icon: "fire", hasChildren: true },
|
||||
});
|
||||
const moved = treeModel.find(next, "src");
|
||||
expect(moved?.name).toBe("Renamed");
|
||||
expect(moved?.icon).toBe("fire");
|
||||
expect(moved?.hasChildren).toBe(true);
|
||||
expect(moved?.position).toBe("a4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeleteTreeNode", () => {
|
||||
it("removes the node together with its descendants", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("child", {
|
||||
position: "a1",
|
||||
parentPageId: "p",
|
||||
hasChildren: true,
|
||||
children: [node("grandchild", { position: "a1", parentPageId: "child" })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("child", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "child")).toBeNull();
|
||||
expect(treeModel.find(next, "grandchild")).toBeNull();
|
||||
expect(treeModel.find(next, "p")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the node is already gone (idempotent)", () => {
|
||||
const tree: SpaceTreeNode[] = [node("a", { position: "a0" })];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("ghost"),
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("flips the parent's hasChildren to false when it is left childless", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("only", { position: "a1", parentPageId: "p" })],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("only", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(false);
|
||||
expect(treeModel.find(next, "p")?.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the parent's hasChildren true when other children remain", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("c1", { position: "a1", parentPageId: "p" }),
|
||||
node("c2", { position: "a2", parentPageId: "p" }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("c1", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAddTreeNode", () => {
|
||||
const roots = (): SpaceTreeNode[] => [
|
||||
node("a", { position: "a0" }),
|
||||
node("b", { position: "a2" }),
|
||||
node("c", { position: "a4" }),
|
||||
];
|
||||
|
||||
it("inserts the new node by position among siblings", () => {
|
||||
const tree = roots();
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: null as unknown as string,
|
||||
index: 0,
|
||||
data: node("x", { position: "a3" }),
|
||||
});
|
||||
expect(next.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the id is already present (idempotent)", () => {
|
||||
const tree = roots();
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: null as unknown as string,
|
||||
index: 0,
|
||||
data: node("b", { position: "a9" }),
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
expect(next.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("flips the new parent's hasChildren to true", () => {
|
||||
// Parent `p` is a childless leaf; adding a child must flip its chevron.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", { position: "a0", hasChildren: false }),
|
||||
];
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: "p",
|
||||
index: 0,
|
||||
data: node("child", { position: "a1", parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
|
||||
expect(treeModel.find(next, "p")?.children?.map((n) => n.id)).toEqual([
|
||||
"child",
|
||||
]);
|
||||
});
|
||||
});
|
||||
164
apps/client/src/features/websocket/tree-socket-reducers.ts
Normal file
164
apps/client/src/features/websocket/tree-socket-reducers.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import type {
|
||||
AddTreeNodeEvent,
|
||||
MoveTreeNodeEvent,
|
||||
DeleteTreeNodeEvent,
|
||||
UpdateEvent,
|
||||
} from "@/features/websocket/types";
|
||||
|
||||
// Pure tree transforms for the `useTreeSocket` reducer arms. Extracted from the
|
||||
// hook so the realtime tree behaviour can be unit-tested without rendering the
|
||||
// hook, the socket, or jotai. The hook calls these inside its `setData`.
|
||||
//
|
||||
// IMPORTANT: these are PURE — no `queryClient`, no notifications, no atoms. The
|
||||
// delete arm's `queryClient.invalidateQueries` side effect stays in the hook;
|
||||
// `applyDeleteTreeNode` is a pure tree transform only.
|
||||
|
||||
// `updateOne` for a page: patch the in-tree node's name/icon from the payload.
|
||||
// No-op (returns the same reference) when the node isn't loaded on this client.
|
||||
export function applyUpdateOne(
|
||||
prev: SpaceTreeNode[],
|
||||
event: UpdateEvent,
|
||||
): SpaceTreeNode[] {
|
||||
if (!treeModel.find(prev, event.id)) return prev;
|
||||
let next = prev;
|
||||
if (event.payload?.title !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
name: event.payload.title,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (event.payload?.icon !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
icon: event.payload.icon,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// `addTreeNode`: insert the new node by its fractional `position` among the
|
||||
// already-loaded siblings (not the sender's absolute index). Idempotent — if the
|
||||
// id already exists (optimistic author insert or re-delivery) returns prev
|
||||
// unchanged. Flips the new parent's `hasChildren` to true so the chevron renders.
|
||||
export function applyAddTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: AddTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
// Idempotent: the author already inserted the node optimistically, and a node
|
||||
// may be re-delivered — never insert a duplicate id.
|
||||
if (treeModel.find(prev, payload.data.id)) return prev;
|
||||
const newParentId = payload.parentId as string | null;
|
||||
// Insert by `position` among already-loaded siblings (not the sender's
|
||||
// absolute index) so order is consistent across clients with different loaded
|
||||
// sets.
|
||||
let next = treeModel.insertByPosition(prev, newParentId, payload.data);
|
||||
// Mirror the emitter: flip new parent's hasChildren to true so the chevron
|
||||
// renders on the receiver.
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// `moveTreeNode`: place the moved node by its fractional `position` among the new
|
||||
// siblings (NOT the sender's absolute index). If the destination parent isn't
|
||||
// loaded on this client, fall back to removing the source so the UI stays
|
||||
// consistent. Applies authoritative `pageData` fields and mirrors the
|
||||
// `hasChildren` bookkeeping for both the old and the new parent.
|
||||
export function applyMoveTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: MoveTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
const sourceBefore = treeModel.find(prev, payload.id);
|
||||
if (!sourceBefore) return prev;
|
||||
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = payload.parentId as string | null;
|
||||
|
||||
// Place the node by its fractional `position` among the new siblings — NOT by
|
||||
// the sender's absolute `index` (the sender computed that against its own
|
||||
// loaded set, which differs from this receiver's). Using the position keeps
|
||||
// the visible order correct on every client; placing at `index: 0` would
|
||||
// wrongly drop reordered/moved nodes at the top of their new sibling list.
|
||||
const placed = treeModel.placeByPosition(prev, payload.id, {
|
||||
parentId: newParentId,
|
||||
position: payload.position,
|
||||
});
|
||||
// `placeByPosition` silently returns the same reference if the destination
|
||||
// parent isn't loaded on this client. Falling back to removing the source
|
||||
// keeps the UI consistent (the source reappears when the user expands the new
|
||||
// parent and lazy-load fetches it).
|
||||
if (placed === prev) {
|
||||
return treeModel.remove(prev, payload.id);
|
||||
}
|
||||
|
||||
// Apply the authoritative node fields the move payload carries (`pageData`) so
|
||||
// receivers don't keep a stale title/icon/chevron on the moved node.
|
||||
// `placeByPosition` already set `position`.
|
||||
const pageData = payload.pageData as
|
||||
| {
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const patch: Partial<SpaceTreeNode> = {
|
||||
position: payload.position,
|
||||
// Honest type: a root move has a null parent, so this is `string | null`,
|
||||
// not always `string`.
|
||||
parentPageId: newParentId as string | null,
|
||||
};
|
||||
if (pageData) {
|
||||
// The tree node stores the title as `name`.
|
||||
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
|
||||
if (pageData.icon !== undefined) patch.icon = pageData.icon ?? undefined;
|
||||
if (pageData.hasChildren !== undefined)
|
||||
patch.hasChildren = pageData.hasChildren;
|
||||
}
|
||||
let next = treeModel.update(placed, payload.id, patch);
|
||||
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
|
||||
// same chevron state.
|
||||
if (oldParentId) {
|
||||
const oldParent = treeModel.find(next, oldParentId);
|
||||
if (!oldParent?.children?.length) {
|
||||
next = treeModel.update(next, oldParentId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
// `deleteTreeNode`: remove the node (and its descendants) from the tree.
|
||||
// Idempotent — if the node is already gone returns prev unchanged. Mirrors the
|
||||
// `hasChildren` bookkeeping: a parent left childless flips `hasChildren` false.
|
||||
//
|
||||
// PURE: the `queryClient.invalidateQueries` side effect lives in the hook, not
|
||||
// here.
|
||||
export function applyDeleteTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: DeleteTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
if (!treeModel.find(prev, payload.node.id)) return prev;
|
||||
let next = treeModel.remove(prev, payload.node.id);
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
|
||||
// same chevron state when the last child is deleted.
|
||||
const parentPageId = payload.node.parentPageId;
|
||||
if (parentPageId) {
|
||||
const parent = treeModel.find(next, parentPageId);
|
||||
if (!parent?.children?.length) {
|
||||
next = treeModel.update(next, parentPageId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { WebSocketEvent } from "@/features/websocket/types";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import {
|
||||
applyUpdateOne,
|
||||
applyAddTreeNode,
|
||||
applyMoveTreeNode,
|
||||
applyDeleteTreeNode,
|
||||
} from "@/features/websocket/tree-socket-reducers.ts";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
export const useTreeSocket = () => {
|
||||
@@ -35,138 +41,26 @@ export const useTreeSocket = () => {
|
||||
switch (event.operation) {
|
||||
case "updateOne":
|
||||
if (event.entity[0] === "pages") {
|
||||
setTreeData((prev) => {
|
||||
if (!treeModel.find(prev, event.id)) return prev;
|
||||
let next = prev;
|
||||
if (event.payload?.title !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
name: event.payload.title,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (event.payload?.icon !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
icon: event.payload.icon,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyUpdateOne(prev, event));
|
||||
}
|
||||
break;
|
||||
case "addTreeNode":
|
||||
setTreeData((prev) => {
|
||||
// Idempotent: the author already inserted the node optimistically,
|
||||
// and a node may be re-delivered — never insert a duplicate id.
|
||||
if (treeModel.find(prev, event.payload.data.id)) return prev;
|
||||
const newParentId = event.payload.parentId as string | null;
|
||||
// Insert by `position` among already-loaded siblings (not the
|
||||
// sender's absolute index) so order is consistent across clients
|
||||
// with different loaded sets.
|
||||
let next = treeModel.insertByPosition(
|
||||
prev,
|
||||
newParentId,
|
||||
event.payload.data,
|
||||
);
|
||||
// Mirror the emitter: flip new parent's hasChildren to true so
|
||||
// the chevron renders on the receiver.
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyAddTreeNode(prev, event.payload));
|
||||
break;
|
||||
case "moveTreeNode":
|
||||
setTreeData((prev) => {
|
||||
const sourceBefore = treeModel.find(prev, event.payload.id);
|
||||
if (!sourceBefore) return prev;
|
||||
const oldParentId =
|
||||
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = event.payload.parentId as string | null;
|
||||
|
||||
// Place the node by its fractional `position` among the new
|
||||
// siblings — NOT by the sender's absolute `index` (the sender
|
||||
// computed that against its own loaded set, which differs from
|
||||
// this receiver's). Using the position keeps the visible order
|
||||
// correct on every client; placing at `index: 0` would wrongly
|
||||
// drop reordered/moved nodes at the top of their new sibling list.
|
||||
const placed = treeModel.placeByPosition(prev, event.payload.id, {
|
||||
parentId: newParentId,
|
||||
position: event.payload.position,
|
||||
});
|
||||
// `placeByPosition` silently returns the same reference if the
|
||||
// destination parent isn't loaded on this client. Falling back to
|
||||
// removing the source keeps the UI consistent (the source will
|
||||
// reappear when the user expands the new parent and lazy-load
|
||||
// fetches it).
|
||||
if (placed === prev) {
|
||||
return treeModel.remove(prev, event.payload.id);
|
||||
}
|
||||
|
||||
// Apply the authoritative node fields the move payload carries
|
||||
// (`pageData`) so receivers don't keep a stale title/icon/chevron
|
||||
// on the moved node. `placeByPosition` already set `position`.
|
||||
const pageData = event.payload.pageData as
|
||||
| {
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const patch: Partial<SpaceTreeNode> = {
|
||||
position: event.payload.position,
|
||||
// Honest type: a root move has a null parent, so this is
|
||||
// `string | null`, not always `string`.
|
||||
parentPageId: newParentId as string | null,
|
||||
};
|
||||
if (pageData) {
|
||||
// The tree node stores the title as `name`.
|
||||
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
|
||||
if (pageData.icon !== undefined)
|
||||
patch.icon = pageData.icon ?? undefined;
|
||||
if (pageData.hasChildren !== undefined)
|
||||
patch.hasChildren = pageData.hasChildren;
|
||||
}
|
||||
let next = treeModel.update(placed, event.payload.id, patch);
|
||||
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients
|
||||
// converge to the same chevron state.
|
||||
if (oldParentId) {
|
||||
const oldParent = treeModel.find(next, oldParentId);
|
||||
if (!oldParent?.children?.length) {
|
||||
next = treeModel.update(next, oldParentId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyMoveTreeNode(prev, event.payload));
|
||||
break;
|
||||
case "deleteTreeNode":
|
||||
// The `invalidateQueries` side effect stays in the hook; the tree
|
||||
// transform (`applyDeleteTreeNode`) is pure. Only invalidate when the
|
||||
// node is actually in the tree (mirrors the pure reducer's early-out).
|
||||
setTreeData((prev) => {
|
||||
if (!treeModel.find(prev, event.payload.node.id)) return prev;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
|
||||
});
|
||||
let next = treeModel.remove(prev, event.payload.node.id);
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients
|
||||
// converge to the same chevron state when the last child is deleted.
|
||||
const parentPageId = event.payload.node.parentPageId;
|
||||
if (parentPageId) {
|
||||
const parent = treeModel.find(next, parentPageId);
|
||||
if (!parent?.children?.length) {
|
||||
next = treeModel.update(next, parentPageId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (treeModel.find(prev, event.payload.node.id)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
return applyDeleteTreeNode(prev, event.payload);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveCardStatus } from './ai-provider-settings';
|
||||
import {
|
||||
resolveCardStatus,
|
||||
isEndpointConfigured,
|
||||
resolveKeyField,
|
||||
} from './ai-provider-settings';
|
||||
|
||||
describe('resolveCardStatus', () => {
|
||||
it('returns "off" when not configured and not enabled', () => {
|
||||
@@ -18,3 +22,52 @@ describe('resolveCardStatus', () => {
|
||||
expect(resolveCardStatus(true, true)).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEndpointConfigured', () => {
|
||||
it('configured when model and the endpoint own base URL are set', () => {
|
||||
expect(isEndpointConfigured('m', 'https://own', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('configured by inheriting the chat base URL when own base is empty', () => {
|
||||
expect(isEndpointConfigured('m', '', 'https://chat')).toBe(true);
|
||||
});
|
||||
|
||||
it('not configured when model is set but both base URLs are empty', () => {
|
||||
expect(isEndpointConfigured('m', '', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('not configured when both base URLs are whitespace-only', () => {
|
||||
expect(isEndpointConfigured('m', ' ', '\t')).toBe(false);
|
||||
});
|
||||
|
||||
it('not configured when the model is whitespace-only', () => {
|
||||
expect(isEndpointConfigured(' ', 'https://own', 'https://chat')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveKeyField (write-only key payload)', () => {
|
||||
// The same logic backs all three keys (chat / embedding / stt) in buildPayload.
|
||||
it('typed a value -> set the new key', () => {
|
||||
expect(resolveKeyField('sk-new', false)).toEqual({
|
||||
set: true,
|
||||
value: 'sk-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('typed a value wins even if cleared was also flagged', () => {
|
||||
expect(resolveKeyField('sk-new', true)).toEqual({
|
||||
set: true,
|
||||
value: 'sk-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('cleared (empty buffer) -> set the key to empty string', () => {
|
||||
expect(resolveKeyField('', true)).toEqual({ set: true, value: '' });
|
||||
});
|
||||
|
||||
it('untouched (empty buffer, not cleared) -> omit the key', () => {
|
||||
expect(resolveKeyField('', false)).toEqual({ set: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,33 @@ export function resolveCardStatus(
|
||||
return enabled ? "warning" : "off";
|
||||
}
|
||||
|
||||
// Pure + unit-testable. A non-chat endpoint (embeddings / voice) is "configured"
|
||||
// when its model is set AND it has a usable base URL: either its own base URL is
|
||||
// non-empty, or the chat base URL is non-empty (inherited when own is empty).
|
||||
// All inputs are trimmed so whitespace-only values do not count as filled.
|
||||
export function isEndpointConfigured(
|
||||
model: string,
|
||||
ownBase: string,
|
||||
chatBase: string,
|
||||
): boolean {
|
||||
return (
|
||||
model.trim() !== "" && (ownBase.trim() !== "" || chatBase.trim() !== "")
|
||||
);
|
||||
}
|
||||
|
||||
// Pure + unit-testable. Write-only API-key payload semantics:
|
||||
// - typed a value (buffer non-empty) -> set it
|
||||
// - explicitly cleared -> send '' to clear the stored key
|
||||
// - untouched (empty buffer, not cleared) -> omit the key entirely
|
||||
export function resolveKeyField(
|
||||
buffer: string,
|
||||
cleared: boolean,
|
||||
): { set: true; value: string } | { set: false } {
|
||||
if (buffer.length > 0) return { set: true, value: buffer };
|
||||
if (cleared) return { set: true, value: "" };
|
||||
return { set: false };
|
||||
}
|
||||
|
||||
// Translate the dot's tooltip label. Kept in one place so all three endpoint
|
||||
// cards share identical wording.
|
||||
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
|
||||
@@ -263,29 +290,23 @@ export default function AiProviderSettings() {
|
||||
sttApiStyle: values.sttApiStyle,
|
||||
};
|
||||
|
||||
// Key semantics (never send the stored key back):
|
||||
// Key semantics (never send the stored key back) — see resolveKeyField:
|
||||
// - typed a value -> set it
|
||||
// - explicitly cleared -> send '' to clear
|
||||
// - untouched -> omit the key entirely (leave unchanged)
|
||||
if (values.apiKey.length > 0) {
|
||||
payload.apiKey = values.apiKey;
|
||||
} else if (keyCleared) {
|
||||
payload.apiKey = "";
|
||||
}
|
||||
const apiKeyField = resolveKeyField(values.apiKey, keyCleared);
|
||||
if (apiKeyField.set) payload.apiKey = apiKeyField.value;
|
||||
|
||||
// Same write-only semantics for the embedding-specific key.
|
||||
if (values.embeddingApiKey.length > 0) {
|
||||
payload.embeddingApiKey = values.embeddingApiKey;
|
||||
} else if (embeddingKeyCleared) {
|
||||
payload.embeddingApiKey = "";
|
||||
}
|
||||
const embeddingKeyField = resolveKeyField(
|
||||
values.embeddingApiKey,
|
||||
embeddingKeyCleared,
|
||||
);
|
||||
if (embeddingKeyField.set) payload.embeddingApiKey = embeddingKeyField.value;
|
||||
|
||||
// Same write-only semantics for the STT-specific key.
|
||||
if (values.sttApiKey.length > 0) {
|
||||
payload.sttApiKey = values.sttApiKey;
|
||||
} else if (sttKeyCleared) {
|
||||
payload.sttApiKey = "";
|
||||
}
|
||||
const sttKeyField = resolveKeyField(values.sttApiKey, sttKeyCleared);
|
||||
if (sttKeyField.set) payload.sttApiKey = sttKeyField.value;
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -460,12 +481,12 @@ export default function AiProviderSettings() {
|
||||
const v = form.values;
|
||||
const chatBase = v.baseUrl.trim();
|
||||
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
|
||||
const embedConfigured =
|
||||
v.embeddingModel.trim() !== "" &&
|
||||
(v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
|
||||
const sttConfigured =
|
||||
v.sttModel.trim() !== "" &&
|
||||
(v.sttBaseUrl.trim() !== "" || chatBase !== "");
|
||||
const embedConfigured = isEndpointConfigured(
|
||||
v.embeddingModel,
|
||||
v.embeddingBaseUrl,
|
||||
v.baseUrl,
|
||||
);
|
||||
const sttConfigured = isEndpointConfigured(v.sttModel, v.sttBaseUrl, v.baseUrl);
|
||||
|
||||
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
|
||||
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
@@ -162,10 +163,30 @@
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
"ts",
|
||||
"tsx"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"<rootDir>/core/auth/auth.controller.spec.ts",
|
||||
"<rootDir>/core/auth/services/auth.service.spec.ts",
|
||||
"<rootDir>/core/auth/services/token.service.spec.ts",
|
||||
"<rootDir>/core/comment/comment.service.spec.ts",
|
||||
"<rootDir>/core/group/group.controller.spec.ts",
|
||||
"<rootDir>/core/group/services/group.service.spec.ts",
|
||||
"<rootDir>/core/page/page.controller.spec.ts",
|
||||
"<rootDir>/core/page/services/page.service.spec.ts",
|
||||
"<rootDir>/core/search/search.controller.spec.ts",
|
||||
"<rootDir>/core/search/search.service.spec.ts",
|
||||
"<rootDir>/core/space/services/space.service.spec.ts",
|
||||
"<rootDir>/core/space/space.controller.spec.ts",
|
||||
"<rootDir>/core/user/user.controller.spec.ts",
|
||||
"<rootDir>/core/workspace/services/workspace.service.spec.ts",
|
||||
"<rootDir>/integrations/environment/environment.service.spec.ts",
|
||||
"<rootDir>/integrations/storage/storage.service.spec.ts"
|
||||
],
|
||||
"transform": {
|
||||
"happy-dom.+\\.js$": [
|
||||
"babel-jest",
|
||||
@@ -182,7 +203,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
|
||||
|
||||
@@ -45,6 +45,9 @@ import {
|
||||
htmlToMarkdown,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
PageEmbed,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
@@ -115,6 +118,9 @@ export const tiptapExtensions = [
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
PageEmbed,
|
||||
] as any;
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { htmlToJson, jsonToHtml } from './collaboration.util';
|
||||
|
||||
const findFirst = (json: any, type: string): any | undefined => {
|
||||
if (!json || typeof json !== 'object') return undefined;
|
||||
if (json.type === type) return json;
|
||||
if (Array.isArray(json.content)) {
|
||||
for (const child of json.content) {
|
||||
const found = findFirst(child, type);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guards the fragile parse-priority approach that lets a `footnoteReference`
|
||||
* NODE win over the `Superscript` MARK for `<sup>` elements. In the server
|
||||
* `tiptapExtensions` list, Superscript is registered BEFORE the footnote nodes,
|
||||
* so without the priority guard a `<sup data-footnote-ref>` would be parsed as
|
||||
* an (empty) superscript mark and the footnote reference would be lost.
|
||||
*/
|
||||
describe('footnote reference vs superscript mark (server schema round-trip)', () => {
|
||||
const HTML =
|
||||
'<p>Water' +
|
||||
'<sup data-footnote-ref data-id="fn1"></sup>' +
|
||||
' here.</p>' +
|
||||
'<section data-footnotes>' +
|
||||
'<div data-footnote-def data-id="fn1"><p>First note.</p></div>' +
|
||||
'</section>';
|
||||
|
||||
it('parses <sup data-footnote-ref> into a footnoteReference NODE (not a superscript mark)', () => {
|
||||
const json = htmlToJson(HTML);
|
||||
|
||||
const ref = findFirst(json, 'footnoteReference');
|
||||
expect(ref).toBeDefined();
|
||||
expect(ref.attrs.id).toBe('fn1');
|
||||
|
||||
// It must NOT have been swallowed as a superscript mark on text.
|
||||
const superscriptText = JSON.stringify(json).includes('"superscript"');
|
||||
expect(superscriptText).toBe(false);
|
||||
|
||||
// The matching definition survives too.
|
||||
const def = findFirst(json, 'footnoteDefinition');
|
||||
expect(def).toBeDefined();
|
||||
expect(def.attrs.id).toBe('fn1');
|
||||
});
|
||||
|
||||
it('round-trips an empty footnoteReference back to <sup data-footnote-ref>', () => {
|
||||
const json = htmlToJson(HTML);
|
||||
const html = jsonToHtml(json);
|
||||
|
||||
expect(html).toContain('data-footnote-ref');
|
||||
expect(html).toContain('data-id="fn1"');
|
||||
|
||||
// And a second parse still yields the node (stable round-trip).
|
||||
const json2 = htmlToJson(html);
|
||||
const ref2 = findFirst(json2, 'footnoteReference');
|
||||
expect(ref2).toBeDefined();
|
||||
expect(ref2.attrs.id).toBe('fn1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { markdownToHtml, encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
||||
import { htmlToJson } from '../../../collaboration/collaboration.util';
|
||||
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
||||
|
||||
/**
|
||||
* CONTRACT (security): an attacker who controls imported markdown/HTML could try
|
||||
* to smuggle an htmlEmbed in the *serialized* DOM form —
|
||||
* <div data-type="htmlEmbed" data-source="...">
|
||||
* — directly, bypassing the editor's `<!--html-embed:-->` comment marker.
|
||||
*
|
||||
* This exercises the REAL server import conversion path that ImportService uses
|
||||
* (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
|
||||
* link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
|
||||
* the ACTUAL behaviour so we know whether the strip gate can be bypassed.
|
||||
*
|
||||
* FINDING (documented): the raw embed div DOES round-trip through marked +
|
||||
* htmlToJson into a real `htmlEmbed` node, so `hasHtmlEmbedNode` returns true and
|
||||
* `stripHtmlEmbedNodes` removes it. The serialized-form bypass is therefore
|
||||
* detectable and STRIPPABLE — the write-path gate covers it.
|
||||
*/
|
||||
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
|
||||
it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
|
||||
const source = '<script>steal()</script>';
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
const md = [
|
||||
'Hello',
|
||||
'',
|
||||
`<div data-type="htmlEmbed" data-source="${encoded}"></div>`,
|
||||
'',
|
||||
'World',
|
||||
].join('\n');
|
||||
|
||||
const html = await markdownToHtml(md);
|
||||
// marked preserves the raw block-level div verbatim.
|
||||
expect(html).toContain('data-type="htmlEmbed"');
|
||||
|
||||
const json = htmlToJson(html);
|
||||
// The div parses into a real htmlEmbed node carrying the decoded source.
|
||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||
|
||||
// Because it is detected, the write-path gate can strip it for non-admins.
|
||||
const stripped = stripHtmlEmbedNodes(json);
|
||||
expect(hasHtmlEmbedNode(stripped)).toBe(false);
|
||||
// Surrounding non-embed content is retained.
|
||||
expect(JSON.stringify(stripped)).toContain('Hello');
|
||||
expect(JSON.stringify(stripped)).toContain('World');
|
||||
});
|
||||
|
||||
it('round-trips through direct HTML conversion (htmlToJson) and is DETECTED', () => {
|
||||
const source = '<script>steal()</script>';
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
const html = `<p>Hello</p><div data-type="htmlEmbed" data-source="${encoded}"></div><p>World</p>`;
|
||||
|
||||
const json = htmlToJson(html);
|
||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
||||
});
|
||||
|
||||
it('is still DETECTED even when the data-source is NOT valid base64', async () => {
|
||||
// A naive raw inline source (HTML-escaped, not base64) still parses as an
|
||||
// htmlEmbed NODE — the decoder just yields an empty source. Detection (and
|
||||
// therefore stripping) does not depend on the source being well-formed, so
|
||||
// the bypass cannot be hidden by sending a malformed data-source.
|
||||
const md = `<div data-type="htmlEmbed" data-source="<script>x</script>"></div>`;
|
||||
const html = await markdownToHtml(md);
|
||||
const json = htmlToJson(html);
|
||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,102 @@ describe('stripHtmlEmbedNodes', () => {
|
||||
const result = stripHtmlEmbedNodes(doc);
|
||||
expect(result).toEqual(doc);
|
||||
});
|
||||
|
||||
it('strips a deeply nested htmlEmbed (3+ levels: callout > column > paragraph-sibling)', () => {
|
||||
// htmlEmbed sits as a sibling of a paragraph, nested four containers deep.
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'callout',
|
||||
content: [
|
||||
{
|
||||
type: 'columns',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'deep keep' }],
|
||||
},
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>x</script>' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = stripHtmlEmbedNodes(doc);
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
const col = findFirstChild(result, 'column');
|
||||
// Sibling paragraph survives; only the embed is removed.
|
||||
expect(col.content).toHaveLength(1);
|
||||
expect(col.content[0].type).toBe('paragraph');
|
||||
expect(col.content[0].content[0].text).toBe('deep keep');
|
||||
});
|
||||
|
||||
it('returns non-object / null / array-without-content nodes unchanged', () => {
|
||||
// Non-object inputs are returned as-is (callers persist what they got).
|
||||
expect(stripHtmlEmbedNodes(null as any)).toBeNull();
|
||||
expect(stripHtmlEmbedNodes(undefined as any)).toBeUndefined();
|
||||
expect(stripHtmlEmbedNodes('not-a-node' as any)).toBe('not-a-node');
|
||||
expect(stripHtmlEmbedNodes(42 as any)).toBe(42);
|
||||
|
||||
// An object node with no `content` array is returned shallow-cloned, equal.
|
||||
const leaf = { type: 'paragraph', attrs: { id: 'x' } };
|
||||
const out = stripHtmlEmbedNodes(leaf);
|
||||
expect(out).toEqual(leaf);
|
||||
expect(out).not.toBe(leaf); // new object, input not mutated
|
||||
});
|
||||
|
||||
it('yields empty content (not null/undefined) for a doc whose only child is an htmlEmbed', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'htmlEmbed', attrs: { source: '<b>only</b>' } }],
|
||||
};
|
||||
const result = stripHtmlEmbedNodes(doc) as any;
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
expect(result.content).toHaveLength(0);
|
||||
expect(result.content).not.toBeNull();
|
||||
expect(result.content).not.toBeUndefined();
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHtmlEmbedNode (root/odd-shape detection)', () => {
|
||||
it('returns true when the ROOT node itself is an htmlEmbed (not only a child)', () => {
|
||||
const rootEmbed = { type: 'htmlEmbed', attrs: { source: '<script>r</script>' } };
|
||||
expect(hasHtmlEmbedNode(rootEmbed)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a doc with embed-like TEXT but no htmlEmbed node', () => {
|
||||
// The literal string "htmlEmbed" appears only as text content, not as a
|
||||
// node type, so it must NOT be detected.
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'type: htmlEmbed <div data-type="htmlEmbed">' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(hasHtmlEmbedNode(doc)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object / null / array inputs', () => {
|
||||
expect(hasHtmlEmbedNode(null)).toBe(false);
|
||||
expect(hasHtmlEmbedNode(undefined)).toBe(false);
|
||||
expect(hasHtmlEmbedNode('htmlEmbed')).toBe(false);
|
||||
// A bare array (no `content` wrapper) has no node `type`, so it's false.
|
||||
expect(hasHtmlEmbedNode([{ type: 'htmlEmbed' }] as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAuthorHtmlEmbed', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
serializeSteps,
|
||||
rowToUiMessage,
|
||||
prepareAgentStep,
|
||||
buildErrorAssistantRecord,
|
||||
MAX_AGENT_STEPS,
|
||||
FINAL_STEP_INSTRUCTION,
|
||||
} from './ai-chat.service';
|
||||
@@ -229,3 +230,32 @@ describe('prepareAgentStep', () => {
|
||||
expect(atBoundary?.toolChoice).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit test for buildErrorAssistantRecord: the pure helper that shapes the
|
||||
* assistant-message record persisted on a first-turn (or any) stream failure.
|
||||
* The streamText onError callback builds the formatted error text via
|
||||
* describeProviderError (tested separately) and hands it to this helper; pinning
|
||||
* the record shape here covers the persist-assistant-on-error logic without
|
||||
* having to seam streamText itself.
|
||||
*/
|
||||
describe('buildErrorAssistantRecord', () => {
|
||||
it('records an empty turn with the error text in metadata (finishReason=error)', () => {
|
||||
const rec = buildErrorAssistantRecord('401: Unauthorized');
|
||||
expect(rec).toEqual({
|
||||
text: '',
|
||||
toolCalls: null,
|
||||
metadata: { finishReason: 'error', parts: [], error: '401: Unauthorized' },
|
||||
});
|
||||
});
|
||||
|
||||
it('always produces empty text + empty parts so a failed turn is still recorded', () => {
|
||||
const rec = buildErrorAssistantRecord('boom');
|
||||
// No partial text and no UI parts: the turn exists in history but renders as
|
||||
// an error, with the cause preserved in metadata.error.
|
||||
expect(rec.text).toBe('');
|
||||
expect(rec.metadata.parts).toEqual([]);
|
||||
expect(rec.toolCalls).toBeNull();
|
||||
expect(rec.metadata.error).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,11 +384,7 @@ export class AiChatService {
|
||||
this.logger.error(`AI chat stream error: ${errorText}`, e?.stack);
|
||||
// Persist whatever text we have (likely empty) so the turn is recorded,
|
||||
// and record the error text in metadata so it is visible in history.
|
||||
await persistAssistant({
|
||||
text: '',
|
||||
toolCalls: null,
|
||||
metadata: { finishReason: 'error', parts: [], error: errorText },
|
||||
});
|
||||
await persistAssistant(buildErrorAssistantRecord(errorText));
|
||||
await closeExternalClients();
|
||||
},
|
||||
onAbort: async ({ steps }) => {
|
||||
@@ -710,6 +706,26 @@ export function rowToUiMessage(row: AiChatMessage): Omit<UIMessage, 'id'> & {
|
||||
return { id: row.id, role, parts: parts as UIMessage['parts'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the assistant-message record persisted when a turn fails before any text
|
||||
* is produced (the streamText onError path). Pure: it takes the formatted error
|
||||
* text and returns the exact `{ text, toolCalls, metadata }` payload handed to
|
||||
* persistAssistant, so the first-turn-failure recording shape is unit-testable
|
||||
* without seaming streamText. The empty text + empty parts mean the failed turn
|
||||
* is still recorded in history, with the provider cause visible in metadata.
|
||||
*/
|
||||
export function buildErrorAssistantRecord(errorText: string): {
|
||||
text: string;
|
||||
toolCalls: null;
|
||||
metadata: { finishReason: 'error'; parts: []; error: string };
|
||||
} {
|
||||
return {
|
||||
text: '',
|
||||
toolCalls: null,
|
||||
metadata: { finishReason: 'error', parts: [], error: errorText },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce SDK step objects to a compact, JSON-serializable trace for the
|
||||
* `tool_calls` column. Stores only what the UI action-log and history need —
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import {
|
||||
resolveShareAssistantRequest,
|
||||
uiMessageTextLength,
|
||||
type ShareAssistantDeps,
|
||||
} from './public-share-chat.controller';
|
||||
import { AiNotConfiguredException } from '../../integrations/ai/ai-not-configured.exception';
|
||||
import {
|
||||
MAX_SHARE_MESSAGES,
|
||||
MAX_SHARE_MESSAGE_CHARS,
|
||||
} from './public-share-chat.service';
|
||||
import type { UIMessage } from 'ai';
|
||||
|
||||
/**
|
||||
* Unit tests for the extracted pre-hijack funnel (resolveShareAssistantRequest)
|
||||
* and the exported size helper (uiMessageTextLength). The funnel order is
|
||||
* security-relevant: the first failing gate must win, every failure must throw
|
||||
* BEFORE any stream/hijack, and the access-shaped failures must all 404 (no
|
||||
* existence leak). These exercise each branch with hand-rolled mocks — no Nest
|
||||
* module graph, no DB.
|
||||
*/
|
||||
describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
|
||||
/** A fully-passing dep set; individual tests override single collaborators. */
|
||||
function makeDeps(over: {
|
||||
assistantEnabled?: boolean;
|
||||
getShareForPage?: jest.Mock;
|
||||
isSharingAllowed?: jest.Mock;
|
||||
findById?: jest.Mock;
|
||||
hasRestrictedAncestor?: jest.Mock;
|
||||
resolveShareRole?: jest.Mock;
|
||||
getShareChatModel?: jest.Mock;
|
||||
tryConsumeWorkspaceQuota?: jest.Mock;
|
||||
} = {}) {
|
||||
const aiSettings = {
|
||||
isPublicShareAssistantEnabled: jest
|
||||
.fn()
|
||||
.mockResolvedValue(over.assistantEnabled ?? true),
|
||||
};
|
||||
const shareService = {
|
||||
getShareForPage:
|
||||
over.getShareForPage ??
|
||||
jest.fn().mockResolvedValue({
|
||||
id: 'SHARE-A',
|
||||
pageId: 'root-page',
|
||||
spaceId: 'space-1',
|
||||
sharedPage: { id: 'root-page', title: 'Root' },
|
||||
}),
|
||||
isSharingAllowed:
|
||||
over.isSharingAllowed ?? jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const pageRepo = {
|
||||
findById:
|
||||
over.findById ?? jest.fn().mockResolvedValue({ id: 'opened-uuid' }),
|
||||
};
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor:
|
||||
over.hasRestrictedAncestor ?? jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const publicShareChat = {
|
||||
resolveShareRole:
|
||||
over.resolveShareRole ?? jest.fn().mockResolvedValue(null),
|
||||
getShareChatModel:
|
||||
over.getShareChatModel ?? jest.fn().mockResolvedValue('MODEL'),
|
||||
tryConsumeWorkspaceQuota:
|
||||
over.tryConsumeWorkspaceQuota ?? jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const deps: ShareAssistantDeps = {
|
||||
aiSettings: aiSettings as never,
|
||||
shareService: shareService as never,
|
||||
pageRepo: pageRepo as never,
|
||||
pagePermissionRepo: pagePermissionRepo as never,
|
||||
publicShareChat: publicShareChat as never,
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
aiSettings,
|
||||
shareService,
|
||||
pageRepo,
|
||||
pagePermissionRepo,
|
||||
publicShareChat,
|
||||
};
|
||||
}
|
||||
|
||||
const body = (over: Record<string, unknown> = {}) => ({
|
||||
shareId: 'SHARE-A',
|
||||
pageId: 'opened-page',
|
||||
messages: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Run the funnel and capture the thrown HttpException status (or null). */
|
||||
async function statusOf(
|
||||
deps: ShareAssistantDeps,
|
||||
b: Record<string, unknown>,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
await resolveShareAssistantRequest(deps, {
|
||||
workspaceId: 'ws-1',
|
||||
body: b as never,
|
||||
});
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) return err.getStatus();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
it('happy path: returns the resolved, non-null request', async () => {
|
||||
const { deps } = makeDeps();
|
||||
const out = await resolveShareAssistantRequest(deps, {
|
||||
workspaceId: 'ws-1',
|
||||
body: body() as never,
|
||||
});
|
||||
expect(out.shareId).toBe('SHARE-A');
|
||||
expect(out.share.id).toBe('SHARE-A');
|
||||
expect(out.model).toBe('MODEL');
|
||||
expect(out.role).toBeNull();
|
||||
expect(out.openedPage).toEqual({ id: 'opened-page', title: 'Root' });
|
||||
});
|
||||
|
||||
it('assistant disabled => 404 and NO share/page/model lookups', async () => {
|
||||
const { deps, shareService, pageRepo, publicShareChat } = makeDeps({
|
||||
assistantEnabled: false,
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(404);
|
||||
expect(shareService.getShareForPage).not.toHaveBeenCalled();
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
expect(publicShareChat.getShareChatModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('share.id !== body.shareId => 404 (cross-share id swap rejected)', async () => {
|
||||
const { deps, publicShareChat } = makeDeps({
|
||||
getShareForPage: jest.fn().mockResolvedValue({
|
||||
id: 'OTHER-SHARE',
|
||||
pageId: 'root',
|
||||
spaceId: 'space-1',
|
||||
sharedPage: null,
|
||||
}),
|
||||
});
|
||||
expect(await statusOf(deps, body({ shareId: 'SHARE-A' }))).toBe(404);
|
||||
// Never reached the model resolution for an unusable share.
|
||||
expect(publicShareChat.getShareChatModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opened page unresolvable (pageRepo.findById -> null) => fail-closed 404', async () => {
|
||||
const { deps } = makeDeps({
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(404);
|
||||
});
|
||||
|
||||
it('restricted descendant => 404 (same as out-of-tree, no existence leak)', async () => {
|
||||
const { deps, pagePermissionRepo } = makeDeps({
|
||||
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(404);
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getShareChatModel throws AiNotConfiguredException => 503', async () => {
|
||||
const { deps } = makeDeps({
|
||||
getShareChatModel: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new AiNotConfiguredException()),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(503);
|
||||
});
|
||||
|
||||
it('getShareChatModel throws a non-AiNotConfigured error => re-thrown (not a 503/404)', async () => {
|
||||
const boom = new Error('boom');
|
||||
const { deps } = makeDeps({
|
||||
getShareChatModel: jest.fn().mockRejectedValue(boom),
|
||||
});
|
||||
await expect(
|
||||
resolveShareAssistantRequest(deps, {
|
||||
workspaceId: 'ws-1',
|
||||
body: body() as never,
|
||||
}),
|
||||
).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
it('tryConsumeWorkspaceQuota false => 429 thrown BEFORE any stream', async () => {
|
||||
const { deps, publicShareChat } = makeDeps({
|
||||
tryConsumeWorkspaceQuota: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(429);
|
||||
// The quota gate ran AFTER the model resolved (provider configured) but the
|
||||
// function returns/throws before producing a streamable request.
|
||||
expect(publicShareChat.tryConsumeWorkspaceQuota).toHaveBeenCalledWith('ws-1');
|
||||
});
|
||||
|
||||
it('messages over MAX_SHARE_MESSAGES => 413', async () => {
|
||||
const { deps } = makeDeps();
|
||||
const tooMany = Array.from({ length: MAX_SHARE_MESSAGES + 1 }, () => ({
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hi' }],
|
||||
}));
|
||||
expect(await statusOf(deps, body({ messages: tooMany }))).toBe(413);
|
||||
});
|
||||
|
||||
it('a single message over MAX_SHARE_MESSAGE_CHARS => 413 (uiMessageTextLength)', async () => {
|
||||
const { deps } = makeDeps();
|
||||
const huge = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
|
||||
};
|
||||
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(413);
|
||||
});
|
||||
|
||||
it('the quota gate is checked BEFORE the payload caps (429 wins over 413)', async () => {
|
||||
// Over-cap workspace AND an over-long message: the 429 must surface first, so
|
||||
// an over-cap caller is rejected without even paying the payload-cap scan.
|
||||
const { deps } = makeDeps({
|
||||
tryConsumeWorkspaceQuota: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const huge = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
|
||||
};
|
||||
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uiMessageTextLength', () => {
|
||||
it('returns 0 for an undefined / parts-less / non-array message', () => {
|
||||
expect(uiMessageTextLength(undefined)).toBe(0);
|
||||
expect(uiMessageTextLength({} as UIMessage)).toBe(0);
|
||||
expect(uiMessageTextLength({ parts: 'nope' } as never)).toBe(0);
|
||||
});
|
||||
|
||||
it('sums the lengths of ONLY the text parts', () => {
|
||||
const msg = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ type: 'text', text: 'hello' }, // 5
|
||||
{ type: 'tool-call', text: 'IGNORED' }, // non-text: ignored
|
||||
{ type: 'text', text: 'world!' }, // 6
|
||||
{ type: 'text' }, // no text field: ignored
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
expect(uiMessageTextLength(msg)).toBe(11);
|
||||
});
|
||||
|
||||
it('matches the 413 boundary used by the funnel', () => {
|
||||
const atCap = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS) }],
|
||||
} as unknown as UIMessage;
|
||||
const overCap = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
|
||||
} as unknown as UIMessage;
|
||||
expect(uiMessageTextLength(atCap)).toBe(MAX_SHARE_MESSAGE_CHARS);
|
||||
expect(uiMessageTextLength(overCap)).toBeGreaterThan(MAX_SHARE_MESSAGE_CHARS);
|
||||
});
|
||||
});
|
||||
@@ -77,142 +77,25 @@ export class PublicShareChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<void> {
|
||||
const body = (req.body ?? {}) as PublicShareChatStreamBody;
|
||||
const shareId = typeof body.shareId === 'string' ? body.shareId.trim() : '';
|
||||
const pageId = typeof body.pageId === 'string' ? body.pageId.trim() : '';
|
||||
|
||||
// ---- Guardrail funnel (order matters; each failure exits before stream) ----
|
||||
|
||||
// 1. Workspace master toggle. 404 (do not reveal the feature exists).
|
||||
const assistantEnabled = await this.aiSettings.isPublicShareAssistantEnabled(
|
||||
workspace.id,
|
||||
// The whole pre-hijack fact-resolution + cap-ordering block is a pure-ish
|
||||
// helper (collaborators passed in) so every funnel branch — 404 disabled /
|
||||
// share-mismatch / page-unresolvable / restricted, 503 unconfigured, 429
|
||||
// over-cap, 413 too many/too long — is unit-testable against the red-team
|
||||
// boundaries without the full Nest/DB graph. It throws the SAME HttpException
|
||||
// the controller would, and never starts streaming.
|
||||
const resolved = await resolveShareAssistantRequest(
|
||||
{
|
||||
aiSettings: this.aiSettings,
|
||||
shareService: this.shareService,
|
||||
pageRepo: this.pageRepo,
|
||||
pagePermissionRepo: this.pagePermissionRepo,
|
||||
publicShareChat: this.publicShareChat,
|
||||
},
|
||||
{ workspaceId: workspace.id, body },
|
||||
);
|
||||
|
||||
// 2. Share usable? Resolved via the page's share membership, since the page
|
||||
// resolution (getShareForPage) ALSO yields the share + workspace. We
|
||||
// still need basic input to attempt it.
|
||||
// 3. Page in share? The same getShareForPage lookup confirms the opened page
|
||||
// resolves to THIS share tree, PLUS an explicit restricted-ancestor gate
|
||||
// (getShareForPage itself does NOT exclude restricted descendants) so a
|
||||
// restricted page hidden from the public view is graded not-in-share.
|
||||
// (shareUsable + pageInShare are set together below; the funnel grades
|
||||
// them as distinct ordered steps.)
|
||||
let share: Awaited<ReturnType<ShareService['getShareForPage']>> | undefined;
|
||||
let shareUsable = false;
|
||||
let pageInShare = false;
|
||||
if (assistantEnabled && shareId && pageId) {
|
||||
// getShareForPage walks up the tree to the nearest ancestor share,
|
||||
// enforces share.workspaceId === workspaceId and includeSubPages, and
|
||||
// returns undefined when the page is not publicly reachable. NOTE: it
|
||||
// joins only the `shares` table — it does NOT exclude restricted
|
||||
// descendants — so a restricted page inside an includeSubPages share
|
||||
// still resolves here. We add an explicit restricted-ancestor gate below
|
||||
// (same as the public view) so the opened page's title never leaks into
|
||||
// the system prompt for a page the public view 404s.
|
||||
share = await this.shareService.getShareForPage(pageId, workspace.id);
|
||||
if (share && share.id === shareId) {
|
||||
// Confirm sharing is still allowed for the share's space (and not
|
||||
// disabled at workspace/space level) — same gate the public views use.
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspace.id,
|
||||
share.spaceId,
|
||||
);
|
||||
// A restricted descendant is hidden from the public share view; treat
|
||||
// the opened page as not-in-share so the funnel returns the SAME 404 it
|
||||
// returns for an out-of-tree page (uniform, no existence leak).
|
||||
// hasRestrictedAncestor matches on the page UUID only, while the
|
||||
// opened pageId may be a slugId, so resolve to the UUID first (cheap
|
||||
// base-fields lookup, mirroring how getSharedPage resolves the page
|
||||
// before its restricted check).
|
||||
const openedPageRow = await this.pageRepo.findById(pageId);
|
||||
const restricted = openedPageRow
|
||||
? await this.pagePermissionRepo.hasRestrictedAncestor(
|
||||
openedPageRow.id,
|
||||
)
|
||||
: true; // unresolvable opened page => fail closed (treat as not-in-share)
|
||||
// The security-relevant combination (server-resolved share id ===
|
||||
// requested shareId, + sharingAllowed, + the restricted gate) is a pure,
|
||||
// unit-tested helper so the access join point can be exercised against
|
||||
// the red-team boundaries without the full Nest/DB graph.
|
||||
({ shareUsable, pageInShare } = deriveShareAccess({
|
||||
resolvedShareId: share.id,
|
||||
requestedShareId: shareId,
|
||||
sharingAllowed,
|
||||
restricted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Provider configured? Resolve the model now so an unconfigured provider
|
||||
// yields a clean 503 (AiNotConfiguredException) BEFORE hijack. Only
|
||||
// attempt this once the earlier gates passed, to avoid leaking timing.
|
||||
let model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>> | undefined;
|
||||
// Admin-selected identity (agent role) for the anonymous assistant, resolved
|
||||
// server-authoritatively. null = built-in locked persona.
|
||||
let role: AiAgentRole | null = null;
|
||||
let providerConfigured = false;
|
||||
if (assistantEnabled && shareUsable && pageInShare) {
|
||||
try {
|
||||
role = await this.publicShareChat.resolveShareRole(workspace.id);
|
||||
model = await this.publicShareChat.getShareChatModel(workspace.id, role);
|
||||
providerConfigured = true;
|
||||
} catch (err) {
|
||||
if (err instanceof AiNotConfiguredException) {
|
||||
providerConfigured = false;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outcome = evaluateShareAssistantFunnel({
|
||||
assistantEnabled,
|
||||
shareUsable,
|
||||
pageInShare,
|
||||
providerConfigured,
|
||||
});
|
||||
if (outcome.ok === false) {
|
||||
// 404 for everything access-shaped (feature/share/page); 503 for config.
|
||||
if (outcome.status === 503) {
|
||||
throw new ServiceUnavailableException('AI is not configured');
|
||||
}
|
||||
throw new NotFoundException('Not found');
|
||||
}
|
||||
|
||||
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). The
|
||||
// per-IP @Throttle above can be evaded by an attacker rotating
|
||||
// `X-Forwarded-For` (the app runs with trustProxy), and each evaded call
|
||||
// spends REAL tokens on the workspace owner's paid AI provider. This cap
|
||||
// is keyed by the server-resolved workspace id (never attacker-
|
||||
// controllable), so it bounds the owner's bill even when the per-IP limit
|
||||
// is fully defeated via XFF spoofing. Checked here, BEFORE res.hijack(),
|
||||
// so an over-cap workspace gets a clean 429 and spends nothing. NOTE:
|
||||
// production should ALSO front this endpoint with a trusted proxy that
|
||||
// REWRITES (not appends) XFF so the per-IP throttle stays meaningful.
|
||||
if (!(await this.publicShareChat.tryConsumeWorkspaceQuota(workspace.id))) {
|
||||
throw new HttpException(
|
||||
'This documentation assistant is temporarily busy. Please try again later.',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Validate / bound the payload (cheap caps; ephemeral, never stored) ----
|
||||
const messages = Array.isArray(body.messages)
|
||||
? (body.messages as UIMessage[])
|
||||
: [];
|
||||
if (messages.length > MAX_SHARE_MESSAGES) {
|
||||
throw new HttpException('Too many messages', 413);
|
||||
}
|
||||
for (const m of messages) {
|
||||
const text = uiMessageTextLength(m);
|
||||
if (text > MAX_SHARE_MESSAGE_CHARS) {
|
||||
throw new HttpException('Message too long', 413);
|
||||
}
|
||||
}
|
||||
|
||||
const openedPage = {
|
||||
id: pageId,
|
||||
title: share?.sharedPage?.title ?? undefined,
|
||||
};
|
||||
const { shareId, share, model, role, messages, openedPage } = resolved;
|
||||
|
||||
// Abort the agent loop when the client disconnects (mirrors ai-chat).
|
||||
const controller = new AbortController();
|
||||
@@ -230,15 +113,15 @@ export class PublicShareChatController {
|
||||
workspaceId: workspace.id,
|
||||
shareId,
|
||||
share: {
|
||||
id: share!.id,
|
||||
pageId: share!.pageId,
|
||||
sharedPage: share!.sharedPage,
|
||||
id: share.id,
|
||||
pageId: share.pageId,
|
||||
sharedPage: share.sharedPage,
|
||||
},
|
||||
openedPage,
|
||||
messages,
|
||||
res,
|
||||
signal: controller.signal,
|
||||
model: model!,
|
||||
model,
|
||||
role,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -255,8 +138,174 @@ export class PublicShareChatController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum of the text-part lengths of a UIMessage (cheap, for the size cap). */
|
||||
function uiMessageTextLength(message: UIMessage | undefined): number {
|
||||
/**
|
||||
* The collaborators the pre-hijack funnel needs. Declared as the minimal slice
|
||||
* of each injected service it actually calls, so the resolver can be unit-tested
|
||||
* with hand-rolled mocks (no Nest module graph, no DB).
|
||||
*/
|
||||
export interface ShareAssistantDeps {
|
||||
aiSettings: Pick<AiSettingsService, 'isPublicShareAssistantEnabled'>;
|
||||
shareService: Pick<
|
||||
ShareService,
|
||||
'getShareForPage' | 'isSharingAllowed'
|
||||
>;
|
||||
pageRepo: Pick<PageRepo, 'findById'>;
|
||||
pagePermissionRepo: Pick<PagePermissionRepo, 'hasRestrictedAncestor'>;
|
||||
publicShareChat: Pick<
|
||||
PublicShareChatService,
|
||||
| 'resolveShareRole'
|
||||
| 'getShareChatModel'
|
||||
| 'tryConsumeWorkspaceQuota'
|
||||
>;
|
||||
}
|
||||
|
||||
/** The resolved, validated request ready to stream (everything is non-null). */
|
||||
export interface ResolvedShareAssistantRequest {
|
||||
shareId: string;
|
||||
share: NonNullable<Awaited<ReturnType<ShareService['getShareForPage']>>>;
|
||||
model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>;
|
||||
role: AiAgentRole | null;
|
||||
messages: UIMessage[];
|
||||
openedPage: { id: string; title?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-hijack fact-resolution + cap-ordering for the anonymous public-share
|
||||
* assistant, extracted from the controller so every funnel branch is unit-
|
||||
* testable without the Nest/DB graph. Order is security-relevant and each
|
||||
* failure exits BEFORE any stream/hijack:
|
||||
* 1. assistant toggle off => 404 (no share/page/model lookups);
|
||||
* 2. share/page access (deriveShareAccess + evaluateShareAssistantFunnel) =>
|
||||
* 404 (uniform; restricted descendant and out-of-tree look identical);
|
||||
* 3. provider unconfigured => 503 (AiNotConfiguredException), other errors
|
||||
* re-thrown;
|
||||
* 4. per-workspace quota exhausted => 429 (BEFORE any stream/hijack);
|
||||
* 5. payload caps => 413 (too many messages / a single message too long).
|
||||
* Throws the SAME HttpException the controller would; returns the resolved,
|
||||
* non-null request otherwise.
|
||||
*/
|
||||
export async function resolveShareAssistantRequest(
|
||||
deps: ShareAssistantDeps,
|
||||
input: { workspaceId: string; body: PublicShareChatStreamBody },
|
||||
): Promise<ResolvedShareAssistantRequest> {
|
||||
const { workspaceId, body } = input;
|
||||
const shareId = typeof body.shareId === 'string' ? body.shareId.trim() : '';
|
||||
const pageId = typeof body.pageId === 'string' ? body.pageId.trim() : '';
|
||||
|
||||
// 1. Workspace master toggle. 404 (do not reveal the feature exists).
|
||||
const assistantEnabled =
|
||||
await deps.aiSettings.isPublicShareAssistantEnabled(workspaceId);
|
||||
|
||||
// 2/3. Share usable? Page in share? Resolved via the page's share membership,
|
||||
// since getShareForPage ALSO yields the share + workspace. The opened
|
||||
// page is then gated by an explicit restricted-ancestor check (which
|
||||
// getShareForPage does NOT do) so a restricted page hidden from the
|
||||
// public view is graded not-in-share.
|
||||
let share: Awaited<ReturnType<ShareService['getShareForPage']>> | undefined;
|
||||
let shareUsable = false;
|
||||
let pageInShare = false;
|
||||
if (assistantEnabled && shareId && pageId) {
|
||||
share = await deps.shareService.getShareForPage(pageId, workspaceId);
|
||||
if (share && share.id === shareId) {
|
||||
const sharingAllowed = await deps.shareService.isSharingAllowed(
|
||||
workspaceId,
|
||||
share.spaceId,
|
||||
);
|
||||
// hasRestrictedAncestor matches on the page UUID only, while the opened
|
||||
// pageId may be a slugId, so resolve to the UUID first (cheap base-fields
|
||||
// lookup). An unresolvable opened page fails closed (not-in-share).
|
||||
const openedPageRow = await deps.pageRepo.findById(pageId);
|
||||
const restricted = openedPageRow
|
||||
? await deps.pagePermissionRepo.hasRestrictedAncestor(openedPageRow.id)
|
||||
: true;
|
||||
({ shareUsable, pageInShare } = deriveShareAccess({
|
||||
resolvedShareId: share.id,
|
||||
requestedShareId: shareId,
|
||||
sharingAllowed,
|
||||
restricted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Provider configured? Resolve the model now so an unconfigured provider
|
||||
// yields a clean 503 BEFORE hijack. Only after the access gates pass, to
|
||||
// avoid leaking timing.
|
||||
let model:
|
||||
| Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>
|
||||
| undefined;
|
||||
let role: AiAgentRole | null = null;
|
||||
let providerConfigured = false;
|
||||
if (assistantEnabled && shareUsable && pageInShare) {
|
||||
try {
|
||||
role = await deps.publicShareChat.resolveShareRole(workspaceId);
|
||||
model = await deps.publicShareChat.getShareChatModel(workspaceId, role);
|
||||
providerConfigured = true;
|
||||
} catch (err) {
|
||||
if (err instanceof AiNotConfiguredException) {
|
||||
providerConfigured = false;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outcome = evaluateShareAssistantFunnel({
|
||||
assistantEnabled,
|
||||
shareUsable,
|
||||
pageInShare,
|
||||
providerConfigured,
|
||||
});
|
||||
if (outcome.ok === false) {
|
||||
// 404 for everything access-shaped (feature/share/page); 503 for config.
|
||||
if (outcome.status === 503) {
|
||||
throw new ServiceUnavailableException('AI is not configured');
|
||||
}
|
||||
throw new NotFoundException('Not found');
|
||||
}
|
||||
|
||||
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). Checked
|
||||
// BEFORE res.hijack(), so an over-cap workspace gets a clean 429 and spends
|
||||
// nothing.
|
||||
if (!(await deps.publicShareChat.tryConsumeWorkspaceQuota(workspaceId))) {
|
||||
throw new HttpException(
|
||||
'This documentation assistant is temporarily busy. Please try again later.',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Validate / bound the payload (cheap caps; ephemeral, never stored) ----
|
||||
const messages = Array.isArray(body.messages)
|
||||
? (body.messages as UIMessage[])
|
||||
: [];
|
||||
if (messages.length > MAX_SHARE_MESSAGES) {
|
||||
throw new HttpException('Too many messages', 413);
|
||||
}
|
||||
for (const m of messages) {
|
||||
if (uiMessageTextLength(m) > MAX_SHARE_MESSAGE_CHARS) {
|
||||
throw new HttpException('Message too long', 413);
|
||||
}
|
||||
}
|
||||
|
||||
const openedPage = {
|
||||
id: pageId,
|
||||
title: share?.sharedPage?.title ?? undefined,
|
||||
};
|
||||
|
||||
// The funnel passed, so share/model are guaranteed present.
|
||||
return {
|
||||
shareId,
|
||||
share: share!,
|
||||
model: model!,
|
||||
role,
|
||||
messages,
|
||||
openedPage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum of the text-part lengths of a UIMessage (cheap, for the size cap).
|
||||
* Exported so the 413 size-cap logic is unit-testable without the Nest/DB graph.
|
||||
*/
|
||||
export function uiMessageTextLength(message: UIMessage | undefined): number {
|
||||
if (!message?.parts || !Array.isArray(message.parts)) return 0;
|
||||
let total = 0;
|
||||
for (const p of message.parts) {
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
filterShareTranscript,
|
||||
} from './public-share-chat.service';
|
||||
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
|
||||
import { PublicShareWorkspaceLimiter } from './public-share-workspace-limiter';
|
||||
import {
|
||||
PublicShareWorkspaceLimiter,
|
||||
resolveShareAiWorkspaceMax,
|
||||
SHARE_AI_WORKSPACE_MAX_PER_WINDOW,
|
||||
} from './public-share-workspace-limiter';
|
||||
|
||||
/**
|
||||
* Minimal in-memory fake of the slice of ioredis the sliding-window limiter
|
||||
@@ -195,6 +199,54 @@ describe('buildShareSystemPrompt locking', () => {
|
||||
expect(prompt).toContain('read-only assistant');
|
||||
expect(prompt).toContain('anti prompt-injection');
|
||||
});
|
||||
|
||||
it('an opened page with a title injects both the pageId and the title', () => {
|
||||
const prompt = buildShareSystemPrompt({
|
||||
share: null,
|
||||
openedPage: { id: 'page-123', title: 'Getting Started' },
|
||||
});
|
||||
expect(prompt).toContain('(pageId: page-123)');
|
||||
expect(prompt).toContain('"Getting Started"');
|
||||
expect(prompt).toContain('the current page');
|
||||
});
|
||||
|
||||
it('an opened page with a blank/whitespace title falls back to "Untitled"', () => {
|
||||
const prompt = buildShareSystemPrompt({
|
||||
share: null,
|
||||
openedPage: { id: 'page-123', title: ' ' },
|
||||
});
|
||||
expect(prompt).toContain('(pageId: page-123)');
|
||||
expect(prompt).toContain('"Untitled"');
|
||||
});
|
||||
|
||||
it('an empty / blank pageId omits the opened-page context line entirely', () => {
|
||||
const emptyId = buildShareSystemPrompt({
|
||||
share: null,
|
||||
openedPage: { id: '', title: 'Ignored' },
|
||||
});
|
||||
expect(emptyId).not.toContain('pageId:');
|
||||
expect(emptyId).not.toContain('the current page');
|
||||
|
||||
const blankId = buildShareSystemPrompt({
|
||||
share: null,
|
||||
openedPage: { id: ' ', title: 'Ignored' },
|
||||
});
|
||||
expect(blankId).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('a present share title is injected; a blank share title is omitted', () => {
|
||||
const withTitle = buildShareSystemPrompt({
|
||||
share: { sharedPageTitle: 'Product Docs' },
|
||||
openedPage: null,
|
||||
});
|
||||
expect(withTitle).toContain('titled "Product Docs"');
|
||||
|
||||
const blankTitle = buildShareSystemPrompt({
|
||||
share: { sharedPageTitle: ' ' },
|
||||
openedPage: null,
|
||||
});
|
||||
expect(blankTitle).not.toContain('This published documentation is titled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareChatService model fallback', () => {
|
||||
@@ -306,6 +358,44 @@ describe('PublicShareChatService model fallback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () => {
|
||||
const ENV = 'SHARE_AI_WORKSPACE_MAX_PER_HOUR';
|
||||
const original = process.env[ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = original;
|
||||
});
|
||||
|
||||
it('uses a valid positive integer from the env', () => {
|
||||
process.env[ENV] = '42';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(42);
|
||||
});
|
||||
|
||||
it('floors a float value', () => {
|
||||
process.env[ENV] = '99.9';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(99);
|
||||
});
|
||||
|
||||
it('falls back to the default for an unparseable / NaN value', () => {
|
||||
process.env[ENV] = 'not-a-number';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(300);
|
||||
});
|
||||
|
||||
it('falls back to the default when unset', () => {
|
||||
delete process.env[ENV];
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
});
|
||||
|
||||
it('falls back to the default for zero or a negative value (no unlimited / negative cap)', () => {
|
||||
process.env[ENV] = '0';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
process.env[ENV] = '-5';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace cap)', () => {
|
||||
it('allows up to the cap within a window, then 429s (returns false)', async () => {
|
||||
const limiter = makeLimiter(3, 60_000, () => 1_000);
|
||||
@@ -353,6 +443,23 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace
|
||||
expect(await limiter.tryConsume('ws-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('consumes a distinct member slot per call at one FIXED clock value (no same-ms score-collision under-count)', async () => {
|
||||
// All calls happen at the SAME millisecond. The limiter mints a unique member
|
||||
// id per attempt, so distinct calls in the same ms must NOT collide on the
|
||||
// sorted-set score and under-count: exactly `cap` calls are admitted, the
|
||||
// rest rejected — even though every score is identical.
|
||||
const cap = 5;
|
||||
const limiter = makeLimiter(cap, 60_000, () => 7_000); // clock never advances
|
||||
const results: boolean[] = [];
|
||||
for (let i = 0; i < cap + 3; i++) {
|
||||
results.push(await limiter.tryConsume('ws-1'));
|
||||
}
|
||||
// First `cap` admitted, the remaining 3 rejected.
|
||||
expect(results.slice(0, cap)).toEqual(Array(cap).fill(true));
|
||||
expect(results.slice(cap)).toEqual([false, false, false]);
|
||||
expect(results.filter(Boolean)).toHaveLength(cap);
|
||||
});
|
||||
|
||||
it('keeps separate budgets per workspace (one over-cap ws cannot starve another)', async () => {
|
||||
const limiter = makeLimiter(1, 60_000, () => 1_000);
|
||||
expect(await limiter.tryConsume('ws-a')).toBe(true);
|
||||
|
||||
@@ -93,6 +93,56 @@ describe('AiAgentRolesService guards', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('instructions cleared to whitespace => BadRequest, repo.update NOT called', async () => {
|
||||
const { service, repo } = makeService({ existing: makeRow() });
|
||||
await expect(
|
||||
service.update('ws-1', 'r1', {
|
||||
instructions: ' ',
|
||||
} as UpdateAgentRoleDto),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('concurrent soft-delete: row exists on the pre-update lookup but the re-fetch is undefined => BadRequest (not a TypeError)', async () => {
|
||||
// findById returns the live row FIRST (pre-update guard passes), then the
|
||||
// role is soft-deleted concurrently, so the POST-update re-fetch returns
|
||||
// undefined. The service must surface a clean 400, never dereference
|
||||
// undefined (which would throw a TypeError in toView).
|
||||
const { service, repo } = makeService();
|
||||
repo.findById
|
||||
.mockResolvedValueOnce(makeRow())
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await expect(
|
||||
service.update('ws-1', 'r1', { name: 'X' } as UpdateAgentRoleDto),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// The UPDATE ran (the row existed pre-update), but the re-fetch failed.
|
||||
expect(repo.update).toHaveBeenCalled();
|
||||
expect(repo.findById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('emoji/description tri-state: emoji:"" => null (clear), emoji omitted => undefined (unchanged), description:" " => null', async () => {
|
||||
const { service, repo } = makeService({ existing: makeRow() });
|
||||
|
||||
// emoji explicitly emptied => clear to null; description whitespace => null.
|
||||
await service.update('ws-1', 'r1', {
|
||||
emoji: '',
|
||||
description: ' ',
|
||||
} as UpdateAgentRoleDto);
|
||||
const patch1 = repo.update.mock.calls[0][2];
|
||||
expect(patch1.emoji).toBeNull();
|
||||
expect(patch1.description).toBeNull();
|
||||
|
||||
repo.update.mockClear();
|
||||
|
||||
// emoji omitted => unchanged (undefined passed through to the repo patch).
|
||||
await service.update('ws-1', 'r1', {
|
||||
name: 'Renamed',
|
||||
} as UpdateAgentRoleDto);
|
||||
const patch2 = repo.update.mock.calls[0][2];
|
||||
expect(patch2.emoji).toBeUndefined();
|
||||
expect(patch2.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
@@ -136,6 +186,51 @@ describe('AiAgentRolesService guards', () => {
|
||||
expect(repo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('modelConfig:{chatModel} only persists {chatModel} (no driver key)', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
modelConfig: { chatModel: 'gpt-4o' },
|
||||
} as CreateAgentRoleDto);
|
||||
const values = repo.insert.mock.calls[0][0];
|
||||
expect(values.modelConfig).toEqual({ chatModel: 'gpt-4o' });
|
||||
expect('driver' in values.modelConfig).toBe(false);
|
||||
});
|
||||
|
||||
it('modelConfig:{} (empty) normalizes to null', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
modelConfig: {},
|
||||
} as CreateAgentRoleDto);
|
||||
expect(repo.insert.mock.calls[0][0].modelConfig).toBeNull();
|
||||
});
|
||||
|
||||
it('modelConfig:{chatModel:" "} (whitespace-only) normalizes to null', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
modelConfig: { chatModel: ' ' },
|
||||
} as CreateAgentRoleDto);
|
||||
expect(repo.insert.mock.calls[0][0].modelConfig).toBeNull();
|
||||
});
|
||||
|
||||
it('modelConfig:{driver,chatModel} round-trips both fields (trimmed)', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
modelConfig: { driver: 'gemini', chatModel: ' gemini-2.0-flash ' },
|
||||
} as CreateAgentRoleDto);
|
||||
expect(repo.insert.mock.calls[0][0].modelConfig).toEqual({
|
||||
driver: 'gemini',
|
||||
chatModel: 'gemini-2.0-flash',
|
||||
});
|
||||
});
|
||||
|
||||
it('duplicate name (Postgres 23505) => ConflictException (409), not 500', async () => {
|
||||
const { service, repo } = makeService();
|
||||
// The partial unique (workspace_id, name) index rejects the insert.
|
||||
@@ -148,6 +243,28 @@ describe('AiAgentRolesService guards', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('duplicate name 409 message contains the TRIMMED submitted name', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.insert.mockRejectedValueOnce({ code: '23505' });
|
||||
await service
|
||||
.create('ws-1', 'u1', {
|
||||
name: ' Researcher ',
|
||||
instructions: 'do',
|
||||
} as CreateAgentRoleDto)
|
||||
.then(
|
||||
() => {
|
||||
throw new Error('expected create to throw');
|
||||
},
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
const message = (err as ConflictException).message;
|
||||
// The trimmed name appears verbatim; the untrimmed padding does not.
|
||||
expect(message).toContain('"Researcher"');
|
||||
expect(message).not.toContain(' Researcher ');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('non-unique-violation error is NOT swallowed (re-thrown as-is)', async () => {
|
||||
const { service, repo } = makeService();
|
||||
const other = Object.assign(new Error('boom'), { code: '23502' });
|
||||
|
||||
30
apps/server/src/core/ai-chat/roles/jsonb-object.spec.ts
Normal file
30
apps/server/src/core/ai-chat/roles/jsonb-object.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { jsonbObject } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
|
||||
/**
|
||||
* Unit tests for jsonbObject: the repo helper that encodes a model_config object
|
||||
* as a jsonb bind (or null when there is nothing to persist). It is the last
|
||||
* line of defence before the column write, so the null-vs-bind decision is what
|
||||
* matters here. We assert only null vs non-null because the non-null value is a
|
||||
* kysely `sql` template fragment whose internal shape is an implementation
|
||||
* detail of the SQL tag.
|
||||
*/
|
||||
describe('jsonbObject', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(jsonbObject(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(jsonbObject(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty object (nothing to persist)', () => {
|
||||
expect(jsonbObject({})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a (non-null) jsonb bind for a non-empty object', () => {
|
||||
const out = jsonbObject({ driver: 'gemini', chatModel: 'gemini-2.0-flash' });
|
||||
// A real sql fragment is produced, never null/undefined.
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { AiService } from '../../../integrations/ai/ai.service';
|
||||
import { AiNotConfiguredException } from '../../../integrations/ai/ai-not-configured.exception';
|
||||
import { roleModelOverride } from './role-model-config';
|
||||
import type { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Contract test for the override SHAPE that travels from a role's persisted
|
||||
* `model_config` (via roleModelOverride) into AiService.getChatModel.
|
||||
*
|
||||
* This is the seam between the two halves of the role-model feature:
|
||||
* - roleModelOverride (pure) turns model_config into a ChatModelOverride;
|
||||
* - getChatModel consumes that override to build the model (or to 503).
|
||||
* Wiring the REAL roleModelOverride output into a unit-constructed AiService
|
||||
* (with stubbed deps, no DB) pins that the two agree on the override contract:
|
||||
* - a cross-driver override whose creds are absent => AiNotConfiguredException
|
||||
* naming the role + driver;
|
||||
* - a chatModel-only override keeps the workspace driver/creds (no creds
|
||||
* lookup, no decrypt);
|
||||
* - an ollama cross-driver override => 503 (no silent baseUrl reuse).
|
||||
*/
|
||||
describe('role override -> AiService.getChatModel contract', () => {
|
||||
function role(modelConfig: unknown, name = 'Researcher'): AiAgentRole {
|
||||
return { id: 'r1', name, modelConfig } as unknown as AiAgentRole;
|
||||
}
|
||||
|
||||
function makeService(opts: {
|
||||
workspaceDriver: string;
|
||||
baseUrl?: string;
|
||||
credsApiKeyEnc?: string;
|
||||
}) {
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: opts.workspaceDriver,
|
||||
chatModel: 'gpt-4o-mini',
|
||||
apiKey: 'workspace-key',
|
||||
baseUrl: opts.baseUrl,
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
opts.credsApiKeyEnc ? { apiKeyEnc: opts.credsApiKeyEnc } : undefined,
|
||||
),
|
||||
};
|
||||
const secretBox = { decryptSecret: jest.fn().mockReturnValue('decrypted') };
|
||||
const service = new AiService(
|
||||
aiSettings as never,
|
||||
aiProviderCredentialsRepo as never,
|
||||
secretBox as never,
|
||||
);
|
||||
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
|
||||
}
|
||||
|
||||
it('cross-driver override with NO creds => 503 naming the role and the override driver', async () => {
|
||||
const override = roleModelOverride(
|
||||
role({ driver: 'gemini', chatModel: 'gemini-2.0-flash' }),
|
||||
);
|
||||
expect(override).toEqual({
|
||||
driver: 'gemini',
|
||||
chatModel: 'gemini-2.0-flash',
|
||||
roleName: 'Researcher',
|
||||
});
|
||||
|
||||
// Workspace is openai; the gemini override has no configured creds.
|
||||
const { service, aiProviderCredentialsRepo } = makeService({
|
||||
workspaceDriver: 'openai',
|
||||
});
|
||||
|
||||
await service.getChatModel('ws-1', override).then(
|
||||
() => {
|
||||
throw new Error('expected getChatModel to throw');
|
||||
},
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(AiNotConfiguredException);
|
||||
const message = (err as AiNotConfiguredException).message;
|
||||
expect(message).toContain('gemini');
|
||||
expect(message).toContain('Researcher');
|
||||
},
|
||||
);
|
||||
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
|
||||
});
|
||||
|
||||
it('chatModel-only override keeps the workspace driver/creds (no creds lookup, no decrypt)', async () => {
|
||||
const override = roleModelOverride(role({ chatModel: 'gpt-4o' }));
|
||||
// No driver in the override => the workspace driver/creds are reused.
|
||||
expect(override).toEqual({
|
||||
driver: undefined,
|
||||
chatModel: 'gpt-4o',
|
||||
roleName: 'Researcher',
|
||||
});
|
||||
|
||||
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
|
||||
workspaceDriver: 'openai',
|
||||
});
|
||||
|
||||
const model = await service.getChatModel('ws-1', override);
|
||||
expect(model).toBeDefined();
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ollama cross-driver override (workspace driver != ollama) => 503, no baseUrl reuse', async () => {
|
||||
const override = roleModelOverride(
|
||||
role({ driver: 'ollama', chatModel: 'llama3' }, 'Local'),
|
||||
);
|
||||
expect(override).toEqual({
|
||||
driver: 'ollama',
|
||||
chatModel: 'llama3',
|
||||
roleName: 'Local',
|
||||
});
|
||||
|
||||
const { service, aiProviderCredentialsRepo } = makeService({
|
||||
workspaceDriver: 'openai',
|
||||
baseUrl: 'https://openrouter.example/v1',
|
||||
});
|
||||
|
||||
await service.getChatModel('ws-1', override).then(
|
||||
() => {
|
||||
throw new Error('expected getChatModel to throw');
|
||||
},
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(AiNotConfiguredException);
|
||||
const message = (err as AiNotConfiguredException).message;
|
||||
expect(message).toContain('ollama');
|
||||
expect(message).toContain('openai');
|
||||
expect(message).toContain('Local');
|
||||
// The workspace gateway baseUrl must never be reused for ollama.
|
||||
expect(message).not.toContain('openrouter.example');
|
||||
},
|
||||
);
|
||||
// No creds lookup for ollama: we fail before reaching the creds branch.
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { PublicShareChatToolsService } from './public-share-chat-tools.service';
|
||||
|
||||
/**
|
||||
* Mock-based integration tests for the anonymous public-share toolset built by
|
||||
* forShare(). Constructed directly with hand-rolled collaborators (no Nest/DB):
|
||||
* - listSharePages tree assembly (dedupe, single-page root fallback, fail-soft);
|
||||
* - the blank-input guards on search / read.
|
||||
*/
|
||||
describe('PublicShareChatToolsService.forShare', () => {
|
||||
type ToolExec = { execute: (args: unknown) => Promise<unknown> };
|
||||
|
||||
function makeService(over: {
|
||||
getShareTree?: jest.Mock;
|
||||
findById?: jest.Mock;
|
||||
searchPage?: jest.Mock;
|
||||
getShareForPage?: jest.Mock;
|
||||
} = {}) {
|
||||
const shareService = {
|
||||
getShareTree: over.getShareTree ?? jest.fn(),
|
||||
getShareForPage: over.getShareForPage ?? jest.fn(),
|
||||
updatePublicAttachments: jest.fn(),
|
||||
};
|
||||
const searchService = { searchPage: over.searchPage ?? jest.fn() };
|
||||
const pageRepo = { findById: over.findById ?? jest.fn() };
|
||||
const pagePermissionRepo = { hasRestrictedAncestor: jest.fn() };
|
||||
const svc = new PublicShareChatToolsService(
|
||||
shareService as never,
|
||||
searchService as never,
|
||||
pageRepo as never,
|
||||
pagePermissionRepo as never,
|
||||
);
|
||||
return { svc, shareService, searchService, pageRepo, pagePermissionRepo };
|
||||
}
|
||||
|
||||
describe('listSharePages', () => {
|
||||
it('includeSubPages tree: returns deduped, titled pages (root already in tree)', async () => {
|
||||
// getShareTree returns the share root + descendants; the root IS in the
|
||||
// tree, so no extra title lookup is needed and the tree is listed as-is.
|
||||
const { svc, pageRepo } = makeService({
|
||||
getShareTree: jest.fn().mockResolvedValue({
|
||||
share: { pageId: 'root' },
|
||||
pageTree: [
|
||||
{ id: 'root', title: 'Home' },
|
||||
{ id: 'child-1', title: 'Child One' },
|
||||
{ id: 'child-2', title: 'Child Two' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
|
||||
{},
|
||||
)) as Array<{ id: string; title: string }>;
|
||||
expect(out).toEqual([
|
||||
{ id: 'root', title: 'Home' },
|
||||
{ id: 'child-1', title: 'Child One' },
|
||||
{ id: 'child-2', title: 'Child Two' },
|
||||
]);
|
||||
// The root was already in the tree => no fallback title lookup.
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('single-page share (empty tree): falls back to the root title and PREPENDS it', async () => {
|
||||
const { svc, pageRepo } = makeService({
|
||||
getShareTree: jest.fn().mockResolvedValue({
|
||||
share: { pageId: 'root' },
|
||||
pageTree: [], // includeSubPages=false => empty tree
|
||||
}),
|
||||
findById: jest.fn().mockResolvedValue({ id: 'root', title: 'Solo Page' }),
|
||||
});
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
|
||||
{},
|
||||
)) as Array<{ id: string; title: string }>;
|
||||
expect(out).toEqual([{ id: 'root', title: 'Solo Page' }]);
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith('root');
|
||||
});
|
||||
|
||||
it('de-duplicates pages by id, keeping the first (titled) occurrence', async () => {
|
||||
const { svc } = makeService({
|
||||
getShareTree: jest.fn().mockResolvedValue({
|
||||
share: { pageId: 'root' },
|
||||
pageTree: [
|
||||
{ id: 'root', title: 'Home' },
|
||||
{ id: 'dup', title: 'First' },
|
||||
{ id: 'dup', title: 'Second (dropped)' },
|
||||
{ id: 'root', title: 'Home again (dropped)' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
|
||||
{},
|
||||
)) as Array<{ id: string; title: string }>;
|
||||
expect(out).toEqual([
|
||||
{ id: 'root', title: 'Home' },
|
||||
{ id: 'dup', title: 'First' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('getShareTree throws => returns [] (fail-soft, never throws to the model)', async () => {
|
||||
const { svc } = makeService({
|
||||
getShareTree: jest.fn().mockRejectedValue(new Error('db down')),
|
||||
});
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
await expect(
|
||||
(tools.listSharePages as unknown as ToolExec).execute({}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSharePages blank guard', () => {
|
||||
it('blank query => [] WITHOUT calling searchService', async () => {
|
||||
const { svc, searchService } = makeService({ searchPage: jest.fn() });
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
await expect(
|
||||
(tools.searchSharePages as unknown as ToolExec).execute({ query: ' ' }),
|
||||
).resolves.toEqual([]);
|
||||
expect(searchService.searchPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharePage blank guard', () => {
|
||||
it('blank pageId => throws "A pageId is required." WITHOUT calling getShareForPage', async () => {
|
||||
const { svc, shareService } = makeService({ getShareForPage: jest.fn() });
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
await expect(
|
||||
(tools.getSharePage as unknown as ToolExec).execute({ pageId: ' ' }),
|
||||
).rejects.toThrow('A pageId is required.');
|
||||
expect(shareService.getShareForPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CREDENTIALS_MISMATCH_MESSAGE } from '../auth.constants';
|
||||
import { hashPassword } from '../../../common/helpers';
|
||||
|
||||
/**
|
||||
* LIVE security contract for AuthService.verifyUserCredentials / login (M4
|
||||
* item 5).
|
||||
*
|
||||
* The (now-fixed) jest config CAN import AuthService at the module level (the
|
||||
* `^src/(.*)$` moduleNameMapper resolves the transitive `src/...` imports and the
|
||||
* ts-jest transform loads the graph). AuthService cannot be `.compile()`-d via
|
||||
* the Nest TestingModule (its full provider graph is not wired here), but it can
|
||||
* be constructed directly with mocked collaborators — which is exactly what we
|
||||
* need to exercise the credential-check decision live.
|
||||
*
|
||||
* The load-bearing property: verifyUserCredentials (and login(), which reuses it)
|
||||
* throws EXACTLY the shared CREDENTIALS_MISMATCH_MESSAGE for all three
|
||||
* credentials-failure cases — unknown email, disabled user, wrong password. The
|
||||
* /mcp Basic brute-force limiter only counts a failure when it recognises THIS
|
||||
* exact message (isCredentialsFailure in mcp-auth.helpers matches the same shared
|
||||
* constant); a reword that diverged here would silently turn /mcp Basic into an
|
||||
* unthrottled password-guessing oracle.
|
||||
*/
|
||||
|
||||
const WORKSPACE_ID = 'ws-1';
|
||||
|
||||
// Build an AuthService with the dependencies verifyUserCredentials/login touch
|
||||
// stubbed, and a userRepo whose findByEmail is overridable per test. Only the
|
||||
// collaborators actually reached on these paths need real behaviour; the rest
|
||||
// are inert mocks (constructor wiring only).
|
||||
function makeAuthService(over: {
|
||||
findByEmail?: jest.Mock;
|
||||
} = {}): {
|
||||
service: AuthService;
|
||||
userRepo: { findByEmail: jest.Mock; updateLastLogin: jest.Mock };
|
||||
sessionService: { createSessionAndToken: jest.Mock };
|
||||
auditService: { log: jest.Mock };
|
||||
} {
|
||||
const userRepo = {
|
||||
findByEmail: over.findByEmail ?? jest.fn(),
|
||||
updateLastLogin: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const sessionService = {
|
||||
createSessionAndToken: jest.fn().mockResolvedValue('issued-token'),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
// environmentService: isCloud() false (so throwIfEmailNotVerified does not
|
||||
// require verification) + a stable app secret.
|
||||
const environmentService = {
|
||||
isCloud: jest.fn().mockReturnValue(false),
|
||||
getAppSecret: jest.fn().mockReturnValue('test-secret'),
|
||||
};
|
||||
|
||||
// Constructor signature (auth.service.ts): signupService, tokenService,
|
||||
// sessionService, userSessionRepo, userRepo, userTokenRepo, mailService,
|
||||
// domainService, environmentService, db, auditService.
|
||||
const service = new (AuthService as unknown as new (...args: unknown[]) => AuthService)(
|
||||
{}, // signupService
|
||||
{}, // tokenService
|
||||
sessionService, // sessionService
|
||||
{}, // userSessionRepo
|
||||
userRepo, // userRepo
|
||||
{}, // userTokenRepo
|
||||
{}, // mailService
|
||||
{}, // domainService
|
||||
environmentService, // environmentService
|
||||
{}, // db
|
||||
auditService, // auditService
|
||||
);
|
||||
|
||||
return { service, userRepo, sessionService, auditService };
|
||||
}
|
||||
|
||||
describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)', () => {
|
||||
it('UNKNOWN email -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||
const { service } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.verifyUserCredentials(
|
||||
{ email: 'nobody@example.com', password: 'whatever' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
await expect(
|
||||
service.verifyUserCredentials(
|
||||
{ email: 'nobody@example.com', password: 'whatever' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('DISABLED user -> throws exactly CREDENTIALS_MISMATCH_MESSAGE (no password oracle)', async () => {
|
||||
// A deactivated user must be indistinguishable from a wrong password: same
|
||||
// message, before any password comparison.
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const disabledUser = {
|
||||
id: 'u-1',
|
||||
email: 'disabled@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(disabledUser),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.verifyUserCredentials(
|
||||
{ email: 'disabled@example.com', password: 'correct-horse' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
});
|
||||
|
||||
it('WRONG password -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const user = {
|
||||
id: 'u-1',
|
||||
email: 'user@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(user),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.verifyUserCredentials(
|
||||
{ email: 'user@example.com', password: 'wrong-password' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
});
|
||||
|
||||
it('CORRECT credentials -> resolves the matched user (no side effects here)', async () => {
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const user = {
|
||||
id: 'u-1',
|
||||
email: 'user@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service, sessionService, auditService, userRepo } =
|
||||
makeAuthService({ findByEmail: jest.fn().mockResolvedValue(user) });
|
||||
|
||||
const result = await service.verifyUserCredentials(
|
||||
{ email: 'user@example.com', password: 'correct-horse' },
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
expect(result).toBe(user);
|
||||
// verifyUserCredentials is non-side-effecting: no session/audit/lastLogin.
|
||||
expect(sessionService.createSessionAndToken).not.toHaveBeenCalled();
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
expect(userRepo.updateLastLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthService.login (live credentials-mismatch contract via verifyUserCredentials)', () => {
|
||||
it('UNKNOWN email -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE, mints NO session', async () => {
|
||||
const { service, sessionService } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.login(
|
||||
{ email: 'nobody@example.com', password: 'whatever' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
expect(sessionService.createSessionAndToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WRONG password -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const user = {
|
||||
id: 'u-1',
|
||||
email: 'user@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(user),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.login(
|
||||
{ email: 'user@example.com', password: 'wrong-password' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
});
|
||||
|
||||
it('CORRECT credentials -> login mints the session (the side-effecting path)', async () => {
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const user = {
|
||||
id: 'u-1',
|
||||
email: 'user@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service, sessionService, auditService, userRepo } =
|
||||
makeAuthService({ findByEmail: jest.fn().mockResolvedValue(user) });
|
||||
|
||||
await expect(
|
||||
service.login(
|
||||
{ email: 'user@example.com', password: 'correct-horse' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).resolves.toBe('issued-token');
|
||||
// login() reuses verifyUserCredentials but DOES run the three side effects.
|
||||
expect(userRepo.updateLastLogin).toHaveBeenCalledWith('u-1', WORKSPACE_ID);
|
||||
expect(auditService.log).toHaveBeenCalled();
|
||||
expect(sessionService.createSessionAndToken).toHaveBeenCalledWith(user);
|
||||
});
|
||||
|
||||
it('the message login throws is the SAME shared constant the /mcp limiter matches', () => {
|
||||
// Cross-file coupling lock: the constant is the single source of truth shared
|
||||
// by AuthService and mcp-auth.helpers.isCredentialsFailure.
|
||||
expect(CREDENTIALS_MISMATCH_MESSAGE).toBe('Email or password does not match');
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,67 @@ describe('collectPageEmbedsFromPmJson', () => {
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a pageEmbed whose sourcePageId is not a string', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 123 as any } },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: null as any } },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: { nested: true } as any } },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: ['arr'] as any } },
|
||||
// a valid one mixed in proves only the bad ones are dropped
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'good' } },
|
||||
],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([
|
||||
{ sourcePageId: 'good' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('collects a pageEmbed nested under multiple block containers', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'callout',
|
||||
content: [
|
||||
{
|
||||
type: 'columns',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{
|
||||
type: 'details',
|
||||
content: [
|
||||
{
|
||||
type: 'pageEmbed',
|
||||
attrs: { sourcePageId: 'deep' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([{ sourcePageId: 'deep' }]);
|
||||
});
|
||||
|
||||
it('terminates (does not silently hang) on a self-referencing/cyclic object', () => {
|
||||
// FINDING: there is NO explicit cycle guard. A hand-built cyclic JS object
|
||||
// (which cannot arise from JSON parsing — the real input path) makes the
|
||||
// recursive walk overflow the stack and throw a RangeError. It TERMINATES
|
||||
// with a controlled error rather than recursing unboundedly forever, and a
|
||||
// non-cyclic (JSON-shaped) document is never affected.
|
||||
const node: any = { type: 'doc', content: [] };
|
||||
node.content.push(node); // content array references its own parent node
|
||||
expect(() => collectPageEmbedsFromPmJson(node)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pageEmbed HTML <-> JSON round-trip (server schema)', () => {
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('TransclusionService — template access core (real filter)', () => {
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo };
|
||||
@@ -187,8 +188,103 @@ describe('TransclusionService — template access core (real filter)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.filterViewerAccessiblePageIds — AND ordering (content-leak control)', () => {
|
||||
function makeDb(executeRows: Array<{ id: string }>) {
|
||||
const builder: any = {};
|
||||
builder.selectFrom = jest.fn(() => builder);
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn(() => builder);
|
||||
builder.execute = jest.fn(async () => executeRows);
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeService(opts: {
|
||||
spaceVisibleRows: Array<{ id: string }>;
|
||||
permissionAccessibleIds: string[];
|
||||
}) {
|
||||
const db = makeDb(opts.spaceVisibleRows);
|
||||
const spaceMemberRepo = {
|
||||
getUserSpaceIdsQuery: jest.fn(() => ({ __subquery: true })),
|
||||
};
|
||||
const filterAccessiblePageIds = jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.permissionAccessibleIds);
|
||||
const pagePermissionRepo = { filterAccessiblePageIds };
|
||||
|
||||
const service = new TransclusionService(
|
||||
db as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
{} as any, // pageRepo
|
||||
pagePermissionRepo as any,
|
||||
spaceMemberRepo as any,
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return { service, filterAccessiblePageIds };
|
||||
}
|
||||
|
||||
it('space-visible AND permission-accessible → returned', async () => {
|
||||
const { service } = makeService({
|
||||
spaceVisibleRows: [{ id: 'p1' }],
|
||||
permissionAccessibleIds: ['p1'],
|
||||
});
|
||||
const out = await service.filterViewerAccessiblePageIds(
|
||||
['p1'],
|
||||
'u1',
|
||||
'w1',
|
||||
);
|
||||
expect(out).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('space-visible but permission-rejected → dropped', async () => {
|
||||
const { service, filterAccessiblePageIds } = makeService({
|
||||
spaceVisibleRows: [{ id: 'p1' }],
|
||||
permissionAccessibleIds: [],
|
||||
});
|
||||
const out = await service.filterViewerAccessiblePageIds(
|
||||
['p1'],
|
||||
'u1',
|
||||
'w1',
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
// The permission filter only ever sees the space-visible candidate.
|
||||
expect(filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['p1'],
|
||||
userId: 'u1',
|
||||
});
|
||||
});
|
||||
|
||||
it('NOT space-visible but permission-accessible → STILL dropped (AND-ordering enforced)', async () => {
|
||||
// The page would pass page-level permission filtering, but it is not visible
|
||||
// at the space level (e.g. a private space the viewer is not a member of).
|
||||
// The space-visibility gate runs FIRST and short-circuits, so the page-level
|
||||
// permission filter is never even consulted — preventing a private-space
|
||||
// content leak via an unrestricted source page.
|
||||
const { service, filterAccessiblePageIds } = makeService({
|
||||
spaceVisibleRows: [],
|
||||
permissionAccessibleIds: ['private-but-permitted'],
|
||||
});
|
||||
const out = await service.filterViewerAccessiblePageIds(
|
||||
['private-but-permitted'],
|
||||
'u1',
|
||||
'w1',
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
expect(filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.syncPageTemplateReferences — workspace scoping', () => {
|
||||
function makeService(opts: { inWorkspaceIds: string[] }) {
|
||||
function makeService(opts: {
|
||||
inWorkspaceIds: string[];
|
||||
/** existing rows already persisted for the reference page */
|
||||
existingSourceIds?: string[];
|
||||
}) {
|
||||
// db stub: the in-workspace existence query returns only allowed ids.
|
||||
const builder: any = {};
|
||||
builder.selectFrom = jest.fn(() => builder);
|
||||
@@ -201,25 +297,37 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = {
|
||||
findByReferencePageId: jest.fn().mockResolvedValue([]),
|
||||
findByReferencePageId: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
(opts.existingSourceIds ?? []).map((sourcePageId) => ({
|
||||
sourcePageId,
|
||||
})),
|
||||
),
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
builder as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
builder as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
pageTemplateReferencesRepo as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return { service, insertMany, pageTemplateReferencesRepo };
|
||||
return {
|
||||
service,
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
pageTemplateReferencesRepo,
|
||||
};
|
||||
}
|
||||
|
||||
function docWithEmbeds(sourceIds: string[]) {
|
||||
@@ -264,4 +372,150 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
|
||||
expect(result.inserted).toBe(0);
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DELETE branch: an existing in-workspace ref removed from the doc is deleted', async () => {
|
||||
// 'gone' was referenced before but is no longer in the doc; 'stay' remains.
|
||||
const { service, insertMany, deleteByReferenceAndSources } = makeService({
|
||||
inWorkspaceIds: ['stay'],
|
||||
existingSourceIds: ['stay', 'gone'],
|
||||
});
|
||||
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
docWithEmbeds(['stay']),
|
||||
);
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
expect(result.inserted).toBe(0); // 'stay' already existed
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
expect(deleteByReferenceAndSources).toHaveBeenCalledTimes(1);
|
||||
expect(deleteByReferenceAndSources).toHaveBeenCalledWith(
|
||||
'host',
|
||||
['gone'],
|
||||
undefined, // no trx supplied
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT delete a stale ref whose source is now cross-workspace if it is also still embedded', async () => {
|
||||
// Edge: 'x' is still embedded in the doc but no longer in-workspace. It is
|
||||
// not in desiredIds (filtered out) AND it exists → it should be deleted, not
|
||||
// kept, because the reference graph must drop the cross-workspace edge.
|
||||
const { service, deleteByReferenceAndSources } = makeService({
|
||||
inWorkspaceIds: [], // 'x' no longer in-workspace
|
||||
existingSourceIds: ['x'],
|
||||
});
|
||||
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
docWithEmbeds(['x']),
|
||||
);
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
expect(deleteByReferenceAndSources).toHaveBeenCalledWith(
|
||||
'host',
|
||||
['x'],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.insertTemplateReferencesForPages — per-workspace existence validation', () => {
|
||||
/**
|
||||
* Smart db stub: each existence query is `.where('id','in', ids)` +
|
||||
* `.where('workspaceId','=', wsId)`; `.execute()` returns only the ids that
|
||||
* `validByWorkspace[wsId]` declares in-workspace. The builder snapshots the
|
||||
* last `id`-in list and `workspaceId` value per chain (selectFrom resets).
|
||||
*/
|
||||
function makeDb(validByWorkspace: Record<string, string[]>) {
|
||||
const builder: any = {};
|
||||
let curIds: string[] = [];
|
||||
let curWs: string | undefined;
|
||||
builder.selectFrom = jest.fn(() => {
|
||||
curIds = [];
|
||||
curWs = undefined;
|
||||
return builder;
|
||||
});
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn((col: string, op: string, val: any) => {
|
||||
if (col === 'id' && op === 'in') curIds = val;
|
||||
if (col === 'workspaceId' && op === '=') curWs = val;
|
||||
return builder;
|
||||
});
|
||||
builder.execute = jest.fn(async () => {
|
||||
const valid = new Set(validByWorkspace[curWs ?? ''] ?? []);
|
||||
return curIds.filter((id) => valid.has(id)).map((id) => ({ id }));
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeService(validByWorkspace: Record<string, string[]>) {
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = { insertMany };
|
||||
const service = new TransclusionService(
|
||||
makeDb(validByWorkspace) as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
pageTemplateReferencesRepo as any,
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
return { service, insertMany };
|
||||
}
|
||||
|
||||
const embedDoc = (ids: string[]) => ({
|
||||
type: 'doc',
|
||||
content: ids.map((id) => ({
|
||||
type: 'pageEmbed',
|
||||
attrs: { sourcePageId: id },
|
||||
})),
|
||||
});
|
||||
|
||||
it('validates each workspace separately: a source in-ws for A but cross-ws for B inserts only the valid delta', async () => {
|
||||
// 'shared' is in-workspace for wA but NOT for wB. Page A embeds 'shared'
|
||||
// (valid → inserted). Page B embeds 'shared' (cross-ws for wB → dropped).
|
||||
const { service, insertMany } = makeService({
|
||||
wA: ['shared'],
|
||||
wB: [], // 'shared' is not a page in wB
|
||||
});
|
||||
|
||||
const result = await service.insertTemplateReferencesForPages([
|
||||
{ id: 'pageA', workspaceId: 'wA', content: embedDoc(['shared']) },
|
||||
{ id: 'pageB', workspaceId: 'wB', content: embedDoc(['shared']) },
|
||||
]);
|
||||
|
||||
expect(result.inserted).toBe(1);
|
||||
expect(insertMany).toHaveBeenCalledTimes(1);
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{ workspaceId: 'wA', referencePageId: 'pageA', sourcePageId: 'shared' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('inserts the in-workspace deltas for both pages when each is valid in its own workspace', async () => {
|
||||
const { service, insertMany } = makeService({
|
||||
wA: ['a-src'],
|
||||
wB: ['b-src'],
|
||||
});
|
||||
|
||||
const result = await service.insertTemplateReferencesForPages([
|
||||
{ id: 'pageA', workspaceId: 'wA', content: embedDoc(['a-src']) },
|
||||
{ id: 'pageB', workspaceId: 'wB', content: embedDoc(['b-src']) },
|
||||
]);
|
||||
|
||||
expect(result.inserted).toBe(2);
|
||||
const rows = insertMany.mock.calls[0][0];
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ workspaceId: 'wA', referencePageId: 'pageA', sourcePageId: 'a-src' },
|
||||
{ workspaceId: 'wB', referencePageId: 'pageB', sourcePageId: 'b-src' },
|
||||
]),
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import * as collabUtil from '../../../../collaboration/collaboration.util';
|
||||
|
||||
/**
|
||||
* Exercises the pure access/mapping logic of `lookupTemplate`:
|
||||
@@ -34,6 +35,7 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
jest
|
||||
@@ -110,4 +112,61 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
|
||||
expect((items[1] as any).status).toBeUndefined();
|
||||
expect((items[2] as any).status).toBe('no_access');
|
||||
});
|
||||
|
||||
// Content-prep failure path: if jsonToNode throws for an accessible page, the
|
||||
// item must degrade to not_found and NEVER return content (which would
|
||||
// otherwise carry the source's un-stripped comment marks).
|
||||
describe('content-prep failure → not_found', () => {
|
||||
let jsonToNodeSpy: jest.SpyInstance;
|
||||
|
||||
afterEach(() => {
|
||||
jsonToNodeSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('maps to not_found and returns no content when jsonToNode throws', async () => {
|
||||
// The page is accessible and present, but content preparation blows up.
|
||||
jsonToNodeSpy = jest
|
||||
.spyOn(collabUtil, 'jsonToNode')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const contentWithComment = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'secret',
|
||||
marks: [{ type: 'comment', attrs: { commentId: 'leak' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { service } = makeService({
|
||||
accessibleIds: ['p1'],
|
||||
pages: [
|
||||
{
|
||||
id: 'p1',
|
||||
title: 'T',
|
||||
icon: null,
|
||||
content: contentWithComment,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Silence the service's error logger for the expected throw.
|
||||
jest.spyOn((service as any).logger, 'error').mockImplementation(() => {});
|
||||
|
||||
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
|
||||
expect(items).toEqual([{ sourcePageId: 'p1', status: 'not_found' }]);
|
||||
// Crucially: no content field, so no comment mark can leak.
|
||||
expect((items[0] as any).content).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { PageTemplateController } from '../page-template.controller';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { TemplateLookupDto } from '../dto/template-lookup.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../../page-access/page-access.service';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
@@ -90,4 +93,52 @@ describe('PageTemplateController.toggleTemplate', () => {
|
||||
);
|
||||
expect(out).toEqual({ pageId: 'p1', isTemplate: false });
|
||||
});
|
||||
|
||||
it('lookup forwards dto.sourcePageIds + user.id + user.workspaceId to the service', async () => {
|
||||
const expected = { items: [] };
|
||||
(transclusionService.lookupTemplate as jest.Mock).mockResolvedValue(
|
||||
expected,
|
||||
);
|
||||
|
||||
const dto = { sourcePageIds: ['s1', 's2'] } as any;
|
||||
const out = await controller.lookup(dto, user);
|
||||
|
||||
expect(transclusionService.lookupTemplate).toHaveBeenCalledWith(
|
||||
['s1', 's2'],
|
||||
'u1', // user.id
|
||||
'w1', // user.workspaceId
|
||||
);
|
||||
expect(out).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TemplateLookupDto validation (class-validator)', () => {
|
||||
const uuid = (n: number) =>
|
||||
`00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
|
||||
it('accepts an array of <=50 valid UUIDs', async () => {
|
||||
const dto = plainToInstance(TemplateLookupDto, {
|
||||
sourcePageIds: [uuid(1), uuid(2)],
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an over-cap array (ArrayMaxSize 50)', async () => {
|
||||
const dto = plainToInstance(TemplateLookupDto, {
|
||||
sourcePageIds: Array.from({ length: 51 }, (_, i) => uuid(i)),
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints).toHaveProperty('arrayMaxSize');
|
||||
});
|
||||
|
||||
it('rejects a non-UUID member (IsUUID each)', async () => {
|
||||
const dto = plainToInstance(TemplateLookupDto, {
|
||||
sourcePageIds: [uuid(1), 'not-a-uuid'],
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints).toHaveProperty('isUuid');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ function buildService(featureEnabled = true) {
|
||||
{} as any, // db (unused on this path)
|
||||
pageTransclusionsRepo as any,
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo (unused on this path)
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo (unused)
|
||||
{} as any, // spaceMemberRepo (unused)
|
||||
|
||||
@@ -131,3 +131,131 @@ describe('ShareService htmlEmbed server-authoritative kill-switch (real code)',
|
||||
expect(hasHtmlEmbedNode(out)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Exercises the REAL ShareService.lookupTransclusionForShare post-processing for
|
||||
// the share-served transclusion path: the same server-authoritative htmlEmbed
|
||||
// kill-switch must apply to each transcluded item's content, and a not_found
|
||||
// item must never be run through prepareContentForShare (so its absent content
|
||||
// can't be serialized/leaked). The access graph (shareRepo / isSharingAllowed /
|
||||
// getShareForPage / restricted-ancestor) is stubbed so the strip/serve mapping
|
||||
// runs deterministically; lookupWithAccessSet is mocked to control the items.
|
||||
describe('ShareService.lookupTransclusionForShare htmlEmbed kill-switch (real code)', () => {
|
||||
const SHARE = 'share-1';
|
||||
const SPACE = 'space-1';
|
||||
const SRC = 'src-page';
|
||||
|
||||
function buildTransclusionService(opts: {
|
||||
htmlEmbed?: boolean | undefined;
|
||||
items: any[];
|
||||
}) {
|
||||
const shareRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: SHARE,
|
||||
workspaceId: WS,
|
||||
spaceId: SPACE,
|
||||
})),
|
||||
};
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: jest.fn(async () => false),
|
||||
};
|
||||
const tokenService = {
|
||||
generateAttachmentToken: jest.fn(async () => 'tok'),
|
||||
};
|
||||
const lookupWithAccessSet = jest.fn(async () => ({ items: opts.items }));
|
||||
const transclusionService = { lookupWithAccessSet };
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: WS,
|
||||
settings: { htmlEmbed: opts.htmlEmbed },
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new ShareService(
|
||||
shareRepo as any,
|
||||
pageRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
{} as any, // db (unused — isSharingAllowed stubbed below)
|
||||
tokenService as any,
|
||||
transclusionService as any,
|
||||
workspaceRepo as any,
|
||||
);
|
||||
|
||||
// isSharingAllowed and getShareForPage hit the raw db; stub them so the
|
||||
// access chain resolves SRC as reachable and prepareContentForShare runs.
|
||||
jest.spyOn(service, 'isSharingAllowed').mockResolvedValue(true);
|
||||
jest
|
||||
.spyOn(service, 'getShareForPage')
|
||||
.mockResolvedValue({ pageId: SRC, spaceId: SPACE, id: 's2' } as any);
|
||||
|
||||
return { service, transclusionService, lookupWithAccessSet };
|
||||
}
|
||||
|
||||
const transcludedItemWithEmbed = () => ({
|
||||
sourcePageId: SRC,
|
||||
transclusionId: 't1',
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'block body' }] },
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>t()</script>' } },
|
||||
],
|
||||
},
|
||||
sourceUpdatedAt: new Date('2026-06-20T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const refs = [{ sourcePageId: SRC, transclusionId: 't1' }];
|
||||
|
||||
it('toggle OFF: strips htmlEmbed from each transcluded item content', async () => {
|
||||
const { service } = buildTransclusionService({
|
||||
htmlEmbed: false,
|
||||
items: [transcludedItemWithEmbed()],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
|
||||
expect(items).toHaveLength(1);
|
||||
const item = items[0] as any;
|
||||
expect(item.status).toBeUndefined();
|
||||
expect(hasHtmlEmbedNode(item.content)).toBe(false);
|
||||
// Non-embed body of the transcluded block is preserved.
|
||||
expect(JSON.stringify(item.content)).toContain('block body');
|
||||
});
|
||||
|
||||
it('toggle ON: serves htmlEmbed in the transcluded item content', async () => {
|
||||
const { service } = buildTransclusionService({
|
||||
htmlEmbed: true,
|
||||
items: [transcludedItemWithEmbed()],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
|
||||
const item = items[0] as any;
|
||||
expect(item.status).toBeUndefined();
|
||||
expect(hasHtmlEmbedNode(item.content)).toBe(true);
|
||||
expect(JSON.stringify(item.content)).toContain('block body');
|
||||
});
|
||||
|
||||
it('a not_found item is NOT run through prepareContentForShare (no token minting)', async () => {
|
||||
const notFoundItem = {
|
||||
sourcePageId: SRC,
|
||||
transclusionId: 't1',
|
||||
status: 'not_found' as const,
|
||||
};
|
||||
const { service } = buildTransclusionService({
|
||||
htmlEmbed: true,
|
||||
items: [notFoundItem],
|
||||
});
|
||||
// tokenService is reachable via the service; spy on it to assert it is never
|
||||
// touched for a status item (prepareContentForShare mints tokens).
|
||||
const tokenSpy = jest.spyOn(
|
||||
(service as any).tokenService,
|
||||
'generateAttachmentToken',
|
||||
);
|
||||
|
||||
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
|
||||
// not_found is collapsed to no_access for share viewers and carries NO content.
|
||||
const item = items[0] as any;
|
||||
expect(item.status).toBe('no_access');
|
||||
expect(item.content).toBeUndefined();
|
||||
expect(tokenSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
|
||||
/**
|
||||
* Exercises the REAL WorkspaceService.update htmlEmbed-toggle persistence at the
|
||||
* service seam: an update carrying `htmlEmbed` must call
|
||||
* `workspaceRepo.updateSetting(workspaceId, 'htmlEmbed', value, trx)`, and an
|
||||
* update WITHOUT it must not touch that setting. The repo, db transaction, and
|
||||
* audit service are mocked; `executeTx` runs the callback against a fake trx.
|
||||
*
|
||||
* DEFERRED (DB-only): the "does not clobber sibling settings" guarantee is a
|
||||
* jsonb merge property of `updateSetting`'s SQL and needs a real Postgres to
|
||||
* assert. This spec only asserts the service-level CALL SHAPE.
|
||||
*/
|
||||
describe('WorkspaceService.update — htmlEmbed toggle persistence (real code)', () => {
|
||||
function buildService(opts: { settingsBefore?: Record<string, any> }) {
|
||||
const updateSetting = jest.fn().mockResolvedValue(undefined);
|
||||
const updateWorkspace = jest.fn().mockResolvedValue(undefined);
|
||||
const workspaceRepo = {
|
||||
// First call: read settingsBefore. Second call: return the updated
|
||||
// workspace (must include a licenseKey because update() destructures it).
|
||||
findById: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'w1', settings: opts.settingsBefore ?? {} })
|
||||
.mockResolvedValueOnce({ id: 'w1', name: 'WS', licenseKey: null }),
|
||||
updateSetting,
|
||||
updateWorkspace,
|
||||
};
|
||||
|
||||
// Fake kysely db: only .transaction().execute(cb) is used on this path.
|
||||
const db = {
|
||||
transaction: jest.fn(() => ({
|
||||
execute: jest.fn(async (cb: any) => cb({ __trx: true })),
|
||||
})),
|
||||
};
|
||||
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
const service = new WorkspaceService(
|
||||
workspaceRepo as any, // workspaceRepo
|
||||
{} as any, // spaceService
|
||||
{} as any, // spaceMemberService
|
||||
{} as any, // groupRepo
|
||||
{} as any, // groupUserRepo
|
||||
{} as any, // userRepo
|
||||
{} as any, // environmentService
|
||||
{} as any, // domainService
|
||||
{} as any, // licenseCheckService
|
||||
{} as any, // shareRepo
|
||||
{} as any, // watcherRepo
|
||||
{} as any, // favoriteRepo
|
||||
db as any, // db (InjectKysely)
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // billingQueue
|
||||
{} as any, // aiQueue
|
||||
auditService as any, // auditService
|
||||
{} as any, // userSessionRepo
|
||||
);
|
||||
|
||||
return { service, workspaceRepo, updateSetting, auditService };
|
||||
}
|
||||
|
||||
it('persists htmlEmbed:true via updateSetting with the htmlEmbed key', async () => {
|
||||
const { service, updateSetting } = buildService({});
|
||||
|
||||
await service.update('w1', { htmlEmbed: true } as any);
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledTimes(1);
|
||||
expect(updateSetting).toHaveBeenCalledWith(
|
||||
'w1',
|
||||
'htmlEmbed',
|
||||
true,
|
||||
expect.anything(), // the transaction handle
|
||||
);
|
||||
});
|
||||
|
||||
it('persists htmlEmbed:false (explicit disable is not dropped)', async () => {
|
||||
const { service, updateSetting } = buildService({
|
||||
settingsBefore: { htmlEmbed: true },
|
||||
});
|
||||
|
||||
await service.update('w1', { htmlEmbed: false } as any);
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith(
|
||||
'w1',
|
||||
'htmlEmbed',
|
||||
false,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT call updateSetting when htmlEmbed is undefined in the dto', async () => {
|
||||
const { service, updateSetting } = buildService({});
|
||||
|
||||
await service.update('w1', { name: 'New name' } as any);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('audits the htmlEmbed change (before/after) when the value actually changes', async () => {
|
||||
const { service, auditService } = buildService({
|
||||
settingsBefore: { htmlEmbed: false },
|
||||
});
|
||||
|
||||
await service.update('w1', { htmlEmbed: true } as any);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
const logged = auditService.log.mock.calls[0][0];
|
||||
expect(logged.changes.before.htmlEmbed).toBe(false);
|
||||
expect(logged.changes.after.htmlEmbed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ export class AiAgentRoleRepo {
|
||||
* generated column type is the broad `JsonValue` union, which a concrete object
|
||||
* type is not structurally assignable to.
|
||||
*/
|
||||
function jsonbObject(value: ModelConfigValue | undefined) {
|
||||
export function jsonbObject(value: ModelConfigValue | undefined) {
|
||||
if (value === null || value === undefined || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,4 +58,26 @@ describe('describeProviderError', () => {
|
||||
// 'e | response body: ' + 300 chars + '…'
|
||||
expect(out.length).toBeLessThan('e | response body: '.length + 305);
|
||||
});
|
||||
|
||||
it('uses the fallback for a numeric or boolean (non-object, non-string) error', () => {
|
||||
// typeof number / boolean is neither 'object' nor a non-empty 'string', so
|
||||
// the early branch returns the fallback verbatim.
|
||||
expect(describeProviderError(500, 'AI stream error')).toBe('AI stream error');
|
||||
expect(describeProviderError(0, 'AI stream error')).toBe('AI stream error');
|
||||
expect(describeProviderError(true)).toBe('Unknown error');
|
||||
expect(describeProviderError(false, 'fb')).toBe('fb');
|
||||
});
|
||||
|
||||
it('statusCode present but message undefined => "<code>:" with no trailing space', () => {
|
||||
// `${code}: ${undefined ?? ''}`.trim() collapses to just "<code>:".
|
||||
expect(describeProviderError({ statusCode: 503 })).toBe('503:');
|
||||
// The trailing space after the colon is trimmed away.
|
||||
expect(describeProviderError({ statusCode: 503 }).endsWith(': ')).toBe(false);
|
||||
});
|
||||
|
||||
it('object with neither message nor statusCode nor body => fallback', () => {
|
||||
expect(describeProviderError({}, 'AI stream error')).toBe('AI stream error');
|
||||
// An object carrying only unrelated keys is still treated as message-less.
|
||||
expect(describeProviderError({ foo: 'bar' } as never)).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,4 +171,117 @@ describe('AiService.getChatModel role model override', () => {
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a service whose workspace driver is ollama (no apiKey, with a baseUrl).
|
||||
* Complements makeService (which configures openai) for the same-driver and
|
||||
* not-configured ollama cases.
|
||||
*/
|
||||
function makeOllamaService(over: { baseUrl?: string } = {}) {
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: 'ollama',
|
||||
chatModel: 'llama3',
|
||||
apiKey: undefined,
|
||||
baseUrl: over.baseUrl ?? 'http://localhost:11434/v1',
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = { find: jest.fn() };
|
||||
const secretBox = { decryptSecret: jest.fn() };
|
||||
const service = new AiService(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiSettings as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiProviderCredentialsRepo as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
secretBox as any,
|
||||
);
|
||||
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
|
||||
}
|
||||
|
||||
it('same-driver ollama override (workspace driver=ollama): reuses the workspace ollama baseUrl, no creds lookup/decrypt', async () => {
|
||||
// Workspace driver IS ollama. A role that overrides to ollama (same driver)
|
||||
// legitimately reuses the workspace's configured ollama endpoint — it must
|
||||
// NOT hit the cross-driver 503 path, NOT query ai_provider_credentials, and
|
||||
// NOT decrypt anything (ollama needs no key).
|
||||
const { service, aiProviderCredentialsRepo, secretBox } = makeOllamaService();
|
||||
|
||||
const model = await service.getChatModel('ws-1', {
|
||||
driver: 'ollama',
|
||||
chatModel: 'llama3.1',
|
||||
roleName: 'Local',
|
||||
});
|
||||
|
||||
expect(model).toBeDefined();
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('chatModel-only override on an ollama workspace: reuses the workspace ollama baseUrl, no creds lookup', async () => {
|
||||
// No override.driver on an ollama workspace => the workspace ollama driver +
|
||||
// baseUrl are reused; no creds lookup, no decrypt (the cheap public-share
|
||||
// model-only override path against an ollama workspace).
|
||||
const { service, aiProviderCredentialsRepo, secretBox } = makeOllamaService();
|
||||
|
||||
const model = await service.getChatModel('ws-1', { chatModel: 'mistral' });
|
||||
|
||||
expect(model).toBeDefined();
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blank chatModel guard: workspace has a driver but a blank chatModel and no override chatModel => AiNotConfiguredException', async () => {
|
||||
// cfg.driver passes the first guard, but cfg.chatModel is blank and the
|
||||
// override carries no chatModel, so the effective chatModel is empty.
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: 'openai',
|
||||
chatModel: '',
|
||||
apiKey: 'workspace-key',
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = { find: jest.fn() };
|
||||
const secretBox = { decryptSecret: jest.fn() };
|
||||
const service = new AiService(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiSettings as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiProviderCredentialsRepo as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
secretBox as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
// Override has only a roleName, no chatModel to fill the blank.
|
||||
service.getChatModel('ws-1', { roleName: 'Writer' }),
|
||||
).rejects.toBeInstanceOf(AiNotConfiguredException);
|
||||
});
|
||||
|
||||
it('non-ollama driver with a missing apiKey => AiNotConfiguredException', async () => {
|
||||
// Workspace is openai (non-ollama) with a model but NO apiKey: the combined
|
||||
// `driver !== ollama && !apiKey` guard must 503.
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: 'openai',
|
||||
chatModel: 'gpt-4o-mini',
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = { find: jest.fn() };
|
||||
const secretBox = { decryptSecret: jest.fn() };
|
||||
const service = new AiService(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiSettings as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiProviderCredentialsRepo as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
secretBox as any,
|
||||
);
|
||||
|
||||
await expect(service.getChatModel('ws-1')).rejects.toBeInstanceOf(
|
||||
AiNotConfiguredException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,6 +384,111 @@ export function isInitializeRequestBody(body: unknown): boolean {
|
||||
return isInitializeRequest(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of McpService.handle's pre-hijack gauntlet, as a pure value the
|
||||
* caller acts on. Either send a JSON error with a fixed status (`respond`), or
|
||||
* proceed to hijack the response and delegate to the MCP transport (`hijack`).
|
||||
* Keeping this a pure decision (no FastifyReply, no res.hijack) makes the
|
||||
* status/body mapping unit-testable, and guarantees no error path can leak the
|
||||
* password or Authorization header — the body is only ever a fixed string or the
|
||||
* UnauthorizedException's own message.
|
||||
*/
|
||||
export type McpHandleDecision =
|
||||
| { kind: 'respond'; status: number; body: { error: string } }
|
||||
| { kind: 'hijack' };
|
||||
|
||||
/**
|
||||
* Pure mapping of McpService.handle's auth/enablement gauntlet to a response
|
||||
* decision. Precedence mirrors handle():
|
||||
* 1. shared X-MCP-Token mismatch -> 401 {error:'Unauthorized'} (no hijack).
|
||||
* 2. workspace MCP disabled -> 403 {error:'MCP is disabled ...'}.
|
||||
* 3. resolveSessionConfig threw:
|
||||
* - an UnauthorizedException -> 401 with err.message (a SPECIFIC reason;
|
||||
* never the password/header — the message is the only thing surfaced).
|
||||
* - any other error -> 500 generic 'Internal server error'.
|
||||
* 4. otherwise (auth resolved) -> hijack and delegate to the transport.
|
||||
*/
|
||||
export function mapAuthResultToResponse(input: {
|
||||
sharedTokenOk: boolean;
|
||||
enabled: boolean;
|
||||
error?: unknown;
|
||||
}): McpHandleDecision {
|
||||
if (!input.sharedTokenOk) {
|
||||
return { kind: 'respond', status: 401, body: { error: 'Unauthorized' } };
|
||||
}
|
||||
|
||||
if (!input.enabled) {
|
||||
return {
|
||||
kind: 'respond',
|
||||
status: 403,
|
||||
body: { error: 'MCP is disabled for this workspace' },
|
||||
};
|
||||
}
|
||||
|
||||
if (input.error !== undefined) {
|
||||
if (input.error instanceof UnauthorizedException) {
|
||||
return {
|
||||
kind: 'respond',
|
||||
status: 401,
|
||||
body: { error: input.error.message },
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'respond',
|
||||
status: 500,
|
||||
body: { error: 'Internal server error' },
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'hijack' };
|
||||
}
|
||||
|
||||
// Result of the EE MFA module's requirement check for the Basic gate. Both
|
||||
// flags absent/false means MFA does not block the password login.
|
||||
export interface BasicGateMfaResult {
|
||||
userHasMfa?: boolean;
|
||||
requiresMfaSetup?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision logic for the /mcp HTTP-Basic pre-token gate, replicating EXACTLY
|
||||
* what AuthController.login enforces before issuing a token, so the Basic path is
|
||||
* not an SSO/MFA bypass. Framework-free (no ModuleRef, no on-disk EE MFA module)
|
||||
* so the SSO/MFA decision is unit-testable in isolation:
|
||||
*
|
||||
* - `ssoEnforced` true -> throw Unauthorized ("enforced SSO"); a password
|
||||
* login is not allowed on an SSO-enforced workspace.
|
||||
* - otherwise, `mfa` is the EE MFA module's requirement result (or undefined
|
||||
* when no EE MFA module is bundled — a community/fork build). If MFA is
|
||||
* present and the user has MFA enabled OR needs MFA setup, throw Unauthorized
|
||||
* telling the caller to use a Bearer access token (Basic cannot complete MFA).
|
||||
* - no SSO + no MFA gate -> resolve (the Basic login is allowed to proceed).
|
||||
*
|
||||
* McpService.enforceBasicLoginGate wires the concrete `validateSsoEnforcement`
|
||||
* result and the lazily-loaded MFA module result into this, so the gate decision
|
||||
* itself carries no framework dependencies. Throws UnauthorizedException on
|
||||
* rejection (surfaced as a clean 401); never logs the password.
|
||||
*/
|
||||
export function decideBasicGate(input: {
|
||||
ssoEnforced: boolean;
|
||||
mfa?: BasicGateMfaResult;
|
||||
}): void {
|
||||
if (input.ssoEnforced) {
|
||||
throw new UnauthorizedException(
|
||||
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const mfa = input.mfa;
|
||||
if (mfa && (mfa.userHasMfa || mfa.requiresMfaSetup)) {
|
||||
throw new UnauthorizedException(
|
||||
'This account requires multi-factor authentication. MCP HTTP Basic ' +
|
||||
'cannot complete MFA — log in normally and use a Bearer access token ' +
|
||||
'instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a Bearer token from an Authorization header (case-insensitive). */
|
||||
export function extractBearer(
|
||||
authHeader: string | undefined,
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
sharedTokenMatches,
|
||||
clientIp,
|
||||
bindAccessJwtVerifier,
|
||||
extractBearer,
|
||||
decideBasicGate,
|
||||
mapAuthResultToResponse,
|
||||
McpAuthDeps,
|
||||
} from './mcp-auth.helpers';
|
||||
import { JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
@@ -79,6 +82,26 @@ describe('parseBasicAuth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBearer', () => {
|
||||
it('extracts the token from a "Bearer <token>" header', () => {
|
||||
expect(extractBearer('Bearer abc.def.ghi')).toBe('abc.def.ghi');
|
||||
});
|
||||
|
||||
it('is case-insensitive on the scheme (lowercase + uppercase)', () => {
|
||||
// The split keeps the token as-is; only the scheme is compared lowercased.
|
||||
expect(extractBearer('bearer abc')).toBe('abc');
|
||||
expect(extractBearer('BEARER abc')).toBe('abc');
|
||||
});
|
||||
|
||||
it('returns undefined for a non-Bearer scheme (e.g. Basic)', () => {
|
||||
expect(extractBearer('Basic abc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an undefined header', () => {
|
||||
expect(extractBearer(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCredentialsFailure', () => {
|
||||
it('is true for the credentials-mismatch UnauthorizedException', () => {
|
||||
expect(
|
||||
@@ -185,6 +208,43 @@ describe('FailedLoginLimiter', () => {
|
||||
expect(lim.isBlocked(k, 0)).toBe(true);
|
||||
expect(lim.isBlocked(k, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
describe('sweep (expired-bucket eviction, injectable clock)', () => {
|
||||
// sweep() drops buckets whose windowStart is older than windowMs so
|
||||
// never-revisited keys cannot accumulate forever. It takes an injectable
|
||||
// `now` so the behaviour is deterministic without faking timers.
|
||||
it('drops a bucket strictly older than windowMs', () => {
|
||||
const lim = new FailedLoginLimiter(5, 1000);
|
||||
// Seed a bucket at t=0 (windowStart=0).
|
||||
lim.recordFailure('stale', 0);
|
||||
// Sweep well past the window: now - windowStart = 5000 >= 1000 -> dropped.
|
||||
lim.sweep(5000);
|
||||
// A dropped bucket means a brand-new bucket is created on next touch, so
|
||||
// the prior failure count is gone (a single fresh failure is far from 5).
|
||||
lim.recordFailure('stale', 5001);
|
||||
expect(lim.isBlocked('stale', 5001)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a bucket exactly at the windowMs boundary (>= is inclusive)', () => {
|
||||
const lim = new FailedLoginLimiter(1, 1000);
|
||||
lim.recordFailure('boundary', 0); // windowStart=0, blocked at threshold 1
|
||||
expect(lim.isBlocked('boundary', 0)).toBe(true);
|
||||
// now - windowStart = 1000 == windowMs -> the >= check evicts it.
|
||||
lim.sweep(1000);
|
||||
// Re-touch at the same instant: a fresh bucket (count 0) is created, so the
|
||||
// key is no longer blocked, proving the boundary bucket was swept.
|
||||
expect(lim.isBlocked('boundary', 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it('retains a fresh bucket still within the window', () => {
|
||||
const lim = new FailedLoginLimiter(1, 1000);
|
||||
lim.recordFailure('fresh', 0); // windowStart=0
|
||||
// now - windowStart = 999 < 1000 -> the bucket survives the sweep.
|
||||
lim.sweep(999);
|
||||
// Still blocked because the bucket (and its count) was retained.
|
||||
expect(lim.isBlocked('fresh', 999)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBearerAccess (Bearer revocation/disabled checks)', () => {
|
||||
@@ -825,3 +885,138 @@ describe('bindAccessJwtVerifier enforces JwtType.ACCESS (item 3)', () => {
|
||||
expect(res).toEqual({ sub: 'user-1', email: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideBasicGate (pure SSO/MFA pre-token gate, refactor R1)', () => {
|
||||
// The pure decision extracted out of McpService.enforceBasicLoginGate. It is
|
||||
// tested WITHOUT ModuleRef and WITHOUT an on-disk EE MFA module: the SSO verdict
|
||||
// and the MFA requirement result are passed in as plain values.
|
||||
|
||||
it('SSO enforced -> throws Unauthorized ("enforced SSO")', () => {
|
||||
expect(() => decideBasicGate({ ssoEnforced: true })).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(() => decideBasicGate({ ssoEnforced: true })).toThrow(/enforced SSO/);
|
||||
// SSO takes precedence even if MFA flags are also set.
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: true, mfa: { userHasMfa: true } }),
|
||||
).toThrow(/enforced SSO/);
|
||||
});
|
||||
|
||||
it('no SSO + no MFA module (mfa undefined) -> resolves (Basic allowed)', () => {
|
||||
// A community/fork build with no EE MFA module passes mfa: undefined and the
|
||||
// gate must allow the password login (same as the controller with no MFA).
|
||||
expect(() => decideBasicGate({ ssoEnforced: false })).not.toThrow();
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: false, mfa: undefined }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('MFA present + userHasMfa -> rejects ("use a Bearer access token")', () => {
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: false, mfa: { userHasMfa: true } }),
|
||||
).toThrow(/use a Bearer access token/);
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: false, mfa: { userHasMfa: true } }),
|
||||
).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('MFA present + requiresMfaSetup -> rejects', () => {
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: false, mfa: { requiresMfaSetup: true } }),
|
||||
).toThrow(/use a Bearer access token/);
|
||||
});
|
||||
|
||||
it('MFA present but none required (both flags false) -> resolves', () => {
|
||||
expect(() =>
|
||||
decideBasicGate({
|
||||
ssoEnforced: false,
|
||||
mfa: { userHasMfa: false, requiresMfaSetup: false },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapAuthResultToResponse (handle status/body mapping, refactor R2)', () => {
|
||||
// The pure response decision extracted out of McpService.handle. It maps the
|
||||
// pre-hijack gauntlet (shared token, enablement, auth error) to either a fixed
|
||||
// JSON error response or the hijack path — never leaking the password/header.
|
||||
|
||||
it('wrong X-MCP-Token -> 401 {error:"Unauthorized"} and NOT the hijack path', () => {
|
||||
const d = mapAuthResultToResponse({ sharedTokenOk: false, enabled: true });
|
||||
expect(d).toEqual({
|
||||
kind: 'respond',
|
||||
status: 401,
|
||||
body: { error: 'Unauthorized' },
|
||||
});
|
||||
});
|
||||
|
||||
it('workspace MCP disabled -> 403', () => {
|
||||
const d = mapAuthResultToResponse({ sharedTokenOk: true, enabled: false });
|
||||
expect(d.kind).toBe('respond');
|
||||
if (d.kind === 'respond') {
|
||||
expect(d.status).toBe(403);
|
||||
expect(d.body).toEqual({ error: 'MCP is disabled for this workspace' });
|
||||
}
|
||||
});
|
||||
|
||||
it('an UnauthorizedException -> 401 with err.message; no password/header leaked', () => {
|
||||
// Construct an UnauthorizedException whose message is the SPECIFIC auth reason.
|
||||
const err = new UnauthorizedException('Email or password does not match');
|
||||
const d = mapAuthResultToResponse({
|
||||
sharedTokenOk: true,
|
||||
enabled: true,
|
||||
error: err,
|
||||
});
|
||||
expect(d).toEqual({
|
||||
kind: 'respond',
|
||||
status: 401,
|
||||
body: { error: 'Email or password does not match' },
|
||||
});
|
||||
// The surfaced body is ONLY the exception message — never the raw secret.
|
||||
if (d.kind === 'respond') {
|
||||
const serialized = JSON.stringify(d.body);
|
||||
expect(serialized).not.toContain('password=');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('Basic ');
|
||||
expect(serialized).not.toContain('Bearer ');
|
||||
}
|
||||
});
|
||||
|
||||
it('a non-Unauthorized error -> 500 generic (no error detail surfaced)', () => {
|
||||
const err = new Error('db blew up: connection string secret');
|
||||
const d = mapAuthResultToResponse({
|
||||
sharedTokenOk: true,
|
||||
enabled: true,
|
||||
error: err,
|
||||
});
|
||||
expect(d).toEqual({
|
||||
kind: 'respond',
|
||||
status: 500,
|
||||
body: { error: 'Internal server error' },
|
||||
});
|
||||
// The generic body must NOT echo the underlying error message.
|
||||
if (d.kind === 'respond') {
|
||||
expect(d.body.error).not.toContain('secret');
|
||||
}
|
||||
});
|
||||
|
||||
it('happy path (auth resolved, no error) -> hijack', () => {
|
||||
const d = mapAuthResultToResponse({ sharedTokenOk: true, enabled: true });
|
||||
expect(d).toEqual({ kind: 'hijack' });
|
||||
});
|
||||
|
||||
it('shared-token failure takes precedence over disabled/error', () => {
|
||||
// Even with a disabled workspace and an error, a bad shared token is the
|
||||
// first gate, so the response is the uniform 401 Unauthorized.
|
||||
const d = mapAuthResultToResponse({
|
||||
sharedTokenOk: false,
|
||||
enabled: false,
|
||||
error: new UnauthorizedException('should not surface'),
|
||||
});
|
||||
expect(d).toEqual({
|
||||
kind: 'respond',
|
||||
status: 401,
|
||||
body: { error: 'Unauthorized' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
sharedTokenMatches,
|
||||
clientIp,
|
||||
bindAccessJwtVerifier,
|
||||
decideBasicGate,
|
||||
mapAuthResultToResponse,
|
||||
DocmostMcpConfig,
|
||||
ResolvedMcpAuth,
|
||||
} from './mcp-auth.helpers';
|
||||
@@ -241,49 +243,54 @@ export class McpService implements OnModuleDestroy {
|
||||
workspace: Workspace,
|
||||
creds: { email: string; password: string },
|
||||
): Promise<void> {
|
||||
// 1) SSO enforcement. validateSsoEnforcement throws BadRequestException; we
|
||||
// re-surface it as Unauthorized so the /mcp 401 path is consistent and a
|
||||
// token is never issued.
|
||||
// 1) SSO enforcement. validateSsoEnforcement throws when the workspace
|
||||
// enforces SSO; we only need the boolean verdict for the pure decision.
|
||||
let ssoEnforced = false;
|
||||
try {
|
||||
validateSsoEnforcement(workspace);
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
|
||||
);
|
||||
ssoEnforced = true;
|
||||
}
|
||||
|
||||
// 2) MFA gate — lazy-require the EE module exactly like AuthController.login.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let MfaModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
MfaModule = require('./../../ee/mfa/services/mfa.service');
|
||||
} catch {
|
||||
// No EE MFA module bundled in this build: same as the controller -> no
|
||||
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
|
||||
return;
|
||||
// On a fork WITHOUT the EE module bundled, mfaResult stays undefined and the
|
||||
// pure gate behaves exactly like the controller (no MFA module -> no MFA
|
||||
// gate). We only LOAD the module + read the requirement flags here; the
|
||||
// accept/reject decision lives in the framework-free decideBasicGate so the
|
||||
// SSO/MFA logic is unit-testable without ModuleRef or the on-disk EE module.
|
||||
let mfaResult: { userHasMfa?: boolean; requiresMfaSetup?: boolean } | undefined;
|
||||
// Only consult the MFA module when SSO has not already disqualified the
|
||||
// request (SSO short-circuits, and skipping the load avoids a needless
|
||||
// require on the SSO-reject path).
|
||||
if (!ssoEnforced) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let MfaModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
MfaModule = require('./../../ee/mfa/services/mfa.service');
|
||||
} catch {
|
||||
// No EE MFA module bundled in this build: same as the controller -> no
|
||||
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
|
||||
MfaModule = undefined;
|
||||
}
|
||||
|
||||
if (MfaModule) {
|
||||
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
|
||||
strict: false,
|
||||
});
|
||||
// Same requirement check the controller uses. We pass NO FastifyReply
|
||||
// (the controller passes `res` only to set a cookie on the no-MFA happy
|
||||
// path, which we never take here): we only read the requirement flags.
|
||||
mfaResult = await mfaService.checkMfaRequirements(
|
||||
creds,
|
||||
workspace,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
|
||||
strict: false,
|
||||
});
|
||||
// Use the same requirement check the controller uses. We pass NO FastifyReply
|
||||
// (the controller passes `res` only to set a cookie on the no-MFA happy path,
|
||||
// which we never take here): we only read the requirement flags. Be tolerant
|
||||
// of either a (loginInput, workspace) or (loginInput, workspace, res) shape.
|
||||
const mfaResult = await mfaService.checkMfaRequirements(
|
||||
creds,
|
||||
workspace,
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (mfaResult && (mfaResult.userHasMfa || mfaResult.requiresMfaSetup)) {
|
||||
throw new UnauthorizedException(
|
||||
'This account requires multi-factor authentication. MCP HTTP Basic ' +
|
||||
'cannot complete MFA — log in normally and use a Bearer access token ' +
|
||||
'instead.',
|
||||
);
|
||||
}
|
||||
// Pure accept/reject decision (throws UnauthorizedException on rejection).
|
||||
decideBasicGate({ ssoEnforced, mfa: mfaResult });
|
||||
}
|
||||
|
||||
// Lazily create the HTTP handler exactly once. The import is indirected so
|
||||
@@ -343,52 +350,61 @@ export class McpService implements OnModuleDestroy {
|
||||
// matching `X-MCP-Token` header. It now lives in its OWN header so it never
|
||||
// collides with `Authorization`, which carries the per-user credentials.
|
||||
const sharedToken = process.env.MCP_TOKEN;
|
||||
if (sharedToken) {
|
||||
const provided = req.headers['x-mcp-token'];
|
||||
if (!sharedTokenMatches(sharedToken, provided)) {
|
||||
res.status(401).send({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const sharedTokenOk = sharedToken
|
||||
? sharedTokenMatches(sharedToken, req.headers['x-mcp-token'])
|
||||
: true;
|
||||
|
||||
if (!(await this.isEnabled())) {
|
||||
res.status(403).send({ error: 'MCP is disabled for this workspace' });
|
||||
return;
|
||||
}
|
||||
// Short-circuit checks (shared token, enablement) that do not need the auth
|
||||
// resolution. Compute them up front so the response mapping is a single pure
|
||||
// decision (mapAuthResultToResponse) that cannot leak the password/header.
|
||||
const enabled = sharedTokenOk ? await this.isEnabled() : false;
|
||||
|
||||
// Resolve + validate the per-session identity BEFORE hijacking the response
|
||||
// so bad credentials surface as a clean 401 JSON (never a torn response and
|
||||
// never a generic "MCP error"). The resolved config/identity is stashed on
|
||||
// the raw request for the package's resolver + identify hook to read back.
|
||||
let resolved: ResolvedMcpAuth;
|
||||
try {
|
||||
resolved = await this.resolveSessionConfig(req);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) {
|
||||
// Warn once if the only thing missing is the service account, to keep
|
||||
// the original operator hint.
|
||||
if (
|
||||
!this.credsConfigured() &&
|
||||
!req.headers['authorization'] &&
|
||||
!this.warnedMissingCreds
|
||||
) {
|
||||
this.warnedMissingCreds = true;
|
||||
this.logger.warn(
|
||||
'MCP is enabled but received a request with no credentials and no ' +
|
||||
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
|
||||
);
|
||||
let resolved: ResolvedMcpAuth | undefined;
|
||||
let authError: unknown;
|
||||
if (sharedTokenOk && enabled) {
|
||||
try {
|
||||
resolved = await this.resolveSessionConfig(req);
|
||||
} catch (err) {
|
||||
authError = err;
|
||||
if (err instanceof UnauthorizedException) {
|
||||
// Warn once if the only thing missing is the service account, to keep
|
||||
// the original operator hint.
|
||||
if (
|
||||
!this.credsConfigured() &&
|
||||
!req.headers['authorization'] &&
|
||||
!this.warnedMissingCreds
|
||||
) {
|
||||
this.warnedMissingCreds = true;
|
||||
this.logger.warn(
|
||||
'MCP is enabled but received a request with no credentials and no ' +
|
||||
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.error('MCP auth resolution failed', err as Error);
|
||||
}
|
||||
res.status(401).send({ error: err.message });
|
||||
return;
|
||||
}
|
||||
this.logger.error('MCP auth resolution failed', err as Error);
|
||||
res.status(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
// Pure status/body mapping for the whole pre-hijack gauntlet.
|
||||
const decision = mapAuthResultToResponse({
|
||||
sharedTokenOk,
|
||||
enabled,
|
||||
error: authError,
|
||||
});
|
||||
if (decision.kind === 'respond') {
|
||||
res.status(decision.status).send(decision.body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stash the resolved auth on the raw request so the package's resolver +
|
||||
// identify hook (wired in getHandler) read it back instead of re-parsing.
|
||||
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] = resolved;
|
||||
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] =
|
||||
resolved as ResolvedMcpAuth;
|
||||
|
||||
// Hand the raw Node req/res to the MCP transport. hijack() tells Fastify
|
||||
// to stop managing this response so the transport can write to it directly.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PageWsListener } from './page-ws.listener';
|
||||
import { WsTreeService } from '../ws-tree.service';
|
||||
import {
|
||||
PageEvent,
|
||||
PageMovedEvent,
|
||||
TreeNodeSnapshot,
|
||||
} from '../../database/listeners/page.listener';
|
||||
|
||||
@@ -93,3 +94,139 @@ describe('PageWsListener.onPageCreated', () => {
|
||||
expect(wsTree.broadcastRefetchRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageWsListener delete/move/restore handlers', () => {
|
||||
let listener: PageWsListener;
|
||||
let wsTree: {
|
||||
broadcastPageCreated: jest.Mock;
|
||||
broadcastPageDeleted: jest.Mock;
|
||||
broadcastPageMoved: jest.Mock;
|
||||
broadcastRefetchRoot: jest.Mock;
|
||||
};
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
const secondSnapshot: TreeNodeSnapshot = {
|
||||
id: 'page-2',
|
||||
slugId: 'slug-2',
|
||||
title: 'World',
|
||||
icon: '📁',
|
||||
position: 'a2',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
wsTree = {
|
||||
broadcastPageCreated: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastPageDeleted: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastPageMoved: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastRefetchRoot: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PageWsListener,
|
||||
{ provide: WsTreeService, useValue: wsTree },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
listener = module.get<PageWsListener>(PageWsListener);
|
||||
// The PAGE_RESTORED-without-spaceId branch logs a warning; silence + assert.
|
||||
warnSpy = jest
|
||||
.spyOn(listener['logger'], 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
// --- onPageDeleted (PAGE_SOFT_DELETED / PAGE_DELETED) ---
|
||||
|
||||
it('onPageDeleted with N `pages`: one broadcastPageDeleted per page', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1', 'page-2'],
|
||||
workspaceId: 'ws-1',
|
||||
pages: [snapshot, secondSnapshot],
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenCalledTimes(2);
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenNthCalledWith(1, snapshot);
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
secondSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it('onPageDeleted with an EMPTY `pages` array: no broadcast', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
pages: [],
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onPageDeleted with UNDEFINED `pages`: no broadcast (no crash)', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- onPageMoved (PAGE_MOVED) ---
|
||||
|
||||
it('onPageMoved: forwards the whole event to a single broadcastPageMoved', async () => {
|
||||
const event: PageMovedEvent = {
|
||||
workspaceId: 'ws-1',
|
||||
oldParentId: 'old-parent',
|
||||
hasChildren: false,
|
||||
node: { ...snapshot, parentPageId: 'new-parent', position: 'a5' },
|
||||
};
|
||||
|
||||
await listener.onPageMoved(event);
|
||||
|
||||
expect(wsTree.broadcastPageMoved).toHaveBeenCalledTimes(1);
|
||||
expect(wsTree.broadcastPageMoved).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
// --- onPageRestored (PAGE_RESTORED) ---
|
||||
|
||||
it('onPageRestored WITHOUT spaceId: warns and does NOT refetch', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await listener.onPageRestored(event);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('PAGE_RESTORED'),
|
||||
);
|
||||
expect(wsTree.broadcastRefetchRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onPageRestored WITH spaceId: one broadcastRefetchRoot scoped to the space', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
spaceId: 'space-9',
|
||||
};
|
||||
|
||||
await listener.onPageRestored(event);
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledTimes(1);
|
||||
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledWith('space-9');
|
||||
});
|
||||
});
|
||||
|
||||
259
apps/server/src/ws/ws-service.spec.ts
Normal file
259
apps/server/src/ws/ws-service.spec.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { WsService } from './ws.service';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import {
|
||||
getSpaceRoomName,
|
||||
WS_SPACE_RESTRICTION_CACHE_PREFIX,
|
||||
WS_CACHE_TTL_MS,
|
||||
} from './ws.utils';
|
||||
|
||||
/**
|
||||
* WsService server-side unit tests (M7 item 2):
|
||||
* - spaceHasRestrictions cache lifecycle (miss -> read+set with TTL; hit ->
|
||||
* no re-read; documents the stale-false window).
|
||||
* - broadcastToAuthorizedUsers fan-out (authorized-only delivery, multi-socket
|
||||
* fan-out per user, sockets with no userId skipped).
|
||||
*
|
||||
* Both private methods are exercised through their public entry points:
|
||||
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via
|
||||
* emitToAuthorizedUsers. WsService is constructed with mocked cache + repo and a
|
||||
* mocked socket.io server, so no live infra is needed.
|
||||
*/
|
||||
|
||||
describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)', () => {
|
||||
let service: WsService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
|
||||
let roomEmit: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn(),
|
||||
hasRestrictedAncestor: jest.fn(),
|
||||
getUserIdsWithPageAccess: jest.fn(),
|
||||
};
|
||||
cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WsService>(WsService);
|
||||
|
||||
roomEmit = jest.fn();
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: roomEmit }),
|
||||
in: jest.fn().mockReturnValue({ fetchSockets: jest.fn() }),
|
||||
};
|
||||
service.setServer(server as never);
|
||||
});
|
||||
|
||||
const cacheKey = (spaceId: string): string =>
|
||||
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
|
||||
|
||||
it('first call MISSES the cache -> reads the repo and sets it with WS_CACHE_TTL_MS', async () => {
|
||||
cache.get.mockResolvedValue(null); // miss
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledTimes(1);
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
);
|
||||
// The freshly-read verdict is cached with the 30s TTL.
|
||||
expect(cache.set).toHaveBeenCalledWith(
|
||||
cacheKey('space-1'),
|
||||
true,
|
||||
WS_CACHE_TTL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it('second call HITS the cache -> the repo is NOT re-read', async () => {
|
||||
// Cache hit returns false (no restrictions) -> open-space fast path.
|
||||
cache.get.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
|
||||
// The whole point of the cache: no repo read on a hit.
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
// false verdict -> broadcast to the whole room (open-space fast path).
|
||||
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'x' });
|
||||
});
|
||||
|
||||
it('a cached `false` is returned even when restrictions now exist (the stale window)', async () => {
|
||||
// The cache says "no restrictions" (false) but the repo, if asked, would now
|
||||
// say true. spaceHasRestrictions trusts the cached false and never re-reads —
|
||||
// this documents the up-to-TTL stale window the production comment warns about
|
||||
// (a payload can fan out room-wide until the cache is invalidated/expires).
|
||||
cache.get.mockResolvedValue(false);
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'stale' });
|
||||
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
|
||||
// Treated as open -> the event is broadcast to the WHOLE room.
|
||||
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'stale' });
|
||||
});
|
||||
|
||||
it('caches a `false` verdict too (so the next emit hits, not re-reads)', async () => {
|
||||
cache.get.mockResolvedValueOnce(null); // first call: miss
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-2', 'page-9', { op: 'y' });
|
||||
|
||||
expect(cache.set).toHaveBeenCalledWith(
|
||||
cacheKey('space-2'),
|
||||
false,
|
||||
WS_CACHE_TTL_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUsers)', () => {
|
||||
let service: WsService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
|
||||
let fetchSockets: jest.Mock;
|
||||
let serverIn: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn(),
|
||||
hasRestrictedAncestor: jest.fn(),
|
||||
getUserIdsWithPageAccess: jest.fn(),
|
||||
};
|
||||
cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WsService>(WsService);
|
||||
|
||||
fetchSockets = jest.fn();
|
||||
serverIn = jest.fn().mockReturnValue({ fetchSockets });
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
||||
in: serverIn,
|
||||
};
|
||||
service.setServer(server as never);
|
||||
});
|
||||
|
||||
it('only sockets whose userId is in getUserIdsWithPageAccess receive the event', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
const noEmit = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
// The authorized set is resolved from the candidate userIds present on the
|
||||
// sockets (deduped), then only those users' sockets get the event.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
expect.arrayContaining(['user-ok', 'user-no']),
|
||||
);
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(noEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a user with TWO sockets receives the event on BOTH (userSocketMap fan-out)', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const tab1 = jest.fn();
|
||||
const tab2 = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: tab1 },
|
||||
{ id: 's2', data: { userId: 'user-ok' }, emit: tab2 },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
// Both of the authorized user's sockets (e.g. two browser tabs) receive it.
|
||||
expect(tab1).toHaveBeenCalledWith('message', data);
|
||||
expect(tab2).toHaveBeenCalledWith('message', data);
|
||||
// The candidate set is deduped to a single userId even with two sockets.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
['user-ok'],
|
||||
);
|
||||
});
|
||||
|
||||
it('a socket with NO userId is skipped (not a candidate, never emitted to)', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
const anonEmit = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
// Unauthenticated socket: no userId -> excluded from the candidate map.
|
||||
{ id: 's2', data: {}, emit: anonEmit },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(anonEmit).not.toHaveBeenCalled();
|
||||
// The no-userId socket is not even offered as a candidate to the repo.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
['user-ok'],
|
||||
);
|
||||
});
|
||||
|
||||
it('no sockets in the room -> no repo lookup, no emit', async () => {
|
||||
fetchSockets.mockResolvedValue([]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes through the space room name', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue([]);
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'u' }, emit: jest.fn() },
|
||||
]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-7', 'page-1', { op: 'x' });
|
||||
|
||||
expect(serverIn).toHaveBeenCalledWith(getSpaceRoomName('space-7'));
|
||||
});
|
||||
});
|
||||
@@ -329,3 +329,109 @@ describe('WsService.emitTreeEvent', () => {
|
||||
expect(anonEmit).toHaveBeenCalledWith('message', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('move-into-restricted disjointness contract (WsTreeService + real WsService)', () => {
|
||||
// CONTRACT: a move under a restricted ancestor PARTITIONS the room. The
|
||||
// authorized set (gets the moveTreeNode via emitToAuthorizedUsers) and its
|
||||
// complement (gets the deleteTreeNode via emitDeleteToUnauthorized) are
|
||||
// disjoint and together cover every socket — and an anonymous (no-userId)
|
||||
// socket lands in the delete set. We wire a REAL WsService (only its repo,
|
||||
// cache and socket server mocked) so both broadcasts run against the SAME fixed
|
||||
// socket set, the way they do in production.
|
||||
let treeService: WsTreeService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
|
||||
// Fixed room: two authorized users (one with two sockets), one unauthorized
|
||||
// user, one anonymous socket.
|
||||
const moveSeen: string[] = [];
|
||||
const deleteSeen: string[] = [];
|
||||
|
||||
const mkSocket = (id: string, userId: string | undefined) => ({
|
||||
id,
|
||||
data: userId ? { userId } : {},
|
||||
emit: jest.fn((_event: string, payload: { operation: string }) => {
|
||||
if (payload.operation === 'moveTreeNode') moveSeen.push(id);
|
||||
if (payload.operation === 'deleteTreeNode') deleteSeen.push(id);
|
||||
}),
|
||||
});
|
||||
|
||||
const sockets = [
|
||||
mkSocket('s-ok-1', 'user-ok'), // authorized, tab 1
|
||||
mkSocket('s-ok-2', 'user-ok'), // authorized, tab 2 (fan-out)
|
||||
mkSocket('s-no', 'user-no'), // unauthorized
|
||||
mkSocket('s-anon', undefined), // anonymous (no userId)
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
moveSeen.length = 0;
|
||||
deleteSeen.length = 0;
|
||||
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn().mockResolvedValue(true),
|
||||
// The move destination IS under a restricted ancestor.
|
||||
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
|
||||
// Only user-ok is authorized to see the page.
|
||||
getUserIdsWithPageAccess: jest.fn().mockResolvedValue(['user-ok']),
|
||||
};
|
||||
const cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsTreeService,
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const wsService = module.get<WsService>(WsService);
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
||||
in: jest.fn().mockReturnValue({
|
||||
fetchSockets: jest.fn().mockResolvedValue(sockets),
|
||||
}),
|
||||
};
|
||||
wsService.setServer(server as never);
|
||||
|
||||
treeService = module.get<WsTreeService>(WsTreeService);
|
||||
});
|
||||
|
||||
it('authorized set (move) and complement (delete) partition the room; anon is in delete', async () => {
|
||||
const event: PageMovedEvent = {
|
||||
workspaceId: 'ws-1',
|
||||
oldParentId: 'old-parent',
|
||||
hasChildren: false,
|
||||
node: { ...snapshot, parentPageId: 'restricted-parent', position: 'a5' },
|
||||
};
|
||||
|
||||
await treeService.broadcastPageMoved(event);
|
||||
|
||||
const moveSet = new Set(moveSeen);
|
||||
const deleteSet = new Set(deleteSeen);
|
||||
|
||||
// Authorized user's BOTH sockets got the move; nobody else did.
|
||||
expect(moveSet).toEqual(new Set(['s-ok-1', 's-ok-2']));
|
||||
// Everyone else (unauthorized + anonymous) got the delete.
|
||||
expect(deleteSet).toEqual(new Set(['s-no', 's-anon']));
|
||||
|
||||
// DISJOINT: no socket received both a move and a delete.
|
||||
const intersection = [...moveSet].filter((id) => deleteSet.has(id));
|
||||
expect(intersection).toEqual([]);
|
||||
|
||||
// PARTITION: the two sets together cover every socket in the room exactly.
|
||||
const union = new Set([...moveSet, ...deleteSet]);
|
||||
expect(union).toEqual(new Set(sockets.map((s) => s.id)));
|
||||
|
||||
// The anonymous socket specifically lands in the DELETE set, never the move.
|
||||
expect(deleteSet.has('s-anon')).toBe(true);
|
||||
expect(moveSet.has('s-anon')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
93
docs/backlog/feature-test-coverage-deferred.md
Normal file
93
docs/backlog/feature-test-coverage-deferred.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Отложенные тесты по фичам с коммита 053a9c0d (хвост от PR #49)
|
||||
|
||||
## Контекст
|
||||
|
||||
PR #49 («test: cover features since 053a9c0d + repair test tooling») закрыл
|
||||
основную массу покрытия новых фич gitmost (+~330 тестов: server/Jest,
|
||||
client/Vitest, editor-ext/Vitest, packages/mcp/node:test) и починил
|
||||
тест-инструментарий (FIX-0 сломанные спеки transclusion, BUILD-0 сборка
|
||||
editor-ext перед серверными тестами, INFRA-0 резолв `.tsx` email-шаблонов).
|
||||
|
||||
Часть тестов из принятого тест-плана **намеренно отложена** — им нужен
|
||||
тестовый Postgres, реальный Redis или HTTP/e2e-харнес, которых в проекте
|
||||
сейчас нет, либо инвазивный рефактор продакшн-кода. Ниже — что осталось и
|
||||
почему, чтобы не потерять.
|
||||
|
||||
---
|
||||
|
||||
## 1. Интеграционные тесты против БД (нужен тестовый Postgres)
|
||||
|
||||
Сейчас все repo-зависимые проверки делаются на моках; SQL-уровень не
|
||||
исполняется. Чтобы покрыть это честно, нужен поднимаемый в CI Postgres
|
||||
(testcontainers или сервис в pipeline) + хелпер миграций.
|
||||
|
||||
- **`AiAgentRoleRepo` — изоляция и индексы.**
|
||||
`apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts`.
|
||||
Проверить против реальной БД: `findById`/`listByWorkspace` исключают
|
||||
soft-deleted строки; `findById` для roleId из ЧУЖОГО workspace → undefined
|
||||
(tenant-изоляция); дубль имени в одном workspace → 23505; то же имя
|
||||
переиспользуемо после softDelete (partial unique index
|
||||
`WHERE deleted_at IS NULL`, миграция `20260620T120000-ai-agent-roles.ts`);
|
||||
одинаковое имя в разных workspace разрешено. Это «хребет» безопасности —
|
||||
сейчас только предполагается unit-моками.
|
||||
|
||||
- **`AiChatRepo.findByCreator` — join role-badge.**
|
||||
`apps/server/src/database/repos/ai-chat/ai-chat.repo.ts` (~:27-70).
|
||||
Чат с enabled-ролью → roleName/roleEmoji заполнены; с soft-deleted ролью →
|
||||
бейдж NULL; с DISABLED ролью → бейдж NULL (должно совпадать с
|
||||
`resolveRoleForRequest`); ORDER BY квалифицирован `aiChats.*` (нет
|
||||
ambiguous column после join). Не проверяемо чистым unit-ом.
|
||||
|
||||
- **`WorkspaceService.update` / `WorkspaceRepo.updateSetting` — jsonb-merge.**
|
||||
`apps/server/src/core/workspace/services/workspace.service.ts` (~:514),
|
||||
`apps/server/src/database/repos/workspace/workspace.repo.ts` (~:275).
|
||||
Сейчас покрыта только форма вызова сервиса
|
||||
(`workspace-html-embed.spec.ts`). Не покрыто (нужна БД): `htmlEmbed:true`
|
||||
персистится через jsonb-merge **не затирая** соседние настройки (ai,
|
||||
sharing). Это и есть «kill-switch пишется» — критично, что write-половина
|
||||
тоггла не ломает остальной settings-namespace.
|
||||
|
||||
- **FK `page_template_references` onDelete('cascade').**
|
||||
Миграция `20260620T131000-page-template-references.ts`. Проверить, что
|
||||
удаление source/reference-страницы каскадит строки ссылок.
|
||||
|
||||
## 2. HTTP / e2e-харнес (его нет в apps/server)
|
||||
|
||||
- **Public-share ассистент: обход per-IP throttle ротацией XFF, но
|
||||
per-workspace cap держит.**
|
||||
Контроллер использует стоковый `@UseGuards(ThrottlerGuard)`
|
||||
(`apps/server/src/core/ai-chat/public-share-chat.controller.ts`), IP берётся
|
||||
из Fastify `trustProxy` → `X-Forwarded-For`. Единственный оправданный e2e
|
||||
(named journey «аноним спамит ассистента»): ротация XFF обходит per-IP
|
||||
лимит 5/min, но per-workspace cost-cap всё равно отдаёт 429. Требует
|
||||
поднятого HTTP-слоя Nest + trusted-proxy конфигурации.
|
||||
|
||||
- **Достоверность Lua-окна cost-cap против реального Redis.**
|
||||
`apps/server/src/core/ai-chat/public-share-workspace-limiter.ts`
|
||||
(`SLIDING_WINDOW_LUA`). Сейчас cap тестируется против TS-реализации
|
||||
`FakeRedis` в `public-share-chat.spec.ts` — баг в самой Lua-строке
|
||||
(`>=` vs `>`, неверный PEXPIRE) не поймается. Нужен интеграционный тест
|
||||
против реального/testcontainers Redis.
|
||||
|
||||
## 3. Полная интеграция `AiChatService.stream` (рефактор R1-stream)
|
||||
|
||||
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
|
||||
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
|
||||
сценарии — **запись чата, упавшего на первом ходу** (onError), жизненный
|
||||
цикл external-MCP клиентов (закрытие при throw/onFinish), и
|
||||
**история восстанавливается из БД, а не из `body.messages`** (анти-tamper) —
|
||||
требуют сидирования SDK `streamText` (инъекция/seam колбэков `onError`/
|
||||
`onFinish`/`onAbort` + `res.hijack`). Отложено, чтобы не дестабилизировать
|
||||
287-строчный `stream()`; делать вместе с выносом testable turn-pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Сопутствующие НЕ-тестовые находки
|
||||
|
||||
Вынесены в отдельные issues (всплыли во время написания тестов):
|
||||
|
||||
- #52 — ai-roles: нет серверной валидации модели роли + дрейф enum драйверов.
|
||||
- #53 — ws: `invalidateSpaceRestrictionCache` без вызывающих (30с stale-окно).
|
||||
- #54 — page-embed: серверный guard глубины/циклов раскрытия.
|
||||
- #55 — transclusion: cycle-guard в `collectPageEmbedsFromPmJson`.
|
||||
- #56 — test-infra: jest DI + lib0 ESM (16 падающих сьютов).
|
||||
@@ -35,4 +35,5 @@ export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/page-break";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
export * from "./lib/footnote";
|
||||
|
||||
|
||||
72
packages/editor-ext/src/lib/footnote/footnote-definition.ts
Normal file
72
packages/editor-ext/src/lib/footnote/footnote-definition.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { FOOTNOTE_DEFINITION_NAME } from "./footnote-util";
|
||||
|
||||
export interface FootnoteDefinitionOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single footnote definition: an editable block (paragraphs only, no nested
|
||||
* footnotes) keyed by `id` to its reference. Lives only inside `footnotesList`.
|
||||
*/
|
||||
export const FootnoteDefinition = Node.create<FootnoteDefinitionOptions>({
|
||||
name: FOOTNOTE_DEFINITION_NAME,
|
||||
|
||||
// paragraph+ keeps definitions simple. Note this does NOT block nested
|
||||
// footnote references on its own: a footnoteReference is inline and the
|
||||
// paragraphs here accept inline content, so the schema would permit one.
|
||||
// Nested references are instead prevented by the setFootnote command and the
|
||||
// sync plugin (which refuse to create/keep a reference inside a definition).
|
||||
content: "paragraph+",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.id) return {};
|
||||
return { "data-id": attributes.id };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "div[data-footnote-def]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-footnote-def": "", class: "footnote-def" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
if (!this.options.view) return null;
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
140
packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts
Normal file
140
packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
|
||||
import { markdownToHtml } from "../markdown/utils/marked.utils";
|
||||
import { extractFootnoteDefinitions } from "../markdown/utils/footnote.marked";
|
||||
|
||||
// HTML the editor-ext nodes render (sup[data-footnote-ref], section/div).
|
||||
const HTML =
|
||||
`<p>Water<sup data-footnote-ref data-id="fn1"></sup> and clay<sup data-footnote-ref data-id="fn2"></sup>.</p>` +
|
||||
`<section data-footnotes>` +
|
||||
`<div data-footnote-def data-id="fn1"><p>First note.</p></div>` +
|
||||
`<div data-footnote-def data-id="fn2"><p>Second note.</p></div>` +
|
||||
`</section>`;
|
||||
|
||||
describe("footnote markdown round-trip", () => {
|
||||
it("HTML -> Markdown produces pandoc footnote syntax", () => {
|
||||
const md = htmlToMarkdown(HTML);
|
||||
expect(md).toContain("[^fn1]");
|
||||
expect(md).toContain("[^fn2]");
|
||||
expect(md).toContain("[^fn1]: First note.");
|
||||
expect(md).toContain("[^fn2]: Second note.");
|
||||
});
|
||||
|
||||
it("Markdown -> HTML rebuilds the footnote nodes' HTML", async () => {
|
||||
const md = htmlToMarkdown(HTML);
|
||||
const html = await markdownToHtml(md);
|
||||
expect(html).toContain('data-footnote-ref data-id="fn1"');
|
||||
expect(html).toContain('data-footnote-ref data-id="fn2"');
|
||||
expect(html).toContain("data-footnotes");
|
||||
expect(html).toContain('data-footnote-def data-id="fn1"');
|
||||
expect(html).toContain("First note.");
|
||||
expect(html).toContain("Second note.");
|
||||
});
|
||||
|
||||
it("preserves a [^id]: line shown inside a fenced code block (not a definition)", async () => {
|
||||
// A document that DOCUMENTS footnote syntax inside a code fence. The
|
||||
// `[^demo]: ...` line is example text, not a real definition, and must
|
||||
// survive the Markdown -> HTML conversion verbatim.
|
||||
const md = [
|
||||
"Here is how footnotes look:",
|
||||
"",
|
||||
"```markdown",
|
||||
"Some text[^demo]",
|
||||
"",
|
||||
"[^demo]: this is the definition",
|
||||
"```",
|
||||
"",
|
||||
"End of doc.",
|
||||
].join("\n");
|
||||
|
||||
const html = await markdownToHtml(md);
|
||||
// The example definition line is kept inside the rendered code block.
|
||||
expect(html).toContain("[^demo]: this is the definition");
|
||||
// It did NOT get pulled out into a real footnotes section.
|
||||
expect(html).not.toContain("data-footnotes");
|
||||
expect(html).not.toContain("data-footnote-def");
|
||||
});
|
||||
|
||||
it("extractFootnoteDefinitions de-duplicates colliding ids and rewrites markers", () => {
|
||||
// Two definitions share id `d`, and the body has two `[^d]` markers. The
|
||||
// output must keep BOTH definitions with DISTINCT ids and rewrite the second
|
||||
// marker so the (reference, definition) pairing stays 1:1.
|
||||
const md = [
|
||||
"See here[^d] and there[^d].",
|
||||
"",
|
||||
"[^d]: first",
|
||||
"[^d]: second",
|
||||
].join("\n");
|
||||
|
||||
const { body, section } = extractFootnoteDefinitions(md);
|
||||
|
||||
// Pull out the def ids from the section in order.
|
||||
const defIds = Array.from(
|
||||
section.matchAll(/data-footnote-def data-id="([^"]+)"/g),
|
||||
).map((m) => m[1]);
|
||||
expect(defIds.length).toBe(2);
|
||||
expect(new Set(defIds).size).toBe(2); // distinct
|
||||
expect(defIds[0]).toBe("d"); // first definition keeps the id
|
||||
|
||||
// Both definition texts survive.
|
||||
expect(section).toContain("first");
|
||||
expect(section).toContain("second");
|
||||
|
||||
// The body still has two markers, now pointing at the two distinct ids.
|
||||
const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map(
|
||||
(m) => m[1],
|
||||
);
|
||||
expect(refIds.length).toBe(2);
|
||||
expect(refIds.sort()).toEqual(defIds.sort());
|
||||
});
|
||||
|
||||
it("extractFootnoteDefinitions dedups DETERMINISTICALLY (same input -> same ids)", () => {
|
||||
// The derived id must be a pure function of the input markdown so importing
|
||||
// the same source twice (or via the editor and the MCP mirror) yields
|
||||
// identical ids — never random/time-based.
|
||||
const md = [
|
||||
"See[^d] one[^d] two[^d].",
|
||||
"",
|
||||
"[^d]: first",
|
||||
"[^d]: second",
|
||||
"[^d]: third",
|
||||
].join("\n");
|
||||
|
||||
const run = () => {
|
||||
const { body, section } = extractFootnoteDefinitions(md);
|
||||
const defIds = Array.from(
|
||||
section.matchAll(/data-footnote-def data-id="([^"]+)"/g),
|
||||
).map((m) => m[1]);
|
||||
const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map(
|
||||
(m) => m[1],
|
||||
);
|
||||
return { defIds, refIds };
|
||||
};
|
||||
|
||||
const a = run();
|
||||
const b = run();
|
||||
// Identical across runs (this is what would FAIL on the random-id version).
|
||||
expect(a.defIds).toEqual(b.defIds);
|
||||
expect(a.refIds).toEqual(b.refIds);
|
||||
// Deterministic derived scheme: keeper "d", duplicates "d__2", "d__3".
|
||||
expect(a.defIds).toEqual(["d", "d__2", "d__3"]);
|
||||
expect(a.refIds.sort()).toEqual(a.defIds.sort());
|
||||
});
|
||||
|
||||
it("markdownToHtml with duplicate ids renders two distinct footnote defs", async () => {
|
||||
const md = [
|
||||
"See here[^d] and there[^d].",
|
||||
"",
|
||||
"[^d]: first",
|
||||
"[^d]: second",
|
||||
].join("\n");
|
||||
const html = await markdownToHtml(md);
|
||||
const defIds = Array.from(
|
||||
html.matchAll(/data-footnote-def data-id="([^"]+)"/g),
|
||||
).map((m) => m[1]);
|
||||
expect(defIds.length).toBe(2);
|
||||
expect(new Set(defIds).size).toBe(2);
|
||||
expect(html).toContain("first");
|
||||
expect(html).toContain("second");
|
||||
});
|
||||
});
|
||||
119
packages/editor-ext/src/lib/footnote/footnote-numbering.ts
Normal file
119
packages/editor-ext/src/lib/footnote/footnote-numbering.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
computeFootnoteNumbers,
|
||||
} from "./footnote-util";
|
||||
|
||||
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
|
||||
"footnoteNumbering",
|
||||
);
|
||||
|
||||
/**
|
||||
* Cached state of the numbering plugin. Both the displayed-number map and the
|
||||
* decoration set are computed ONCE per doc-changing transaction (in `apply`) and
|
||||
* cached here, so NodeViews can read a footnote's number by id without walking
|
||||
* the whole document on every React render (which was O(n^2) per keystroke in
|
||||
* large docs).
|
||||
*/
|
||||
interface FootnoteNumberingState {
|
||||
/** referenceId -> 1-based display number, for the current doc. */
|
||||
numbers: Map<string, number>;
|
||||
/** Decorations rendering those numbers (refs + definitions). */
|
||||
decorations: DecorationSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the decoration set for footnote numbers. Pure function of the document:
|
||||
* walk references in document order, assign 1-based numbers, then attach a
|
||||
* node decoration (carrying the number via a CSS variable + data attribute) to
|
||||
* every reference and to every matching definition. Because it is deterministic
|
||||
* from the document alone, all collaborating clients compute identical numbers
|
||||
* with no document mutation.
|
||||
*/
|
||||
export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet {
|
||||
return buildFootnoteNumberingState(doc).decorations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute both the number map AND the decorations for `doc` in a single walk.
|
||||
* The plugin caches the result so NodeViews can read numbers without
|
||||
* recomputing.
|
||||
*/
|
||||
function buildFootnoteNumberingState(
|
||||
doc: ProseMirrorNode,
|
||||
): FootnoteNumberingState {
|
||||
const numbers = computeFootnoteNumbers(doc);
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
|
||||
const num = numbers.get(node.attrs.id);
|
||||
if (num != null) {
|
||||
decorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
"data-footnote-number": String(num),
|
||||
style: `--footnote-number: "${num}";`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
||||
const num = numbers.get(node.attrs.id);
|
||||
if (num != null) {
|
||||
decorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
"data-footnote-number": String(num),
|
||||
style: `--footnote-number: "${num}";`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { numbers, decorations: DecorationSet.create(doc, decorations) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached footnote number for `id` from the numbering plugin's state.
|
||||
* This is the source NodeViews should use instead of calling
|
||||
* computeFootnoteNumbers() on every render (that walked the whole doc per
|
||||
* NodeView per render = O(n^2) per keystroke). Returns undefined if the plugin
|
||||
* is not installed or the id has no number yet.
|
||||
*/
|
||||
export function getFootnoteNumber(
|
||||
state: EditorState,
|
||||
id: string,
|
||||
): number | undefined {
|
||||
return footnoteNumberingPluginKey.getState(state)?.numbers.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProseMirror plugin that renders footnote numbers as decorations. It never
|
||||
* mutates the document (safe in read-only / share and in collaboration) — it
|
||||
* only recomputes decorations from the current doc on each transaction.
|
||||
*/
|
||||
export function footnoteNumberingPlugin(): Plugin {
|
||||
return new Plugin({
|
||||
key: footnoteNumberingPluginKey,
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return buildFootnoteNumberingState(doc);
|
||||
},
|
||||
apply(tr, old) {
|
||||
// Recompute (and re-cache) only when the document actually changed, so
|
||||
// the number map NodeViews read stays current on every edit while
|
||||
// non-doc transactions (selection, etc.) reuse the cache for free.
|
||||
if (!tr.docChanged) return old;
|
||||
return buildFootnoteNumberingState(tr.doc);
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return footnoteNumberingPluginKey.getState(state)?.decorations;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
331
packages/editor-ext/src/lib/footnote/footnote-reference.ts
Normal file
331
packages/editor-ext/src/lib/footnote/footnote-reference.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { TextSelection, Transaction } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
generateFootnoteId,
|
||||
} from "./footnote-util";
|
||||
import { footnoteNumberingPlugin } from "./footnote-numbering";
|
||||
import { footnoteSyncPlugin, footnotePastePlugin } from "./footnote-sync";
|
||||
|
||||
export interface FootnoteReferenceOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
/**
|
||||
* Optional predicate identifying remote/collaboration transactions so the
|
||||
* sync plugin skips them (orphan cleanup must run only on local changes).
|
||||
*/
|
||||
isRemoteTransaction?: (tr: Transaction) => boolean;
|
||||
/**
|
||||
* When false, the footnote sync/integrity plugin is fully disabled — it never
|
||||
* appends a transaction. Numbering decorations stay active. Set this in
|
||||
* read-only / share editors so a viewer's doc is decorated (numbered) but
|
||||
* never mutated (e.g. by a programmatic setContent). Defaults to true.
|
||||
*/
|
||||
enableSync?: boolean;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
footnote: {
|
||||
/**
|
||||
* Insert a footnote reference at the cursor and create the matching
|
||||
* (empty) definition in the bottom footnotes list, in one transaction.
|
||||
*/
|
||||
setFootnote: () => ReturnType;
|
||||
/**
|
||||
* Remove a footnote reference and cascade-delete its definition (one
|
||||
* transaction so a single undo restores both).
|
||||
*/
|
||||
removeFootnote: (id: string) => ReturnType;
|
||||
/** Scroll to (and focus) a footnote definition by id. */
|
||||
scrollToFootnote: (id: string) => ReturnType;
|
||||
/** Scroll to (and select) a footnote reference by id. */
|
||||
scrollToReference: (id: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline atom that marks a footnote reference in the body text. It holds only
|
||||
* an `id` linking it to its `footnoteDefinition`; the visible number is NOT
|
||||
* stored — it is rendered by the numbering plugin as a decoration (see
|
||||
* footnote-numbering.ts). Modeled on mention.ts (inline atom).
|
||||
*
|
||||
* The reference is forbidden inside code blocks and inside footnote definitions
|
||||
* (no nested footnotes); those restrictions are enforced by the `setFootnote`
|
||||
* command and the sync plugin rather than by schema content expressions, since
|
||||
* an inline group node cannot express "not inside X" declaratively.
|
||||
*/
|
||||
export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
|
||||
name: FOOTNOTE_REFERENCE_NAME,
|
||||
|
||||
// Higher than the default (100) so its parse rule is considered before the
|
||||
// Superscript mark's <sup> rule.
|
||||
priority: 101,
|
||||
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
isRemoteTransaction: undefined,
|
||||
enableSync: true,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const plugins = [footnoteNumberingPlugin()];
|
||||
// Numbering always runs (decoration-only). The sync/integrity plugin is
|
||||
// skipped entirely when sync is disabled (read-only / share) so the viewer's
|
||||
// doc is never mutated.
|
||||
if (this.options.enableSync !== false) {
|
||||
plugins.push(footnoteSyncPlugin(this.options.isRemoteTransaction));
|
||||
// Regenerate colliding footnote ids on paste so a pasted reference+
|
||||
// definition pair never clobbers/merges with an existing footnote.
|
||||
plugins.push(footnotePastePlugin());
|
||||
}
|
||||
return plugins;
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.id) return {};
|
||||
return { "data-id": attributes.id };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
// High priority so the Superscript mark (which also matches <sup>) does
|
||||
// not claim a footnote reference and drop it as empty content.
|
||||
tag: "sup[data-footnote-ref]",
|
||||
priority: 100,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"sup",
|
||||
mergeAttributes(
|
||||
{ "data-footnote-ref": "", class: "footnote-ref" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
// Plain-text representation (used by generateText / markdown text fallbacks).
|
||||
renderText({ node }) {
|
||||
return `[^${node.attrs.id ?? ""}]`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
if (!this.options.view) return null;
|
||||
// Force the react node view to render immediately using flush sync.
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setFootnote:
|
||||
() =>
|
||||
({ state, tr, dispatch, editor }) => {
|
||||
const { schema, selection } = state;
|
||||
const refType = schema.nodes[FOOTNOTE_REFERENCE_NAME];
|
||||
const listType = schema.nodes[FOOTNOTES_LIST_NAME];
|
||||
const defType = schema.nodes[FOOTNOTE_DEFINITION_NAME];
|
||||
if (!refType || !listType || !defType) return false;
|
||||
|
||||
const { $from } = selection;
|
||||
|
||||
// Forbid references inside code blocks and inside footnote definitions
|
||||
// (no nested footnotes).
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (
|
||||
node.type.spec.code ||
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME ||
|
||||
node.type.name === FOOTNOTES_LIST_NAME
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the parent accepts an inline atom here.
|
||||
const insertPos = selection.from;
|
||||
if (!$from.parent.type.spec.content?.includes("inline") &&
|
||||
!$from.parent.isTextblock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const id = generateFootnoteId();
|
||||
|
||||
// 1) Count references that occur strictly before the insertion point;
|
||||
// the new definition goes at that index in the bottom list so the
|
||||
// list order matches reference order.
|
||||
let refsBefore = 0;
|
||||
state.doc.nodesBetween(0, insertPos, (node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) refsBefore++;
|
||||
});
|
||||
|
||||
// 2) Insert the reference at the cursor.
|
||||
tr.insert(insertPos, refType.create({ id }));
|
||||
|
||||
// 3) Locate (or create) the footnotes list, then insert the new
|
||||
// definition at index `refsBefore`.
|
||||
const emptyParagraph = schema.nodes.paragraph.create();
|
||||
const definition = defType.create({ id }, emptyParagraph);
|
||||
|
||||
// Find existing list (always the last top-level child if present).
|
||||
let listPos: number | null = null;
|
||||
let listNode: any = null;
|
||||
tr.doc.forEach((child, offset) => {
|
||||
if (child.type.name === FOOTNOTES_LIST_NAME) {
|
||||
listPos = offset;
|
||||
listNode = child;
|
||||
}
|
||||
});
|
||||
|
||||
let defInsidePos: number | null = null;
|
||||
if (listNode == null) {
|
||||
// Create a new list at the very end of the document.
|
||||
const list = listType.create(null, definition);
|
||||
const end = tr.doc.content.size;
|
||||
tr.insert(end, list);
|
||||
// Cursor target: inside the new definition's first paragraph.
|
||||
// end -> list open, +1 definition open, +1 paragraph open.
|
||||
defInsidePos = end + 3;
|
||||
} else {
|
||||
// Insert at the right index within the existing list.
|
||||
const listStart = listPos! + 1; // position of the first definition
|
||||
let pos = listStart;
|
||||
let index = 0;
|
||||
listNode.forEach((defChild: any, defOffset: number) => {
|
||||
if (index < refsBefore) {
|
||||
pos = listStart + defOffset + defChild.nodeSize;
|
||||
index++;
|
||||
}
|
||||
});
|
||||
tr.insert(pos, definition);
|
||||
defInsidePos = pos + 2; // +1 enter definition, +1 enter paragraph
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
// Move the cursor into the new definition's paragraph so the user
|
||||
// can immediately type the footnote text.
|
||||
try {
|
||||
const resolved = tr.doc.resolve(
|
||||
Math.min(defInsidePos!, tr.doc.content.size),
|
||||
);
|
||||
tr.setSelection(TextSelection.near(resolved));
|
||||
} catch {
|
||||
// Selection placement is best-effort; ignore failures.
|
||||
}
|
||||
tr.scrollIntoView();
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
removeFootnote:
|
||||
(id: string) =>
|
||||
({ state, tr, dispatch }) => {
|
||||
if (!id) return false;
|
||||
|
||||
// Collect: reference range(s), the definition range, and the list.
|
||||
const refRanges: Array<{ from: number; to: number }> = [];
|
||||
let defRange: { from: number; to: number } | null = null;
|
||||
let listInfo: { pos: number; size: number; count: number } | null =
|
||||
null;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_REFERENCE_NAME &&
|
||||
node.attrs.id === id
|
||||
) {
|
||||
refRanges.push({ from: pos, to: pos + node.nodeSize });
|
||||
}
|
||||
if (
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
||||
node.attrs.id === id
|
||||
) {
|
||||
defRange = { from: pos, to: pos + node.nodeSize };
|
||||
}
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) {
|
||||
listInfo = {
|
||||
pos,
|
||||
size: node.nodeSize,
|
||||
count: node.childCount,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (refRanges.length === 0 && !defRange) return false;
|
||||
|
||||
// Build the list of ranges to delete. If removing this definition
|
||||
// would empty the list (it is the list's only child), delete the
|
||||
// entire list instead — an empty footnotesList is invalid schema and
|
||||
// a leftover empty list would be ugly.
|
||||
const ranges: Array<{ from: number; to: number }> = [...refRanges];
|
||||
if (defRange) {
|
||||
if (listInfo && (listInfo as any).count <= 1) {
|
||||
const li = listInfo as { pos: number; size: number };
|
||||
ranges.push({ from: li.pos, to: li.pos + li.size });
|
||||
} else {
|
||||
ranges.push(defRange);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from the end so earlier positions stay valid.
|
||||
ranges
|
||||
.sort((a, b) => b.from - a.from)
|
||||
.forEach(({ from, to }) => tr.delete(from, to));
|
||||
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
scrollToFootnote:
|
||||
(id: string) =>
|
||||
({ editor }) => {
|
||||
if (!id) return false;
|
||||
const dom = editor.view.dom.querySelector(
|
||||
`[data-footnote-def][data-id="${id}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (!dom) return false;
|
||||
dom.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return true;
|
||||
},
|
||||
|
||||
scrollToReference:
|
||||
(id: string) =>
|
||||
({ editor }) => {
|
||||
if (!id) return false;
|
||||
const dom = editor.view.dom.querySelector(
|
||||
`sup[data-footnote-ref][data-id="${id}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (!dom) return false;
|
||||
dom.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
634
packages/editor-ext/src/lib/footnote/footnote-sync.ts
Normal file
634
packages/editor-ext/src/lib/footnote/footnote-sync.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
import { Node as ProseMirrorNode, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
deriveFootnoteId,
|
||||
} from "./footnote-util";
|
||||
|
||||
export const footnoteSyncPluginKey = new PluginKey("footnoteSync");
|
||||
|
||||
const SYNC_META = "footnoteSyncApplied";
|
||||
|
||||
interface RefOccurrence {
|
||||
/** Position of the reference node in the document. */
|
||||
pos: number;
|
||||
/** The id the reference currently carries. */
|
||||
id: string;
|
||||
node: ProseMirrorNode;
|
||||
}
|
||||
|
||||
interface DefOccurrence {
|
||||
/** Position of the definition node in the document. */
|
||||
pos: number;
|
||||
/** The id the definition currently carries. */
|
||||
id: string;
|
||||
node: ProseMirrorNode;
|
||||
}
|
||||
|
||||
interface FootnoteScan {
|
||||
/**
|
||||
* Every reference occurrence in document order (NOT de-duplicated). Needed so
|
||||
* that duplicate ids — which would otherwise be silently collapsed — can be
|
||||
* detected and (together with their definitions) re-id'd instead of dropped.
|
||||
*/
|
||||
refOccurrences: RefOccurrence[];
|
||||
/**
|
||||
* Every definition occurrence in document order (NOT de-duplicated). The old
|
||||
* implementation used a last-wins Map here, which is exactly what caused
|
||||
* silent data loss: two definitions sharing an id collapsed to one.
|
||||
*/
|
||||
defOccurrences: DefOccurrence[];
|
||||
/** Every top-level footnotesList node, in document order. */
|
||||
lists: Array<{ pos: number; node: ProseMirrorNode }>;
|
||||
}
|
||||
|
||||
function scan(doc: ProseMirrorNode): FootnoteScan {
|
||||
const refOccurrences: RefOccurrence[] = [];
|
||||
const defOccurrences: DefOccurrence[] = [];
|
||||
const lists: Array<{ pos: number; node: ProseMirrorNode }> = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
|
||||
const id = node.attrs.id;
|
||||
if (id) refOccurrences.push({ pos, id, node });
|
||||
}
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
||||
const id = node.attrs.id;
|
||||
if (id) defOccurrences.push({ pos, id, node });
|
||||
}
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) {
|
||||
lists.push({ pos, node });
|
||||
}
|
||||
});
|
||||
|
||||
return { refOccurrences, defOccurrences, lists };
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving id collisions: a 1:1, de-duplicated pairing plan plus the
|
||||
* concrete reference re-id edits that must be applied to the body so the doc no
|
||||
* longer contains two footnotes sharing a single id.
|
||||
*
|
||||
* The overriding invariant is that NO definition is ever dropped here: every
|
||||
* definition occurrence ends up with a unique id and therefore survives the
|
||||
* canonical rebuild. Duplicate references are likewise re-id'd (and paired with
|
||||
* a duplicate definition when one exists) so importing/pasting `[^d]` twice with
|
||||
* two `[^d]:` definitions yields TWO distinct footnotes rather than one.
|
||||
*/
|
||||
interface CollisionPlan {
|
||||
/**
|
||||
* Reference ids in document order, de-duplicated AFTER re-id. This is the
|
||||
* source of truth for definition order/numbering, exactly as before — only
|
||||
* now collisions have been resolved so it no longer hides duplicates.
|
||||
*/
|
||||
referenceIds: string[];
|
||||
/** id -> definition node, after duplicates were re-id'd. One entry per id. */
|
||||
definitions: Map<string, ProseMirrorNode>;
|
||||
/**
|
||||
* Body reference re-id edits to apply (position of a reference node -> the
|
||||
* fresh id it must carry). Empty when there are no colliding references.
|
||||
*/
|
||||
refReids: Array<{ pos: number; node: ProseMirrorNode; newId: string }>;
|
||||
/** True when any collision required a re-id (refs and/or defs). */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve duplicate-id collisions among references and definitions WITHOUT ever
|
||||
* dropping a definition.
|
||||
*
|
||||
* Strategy:
|
||||
* - Walk references in document order. The FIRST reference for an id keeps it.
|
||||
* Any later reference sharing that id is a duplicate and gets a fresh unique
|
||||
* id; if a still-unclaimed duplicate definition with the original id exists,
|
||||
* it is re-id'd to the SAME fresh id so the (ref, def) pair stays matched.
|
||||
* - Walk definitions in document order. The FIRST definition for an id keeps
|
||||
* it; later duplicates that were not already claimed by a duplicate reference
|
||||
* get their own fresh unique id (surviving as a distinct footnote/orphan).
|
||||
*
|
||||
* Re-id determinism: every fresh id is DERIVED from document state via
|
||||
* deriveFootnoteId (e.g. `X__2`, `X__3`, collision-bumped against the set of ids
|
||||
* already present) — NEVER random/time-based. Because the sync plugin runs
|
||||
* identically on every collaborating client, a deterministic re-id is the only
|
||||
* way they can converge on the SAME ids; a random id (the previous
|
||||
* implementation) made two clients editing the same duplicate-id document mint
|
||||
* DIFFERENT ids for the same duplicate, causing permanent Yjs divergence.
|
||||
*/
|
||||
function resolveCollisions(scan: FootnoteScan): CollisionPlan {
|
||||
const definitions = new Map<string, ProseMirrorNode>();
|
||||
const refReids: Array<{
|
||||
pos: number;
|
||||
node: ProseMirrorNode;
|
||||
newId: string;
|
||||
}> = [];
|
||||
const referenceIds: string[] = [];
|
||||
const seenRefIds = new Set<string>();
|
||||
let changed = false;
|
||||
|
||||
// `taken` is the set of every id that must be avoided when minting a derived
|
||||
// id: all original reference + definition ids in the document PLUS every id we
|
||||
// mint during this pass. It is pure document state, so the derivation stays
|
||||
// deterministic across clients. Per-original occurrence counters make the k-th
|
||||
// duplicate of `X` deterministically become `X__2`, `X__3`, ...
|
||||
const taken = new Set<string>();
|
||||
for (const occ of scan.refOccurrences) taken.add(occ.id);
|
||||
for (const occ of scan.defOccurrences) taken.add(occ.id);
|
||||
const occurrenceOf = new Map<string, number>();
|
||||
// Mint a deterministic unique id for a duplicate of `originalId`. The first
|
||||
// duplicate is occurrence 2 (the keeper is occurrence 1), then 3, 4, ...
|
||||
const mintId = (originalId: string): string => {
|
||||
const next = (occurrenceOf.get(originalId) ?? 1) + 1;
|
||||
occurrenceOf.set(originalId, next);
|
||||
const id = deriveFootnoteId(originalId, next, taken);
|
||||
taken.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
// Bucket definition occurrences by their original id so a duplicate reference
|
||||
// can claim a matching (as-yet-unclaimed) duplicate definition and re-id the
|
||||
// pair together. defByOriginalId[id] is consumed front-to-back.
|
||||
const defByOriginalId = new Map<string, DefOccurrence[]>();
|
||||
for (const occ of scan.defOccurrences) {
|
||||
const arr = defByOriginalId.get(occ.id);
|
||||
if (arr) arr.push(occ);
|
||||
else defByOriginalId.set(occ.id, [occ]);
|
||||
}
|
||||
// The FIRST definition for each id is the canonical keeper of that id.
|
||||
const claimed = new Set<DefOccurrence>();
|
||||
|
||||
for (const ref of scan.refOccurrences) {
|
||||
if (!seenRefIds.has(ref.id)) {
|
||||
// First reference with this id keeps it.
|
||||
seenRefIds.add(ref.id);
|
||||
referenceIds.push(ref.id);
|
||||
continue;
|
||||
}
|
||||
// Duplicate reference: assign a deterministic derived id. Pair it with the
|
||||
// next unclaimed duplicate definition (NOT the first keeper) carrying the
|
||||
// same original id, if one exists, so the (ref, def) pairing is preserved
|
||||
// 1:1.
|
||||
const newId = mintId(ref.id);
|
||||
refReids.push({ pos: ref.pos, node: ref.node, newId });
|
||||
seenRefIds.add(newId);
|
||||
referenceIds.push(newId);
|
||||
changed = true;
|
||||
|
||||
const candidates = defByOriginalId.get(ref.id) ?? [];
|
||||
// Skip the first occurrence (it keeps the original id); pick the first
|
||||
// duplicate not already claimed.
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
const cand = candidates[i];
|
||||
if (!claimed.has(cand)) {
|
||||
claimed.add(cand);
|
||||
definitions.set(newId, cand.node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now place every definition under a unique id. The first occurrence of each
|
||||
// original id keeps it; remaining duplicates either were paired with a
|
||||
// duplicate reference above (already placed) or get a fresh standalone id.
|
||||
const seenDefIds = new Set<string>();
|
||||
for (const occ of scan.defOccurrences) {
|
||||
if (claimed.has(occ)) continue; // already placed against a duplicate ref id
|
||||
if (!seenDefIds.has(occ.id)) {
|
||||
seenDefIds.add(occ.id);
|
||||
definitions.set(occ.id, occ.node);
|
||||
} else {
|
||||
// Duplicate definition with no duplicate reference to pair with: keep it
|
||||
// with a deterministic derived id so it is NEVER silently dropped. (It
|
||||
// becomes an orphan and is then subject to the normal orphan policy — but
|
||||
// only ever because it has no matching reference, never because it
|
||||
// collided.)
|
||||
const newId = mintId(occ.id);
|
||||
definitions.set(newId, occ.node);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { referenceIds, definitions, refReids, changed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent integrity pass for footnotes. Runs only on LOCAL document changes
|
||||
* (skips remote/collaboration steps and — crucially — its own appended meta) so
|
||||
* the plugin can never re-trigger itself, guaranteeing termination.
|
||||
*
|
||||
* Everything is computed against the CURRENT document in a SINGLE invocation and
|
||||
* emitted as AT MOST ONE transaction, always tagged with SYNC_META (and
|
||||
* addToHistory:false). The strategy is "rebuild the canonical footnotes section
|
||||
* from the desired end-state" rather than running several self-triggering
|
||||
* passes:
|
||||
*
|
||||
* 1. Collect every footnote reference id in document order (the source of
|
||||
* truth for which definitions must exist and in what order).
|
||||
* 2. Compute the desired list of definitions: one per referenced id, in
|
||||
* reference order, reusing the existing definition node when present or
|
||||
* creating an empty one when missing. Orphan definitions (no matching
|
||||
* reference) are dropped.
|
||||
* 3. Compare against the actual footnotesList state:
|
||||
* - no references -> there must be NO list (remove any);
|
||||
* - references present -> there must be exactly ONE list, holding
|
||||
* exactly the desired definitions, and it
|
||||
* must sit after all real body content.
|
||||
* 4. If the document already matches the desired end-state, return null (no
|
||||
* transaction) — this idempotence is what stops oscillation.
|
||||
*
|
||||
* Placement note: the list is considered correctly placed when nothing but
|
||||
* EMPTY paragraphs follow it. This is deliberate so the plugin coexists with a
|
||||
* trailing-node plugin (which keeps an empty paragraph at the very end of the
|
||||
* doc): the footnote list does not need to be the literal last child, only the
|
||||
* last block of meaningful content. Without this, the two plugins would
|
||||
* ping-pong forever (list moved to end -> trailing paragraph appended -> list
|
||||
* no longer last -> moved again ...).
|
||||
*
|
||||
* Duplicate-id collisions (two references and/or two definitions sharing one
|
||||
* id — produced by importing `[^d]: a` / `[^d]: b`, or by pasting/duplicating a
|
||||
* reference+definition pair) are resolved up front by resolveCollisions(): the
|
||||
* duplicates are re-id'd to fresh unique ids so BOTH survive as distinct
|
||||
* footnotes. This guarantees the overriding invariant — no footnoteDefinition is
|
||||
* ever silently deleted by this automatic (addToHistory:false) transaction. A
|
||||
* definition is only ever removed when it has NO matching reference (orphan
|
||||
* policy), never because its id collided with another.
|
||||
*/
|
||||
export function footnoteSyncPlugin(
|
||||
isRemoteTransaction?: (tr: Transaction) => boolean,
|
||||
): Plugin {
|
||||
return new Plugin({
|
||||
key: footnoteSyncPluginKey,
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
// Only react to document changes.
|
||||
if (!transactions.some((t) => t.docChanged)) return null;
|
||||
// Skip our OWN appended transaction. This is the guard that makes the
|
||||
// plugin loop-safe: the transaction we emit carries SYNC_META, so when
|
||||
// ProseMirror feeds it back to appendTransaction we bail out immediately
|
||||
// and never produce a follow-up. (Termination invariant.)
|
||||
if (transactions.some((t) => t.getMeta(SYNC_META))) return null;
|
||||
// Skip remote/collab steps (orphan cleanup must run only on local edits).
|
||||
if (
|
||||
isRemoteTransaction &&
|
||||
transactions.some((t) => isRemoteTransaction(t))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { doc, schema } = newState;
|
||||
const defType = schema.nodes[FOOTNOTE_DEFINITION_NAME];
|
||||
const listType = schema.nodes[FOOTNOTES_LIST_NAME];
|
||||
const paragraphType = schema.nodes.paragraph;
|
||||
if (!defType || !listType || !paragraphType) return null;
|
||||
|
||||
const info = scan(doc);
|
||||
|
||||
// 0) Resolve duplicate-id collisions (two references and/or two
|
||||
// definitions sharing one id) by re-id'ing duplicates to fresh unique
|
||||
// ids. This is the critical defense: the old last-wins Map silently
|
||||
// dropped all but the last definition for a shared id; here EVERY
|
||||
// definition survives with a unique id, and duplicate references are
|
||||
// paired with duplicate definitions so two same-id imports/pastes yield
|
||||
// two distinct footnotes instead of one.
|
||||
const plan = resolveCollisions(info);
|
||||
const referenceIds = plan.referenceIds;
|
||||
|
||||
// The set of ids that must have a definition, in reference order (after
|
||||
// collision re-id). De-duplicated already by resolveCollisions.
|
||||
const referenceIdSet = new Set(referenceIds);
|
||||
|
||||
// 1) For each definition occurrence, compute the id it should END UP with
|
||||
// (which differs from its current id only when collision resolution
|
||||
// re-id'd it). plan.definitions maps a FINAL id -> the chosen node, so
|
||||
// we invert it by node identity to recover each occurrence's target id.
|
||||
const finalIdByNode = new Map<ProseMirrorNode, string>();
|
||||
for (const [id, node] of plan.definitions) finalIdByNode.set(node, id);
|
||||
|
||||
const isEmptyParagraph = (node: ProseMirrorNode) =>
|
||||
node.type === paragraphType && node.content.size === 0;
|
||||
|
||||
// 2) Classify every existing definition occurrence:
|
||||
// - reId: keep the node in place, only change its id attr (collision).
|
||||
// - orphan: delete it (its final id has no matching reference).
|
||||
// A definition that already carries the right id and is referenced is
|
||||
// left COMPLETELY untouched (its Yjs subtree is preserved). This is the
|
||||
// core of the data-loss fix: a pure reference reorder produces NO
|
||||
// mutation of any definition subtree.
|
||||
interface DefReid {
|
||||
pos: number;
|
||||
node: ProseMirrorNode;
|
||||
newId: string;
|
||||
}
|
||||
const defReids: DefReid[] = [];
|
||||
const orphanDefs: DefOccurrence[] = [];
|
||||
// Track which referenced ids already have a surviving (non-orphan)
|
||||
// definition, so we can synthesize the genuinely missing ones.
|
||||
const satisfiedIds = new Set<string>();
|
||||
// Choose a "primary" list to receive inserts/migrated defs: the LAST list
|
||||
// whose placement is canonical (only empty paragraphs follow it), else the
|
||||
// last list, else none. New defs and consolidated defs land here.
|
||||
for (const occ of info.defOccurrences) {
|
||||
const finalId = finalIdByNode.get(occ.node) ?? occ.id;
|
||||
if (!referenceIdSet.has(finalId)) {
|
||||
orphanDefs.push(occ);
|
||||
continue;
|
||||
}
|
||||
if (occ.id !== finalId) {
|
||||
defReids.push({ pos: occ.pos, node: occ.node, newId: finalId });
|
||||
}
|
||||
satisfiedIds.add(finalId);
|
||||
}
|
||||
|
||||
// 3) Referenced ids with no surviving definition need a fresh empty one.
|
||||
const missingIds = referenceIds.filter((id) => !satisfiedIds.has(id));
|
||||
|
||||
// 4) Determine list topology.
|
||||
const hasRefs = referenceIds.length > 0;
|
||||
|
||||
// Pick the primary list: prefer the last canonically-placed list.
|
||||
const listIsTrailing = (listPos: number, listNode: ProseMirrorNode) => {
|
||||
const listEnd = listPos + listNode.nodeSize;
|
||||
let ok = true;
|
||||
doc.nodesBetween(listEnd, doc.content.size, (child, childPos) => {
|
||||
if (childPos >= listEnd && child !== listNode) {
|
||||
if (!isEmptyParagraph(child)) ok = false;
|
||||
}
|
||||
return false; // do not descend
|
||||
});
|
||||
return ok;
|
||||
};
|
||||
let primaryList: { pos: number; node: ProseMirrorNode } | null = null;
|
||||
for (let i = info.lists.length - 1; i >= 0; i--) {
|
||||
if (listIsTrailing(info.lists[i].pos, info.lists[i].node)) {
|
||||
primaryList = info.lists[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!primaryList && info.lists.length > 0) {
|
||||
primaryList = info.lists[info.lists.length - 1];
|
||||
}
|
||||
// Extra lists (everything except the primary) must be consolidated away.
|
||||
const extraLists = info.lists.filter((l) => l !== primaryList);
|
||||
const inExtraList = (pos: number) =>
|
||||
extraLists.some((l) => pos > l.pos && pos < l.pos + l.node.nodeSize);
|
||||
|
||||
// Definitions inside an extra list are migrated (recreated with the right
|
||||
// id) into the primary list, so drop their in-place re-id markups — the
|
||||
// whole extra list is deleted below and the markup would be wasted.
|
||||
const defReidsToApply = defReids.filter((r) => !inExtraList(r.pos));
|
||||
|
||||
// 5) Decide whether anything must change. The document is canonical when:
|
||||
// - no collisions were resolved (refs or defs), AND
|
||||
// - no orphan definitions, AND
|
||||
// - no missing definitions, AND
|
||||
// - exactly the right number of lists (0 when no refs, else 1) AND the
|
||||
// single list is canonically placed (trailing).
|
||||
const noChangeNeeded =
|
||||
!plan.changed &&
|
||||
defReids.length === 0 &&
|
||||
orphanDefs.length === 0 &&
|
||||
missingIds.length === 0 &&
|
||||
extraLists.length === 0 &&
|
||||
(hasRefs
|
||||
? info.lists.length === 1 && primaryList !== null
|
||||
: info.lists.length === 0);
|
||||
|
||||
if (noChangeNeeded) return null;
|
||||
|
||||
// 6) Apply the targeted, minimal mutations in ONE transaction. We never
|
||||
// delete-and-recreate an unchanged definition subtree; we only:
|
||||
// (a) re-id specific colliding references and definitions (attr-only),
|
||||
// (b) delete genuine orphan definitions and extra/empty lists,
|
||||
// (c) insert genuinely-missing empty definitions and migrate defs out
|
||||
// of extra lists into the primary list,
|
||||
// (d) create the primary list if references exist but none does yet.
|
||||
const tr = newState.tr;
|
||||
|
||||
// 6a) Re-id colliding references (inline atoms: attr-only, size-stable).
|
||||
for (const reid of plan.refReids) {
|
||||
tr.setNodeMarkup(tr.mapping.map(reid.pos), undefined, {
|
||||
...reid.node.attrs,
|
||||
id: reid.newId,
|
||||
});
|
||||
}
|
||||
// 6b) Re-id colliding definitions IN PLACE (attr-only). This preserves the
|
||||
// definition's content subtree — never delete+recreate it.
|
||||
for (const reid of defReidsToApply) {
|
||||
tr.setNodeMarkup(tr.mapping.map(reid.pos), undefined, {
|
||||
...reid.node.attrs,
|
||||
id: reid.newId,
|
||||
});
|
||||
}
|
||||
|
||||
// 6c) Migrate non-orphan definitions out of every extra list into the
|
||||
// primary list (or, if there is no primary list, into a new one we
|
||||
// build), then delete the extra (now drained) lists. This is the only
|
||||
// path that moves a definition subtree, and it runs ONLY in the
|
||||
// abnormal multi-list case (paste/collab merge) — never on a plain
|
||||
// reorder, which keeps a single list untouched.
|
||||
const migrated: ProseMirrorNode[] = [];
|
||||
for (const extra of extraLists) {
|
||||
extra.node.forEach((defChild) => {
|
||||
if (defChild.type !== defType) return;
|
||||
const finalId = finalIdByNode.get(defChild) ?? defChild.attrs.id;
|
||||
if (!referenceIdSet.has(finalId)) return; // orphan: drop it
|
||||
migrated.push(
|
||||
defChild.attrs.id === finalId
|
||||
? defChild
|
||||
: defType.create({ id: finalId }, defChild.content),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 6c-bis) The definitions to INSERT into the primary list: migrated defs
|
||||
// from extra lists + freshly synthesized empty defs for references
|
||||
// that have no definition at all. Computed before deletions so we can
|
||||
// decide whether the primary list would be left empty.
|
||||
const toInsert: ProseMirrorNode[] = [
|
||||
...migrated,
|
||||
...missingIds.map((id) =>
|
||||
defType.create({ id }, paragraphType.create()),
|
||||
),
|
||||
];
|
||||
|
||||
// Does the primary list keep at least one definition after we strip its
|
||||
// orphans AND counting the defs we are about to insert? If it ends up
|
||||
// empty (an empty footnotesList is invalid schema), delete the WHOLE list
|
||||
// instead of leaving a hollow shell. Only the primary list can receive
|
||||
// inserts; extra lists are always deleted wholesale.
|
||||
let primarySurvivors = 0;
|
||||
if (primaryList) {
|
||||
primaryList.node.forEach((defChild) => {
|
||||
if (defChild.type !== defType) return;
|
||||
const finalId = finalIdByNode.get(defChild) ?? defChild.attrs.id;
|
||||
if (referenceIdSet.has(finalId)) primarySurvivors += 1;
|
||||
});
|
||||
}
|
||||
const primaryWillBeEmpty =
|
||||
!!primaryList && primarySurvivors === 0 && toInsert.length === 0;
|
||||
|
||||
// 6d) Delete orphan definitions, extra lists, and any list that would be
|
||||
// left empty. Sort deletions from the end so earlier positions stay
|
||||
// valid; map through tr.mapping to account for the (size-stable) re-id
|
||||
// markups and earlier deletions.
|
||||
const deletions: Array<{ from: number; to: number }> = [];
|
||||
const wholeListDeletes = new Set(extraLists);
|
||||
if (primaryWillBeEmpty && primaryList) wholeListDeletes.add(primaryList);
|
||||
|
||||
for (const occ of orphanDefs) {
|
||||
// Skip orphans inside a list that is being deleted wholesale.
|
||||
const inWholeDeleted = [...wholeListDeletes].some(
|
||||
(l) => occ.pos > l.pos && occ.pos < l.pos + l.node.nodeSize,
|
||||
);
|
||||
if (inWholeDeleted) continue;
|
||||
deletions.push({ from: occ.pos, to: occ.pos + occ.node.nodeSize });
|
||||
}
|
||||
for (const l of wholeListDeletes) {
|
||||
deletions.push({ from: l.pos, to: l.pos + l.node.nodeSize });
|
||||
}
|
||||
deletions
|
||||
.sort((a, b) => b.from - a.from)
|
||||
.forEach(({ from, to }) => {
|
||||
tr.delete(tr.mapping.map(from), tr.mapping.map(to));
|
||||
});
|
||||
|
||||
// If we deleted the primary list wholesale, it can no longer receive the
|
||||
// inserts below — null it out so a fresh list is created when needed.
|
||||
if (primaryWillBeEmpty) primaryList = null;
|
||||
|
||||
// 6e) Insert the migrated + synthesized definitions.
|
||||
if (hasRefs) {
|
||||
if (primaryList) {
|
||||
if (toInsert.length > 0) {
|
||||
// Append at the end of the (mapped) primary list, just before its
|
||||
// closing token, so its existing definition subtrees are untouched.
|
||||
// We only changed attrs (size-stable) and deleted OTHER nodes, so
|
||||
// mapping the original list-end position forward lands at the same
|
||||
// boundary; -1 puts us just inside the list's closing token.
|
||||
const insertAt =
|
||||
tr.mapping.map(primaryList.pos + primaryList.node.nodeSize) - 1;
|
||||
tr.insert(insertAt, Fragment.fromArray(toInsert));
|
||||
}
|
||||
} else {
|
||||
// No usable list exists yet but references do — create one holding the
|
||||
// migrated + synthesized definitions, placed after the last meaningful
|
||||
// (non-empty-paragraph) top-level block so it sits before any trailing
|
||||
// empty paragraph the trailing-node plugin maintains.
|
||||
const mappedDoc = tr.doc;
|
||||
let insertPos = mappedDoc.content.size;
|
||||
for (let i = mappedDoc.childCount - 1; i >= 0; i--) {
|
||||
const child = mappedDoc.child(i);
|
||||
if (isEmptyParagraph(child)) insertPos -= child.nodeSize;
|
||||
else break;
|
||||
}
|
||||
const list = listType.create(null, Fragment.fromArray(toInsert));
|
||||
tr.insert(insertPos, list);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tr.docChanged) return null;
|
||||
|
||||
tr.setMeta(SYNC_META, true);
|
||||
tr.setMeta("addToHistory", false);
|
||||
return tr;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const footnotePastePluginKey = new PluginKey("footnotePaste");
|
||||
|
||||
/**
|
||||
* Paste id-collision guard. When pasted content carries footnote reference or
|
||||
* definition ids that ALREADY EXIST in the current document, regenerate those
|
||||
* ids (consistently across the pasted slice, so a pasted reference and its
|
||||
* definition keep pointing at each other) BEFORE the slice is inserted.
|
||||
*
|
||||
* Without this, pasting a reference+definition pair copied from elsewhere — or
|
||||
* duplicating one in place — would merge with (or clobber) the existing footnote
|
||||
* of the same id. The schema-sync plugin already guarantees no definition is
|
||||
* ever silently deleted after the fact (it re-id's collisions), but regenerating
|
||||
* at paste time keeps the pasted footnote cleanly separate from the start and
|
||||
* avoids any transient merge.
|
||||
*
|
||||
* Only COLLIDING ids are remapped: a self-paste of a lone reference whose id is
|
||||
* not present elsewhere is left untouched (so it still resolves to its existing
|
||||
* definition).
|
||||
*/
|
||||
export function footnotePastePlugin(): Plugin {
|
||||
return new Plugin({
|
||||
key: footnotePastePluginKey,
|
||||
props: {
|
||||
transformPasted(slice, view) {
|
||||
// Collect ids already present in the current document.
|
||||
const existing = new Set<string>();
|
||||
view.state.doc.descendants((node) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_REFERENCE_NAME ||
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME
|
||||
) {
|
||||
const id = node.attrs.id;
|
||||
if (id) existing.add(id);
|
||||
}
|
||||
});
|
||||
if (existing.size === 0) return slice;
|
||||
|
||||
// Build a remap (old id -> fresh id) for every COLLIDING id found in the
|
||||
// pasted slice, shared by references and definitions so a pasted pair
|
||||
// stays matched. A paste is a distinct local user action (not a
|
||||
// shared-state convergence point), so determinism is not strictly
|
||||
// required here — but we derive the new id deterministically anyway
|
||||
// (deriveFootnoteId against the current doc's id set) for consistency
|
||||
// with the sync/import paths and to keep Math.random off this code path.
|
||||
const remap = new Map<string, string>();
|
||||
const collectColliding = (node: ProseMirrorNode) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_REFERENCE_NAME ||
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME
|
||||
) {
|
||||
const id = node.attrs.id;
|
||||
if (id && existing.has(id) && !remap.has(id)) {
|
||||
const newId = deriveFootnoteId(id, 2, existing);
|
||||
remap.set(id, newId);
|
||||
// Reserve it so a second colliding id deriving to the same base
|
||||
// bumps instead of clashing.
|
||||
existing.add(newId);
|
||||
}
|
||||
}
|
||||
node.descendants(collectColliding);
|
||||
};
|
||||
slice.content.descendants(collectColliding);
|
||||
if (remap.size === 0) return slice;
|
||||
|
||||
// Rewrite the colliding ids throughout the slice.
|
||||
const rewrite = (fragment: Fragment): Fragment => {
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
fragment.forEach((node) => {
|
||||
const isFootnote =
|
||||
node.type.name === FOOTNOTE_REFERENCE_NAME ||
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME;
|
||||
const newId = isFootnote ? remap.get(node.attrs.id) : undefined;
|
||||
const newContent = node.content.size
|
||||
? rewrite(node.content)
|
||||
: node.content;
|
||||
if (newId) {
|
||||
nodes.push(
|
||||
node.type.create(
|
||||
{ ...node.attrs, id: newId },
|
||||
newContent,
|
||||
node.marks,
|
||||
),
|
||||
);
|
||||
} else if (newContent !== node.content) {
|
||||
nodes.push(node.copy(newContent));
|
||||
} else {
|
||||
nodes.push(node);
|
||||
}
|
||||
});
|
||||
return Fragment.fromArray(nodes);
|
||||
};
|
||||
|
||||
return new Slice(rewrite(slice.content), slice.openStart, slice.openEnd);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
132
packages/editor-ext/src/lib/footnote/footnote-util.ts
Normal file
132
packages/editor-ext/src/lib/footnote/footnote-util.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
/**
|
||||
* Node type names for the footnote feature. Centralized so every part of the
|
||||
* feature (nodes, plugins, commands) references the same string.
|
||||
*/
|
||||
export const FOOTNOTE_REFERENCE_NAME = "footnoteReference";
|
||||
export const FOOTNOTES_LIST_NAME = "footnotesList";
|
||||
export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
||||
|
||||
/**
|
||||
* Generate a uuidv7-style id (time-ordered). Implemented locally so editor-ext
|
||||
* does not need a runtime dependency on the `uuid` package; matches the
|
||||
* lexicographically-sortable layout uuidv7 produces.
|
||||
*/
|
||||
export function generateFootnoteId(): string {
|
||||
const now = Date.now();
|
||||
const timeHex = now.toString(16).padStart(12, "0");
|
||||
|
||||
const rand = (length: number) => {
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += Math.floor(Math.random() * 16).toString(16);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// version 7 nibble, then variant (8..b) nibble.
|
||||
const versioned = "7" + rand(3);
|
||||
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
||||
const variant = variantNibble + rand(3);
|
||||
|
||||
return (
|
||||
timeHex.slice(0, 8) +
|
||||
"-" +
|
||||
timeHex.slice(8, 12) +
|
||||
"-" +
|
||||
versioned +
|
||||
"-" +
|
||||
variant +
|
||||
"-" +
|
||||
rand(12)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of
|
||||
* an original id `X` during collision resolution. The result is a pure function
|
||||
* of (`originalId`, `occurrence`, `taken`) so that every collaborating client —
|
||||
* and every import path — computes the SAME new id for the same input document.
|
||||
*
|
||||
* CRITICAL: this MUST NOT use Math.random()/Date.now()/uuid. Two clients that
|
||||
* each make a local edit on the same duplicate-id document have to converge on
|
||||
* identical ids; a random id would diverge permanently over Yjs.
|
||||
*
|
||||
* Scheme: the base candidate is `${originalId}__${occurrence}` (e.g. `X__2`,
|
||||
* `X__3`). If that candidate already exists in `taken` (an existing footnote id,
|
||||
* or one we already minted in this pass), a stable alphabetic suffix is appended
|
||||
* and bumped — `X__2b`, `X__2c`, ... — until the candidate is unique. `taken` is
|
||||
* itself part of the document state, so the whole walk stays deterministic.
|
||||
*
|
||||
* `taken` is consulted but NOT mutated here; the caller adds the returned id to
|
||||
* its own seen-set before requesting the next derived id.
|
||||
*
|
||||
* NOTE: this implementation is intentionally duplicated in
|
||||
* packages/mcp/src/lib/collaboration.ts (deriveFootnoteId)
|
||||
* and MUST stay in sync with it so markdown imported through either path yields
|
||||
* identical ids.
|
||||
*/
|
||||
export function deriveFootnoteId(
|
||||
originalId: string,
|
||||
occurrence: number,
|
||||
taken: Set<string> | ReadonlySet<string>,
|
||||
): string {
|
||||
let candidate = `${originalId}__${occurrence}`;
|
||||
// Deterministic suffix bump: b, c, d, ... then aa, ab, ... if ever exhausted.
|
||||
let n = 0;
|
||||
while (taken.has(candidate)) {
|
||||
n += 1;
|
||||
candidate = `${originalId}__${occurrence}${suffix(n)}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map 1 -> "b", 2 -> "c", ... 25 -> "z", 26 -> "ba", ... (base-25 over b..z,
|
||||
* skipping "a" so the first bump is visibly distinct from the un-bumped base).
|
||||
* Purely deterministic.
|
||||
*/
|
||||
function suffix(n: number): string {
|
||||
let out = "";
|
||||
let x = n;
|
||||
while (x > 0) {
|
||||
const rem = (x - 1) % 25;
|
||||
out = String.fromCharCode(98 + rem) + out; // 98 = 'b'
|
||||
x = Math.floor((x - 1) / 25);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every `footnoteReference` id in document order. This is the single
|
||||
* source of truth for numbering and ordering — a pure function of the document
|
||||
* so every collaborating client computes the same result.
|
||||
*/
|
||||
export function collectReferenceIds(doc: ProseMirrorNode): string[] {
|
||||
const ids: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
|
||||
const id = node.attrs.id;
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of `referenceId -> displayNumber` (1-based) from document order.
|
||||
* Pure function — the basis for the numbering decorations and any test.
|
||||
*/
|
||||
export function computeFootnoteNumbers(
|
||||
doc: ProseMirrorNode,
|
||||
): Map<string, number> {
|
||||
const numbers = new Map<string, number>();
|
||||
let n = 0;
|
||||
for (const id of collectReferenceIds(doc)) {
|
||||
if (!numbers.has(id)) {
|
||||
numbers.set(id, ++n);
|
||||
}
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
948
packages/editor-ext/src/lib/footnote/footnote.test.ts
Normal file
948
packages/editor-ext/src/lib/footnote/footnote.test.ts
Normal file
@@ -0,0 +1,948 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor, Extension, getSchema } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import { FootnoteReference } from "./footnote-reference";
|
||||
import { FootnotesList } from "./footnotes-list";
|
||||
import { FootnoteDefinition } from "./footnote-definition";
|
||||
import { TrailingNode } from "../trailing-node";
|
||||
import { footnoteSyncPlugin } from "./footnote-sync";
|
||||
import { getFootnoteNumber } from "./footnote-numbering";
|
||||
import {
|
||||
computeFootnoteNumbers,
|
||||
collectReferenceIds,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
} from "./footnote-util";
|
||||
|
||||
const extensions = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
];
|
||||
|
||||
function makeEditor(content?: any) {
|
||||
return new Editor({
|
||||
extensions,
|
||||
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
}
|
||||
|
||||
function countType(doc: PMNode, name: string): number {
|
||||
let n = 0;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === name) n++;
|
||||
});
|
||||
return n;
|
||||
}
|
||||
|
||||
describe("footnote numbering (pure function)", () => {
|
||||
it("numbers references in document order", () => {
|
||||
const schema = getSchema(extensions);
|
||||
const doc = PMNode.fromJSON(schema, {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [{ type: "paragraph" }],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "y" },
|
||||
content: [{ type: "paragraph" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(collectReferenceIds(doc)).toEqual(["x", "y"]);
|
||||
const numbers = computeFootnoteNumbers(doc);
|
||||
expect(numbers.get("x")).toBe(1);
|
||||
expect(numbers.get("y")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFootnote command", () => {
|
||||
it("inserts a reference and a matching definition in the footnotes list", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello" }] },
|
||||
],
|
||||
});
|
||||
// Cursor at end of the word.
|
||||
editor.commands.setTextSelection(6);
|
||||
const ok = editor.commands.setFootnote();
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1);
|
||||
|
||||
// The reference id and the definition id match.
|
||||
let refId: string | null = null;
|
||||
let defId: string | null = null;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) refId = node.attrs.id;
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) defId = node.attrs.id;
|
||||
});
|
||||
expect(refId).toBeTruthy();
|
||||
expect(refId).toBe(defId);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("inserts the definition at the correct position matching reference order", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "AAAA" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "BBBB" }] },
|
||||
],
|
||||
});
|
||||
|
||||
// First footnote: place inside the SECOND paragraph (after "BBBB").
|
||||
editor.commands.setTextSelection(11); // end of BBBB
|
||||
editor.commands.setFootnote();
|
||||
|
||||
// Second footnote: place inside the FIRST paragraph (after "AAAA"),
|
||||
// which is BEFORE the first reference in document order.
|
||||
editor.commands.setTextSelection(5); // end of AAAA
|
||||
editor.commands.setFootnote();
|
||||
|
||||
const doc = editor.state.doc;
|
||||
// Reference order in document.
|
||||
const refOrder = collectReferenceIds(doc);
|
||||
// Definition order in the list.
|
||||
const defOrder: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
||||
defOrder.push(node.attrs.id);
|
||||
}
|
||||
});
|
||||
|
||||
expect(defOrder).toEqual(refOrder);
|
||||
expect(defOrder.length).toBe(2);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeFootnote command (cascade)", () => {
|
||||
it("removes both the reference and its definition, and drops the empty list", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello" }] },
|
||||
],
|
||||
});
|
||||
editor.commands.setTextSelection(6);
|
||||
editor.commands.setFootnote();
|
||||
|
||||
let id: string | null = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) id = node.attrs.id;
|
||||
});
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
editor.commands.removeFootnote(id!);
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(0);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
|
||||
// empty list removed
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("footnote sync plugin (orphans)", () => {
|
||||
it("creates an empty definition for a reference pasted without one", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "x" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan-ref" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Trigger a doc change so appendTransaction runs.
|
||||
editor.commands.insertContentAt(1, " ");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
let defFound = false;
|
||||
doc.descendants((node) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
||||
node.attrs.id === "orphan-ref"
|
||||
) {
|
||||
defFound = true;
|
||||
}
|
||||
});
|
||||
expect(defFound).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("merges multiple footnotesList nodes into one, preserving all definitions, as the last child", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
||||
],
|
||||
},
|
||||
// First (stray) footnotes list, e.g. from a paste/collab merge.
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "paragraph", content: [{ type: "text", text: "tail" }] },
|
||||
// Second footnotes list (the "real" trailing one).
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "y" },
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Y note" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Trigger a local doc change so appendTransaction runs.
|
||||
editor.commands.insertContentAt(1, " ");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
// Converged to exactly ONE list.
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
// Both definitions preserved (no tracking lost).
|
||||
const defIds: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) defIds.push(node.attrs.id);
|
||||
});
|
||||
expect(defIds.sort()).toEqual(["x", "y"]);
|
||||
// The single list is the LAST child of the document.
|
||||
const lastChild = doc.child(doc.childCount - 1);
|
||||
expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a correct doc (single trailing list) unchanged — no merge loop", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const before = editor.state.doc.toJSON();
|
||||
// A change that doesn't touch footnote structure.
|
||||
editor.commands.insertContentAt(1, "z");
|
||||
const doc = editor.state.doc;
|
||||
// Still exactly one list, still last, definition preserved.
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
const lastChild = doc.child(doc.childCount - 1);
|
||||
expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME);
|
||||
// The footnotes list subtree is identical to before (no spurious rewrite).
|
||||
const beforeList = before.content.find(
|
||||
(n: any) => n.type === FOOTNOTES_LIST_NAME,
|
||||
);
|
||||
const afterList = doc
|
||||
.toJSON()
|
||||
.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME);
|
||||
expect(afterList).toEqual(beforeList);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("two definitions sharing an id (with two matching references) BOTH survive the first edit (no data loss)", () => {
|
||||
// Reproduces the verified data-loss bug: two footnoteDefinition nodes share
|
||||
// id "d", and there are two references with id "d". The OLD code built the
|
||||
// definitions Map last-wins and emitted exactly one definition for the
|
||||
// de-duplicated reference, so the very first keystroke's sync transaction
|
||||
// deleted the whole list and rebuilt it from one definition — silently
|
||||
// destroying "first" and keeping only "second".
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "d" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "d" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "second" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// The first local keystroke fires the sync plugin's appendTransaction.
|
||||
editor.commands.insertContentAt(1, " ");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
// BOTH definitions survive.
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
|
||||
const defTexts: string[] = [];
|
||||
const defIds: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
||||
defIds.push(node.attrs.id);
|
||||
defTexts.push(node.textContent);
|
||||
}
|
||||
});
|
||||
// No content was lost: both "first" and "second" are still present.
|
||||
expect(defTexts.sort()).toEqual(["first", "second"]);
|
||||
// The colliding ids were made distinct.
|
||||
expect(new Set(defIds).size).toBe(2);
|
||||
// Each definition's id matches exactly one reference (1:1 pairing).
|
||||
const refIds: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) refIds.push(node.attrs.id);
|
||||
});
|
||||
expect(refIds.sort()).toEqual(defIds.sort());
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("re-ids colliding duplicates DETERMINISTICALLY (two clients converge to identical ids)", () => {
|
||||
// Cross-client determinism guard. Two collaborating clients each see the
|
||||
// SAME duplicate-id document and each make a local edit. The sync plugin
|
||||
// runs identically on every client, so it MUST mint the SAME new ids on both
|
||||
// — otherwise the two clients diverge permanently over Yjs (duplicated
|
||||
// footnotes). This is exactly the blocker the previous random-id
|
||||
// (generateFootnoteId / Math.random) implementation caused: it would mint
|
||||
// DIFFERENT ids on each client and this assertion would fail.
|
||||
const duplicateDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
||||
{ type: "text", text: "c" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "d" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "one" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "d" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "two" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "d" },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "three" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const idsAfterLocalEdit = () => {
|
||||
// A fresh editor instance = an independent "client" running the same
|
||||
// plugin pipeline on the same starting document.
|
||||
const editor = makeEditor(structuredClone(duplicateDoc));
|
||||
editor.commands.insertContentAt(1, " "); // local keystroke -> sync runs
|
||||
const refIds: string[] = [];
|
||||
const defIds: string[] = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME)
|
||||
refIds.push(node.attrs.id);
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME)
|
||||
defIds.push(node.attrs.id);
|
||||
});
|
||||
editor.destroy();
|
||||
return { refIds, defIds };
|
||||
};
|
||||
|
||||
const clientA = idsAfterLocalEdit();
|
||||
const clientB = idsAfterLocalEdit();
|
||||
|
||||
// Both clients computed IDENTICAL ids (the property that makes Yjs converge).
|
||||
expect(clientA.refIds).toEqual(clientB.refIds);
|
||||
expect(clientA.defIds).toEqual(clientB.defIds);
|
||||
|
||||
// And the ids are deterministic-derived (not random uuid-style): the keeper
|
||||
// keeps "d", the duplicates become "d__2", "d__3".
|
||||
expect(new Set(clientA.refIds)).toEqual(new Set(["d", "d__2", "d__3"]));
|
||||
// Every definition survived with a unique id, 1:1 with the references.
|
||||
expect(clientA.defIds.length).toBe(3);
|
||||
expect(new Set(clientA.defIds).size).toBe(3);
|
||||
expect([...clientA.refIds].sort()).toEqual([...clientA.defIds].sort());
|
||||
});
|
||||
|
||||
it("removes an orphan definition with no matching reference", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "x" }] },
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "orphan-def" },
|
||||
content: [{ type: "paragraph" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
editor.commands.insertContentAt(1, "y");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Live-editor regression tests for the sync-plugin infinite loop (the hard
|
||||
* freeze when activating /footnote). These drive a REAL Tiptap editor through
|
||||
* the same plugin pipeline the browser uses — including the TrailingNode plugin,
|
||||
* which is what turned the "move list to the end" pass into an infinite
|
||||
* ping-pong (list moved last -> trailing paragraph appended after it -> list no
|
||||
* longer last -> moved again -> ...).
|
||||
*
|
||||
* If the loop regresses, ProseMirror's appendTransaction round loop never
|
||||
* terminates and these tests HANG (the vitest timeout fails them). The
|
||||
* transaction counter additionally fails fast with a bounded iteration cap, so
|
||||
* a regression surfaces as an explicit error instead of only a slow timeout.
|
||||
*/
|
||||
describe("footnote sync plugin (no infinite loop — live editor)", () => {
|
||||
// Hard cap on how many doc-changing appendTransaction rounds we tolerate for a
|
||||
// single user action. Convergence takes a couple of rounds at most; anything
|
||||
// approaching this means the plugins are oscillating.
|
||||
const MAX_ROUNDS = 50;
|
||||
|
||||
// The production editor wires FootnoteReference alongside TrailingNode and
|
||||
// Superscript; both participate in the loop the bug exhibited, so we mirror
|
||||
// that here.
|
||||
function makeLiveEditor(content?: any) {
|
||||
let rounds = 0;
|
||||
// A guard plugin that counts doc-changing appendTransaction rounds and
|
||||
// throws if they exceed the cap, converting a would-be infinite loop into a
|
||||
// deterministic failure instead of a wall-clock hang.
|
||||
const LoopGuard = Extension.create({
|
||||
name: "footnoteLoopGuard",
|
||||
// Run last so it observes every other plugin's appended transaction.
|
||||
priority: -1000,
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("footnoteLoopGuard"),
|
||||
appendTransaction(transactions) {
|
||||
if (transactions.some((t) => t.docChanged)) {
|
||||
rounds += 1;
|
||||
if (rounds > MAX_ROUNDS) {
|
||||
throw new Error(
|
||||
`footnote sync did not converge: exceeded ${MAX_ROUNDS} appendTransaction rounds (infinite loop)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Superscript,
|
||||
TrailingNode,
|
||||
LoopGuard,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
],
|
||||
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
return { editor, getRounds: () => rounds, resetRounds: () => (rounds = 0) };
|
||||
}
|
||||
|
||||
function lastFootnotesListIsTrailing(doc: PMNode): boolean {
|
||||
// Canonical placement: the list is the last meaningful block — only empty
|
||||
// paragraphs (the trailing-node) may follow it.
|
||||
let listIndex = -1;
|
||||
for (let i = 0; i < doc.childCount; i++) {
|
||||
if (doc.child(i).type.name === FOOTNOTES_LIST_NAME) listIndex = i;
|
||||
}
|
||||
if (listIndex === -1) return false;
|
||||
for (let i = listIndex + 1; i < doc.childCount; i++) {
|
||||
const child = doc.child(i);
|
||||
if (!(child.type.name === "paragraph" && child.content.size === 0)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
it("setFootnote() RETURNS (no hang) and produces one ref + one def in a trailing list", () => {
|
||||
const { editor } = makeLiveEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(3);
|
||||
const ok = editor.commands.setFootnote();
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1);
|
||||
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("a second setFootnote() does not hang: two refs + two defs in one list", () => {
|
||||
const { editor } = makeLiveEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(3);
|
||||
editor.commands.setFootnote();
|
||||
editor.commands.setTextSelection(3);
|
||||
editor.commands.setFootnote();
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(2);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("converges and stabilizes: an unrelated edit does not keep producing transactions", () => {
|
||||
const { editor, getRounds, resetRounds } = makeLiveEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(3);
|
||||
editor.commands.setFootnote();
|
||||
|
||||
// Now the doc is canonical. Dispatch an unrelated edit (insert text) and
|
||||
// assert the sync plugin converges in a bounded number of rounds and the
|
||||
// document is stable (one ref/def/list, list trailing).
|
||||
resetRounds();
|
||||
editor.commands.insertContentAt(1, "Z");
|
||||
const afterFirst = editor.state.doc.toJSON();
|
||||
const roundsAfterEdit = getRounds();
|
||||
expect(roundsAfterEdit).toBeLessThan(MAX_ROUNDS);
|
||||
|
||||
// A follow-up no-op-ish edit must not re-trigger structural rewrites: the
|
||||
// footnotes section is identical before and after a further unrelated edit.
|
||||
editor.commands.insertContentAt(2, "Y");
|
||||
const afterSecond = editor.state.doc.toJSON();
|
||||
|
||||
const listOf = (json: any) =>
|
||||
json.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME);
|
||||
expect(listOf(afterSecond)).toEqual(listOf(afterFirst));
|
||||
expect(countType(editor.state.doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("two footnotesList nodes converge to one (merge) without looping", () => {
|
||||
const { editor } = makeLiveEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "X" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "paragraph", content: [{ type: "text", text: "tail" }] },
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "y" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Y" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Trigger a local doc change so appendTransaction runs (must not hang).
|
||||
editor.commands.insertContentAt(1, " ");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
const defIds: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME)
|
||||
defIds.push(node.attrs.id);
|
||||
});
|
||||
expect(defIds.sort()).toEqual(["x", "y"]);
|
||||
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Data-loss-window regression guard (Fix 1). A pure reference REORDER must not
|
||||
* cause the sync plugin to delete-and-recreate any definition subtree — doing so
|
||||
* (the previous behaviour) would, through Yjs, replace the CRDT subtree of every
|
||||
* definition and could lose a collaborator's in-flight characters on merge.
|
||||
*
|
||||
* Numbering is decoration-only (footnote-numbering.ts derives numbers from
|
||||
* reference order), so the bottom list's PHYSICAL order need not match reference
|
||||
* order for the displayed numbers to be correct. We therefore assert: the
|
||||
* existing definition NODE INSTANCES are preserved (identity-equal) after the
|
||||
* sync pass, AND the derived numbers follow the new reference order.
|
||||
*/
|
||||
describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", () => {
|
||||
function reorderedDoc() {
|
||||
// The "out of order" end-state of a reorder: references occur as [b, a] but
|
||||
// the bottom list still physically holds definitions in [a, b] order. This
|
||||
// is exactly the situation a reference reorder produces (decoration-only
|
||||
// numbering keeps the displayed numbers correct without physically moving
|
||||
// the definition subtrees). The sync plugin must leave the definitions
|
||||
// ALONE here — no delete/recreate of any definition subtree.
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "p" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "b" } },
|
||||
{ type: "text", text: "q" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "a" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "a" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "A" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "b" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "B" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getDefNodesById(doc: PMNode): Map<string, PMNode> {
|
||||
const m = new Map<string, PMNode>();
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) m.set(node.attrs.id, node);
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
it("does NOT delete/recreate existing definition subtrees for an out-of-order list (numbers still correct)", () => {
|
||||
const editor = makeEditor(reorderedDoc());
|
||||
|
||||
// Capture the exact definition NODE INSTANCES before any sync pass.
|
||||
const before = getDefNodesById(editor.state.doc);
|
||||
// Sanity: both carry their content right now.
|
||||
expect(before.get("a")!.textContent).toBe("A");
|
||||
expect(before.get("b")!.textContent).toBe("B");
|
||||
|
||||
// Trigger a local edit elsewhere in the body so the sync plugin runs.
|
||||
editor.commands.insertContentAt(1, "z");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
|
||||
// Reference order is [b, a]; the displayed numbers follow reference order
|
||||
// (decoration-only numbering): b -> 1, a -> 2 — regardless of physical list
|
||||
// order.
|
||||
expect(collectReferenceIds(doc)).toEqual(["b", "a"]);
|
||||
const numbers = computeFootnoteNumbers(doc);
|
||||
expect(numbers.get("b")).toBe(1);
|
||||
expect(numbers.get("a")).toBe(2);
|
||||
|
||||
// CRITICAL regression guard: both definitions still exist and are the SAME
|
||||
// node instances as before the edit — the plugin did NOT delete/recreate the
|
||||
// list (which would replace every definition's CRDT subtree and open the
|
||||
// concurrent-edit data-loss window). Identity equality proves the subtree
|
||||
// was preserved verbatim.
|
||||
const after = getDefNodesById(doc);
|
||||
expect(after.get("a")).toBe(before.get("a"));
|
||||
expect(after.get("b")).toBe(before.get("b"));
|
||||
// Content intact, exactly one list, both definitions present.
|
||||
expect(after.get("a")!.textContent).toBe("A");
|
||||
expect(after.get("b")!.textContent).toBe("B");
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Sync-plugin guard paths that are awkward to exercise through a live editor:
|
||||
* the remote-transaction skip and the enableSync:false (read-only) mode.
|
||||
*/
|
||||
describe("footnote sync plugin (guards)", () => {
|
||||
// Build a non-canonical document (an orphan reference with no definition) so a
|
||||
// sync pass would normally append a transaction.
|
||||
function nonCanonicalState() {
|
||||
const schema = getSchema(extensions);
|
||||
const doc = PMNode.fromJSON(schema, {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "x" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
return EditorState.create({ schema, doc });
|
||||
}
|
||||
|
||||
it("isRemoteTransaction => true: appendTransaction returns null (no rebuild on remote txns)", () => {
|
||||
// The sync plugin must SKIP remote/collab transactions so orphan cleanup and
|
||||
// structural rewrites only ever run on local edits.
|
||||
const plugin = footnoteSyncPlugin(() => true);
|
||||
const state = nonCanonicalState();
|
||||
|
||||
// Produce a doc-changing transaction (insert a space) and feed it to the
|
||||
// plugin's appendTransaction exactly as ProseMirror would.
|
||||
const tr = state.tr.insertText(" ", 1);
|
||||
const newState = state.apply(tr);
|
||||
const result = plugin.spec.appendTransaction!(
|
||||
[tr],
|
||||
state,
|
||||
newState,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("isRemoteTransaction => false: appendTransaction DOES rebuild (sanity)", () => {
|
||||
// Control: with a local (non-remote) transaction the same non-canonical doc
|
||||
// triggers a sync transaction, proving the null above is the remote guard
|
||||
// and not a no-op everywhere.
|
||||
const plugin = footnoteSyncPlugin(() => false);
|
||||
const state = nonCanonicalState();
|
||||
const tr = state.tr.insertText(" ", 1);
|
||||
const newState = state.apply(tr);
|
||||
const result = plugin.spec.appendTransaction!([tr], state, newState);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.docChanged).toBe(true);
|
||||
});
|
||||
|
||||
it("enableSync:false: the plugin never mutates the doc (read-only viewer)", () => {
|
||||
// Build an editor with sync disabled. An orphan reference (no definition)
|
||||
// must NOT trigger a definition insertion — the document is left untouched.
|
||||
const editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
FootnoteReference.configure({ enableSync: false }),
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
],
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "x" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// A local edit that would normally trigger orphan-definition synthesis.
|
||||
editor.commands.insertContentAt(1, "y");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
// No definition (and no list) was ever created — sync is disabled.
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
|
||||
// Numbering decorations still work: the reference is numbered 1.
|
||||
expect(getFootnoteNumber(editor.state, "orphan")).toBe(1);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Numbering cache (Fix 2). NodeViews must read footnote numbers from the
|
||||
* numbering plugin's cached map (updated once per doc change) rather than
|
||||
* recomputing the whole map per render. We assert the cache exists, is correct,
|
||||
* and stays current across edits.
|
||||
*/
|
||||
describe("footnote numbering cache", () => {
|
||||
it("exposes correct numbers via getFootnoteNumber and updates on edits", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [{ type: "paragraph" }],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "y" },
|
||||
content: [{ type: "paragraph" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// The cache mirrors computeFootnoteNumbers — but is read in O(1) per id.
|
||||
expect(getFootnoteNumber(editor.state, "x")).toBe(1);
|
||||
expect(getFootnoteNumber(editor.state, "y")).toBe(2);
|
||||
// The cached map is the SAME values a fresh full computation would yield.
|
||||
const fresh = computeFootnoteNumbers(editor.state.doc);
|
||||
expect(getFootnoteNumber(editor.state, "x")).toBe(fresh.get("x"));
|
||||
expect(getFootnoteNumber(editor.state, "y")).toBe(fresh.get("y"));
|
||||
|
||||
// After inserting a new earlier reference, the cache updates so the numbers
|
||||
// shift (decoration-only numbering follows reference order).
|
||||
editor.commands.insertContentAt(1, {
|
||||
type: FOOTNOTE_REFERENCE_NAME,
|
||||
attrs: { id: "z" },
|
||||
});
|
||||
expect(getFootnoteNumber(editor.state, "z")).toBe(1);
|
||||
expect(getFootnoteNumber(editor.state, "x")).toBe(2);
|
||||
expect(getFootnoteNumber(editor.state, "y")).toBe(3);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
56
packages/editor-ext/src/lib/footnote/footnotes-list.ts
Normal file
56
packages/editor-ext/src/lib/footnote/footnotes-list.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { FOOTNOTES_LIST_NAME } from "./footnote-util";
|
||||
|
||||
export interface FootnotesListOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block container that holds all footnote definitions. There is a single
|
||||
* instance per document and it is always the last child of the doc (enforced by
|
||||
* the sync plugin). Modeled on the callout block node.
|
||||
*/
|
||||
export const FootnotesList = Node.create<FootnotesListOptions>({
|
||||
name: FOOTNOTES_LIST_NAME,
|
||||
|
||||
group: "block",
|
||||
content: "footnoteDefinition+",
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
defining: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "section[data-footnotes]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"section",
|
||||
mergeAttributes(
|
||||
{ "data-footnotes": "", class: "footnotes" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
if (!this.options.view) return null;
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
6
packages/editor-ext/src/lib/footnote/index.ts
Normal file
6
packages/editor-ext/src/lib/footnote/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./footnote-util";
|
||||
export * from "./footnote-reference";
|
||||
export * from "./footnotes-list";
|
||||
export * from "./footnote-definition";
|
||||
export * from "./footnote-numbering";
|
||||
export * from "./footnote-sync";
|
||||
116
packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts
Normal file
116
packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
encodeHtmlEmbedSource,
|
||||
decodeHtmlEmbedSource,
|
||||
} from "./html-embed";
|
||||
|
||||
// Unit coverage for the base64 codec used by the htmlEmbed node's
|
||||
// data-source attribute (html-embed.ts). The codec has two branches:
|
||||
// - the BROWSER branch: btoa(encodeURIComponent(s)) / decodeURIComponent(atob(s));
|
||||
// - the NODE fallback: Buffer.from(..).toString("base64") / Buffer.from(s,"base64").
|
||||
// Server-side schema parsing (htmlToJson with no global btoa/atob) hits the
|
||||
// fallback, so both branches must round-trip identically; otherwise an embed
|
||||
// encoded in the browser would decode wrong on the server (or vice versa).
|
||||
//
|
||||
// We force the fallback by temporarily DELETING globalThis.btoa/atob (jsdom
|
||||
// provides them in this env), restoring them after each test so the suite stays
|
||||
// hermetic.
|
||||
|
||||
const realBtoa = globalThis.btoa;
|
||||
const realAtob = globalThis.atob;
|
||||
|
||||
function deleteBase64Globals(): void {
|
||||
// @ts-expect-error — intentionally removing the globals to exercise the
|
||||
// `typeof btoa !== "function"` Node fallback branch in the codec.
|
||||
delete globalThis.btoa;
|
||||
// @ts-expect-error — see above.
|
||||
delete globalThis.atob;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Always restore so one test's stubbing never leaks into another.
|
||||
globalThis.btoa = realBtoa;
|
||||
globalThis.atob = realAtob;
|
||||
});
|
||||
|
||||
describe("html-embed codec — browser btoa/atob branch", () => {
|
||||
it("round-trips ASCII source", () => {
|
||||
const src = "<script>alert(1)</script>";
|
||||
const enc = encodeHtmlEmbedSource(src);
|
||||
expect(enc).not.toBe("");
|
||||
// base64 of the encodeURIComponent form never contains a raw '<'.
|
||||
expect(enc).not.toContain("<");
|
||||
expect(decodeHtmlEmbedSource(enc)).toBe(src);
|
||||
});
|
||||
|
||||
it("round-trips UTF-8 / non-Latin1 source (the reason for encodeURIComponent)", () => {
|
||||
const src = '<p>héllo → 世界 𝕏</p>';
|
||||
const enc = encodeHtmlEmbedSource(src);
|
||||
expect(decodeHtmlEmbedSource(enc)).toBe(src);
|
||||
});
|
||||
});
|
||||
|
||||
describe("html-embed codec — Node Buffer fallback branch", () => {
|
||||
it("encode uses the Buffer fallback when btoa is unavailable and still round-trips (UTF-8)", () => {
|
||||
const src = '<div>héllo → 世界 𝕏</div>';
|
||||
|
||||
deleteBase64Globals();
|
||||
// With the globals gone, encode must take the Buffer path...
|
||||
const encFallback = encodeHtmlEmbedSource(src);
|
||||
expect(encFallback).not.toBe("");
|
||||
// ...and decode (also via Buffer) must recover the exact source.
|
||||
expect(decodeHtmlEmbedSource(encFallback)).toBe(src);
|
||||
});
|
||||
|
||||
it("the Buffer fallback produces the SAME bytes the browser branch does (cross-env parity)", () => {
|
||||
const src = '<span>café — 日本語</span>';
|
||||
|
||||
// Browser branch (globals intact).
|
||||
const encBrowser = encodeHtmlEmbedSource(src);
|
||||
|
||||
// Fallback branch.
|
||||
deleteBase64Globals();
|
||||
const encFallback = encodeHtmlEmbedSource(src);
|
||||
|
||||
// Identical base64 => an embed encoded in either environment decodes
|
||||
// identically in the other (server <-> client losslessness).
|
||||
expect(encFallback).toBe(encBrowser);
|
||||
|
||||
// And the fallback can decode what the browser produced.
|
||||
expect(decodeHtmlEmbedSource(encBrowser)).toBe(src);
|
||||
});
|
||||
|
||||
it("empty string -> '' on both encode and decode in the fallback (early return, branch never reached)", () => {
|
||||
deleteBase64Globals();
|
||||
expect(encodeHtmlEmbedSource("")).toBe("");
|
||||
expect(decodeHtmlEmbedSource("")).toBe("");
|
||||
});
|
||||
|
||||
it("decode of malformed base64 -> '' via the catch branch (fallback)", () => {
|
||||
// In the Buffer fallback, Buffer.from(..,'base64') is lenient and never
|
||||
// throws, so to hit the catch we need a payload whose DECODED bytes are an
|
||||
// invalid percent-escape, which makes decodeURIComponent throw. base64 of a
|
||||
// lone '%' decodes back to '%', and decodeURIComponent('%') is a URIError.
|
||||
const badBase64 = Buffer.from("%", "utf-8").toString("base64"); // "JQ=="
|
||||
|
||||
deleteBase64Globals();
|
||||
// Sanity: the raw decode really does throw, so we're exercising the catch.
|
||||
expect(() =>
|
||||
decodeURIComponent(Buffer.from(badBase64, "base64").toString("utf-8")),
|
||||
).toThrow();
|
||||
// The codec swallows it and returns "" rather than propagating.
|
||||
expect(decodeHtmlEmbedSource(badBase64)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("html-embed codec — decode of malformed input (browser branch)", () => {
|
||||
it("returns '' for input atob rejects (catch branch)", () => {
|
||||
// atob throws on characters outside the base64 alphabet; the codec catches
|
||||
// it and returns "" instead of throwing.
|
||||
expect(decodeHtmlEmbedSource("@@not-base64@@")).toBe("");
|
||||
});
|
||||
|
||||
it("empty string short-circuits to '' (never calls atob)", () => {
|
||||
expect(decodeHtmlEmbedSource("")).toBe("");
|
||||
});
|
||||
});
|
||||
105
packages/editor-ext/src/lib/markdown/html-embed-marked.spec.ts
Normal file
105
packages/editor-ext/src/lib/markdown/html-embed-marked.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { htmlEmbedExtension } from "./utils/html-embed.marked";
|
||||
import { markdownToHtml } from "./index";
|
||||
import { encodeHtmlEmbedSource } from "../html-embed/html-embed";
|
||||
|
||||
// CONTRACT tests for the marked block tokenizer that rebuilds an htmlEmbed node
|
||||
// from the `<!--html-embed:BASE64-->` marker (html-embed.marked.ts), plus the
|
||||
// observable round-trip through markdownToHtml.
|
||||
//
|
||||
// These pin the REAL tokenizer behaviour the import path depends on:
|
||||
// - the tokenizer rule is anchored (^) and only accepts the base64 alphabet
|
||||
// [A-Za-z0-9+/=], so a marker with non-base64 chars is NOT tokenized and
|
||||
// survives as a literal HTML comment (not silently turned into something the
|
||||
// server's strip no longer recognizes);
|
||||
// - start() reports the correct index of the next marker so marked invokes the
|
||||
// tokenizer at the right offset when a marker sits mid-document / after text;
|
||||
// - a marker with surrounding text on the SAME line is split out into its own
|
||||
// embed div while the surrounding text becomes ordinary paragraphs.
|
||||
//
|
||||
// The contract is asserted against the actual exported extension and pipeline —
|
||||
// no behaviour is invented; the expectations were read off the real tokenizer.
|
||||
|
||||
const SAMPLE = "<b>x</b>";
|
||||
const ENC = encodeHtmlEmbedSource(SAMPLE);
|
||||
|
||||
describe("htmlEmbed marked tokenizer — start()", () => {
|
||||
it("returns the index of a marker that sits mid-document", () => {
|
||||
const src = `hello world <!--html-embed:${ENC}-->`;
|
||||
expect(htmlEmbedExtension.start(src)).toBe(src.indexOf("<!--html-embed:"));
|
||||
});
|
||||
|
||||
it("returns 0 when the marker is at the very start", () => {
|
||||
expect(htmlEmbedExtension.start(`<!--html-embed:${ENC}-->`)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns -1 when there is no marker", () => {
|
||||
expect(htmlEmbedExtension.start("no marker here")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("htmlEmbed marked tokenizer — tokenizer()", () => {
|
||||
it("tokenizes a marker at the start of the input, capturing the base64 payload", () => {
|
||||
const token = htmlEmbedExtension.tokenizer(`<!--html-embed:${ENC}-->`);
|
||||
expect(token).toBeTruthy();
|
||||
expect(token!.type).toBe("htmlEmbed");
|
||||
expect(token!.raw).toBe(`<!--html-embed:${ENC}-->`);
|
||||
expect(token!.encoded).toBe(ENC);
|
||||
});
|
||||
|
||||
it("tokenizes an EMPTY marker (the [A-Za-z0-9+/=]* class allows zero chars)", () => {
|
||||
const token = htmlEmbedExtension.tokenizer("<!--html-embed:-->");
|
||||
expect(token).toBeTruthy();
|
||||
expect(token!.encoded).toBe("");
|
||||
expect(token!.raw).toBe("<!--html-embed:-->");
|
||||
});
|
||||
|
||||
it("does NOT tokenize when text precedes the marker (rule is anchored ^)", () => {
|
||||
// marked relies on start() to advance to the marker; the tokenizer itself
|
||||
// only matches at offset 0, so a non-anchored call returns undefined.
|
||||
expect(
|
||||
htmlEmbedExtension.tokenizer(`hello <!--html-embed:${ENC}-->`),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT tokenize a marker containing a non-base64 char ('$')", () => {
|
||||
expect(
|
||||
htmlEmbedExtension.tokenizer("<!--html-embed:ab$cd-->"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT tokenize a marker containing a space", () => {
|
||||
expect(
|
||||
htmlEmbedExtension.tokenizer("<!--html-embed:ab cd-->"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renderer emits the embed div the node's parseHTML recognizes", () => {
|
||||
const token = htmlEmbedExtension.tokenizer(`<!--html-embed:${ENC}-->`)!;
|
||||
const html = htmlEmbedExtension.renderer(token as any);
|
||||
expect(html).toBe(
|
||||
`<div data-type="htmlEmbed" data-source="${ENC}"></div>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("htmlEmbed marked tokenizer — markdownToHtml round-trip", () => {
|
||||
it("splits a marker out of surrounding same-line text into its own embed div", async () => {
|
||||
const html = await markdownToHtml(`before <!--html-embed:${ENC}--> after`);
|
||||
// The marker became the embed div...
|
||||
expect(html).toContain(
|
||||
`<div data-type="htmlEmbed" data-source="${ENC}"></div>`,
|
||||
);
|
||||
// ...and the surrounding text survived as ordinary paragraph content.
|
||||
expect(html).toContain("before");
|
||||
expect(html).toContain("after");
|
||||
});
|
||||
|
||||
it("leaves a marker with non-base64 chars as a literal comment (NOT an embed div)", async () => {
|
||||
const html = await markdownToHtml("<!--html-embed:ab$cd-->");
|
||||
// It is NOT tokenized into an embed div the server would strip...
|
||||
expect(html).not.toContain('data-type="htmlEmbed"');
|
||||
// ...it passes through unchanged as a literal HTML comment.
|
||||
expect(html).toContain("<!--html-embed:ab$cd-->");
|
||||
});
|
||||
});
|
||||
170
packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts
Normal file
170
packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { marked } from "marked";
|
||||
import { deriveFootnoteId } from "../../footnote/footnote-util";
|
||||
|
||||
/**
|
||||
* Pandoc/GFM footnote support for the marked (Markdown -> HTML) pipeline.
|
||||
*
|
||||
* Two pieces:
|
||||
* - an INLINE tokenizer for `[^id]` references -> <sup data-footnote-ref
|
||||
* data-id="id"> (matches the editor-ext FootnoteReference renderHTML);
|
||||
* - a document hook (`preprocess`/`walkTokens` is awkward for collecting +
|
||||
* removing definitions, so we use a regex preprocessing step instead) that
|
||||
* pulls every `[^id]: text` definition line out of the body and appends a
|
||||
* single <section data-footnotes> with one <div data-footnote-def> per
|
||||
* definition, so the round-trip rebuilds footnotesList + footnoteDefinition.
|
||||
*
|
||||
* Only definitions that have a matching reference are emitted (and vice-versa
|
||||
* the sync plugin fills any gaps on the editor side), keeping the output valid.
|
||||
*/
|
||||
|
||||
const DEFINITION_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
||||
const REFERENCE_RE = /\[\^([^\]\s]+)\]/;
|
||||
|
||||
interface FootnoteRefToken {
|
||||
type: "footnoteRef";
|
||||
raw: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const footnoteReferenceExtension = {
|
||||
name: "footnoteRef",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.match(/\[\^/)?.index ?? -1;
|
||||
},
|
||||
tokenizer(src: string): FootnoteRefToken | undefined {
|
||||
const match = REFERENCE_RE.exec(src);
|
||||
// Only match at the very start of the remaining inline source.
|
||||
if (match && match.index === 0) {
|
||||
return {
|
||||
type: "footnoteRef",
|
||||
raw: match[0],
|
||||
id: match[1],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
renderer(token: FootnoteRefToken) {
|
||||
return `<sup data-footnote-ref data-id="${escapeAttr(token.id)}"></sup>`;
|
||||
},
|
||||
};
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return String(value).replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `[^id]: text` definition lines from the markdown body, returning the
|
||||
* cleaned body plus a rendered <section data-footnotes> (empty string when no
|
||||
* definitions). Call this BEFORE marked.parse and append the section to the
|
||||
* resulting HTML.
|
||||
*/
|
||||
export function extractFootnoteDefinitions(markdown: string): {
|
||||
body: string;
|
||||
section: string;
|
||||
} {
|
||||
const lines = markdown.split("\n");
|
||||
const bodyLines: string[] = [];
|
||||
const definitions: Array<{ id: string; text: string }> = [];
|
||||
|
||||
// Track fenced-code state so a `[^id]: ...` line that merely SHOWS footnote
|
||||
// syntax inside a ``` / ~~~ code block is left in the body verbatim and not
|
||||
// mistaken for a real definition.
|
||||
let fence: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[2][0];
|
||||
if (fence === null) {
|
||||
fence = marker; // opening fence
|
||||
} else if (marker === fence) {
|
||||
fence = null; // closing fence (matching delimiter type)
|
||||
}
|
||||
bodyLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const m = fence === null ? DEFINITION_RE.exec(line) : null;
|
||||
if (m) {
|
||||
definitions.push({ id: m[1], text: m[2] });
|
||||
} else {
|
||||
bodyLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (definitions.length === 0) {
|
||||
return { body: markdown, section: "" };
|
||||
}
|
||||
|
||||
// De-duplicate colliding definition ids. Two definitions sharing an id (e.g.
|
||||
// `[^d]: first` / `[^d]: second`) would otherwise collapse into one footnote
|
||||
// downstream (the editor's last-wins sync). Rename each colliding id to a
|
||||
// DETERMINISTIC derived one AND rewrite the corresponding `[^id]` reference
|
||||
// marker so the (reference, definition) pairing stays 1:1. The FIRST
|
||||
// definition keeps the id and pairs with the FIRST `[^id]` marker; the Nth
|
||||
// duplicate gets the derived id `${id}__${N}` and rewrites the Nth `[^id]`
|
||||
// marker. If there are fewer markers than definitions, the surplus definition
|
||||
// keeps a derived (orphan) id so it is never silently merged away.
|
||||
//
|
||||
// The id is derived (deriveFootnoteId), NOT random: importing the same
|
||||
// markdown through two paths (here and the MCP mirror) must yield identical
|
||||
// ids, and re-importing the same markdown twice must be stable.
|
||||
let dedupedBody = bodyLines.join("\n");
|
||||
// Every original definition id is reserved up front so a derived id can never
|
||||
// collide with an unrelated original id present in the document.
|
||||
const taken = new Set<string>(definitions.map((d) => d.id));
|
||||
const seenDefIds = new Map<string, number>(); // original id -> how many seen
|
||||
for (const def of definitions) {
|
||||
const originalId = def.id;
|
||||
const count = seenDefIds.get(originalId) ?? 0;
|
||||
seenDefIds.set(originalId, count + 1);
|
||||
if (count === 0) continue; // first definition keeps its id
|
||||
|
||||
// count is the 0-based number of PRIOR occurrences; this is occurrence
|
||||
// (count + 1), i.e. 2 for the first duplicate, 3 for the next, ...
|
||||
const newId = deriveFootnoteId(originalId, count + 1, taken);
|
||||
taken.add(newId);
|
||||
def.id = newId;
|
||||
|
||||
// Rewrite the NEXT still-unrewritten `[^originalId]` marker that does not
|
||||
// belong to the keeper definition. After a prior duplicate rewrote its
|
||||
// marker (to `[^someNewId]`), it no longer matches `[^originalId]`, so the
|
||||
// remaining matches are: index 0 = the keeper's marker (left alone), index 1
|
||||
// = this duplicate's marker. Rewrite index 1.
|
||||
let occurrence = 0;
|
||||
let rewritten = false;
|
||||
const re = new RegExp(`\\[\\^${escapeRegExp(originalId)}\\]`, "g");
|
||||
dedupedBody = dedupedBody.replace(re, (match) => {
|
||||
const idx = occurrence++;
|
||||
if (!rewritten && idx === 1) {
|
||||
rewritten = true;
|
||||
return `[^${newId}]`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
// If there was no second marker (more definitions than references), the
|
||||
// duplicate simply survives as an orphan with its fresh id — no body change.
|
||||
}
|
||||
|
||||
const defsHtml = definitions
|
||||
.map((d) => {
|
||||
// Render the definition text as inline markdown so emphasis/links inside
|
||||
// a footnote survive the round-trip; wrap in a paragraph (the node's
|
||||
// content is paragraph+).
|
||||
const inner = marked.parseInline(d.text || "");
|
||||
return `<div data-footnote-def data-id="${escapeAttr(
|
||||
d.id,
|
||||
)}"><p>${inner}</p></div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return {
|
||||
body: dedupedBody,
|
||||
section: `<section data-footnotes>${defsHtml}</section>`,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { marked } from "marked";
|
||||
import { calloutExtension } from "./callout.marked";
|
||||
import { mathBlockExtension } from "./math-block.marked";
|
||||
import { mathInlineExtension } from "./math-inline.marked";
|
||||
import {
|
||||
footnoteReferenceExtension,
|
||||
extractFootnoteDefinitions,
|
||||
} from "./footnote.marked";
|
||||
import { htmlEmbedExtension } from "./html-embed.marked";
|
||||
|
||||
marked.use({
|
||||
@@ -39,6 +43,7 @@ marked.use({
|
||||
calloutExtension,
|
||||
mathBlockExtension,
|
||||
mathInlineExtension,
|
||||
footnoteReferenceExtension,
|
||||
htmlEmbedExtension,
|
||||
],
|
||||
});
|
||||
@@ -54,5 +59,16 @@ export function markdownToHtml(
|
||||
.replace(YAML_FONT_MATTER_REGEX, "")
|
||||
.trimStart();
|
||||
|
||||
return marked.parse(markdown).toString();
|
||||
// Pull `[^id]: ...` definition lines out of the body, render the body, then
|
||||
// append a single <section data-footnotes> so the round-trip rebuilds the
|
||||
// footnotesList + footnoteDefinition nodes.
|
||||
const { body, section } = extractFootnoteDefinitions(markdown);
|
||||
|
||||
const parsed = marked.parse(body);
|
||||
if (!section) return parsed;
|
||||
|
||||
if (typeof parsed === "string") {
|
||||
return parsed + section;
|
||||
}
|
||||
return parsed.then((html) => html + section);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,44 @@ function sanitizeMdLinkText(value: string): string {
|
||||
.replace(/[\r\n]+/g, ' ');
|
||||
}
|
||||
|
||||
// Tags turndown treats as void (self-closing). Footnote references render as an
|
||||
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
|
||||
// without marking it void, turndown's blank-node removal drops it before our
|
||||
// rule runs, losing the `[^id]` marker. Mirrors turndown's built-in list.
|
||||
const TURNDOWN_VOID_ELEMENTS = [
|
||||
'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT',
|
||||
'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR',
|
||||
];
|
||||
|
||||
function isVoidNode(node: any): boolean {
|
||||
const name = node?.nodeName?.toUpperCase?.();
|
||||
if (!name) return false;
|
||||
if (name === 'SUP' && node.hasAttribute?.('data-footnote-ref')) {
|
||||
return true;
|
||||
}
|
||||
return TURNDOWN_VOID_ELEMENTS.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty <sup data-footnote-ref> is "blank" to turndown, which removes blank
|
||||
* inline nodes (RootNode/Node use a module-level isVoid the options cannot
|
||||
* override). To survive, inject the id as text content so the node is non-blank;
|
||||
* the footnoteReference rule then reads data-id and emits `[^id]`.
|
||||
*/
|
||||
function fillEmptyFootnoteRefs(html: string): string {
|
||||
return html.replace(
|
||||
/<sup\b([^>]*\bdata-footnote-ref\b[^>]*)>\s*<\/sup>/gi,
|
||||
(_m, attrs) => `<sup${attrs}></sup>`,
|
||||
);
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
hr: '---',
|
||||
bulletListMarker: '-',
|
||||
isVoid: isVoidNode,
|
||||
});
|
||||
|
||||
turndownService.use([
|
||||
@@ -35,8 +67,12 @@ export function htmlToMarkdown(html: string): string {
|
||||
htmlEmbed,
|
||||
image,
|
||||
video,
|
||||
footnoteReference,
|
||||
footnotesList,
|
||||
]);
|
||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||
return turndownService
|
||||
.turndown(fillEmptyFootnoteRefs(html))
|
||||
.replaceAll('<br>', ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,6 +266,57 @@ function image(turndownService: _TurndownService) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Footnote reference (inline atom) -> pandoc/GFM marker `[^id]`.
|
||||
* The visible number is derived (not stored), so the id is the stable anchor.
|
||||
*/
|
||||
function footnoteReference(turndownService: _TurndownService) {
|
||||
turndownService.addRule('footnoteReference', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'SUP' && node.hasAttribute('data-footnote-ref')
|
||||
);
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const id = node.getAttribute('data-id') || '';
|
||||
return id ? `[^${id}]` : '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Footnotes container -> the list of `[^id]: text` definitions at the end of
|
||||
* the document (one per line). Each footnoteDefinition inside emits its own
|
||||
* `[^id]: ...` line; turndown joins them with the surrounding block spacing.
|
||||
*/
|
||||
function footnotesList(turndownService: _TurndownService) {
|
||||
turndownService.addRule('footnoteDefinition', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'DIV' && node.hasAttribute('data-footnote-def')
|
||||
);
|
||||
},
|
||||
replacement: function (content: string, node: HTMLInputElement) {
|
||||
const id = node.getAttribute('data-id') || '';
|
||||
// Collapse internal newlines so the definition stays a single MD line;
|
||||
// continuation lines are a v2 refinement.
|
||||
const text = content.replace(/\s*\n+\s*/g, ' ').trim();
|
||||
return id ? `\n[^${id}]: ${text}\n` : '';
|
||||
},
|
||||
});
|
||||
|
||||
turndownService.addRule('footnotesList', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'SECTION' && node.hasAttribute('data-footnotes')
|
||||
);
|
||||
},
|
||||
replacement: function (content: string) {
|
||||
return `\n\n${content.trim()}\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function video(turndownService: _TurndownService) {
|
||||
turndownService.addRule('video', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
|
||||
88
packages/editor-ext/src/lib/page-embed/page-embed.spec.ts
Normal file
88
packages/editor-ext/src/lib/page-embed/page-embed.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { PageEmbed } from "./page-embed";
|
||||
|
||||
// CONTRACT tests for the PageEmbed node's parse/render round-trip
|
||||
// (page-embed.ts). The whole-page live embed stores ONLY a `sourcePageId`
|
||||
// reference; renderHTML must serialize it as `data-source-page-id` and parseHTML
|
||||
// must recover it. If this attribute mapping drifts, an embed saved to HTML loses
|
||||
// its target page on reload (the node view would have nothing to fetch).
|
||||
//
|
||||
// We assert at the editor-ext schema level using the same Tiptap utilities the
|
||||
// other editor-ext tests use (getSchema + @tiptap/html generateHTML/generateJSON
|
||||
// over a jsdom DOM), driving a real HTML -> node JSON -> HTML round-trip through
|
||||
// the node's actual addAttributes()/parseHTML()/renderHTML().
|
||||
|
||||
// Minimal schema: a doc of blocks, plus the PageEmbed block node under test.
|
||||
const extensions = [Document, Paragraph, Text, PageEmbed];
|
||||
|
||||
describe("PageEmbed schema", () => {
|
||||
it("registers the pageEmbed node in the schema", () => {
|
||||
const schema = getSchema(extensions);
|
||||
expect(schema.nodes.pageEmbed).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageEmbed parse/render round-trip", () => {
|
||||
it("recovers sourcePageId from data-source-page-id on parse (HTML -> JSON)", () => {
|
||||
const html = `<div data-type="pageEmbed" data-source-page-id="pg-123"></div>`;
|
||||
const json = generateJSON(html, extensions);
|
||||
|
||||
const node = json.content?.[0];
|
||||
expect(node?.type).toBe("pageEmbed");
|
||||
expect(node?.attrs?.sourcePageId).toBe("pg-123");
|
||||
});
|
||||
|
||||
it("emits data-source-page-id on render (JSON -> HTML)", () => {
|
||||
const json = {
|
||||
type: "doc",
|
||||
content: [{ type: "pageEmbed", attrs: { sourcePageId: "pg-456" } }],
|
||||
};
|
||||
const html = generateHTML(json, extensions);
|
||||
|
||||
expect(html).toContain('data-type="pageEmbed"');
|
||||
expect(html).toContain('data-source-page-id="pg-456"');
|
||||
});
|
||||
|
||||
it("survives a full HTML -> node -> HTML round-trip (attribute preserved)", () => {
|
||||
const start = `<div data-type="pageEmbed" data-source-page-id="pg-789"></div>`;
|
||||
|
||||
// HTML -> node JSON -> HTML.
|
||||
const json = generateJSON(start, extensions);
|
||||
const html = generateHTML(json, extensions);
|
||||
|
||||
// The id survived the round-trip in the serialized HTML...
|
||||
expect(html).toContain('data-source-page-id="pg-789"');
|
||||
|
||||
// ...and re-parsing the round-tripped HTML yields the same id (stable across
|
||||
// an extra pass — no loss, no duplication).
|
||||
const json2 = generateJSON(html, extensions);
|
||||
expect(json2.content?.[0]?.attrs?.sourcePageId).toBe("pg-789");
|
||||
});
|
||||
|
||||
it("omits data-source-page-id entirely when sourcePageId is null (renderHTML guard)", () => {
|
||||
// The renderHTML maps a null/empty id to {} (no attribute), so an embed
|
||||
// without a target page does not emit a stray empty attribute.
|
||||
const json = {
|
||||
type: "doc",
|
||||
content: [{ type: "pageEmbed", attrs: { sourcePageId: null } }],
|
||||
};
|
||||
const html = generateHTML(json, extensions);
|
||||
|
||||
expect(html).toContain('data-type="pageEmbed"');
|
||||
expect(html).not.toContain("data-source-page-id");
|
||||
});
|
||||
|
||||
it("parses a div without the attribute to a null sourcePageId (default)", () => {
|
||||
const html = `<div data-type="pageEmbed"></div>`;
|
||||
const json = generateJSON(html, extensions);
|
||||
|
||||
expect(json.content?.[0]?.type).toBe("pageEmbed");
|
||||
// getAttribute returns null when absent; parseHTML returns it verbatim.
|
||||
expect(json.content?.[0]?.attrs?.sourcePageId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Minimal vitest setup for @docmost/editor-ext (mirrors apps/client's config,
|
||||
// trimmed to what the markdown/html-embed round-trip tests need). The markdown
|
||||
// utils run in plain Node (marked + turndown), so no jsdom/react plugin is
|
||||
// required here.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.{test,spec}.ts"],
|
||||
},
|
||||
|
||||
@@ -263,10 +263,145 @@ function bridgeTaskLists(html) {
|
||||
}
|
||||
return document.body.innerHTML;
|
||||
}
|
||||
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
|
||||
// marker becomes <sup data-footnote-ref data-id="id">, and `[^id]: text`
|
||||
// definition lines are collected into a single <section data-footnotes>.
|
||||
const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
||||
const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
|
||||
function escapeFootnoteAttr(value) {
|
||||
return String(value).replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
function escapeFootnoteRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
/**
|
||||
* Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of
|
||||
* an original id `X` during definition dedup.
|
||||
*
|
||||
* EXACT MIRROR of editor-ext `deriveFootnoteId`
|
||||
* (packages/editor-ext/src/lib/footnote/footnote-util.ts). These two copies MUST
|
||||
* STAY IN SYNC: the same markdown imported through the editor and through this
|
||||
* MCP path has to produce identical ids, and the sync plugin (which re-ids on
|
||||
* every collaborating client) relies on the same scheme to converge. NEVER use
|
||||
* Math.random()/Date.now()/uuid here — a random id would diverge across clients.
|
||||
*
|
||||
* Scheme: base candidate `${originalId}__${occurrence}` (e.g. `X__2`), bumped
|
||||
* with a stable alphabetic suffix (`X__2b`, `X__2c`, ...) until it is not in
|
||||
* `taken` (the set of ids already present / already minted — pure doc state).
|
||||
*/
|
||||
function deriveFootnoteId(originalId, occurrence, taken) {
|
||||
let candidate = `${originalId}__${occurrence}`;
|
||||
let n = 0;
|
||||
while (taken.has(candidate)) {
|
||||
n += 1;
|
||||
candidate = `${originalId}__${occurrence}${footnoteSuffix(n)}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
/** Map 1 -> "b", 2 -> "c", ... (mirror of editor-ext `suffix`). */
|
||||
function footnoteSuffix(n) {
|
||||
let out = "";
|
||||
let x = n;
|
||||
while (x > 0) {
|
||||
const rem = (x - 1) % 25;
|
||||
out = String.fromCharCode(98 + rem) + out; // 98 = 'b'
|
||||
x = Math.floor((x - 1) / 25);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const footnoteRefMarkedExtension = {
|
||||
name: "footnoteRef",
|
||||
level: "inline",
|
||||
start(src) {
|
||||
return src.match(/\[\^/)?.index ?? -1;
|
||||
},
|
||||
tokenizer(src) {
|
||||
const match = FOOTNOTE_REF_RE.exec(src);
|
||||
if (match && match.index === 0) {
|
||||
return { type: "footnoteRef", raw: match[0], id: match[1] };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
renderer(token) {
|
||||
return `<sup data-footnote-ref data-id="${escapeFootnoteAttr(token.id)}"></sup>`;
|
||||
},
|
||||
};
|
||||
marked.use({ extensions: [footnoteRefMarkedExtension] });
|
||||
/**
|
||||
* Pull `[^id]: text` definition lines out of the body and render a single
|
||||
* <section data-footnotes> for them (or "" when there are none).
|
||||
*/
|
||||
function extractFootnotes(markdown) {
|
||||
const lines = markdown.split("\n");
|
||||
const bodyLines = [];
|
||||
const defs = [];
|
||||
// Track fenced-code state so a `[^id]: ...` line shown inside a ``` / ~~~ code
|
||||
// block is preserved verbatim and not treated as a footnote definition.
|
||||
let fence = null;
|
||||
for (const line of lines) {
|
||||
const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[2][0];
|
||||
if (fence === null)
|
||||
fence = marker;
|
||||
else if (marker === fence)
|
||||
fence = null;
|
||||
bodyLines.push(line);
|
||||
continue;
|
||||
}
|
||||
const m = fence === null ? FOOTNOTE_DEF_RE.exec(line) : null;
|
||||
if (m)
|
||||
defs.push({ id: m[1], text: m[2] });
|
||||
else
|
||||
bodyLines.push(line);
|
||||
}
|
||||
if (defs.length === 0)
|
||||
return { body: markdown, section: "" };
|
||||
// De-duplicate colliding definition ids (mirror of editor-ext
|
||||
// extractFootnoteDefinitions). Two definitions sharing an id would otherwise
|
||||
// collapse into one footnote downstream; rename each colliding id to a
|
||||
// DETERMINISTIC derived one (NOT random) and rewrite the corresponding `[^id]`
|
||||
// marker so the (reference, definition) pairing stays 1:1. Determinism lets
|
||||
// the same markdown imported here and via the editor produce identical ids.
|
||||
let dedupedBody = bodyLines.join("\n");
|
||||
const taken = new Set(defs.map((d) => d.id));
|
||||
const seenDefIds = new Map();
|
||||
for (const def of defs) {
|
||||
const originalId = def.id;
|
||||
const count = seenDefIds.get(originalId) ?? 0;
|
||||
seenDefIds.set(originalId, count + 1);
|
||||
if (count === 0)
|
||||
continue; // first definition keeps its id
|
||||
const newId = deriveFootnoteId(originalId, count + 1, taken);
|
||||
taken.add(newId);
|
||||
def.id = newId;
|
||||
// Remaining `[^originalId]` matches: index 0 = keeper's marker (left alone),
|
||||
// index 1 = this duplicate's marker. Rewrite index 1.
|
||||
let occurrence = 0;
|
||||
let rewritten = false;
|
||||
const re = new RegExp(`\\[\\^${escapeFootnoteRegExp(originalId)}\\]`, "g");
|
||||
dedupedBody = dedupedBody.replace(re, (match) => {
|
||||
const idx = occurrence++;
|
||||
if (!rewritten && idx === 1) {
|
||||
rewritten = true;
|
||||
return `[^${newId}]`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
const inner = defs
|
||||
.map((d) => `<div data-footnote-def data-id="${escapeFootnoteAttr(d.id)}"><p>${marked.parseInline(d.text || "")}</p></div>`)
|
||||
.join("");
|
||||
return {
|
||||
body: dedupedBody,
|
||||
section: `<section data-footnotes>${inner}</section>`,
|
||||
};
|
||||
}
|
||||
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */
|
||||
export async function markdownToProseMirror(markdownContent) {
|
||||
const withCallouts = await preprocessCallouts(markdownContent);
|
||||
const html = await marked.parse(withCallouts);
|
||||
const { body, section } = extractFootnotes(withCallouts);
|
||||
const html = (await marked.parse(body)) + section;
|
||||
const bridged = bridgeTaskLists(html);
|
||||
return generateJSON(bridged, docmostExtensions);
|
||||
}
|
||||
|
||||
@@ -79,10 +79,26 @@ function countUniqueLinks(doc) {
|
||||
visit(doc);
|
||||
return hrefs.size;
|
||||
}
|
||||
/** Count footnoteReference nodes anywhere under a node (reading order). */
|
||||
function countFootnoteRefs(node) {
|
||||
if (!node || typeof node !== "object")
|
||||
return 0;
|
||||
let n = node.type === "footnoteReference" ? 1 : 0;
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content)
|
||||
n += countFootnoteRefs(child);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
/**
|
||||
* Parse the ordered list of integers from `[N]` footnote markers found in the
|
||||
* BODY only (every top-level block before the first "Примечания..." notes
|
||||
* heading; if no such heading, the whole doc). Returned in reading order.
|
||||
* Ordered list of footnote marker numbers found in the BODY only (every
|
||||
* top-level block before the first "Примечания..." notes heading; if no such
|
||||
* heading, the whole doc), in reading order.
|
||||
*
|
||||
* Supports BOTH representations:
|
||||
* - real `footnoteReference` nodes (the current footnote feature) — numbered
|
||||
* 1..n by reading position, since their visible number is derived;
|
||||
* - legacy `[N]` text markers (older translated docs) — the literal N.
|
||||
*/
|
||||
function footnoteMarkers(doc, notesHeading) {
|
||||
const top = Array.isArray(doc?.content) ? doc.content : [];
|
||||
@@ -90,6 +106,15 @@ function footnoteMarkers(doc, notesHeading) {
|
||||
n.type === "heading" &&
|
||||
plainText(n).trim() === notesHeading);
|
||||
const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top;
|
||||
// Real footnoteReference nodes take precedence: when present, number them by
|
||||
// reading position (their displayed number is not stored).
|
||||
let refCount = 0;
|
||||
for (const block of bodyBlocks)
|
||||
refCount += countFootnoteRefs(block);
|
||||
if (refCount > 0) {
|
||||
return Array.from({ length: refCount }, (_, i) => i + 1);
|
||||
}
|
||||
// Fallback: legacy `[N]` text markers.
|
||||
const markers = [];
|
||||
const re = /\[(\d+)\]/g;
|
||||
for (const block of bodyBlocks) {
|
||||
|
||||
@@ -342,6 +342,78 @@ const Mention = Node.create({
|
||||
return ["span", { "data-type": "mention", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Footnote feature (mirror of packages/editor-ext/src/lib/footnote). Three
|
||||
* nodes connected by `id`:
|
||||
* - FootnoteReference: inline atom marker in the body (<sup data-footnote-ref>);
|
||||
* - FootnotesList: a single bottom container (<section data-footnotes>);
|
||||
* - FootnoteDefinition: one editable note keyed by id (<div data-footnote-def>).
|
||||
* The visible number is not stored; it is derived from reference order.
|
||||
*
|
||||
* priority 101 so this node's <sup> parse rule beats the Superscript mark's
|
||||
* <sup> rule (otherwise an empty reference is parsed as an empty superscript
|
||||
* mark and dropped). Keep in sync with editor-ext.
|
||||
*/
|
||||
const FootnoteReference = Node.create({
|
||||
name: "footnoteReference",
|
||||
priority: 101,
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-id"),
|
||||
renderHTML: (attrs) => attrs.id ? { "data-id": attrs.id } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "sup[data-footnote-ref]", priority: 100 }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["sup", { "data-footnote-ref": "", ...HTMLAttributes }];
|
||||
},
|
||||
});
|
||||
const FootnotesList = Node.create({
|
||||
name: "footnotesList",
|
||||
group: "block",
|
||||
content: "footnoteDefinition+",
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
defining: true,
|
||||
parseHTML() {
|
||||
return [{ tag: "section[data-footnotes]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["section", { "data-footnotes": "", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
const FootnoteDefinition = Node.create({
|
||||
name: "footnoteDefinition",
|
||||
content: "paragraph+",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-id"),
|
||||
renderHTML: (attrs) => attrs.id ? { "data-id": attrs.id } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-footnote-def]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", { "data-footnote-def": "", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
/** Inline KaTeX expression. Carries the LaTeX source in `text`. */
|
||||
const MathInline = Node.create({
|
||||
name: "mathInline",
|
||||
@@ -978,6 +1050,9 @@ export const docmostExtensions = [
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Mention,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
MathInline,
|
||||
MathBlock,
|
||||
Details,
|
||||
|
||||
@@ -388,6 +388,27 @@ export function convertProseMirrorToMarkdown(content) {
|
||||
// carry the real values), so escape it for the text context, not attrs.
|
||||
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
|
||||
}
|
||||
case "footnoteReference": {
|
||||
// Pandoc/GFM inline marker. The number is derived (not stored), so the
|
||||
// id is the stable anchor.
|
||||
const fnId = node.attrs?.id || "";
|
||||
return fnId ? `[^${fnId}]` : "";
|
||||
}
|
||||
case "footnotesList":
|
||||
// The container renders its definitions, each on its own `[^id]: ...`
|
||||
// line. A blank line separates the body from the notes block.
|
||||
return nodeContent.map(processNode).join("\n");
|
||||
case "footnoteDefinition": {
|
||||
const defId = node.attrs?.id || "";
|
||||
// Collapse the definition's paragraphs into a single line; multi-line
|
||||
// footnotes are a v2 refinement.
|
||||
const defText = nodeContent
|
||||
.map(processNode)
|
||||
.join(" ")
|
||||
.replace(/\s*\n+\s*/g, " ")
|
||||
.trim();
|
||||
return defId ? `[^${defId}]: ${defText}` : "";
|
||||
}
|
||||
case "attachment": {
|
||||
// BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but
|
||||
// the schema stores name/url (plus mime/size/attachmentId). Emit the
|
||||
|
||||
@@ -223,6 +223,59 @@ export function noteItem(inlineNodes) {
|
||||
],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id:
|
||||
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
|
||||
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
|
||||
*/
|
||||
export function footnoteDefinition(id, inlineNodes) {
|
||||
const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : [];
|
||||
return {
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id },
|
||||
content: [{ type: "paragraph", attrs: { id: freshId() }, content }],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Replace every `[N]` body marker and `\u0000FN<i>\u0000` comment placeholder in
|
||||
* an inline content array with a real `footnoteReference` node, in reading
|
||||
* order. `onMarker` is called for each replaced marker (with the original `[N]`
|
||||
* number or the placeholder index) and returns the fresh footnote id to attach
|
||||
* to the inserted node. Mutates `inline` in place.
|
||||
*/
|
||||
function replaceMarkersWithReferences(inline, onMarker) {
|
||||
const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g;
|
||||
for (let i = 0; i < inline.length; i++) {
|
||||
const n = inline[i];
|
||||
if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (!re.test(n.text))
|
||||
continue;
|
||||
re.lastIndex = 0;
|
||||
const marks = Array.isArray(n.marks) ? n.marks : [];
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = re.exec(n.text)) !== null) {
|
||||
if (m.index > last) {
|
||||
parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] });
|
||||
}
|
||||
const oldNum = m[1] != null ? Number(m[1]) : undefined;
|
||||
const phIdx = m[2] != null ? Number(m[2]) : undefined;
|
||||
const fnId = onMarker({ oldNum, phIdx });
|
||||
parts.push({ type: "footnoteReference", attrs: { id: fnId } });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < n.text.length) {
|
||||
parts.push({ ...n, text: n.text.slice(last), marks: [...marks] });
|
||||
}
|
||||
// Drop any zero-length text runs the slicing may have produced.
|
||||
const cleaned = parts.filter((p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0));
|
||||
inline.splice(i, 1, ...cleaned);
|
||||
i += cleaned.length - 1;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Convert a comment's markdown (e.g. `**Lead.** body...`) into inline
|
||||
* ProseMirror nodes.
|
||||
@@ -321,85 +374,100 @@ export function commentsToFootnotes(doc, comments, opts = {}) {
|
||||
throw new Error("notes orderedList not found");
|
||||
}
|
||||
const consumed = [];
|
||||
const noteByPh = new Map();
|
||||
const noteInlineByPh = new Map();
|
||||
(Array.isArray(comments) ? comments : []).forEach((c, i) => {
|
||||
if (!c || !c.selection)
|
||||
return;
|
||||
// Collision-proof sentinel delimited by NUL control chars, which never occur
|
||||
// in real Docmost prose — so the renumber regex below cannot mistake any body
|
||||
// text (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is
|
||||
// transient: the placeholder round-trips within this function (insertMarkerAfter
|
||||
// inserts it, the renumber pass replaces it with "[N]"), so it never persists
|
||||
// in a returned/pushed document.
|
||||
// in real Docmost prose - so the marker regex cannot mistake any body text
|
||||
// (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is
|
||||
// transient: the placeholder is inserted here and replaced by a
|
||||
// footnoteReference node below; it never persists in a returned document.
|
||||
const ph = `\u0000FN${i}\u0000`;
|
||||
// insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh
|
||||
// the `top` / `notesList` references that point into it.
|
||||
// insertMarkerAfter returns a NEW cloned doc; reassign `working`.
|
||||
const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, {
|
||||
beforeBlock: notesIdx,
|
||||
});
|
||||
if (!r.inserted)
|
||||
return;
|
||||
working = r.doc;
|
||||
noteByPh.set(ph, noteItem(mdToInlineNodes(c.content)));
|
||||
noteInlineByPh.set(ph, mdToInlineNodes(c.content));
|
||||
consumed.push(c.id);
|
||||
});
|
||||
// Re-resolve references into the (possibly re-cloned) working doc.
|
||||
const top2 = Array.isArray(working.content) ? working.content : [];
|
||||
const notesList2 = top2
|
||||
.slice(notesIdx)
|
||||
.find((n) => isObject(n) && n.type === "orderedList");
|
||||
const notesIdx2 = top2.findIndex((n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading);
|
||||
const oldListIndex = top2.findIndex((n) => isObject(n) && n.type === "orderedList");
|
||||
const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null;
|
||||
if (!notesList2) {
|
||||
throw new Error("notes orderedList not found");
|
||||
}
|
||||
const oldNotes = Array.isArray(notesList2.content)
|
||||
// Inline content of each existing note (listItem -> paragraph -> inline).
|
||||
const oldNoteInline = (Array.isArray(notesList2.content)
|
||||
? notesList2.content
|
||||
: [];
|
||||
const newNotes = [];
|
||||
let seq = 0;
|
||||
// Match either an existing "[N]" marker or a NUL-delimited "\u0000FN<i>\u0000"
|
||||
// placeholder, in reading order across the body (blocks before the notes heading).
|
||||
const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g;
|
||||
// Same range regex setCalloutRange uses to detect the disclaimer callout's
|
||||
// "[1]…[K]" range; used here to decide whether a top-level callout is the
|
||||
// disclaimer (skip) or an ordinary callout (renumber normally).
|
||||
: []).map((item) => {
|
||||
const para = isObject(item) && Array.isArray(item.content)
|
||||
? item.content.find((c) => isObject(c) && c.type === "paragraph")
|
||||
: null;
|
||||
return para && Array.isArray(para.content) ? para.content : [];
|
||||
});
|
||||
// Walk the body in reading order, turning each "[N]" / placeholder marker into
|
||||
// a real footnoteReference node and collecting its definition inline content.
|
||||
const definitions = [];
|
||||
const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/;
|
||||
for (let i = 0; i < notesIdx; i++) {
|
||||
// Skip ONLY the disclaimer callout: its "[1]…[K]" range is NOT a footnote
|
||||
// marker and is synced separately by setCalloutRange. Renumbering it here
|
||||
// would consume note slots and corrupt the sequence. Other top-level
|
||||
// callouts may carry legitimate "[N]" body markers and are renumbered.
|
||||
// Recursively visit inline arrays inside a block (paragraph, heading, callout
|
||||
// child paragraphs, table cells, ...), preserving document reading order.
|
||||
const visitInlineArrays = (container) => {
|
||||
if (!isObject(container) || !Array.isArray(container.content))
|
||||
return;
|
||||
const hasText = container.content.some((n) => isObject(n) && n.type === "text");
|
||||
if (hasText) {
|
||||
replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => {
|
||||
const fnId = freshId();
|
||||
if (oldNum != null) {
|
||||
const inline = oldNoteInline[oldNum - 1];
|
||||
// Every existing body marker MUST map to a real note. An out-of-range
|
||||
// marker means the document is internally inconsistent; fail loudly.
|
||||
if (inline === undefined) {
|
||||
throw new Error(`footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`);
|
||||
}
|
||||
definitions.push(footnoteDefinition(fnId, inline));
|
||||
}
|
||||
else {
|
||||
const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || [];
|
||||
definitions.push(footnoteDefinition(fnId, inline));
|
||||
}
|
||||
return fnId;
|
||||
});
|
||||
}
|
||||
else {
|
||||
for (const child of container.content)
|
||||
visitInlineArrays(child);
|
||||
}
|
||||
};
|
||||
const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex;
|
||||
for (let i = 0; i < notesBoundary; i++) {
|
||||
// Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote
|
||||
// marker and is synced separately by setCalloutRange.
|
||||
if (isObject(top2[i]) &&
|
||||
top2[i].type === "callout" &&
|
||||
disclaimerRangeRe.test(blockText(top2[i]))) {
|
||||
continue;
|
||||
}
|
||||
walk(top2[i], (node) => {
|
||||
if (node.type !== "text" || typeof node.text !== "string")
|
||||
return;
|
||||
node.text = node.text.replace(re, (_m, oldNum, phIdx) => {
|
||||
if (oldNum != null) {
|
||||
const note = oldNotes[Number(oldNum) - 1];
|
||||
// Every existing body marker MUST map to a real note. An out-of-range
|
||||
// marker means the document is internally inconsistent; fail loudly
|
||||
// rather than silently dropping the note and desyncing the callout.
|
||||
if (note === undefined) {
|
||||
throw new Error(`footnote [${oldNum}] has no matching note (notes list has ${oldNotes.length} items); document is inconsistent`);
|
||||
}
|
||||
newNotes.push(note);
|
||||
}
|
||||
else {
|
||||
newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`));
|
||||
}
|
||||
return `[${++seq}]`;
|
||||
});
|
||||
});
|
||||
visitInlineArrays(top2[i]);
|
||||
}
|
||||
// Reorder the notes list IN PLACE on `working` first, THEN sync the callout
|
||||
// range. setCalloutRange clones `working`, so the reordered notes (mutated
|
||||
// before the clone) are carried into its result automatically. No null-filter
|
||||
// here: marker count and note count must stay exactly equal (the out-of-range
|
||||
// guard above guarantees no undefined entry is ever pushed).
|
||||
notesList2.content = newNotes;
|
||||
const synced = setCalloutRange(working, notesList2.content.length);
|
||||
// Replace the old orderedList with a real footnotesList of the collected
|
||||
// definitions (reading order). If there are no definitions, drop the list.
|
||||
if (definitions.length > 0) {
|
||||
top2[oldListIndex] = {
|
||||
type: "footnotesList",
|
||||
content: definitions,
|
||||
};
|
||||
}
|
||||
else {
|
||||
top2.splice(oldListIndex, 1);
|
||||
}
|
||||
// Sync the disclaimer callout range to the new note count.
|
||||
const synced = setCalloutRange(working, definitions.length);
|
||||
return { doc: synced.doc, consumed };
|
||||
}
|
||||
|
||||
@@ -296,12 +296,165 @@ function bridgeTaskLists(html: string): string {
|
||||
return document.body.innerHTML;
|
||||
}
|
||||
|
||||
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
|
||||
// marker becomes <sup data-footnote-ref data-id="id">, and `[^id]: text`
|
||||
// definition lines are collected into a single <section data-footnotes>.
|
||||
const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
||||
const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
|
||||
|
||||
function escapeFootnoteAttr(value: string): string {
|
||||
return String(value).replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeFootnoteRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of
|
||||
* an original id `X` during definition dedup.
|
||||
*
|
||||
* EXACT MIRROR of editor-ext `deriveFootnoteId`
|
||||
* (packages/editor-ext/src/lib/footnote/footnote-util.ts). These two copies MUST
|
||||
* STAY IN SYNC: the same markdown imported through the editor and through this
|
||||
* MCP path has to produce identical ids, and the sync plugin (which re-ids on
|
||||
* every collaborating client) relies on the same scheme to converge. NEVER use
|
||||
* Math.random()/Date.now()/uuid here — a random id would diverge across clients.
|
||||
*
|
||||
* Scheme: base candidate `${originalId}__${occurrence}` (e.g. `X__2`), bumped
|
||||
* with a stable alphabetic suffix (`X__2b`, `X__2c`, ...) until it is not in
|
||||
* `taken` (the set of ids already present / already minted — pure doc state).
|
||||
*/
|
||||
function deriveFootnoteId(
|
||||
originalId: string,
|
||||
occurrence: number,
|
||||
taken: Set<string>,
|
||||
): string {
|
||||
let candidate = `${originalId}__${occurrence}`;
|
||||
let n = 0;
|
||||
while (taken.has(candidate)) {
|
||||
n += 1;
|
||||
candidate = `${originalId}__${occurrence}${footnoteSuffix(n)}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** Map 1 -> "b", 2 -> "c", ... (mirror of editor-ext `suffix`). */
|
||||
function footnoteSuffix(n: number): string {
|
||||
let out = "";
|
||||
let x = n;
|
||||
while (x > 0) {
|
||||
const rem = (x - 1) % 25;
|
||||
out = String.fromCharCode(98 + rem) + out; // 98 = 'b'
|
||||
x = Math.floor((x - 1) / 25);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const footnoteRefMarkedExtension = {
|
||||
name: "footnoteRef",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.match(/\[\^/)?.index ?? -1;
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = FOOTNOTE_REF_RE.exec(src);
|
||||
if (match && match.index === 0) {
|
||||
return { type: "footnoteRef", raw: match[0], id: match[1] };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
renderer(token: any) {
|
||||
return `<sup data-footnote-ref data-id="${escapeFootnoteAttr(
|
||||
token.id,
|
||||
)}"></sup>`;
|
||||
},
|
||||
};
|
||||
|
||||
marked.use({ extensions: [footnoteRefMarkedExtension] });
|
||||
|
||||
/**
|
||||
* Pull `[^id]: text` definition lines out of the body and render a single
|
||||
* <section data-footnotes> for them (or "" when there are none).
|
||||
*/
|
||||
function extractFootnotes(markdown: string): {
|
||||
body: string;
|
||||
section: string;
|
||||
} {
|
||||
const lines = markdown.split("\n");
|
||||
const bodyLines: string[] = [];
|
||||
const defs: Array<{ id: string; text: string }> = [];
|
||||
// Track fenced-code state so a `[^id]: ...` line shown inside a ``` / ~~~ code
|
||||
// block is preserved verbatim and not treated as a footnote definition.
|
||||
let fence: string | null = null;
|
||||
for (const line of lines) {
|
||||
const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[2][0];
|
||||
if (fence === null) fence = marker;
|
||||
else if (marker === fence) fence = null;
|
||||
bodyLines.push(line);
|
||||
continue;
|
||||
}
|
||||
const m = fence === null ? FOOTNOTE_DEF_RE.exec(line) : null;
|
||||
if (m) defs.push({ id: m[1], text: m[2] });
|
||||
else bodyLines.push(line);
|
||||
}
|
||||
if (defs.length === 0) return { body: markdown, section: "" };
|
||||
|
||||
// De-duplicate colliding definition ids (mirror of editor-ext
|
||||
// extractFootnoteDefinitions). Two definitions sharing an id would otherwise
|
||||
// collapse into one footnote downstream; rename each colliding id to a
|
||||
// DETERMINISTIC derived one (NOT random) and rewrite the corresponding `[^id]`
|
||||
// marker so the (reference, definition) pairing stays 1:1. Determinism lets
|
||||
// the same markdown imported here and via the editor produce identical ids.
|
||||
let dedupedBody = bodyLines.join("\n");
|
||||
const taken = new Set<string>(defs.map((d) => d.id));
|
||||
const seenDefIds = new Map<string, number>();
|
||||
for (const def of defs) {
|
||||
const originalId = def.id;
|
||||
const count = seenDefIds.get(originalId) ?? 0;
|
||||
seenDefIds.set(originalId, count + 1);
|
||||
if (count === 0) continue; // first definition keeps its id
|
||||
const newId = deriveFootnoteId(originalId, count + 1, taken);
|
||||
taken.add(newId);
|
||||
def.id = newId;
|
||||
// Remaining `[^originalId]` matches: index 0 = keeper's marker (left alone),
|
||||
// index 1 = this duplicate's marker. Rewrite index 1.
|
||||
let occurrence = 0;
|
||||
let rewritten = false;
|
||||
const re = new RegExp(`\\[\\^${escapeFootnoteRegExp(originalId)}\\]`, "g");
|
||||
dedupedBody = dedupedBody.replace(re, (match) => {
|
||||
const idx = occurrence++;
|
||||
if (!rewritten && idx === 1) {
|
||||
rewritten = true;
|
||||
return `[^${newId}]`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
const inner = defs
|
||||
.map(
|
||||
(d) =>
|
||||
`<div data-footnote-def data-id="${escapeFootnoteAttr(
|
||||
d.id,
|
||||
)}"><p>${marked.parseInline(d.text || "")}</p></div>`,
|
||||
)
|
||||
.join("");
|
||||
return {
|
||||
body: dedupedBody,
|
||||
section: `<section data-footnotes>${inner}</section>`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */
|
||||
export async function markdownToProseMirror(
|
||||
markdownContent: string,
|
||||
): Promise<any> {
|
||||
const withCallouts = await preprocessCallouts(markdownContent);
|
||||
const html = await marked.parse(withCallouts);
|
||||
const { body, section } = extractFootnotes(withCallouts);
|
||||
const html = (await marked.parse(body)) + section;
|
||||
const bridged = bridgeTaskLists(html);
|
||||
return generateJSON(bridged, docmostExtensions);
|
||||
}
|
||||
|
||||
@@ -101,10 +101,25 @@ function countUniqueLinks(doc: any): number {
|
||||
return hrefs.size;
|
||||
}
|
||||
|
||||
/** Count footnoteReference nodes anywhere under a node (reading order). */
|
||||
function countFootnoteRefs(node: any): number {
|
||||
if (!node || typeof node !== "object") return 0;
|
||||
let n = node.type === "footnoteReference" ? 1 : 0;
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) n += countFootnoteRefs(child);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the ordered list of integers from `[N]` footnote markers found in the
|
||||
* BODY only (every top-level block before the first "Примечания..." notes
|
||||
* heading; if no such heading, the whole doc). Returned in reading order.
|
||||
* Ordered list of footnote marker numbers found in the BODY only (every
|
||||
* top-level block before the first "Примечания..." notes heading; if no such
|
||||
* heading, the whole doc), in reading order.
|
||||
*
|
||||
* Supports BOTH representations:
|
||||
* - real `footnoteReference` nodes (the current footnote feature) — numbered
|
||||
* 1..n by reading position, since their visible number is derived;
|
||||
* - legacy `[N]` text markers (older translated docs) — the literal N.
|
||||
*/
|
||||
function footnoteMarkers(doc: any, notesHeading: string): number[] {
|
||||
const top: any[] = Array.isArray(doc?.content) ? doc.content : [];
|
||||
@@ -115,6 +130,16 @@ function footnoteMarkers(doc: any, notesHeading: string): number[] {
|
||||
plainText(n).trim() === notesHeading,
|
||||
);
|
||||
const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top;
|
||||
|
||||
// Real footnoteReference nodes take precedence: when present, number them by
|
||||
// reading position (their displayed number is not stored).
|
||||
let refCount = 0;
|
||||
for (const block of bodyBlocks) refCount += countFootnoteRefs(block);
|
||||
if (refCount > 0) {
|
||||
return Array.from({ length: refCount }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
// Fallback: legacy `[N]` text markers.
|
||||
const markers: number[] = [];
|
||||
const re = /\[(\d+)\]/g;
|
||||
for (const block of bodyBlocks) {
|
||||
|
||||
@@ -378,6 +378,83 @@ const Mention = Node.create({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Footnote feature (mirror of packages/editor-ext/src/lib/footnote). Three
|
||||
* nodes connected by `id`:
|
||||
* - FootnoteReference: inline atom marker in the body (<sup data-footnote-ref>);
|
||||
* - FootnotesList: a single bottom container (<section data-footnotes>);
|
||||
* - FootnoteDefinition: one editable note keyed by id (<div data-footnote-def>).
|
||||
* The visible number is not stored; it is derived from reference order.
|
||||
*
|
||||
* priority 101 so this node's <sup> parse rule beats the Superscript mark's
|
||||
* <sup> rule (otherwise an empty reference is parsed as an empty superscript
|
||||
* mark and dropped). Keep in sync with editor-ext.
|
||||
*/
|
||||
const FootnoteReference = Node.create({
|
||||
name: "footnoteReference",
|
||||
priority: 101,
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-id"),
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.id ? { "data-id": attrs.id } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "sup[data-footnote-ref]", priority: 100 }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["sup", { "data-footnote-ref": "", ...HTMLAttributes }];
|
||||
},
|
||||
});
|
||||
|
||||
const FootnotesList = Node.create({
|
||||
name: "footnotesList",
|
||||
group: "block",
|
||||
content: "footnoteDefinition+",
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
defining: true,
|
||||
parseHTML() {
|
||||
return [{ tag: "section[data-footnotes]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["section", { "data-footnotes": "", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
|
||||
const FootnoteDefinition = Node.create({
|
||||
name: "footnoteDefinition",
|
||||
content: "paragraph+",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
selectable: false,
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-id"),
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.id ? { "data-id": attrs.id } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-footnote-def]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", { "data-footnote-def": "", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
|
||||
/** Inline KaTeX expression. Carries the LaTeX source in `text`. */
|
||||
const MathInline = Node.create({
|
||||
name: "mathInline",
|
||||
@@ -1069,6 +1146,9 @@ export const docmostExtensions = [
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Mention,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
MathInline,
|
||||
MathBlock,
|
||||
Details,
|
||||
|
||||
@@ -430,6 +430,30 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
|
||||
}
|
||||
|
||||
case "footnoteReference": {
|
||||
// Pandoc/GFM inline marker. The number is derived (not stored), so the
|
||||
// id is the stable anchor.
|
||||
const fnId = node.attrs?.id || "";
|
||||
return fnId ? `[^${fnId}]` : "";
|
||||
}
|
||||
|
||||
case "footnotesList":
|
||||
// The container renders its definitions, each on its own `[^id]: ...`
|
||||
// line. A blank line separates the body from the notes block.
|
||||
return nodeContent.map(processNode).join("\n");
|
||||
|
||||
case "footnoteDefinition": {
|
||||
const defId = node.attrs?.id || "";
|
||||
// Collapse the definition's paragraphs into a single line; multi-line
|
||||
// footnotes are a v2 refinement.
|
||||
const defText = nodeContent
|
||||
.map(processNode)
|
||||
.join(" ")
|
||||
.replace(/\s*\n+\s*/g, " ")
|
||||
.trim();
|
||||
return defId ? `[^${defId}]: ${defText}` : "";
|
||||
}
|
||||
|
||||
case "attachment": {
|
||||
// BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but
|
||||
// the schema stores name/url (plus mime/size/attachmentId). Emit the
|
||||
|
||||
@@ -264,6 +264,66 @@ export function noteItem(inlineNodes: any[]): any {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id:
|
||||
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
|
||||
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
|
||||
*/
|
||||
export function footnoteDefinition(id: string, inlineNodes: any[]): any {
|
||||
const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : [];
|
||||
return {
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id },
|
||||
content: [{ type: "paragraph", attrs: { id: freshId() }, content }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every `[N]` body marker and `\u0000FN<i>\u0000` comment placeholder in
|
||||
* an inline content array with a real `footnoteReference` node, in reading
|
||||
* order. `onMarker` is called for each replaced marker (with the original `[N]`
|
||||
* number or the placeholder index) and returns the fresh footnote id to attach
|
||||
* to the inserted node. Mutates `inline` in place.
|
||||
*/
|
||||
function replaceMarkersWithReferences(
|
||||
inline: any[],
|
||||
onMarker: (info: { oldNum?: number; phIdx?: number }) => string,
|
||||
): void {
|
||||
const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g;
|
||||
for (let i = 0; i < inline.length; i++) {
|
||||
const n = inline[i];
|
||||
if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (!re.test(n.text)) continue;
|
||||
re.lastIndex = 0;
|
||||
|
||||
const marks = Array.isArray(n.marks) ? n.marks : [];
|
||||
const parts: any[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(n.text)) !== null) {
|
||||
if (m.index > last) {
|
||||
parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] });
|
||||
}
|
||||
const oldNum = m[1] != null ? Number(m[1]) : undefined;
|
||||
const phIdx = m[2] != null ? Number(m[2]) : undefined;
|
||||
const fnId = onMarker({ oldNum, phIdx });
|
||||
parts.push({ type: "footnoteReference", attrs: { id: fnId } });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < n.text.length) {
|
||||
parts.push({ ...n, text: n.text.slice(last), marks: [...marks] });
|
||||
}
|
||||
// Drop any zero-length text runs the slicing may have produced.
|
||||
const cleaned = parts.filter(
|
||||
(p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0),
|
||||
);
|
||||
inline.splice(i, 1, ...cleaned);
|
||||
i += cleaned.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a comment's markdown (e.g. `**Lead.** body...`) into inline
|
||||
* ProseMirror nodes.
|
||||
@@ -388,54 +448,91 @@ export function commentsToFootnotes(
|
||||
}
|
||||
|
||||
const consumed: string[] = [];
|
||||
const noteByPh = new Map<string, any>();
|
||||
const noteInlineByPh = new Map<string, any[]>();
|
||||
|
||||
(Array.isArray(comments) ? comments : []).forEach((c, i) => {
|
||||
if (!c || !c.selection) return;
|
||||
// Collision-proof sentinel delimited by NUL control chars, which never occur
|
||||
// in real Docmost prose — so the renumber regex below cannot mistake any body
|
||||
// text (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is
|
||||
// transient: the placeholder round-trips within this function (insertMarkerAfter
|
||||
// inserts it, the renumber pass replaces it with "[N]"), so it never persists
|
||||
// in a returned/pushed document.
|
||||
// in real Docmost prose - so the marker regex cannot mistake any body text
|
||||
// (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is
|
||||
// transient: the placeholder is inserted here and replaced by a
|
||||
// footnoteReference node below; it never persists in a returned document.
|
||||
const ph = `\u0000FN${i}\u0000`;
|
||||
// insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh
|
||||
// the `top` / `notesList` references that point into it.
|
||||
// insertMarkerAfter returns a NEW cloned doc; reassign `working`.
|
||||
const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, {
|
||||
beforeBlock: notesIdx,
|
||||
});
|
||||
if (!r.inserted) return;
|
||||
working = r.doc;
|
||||
noteByPh.set(ph, noteItem(mdToInlineNodes(c.content)));
|
||||
noteInlineByPh.set(ph, mdToInlineNodes(c.content));
|
||||
consumed.push(c.id);
|
||||
});
|
||||
|
||||
// Re-resolve references into the (possibly re-cloned) working doc.
|
||||
const top2: any[] = Array.isArray(working.content) ? working.content : [];
|
||||
const notesList2 = top2
|
||||
.slice(notesIdx)
|
||||
.find((n) => isObject(n) && n.type === "orderedList");
|
||||
const notesIdx2 = top2.findIndex(
|
||||
(n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading,
|
||||
);
|
||||
const oldListIndex = top2.findIndex(
|
||||
(n) => isObject(n) && n.type === "orderedList",
|
||||
);
|
||||
const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null;
|
||||
if (!notesList2) {
|
||||
throw new Error("notes orderedList not found");
|
||||
}
|
||||
|
||||
const oldNotes: any[] = Array.isArray(notesList2.content)
|
||||
// Inline content of each existing note (listItem -> paragraph -> inline).
|
||||
const oldNoteInline = (Array.isArray(notesList2.content)
|
||||
? notesList2.content
|
||||
: [];
|
||||
const newNotes: any[] = [];
|
||||
let seq = 0;
|
||||
// Match either an existing "[N]" marker or a NUL-delimited "\u0000FN<i>\u0000"
|
||||
// placeholder, in reading order across the body (blocks before the notes heading).
|
||||
const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g;
|
||||
// Same range regex setCalloutRange uses to detect the disclaimer callout's
|
||||
// "[1]…[K]" range; used here to decide whether a top-level callout is the
|
||||
// disclaimer (skip) or an ordinary callout (renumber normally).
|
||||
: []
|
||||
).map((item: any) => {
|
||||
const para =
|
||||
isObject(item) && Array.isArray(item.content)
|
||||
? item.content.find((c: any) => isObject(c) && c.type === "paragraph")
|
||||
: null;
|
||||
return para && Array.isArray(para.content) ? para.content : [];
|
||||
});
|
||||
|
||||
// Walk the body in reading order, turning each "[N]" / placeholder marker into
|
||||
// a real footnoteReference node and collecting its definition inline content.
|
||||
const definitions: any[] = [];
|
||||
const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/;
|
||||
for (let i = 0; i < notesIdx; i++) {
|
||||
// Skip ONLY the disclaimer callout: its "[1]…[K]" range is NOT a footnote
|
||||
// marker and is synced separately by setCalloutRange. Renumbering it here
|
||||
// would consume note slots and corrupt the sequence. Other top-level
|
||||
// callouts may carry legitimate "[N]" body markers and are renumbered.
|
||||
|
||||
// Recursively visit inline arrays inside a block (paragraph, heading, callout
|
||||
// child paragraphs, table cells, ...), preserving document reading order.
|
||||
const visitInlineArrays = (container: any): void => {
|
||||
if (!isObject(container) || !Array.isArray(container.content)) return;
|
||||
const hasText = container.content.some(
|
||||
(n: any) => isObject(n) && n.type === "text",
|
||||
);
|
||||
if (hasText) {
|
||||
replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => {
|
||||
const fnId = freshId();
|
||||
if (oldNum != null) {
|
||||
const inline = oldNoteInline[oldNum - 1];
|
||||
// Every existing body marker MUST map to a real note. An out-of-range
|
||||
// marker means the document is internally inconsistent; fail loudly.
|
||||
if (inline === undefined) {
|
||||
throw new Error(
|
||||
`footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`,
|
||||
);
|
||||
}
|
||||
definitions.push(footnoteDefinition(fnId, inline));
|
||||
} else {
|
||||
const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || [];
|
||||
definitions.push(footnoteDefinition(fnId, inline));
|
||||
}
|
||||
return fnId;
|
||||
});
|
||||
} else {
|
||||
for (const child of container.content) visitInlineArrays(child);
|
||||
}
|
||||
};
|
||||
|
||||
const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex;
|
||||
for (let i = 0; i < notesBoundary; i++) {
|
||||
// Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote
|
||||
// marker and is synced separately by setCalloutRange.
|
||||
if (
|
||||
isObject(top2[i]) &&
|
||||
top2[i].type === "callout" &&
|
||||
@@ -443,35 +540,22 @@ export function commentsToFootnotes(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
walk(top2[i], (node) => {
|
||||
if (node.type !== "text" || typeof node.text !== "string") return;
|
||||
node.text = node.text.replace(re, (_m: string, oldNum: string, phIdx: string) => {
|
||||
if (oldNum != null) {
|
||||
const note = oldNotes[Number(oldNum) - 1];
|
||||
// Every existing body marker MUST map to a real note. An out-of-range
|
||||
// marker means the document is internally inconsistent; fail loudly
|
||||
// rather than silently dropping the note and desyncing the callout.
|
||||
if (note === undefined) {
|
||||
throw new Error(
|
||||
`footnote [${oldNum}] has no matching note (notes list has ${oldNotes.length} items); document is inconsistent`,
|
||||
);
|
||||
}
|
||||
newNotes.push(note);
|
||||
} else {
|
||||
newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`));
|
||||
}
|
||||
return `[${++seq}]`;
|
||||
});
|
||||
});
|
||||
visitInlineArrays(top2[i]);
|
||||
}
|
||||
|
||||
// Reorder the notes list IN PLACE on `working` first, THEN sync the callout
|
||||
// range. setCalloutRange clones `working`, so the reordered notes (mutated
|
||||
// before the clone) are carried into its result automatically. No null-filter
|
||||
// here: marker count and note count must stay exactly equal (the out-of-range
|
||||
// guard above guarantees no undefined entry is ever pushed).
|
||||
notesList2.content = newNotes;
|
||||
const synced = setCalloutRange(working, notesList2.content.length);
|
||||
// Replace the old orderedList with a real footnotesList of the collected
|
||||
// definitions (reading order). If there are no definitions, drop the list.
|
||||
if (definitions.length > 0) {
|
||||
top2[oldListIndex] = {
|
||||
type: "footnotesList",
|
||||
content: definitions,
|
||||
};
|
||||
} else {
|
||||
top2.splice(oldListIndex, 1);
|
||||
}
|
||||
|
||||
// Sync the disclaimer callout range to the new note count.
|
||||
const synced = setCalloutRange(working, definitions.length);
|
||||
|
||||
return { doc: synced.doc, consumed };
|
||||
}
|
||||
|
||||
153
packages/mcp/test/unit/footnotes.test.mjs
Normal file
153
packages/mcp/test/unit/footnotes.test.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
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";
|
||||
|
||||
/** Recursively collect every node of `type`. */
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const c of node.content) findAll(c, type, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const footnoteDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Water" },
|
||||
{ type: "footnoteReference", attrs: { id: "fn1" } },
|
||||
{ type: "text", text: " and clay" },
|
||||
{ type: "footnoteReference", attrs: { id: "fn2" } },
|
||||
{ type: "text", text: "." },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "footnotesList",
|
||||
content: [
|
||||
{
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id: "fn1" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "First note." }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id: "fn2" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Second note." }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("JSON -> Markdown emits pandoc footnote syntax", () => {
|
||||
const md = convertProseMirrorToMarkdown(footnoteDoc);
|
||||
assert.match(md, /\[\^fn1\]/);
|
||||
assert.match(md, /\[\^fn2\]/);
|
||||
assert.match(md, /\[\^fn1\]: First note\./);
|
||||
assert.match(md, /\[\^fn2\]: Second note\./);
|
||||
});
|
||||
|
||||
test("Markdown -> JSON rebuilds footnote nodes", async () => {
|
||||
const md = convertProseMirrorToMarkdown(footnoteDoc);
|
||||
const json = await markdownToProseMirror(md);
|
||||
|
||||
const refs = findAll(json, "footnoteReference");
|
||||
const list = findAll(json, "footnotesList");
|
||||
const defs = findAll(json, "footnoteDefinition");
|
||||
|
||||
assert.equal(refs.length, 2);
|
||||
assert.deepEqual(
|
||||
refs.map((r) => r.attrs.id),
|
||||
["fn1", "fn2"],
|
||||
);
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(defs.length, 2);
|
||||
assert.deepEqual(
|
||||
defs.map((d) => d.attrs.id),
|
||||
["fn1", "fn2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("JSON -> MD -> JSON preserves footnote ids and text", async () => {
|
||||
const md = convertProseMirrorToMarkdown(footnoteDoc);
|
||||
const json = await markdownToProseMirror(md);
|
||||
const md2 = convertProseMirrorToMarkdown(json);
|
||||
|
||||
// The second markdown serialization carries the same markers + definitions.
|
||||
assert.match(md2, /\[\^fn1\]/);
|
||||
assert.match(md2, /\[\^fn2\]/);
|
||||
assert.match(md2, /\[\^fn1\]: First note\./);
|
||||
assert.match(md2, /\[\^fn2\]: Second note\./);
|
||||
});
|
||||
|
||||
test("duplicate-id markdown dedups DETERMINISTICALLY (same input -> same ids)", async () => {
|
||||
// The MCP import must derive duplicate ids deterministically (NOT random) so
|
||||
// the same markdown imported here and via the editor produces identical ids,
|
||||
// and re-importing is stable. This is the test that would FAIL on the old
|
||||
// Math.random()/Date.now() implementation.
|
||||
const md = [
|
||||
"See[^d] one[^d] two[^d].",
|
||||
"",
|
||||
"[^d]: first",
|
||||
"[^d]: second",
|
||||
"[^d]: third",
|
||||
].join("\n");
|
||||
|
||||
const idsOf = async () => {
|
||||
const json = await markdownToProseMirror(md);
|
||||
const refs = findAll(json, "footnoteReference").map((r) => r.attrs.id);
|
||||
const defs = findAll(json, "footnoteDefinition").map((d) => d.attrs.id);
|
||||
return { refs, defs };
|
||||
};
|
||||
|
||||
const a = await idsOf();
|
||||
const b = await idsOf();
|
||||
|
||||
// Identical across runs.
|
||||
assert.deepEqual(a.refs, b.refs);
|
||||
assert.deepEqual(a.defs, b.defs);
|
||||
// Deterministic derived scheme: keeper "d", duplicates "d__2", "d__3".
|
||||
assert.deepEqual([...a.defs].sort(), ["d", "d__2", "d__3"]);
|
||||
// 1:1 reference <-> definition pairing, all distinct.
|
||||
assert.equal(new Set(a.defs).size, 3);
|
||||
assert.deepEqual([...a.refs].sort(), [...a.defs].sort());
|
||||
});
|
||||
|
||||
test("a [^id]: line inside a fenced code block is NOT treated as a definition", async () => {
|
||||
// Markdown that DOCUMENTS footnote syntax inside a code fence. The example
|
||||
// definition line must be preserved verbatim inside the code block and not
|
||||
// pulled out into a real footnotesList / footnoteDefinition.
|
||||
const md = [
|
||||
"Intro text.",
|
||||
"",
|
||||
"```markdown",
|
||||
"Body[^demo]",
|
||||
"",
|
||||
"[^demo]: example definition",
|
||||
"```",
|
||||
"",
|
||||
"Outro.",
|
||||
].join("\n");
|
||||
|
||||
const json = await markdownToProseMirror(md);
|
||||
|
||||
// No real footnote nodes were extracted from the code block.
|
||||
assert.equal(findAll(json, "footnotesList").length, 0);
|
||||
assert.equal(findAll(json, "footnoteDefinition").length, 0);
|
||||
|
||||
// The example definition line survives somewhere in the code block text.
|
||||
const codeBlocks = findAll(json, "codeBlock");
|
||||
assert.ok(codeBlocks.length >= 1, "code block present");
|
||||
const codeText = JSON.stringify(json);
|
||||
assert.match(codeText, /\[\^demo\]: example definition/);
|
||||
});
|
||||
273
packages/mcp/test/unit/http-idle-eviction.test.mjs
Normal file
273
packages/mcp/test/unit/http-idle-eviction.test.mjs
Normal file
@@ -0,0 +1,273 @@
|
||||
// Unit tests for createMcpHttpHandler's idle-session eviction (http.ts).
|
||||
//
|
||||
// http.ts keeps one transport per MCP session alive between requests, keyed by
|
||||
// the mcp-session-id header, and runs a periodic sweep (setInterval, every 5
|
||||
// min) that closes any transport idle longer than the idle TTL
|
||||
// (MCP_SESSION_IDLE_MS, default 30 min) and drops its lastSeen + sessionIdentity
|
||||
// bookkeeping. Routing a request to an existing transport refreshes its
|
||||
// lastSeen.
|
||||
//
|
||||
// We drive this DETERMINISTICALLY rather than waiting wall-clock: the env knob
|
||||
// MCP_SESSION_IDLE_MS is read ONCE when the handler is created, so we set it
|
||||
// small; and node:test's mock.timers lets us mock both `setInterval` (the sweep)
|
||||
// and `Date` (the lastSeen comparison clock) so ticking advances the clock and
|
||||
// fires the sweep on demand.
|
||||
//
|
||||
// IMPORTANT mock.timers semantics: when a tick spans MULTIPLE timer fires (or
|
||||
// overshoots a fire), the callbacks all observe Date.now() == the FINAL ticked
|
||||
// time, not their individual scheduled times. So to make the sweep's
|
||||
// `now - lastSeen` comparison meaningful we tick EXACTLY to a sweep boundary
|
||||
// (a multiple of the sweep interval): then Date.now() inside the sweep equals
|
||||
// that boundary. The mocked clock starts at 0, so sweeps fire at SWEEP, 2*SWEEP,
|
||||
// ... We pin each session's lastSeen by establishing/touching it at a known
|
||||
// pre-boundary clock, then tick the remaining delta to land exactly on the
|
||||
// boundary.
|
||||
//
|
||||
// Sessions are established over a real loopback http server (so the SDK's
|
||||
// StreamableHTTPServerTransport gets genuine Node req/res and a real
|
||||
// mcp-session-id), exactly like http-resolver.test.mjs, and the server is closed
|
||||
// in a finally.
|
||||
//
|
||||
// Eviction is asserted via its OBSERVABLE effect: once a session is evicted its
|
||||
// transport is gone from the handler's internal map, so a subsequent non-init
|
||||
// request replaying that session id is treated as unknown (400 "no valid
|
||||
// session ID") — the same response an id that was never established would get.
|
||||
// An active (recently-seen) session is retained and its subsequent request is
|
||||
// NOT a 400.
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const INIT_BODY = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "0.0.0" },
|
||||
},
|
||||
};
|
||||
|
||||
const SWEEP_MS = 5 * 60 * 1000; // setInterval cadence in http.ts.
|
||||
|
||||
// Spin a loopback http server bridging every request into the MCP handler with
|
||||
// its JSON body parsed, mirroring the embedding host. Returns { call, close }.
|
||||
async function startLoopback(handler) {
|
||||
const http = await import("node:http");
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => {
|
||||
const body = raw ? JSON.parse(raw) : undefined;
|
||||
handler.handleRequest(req, res, body).catch(() => {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const { port } = server.address();
|
||||
|
||||
const call = (headers, body) =>
|
||||
new Promise((resolve) => {
|
||||
const r = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
method: "POST",
|
||||
path: "/mcp",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(resp) => {
|
||||
let data = "";
|
||||
resp.on("data", (c) => (data += c));
|
||||
resp.on("end", () =>
|
||||
resolve({
|
||||
statusCode: resp.statusCode,
|
||||
sessionId: resp.headers["mcp-session-id"],
|
||||
body: data,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
r.end(JSON.stringify(body));
|
||||
});
|
||||
|
||||
return { call, close: () => new Promise((r) => server.close(r)) };
|
||||
}
|
||||
|
||||
// The sweep closes transports asynchronously (void transport.close()), whose
|
||||
// onclose then removes the entry from the internal map. Yield to the event loop
|
||||
// so those microtasks settle before we assert the observable effect.
|
||||
const settle = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
// Set the idle TTL env knob (read once at handler creation) and enable mocked
|
||||
// setInterval + Date BEFORE creating the handler, so the sweep interval and
|
||||
// every Date.now() (lastSeen at init, lastSeen on routing, and the sweep's
|
||||
// comparison) all run on the same mocked clock. Returns restore() to undo it.
|
||||
function withMockedTimers(idleMs) {
|
||||
const prevIdle = process.env.MCP_SESSION_IDLE_MS;
|
||||
process.env.MCP_SESSION_IDLE_MS = String(idleMs);
|
||||
mock.timers.enable({ apis: ["setInterval", "Date"] });
|
||||
return () => {
|
||||
mock.timers.reset();
|
||||
if (prevIdle === undefined) delete process.env.MCP_SESSION_IDLE_MS;
|
||||
else process.env.MCP_SESSION_IDLE_MS = prevIdle;
|
||||
};
|
||||
}
|
||||
|
||||
test("idle session is evicted by the sweep; an active session is retained", async () => {
|
||||
// A small TTL: idle longer than 1s triggers eviction. Both sessions start at
|
||||
// clock 0; we keep one fresh (touch it just before the sweep) and leave the
|
||||
// other idle, then fire ONE sweep exactly on its boundary.
|
||||
const idleMs = 1000;
|
||||
const restore = withMockedTimers(idleMs);
|
||||
|
||||
const { createMcpHttpHandler } = await import("../../build/http.js");
|
||||
const handler = createMcpHttpHandler(() => ({
|
||||
apiUrl: "http://127.0.0.1:3000/api",
|
||||
getToken: async () => "t",
|
||||
}));
|
||||
|
||||
const lb = await startLoopback(handler);
|
||||
try {
|
||||
// T0 (clock 0): establish both sessions; lastSeen(A) = lastSeen(B) = 0.
|
||||
const a = await lb.call({}, INIT_BODY);
|
||||
const b = await lb.call({}, INIT_BODY);
|
||||
assert.ok(a.sessionId, "session A must get an mcp-session-id");
|
||||
assert.ok(b.sessionId, "session B must get an mcp-session-id");
|
||||
assert.notEqual(a.sessionId, b.sessionId, "distinct sessions");
|
||||
|
||||
// Advance to just before the first sweep boundary (SWEEP - 1ms): no sweep
|
||||
// fires yet (boundary not reached). lastSeen(A) stays 0.
|
||||
mock.timers.tick(SWEEP_MS - 1);
|
||||
// Touch ONLY B here, refreshing lastSeen(B) to SWEEP-1 (active); A is left
|
||||
// idle since clock 0.
|
||||
const touchB = await lb.call(
|
||||
{ "mcp-session-id": b.sessionId },
|
||||
{ jsonrpc: "2.0", method: "ping", id: 5 },
|
||||
);
|
||||
assert.notEqual(touchB.statusCode, 400, "B alive right before the sweep");
|
||||
|
||||
// Land EXACTLY on the sweep boundary (clock = SWEEP). Inside the sweep
|
||||
// Date.now() == SWEEP, so:
|
||||
// idle(A) = SWEEP - 0 = SWEEP > TTL(1s) -> A EVICTED
|
||||
// idle(B) = SWEEP - (SWEEP-1) = 1ms < TTL(1s) -> B RETAINED
|
||||
mock.timers.tick(1);
|
||||
await settle();
|
||||
|
||||
// OBSERVABLE EFFECT 1 — A evicted: replaying its session id on a non-init
|
||||
// request is now treated as unknown (400, no valid session).
|
||||
const aAfter = await lb.call(
|
||||
{ "mcp-session-id": a.sessionId },
|
||||
{ jsonrpc: "2.0", method: "ping", id: 10 },
|
||||
);
|
||||
assert.equal(aAfter.statusCode, 400, "evicted session id is unknown -> 400");
|
||||
assert.match(aAfter.body, /no valid session ID/);
|
||||
|
||||
// OBSERVABLE EFFECT 2 — B retained: a subsequent request on its session id
|
||||
// is routed to the live transport, NOT rejected as an unknown session.
|
||||
const bAfter = await lb.call(
|
||||
{ "mcp-session-id": b.sessionId },
|
||||
{ jsonrpc: "2.0", method: "ping", id: 11 },
|
||||
);
|
||||
assert.notEqual(
|
||||
bAfter.statusCode,
|
||||
400,
|
||||
"active session must survive the sweep (not 400)",
|
||||
);
|
||||
} finally {
|
||||
await lb.close();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a session left idle past the TTL is dropped so its id becomes unknown", async () => {
|
||||
// Simplest single-session eviction: establish a session, let it go idle past
|
||||
// the TTL, fire the sweep on its boundary, and confirm its id is now unknown
|
||||
// (400). Pins the core "lastSeen older than TTL -> closed and dropped" path.
|
||||
const idleMs = 1000;
|
||||
const restore = withMockedTimers(idleMs);
|
||||
|
||||
const { createMcpHttpHandler } = await import("../../build/http.js");
|
||||
const handler = createMcpHttpHandler(() => ({
|
||||
apiUrl: "http://127.0.0.1:3000/api",
|
||||
getToken: async () => "t",
|
||||
}));
|
||||
|
||||
const lb = await startLoopback(handler);
|
||||
try {
|
||||
const s = await lb.call({}, INIT_BODY);
|
||||
assert.ok(s.sessionId, "session must get an mcp-session-id");
|
||||
|
||||
// Fire the first sweep exactly on its boundary: Date.now() == SWEEP, idle =
|
||||
// SWEEP - 0 = SWEEP > TTL, so the untouched session is evicted.
|
||||
mock.timers.tick(SWEEP_MS);
|
||||
await settle();
|
||||
|
||||
const after = await lb.call(
|
||||
{ "mcp-session-id": s.sessionId },
|
||||
{ jsonrpc: "2.0", method: "ping", id: 30 },
|
||||
);
|
||||
assert.equal(after.statusCode, 400, "idle session id is unknown -> 400");
|
||||
assert.match(after.body, /no valid session ID/);
|
||||
} finally {
|
||||
await lb.close();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("activity refreshes lastSeen so a busy session is never evicted", async () => {
|
||||
// A session kept busy (a request just before the sweep) refreshes its
|
||||
// lastSeen, so even though it was created long ago the sweep must not evict
|
||||
// it. Pins the "routing to an existing transport refreshes its idle
|
||||
// timestamp" branch of http.ts.
|
||||
const idleMs = 1000;
|
||||
const restore = withMockedTimers(idleMs);
|
||||
|
||||
const { createMcpHttpHandler } = await import("../../build/http.js");
|
||||
const handler = createMcpHttpHandler(() => ({
|
||||
apiUrl: "http://127.0.0.1:3000/api",
|
||||
getToken: async () => "t",
|
||||
}));
|
||||
|
||||
const lb = await startLoopback(handler);
|
||||
try {
|
||||
const s = await lb.call({}, INIT_BODY);
|
||||
assert.ok(s.sessionId, "session must get an mcp-session-id");
|
||||
|
||||
// Age to just before the sweep boundary, then touch the session so its
|
||||
// lastSeen is refreshed to SWEEP-1 (well within the TTL of the imminent
|
||||
// sweep).
|
||||
mock.timers.tick(SWEEP_MS - 1);
|
||||
const touch = await lb.call(
|
||||
{ "mcp-session-id": s.sessionId },
|
||||
{ jsonrpc: "2.0", method: "ping", id: 40 },
|
||||
);
|
||||
assert.notEqual(touch.statusCode, 400, "session still alive before sweep");
|
||||
|
||||
// Land exactly on the sweep boundary: idle = SWEEP - (SWEEP-1) = 1ms < TTL,
|
||||
// so the busy session is retained.
|
||||
mock.timers.tick(1);
|
||||
await settle();
|
||||
|
||||
const after = await lb.call(
|
||||
{ "mcp-session-id": s.sessionId },
|
||||
{ jsonrpc: "2.0", method: "ping", id: 41 },
|
||||
);
|
||||
assert.notEqual(
|
||||
after.statusCode,
|
||||
400,
|
||||
"a session touched just before the sweep must not be evicted",
|
||||
);
|
||||
} finally {
|
||||
await lb.close();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -34,6 +34,18 @@ const li = (text) => ({
|
||||
const doc = (...children) => ({ type: "doc", content: children });
|
||||
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
||||
|
||||
// Collect every footnoteReference id under a node, in reading order.
|
||||
const collectRefIds = (node, acc = []) => {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === "footnoteReference") acc.push(node.attrs?.id);
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const c of node.content) collectRefIds(c, acc);
|
||||
}
|
||||
return acc;
|
||||
};
|
||||
// Plain text of a footnoteDefinition.
|
||||
const defText = (def) => blockText(def);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// blockText / walk / getList
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -173,21 +185,30 @@ test("commentsToFootnotes anchors comments and renumbers by position", () => {
|
||||
const { doc: out, consumed } = commentsToFootnotes(d, comments);
|
||||
assert.deepEqual(consumed.sort(), ["cA", "cB"]);
|
||||
|
||||
// Markers in reading order: p1 "apple"->[1], p2 existing->[2], p3 "banana"->[3]
|
||||
assert.match(blockText(out.content[1]), /\[1\]/);
|
||||
assert.match(blockText(out.content[2]), /\[2\]/);
|
||||
assert.match(blockText(out.content[3]), /\[3\]/);
|
||||
// Real footnoteReference nodes were inserted at p1 (apple), p2 (existing),
|
||||
// p3 (banana), in reading order — the old `[N]` text markers are gone.
|
||||
const refIds = collectRefIds(out);
|
||||
assert.equal(refIds.length, 3);
|
||||
// Body paragraphs p1..p3 no longer carry literal [N] text markers.
|
||||
assert.doesNotMatch(blockText(out.content[1]), /\[\d+\]/);
|
||||
assert.doesNotMatch(blockText(out.content[2]), /\[\d+\]/);
|
||||
assert.doesNotMatch(blockText(out.content[3]), /\[\d+\]/);
|
||||
|
||||
// No stray placeholders remain.
|
||||
const allText = blockText(out);
|
||||
assert.doesNotMatch(allText, / F\d+ /);
|
||||
// No stray NUL placeholders remain.
|
||||
assert.doesNotMatch(blockText(out), /\u0000/);
|
||||
|
||||
// Notes list reordered to [apple, existing, banana] (reading order).
|
||||
const list = out.content.find((n) => n.type === "orderedList");
|
||||
// The bottom footnotesList holds the definitions in reading order, each keyed
|
||||
// by the matching reference id.
|
||||
const list = out.content.find((n) => n.type === "footnotesList");
|
||||
assert.ok(list, "footnotesList present");
|
||||
assert.equal(list.content.length, 3);
|
||||
assert.equal(blockText(list.content[0]), "apple note");
|
||||
assert.equal(blockText(list.content[1]), "existing note one");
|
||||
assert.equal(blockText(list.content[2]), "banana note");
|
||||
assert.deepEqual(
|
||||
list.content.map((d) => d.attrs.id),
|
||||
refIds,
|
||||
);
|
||||
assert.equal(defText(list.content[0]), "apple note");
|
||||
assert.equal(defText(list.content[1]), "existing note one");
|
||||
assert.equal(defText(list.content[2]), "banana note");
|
||||
|
||||
// Callout range synced to 3 notes.
|
||||
assert.match(blockText(out.content[0]), /\[1\]…\[3\]/);
|
||||
@@ -224,15 +245,16 @@ test("commentsToFootnotes leaves literal 'F1'/'FN2'/'F12' body text untouched",
|
||||
// The literal "F1"/"FN2"/"F12" prose is preserved verbatim (no bogus
|
||||
// footnotes, no eaten spaces around them).
|
||||
assert.match(bodyText, /Press F1 for help, model FN2 and F12 for tools/);
|
||||
// Exactly one real footnote marker was produced, at the anchored word.
|
||||
const markerCount = (bodyText.match(/\[\d+\]/g) || []).length;
|
||||
assert.equal(markerCount, 1);
|
||||
assert.match(bodyText, /apple \[1\]/);
|
||||
// Exactly one real footnoteReference node was produced, at the anchored word.
|
||||
const refIds = collectRefIds(out);
|
||||
assert.equal(refIds.length, 1);
|
||||
|
||||
// Exactly one note in the list — "F1"/"FN2"/"F12" did not spawn extra notes.
|
||||
const list = out.content.find((n) => n.type === "orderedList");
|
||||
const list = out.content.find((n) => n.type === "footnotesList");
|
||||
assert.ok(list, "footnotesList present");
|
||||
assert.equal(list.content.length, 1);
|
||||
assert.equal(blockText(list.content[0]), "apple note");
|
||||
assert.equal(list.content[0].attrs.id, refIds[0]);
|
||||
assert.equal(defText(list.content[0]), "apple note");
|
||||
|
||||
// No stray placeholder sentinel remains anywhere: the NUL-delimited sentinel
|
||||
// is fully consumed by the renumber pass, so no raw NUL control char persists
|
||||
@@ -287,17 +309,25 @@ test("commentsToFootnotes renumbers body callouts but skips the disclaimer range
|
||||
assert.deepEqual(consumed, []);
|
||||
|
||||
// The disclaimer's "[1]…[K]" range is NOT treated as body markers: it stays
|
||||
// a range and is synced to the note count (2), not renumbered into [1],[2].
|
||||
// a range and is synced to the note count (2), not turned into references.
|
||||
assert.match(blockText(out.content[0]), /\[1\]…\[2\]/);
|
||||
|
||||
// The body callout's [1] is renumbered as a real reading-order marker.
|
||||
assert.match(blockText(out.content[1]), /noted \[1\] above/);
|
||||
// The following paragraph's [2] keeps reading order.
|
||||
assert.match(blockText(out.content[2]), /with \[2\] too/);
|
||||
// The body callout's [1] and the paragraph's [2] became footnoteReference
|
||||
// nodes in reading order (the literal text markers are gone).
|
||||
const refIds = collectRefIds(out);
|
||||
assert.equal(refIds.length, 2);
|
||||
assert.match(blockText(out.content[1]), /noted +above/); // [1] -> node, no text
|
||||
assert.match(blockText(out.content[2]), /with +too/); // [2] -> node, no text
|
||||
|
||||
// Notes list still has the two original notes in order.
|
||||
const list = out.content.find((n) => n.type === "orderedList");
|
||||
// The footnotesList holds the two original notes in reading order, keyed to
|
||||
// the new reference ids.
|
||||
const list = out.content.find((n) => n.type === "footnotesList");
|
||||
assert.ok(list, "footnotesList present");
|
||||
assert.equal(list.content.length, 2);
|
||||
assert.equal(blockText(list.content[0]), "first note");
|
||||
assert.equal(blockText(list.content[1]), "second note");
|
||||
assert.deepEqual(
|
||||
list.content.map((d) => d.attrs.id),
|
||||
refIds,
|
||||
);
|
||||
assert.equal(defText(list.content[0]), "first note");
|
||||
assert.equal(defText(list.content[1]), "second note");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user