Files
gitmost/packages/prosemirror-markdown/test/find-invalid-node.test.ts
T
agent_coder 0f5f048ca2 fix(mcp): pre-validate node JSON против схемы + путь битого узла (#409, остаток Фазы 1)
Структурные редакторы (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>
2026-07-10 10:49:51 +03:00

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