markdown-baseline.test.tsx4.9 KBView on GitHub
/**
 * Phase 1 of the coaching-agent design (apps/server/docs/coaching-agent.md).
 *
 * The whole design rests on one claim: an agent can write a PLAIN GFM pipe table and a PLAIN
 * GFM task list into a Cedar Doc and both render, with no new node types. If that is false the
 * scorecard has to go back to a JSON spec and the design needs revisiting — so it is pinned here
 * before anything is built on top of it.
 *
 * These drive the real extension set the document editor registers (StarterKit + Markdown +
 * Table/TaskList), not a stub, so a change to `markdown-editor.tsx`'s extension list that drops
 * table or task-list support fails this test.
 */
import { Editor, getSchema } from '@tiptap/core';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';

/** The subset of `markdown-editor.tsx`'s extensions that this claim depends on. */
const extensions = [
  StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
  Markdown,
  Table.configure({ resizable: false }),
  TableRow,
  TableHeader,
  TableCell,
  TaskList,
  TaskItem.configure({ nested: true }),
];

/** Every node type present anywhere in a ProseMirror JSON tree. */
function nodeTypes(json: unknown, acc: Set<string> = new Set()): Set<string> {
  if (!json || typeof json !== 'object') return acc;
  const node = json as { type?: string; content?: unknown[] };
  if (node.type) acc.add(node.type);
  for (const child of node.content ?? []) nodeTypes(child, acc);
  return acc;
}

/** Parse markdown through a REAL editor — the only path where the Markdown parser is reachable.
 *  `Markdown.parseMarkdown` is not a static; an earlier version of this test called it and
 *  silently short-circuited, which is why parsing goes through an Editor instance here. */
function parse(markdown: string) {
  const editor = new Editor({ extensions, content: markdown, contentType: 'markdown' as never });
  try {
    return editor.getJSON();
  } finally {
    editor.destroy();
  }
}

const SCORECARD_TABLE = [
  '| Discovery | Score | Why |',
  '| --- | --- | --- |',
  '| D1 Knew the must-asks | 4 | Got detail count and build-vs-grow. |',
  '| D2 Genuinely curious | 2 | 6 pains voiced, 4 with no follow-up. |',
].join('\n');

const CHECKLIST = [
  '- [x] Built rapport before starting discovery',
  '- [ ] Asked for the decision-maker’s name',
].join('\n');

describe('coaching markdown baseline', () => {
  it('registers a table schema, so a GFM pipe table has somewhere to render', () => {
    const schema = getSchema(extensions);
    expect(schema.nodes.table).toBeDefined();
    expect(schema.nodes.tableRow).toBeDefined();
    expect(schema.nodes.tableCell).toBeDefined();
    expect(schema.nodes.tableHeader).toBeDefined();
  });

  it('registers a task-list schema, so a GFM task list has somewhere to render', () => {
    const schema = getSchema(extensions);
    expect(schema.nodes.taskList).toBeDefined();
    expect(schema.nodes.taskItem).toBeDefined();
  });

  it('taskItem carries a checked attribute, so [x] vs [ ] survives as state', () => {
    const schema = getSchema(extensions);
    expect(schema.nodes.taskItem.spec.attrs).toHaveProperty('checked');
  });

  it('parses a scorecard pipe table into real table nodes', () => {
    const types = nodeTypes(parse(SCORECARD_TABLE));
    expect(types.has('table')).toBe(true);
    expect(types.has('tableRow')).toBe(true);
    expect(types.has('tableHeader')).toBe(true);
    expect(types.has('tableCell')).toBe(true);
  });

  it('parses a checklist into real taskItem nodes, preserving checked state', () => {
    const json = parse(CHECKLIST) as { content?: unknown[] };
    const types = nodeTypes(json);
    expect(types.has('taskList')).toBe(true);
    expect(types.has('taskItem')).toBe(true);

    const checked: boolean[] = [];
    const walk = (n: unknown): void => {
      if (!n || typeof n !== 'object') return;
      const node = n as { type?: string; attrs?: { checked?: boolean }; content?: unknown[] };
      if (node.type === 'taskItem') checked.push(Boolean(node.attrs?.checked));
      for (const c of node.content ?? []) walk(c);
    };
    walk(json);
    expect(checked).toEqual([true, false]);
  });

  it('round-trips a scorecard table back to a well-formed pipe table', () => {
    const editor = new Editor({ extensions, content: SCORECARD_TABLE, contentType: 'markdown' as never });
    try {
      const out = (editor as unknown as { getMarkdown: () => string }).getMarkdown();
      expect(out).toContain('|');
      expect(out).toContain('D1 Knew the must-asks');
      expect(out).toContain('D2 Genuinely curious');
    } finally {
      editor.destroy();
    }
  });
});