CHANGELOG: stop presenting the service-worker API cache as an active offline store (/api is NetworkOnly) — describe it as a defensive purge of the legacy api-get-cache from older clients; add an explicit upgrade note that the new CORS allowlist rejects previously-allowed cross-domain REST clients until their origin is added to CORS_ALLOWED_ORIGINS. test(offline): cover make-offline ancestor-walk + dedup — a real-ancestor case exercising the ancestorId===pageId guard (page warmed once), the dedup of repeated tree failures into a single "tree" label, and the "breadcrumbs" label when the breadcrumbs lookup rejects. test(auth): cover clearOfflineCache in handleLogout — purged exactly once before window.location.replace, and a thrown purge error does not block the redirect. conventions: use pageKeys.detail() instead of raw ["pages", …] literals in title-editor and use-page-collab-providers. cleanup: remove the dead emit() in title-editor (the gateway ignores it; the cross-user tree refresh is server-side via the Yjs title fragment); drop the trivial Array.isArray(tiptapExtensions) test (schema is exercised transitively). refactor: extract the shared page.<id> Yjs doc-name convention into pageYdocName()/PAGE_YDOC_NAME_PREFIX so the editor providers, offline warm, and offline purge can no longer drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
287 lines
8.9 KiB
TypeScript
287 lines
8.9 KiB
TypeScript
import * as Y from 'yjs';
|
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
|
import {
|
|
getPageId,
|
|
isEmptyParagraphDoc,
|
|
jsonToNode,
|
|
prosemirrorNodeToYElement,
|
|
buildTitleSeedYdoc,
|
|
jsonToText,
|
|
} from './collaboration.util';
|
|
import { Node } from '@tiptap/pm/model';
|
|
|
|
// Collect every node type name in a ProseMirror Node, in document order.
|
|
const collectTypes = (node: Node): string[] => {
|
|
const types: string[] = [];
|
|
node.descendants((n) => {
|
|
types.push(n.type.name);
|
|
});
|
|
return types;
|
|
};
|
|
|
|
// Yjs types throw "Invalid access" until attached to a document, so every
|
|
// produced Y element must be inserted into a fragment before it is inspected.
|
|
const attach = (json: any): any => {
|
|
const ydoc = new Y.Doc();
|
|
const fragment = ydoc.getXmlFragment('default');
|
|
const element = prosemirrorNodeToYElement(json);
|
|
fragment.insert(0, [element as any]);
|
|
return element;
|
|
};
|
|
|
|
describe('getPageId', () => {
|
|
it('extracts the uuid from a "page.<uuid>" document name', () => {
|
|
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
|
expect(getPageId(`page.${uuid}`)).toBe(uuid);
|
|
});
|
|
|
|
it('returns undefined when the name has no separator', () => {
|
|
// Auth keying depends on this: a malformed name must not yield a stray id.
|
|
expect(getPageId('justaname')).toBeUndefined();
|
|
});
|
|
|
|
it('returns the second segment only, ignoring extra dotted parts', () => {
|
|
expect(getPageId('page.abc.def')).toBe('abc');
|
|
});
|
|
|
|
it('returns an empty string for a trailing dot', () => {
|
|
expect(getPageId('page.')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('isEmptyParagraphDoc', () => {
|
|
it('returns true for a doc with a single empty paragraph', () => {
|
|
expect(
|
|
isEmptyParagraphDoc({ type: 'doc', content: [{ type: 'paragraph' }] }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('returns true for a single paragraph with an empty content array', () => {
|
|
expect(
|
|
isEmptyParagraphDoc({
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph', content: [] }],
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('returns false for a paragraph containing text', () => {
|
|
expect(
|
|
isEmptyParagraphDoc({
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] },
|
|
],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false for a doc with more than one child', () => {
|
|
expect(
|
|
isEmptyParagraphDoc({
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph' }, { type: 'paragraph' }],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false when the single child is not a paragraph', () => {
|
|
expect(
|
|
isEmptyParagraphDoc({
|
|
type: 'doc',
|
|
content: [{ type: 'heading', attrs: { level: 1 } }],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false when the root is not a "doc"', () => {
|
|
expect(
|
|
isEmptyParagraphDoc({ type: 'paragraph', content: [] } as any),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false for null / undefined input', () => {
|
|
expect(isEmptyParagraphDoc(null as any)).toBe(false);
|
|
expect(isEmptyParagraphDoc(undefined as any)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('stripUnknownNodes (via jsonToNode fallback)', () => {
|
|
it('drops an unknown leaf node while keeping known siblings', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] },
|
|
{ type: 'totallyUnknownLeaf', attrs: {} },
|
|
],
|
|
};
|
|
const node = jsonToNode(json);
|
|
// Only the paragraph + its text remain; the unknown leaf is gone.
|
|
expect(collectTypes(node)).toEqual(['paragraph', 'text']);
|
|
expect(node.textContent).toBe('keep');
|
|
});
|
|
|
|
it('unwraps an unknown WRAPPER, flattening its children (no content loss)', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'unknownWrapper',
|
|
content: [
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'inside' }] },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const node = jsonToNode(json);
|
|
// The wrapper disappears but its paragraph child is lifted up, not deleted.
|
|
expect(collectTypes(node)).toEqual(['paragraph', 'text']);
|
|
expect(node.textContent).toBe('inside');
|
|
});
|
|
|
|
it('leaves an entirely known document untouched', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
|
|
{
|
|
type: 'heading',
|
|
attrs: { level: 2 },
|
|
content: [{ type: 'text', text: 'b' }],
|
|
},
|
|
],
|
|
};
|
|
const node = jsonToNode(json);
|
|
expect(collectTypes(node)).toEqual([
|
|
'paragraph',
|
|
'text',
|
|
'heading',
|
|
'text',
|
|
]);
|
|
expect(node.textContent).toBe('ab');
|
|
});
|
|
|
|
it('drops an unknown inline nested inside a known node', () => {
|
|
const json = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [
|
|
{ type: 'text', text: 'a' },
|
|
{ type: 'weirdInline' },
|
|
{ type: 'text', text: 'b' },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const node = jsonToNode(json);
|
|
// The unknown inline is silently removed; surrounding text survives.
|
|
expect(node.textContent).toBe('ab');
|
|
expect(collectTypes(node)).toEqual(['paragraph', 'text', 'text']);
|
|
});
|
|
});
|
|
|
|
describe('prosemirrorNodeToYElement', () => {
|
|
it('produces a Y.XmlText carrying mark attrs as format on a marked text node', () => {
|
|
const ytext = attach({
|
|
type: 'text',
|
|
text: 'hi',
|
|
marks: [{ type: 'bold', attrs: { level: 2 } }, { type: 'italic' }],
|
|
});
|
|
const delta = ytext.toDelta();
|
|
expect(delta).toHaveLength(1);
|
|
expect(delta[0].insert).toBe('hi');
|
|
// mark.attrs is used when present, otherwise `true` (the `|| true` path).
|
|
expect(delta[0].attributes).toEqual({
|
|
bold: { level: 2 },
|
|
italic: true,
|
|
});
|
|
expect(ytext.length).toBe(2);
|
|
});
|
|
|
|
it('produces a plain Y.XmlText with no format for an unmarked text node', () => {
|
|
const ytext = attach({ type: 'text', text: 'plain' });
|
|
const delta = ytext.toDelta();
|
|
expect(delta).toEqual([{ insert: 'plain' }]);
|
|
expect(ytext.length).toBe(5);
|
|
});
|
|
|
|
it('sets element attributes, skipping null and undefined values', () => {
|
|
const element = attach({
|
|
type: 'paragraph',
|
|
attrs: { textAlign: 'left', indent: 0, anchorId: null, ghost: undefined },
|
|
content: [{ type: 'text', text: 'abc' }],
|
|
});
|
|
expect(element.nodeName).toBe('paragraph');
|
|
expect(element.getAttribute('textAlign')).toBe('left');
|
|
// indent is 0 (falsy but defined) -> must still be set.
|
|
expect(element.getAttribute('indent')).toBe(0);
|
|
// null / undefined attrs are skipped, never set.
|
|
expect(element.getAttribute('anchorId')).toBeUndefined();
|
|
expect(element.getAttribute('ghost')).toBeUndefined();
|
|
expect(element.getAttributes()).toEqual({ textAlign: 'left', indent: 0 });
|
|
});
|
|
|
|
it('creates an element with no attributes when attrs is absent', () => {
|
|
const element = attach({ type: 'horizontalRule' });
|
|
expect(element.nodeName).toBe('horizontalRule');
|
|
expect(element.getAttributes()).toEqual({});
|
|
expect(element.length).toBe(0);
|
|
});
|
|
|
|
it('recurses into nested content preserving order', () => {
|
|
const element = attach({
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
|
|
],
|
|
});
|
|
// Two child paragraphs, in original order.
|
|
expect(element.length).toBe(2);
|
|
expect(element.get(0).get(0).toString()).toBe('one');
|
|
expect(element.get(1).get(0).toString()).toBe('two');
|
|
});
|
|
});
|
|
|
|
describe('buildTitleSeedYdoc', () => {
|
|
it('builds a level-1 heading carrying the title text', () => {
|
|
const doc = buildTitleSeedYdoc('Hello World');
|
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
|
|
|
const first = json.content?.[0];
|
|
expect(first.type).toBe('heading');
|
|
expect(first.attrs.level).toBe(1);
|
|
expect(jsonToText(json).trim()).toBe('Hello World');
|
|
});
|
|
|
|
it('produces a non-empty title fragment for a non-empty title', () => {
|
|
const doc = buildTitleSeedYdoc('Some Title');
|
|
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('produces a heading with no text child for an empty title', () => {
|
|
const doc = buildTitleSeedYdoc('');
|
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
|
|
|
const first = json.content?.[0];
|
|
expect(first.type).toBe('heading');
|
|
// No text content for an empty title.
|
|
expect(first.content ?? []).toHaveLength(0);
|
|
expect(jsonToText(json).trim()).toBe('');
|
|
});
|
|
|
|
it('round-trips a title through build -> extract -> build -> extract', () => {
|
|
const title = 'Round Trip Title';
|
|
const doc1 = buildTitleSeedYdoc(title);
|
|
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
|
|
|
|
const doc2 = buildTitleSeedYdoc(text1);
|
|
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
|
|
|
|
expect(text1).toBe(title);
|
|
expect(text2).toBe(text1);
|
|
});
|
|
});
|