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