coaching-yjs-survival.test.ts3.8 KBView on GitHub /**
* Phase 1 of the coaching-agent design (apps/server/docs/coaching-agent.md).
*
* The coaching documents are Y.js-backed like every other Cedar Doc, and they carry two things
* whose state is easy to lose in a round trip: a GFM pipe table (structure) and a GFM task list
* (the `checked` attribute). If `- [x]` degrades to `- [ ]` — or a table flattens to paragraphs —
* through a Y.Doc round trip, a rep's checklist silently resets every time the doc syncs.
*
* This drives the same `prosemirrorJSONToYDoc` / `yDocToProsemirrorJSON` pair the collaboration
* layer uses (see `playbookNodeSurvival.test.ts`, which pins the analogous bug for playbook nodes).
*/
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';
import { prosemirrorJSONToYDoc, yDocToProsemirrorJSON } from 'y-prosemirror';
const extensions = [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
Markdown,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
];
const DAY_ENTRY = [
'# Keenan — coaching',
'',
'## 2026-08-20',
'',
'| 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. |',
'',
'- [x] Built rapport before starting discovery',
'- [x] Asked "what piqued your interest"',
'- [ ] Asked for the decision-maker’s name',
].join('\n');
function toJSON(markdown: string) {
const editor = new Editor({ extensions, content: markdown, contentType: 'markdown' as never });
try {
return editor.getJSON();
} finally {
editor.destroy();
}
}
function collectTaskChecked(json: unknown, acc: boolean[] = []): boolean[] {
if (!json || typeof json !== 'object') return acc;
const node = json as { type?: string; attrs?: { checked?: boolean }; content?: unknown[] };
if (node.type === 'taskItem') acc.push(Boolean(node.attrs?.checked));
for (const child of node.content ?? []) collectTaskChecked(child, acc);
return acc;
}
function countType(json: unknown, type: string, acc = { n: 0 }): number {
if (!json || typeof json !== 'object') return acc.n;
const node = json as { type?: string; content?: unknown[] };
if (node.type === type) acc.n += 1;
for (const child of node.content ?? []) countType(child, type, acc);
return acc.n;
}
describe('coaching document survives a Y.js round trip', () => {
const schema = getSchema(extensions);
const before = toJSON(DAY_ENTRY);
const ydoc = prosemirrorJSONToYDoc(schema, before, 'prosemirror');
const after = yDocToProsemirrorJSON(ydoc, 'prosemirror');
it('keeps the scorecard table intact', () => {
expect(countType(before, 'table')).toBe(1);
expect(countType(after, 'table')).toBe(1);
expect(countType(after, 'tableRow')).toBe(countType(before, 'tableRow'));
expect(countType(after, 'tableCell')).toBe(countType(before, 'tableCell'));
});
it('keeps every checklist item and its checked state', () => {
const beforeChecked = collectTaskChecked(before);
const afterChecked = collectTaskChecked(after);
expect(beforeChecked).toEqual([true, true, false]);
expect(afterChecked).toEqual(beforeChecked);
});
it('keeps the day heading, so newest-first ordering is not lost', () => {
expect(countType(after, 'heading')).toBe(countType(before, 'heading'));
expect(JSON.stringify(after)).toContain('2026-08-20');
});
});