composite-hydration.test.tsx4.2 KBView on GitHub /**
* Reproduction: hydrating the composite playbook editor with real-shaped content.
*
* Mirrors what CompositePlaybookDocument does on open — build the editor with the
* composite extensions, then `setContent(mergeComposite(userJson, orgJson))`. If
* setContent throws or the schema silently drops the content, the editor renders
* empty (the reported bug). This test fails loudly in that case.
*/
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { mergeComposite, type PMNode } from '@/modules/documents/playbook/composite-merge';
import { createCompositePlaybookExtensions } from '@/modules/documents/playbook/playbookExtensions';
const para = (text?: string): PMNode =>
text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
const fileLinkPara = (documentId: string): PMNode => ({
type: 'paragraph',
content: [{ type: 'fileLink', attrs: { documentId } }],
});
// Shaped like parsePlaybookXmlToJson output for a real playbook.
const userJson: PMNode = {
type: 'doc',
content: [
{ type: 'alwaysLoadedSection', content: [fileLinkPara('doc-a'), fileLinkPara('doc-b')] },
{ type: 'onDemandSection', content: [fileLinkPara('doc-c')] },
{
type: 'globalSection',
content: [
{
type: 'crossCuttingSection',
content: [
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'IGNORE (no action needed)' }] },
// Empty ``` fence parses to a codeBlock holding a single EMPTY text
// node — ProseMirror rejects it ("Empty text nodes are not allowed")
// and setContent throws, blanking the editor. mergeComposite must
// strip it.
{ type: 'codeBlock', content: [{ type: 'text', text: '' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [para('Meeting recaps')] }] },
],
},
{
type: 'triggerNode',
attrs: { config: JSON.stringify({ type: 'event_occurred', eventTypes: [] }) },
content: [fileLinkPara('sub-1'), fileLinkPara('sub-2')],
},
],
},
{
type: 'stageSection',
attrs: { id: 'prospecting', label: 'Prospecting' },
content: [
{ type: 'stageEntry', content: [para('entered')] },
{ type: 'stageExit', content: [para('exited')] },
{ type: 'stageInstructions', content: [para('')] },
],
},
],
};
const orgJson: PMNode = {
type: 'doc',
content: [
{ type: 'alwaysLoadedSection', content: [fileLinkPara('org-doc-a')] },
{
type: 'globalSection',
content: [
{
type: 'triggerNode',
attrs: { config: JSON.stringify({ type: 'event_occurred', eventTypes: ['meeting'] }) },
content: [para('Only fire for prospect meetings:'), fileLinkPara('org-sub-1')],
},
],
},
{
type: 'stageSection',
attrs: { id: 'prospecting', label: 'Prospecting' },
content: [
{ type: 'stageEntry', content: [para('org entered')] },
{ type: 'stageExit', content: [para('org exited')] },
{ type: 'stageInstructions', content: [para('')] },
],
},
],
};
function makeEditor(): Editor {
return new Editor({
extensions: [
StarterKit.configure({ codeBlock: {}, heading: { levels: [1, 2, 3] } }),
...createCompositePlaybookExtensions({ aopId: 'test-aop' }),
],
content: '',
});
}
describe('composite playbook hydration', () => {
it('setContent(mergeComposite(...)) does not throw and keeps the content', () => {
const editor = makeEditor();
const composite = mergeComposite(userJson, orgJson);
expect(() => editor.commands.setContent(composite)).not.toThrow();
const json = editor.getJSON();
// The editor must not collapse to an empty doc.
expect(json.content?.length ?? 0).toBeGreaterThan(0);
// Composite section wrappers survived hydration.
const types = (json.content ?? []).map((n) => n.type);
expect(types).toContain('compositeSection');
// The actual prose survived (not silently dropped by a schema mismatch).
expect(editor.state.doc.textContent).toContain('IGNORE (no action needed)');
editor.destroy();
});
});