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();
  });
});