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:
vvzvlad
2026-06-17 02:03:42 +03:00
parent b630a5ccac
commit 480f4c3747
6 changed files with 400 additions and 1333 deletions

View File

@@ -1,9 +1,7 @@
// Unit tests for the PURE (private) helper methods of DocmostClient plus the
// transformPage vm sandbox. The constructor only calls axios.create (no
// network), so a real instance can be built and its private methods exercised
// via an `(client as any)` cast. Private methods are pure (no I/O) except for
// transformPage, whose network calls (ensureAuthenticated / listComments /
// getPageRaw) we stub on the instance so the dryRun path runs offline.
// Unit tests for the PURE (private) helper methods of DocmostClient. The
// constructor only calls axios.create (no network), so a real instance can be
// built and its private methods exercised via an `(client as any)` cast. These
// private methods are pure (no I/O).
import { describe, it, expect } from 'vitest';
import { DocmostClient } from '../packages/docmost-client/src/client.js';
@@ -361,114 +359,3 @@ describe('DocmostClient.parseCommentContent', () => {
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/);
});
});

View File

@@ -715,3 +715,399 @@ describe('auth: getCollabTokenWithReauth', () => {
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.

View File

@@ -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);
});
});