0f5f048ca2
Структурные редакторы (patchNode/insertNode/updatePageJson/transformPage) кидали
опаковый Yjs-крах на агентском JSON с вложенным узлом без/с неизвестным `type`:
«Failed to encode document to Yjs (fromJSON): Unknown node type: undefined» —
ГЛУБОКО в энкодере, уже ПОСЛЕ открытия collab-сессии, а хинт мислейблил это как
проблему атрибута. Агент ретраил вслепую (~34 краха в истории 06-17…07-07).
- findInvalidNode(doc) в prosemirror-markdown/node-ops.ts: DFS по content,
возвращает {path, summary} первого узла с отсутствующим/не-строковым `type`
или типом/маркой вне схемы. Множество имён — из getSchema(docmostExtensions),
ТОГО ЖЕ, из которого энкод-путь строит docmostSchema → «известный тип»
обходчика ровно то, что примет PMNode.fromJSON/toYdoc (сверено на 45 узлах +
12 марках, ни ложных положительных, ни пропуска краш-типа).
- unstorableYjsError: findInvalidNode ПЕРВЫМ (node-shape крах больше не
мислейблится как атрибут), затем findUnstorableAttr, generic-фраза последней.
- assertValidNodeShape(op, node) ДО getCollabTokenWithReauth/mutatePageContent
в patchNode/insertNode/updatePageJson: fail-fast — collab-сессия не
открывается, page-lock не берётся, сообщение детерминировано (mock-тест
ассертит collabTokenFetched===false на битом пути). tableUpdateCell не тронут
(строит абзац из plain text через makeCellParagraph, агентский JSON не глотает).
- Описания patch_node/insert_node/update_page_json: каждый узел, включая
вложенные, несёт строковый `type` из схемы; текст-листы {"type":"text",...}.
sanitizeForYjs (стрип undefined-атрибутов) сохранён — другой класс отказа.
Внутреннее ревью: APPROVE WITH SUGGESTIONS — schema-fidelity/fail-fast/
no-false-positive/precedence подтверждены; замечания необязательны (тест
перечисления схемы, depth-guard безобиден т.к. энкодер падает раньше).
prosemirror-markdown vitest 726/726, mcp node --test 613/613.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { findInvalidNode } from '../src/lib/node-ops.js';
|
|
|
|
// findInvalidNode (#409): a depth-first SHAPE gate that turns the encoder's
|
|
// opaque `Unknown node type: undefined` into a path-anchored, pre-write
|
|
// diagnostic. It flags the FIRST node whose `type` is absent/non-string or not
|
|
// a known Docmost schema node, or that carries an unknown mark; returns null for
|
|
// a well-formed doc.
|
|
|
|
const doc = (...content: any[]) => ({ type: 'doc', content });
|
|
const para = (...content: any[]) => ({
|
|
type: 'paragraph',
|
|
attrs: { id: 'p1' },
|
|
content,
|
|
});
|
|
const text = (value: string, marks?: any[]) => {
|
|
const node: any = { type: 'text', text: value };
|
|
if (marks) node.marks = marks;
|
|
return node;
|
|
};
|
|
|
|
describe('findInvalidNode', () => {
|
|
it('returns null for a fully valid document', () => {
|
|
const good = doc(
|
|
para(text('hello ', [{ type: 'bold' }]), text('world')),
|
|
{
|
|
type: 'heading',
|
|
attrs: { id: 'h1', level: 2 },
|
|
content: [text('Title')],
|
|
},
|
|
);
|
|
expect(findInvalidNode(good)).toBeNull();
|
|
});
|
|
|
|
it('flags a NESTED typeless text leaf with a path-precise summary', () => {
|
|
// A text leaf written as {"text":"foo"} with no "type":"text" — the dominant
|
|
// `Unknown node type: undefined` cause.
|
|
const bad = doc(
|
|
para(text('ok')),
|
|
para({ text: 'foo', marks: [] } as any),
|
|
);
|
|
const hit = findInvalidNode(bad);
|
|
expect(hit).not.toBeNull();
|
|
// Second paragraph (index 1), first child (index 0).
|
|
expect(hit!.path).toBe('node.content[1].content[0]');
|
|
expect(hit!.summary).toContain('node.content[1].content[0]');
|
|
expect(hit!.summary).toContain('missing "type"');
|
|
expect(hit!.summary).toContain('keys: text, marks');
|
|
expect(hit!.summary).toContain('did you mean {"type": "text", ...}');
|
|
});
|
|
|
|
it('flags a non-string type (e.g. numeric)', () => {
|
|
const bad = doc(para({ type: 123, content: [] } as any));
|
|
const hit = findInvalidNode(bad);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.path).toBe('node.content[0].content[0]');
|
|
expect(hit!.summary).toContain('missing "type"');
|
|
});
|
|
|
|
it('flags an UNKNOWN node type name that is not in the Docmost schema', () => {
|
|
const bad = doc({
|
|
type: 'paragraf', // typo — not a real node
|
|
attrs: { id: 'x' },
|
|
content: [text('hi')],
|
|
});
|
|
const hit = findInvalidNode(bad);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.path).toBe('node.content[0]');
|
|
expect(hit!.summary).toContain('unknown node type "paragraf"');
|
|
expect(hit!.summary).toContain('not in the Docmost schema');
|
|
});
|
|
|
|
it('flags an UNKNOWN mark type on an otherwise-valid node', () => {
|
|
const bad = doc(para(text('hi', [{ type: 'blink' }])));
|
|
const hit = findInvalidNode(bad);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.path).toBe('node.content[0].content[0].marks[0]');
|
|
expect(hit!.summary).toContain('unknown mark type "blink"');
|
|
});
|
|
|
|
it('accepts every real Docmost node/mark type it is asked about', () => {
|
|
// Known node (callout) and known marks (italic, code) must NOT be flagged.
|
|
const good = doc({
|
|
type: 'callout',
|
|
attrs: { id: 'c1', type: 'info' },
|
|
content: [para(text('x', [{ type: 'italic' }, { type: 'code' }]))],
|
|
});
|
|
expect(findInvalidNode(good)).toBeNull();
|
|
});
|
|
|
|
it('reports the root itself when the root is typeless', () => {
|
|
const hit = findInvalidNode({ content: [] } as any);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.path).toBe('node');
|
|
});
|
|
|
|
it('is null-safe for non-object input', () => {
|
|
expect(findInvalidNode(null)).toBeNull();
|
|
expect(findInvalidNode(undefined)).toBeNull();
|
|
expect(findInvalidNode('nope' as any)).toBeNull();
|
|
});
|
|
});
|