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:
415
test/collaboration-pure.test.ts
Normal file
415
test/collaboration-pure.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user