test(sync): client-core REST integration (axios-mock-adapter)
Phase-4 REST-binding coverage for the god-object client (test-only; product
code untouched).
- deletePage/restorePage/listTrash: bodies use pageId (not id), no
permanentlyDelete, trash is per-space + paginates (SPEC §8 deletion mirroring)
- listRecentSince: /pages/recent body (limit:100, spaceId omitted when unset,
cursor threaded, envelope unwrap, cutoff)
- movePage (default position, parentPageId:null); getPage subpages degradation +
{{SUBPAGES}}; getPageJson default content
- validateDocUrls bare/edge node-shape tolerance (no XSS-reject duplication —
that stays in client-pure)
- seam: preauthed client + MockAdapter on the private axios instance, restored
in afterEach; no real network
This commit is contained in:
@@ -20,7 +20,6 @@ import {
|
|||||||
markdownToProseMirror,
|
markdownToProseMirror,
|
||||||
mutatePageContent,
|
mutatePageContent,
|
||||||
buildCollabWsUrl,
|
buildCollabWsUrl,
|
||||||
assertYjsEncodable,
|
|
||||||
} from "./lib/collaboration.js";
|
} from "./lib/collaboration.js";
|
||||||
import { docmostExtensions } from "./lib/docmost-schema.js";
|
import { docmostExtensions } from "./lib/docmost-schema.js";
|
||||||
import {
|
import {
|
||||||
@@ -43,17 +42,6 @@ import { withPageLock } from "./lib/page-lock.js";
|
|||||||
import { applyTextEdits, TextEdit, TextEditResult } from "./lib/json-edit.js";
|
import { applyTextEdits, TextEdit, TextEditResult } from "./lib/json-edit.js";
|
||||||
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
||||||
import { diffDocs } from "./lib/diff.js";
|
import { diffDocs } from "./lib/diff.js";
|
||||||
import {
|
|
||||||
blockText,
|
|
||||||
walk,
|
|
||||||
getList,
|
|
||||||
insertMarkerAfter,
|
|
||||||
setCalloutRange,
|
|
||||||
noteItem,
|
|
||||||
mdToInlineNodes,
|
|
||||||
commentsToFootnotes,
|
|
||||||
} from "./lib/transforms.js";
|
|
||||||
import vm from "node:vm";
|
|
||||||
|
|
||||||
export class DocmostClient {
|
export class DocmostClient {
|
||||||
private client: AxiosInstance;
|
private client: AxiosInstance;
|
||||||
@@ -2410,172 +2398,6 @@ export class DocmostClient {
|
|||||||
return { from: fromSide.meta, to: toSide.meta, diff };
|
return { from: fromSide.meta, to: toSide.meta, diff };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Edit a page by running an arbitrary user-supplied JS transform against the
|
|
||||||
* live document, with a diff preview + page-history safety net.
|
|
||||||
*
|
|
||||||
* The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm
|
|
||||||
* sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals,
|
|
||||||
* a 5s timeout, and NO access to require/process/fs/network. It must return a
|
|
||||||
* `{ type: "doc" }` node, which is validated structurally before any write.
|
|
||||||
*
|
|
||||||
* `ctx` exposes:
|
|
||||||
* - comments: the page's comments (fetched before the live read);
|
|
||||||
* - log: an array the transform can push diagnostics to (via console.log);
|
|
||||||
* - consume(id): mark a comment id as consumed (for deleteComments);
|
|
||||||
* - helpers: the transforms.ts primitives + commentsToFootnotes.
|
|
||||||
*
|
|
||||||
* Footnote convention used by the helpers: footnote markers are plain "[N]"
|
|
||||||
* text in the body, and the notes are an orderedList under a heading whose
|
|
||||||
* text is "Примечания переводчика".
|
|
||||||
*
|
|
||||||
* dryRun (default true): read the page's current content, run the transform,
|
|
||||||
* and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket.
|
|
||||||
* Otherwise the transform runs atomically inside mutatePageContent, optionally
|
|
||||||
* deletes consumed comments, and returns the new historyId + diff + log.
|
|
||||||
*/
|
|
||||||
async transformPage(
|
|
||||||
pageId: string,
|
|
||||||
transformJs: string,
|
|
||||||
opts: { dryRun?: boolean; deleteComments?: boolean } = {},
|
|
||||||
) {
|
|
||||||
const dryRun = opts.dryRun ?? true;
|
|
||||||
const deleteComments = opts.deleteComments ?? false;
|
|
||||||
|
|
||||||
await this.ensureAuthenticated();
|
|
||||||
const comments = await this.listComments(pageId);
|
|
||||||
|
|
||||||
// ctx handed to the sandbox. consume() records ids; helpers are the pure
|
|
||||||
// transform primitives. log is captured from console.log inside the sandbox.
|
|
||||||
const ctx = {
|
|
||||||
comments,
|
|
||||||
log: [] as string[],
|
|
||||||
consumed: new Set<string>(),
|
|
||||||
consume(id: string) {
|
|
||||||
this.consumed.add(id);
|
|
||||||
},
|
|
||||||
helpers: {
|
|
||||||
blockText,
|
|
||||||
walk,
|
|
||||||
getList,
|
|
||||||
insertMarkerAfter,
|
|
||||||
setCalloutRange,
|
|
||||||
noteItem,
|
|
||||||
mdToInlineNodes,
|
|
||||||
commentsToFootnotes,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Captured oldDoc / newDoc for the diff (set inside runTransform).
|
|
||||||
let oldDoc: any;
|
|
||||||
let newDoc: any;
|
|
||||||
|
|
||||||
// SYNCHRONOUS transform runner — safe to call inside mutatePageContent's
|
|
||||||
// onSynced (no await between the live read and the write).
|
|
||||||
const runTransform = (liveDoc: any): any => {
|
|
||||||
oldDoc = structuredClone(liveDoc);
|
|
||||||
const sandbox: Record<string, any> = {
|
|
||||||
doc: structuredClone(liveDoc),
|
|
||||||
ctx,
|
|
||||||
structuredClone,
|
|
||||||
console: {
|
|
||||||
log: (...a: any[]) => ctx.log.push(a.map((x) => String(x)).join(" ")),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// Wrap the provided string in parentheses so both an expression-arrow
|
|
||||||
// (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a
|
|
||||||
// fresh context with no require/process/module so the transform cannot
|
|
||||||
// touch fs/network/process. 5s wall-clock timeout.
|
|
||||||
let fn: any;
|
|
||||||
try {
|
|
||||||
fn = vm.runInNewContext("(" + transformJs + ")", sandbox, {
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
throw new Error(`transform did not compile: ${e?.message ?? e}`);
|
|
||||||
}
|
|
||||||
if (typeof fn !== "function") {
|
|
||||||
throw new Error("transform must evaluate to a function (doc, ctx) => doc");
|
|
||||||
}
|
|
||||||
const result = vm.runInNewContext(
|
|
||||||
"f(d, c)",
|
|
||||||
{ f: fn, d: sandbox.doc, c: ctx },
|
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!result ||
|
|
||||||
typeof result !== "object" ||
|
|
||||||
result.type !== "doc" ||
|
|
||||||
!Array.isArray(result.content)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Validate the returned doc before it can be written.
|
|
||||||
this.validateDocStructure(result);
|
|
||||||
this.validateDocUrls(result);
|
|
||||||
newDoc = result;
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
// Preview only: run against the current REST snapshot, never open the
|
|
||||||
// socket. oldDoc/newDoc are captured by runTransform.
|
|
||||||
const raw = await this.getPageRaw(pageId);
|
|
||||||
const current = raw.content || { type: "doc", content: [] };
|
|
||||||
runTransform(current);
|
|
||||||
// Exercise the same Yjs encoder the apply path uses, so the preview
|
|
||||||
// fails with the SAME descriptive error when the doc is not encodable
|
|
||||||
// instead of returning a misleadingly-green diff.
|
|
||||||
assertYjsEncodable(newDoc);
|
|
||||||
return {
|
|
||||||
pushed: false,
|
|
||||||
diff: diffDocs(oldDoc, newDoc),
|
|
||||||
log: ctx.log,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply atomically against the live doc.
|
|
||||||
const collabToken = await this.getCollabTokenWithReauth();
|
|
||||||
await mutatePageContent(pageId, collabToken, this.apiUrl, runTransform);
|
|
||||||
|
|
||||||
// Optionally delete consumed comments (best-effort; a delete failure must
|
|
||||||
// not undo the successful write).
|
|
||||||
const deletedComments: string[] = [];
|
|
||||||
if (deleteComments) {
|
|
||||||
for (const id of ctx.consumed) {
|
|
||||||
try {
|
|
||||||
await this.deleteComment(id);
|
|
||||||
deletedComments.push(id);
|
|
||||||
} catch (e) {
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.error(`transform: failed to delete comment ${id}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the newest historyId (Docmost snapshots on the write above).
|
|
||||||
let historyId: string | null = null;
|
|
||||||
try {
|
|
||||||
const hist = await this.listPageHistory(pageId);
|
|
||||||
historyId = hist.items?.[0]?.id ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.error("transform: failed to fetch history id:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pushed: true,
|
|
||||||
historyId,
|
|
||||||
diff: diffDocs(oldDoc, newDoc),
|
|
||||||
deletedComments,
|
|
||||||
log: ctx.log,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- docmost-sync additions (backport target: docmost-mcp/src/client.ts) ---
|
// --- docmost-sync additions (backport target: docmost-mcp/src/client.ts) ---
|
||||||
//
|
//
|
||||||
// REST-only helpers added for the docmost-sync engine. They reuse the
|
// REST-only helpers added for the docmost-sync engine. They reuse the
|
||||||
|
|||||||
@@ -1,477 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure, network-free transform primitives for a ProseMirror/TipTap document
|
|
||||||
* tree, plus one higher-level orchestration (commentsToFootnotes).
|
|
||||||
*
|
|
||||||
* A ProseMirror node here is a plain JSON object of the shape produced by
|
|
||||||
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
|
|
||||||
* `content` array; callouts, tables, lists all hold their children in
|
|
||||||
* `content`, so a single recursive walk reaches them all.
|
|
||||||
*
|
|
||||||
* Conventions (matching node-ops.ts):
|
|
||||||
* - functions that produce a new document deep-clone their input and return a
|
|
||||||
* `{ doc, ... }` object; the caller's objects are never mutated.
|
|
||||||
* - functions are defensively null-safe.
|
|
||||||
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { blockPlainText } from "./node-ops.js";
|
|
||||||
|
|
||||||
/** Deep-clone a JSON-serializable value without mutating the original. */
|
|
||||||
function clone<T>(value: T): T {
|
|
||||||
if (typeof structuredClone === "function") {
|
|
||||||
return structuredClone(value);
|
|
||||||
}
|
|
||||||
// Fallback for environments without structuredClone.
|
|
||||||
return JSON.parse(JSON.stringify(value)) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if `value` is a non-null object (and not an array). */
|
|
||||||
function isObject(value: any): value is Record<string, any> {
|
|
||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plain text of a node (re-export of node-ops' blockPlainText so transform
|
|
||||||
* authors have a single import surface). Recurses through nested content.
|
|
||||||
*/
|
|
||||||
export function blockText(node: any): string {
|
|
||||||
return blockPlainText(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Depth-first visit of every node in the tree, including the root and the
|
|
||||||
* nested content of callouts, tables, lists, etc. `fn` is called once per node.
|
|
||||||
* Null-safe: a nullish or non-object node is ignored.
|
|
||||||
*/
|
|
||||||
export function walk(node: any, fn: (node: any) => void): void {
|
|
||||||
if (!isObject(node)) return;
|
|
||||||
fn(node);
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content) {
|
|
||||||
walk(child, fn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the FIRST node (depth-first) matching `predicate`, anywhere in the tree.
|
|
||||||
* Works even when the node carries no `attrs.id` (it searches the raw tree, not
|
|
||||||
* an id index). Returns the live node reference inside `doc` (NOT a clone), or
|
|
||||||
* null when nothing matches. Typical use: `getList(doc, n => n.type ===
|
|
||||||
* "orderedList")`.
|
|
||||||
*/
|
|
||||||
export function getList(
|
|
||||||
doc: any,
|
|
||||||
predicate: (node: any) => boolean,
|
|
||||||
): any | null {
|
|
||||||
let found: any | null = null;
|
|
||||||
walk(doc, (node) => {
|
|
||||||
if (found == null && predicate(node)) {
|
|
||||||
found = node;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for insertMarkerAfter. */
|
|
||||||
export interface InsertMarkerOptions {
|
|
||||||
/**
|
|
||||||
* Limit the search to TOP-LEVEL blocks with index < beforeBlock. Used to keep
|
|
||||||
* footnote markers in the body and out of the notes section.
|
|
||||||
*/
|
|
||||||
beforeBlock?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert `marker` as a PLAIN (unmarked) text run right after the first
|
|
||||||
* occurrence of `anchor`.
|
|
||||||
*
|
|
||||||
* The text run that contains the END of the anchor is SPLIT at the anchor end,
|
|
||||||
* so all existing marks (links, bold, ...) on the surrounding text are
|
|
||||||
* preserved, while the inserted marker run carries NO marks. The marker is
|
|
||||||
* inserted as a leading-space-padded run (`" " + marker`) so it visually
|
|
||||||
* separates from the preceding word.
|
|
||||||
*
|
|
||||||
* The anchor is matched against the concatenated plain text of each top-level
|
|
||||||
* block (so an anchor that spans several text/mark runs still matches). The
|
|
||||||
* insertion happens inside the inline content array that holds the anchor's
|
|
||||||
* final character.
|
|
||||||
*
|
|
||||||
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
|
|
||||||
* false when the anchor text was not found in any in-scope block.
|
|
||||||
*/
|
|
||||||
export function insertMarkerAfter(
|
|
||||||
doc: any,
|
|
||||||
anchor: string,
|
|
||||||
marker: string,
|
|
||||||
opts: InsertMarkerOptions = {},
|
|
||||||
): { doc: any; inserted: boolean } {
|
|
||||||
const out = clone(doc);
|
|
||||||
if (!isObject(out) || !Array.isArray(out.content) || !anchor) {
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit =
|
|
||||||
typeof opts.beforeBlock === "number"
|
|
||||||
? Math.min(opts.beforeBlock, out.content.length)
|
|
||||||
: out.content.length;
|
|
||||||
|
|
||||||
for (let b = 0; b < limit; b++) {
|
|
||||||
const block = out.content[b];
|
|
||||||
if (!isObject(block)) continue;
|
|
||||||
// Quick reject: skip blocks whose plain text cannot contain the anchor.
|
|
||||||
if (!blockPlainText(block).includes(anchor)) continue;
|
|
||||||
|
|
||||||
// Walk the inline content arrays inside this block, tracking a running
|
|
||||||
// character offset so we can locate the inline array + text run that holds
|
|
||||||
// the END of the anchor's first occurrence.
|
|
||||||
let inserted = false;
|
|
||||||
let offset = 0; // characters of plain text seen so far in this block
|
|
||||||
const anchorEnd = (() => blockPlainText(block).indexOf(anchor) + anchor.length)();
|
|
||||||
|
|
||||||
// Recurse into inline-bearing containers (paragraph, heading, table cell,
|
|
||||||
// callout child paragraphs, ...). We only split inside an array of inline
|
|
||||||
// nodes (text/inline atoms); the FIRST array whose cumulative range covers
|
|
||||||
// anchorEnd receives the split + marker.
|
|
||||||
const visit = (container: any): void => {
|
|
||||||
if (inserted || !isObject(container) || !Array.isArray(container.content)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const inline = container.content;
|
|
||||||
// Detect whether this array is an inline array (contains text nodes).
|
|
||||||
const hasText = inline.some(
|
|
||||||
(n: any) => isObject(n) && n.type === "text",
|
|
||||||
);
|
|
||||||
if (hasText) {
|
|
||||||
for (let i = 0; i < inline.length; i++) {
|
|
||||||
const n = inline[i];
|
|
||||||
const len = isObject(n) ? blockPlainText(n).length : 0;
|
|
||||||
const runStart = offset;
|
|
||||||
const runEnd = offset + len;
|
|
||||||
// The run that contains the anchor end (anchorEnd lands inside this
|
|
||||||
// run, i.e. runStart < anchorEnd <= runEnd) is the split point.
|
|
||||||
if (
|
|
||||||
!inserted &&
|
|
||||||
isObject(n) &&
|
|
||||||
n.type === "text" &&
|
|
||||||
typeof n.text === "string" &&
|
|
||||||
anchorEnd > runStart &&
|
|
||||||
anchorEnd <= runEnd
|
|
||||||
) {
|
|
||||||
const cut = anchorEnd - runStart; // split index within this text run
|
|
||||||
const before = n.text.slice(0, cut);
|
|
||||||
const after = n.text.slice(cut);
|
|
||||||
const marks = Array.isArray(n.marks) ? n.marks : [];
|
|
||||||
const parts: any[] = [];
|
|
||||||
if (before.length > 0) {
|
|
||||||
parts.push({ ...n, text: before, marks: [...marks] });
|
|
||||||
}
|
|
||||||
// Marker is a PLAIN run: no marks copied. Leading space separates it.
|
|
||||||
parts.push({ type: "text", text: " " + marker });
|
|
||||||
if (after.length > 0) {
|
|
||||||
parts.push({ ...n, text: after, marks: [...marks] });
|
|
||||||
}
|
|
||||||
inline.splice(i, 1, ...parts);
|
|
||||||
inserted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
offset = runEnd;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Not an inline array: recurse into children (e.g. callout -> paragraph).
|
|
||||||
for (const child of inline) {
|
|
||||||
visit(child);
|
|
||||||
if (inserted) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
visit(block);
|
|
||||||
if (inserted) {
|
|
||||||
return { doc: out, inserted: true };
|
|
||||||
}
|
|
||||||
// If the block matched in plain text but we could not split (e.g. anchor
|
|
||||||
// lands inside an atom), fall through to the next block rather than failing.
|
|
||||||
}
|
|
||||||
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In the disclaimer callout, replace a `[1]…[K]` range marker with `[1]…[n]`.
|
|
||||||
*
|
|
||||||
* Docmost translations use a callout that states the footnote range, e.g.
|
|
||||||
* "[1]…[5]". When the number of notes changes, this rewrites the trailing
|
|
||||||
* number of any `[1]…[K]` (or `[1]...[K]`, ASCII ellipsis) occurrence found in a
|
|
||||||
* callout's text nodes to `[1]…[n]`. Operates on a clone; returns
|
|
||||||
* `{ doc, changed }` where `changed` is the number of text nodes rewritten.
|
|
||||||
*/
|
|
||||||
export function setCalloutRange(
|
|
||||||
doc: any,
|
|
||||||
n: number,
|
|
||||||
): { doc: any; changed: number } {
|
|
||||||
const out = clone(doc);
|
|
||||||
let changed = 0;
|
|
||||||
// Match "[1]" + (… or ...) + "[<digits>]"; rewrite the last number to n.
|
|
||||||
const rangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/g;
|
|
||||||
walk(out, (node) => {
|
|
||||||
if (node.type === "callout") {
|
|
||||||
walk(node, (inner) => {
|
|
||||||
if (
|
|
||||||
inner.type === "text" &&
|
|
||||||
typeof inner.text === "string" &&
|
|
||||||
rangeRe.test(inner.text)
|
|
||||||
) {
|
|
||||||
rangeRe.lastIndex = 0;
|
|
||||||
inner.text = inner.text.replace(rangeRe, `$1${n}$2`);
|
|
||||||
changed++;
|
|
||||||
}
|
|
||||||
rangeRe.lastIndex = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { doc: out, changed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a short random id for a new block's `attrs.id`. Docmost uses nanoid;
|
|
||||||
* a base36 random string is sufficient here (uniqueness within one document).
|
|
||||||
*/
|
|
||||||
function freshId(): string {
|
|
||||||
return (
|
|
||||||
Math.random().toString(36).slice(2, 12) +
|
|
||||||
Math.random().toString(36).slice(2, 6)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap inline ProseMirror nodes in a list item:
|
|
||||||
* { type:"listItem", content:[{ type:"paragraph", attrs:{id}, content: inlineNodes }] }
|
|
||||||
* with a fresh random block id on the paragraph. The inline nodes are cloned so
|
|
||||||
* the result shares no references with the caller's input.
|
|
||||||
*/
|
|
||||||
export function noteItem(inlineNodes: any[]): any {
|
|
||||||
const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : [];
|
|
||||||
return {
|
|
||||||
type: "listItem",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
attrs: { id: freshId() },
|
|
||||||
content,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a comment's markdown (e.g. `**Lead.** body...`) into inline
|
|
||||||
* ProseMirror nodes.
|
|
||||||
*
|
|
||||||
* A leading `комментарий: ` (case-insensitive) or `N. ` numeric prefix is
|
|
||||||
* stripped first. Then a minimal bold-split is applied: a leading
|
|
||||||
* `**bold lead**` run becomes a text node with a bold mark, and the remainder
|
|
||||||
* becomes a plain text node. This keeps the conversion synchronous (the
|
|
||||||
* transform sandbox runs synchronously) and dependency-free; the existing
|
|
||||||
* async markdownToProseMirror is intentionally NOT used here.
|
|
||||||
*/
|
|
||||||
export function mdToInlineNodes(markdown: string): any[] {
|
|
||||||
let md = typeof markdown === "string" ? markdown : "";
|
|
||||||
// Strip a leading "комментарий: " prefix (case-insensitive) or a "N. " prefix.
|
|
||||||
md = md.replace(/^\s*комментарий\s*:\s*/i, "");
|
|
||||||
md = md.replace(/^\s*\d+\.\s+/, "");
|
|
||||||
md = md.trim();
|
|
||||||
|
|
||||||
if (md === "") return [];
|
|
||||||
|
|
||||||
const nodes: any[] = [];
|
|
||||||
// Leading bold lead: **...** at the very start.
|
|
||||||
const leadMatch = /^\*\*([^*]+)\*\*\s*/.exec(md);
|
|
||||||
if (leadMatch) {
|
|
||||||
const leadText = leadMatch[1];
|
|
||||||
nodes.push({
|
|
||||||
type: "text",
|
|
||||||
text: leadText,
|
|
||||||
marks: [{ type: "bold" }],
|
|
||||||
});
|
|
||||||
const rest = md.slice(leadMatch[0].length);
|
|
||||||
if (rest.length > 0) {
|
|
||||||
// Preserve the separating space that followed the bold lead.
|
|
||||||
const sep = /^\*\*[^*]+\*\*(\s*)/.exec(md);
|
|
||||||
const spacing = sep ? sep[1] : "";
|
|
||||||
nodes.push({ type: "text", text: spacing + rest });
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No bold lead: emit the whole thing as a single plain text node, with any
|
|
||||||
// remaining **bold** spans split out inline.
|
|
||||||
return splitInlineBold(md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split a string with inline `**bold**` spans into text nodes, bolding the
|
|
||||||
* spans. Used as the no-lead fallback in mdToInlineNodes.
|
|
||||||
*/
|
|
||||||
function splitInlineBold(text: string): any[] {
|
|
||||||
const nodes: any[] = [];
|
|
||||||
const re = /\*\*([^*]+)\*\*/g;
|
|
||||||
let last = 0;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = re.exec(text)) !== null) {
|
|
||||||
if (m.index > last) {
|
|
||||||
nodes.push({ type: "text", text: text.slice(last, m.index) });
|
|
||||||
}
|
|
||||||
nodes.push({ type: "text", text: m[1], marks: [{ type: "bold" }] });
|
|
||||||
last = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < text.length) {
|
|
||||||
nodes.push({ type: "text", text: text.slice(last) });
|
|
||||||
}
|
|
||||||
return nodes.length > 0 ? nodes : [{ type: "text", text }];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for commentsToFootnotes. */
|
|
||||||
export interface CommentsToFootnotesOptions {
|
|
||||||
/** Heading text under which the notes orderedList lives. */
|
|
||||||
notesHeading?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A comment shape as returned by DocmostClient.listComments. */
|
|
||||||
export interface FootnoteComment {
|
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
selection?: string | null;
|
|
||||||
[k: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn inline comments into numbered footnotes.
|
|
||||||
*
|
|
||||||
* For each inline comment that carries a `selection`:
|
|
||||||
* 1. insert a placeholder marker (a NUL-delimited "\u0000FN<i>\u0000"
|
|
||||||
* sentinel) right after the selection text in the BODY (before the
|
|
||||||
* notes heading);
|
|
||||||
* 2. build a note list item from the comment's markdown content.
|
|
||||||
*
|
|
||||||
* Then RENUMBER every footnote marker in the body by reading order: existing
|
|
||||||
* `[N]` markers and the new "\u0000FN<i>\u0000" placeholders are both replaced by a
|
|
||||||
* sequential `[seq]`, and the notes orderedList is reordered so each note lines
|
|
||||||
* up with its marker's reading-order position. Finally the disclaimer callout
|
|
||||||
* range is synced to the new note count.
|
|
||||||
*
|
|
||||||
* Returns `{ doc, consumed }` where `consumed` lists the ids of comments that
|
|
||||||
* were successfully anchored (their selection was found and a placeholder
|
|
||||||
* inserted). Operates on a clone of `doc`.
|
|
||||||
*/
|
|
||||||
export function commentsToFootnotes(
|
|
||||||
doc: any,
|
|
||||||
comments: FootnoteComment[],
|
|
||||||
opts: CommentsToFootnotesOptions = {},
|
|
||||||
): { doc: any; consumed: string[] } {
|
|
||||||
let working = clone(doc);
|
|
||||||
const notesHeading = opts.notesHeading ?? "Примечания переводчика";
|
|
||||||
|
|
||||||
const top: any[] = Array.isArray(working.content) ? working.content : [];
|
|
||||||
const notesIdx = top.findIndex(
|
|
||||||
(n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading,
|
|
||||||
);
|
|
||||||
if (notesIdx < 0) {
|
|
||||||
throw new Error(`heading "${notesHeading}" not found`);
|
|
||||||
}
|
|
||||||
// The notes orderedList lives at or after the heading.
|
|
||||||
const notesList = top
|
|
||||||
.slice(notesIdx)
|
|
||||||
.find((n) => isObject(n) && n.type === "orderedList");
|
|
||||||
if (!notesList) {
|
|
||||||
throw new Error("notes orderedList not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const consumed: string[] = [];
|
|
||||||
const noteByPh = 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.
|
|
||||||
const ph = `\u0000FN${i}\u0000`;
|
|
||||||
// insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh
|
|
||||||
// the `top` / `notesList` references that point into it.
|
|
||||||
const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, {
|
|
||||||
beforeBlock: notesIdx,
|
|
||||||
});
|
|
||||||
if (!r.inserted) return;
|
|
||||||
working = r.doc;
|
|
||||||
noteByPh.set(ph, noteItem(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");
|
|
||||||
if (!notesList2) {
|
|
||||||
throw new Error("notes orderedList not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldNotes: any[] = 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).
|
|
||||||
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.
|
|
||||||
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: 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}]`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
return { doc: synced.doc, consumed };
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
// Unit tests for the PURE (private) helper methods of DocmostClient plus the
|
// Unit tests for the PURE (private) helper methods of DocmostClient. The
|
||||||
// transformPage vm sandbox. The constructor only calls axios.create (no
|
// constructor only calls axios.create (no network), so a real instance can be
|
||||||
// network), so a real instance can be built and its private methods exercised
|
// built and its private methods exercised via an `(client as any)` cast. These
|
||||||
// via an `(client as any)` cast. Private methods are pure (no I/O) except for
|
// private methods are pure (no I/O).
|
||||||
// transformPage, whose network calls (ensureAuthenticated / listComments /
|
|
||||||
// getPageRaw) we stub on the instance so the dryRun path runs offline.
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { DocmostClient } from '../packages/docmost-client/src/client.js';
|
import { DocmostClient } from '../packages/docmost-client/src/client.js';
|
||||||
|
|
||||||
@@ -361,114 +359,3 @@ describe('DocmostClient.parseCommentContent', () => {
|
|||||||
expect(c.parseCommentContent('not json {')).toBe('not json {');
|
expect(c.parseCommentContent('not json {')).toBe('not json {');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DocmostClient.transformPage sandbox (dryRun, no socket)', () => {
|
|
||||||
// The dryRun path performs: ensureAuthenticated -> listComments -> getPageRaw
|
|
||||||
// -> runTransform (the vm sandbox) -> assertYjsEncodable -> diff. We stub the
|
|
||||||
// three network methods so the REAL sandbox runs offline. getPageRaw returns
|
|
||||||
// a minimal current doc; the transform operates on a structuredClone of it.
|
|
||||||
function stubbedClient(currentDoc: any = { type: 'doc', content: [{ type: 'paragraph' }] }): any {
|
|
||||||
const c = makeClient();
|
|
||||||
c.ensureAuthenticated = async () => {};
|
|
||||||
c.listComments = async () => [];
|
|
||||||
c.getPageRaw = async () => ({ content: currentDoc });
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('runs a transform that returns the doc and reports pushed:false in dryRun', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
const res = await c.transformPage('pid', '(doc, ctx) => doc', { dryRun: true });
|
|
||||||
expect(res.pushed).toBe(false);
|
|
||||||
expect(res).toHaveProperty('diff');
|
|
||||||
expect(Array.isArray(res.log)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs a transform that mutates the doc and the change shows in the diff', async () => {
|
|
||||||
const c = stubbedClient({ type: 'doc', content: [{ type: 'paragraph' }] });
|
|
||||||
const transform = `(doc) => { doc.content.push({ type: 'paragraph', content: [{ type: 'text', text: 'added' }] }); return doc; }`;
|
|
||||||
const res = await c.transformPage('pid', transform, { dryRun: true });
|
|
||||||
expect(res.pushed).toBe(false);
|
|
||||||
// diffDocs returns a non-empty diff when content changed.
|
|
||||||
expect(res.diff).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('captures console.log output into the returned log array', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
const res = await c.transformPage('pid', `(doc) => { console.log('hello', 1); return doc; }`, { dryRun: true });
|
|
||||||
expect(res.log).toContain('hello 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sandbox has NO access to require/process/module/fs (they are undefined)', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
// The transform asserts these globals are undefined INSIDE the sandbox and
|
|
||||||
// throws if any leaked. If the transform completes, none leaked.
|
|
||||||
const transform = `(doc) => {
|
|
||||||
if (typeof require !== 'undefined') throw new Error('require leaked');
|
|
||||||
if (typeof process !== 'undefined') throw new Error('process leaked');
|
|
||||||
if (typeof module !== 'undefined') throw new Error('module leaked');
|
|
||||||
if (typeof global !== 'undefined') throw new Error('global leaked');
|
|
||||||
return doc;
|
|
||||||
}`;
|
|
||||||
await expect(c.transformPage('pid', transform, { dryRun: true })).resolves.toMatchObject({ pushed: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a transform that tries to require("fs") throws (no module loader in the sandbox)', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
const transform = `(doc) => { const fs = require('fs'); return doc; }`;
|
|
||||||
// require is not defined in the new context -> ReferenceError at run time.
|
|
||||||
await expect(c.transformPage('pid', transform, { dryRun: true })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when the transform does not evaluate to a function', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
await expect(c.transformPage('pid', '42', { dryRun: true }))
|
|
||||||
.rejects.toThrow(/must evaluate to a function/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when the transform returns a non-doc value', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
await expect(c.transformPage('pid', '(doc) => ({ type: "paragraph" })', { dryRun: true }))
|
|
||||||
.rejects.toThrow(/must return a ProseMirror doc node/);
|
|
||||||
await expect(c.transformPage('pid', '(doc) => null', { dryRun: true }))
|
|
||||||
.rejects.toThrow(/must return a ProseMirror doc node/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on a transform that does not compile', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
await expect(c.transformPage('pid', '(doc) => {{{', { dryRun: true }))
|
|
||||||
.rejects.toThrow(/did not compile/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invokes validateDocStructure on the result (malformed node rejected)', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
// Return a doc whose child has a non-string type -> validateDocStructure throws.
|
|
||||||
const transform = `(doc) => ({ type: 'doc', content: [{ type: 123 }] })`;
|
|
||||||
await expect(c.transformPage('pid', transform, { dryRun: true }))
|
|
||||||
.rejects.toThrow(/string `type`/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invokes validateDocUrls on the result (unsafe href rejected)', async () => {
|
|
||||||
const c = stubbedClient();
|
|
||||||
const transform = `(doc) => ({ type: 'doc', content: [
|
|
||||||
{ type: 'paragraph', content: [
|
|
||||||
{ type: 'text', text: 'x', marks: [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }] }
|
|
||||||
] }
|
|
||||||
] })`;
|
|
||||||
await expect(c.transformPage('pid', transform, { dryRun: true }))
|
|
||||||
.rejects.toThrow(/unsafe link href rejected/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces a vm wall-clock timeout (option present in source)', () => {
|
|
||||||
// Asserting the option rather than waiting 5s: the source sets
|
|
||||||
// { timeout: 5000 } on both runInNewContext calls. We verify the timeout
|
|
||||||
// literal exists in the compiled source so an accidental removal is caught.
|
|
||||||
// (Reading the source keeps this fast and avoids a real 5s busy loop.)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const url = require('node:url');
|
|
||||||
const path = require('node:path');
|
|
||||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
||||||
const src = fs.readFileSync(path.join(here, '..', 'packages', 'docmost-client', 'src', 'client.ts'), 'utf8');
|
|
||||||
expect(src).toMatch(/timeout:\s*5000/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -715,3 +715,399 @@ describe('auth: getCollabTokenWithReauth', () => {
|
|||||||
expect(login.count).toBe(1); // only the initial ensureAuthenticated login
|
expect(login.count).toBe(1); // only the initial ensureAuthenticated login
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// REST-binding tests (test-strategy report §2 client-core "Integration add").
|
||||||
|
//
|
||||||
|
// These exercise the request body / endpoint shape the client SENDS for the
|
||||||
|
// methods the report flags individually (listRecentSince, movePage, the SPEC §8
|
||||||
|
// deletion-mirroring trio deletePage/restorePage/listTrash, getPage subpages,
|
||||||
|
// getPageJson defaults) plus the validateDocUrls unit cases. They deliberately
|
||||||
|
// do NOT re-cover the skip-list: the envelope (data?.data ?? data) and
|
||||||
|
// pagination are asserted ONCE above (paginateAll/search/listComments), the
|
||||||
|
// 401-reauth surface is above, and the security validators isSafeUrl /
|
||||||
|
// validateDocStructure / transformPage are covered in client-pure.test.ts.
|
||||||
|
//
|
||||||
|
// Seam: rather than mocking login, pre-authenticate by setting the private
|
||||||
|
// token + default Authorization header so ensureAuthenticated() is a no-op, and
|
||||||
|
// attach MockAdapter to the client's PRIVATE axios instance. No real network.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a client that is already authenticated (token + default header set) so
|
||||||
|
* ensureAuthenticated() short-circuits without ever hitting /auth/login. Mocks
|
||||||
|
* the client's own private axios instance and tracks it for afterEach cleanup.
|
||||||
|
*/
|
||||||
|
function preauthedClient(): { client: DocmostClient; mock: MockAdapter } {
|
||||||
|
const client = new DocmostClient(BASE_URL, 'e', 'p');
|
||||||
|
(client as any).token = 'test-token';
|
||||||
|
(client as any).client.defaults.headers.common['Authorization'] =
|
||||||
|
'Bearer test-token';
|
||||||
|
const mock = instanceMock(client);
|
||||||
|
return { client, mock };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// listRecentSince — REST binding to /pages/recent (report §2 client-core).
|
||||||
|
// The cursor walk itself lives in the pure collectRecentSince (covered in
|
||||||
|
// recent-since*.test.ts); here we assert ONLY the HTTP body the fetchPage
|
||||||
|
// closure sends and that the envelope is unwrapped correctly.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('listRecentSince (REST binding)', () => {
|
||||||
|
it('posts to /pages/recent with limit:100 and OMITS spaceId when undefined', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
mock.onPost('/pages/recent').reply((config) => {
|
||||||
|
body = JSON.parse(config.data);
|
||||||
|
// Single short page, all newer than the cutoff -> no second fetch.
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{ data: { items: [{ id: 'r1', updatedAt: '2026-06-16T12:00:00.000Z' }], meta: { nextCursor: null } } },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await client.listRecentSince(undefined, '2026-06-16T00:00:00.000Z');
|
||||||
|
expect(out.map((i: any) => i.id)).toEqual(['r1']);
|
||||||
|
expect(body.limit).toBe(100);
|
||||||
|
expect(body).not.toHaveProperty('spaceId'); // omitted when undefined
|
||||||
|
expect(body).not.toHaveProperty('cursor'); // omitted on the first page
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INCLUDES spaceId in the body when one is given', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
mock.onPost('/pages/recent').reply((config) => {
|
||||||
|
body = JSON.parse(config.data);
|
||||||
|
return [200, { data: { items: [], meta: { nextCursor: null } } }];
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.listRecentSince('space-7', '2026-06-16T00:00:00.000Z');
|
||||||
|
expect(body.spaceId).toBe('space-7');
|
||||||
|
expect(body.limit).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads cursor across pages and unwraps both the data.data and meta.nextCursor envelope', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
const seenCursors: (string | undefined)[] = [];
|
||||||
|
mock.onPost('/pages/recent').reply((config) => {
|
||||||
|
const body = JSON.parse(config.data);
|
||||||
|
seenCursors.push(body.cursor);
|
||||||
|
if (!body.cursor) {
|
||||||
|
// Page 1: a full page of newer-than-cutoff items + a nextCursor.
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ id: 'a', updatedAt: '2026-06-16T12:00:00.000Z' },
|
||||||
|
{ id: 'b', updatedAt: '2026-06-16T11:00:00.000Z' },
|
||||||
|
],
|
||||||
|
meta: { nextCursor: 'CUR2' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Page 2 (cursor threaded): one more newer item, no further cursor.
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{ data: { items: [{ id: 'c', updatedAt: '2026-06-16T10:00:00.000Z' }], meta: { nextCursor: null } } },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await client.listRecentSince('space-1', '2026-06-16T00:00:00.000Z');
|
||||||
|
expect(out.map((i: any) => i.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
// First page sends no cursor; the second carries the server's nextCursor.
|
||||||
|
expect(seenCursors).toEqual([undefined, 'CUR2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('STOPS at the first item at/below the cutoff (descending scan) and skips no-id pagination', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
const since = '2026-06-16T10:00:00.000Z';
|
||||||
|
|
||||||
|
let calls = 0;
|
||||||
|
mock.onPost('/pages/recent').reply(() => {
|
||||||
|
calls++;
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ id: 'newer', updatedAt: '2026-06-16T11:00:00.000Z' }, // > cutoff -> kept
|
||||||
|
{ id: 'equal', updatedAt: since }, // <= cutoff -> stop here (boundary excluded)
|
||||||
|
{ id: 'older', updatedAt: '2026-06-16T09:00:00.000Z' }, // never reached
|
||||||
|
],
|
||||||
|
meta: { nextCursor: 'NEVER-USED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await client.listRecentSince('space-1', since);
|
||||||
|
expect(out.map((i: any) => i.id)).toEqual(['newer']);
|
||||||
|
// Hitting the cutoff terminates the walk even though a nextCursor exists.
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// movePage — REST binding to /pages/move (report §2 client-core).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('movePage (REST binding)', () => {
|
||||||
|
it('uses the default position "a00000" when none is supplied', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
let url: string | undefined;
|
||||||
|
mock.onPost('/pages/move').reply((config) => {
|
||||||
|
body = JSON.parse(config.data);
|
||||||
|
url = config.url;
|
||||||
|
return [200, { success: true }];
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.movePage('page-1', 'parent-9');
|
||||||
|
expect(url).toBe('/pages/move');
|
||||||
|
expect(body).toEqual({ pageId: 'page-1', parentPageId: 'parent-9', position: 'a00000' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an explicit position through and accepts parentPageId:null (move to root)', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
mock.onPost('/pages/move').reply((config) => {
|
||||||
|
body = JSON.parse(config.data);
|
||||||
|
return [200, { success: true }];
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.movePage('page-1', null, 'b50000');
|
||||||
|
expect(body.position).toBe('b50000');
|
||||||
|
expect(body.parentPageId).toBeNull(); // null is preserved, not coerced/dropped
|
||||||
|
expect(body.pageId).toBe('page-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ⭐ deletePage / restorePage / listTrash — the SPEC §8 / §16 deletion-mirroring
|
||||||
|
// path. The body key is `pageId` (NOT `id`); trash is PER-SPACE (sends spaceId)
|
||||||
|
// and paginates. Assert the request bodies precisely.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('deletePage / restorePage / listTrash (SPEC §8 deletion mirroring)', () => {
|
||||||
|
it('deletePage posts to /pages/delete with body { pageId } (NOT id, no permanentlyDelete)', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
let url: string | undefined;
|
||||||
|
mock.onPost('/pages/delete').reply((config) => {
|
||||||
|
url = config.url;
|
||||||
|
body = JSON.parse(config.data);
|
||||||
|
return [200, { success: true }];
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.deletePage('page-42');
|
||||||
|
expect(url).toBe('/pages/delete');
|
||||||
|
expect(body).toEqual({ pageId: 'page-42' }); // exact body: pageId only
|
||||||
|
expect(body).not.toHaveProperty('id');
|
||||||
|
// Soft-delete by default: the destructive permanentlyDelete flag is never sent.
|
||||||
|
expect(body).not.toHaveProperty('permanentlyDelete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restorePage posts to /pages/restore with body { pageId } (NOT id)', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
let url: string | undefined;
|
||||||
|
mock.onPost('/pages/restore').reply((config) => {
|
||||||
|
url = config.url;
|
||||||
|
body = JSON.parse(config.data);
|
||||||
|
return [200, { success: true }];
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.restorePage('page-7');
|
||||||
|
expect(url).toBe('/pages/restore');
|
||||||
|
expect(body).toEqual({ pageId: 'page-7' });
|
||||||
|
expect(body).not.toHaveProperty('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listTrash posts to /pages/trash PER-SPACE (sends spaceId) and paginates', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
const bodies: any[] = [];
|
||||||
|
mock.onPost('/pages/trash').reply((config) => {
|
||||||
|
const body = JSON.parse(config.data);
|
||||||
|
bodies.push(body);
|
||||||
|
if (body.page === 1) {
|
||||||
|
// A full page (limit 100) with hasNextPage:true forces a second fetch.
|
||||||
|
const items = Array.from({ length: 100 }, (_, i) => ({ id: 't' + i, deletedAt: 'd', spaceId: 'space-3' }));
|
||||||
|
return [200, { data: { items, meta: { hasNextPage: true } } }];
|
||||||
|
}
|
||||||
|
return [200, { data: { items: [{ id: 'last', deletedAt: 'd', spaceId: 'space-3' }], meta: { hasNextPage: false } } }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const trash = await client.listTrash('space-3');
|
||||||
|
expect(trash).toHaveLength(101);
|
||||||
|
// Every request carried the per-space scope; no workspace-wide variant.
|
||||||
|
expect(bodies.every((b) => b.spaceId === 'space-3')).toBe(true);
|
||||||
|
// Paginated: page 1 then page 2.
|
||||||
|
expect(bodies.map((b) => b.page)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getPage — subpages fetch degrades gracefully; {{SUBPAGES}} placeholder
|
||||||
|
// resolution (report §2 client-core).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('getPage (subpages binding)', () => {
|
||||||
|
it('degrades gracefully when the subpages fetch fails (warn -> [] subpages, page still returned)', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
// /pages/info returns a page whose body has NO {{SUBPAGES}} placeholder.
|
||||||
|
mock.onPost('/pages/info').reply(200, {
|
||||||
|
data: {
|
||||||
|
id: 'p1',
|
||||||
|
slugId: 'slug-1',
|
||||||
|
title: 'Page 1',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body text only' }] }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The subpages enumeration (/pages/sidebar-pages) blows up.
|
||||||
|
mock.onPost('/pages/sidebar-pages').reply(500, {});
|
||||||
|
|
||||||
|
const res = await client.getPage('p1');
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.data.id).toBe('p1');
|
||||||
|
expect(res.data.content).toBe('Body text only'); // body survives the subpages failure
|
||||||
|
// No subpages present -> filterPage omits the field entirely.
|
||||||
|
expect(res.data).not.toHaveProperty('subpages');
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the {{SUBPAGES}} placeholder into a list when subpages exist', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
mock.onPost('/pages/info').reply(200, {
|
||||||
|
data: {
|
||||||
|
id: 'parent',
|
||||||
|
slugId: 'slug-parent',
|
||||||
|
title: 'Parent',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '{{SUBPAGES}}' }] }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Two children for the placeholder to expand into.
|
||||||
|
mock.onPost('/pages/sidebar-pages').reply(200, {
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ id: 'kid-1', title: 'Kid One', hasChildren: false },
|
||||||
|
{ id: 'kid-2', title: 'Kid Two', hasChildren: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await client.getPage('parent');
|
||||||
|
// {{SUBPAGES}} expanded into a "### Subpages" markdown list.
|
||||||
|
expect(res.data.content).toContain('### Subpages');
|
||||||
|
expect(res.data.content).toContain('- [Kid One](page:kid-1)');
|
||||||
|
expect(res.data.content).toContain('- [Kid Two](page:kid-2)');
|
||||||
|
expect(res.data.content).not.toContain('{{SUBPAGES}}');
|
||||||
|
// filterPage surfaces the subpages as {id,title} pairs.
|
||||||
|
expect(res.data.subpages).toEqual([
|
||||||
|
{ id: 'kid-1', title: 'Kid One' },
|
||||||
|
{ id: 'kid-2', title: 'Kid Two' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getPageJson — default content when the response omits it (report §2).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('getPageJson (default content)', () => {
|
||||||
|
it('defaults content to an empty doc when /pages/info returns none', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
mock.onPost('/pages/info').reply(200, {
|
||||||
|
data: {
|
||||||
|
id: 'p1',
|
||||||
|
slugId: 'slug-1',
|
||||||
|
title: 'No Content Page',
|
||||||
|
parentPageId: null,
|
||||||
|
spaceId: 'space-1',
|
||||||
|
updatedAt: '2026-06-16T00:00:00.000Z',
|
||||||
|
// no `content` field
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await client.getPageJson('p1');
|
||||||
|
expect(json.content).toEqual({ type: 'doc', content: [] });
|
||||||
|
expect(json.id).toBe('p1');
|
||||||
|
expect(json.title).toBe('No Content Page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an existing content doc through untouched', async () => {
|
||||||
|
const { client, mock } = preauthedClient();
|
||||||
|
|
||||||
|
const doc = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }] };
|
||||||
|
mock.onPost('/pages/info').reply(200, {
|
||||||
|
data: { id: 'p2', slugId: 's2', title: 'T', parentPageId: 'par', spaceId: 'sp', updatedAt: 'u', content: doc },
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await client.getPageJson('p2');
|
||||||
|
expect(json.content).toEqual(doc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// validateDocUrls — bare/edge node-shape tolerance (report §2 client-core
|
||||||
|
// "Unit add"). Reached directly via the private validator (a pure, synchronous,
|
||||||
|
// network-free guard); no mock needed. The XSS allowlist of isSafeUrl AND the
|
||||||
|
// unsafe-href/src rejection are already covered in client-pure.test.ts and are
|
||||||
|
// NOT re-tested here — these cases target validateDocUrls' node-shape
|
||||||
|
// robustness (missing attrs / empty href / null mark) specifically.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('validateDocUrls (bare/edge nodes)', () => {
|
||||||
|
// A plain instance is enough; validateDocUrls never touches the network.
|
||||||
|
function validator() {
|
||||||
|
const client = new DocmostClient(BASE_URL, 'e', 'p');
|
||||||
|
return (doc: any) => (client as any).validateDocUrls(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does NOT throw (no NPE) when a link mark has no attrs object at all', () => {
|
||||||
|
// The `mark.attrs` guard short-circuits before isSafeUrl, so a link mark
|
||||||
|
// without an attrs object is a no-op rather than a null-deref crash.
|
||||||
|
const check = validator();
|
||||||
|
const doc = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'text', text: 'x', marks: [{ type: 'link' }] }], // no attrs key
|
||||||
|
};
|
||||||
|
expect(() => check(doc)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT throw when a link mark href is an empty string (safe-but-bare value)', () => {
|
||||||
|
// isSafeUrl treats a trimmed-empty href as harmless, so a bare empty href
|
||||||
|
// passes without being falsely rejected.
|
||||||
|
const check = validator();
|
||||||
|
const doc = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'text', text: 'x', marks: [{ type: 'link', attrs: { href: '' } }] }],
|
||||||
|
};
|
||||||
|
expect(() => check(doc)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT throw on a media node with no attrs at all', () => {
|
||||||
|
const check = validator();
|
||||||
|
const doc = { type: 'doc', content: [{ type: 'image' }] }; // no attrs object
|
||||||
|
expect(() => check(doc)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a null entry inside the marks array', () => {
|
||||||
|
const check = validator();
|
||||||
|
const doc = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'text', text: 'x', marks: [null, { type: 'bold' }] }],
|
||||||
|
};
|
||||||
|
expect(() => check(doc)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
@@ -1,561 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
walk,
|
|
||||||
getList,
|
|
||||||
insertMarkerAfter,
|
|
||||||
setCalloutRange,
|
|
||||||
noteItem,
|
|
||||||
mdToInlineNodes,
|
|
||||||
commentsToFootnotes,
|
|
||||||
} from '../packages/docmost-client/src/lib/transforms.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Small inline fixture builders. A ProseMirror node is a plain JSON object of
|
|
||||||
// shape { type, attrs?, content?, text?, marks? }.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** A plain text run, optionally with marks. */
|
|
||||||
function text(t: string, marks?: any[]): any {
|
|
||||||
return marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A paragraph holding the given inline runs. */
|
|
||||||
function para(...runs: any[]): any {
|
|
||||||
return { type: 'paragraph', attrs: { id: 'p' }, content: runs };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A callout holding the given child blocks. */
|
|
||||||
function callout(...children: any[]): any {
|
|
||||||
return { type: 'callout', content: children };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A document with the given top-level blocks. */
|
|
||||||
function doc(...blocks: any[]): any {
|
|
||||||
return { type: 'doc', content: blocks };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively strip every `attrs.id` so docs containing freshId()-generated ids
|
|
||||||
* can be deep-compared structurally. Mutates a clone, returns it.
|
|
||||||
*/
|
|
||||||
function stripIds<T>(value: T): T {
|
|
||||||
const v: any = structuredClone(value);
|
|
||||||
const recur = (n: any): void => {
|
|
||||||
if (Array.isArray(n)) {
|
|
||||||
n.forEach(recur);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (n && typeof n === 'object') {
|
|
||||||
if (n.attrs && typeof n.attrs === 'object' && 'id' in n.attrs) {
|
|
||||||
delete n.attrs.id;
|
|
||||||
}
|
|
||||||
for (const k of Object.keys(n)) recur(n[k]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
recur(v);
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
describe('walk', () => {
|
|
||||||
it('is a no-op for a nullish or non-object root', () => {
|
|
||||||
const seen: any[] = [];
|
|
||||||
walk(null, (n) => seen.push(n));
|
|
||||||
walk(undefined, (n) => seen.push(n));
|
|
||||||
walk('string', (n) => seen.push(n));
|
|
||||||
walk(42, (n) => seen.push(n));
|
|
||||||
walk([1, 2, 3], (n) => seen.push(n)); // array is not an object root
|
|
||||||
expect(seen).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('visits the root itself and all nested nodes (callout/table/list)', () => {
|
|
||||||
const tree = {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
callout(para(text('a'))),
|
|
||||||
{
|
|
||||||
type: 'table',
|
|
||||||
content: [
|
|
||||||
{ type: 'tableRow', content: [{ type: 'tableCell', content: [para(text('b'))] }] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'orderedList',
|
|
||||||
content: [{ type: 'listItem', content: [para(text('c'))] }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const types: string[] = [];
|
|
||||||
walk(tree, (n) => types.push(n.type));
|
|
||||||
// Root first, then DFS into every nested container.
|
|
||||||
expect(types[0]).toBe('doc');
|
|
||||||
expect(types).toContain('callout');
|
|
||||||
expect(types).toContain('table');
|
|
||||||
expect(types).toContain('tableRow');
|
|
||||||
expect(types).toContain('tableCell');
|
|
||||||
expect(types).toContain('orderedList');
|
|
||||||
expect(types).toContain('listItem');
|
|
||||||
expect(types).toContain('paragraph');
|
|
||||||
expect(types).toContain('text');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores a non-array content field', () => {
|
|
||||||
const node = { type: 'weird', content: { not: 'an array' } };
|
|
||||||
const seen: any[] = [];
|
|
||||||
walk(node, (n) => seen.push(n));
|
|
||||||
// Only the root is visited; the object content is never recursed into.
|
|
||||||
expect(seen).toEqual([node]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
describe('getList', () => {
|
|
||||||
it('returns the FIRST match in depth-first order', () => {
|
|
||||||
const first = { type: 'orderedList', attrs: { id: 'L1' }, content: [] };
|
|
||||||
const second = { type: 'orderedList', attrs: { id: 'L2' }, content: [] };
|
|
||||||
const tree = doc(callout(first), second);
|
|
||||||
const found = getList(tree, (n) => n.type === 'orderedList');
|
|
||||||
expect(found).toBe(first); // DFS reaches the callout's child before the sibling
|
|
||||||
expect(found.attrs.id).toBe('L1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when nothing matches', () => {
|
|
||||||
const tree = doc(para(text('x')));
|
|
||||||
expect(getList(tree, (n) => n.type === 'orderedList')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a LIVE reference, not a clone', () => {
|
|
||||||
const list = { type: 'orderedList', content: [] };
|
|
||||||
const tree = doc(list);
|
|
||||||
const found = getList(tree, (n) => n.type === 'orderedList');
|
|
||||||
expect(found).toBe(list); // same object identity
|
|
||||||
found.marker = 'mutated';
|
|
||||||
expect(list.marker).toBe('mutated'); // mutation visible on the original
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches a node lacking attrs.id', () => {
|
|
||||||
const noId = { type: 'orderedList', content: [] }; // no attrs at all
|
|
||||||
const tree = doc(para(text('x')), noId);
|
|
||||||
const found = getList(tree, (n) => n.type === 'orderedList');
|
|
||||||
expect(found).toBe(noId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
describe('insertMarkerAfter', () => {
|
|
||||||
it('returns inserted:false when the anchor is not found', () => {
|
|
||||||
const d = doc(para(text('hello world')));
|
|
||||||
const r = insertMarkerAfter(d, 'absent text', '[1]');
|
|
||||||
expect(r.inserted).toBe(false);
|
|
||||||
// Returned doc is a clone of the unchanged input.
|
|
||||||
expect(r.doc).toEqual(d);
|
|
||||||
expect(r.doc).not.toBe(d);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inserts a plain marker run after the anchor in a single text run', () => {
|
|
||||||
const d = doc(para(text('see here for details')));
|
|
||||||
const r = insertMarkerAfter(d, 'see here', '[1]');
|
|
||||||
expect(r.inserted).toBe(true);
|
|
||||||
expect(r.doc.content[0].content).toEqual([
|
|
||||||
{ type: 'text', text: 'see here', marks: [] },
|
|
||||||
{ type: 'text', text: ' [1]' },
|
|
||||||
{ type: 'text', text: ' for details', marks: [] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves marks across runs and emits a PLAIN marker, no empty runs', () => {
|
|
||||||
// Anchor "foo bar" spans a plain run "foo " and a bold run "bar baz".
|
|
||||||
const d = doc(
|
|
||||||
para(
|
|
||||||
text('foo '),
|
|
||||||
text('bar baz', [{ type: 'bold' }]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const r = insertMarkerAfter(d, 'foo bar', '[1]');
|
|
||||||
expect(r.inserted).toBe(true);
|
|
||||||
// The bold run "bar baz" is split at the anchor end (after "bar"); the
|
|
||||||
// leading "foo " run is untouched, the marker is plain, surrounding marks
|
|
||||||
// are preserved verbatim, and no empty text run is emitted.
|
|
||||||
expect(r.doc.content[0].content).toEqual([
|
|
||||||
{ type: 'text', text: 'foo ' },
|
|
||||||
{ type: 'text', text: 'bar', marks: [{ type: 'bold' }] },
|
|
||||||
{ type: 'text', text: ' [1]' },
|
|
||||||
{ type: 'text', text: ' baz', marks: [{ type: 'bold' }] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('splits exactly at a run boundary without emitting an empty run', () => {
|
|
||||||
// Anchor ends exactly at the end of the first run "alpha".
|
|
||||||
const d = doc(para(text('alpha'), text('beta')));
|
|
||||||
const r = insertMarkerAfter(d, 'alpha', '[1]');
|
|
||||||
expect(r.inserted).toBe(true);
|
|
||||||
// "before" == whole first run, "after" is empty -> no empty run pushed.
|
|
||||||
expect(r.doc.content[0].content).toEqual([
|
|
||||||
{ type: 'text', text: 'alpha', marks: [] },
|
|
||||||
{ type: 'text', text: ' [1]' },
|
|
||||||
{ type: 'text', text: 'beta' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('beforeBlock scope excludes blocks at/after the boundary', () => {
|
|
||||||
const d = doc(
|
|
||||||
para(text('body anchor')), // index 0 (in scope when beforeBlock=1)
|
|
||||||
para(text('notes anchor')), // index 1 (out of scope)
|
|
||||||
);
|
|
||||||
// Anchor only exists in the out-of-scope block -> not inserted.
|
|
||||||
const r = insertMarkerAfter(d, 'notes anchor', '[1]', { beforeBlock: 1 });
|
|
||||||
expect(r.inserted).toBe(false);
|
|
||||||
// The in-scope anchor still inserts when limited.
|
|
||||||
const r2 = insertMarkerAfter(d, 'body anchor', '[1]', { beforeBlock: 1 });
|
|
||||||
expect(r2.inserted).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mutate the input document', () => {
|
|
||||||
const d = doc(para(text('keep me intact please')));
|
|
||||||
const snapshot = structuredClone(d);
|
|
||||||
insertMarkerAfter(d, 'keep me', '[1]');
|
|
||||||
expect(d).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns inserted:false for an empty anchor', () => {
|
|
||||||
const d = doc(para(text('anything')));
|
|
||||||
const r = insertMarkerAfter(d, '', '[1]');
|
|
||||||
expect(r.inserted).toBe(false);
|
|
||||||
expect(r.doc).toEqual(d);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
describe('setCalloutRange', () => {
|
|
||||||
it('rewrites a Unicode-ellipsis [1]…[K] range inside a callout', () => {
|
|
||||||
const d = doc(callout(para(text('Footnotes [1]…[5] follow'))));
|
|
||||||
const r = setCalloutRange(d, 7);
|
|
||||||
expect(r.changed).toBe(1);
|
|
||||||
expect(r.doc.content[0].content[0].content[0].text).toBe('Footnotes [1]…[7] follow');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rewrites an ASCII-ellipsis [1]...[K] range inside a callout', () => {
|
|
||||||
const d = doc(callout(para(text('range [1]...[3] here'))));
|
|
||||||
const r = setCalloutRange(d, 9);
|
|
||||||
expect(r.changed).toBe(1);
|
|
||||||
expect(r.doc.content[0].content[0].content[0].text).toBe('range [1]...[9] here');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a paragraph [1]…[K] (outside any callout) untouched', () => {
|
|
||||||
const d = doc(para(text('not a callout [1]…[5]')));
|
|
||||||
const r = setCalloutRange(d, 9);
|
|
||||||
expect(r.changed).toBe(0);
|
|
||||||
expect(r.doc.content[0].content[0].text).toBe('not a callout [1]…[5]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rewrites across multiple callouts and reports the changed count', () => {
|
|
||||||
const d = doc(
|
|
||||||
callout(para(text('a [1]…[2] b'))),
|
|
||||||
para(text('skip [1]…[2]')),
|
|
||||||
callout(para(text('c [1]...[4] d'))),
|
|
||||||
);
|
|
||||||
const r = setCalloutRange(d, 10);
|
|
||||||
expect(r.changed).toBe(2);
|
|
||||||
expect(r.doc.content[0].content[0].content[0].text).toBe('a [1]…[10] b');
|
|
||||||
expect(r.doc.content[2].content[0].content[0].text).toBe('c [1]...[10] d');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports changed:0 when no range matches', () => {
|
|
||||||
const d = doc(callout(para(text('no range here'))));
|
|
||||||
const r = setCalloutRange(d, 4);
|
|
||||||
expect(r.changed).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mutate the input document', () => {
|
|
||||||
const d = doc(callout(para(text('x [1]…[5] y'))));
|
|
||||||
const snapshot = structuredClone(d);
|
|
||||||
setCalloutRange(d, 99);
|
|
||||||
expect(d).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles TWO matching text nodes in one callout (regex lastIndex reset)', () => {
|
|
||||||
// Two separate text nodes, each carrying a range, inside one callout.
|
|
||||||
const d = doc(
|
|
||||||
callout(
|
|
||||||
para(text('first [1]…[2]')),
|
|
||||||
para(text('second [1]…[3]')),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const r = setCalloutRange(d, 6);
|
|
||||||
expect(r.changed).toBe(2);
|
|
||||||
expect(r.doc.content[0].content[0].content[0].text).toBe('first [1]…[6]');
|
|
||||||
expect(r.doc.content[0].content[1].content[0].text).toBe('second [1]…[6]');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
describe('noteItem', () => {
|
|
||||||
it('wraps inline nodes in listItem > paragraph', () => {
|
|
||||||
const inline = [text('hello')];
|
|
||||||
const item = noteItem(inline);
|
|
||||||
expect(item.type).toBe('listItem');
|
|
||||||
expect(item.content).toHaveLength(1);
|
|
||||||
const p = item.content[0];
|
|
||||||
expect(p.type).toBe('paragraph');
|
|
||||||
expect(p.content).toEqual([text('hello')]);
|
|
||||||
// The paragraph carries a string id from Math.random()-based freshId().
|
|
||||||
expect(typeof p.attrs.id).toBe('string');
|
|
||||||
expect(p.attrs.id.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('produces empty content for non-array input', () => {
|
|
||||||
expect(noteItem(undefined as any).content[0].content).toEqual([]);
|
|
||||||
expect(noteItem(null as any).content[0].content).toEqual([]);
|
|
||||||
expect(noteItem('nope' as any).content[0].content).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clones the input so the result shares no references', () => {
|
|
||||||
const inline = [text('mutable')];
|
|
||||||
const item = noteItem(inline);
|
|
||||||
inline[0].text = 'changed';
|
|
||||||
expect(item.content[0].content[0].text).toBe('mutable'); // unaffected
|
|
||||||
expect(item.content[0].content[0]).not.toBe(inline[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches the expected structure (ignoring the random id)', () => {
|
|
||||||
const item = noteItem([text('body', [{ type: 'bold' }])]);
|
|
||||||
expect(stripIds(item)).toEqual({
|
|
||||||
type: 'listItem',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'paragraph',
|
|
||||||
attrs: {},
|
|
||||||
content: [{ type: 'text', text: 'body', marks: [{ type: 'bold' }] }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
describe('mdToInlineNodes', () => {
|
|
||||||
it('returns [] for empty or non-string input', () => {
|
|
||||||
expect(mdToInlineNodes('')).toEqual([]);
|
|
||||||
expect(mdToInlineNodes(' ')).toEqual([]);
|
|
||||||
expect(mdToInlineNodes(undefined as any)).toEqual([]);
|
|
||||||
expect(mdToInlineNodes(null as any)).toEqual([]);
|
|
||||||
expect(mdToInlineNodes(123 as any)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips a case-insensitive "комментарий:" prefix', () => {
|
|
||||||
expect(mdToInlineNodes('Комментарий: hello')).toEqual([{ type: 'text', text: 'hello' }]);
|
|
||||||
expect(mdToInlineNodes('комментарий : hi')).toEqual([{ type: 'text', text: 'hi' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips a leading "N. " numeric prefix', () => {
|
|
||||||
expect(mdToInlineNodes('3. some note')).toEqual([{ type: 'text', text: 'some note' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('turns a leading **bold lead** into a bold node + plain remainder, space preserved', () => {
|
|
||||||
const nodes = mdToInlineNodes('**Lead** rest of text');
|
|
||||||
expect(nodes).toEqual([
|
|
||||||
{ type: 'text', text: 'Lead', marks: [{ type: 'bold' }] },
|
|
||||||
{ type: 'text', text: ' rest of text' }, // separating space preserved
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('splits an inline **bold** mid-text', () => {
|
|
||||||
const nodes = mdToInlineNodes('start **mid** end');
|
|
||||||
expect(nodes).toEqual([
|
|
||||||
{ type: 'text', text: 'start ' },
|
|
||||||
{ type: 'text', text: 'mid', marks: [{ type: 'bold' }] },
|
|
||||||
{ type: 'text', text: ' end' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes plain text through unchanged when there is no bold', () => {
|
|
||||||
expect(mdToInlineNodes('just plain text')).toEqual([{ type: 'text', text: 'just plain text' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles a bold-only string', () => {
|
|
||||||
// A bold-only string is treated as a leading bold lead with empty remainder.
|
|
||||||
expect(mdToInlineNodes('**only**')).toEqual([
|
|
||||||
{ type: 'text', text: 'only', marks: [{ type: 'bold' }] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
describe('commentsToFootnotes', () => {
|
|
||||||
const HEADING = 'Примечания переводчика';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a realistic doc: body paragraphs, then the notes heading, then the
|
|
||||||
* notes orderedList. `notes` is an array of inline-text strings for existing
|
|
||||||
* list items.
|
|
||||||
*/
|
|
||||||
function buildDoc(opts: {
|
|
||||||
body: any[];
|
|
||||||
notes?: string[];
|
|
||||||
omitHeading?: boolean;
|
|
||||||
omitList?: boolean;
|
|
||||||
disclaimer?: any;
|
|
||||||
}): any {
|
|
||||||
const blocks: any[] = [];
|
|
||||||
if (opts.disclaimer) blocks.push(opts.disclaimer);
|
|
||||||
blocks.push(...opts.body);
|
|
||||||
if (!opts.omitHeading) {
|
|
||||||
blocks.push({ type: 'heading', attrs: { id: 'h', level: 2 }, content: [text(HEADING)] });
|
|
||||||
}
|
|
||||||
if (!opts.omitList) {
|
|
||||||
const items = (opts.notes ?? []).map((t, i) => ({
|
|
||||||
type: 'listItem',
|
|
||||||
attrs: { id: `li${i}` },
|
|
||||||
content: [para(text(t))],
|
|
||||||
}));
|
|
||||||
blocks.push({ type: 'orderedList', attrs: { id: 'ol' }, content: items });
|
|
||||||
}
|
|
||||||
return doc(...blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNotesList(d: any): any {
|
|
||||||
return d.content.find((n: any) => n.type === 'orderedList');
|
|
||||||
}
|
|
||||||
|
|
||||||
it('is identity (renumber pass only) when there are zero comments', () => {
|
|
||||||
const d = buildDoc({ body: [para(text('plain body'))], notes: [] });
|
|
||||||
const r = commentsToFootnotes(d, []);
|
|
||||||
expect(r.consumed).toEqual([]);
|
|
||||||
expect(r.doc.content[0]).toEqual(para(text('plain body')));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inserts a marker and appends one note for one comment with a selection', () => {
|
|
||||||
const d = buildDoc({ body: [para(text('the quick brown fox'))], notes: [] });
|
|
||||||
const r = commentsToFootnotes(d, [
|
|
||||||
{ id: 'c1', content: 'A note', selection: 'quick brown' },
|
|
||||||
]);
|
|
||||||
expect(r.consumed).toEqual(['c1']);
|
|
||||||
// Body now carries "[1]" right after the selection.
|
|
||||||
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
|
|
||||||
expect(bodyText).toBe('the quick brown [1] fox');
|
|
||||||
// The notes list holds exactly one note built from the comment content.
|
|
||||||
const list = findNotesList(r.doc);
|
|
||||||
expect(list.content).toHaveLength(1);
|
|
||||||
expect(stripIds(list.content[0])).toEqual(
|
|
||||||
stripIds(noteItem(mdToInlineNodes('A note'))),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('numbers many comments by BODY reading order, not comment-array order', () => {
|
|
||||||
// Body order: "alpha" then "omega". Comments are given out of order.
|
|
||||||
const d = buildDoc({
|
|
||||||
body: [para(text('alpha then omega here'))],
|
|
||||||
notes: [],
|
|
||||||
});
|
|
||||||
const r = commentsToFootnotes(d, [
|
|
||||||
{ id: 'cOmega', content: 'note for omega', selection: 'omega' },
|
|
||||||
{ id: 'cAlpha', content: 'note for alpha', selection: 'alpha' },
|
|
||||||
]);
|
|
||||||
// Both consumed, in comment-array processing order (NOT reading order, NOT sorted).
|
|
||||||
expect(r.consumed).toEqual(['cOmega', 'cAlpha']);
|
|
||||||
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
|
|
||||||
// "alpha" precedes "omega" in reading order => [1] then [2].
|
|
||||||
expect(bodyText).toBe('alpha [1] then omega [2] here');
|
|
||||||
const list = findNotesList(r.doc);
|
|
||||||
// Note list reordered to reading order: alpha-note first, omega-note second.
|
|
||||||
expect(list.content[0].content[0].content[0].text).toBe('note for alpha');
|
|
||||||
expect(list.content[1].content[0].content[0].text).toBe('note for omega');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips a comment with no selection without consuming it', () => {
|
|
||||||
const d = buildDoc({ body: [para(text('body text here'))], notes: [] });
|
|
||||||
const r = commentsToFootnotes(d, [
|
|
||||||
{ id: 'c1', content: 'no anchor', selection: null },
|
|
||||||
{ id: 'c2', content: 'anchored', selection: 'body text' },
|
|
||||||
]);
|
|
||||||
expect(r.consumed).toEqual(['c2']);
|
|
||||||
const list = findNotesList(r.doc);
|
|
||||||
expect(list.content).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips a comment whose selection is absent (no orphan note)', () => {
|
|
||||||
const d = buildDoc({ body: [para(text('present text'))], notes: [] });
|
|
||||||
const r = commentsToFootnotes(d, [
|
|
||||||
{ id: 'c1', content: 'orphan', selection: 'this string is not in the body' },
|
|
||||||
]);
|
|
||||||
expect(r.consumed).toEqual([]); // nothing anchored
|
|
||||||
const list = findNotesList(r.doc);
|
|
||||||
expect(list.content).toHaveLength(0); // no orphan note appended
|
|
||||||
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
|
|
||||||
expect(bodyText).toBe('present text'); // body unchanged
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renumbers existing [N] markers mixed with new placeholders by reading order', () => {
|
|
||||||
// Body already has an existing "[1]" marker after "first"; a new comment
|
|
||||||
// anchors before it in reading order at "intro".
|
|
||||||
const d = buildDoc({
|
|
||||||
body: [para(text('intro and first [1] then more'))],
|
|
||||||
notes: ['existing note one'],
|
|
||||||
});
|
|
||||||
const r = commentsToFootnotes(d, [
|
|
||||||
{ id: 'cNew', content: 'fresh note', selection: 'intro' },
|
|
||||||
]);
|
|
||||||
expect(r.consumed).toEqual(['cNew']);
|
|
||||||
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
|
|
||||||
// Reading order: "intro" placeholder -> [1]; existing "[1]" -> [2].
|
|
||||||
expect(bodyText).toBe('intro [1] and first [2] then more');
|
|
||||||
const list = findNotesList(r.doc);
|
|
||||||
// Notes reordered: the new note (for "intro") first, the existing note second.
|
|
||||||
expect(list.content).toHaveLength(2);
|
|
||||||
expect(list.content[0].content[0].content[0].text).toBe('fresh note');
|
|
||||||
expect(list.content[1].content[0].content[0].text).toBe('existing note one');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws "document is inconsistent" when a body [N] has no matching note', () => {
|
|
||||||
// Body references [9] but the notes list has only 3 items.
|
|
||||||
const d = buildDoc({
|
|
||||||
body: [para(text('see footnote [9] here'))],
|
|
||||||
notes: ['n1', 'n2', 'n3'],
|
|
||||||
});
|
|
||||||
expect(() => commentsToFootnotes(d, [])).toThrow(/document is inconsistent/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when the notes heading is missing', () => {
|
|
||||||
const d = buildDoc({ body: [para(text('x'))], notes: [], omitHeading: true });
|
|
||||||
expect(() => commentsToFootnotes(d, [])).toThrow(/not found/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when the notes orderedList is missing', () => {
|
|
||||||
const d = buildDoc({ body: [para(text('x'))], omitList: true });
|
|
||||||
expect(() => commentsToFootnotes(d, [])).toThrow(/orderedList not found/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mutate the input document', () => {
|
|
||||||
const d = buildDoc({ body: [para(text('the quick brown fox'))], notes: [] });
|
|
||||||
const snapshot = structuredClone(d);
|
|
||||||
commentsToFootnotes(d, [{ id: 'c1', content: 'A note', selection: 'quick brown' }]);
|
|
||||||
expect(d).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not renumber a top-level disclaimer callout but syncs its range', () => {
|
|
||||||
// A disclaimer callout carries "[1]…[K]"; it must be preserved (not consumed
|
|
||||||
// as a footnote marker) and its range synced to the final note count.
|
|
||||||
const disclaimer = callout(para(text('Notes range [1]…[1] applies')));
|
|
||||||
const d = buildDoc({
|
|
||||||
body: [para(text('alpha and beta here'))],
|
|
||||||
notes: [],
|
|
||||||
disclaimer,
|
|
||||||
});
|
|
||||||
const r = commentsToFootnotes(d, [
|
|
||||||
{ id: 'c1', content: 'note a', selection: 'alpha' },
|
|
||||||
{ id: 'c2', content: 'note b', selection: 'beta' },
|
|
||||||
]);
|
|
||||||
expect(r.consumed.sort()).toEqual(['c1', 'c2']);
|
|
||||||
// Disclaimer callout is at index 0; its body must NOT have been renumbered
|
|
||||||
// into [1][2], it remains a "[1]…[n]" range synced to 2 notes.
|
|
||||||
const calloutText = r.doc.content[0].content[0].content
|
|
||||||
.map((n: any) => n.text)
|
|
||||||
.join('');
|
|
||||||
expect(calloutText).toBe('Notes range [1]…[2] applies');
|
|
||||||
// Body markers (index 1) are the real footnotes.
|
|
||||||
const bodyText = r.doc.content[1].content.map((n: any) => n.text).join('');
|
|
||||||
expect(bodyText).toBe('alpha [1] and beta [2] here');
|
|
||||||
const list = findNotesList(r.doc);
|
|
||||||
expect(list.content).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user