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