test: add full test suite for docmost-client and remaining modules

Raise coverage from 2.6% to 68% statements by adding 19 test files (~480
tests) covering every module in test-strategy-report.md. No production code
changed — tests reach private logic via (client as any), mock HTTP with
axios-mock-adapter on the real axios instance (interceptors intact), and mock
the Hocuspocus provider with vi.mock + real yjs + fake timers.

Coverage: auth-utils/filters/page-lock/json-edit 100%, diff 99%, node-ops 96%,
transforms 95%, collaboration 86%, layout 91%, client.ts 41% (transport).

- node-ops/transforms/json-edit/page-lock/filters: pure tree/text ops,
  immutability + clone guarantees, throw-vs-noop contracts
- markdown-converter + markdown-document envelope + fast-check round-trip
  property test
- diff, docmost-schema (sanitizeCssColor/clampCalloutType security guards)
- collaboration: pure (buildCollabWsUrl/buildYDoc) + write-path (mutatePageContent
  read-transform-write, false-success suppression)
- client.ts: isSafeUrl/validateDoc* XSS guards, vm-sandbox, REST pagination,
  401 re-auth interceptor, login dedup, uploadImage/createPage multipart guards
- collectRecentSince edge cases; loadSettingsOrExit invalid-value branch
- env-gated E2E skeleton (DOCMOST_E2E)

Two genuine markdown round-trip non-idempotency bugs are documented as it.fails
(code-mark excludes other marks; block-image injects a blank line). Latent:
isSafeUrl allows file:// on link context.

Adds dev-deps: fast-check, @vitest/coverage-v8, axios-mock-adapter; adds the
"coverage" npm script.
This commit is contained in:
vvzvlad
2026-06-16 22:50:04 +03:00
parent cc13c94f53
commit 90d8f86fda
14 changed files with 5306 additions and 2 deletions

View File

@@ -0,0 +1,415 @@
import { describe, expect, it } from 'vitest';
// Importing this module mutates the global DOM (jsdom is installed onto
// global.window/document/Element at load time) and pulls in yjs/hocuspocus/
// ws/marked. That is expected and works in the vitest "node" environment.
import {
buildCollabWsUrl,
buildYDoc,
assertYjsEncodable,
markdownToProseMirror,
} from '../packages/docmost-client/src/lib/collaboration.js';
// Y is imported only to assert the runtime type of buildYDoc's return value.
import * as Y from 'yjs';
// ---------------------------------------------------------------------------
// Small helpers / fixtures.
// ---------------------------------------------------------------------------
// A minimal, valid Docmost ProseMirror doc that Yjs can encode without issue.
// `null` attribute values are legitimate (only `undefined` is unstorable), so
// this round-trips cleanly through sanitizeForYjs + toYdoc.
const validDoc = () => ({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { id: null, indent: null, textAlign: null },
content: [{ type: 'text', text: 'hello' }],
},
],
});
// ===========================================================================
describe('buildCollabWsUrl', () => {
// -------------------------------------------------------------------------
describe('scheme rewrite', () => {
it('rewrites http:// to ws://', () => {
expect(buildCollabWsUrl('http://localhost:3000')).toBe(
'ws://localhost:3000/collab',
);
});
it('rewrites https:// to wss://', () => {
expect(buildCollabWsUrl('https://docmost.example.com')).toBe(
'wss://docmost.example.com/collab',
);
});
it('only the LEADING http is rewritten (^http anchor)', () => {
// The regex is anchored at the start, so "http" appearing later in the
// host/path is untouched — only the scheme flips to ws.
expect(buildCollabWsUrl('https://httpbin.example.com')).toBe(
'wss://httpbin.example.com/collab',
);
});
});
// -------------------------------------------------------------------------
describe('trailing /api stripping', () => {
it('strips a trailing /api (no slash) before mounting /collab', () => {
expect(buildCollabWsUrl('http://localhost:3000/api')).toBe(
'ws://localhost:3000/collab',
);
});
it('strips a trailing /api/ (with slash)', () => {
expect(buildCollabWsUrl('https://docmost.example.com/api/')).toBe(
'wss://docmost.example.com/collab',
);
});
it('strips /api even when it follows a base path, keeping the base', () => {
expect(buildCollabWsUrl('http://x.com/base/api')).toBe(
'ws://x.com/base/collab',
);
expect(buildCollabWsUrl('http://x.com/base/api/')).toBe(
'ws://x.com/base/collab',
);
});
});
// -------------------------------------------------------------------------
describe('/collab mounting', () => {
it('mounts /collab exactly once on a bare host', () => {
const out = buildCollabWsUrl('http://x.com');
expect(out).toBe('ws://x.com/collab');
// Exactly one occurrence of "/collab".
expect(out.match(/\/collab/g)?.length).toBe(1);
});
it('does not leave a double slash when the base ends in /', () => {
// The pathname has its single trailing slash stripped before "/collab"
// is appended, so there is no "//collab".
expect(buildCollabWsUrl('http://x.com/')).toBe('ws://x.com/collab');
});
});
// -------------------------------------------------------------------------
describe('query/hash dropping', () => {
it('drops any query string and hash from the base URL', () => {
expect(buildCollabWsUrl('https://x.com/api/?a=1&b=2#frag')).toBe(
'wss://x.com/collab',
);
});
it('drops a bare query on a host with no path', () => {
expect(buildCollabWsUrl('http://x.com?token=abc')).toBe(
'ws://x.com/collab',
);
});
});
// -------------------------------------------------------------------------
describe('"already /collab" handling (valid-URL path is NOT idempotent)', () => {
// The task spec claims an already-"/collab" input is idempotent. That is
// only true on the malformed-URL FALLBACK branch (which guards with
// `if (!wsUrl.endsWith("/collab"))`). On the normal valid-URL parsing path
// there is NO such guard: the pathname always gets "/collab" appended, so a
// URL that already ends in /collab gets a SECOND /collab. We assert the
// ACTUAL behaviour rather than the (incorrect) idempotency claim.
it('appends a second /collab to a valid URL already ending in /collab', () => {
expect(buildCollabWsUrl('https://x.com/collab')).toBe(
'wss://x.com/collab/collab',
);
});
});
// -------------------------------------------------------------------------
describe('malformed-URL fallback branch', () => {
// `new URL("not-a-valid-url")` throws (no scheme), so the catch branch
// runs: it appends "/collab" only when the string does not already end in
// "/collab", after stripping one trailing slash.
it('appends /collab to a string that cannot be parsed as a URL', () => {
expect(buildCollabWsUrl('not-a-valid-url')).toBe('not-a-valid-url/collab');
});
it('strips a single trailing slash before appending /collab (fallback)', () => {
expect(buildCollabWsUrl('not-a-valid-url/')).toBe(
'not-a-valid-url/collab',
);
});
it('is idempotent in the fallback branch when already ending in /collab', () => {
// This is the ONLY branch where "/collab" is idempotent: the fallback
// guard `!wsUrl.endsWith("/collab")` skips the append.
expect(buildCollabWsUrl('not-a-valid-url/collab')).toBe(
'not-a-valid-url/collab',
);
});
});
});
// ===========================================================================
describe('buildYDoc', () => {
it('round-trips a valid PM doc into a Y.Doc (encodes without throwing)', () => {
const ydoc = buildYDoc(validDoc());
expect(ydoc).toBeInstanceOf(Y.Doc);
// It also encodes to a non-empty Yjs update, proving content was stored.
expect(Y.encodeStateAsUpdate(ydoc).length).toBeGreaterThan(0);
});
it('sanitizes an undefined attribute value and still encodes', () => {
// `undefined` is the common cause of the opaque yjs failure; sanitizeForYjs
// strips it, so the doc encodes cleanly into a Y.Doc.
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { id: undefined, indent: null, textAlign: null },
content: [{ type: 'text', text: 'hi' }],
},
],
};
const ydoc = buildYDoc(doc);
expect(ydoc).toBeInstanceOf(Y.Doc);
});
it('throws a descriptive error that names the offending attribute path', () => {
// To exercise the `findUnstorableAttr` branch we need a doc that (a)
// survives structuredClone in sanitizeForYjs, (b) makes toYdoc throw, and
// (c) carries an attribute value findUnstorableAttr flags. An UNKNOWN node
// type (toYdoc throws "Unknown node type") plus a `bigint` attr (survives
// structuredClone, flagged by findUnstorableAttr) satisfies all three.
const doc = {
type: 'doc',
content: [
{
type: 'bogusNode',
attrs: { id: 'x', big: 7n },
content: [],
},
],
};
let err: Error | undefined;
try {
buildYDoc(doc);
} catch (e) {
err = e as Error;
}
expect(err).toBeInstanceOf(Error);
// Descriptive wrapper, original cause, and the named offending path.
expect(err!.message).toContain('Failed to encode document to Yjs (toYdoc):');
expect(err!.message).toContain('Offending attribute:');
expect(err!.message).toContain('content[0].attrs.big (bigint)');
});
it('falls back to the generic hint when toYdoc throws but no attr is flagged', () => {
// An invalid doc whose attrs are all storable: the descriptive wrapper
// fires, but with the generic "...likely holds a value Yjs cannot store"
// suffix instead of a named "Offending attribute:".
const doc = { type: 'doc', content: [{ type: 'unknownThing', attrs: { id: 'a' } }] };
let err: Error | undefined;
try {
buildYDoc(doc);
} catch (e) {
err = e as Error;
}
expect(err).toBeInstanceOf(Error);
expect(err!.message).toContain('Failed to encode document to Yjs (toYdoc):');
expect(err!.message).not.toContain('Offending attribute:');
expect(err!.message).toContain('value Yjs cannot store');
});
it('a function-valued attribute throws (raw structuredClone error, NOT the descriptive one)', () => {
// NOTE on the spec: it suggested "a function or bigint value throws a
// descriptive error that names the offending attribute path". In reality a
// FUNCTION attribute makes `sanitizeForYjs` -> structuredClone throw
// BEFORE buildYDoc's try/catch is reached, so the thrown error is the raw
// "... could not be cloned." message and does NOT name the path. We assert
// that actual behaviour here.
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { id: 'p1', indent: null, textAlign: null, weird: () => {} },
content: [{ type: 'text', text: 'hi' }],
},
],
};
let err: Error | undefined;
try {
buildYDoc(doc);
} catch (e) {
err = e as Error;
}
expect(err).toBeInstanceOf(Error);
expect(err!.message).toContain('could not be cloned');
// It is the structuredClone error, not the wrapped descriptive one.
expect(err!.message).not.toContain('Failed to encode document to Yjs');
});
it('a lone bigint attribute on a VALID doc is storable and does NOT throw', () => {
// Another spec correction: a bigint that is NOT accompanied by an otherwise
// invalid doc survives structuredClone and toYdoc stores it fine, so no
// error is raised. (findUnstorableAttr only ever runs after toYdoc throws.)
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { id: null, indent: null, textAlign: null, big: 5n },
content: [{ type: 'text', text: 'hi' }],
},
],
};
expect(() => buildYDoc(doc)).not.toThrow();
});
});
// ===========================================================================
describe('assertYjsEncodable', () => {
it('returns void (no throw) for a valid doc', () => {
expect(() => assertYjsEncodable(validDoc())).not.toThrow();
expect(assertYjsEncodable(validDoc())).toBeUndefined();
});
it('throws IDENTICALLY to buildYDoc for an invalid doc', () => {
// assertYjsEncodable just calls buildYDoc and discards the Y.Doc, so the
// error message must be byte-for-byte the same descriptive error.
const doc = {
type: 'doc',
content: [{ type: 'bogusNode', attrs: { id: 'x', big: 7n }, content: [] }],
};
const grab = (fn: () => void): string => {
try {
fn();
} catch (e) {
return (e as Error).message;
}
throw new Error('expected the call to throw, but it did not');
};
const fromAssert = grab(() => assertYjsEncodable(doc));
const fromBuild = grab(() => buildYDoc(doc));
expect(fromAssert).toBe(fromBuild);
expect(fromAssert).toContain('Offending attribute: content[0].attrs.big (bigint)');
});
});
// ===========================================================================
describe('markdownToProseMirror', () => {
it('returns a single empty paragraph doc for an empty string', async () => {
const out = await markdownToProseMirror('');
expect(out.type).toBe('doc');
expect(Array.isArray(out.content)).toBe(true);
expect(out.content).toHaveLength(1);
expect(out.content[0].type).toBe('paragraph');
// An empty paragraph has no `content` array (no inline children).
expect(out.content[0].content).toBeUndefined();
});
// -------------------------------------------------------------------------
describe('callouts (via preprocessCallouts)', () => {
it('converts a :::info ... ::: fence into a callout node', async () => {
const out = await markdownToProseMirror(':::info\nHello **world**\n:::');
expect(out.type).toBe('doc');
expect(out.content).toHaveLength(1);
const callout = out.content[0];
expect(callout.type).toBe('callout');
expect(callout.attrs.type).toBe('info');
// Inner markdown is rendered: a paragraph containing the bold run.
expect(callout.content[0].type).toBe('paragraph');
const inline = callout.content[0].content;
const boldNode = inline.find((n: any) =>
Array.isArray(n.marks) && n.marks.some((m: any) => m.type === 'bold'),
);
expect(boldNode).toBeDefined();
expect(boldNode.text).toBe('world');
});
it('lower-cases the callout type from the fence', async () => {
// CALLOUT_OPEN_RE captures the type and preprocessCallouts lower-cases it.
const out = await markdownToProseMirror(':::WARNING\nbeware\n:::');
expect(out.content[0].type).toBe('callout');
expect(out.content[0].attrs.type).toBe('warning');
});
it('handles a nested callout via the depth counter', async () => {
// An inner :::type opens a deeper level; the outer fence matches the
// OUTERMOST closing :::, so the inner callout nests inside the outer one.
const md = ':::info\nouter\n:::success\ninner\n:::\n:::';
const out = await markdownToProseMirror(md);
expect(out.content).toHaveLength(1);
const outer = out.content[0];
expect(outer.type).toBe('callout');
expect(outer.attrs.type).toBe('info');
// Somewhere inside the outer callout there is a nested success callout.
const nested = outer.content.find((n: any) => n.type === 'callout');
expect(nested).toBeDefined();
expect(nested.attrs.type).toBe('success');
});
it('does NOT treat a ::: line inside a fenced code block as a callout', async () => {
// The single-pass scanner tracks code fences and copies their `:::` lines
// through verbatim, so the result is a codeBlock, not a callout.
const md = '```\n:::info\nnot a callout\n:::\n```';
const out = await markdownToProseMirror(md);
expect(out.content).toHaveLength(1);
expect(out.content[0].type).toBe('codeBlock');
// No callout node anywhere in the output.
expect(out.content.some((n: any) => n.type === 'callout')).toBe(false);
// The literal ::: text survives inside the code block's text.
const codeText = out.content[0].content[0].text;
expect(codeText).toContain(':::info');
expect(codeText).toContain(':::');
});
});
// -------------------------------------------------------------------------
describe('task lists (via bridgeTaskLists)', () => {
it('converts a GFM checkbox UL into a taskList with checked state', async () => {
const out = await markdownToProseMirror('- [x] done\n- [ ] todo');
expect(out.content).toHaveLength(1);
const list = out.content[0];
expect(list.type).toBe('taskList');
expect(list.content).toHaveLength(2);
expect(list.content[0].type).toBe('taskItem');
expect(list.content[0].attrs.checked).toBe(true);
expect(list.content[1].type).toBe('taskItem');
expect(list.content[1].attrs.checked).toBe(false);
// No stray bulletList/orderedList survives beside the taskList.
expect(out.content.some((n: any) => n.type === 'bulletList')).toBe(false);
});
it('converts an ORDERED list whose every item is a checkbox into a taskList', async () => {
// bridgeTaskLists rule: BOTH <ul> and <ol> are candidates, and an <ol>
// whose every direct <li> carries its own checkbox is rewritten to a
// taskList (the <ol> is renamed to <ul> so no phantom orderedList remains).
const out = await markdownToProseMirror('1. [x] a\n2. [ ] b');
expect(out.content).toHaveLength(1);
const list = out.content[0];
expect(list.type).toBe('taskList');
expect(list.content[0].attrs.checked).toBe(true);
expect(list.content[1].attrs.checked).toBe(false);
// Crucially, NO phantom empty orderedList is emitted beside the taskList.
expect(out.content.some((n: any) => n.type === 'orderedList')).toBe(false);
});
it('leaves an ordinary ordered list (no checkboxes) as an orderedList', async () => {
// Mixed/ordinary lists are untouched — they keep rendering as numbered.
const out = await markdownToProseMirror('1. a\n2. b');
expect(out.content).toHaveLength(1);
expect(out.content[0].type).toBe('orderedList');
expect(out.content.some((n: any) => n.type === 'taskList')).toBe(false);
});
it('leaves an ordinary bullet list untouched', async () => {
const out = await markdownToProseMirror('- a\n- b');
expect(out.content[0].type).toBe('bulletList');
expect(out.content.some((n: any) => n.type === 'taskList')).toBe(false);
});
});
});