Files
gitmost/packages/mcp/test/unit/unstorable-yjs-error.test.mjs
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

83 lines
3.4 KiB
JavaScript

// #409: unstorableYjsError diagnostics PRECEDENCE. The opaque Yjs encode failure
// (`Unknown node type: undefined`) is a node-SHAPE problem, so the shared
// findInvalidNode is consulted FIRST and yields a path-anchored node message;
// only a shape-sound doc falls back to findUnstorableAttr (undefined/function/
// etc. attr values). Exercised through `assertYjsEncodable`, which runs the same
// encode + error-wrapping the live write path uses.
import { test } from "node:test";
import assert from "node:assert/strict";
import { assertYjsEncodable } from "../../build/lib/collaboration.js";
import {
findInvalidNode,
findUnstorableAttr,
} from "@docmost/prosemirror-markdown";
const doc = (...content) => ({ type: "doc", content });
test("a nested typeless node yields the rich node-shape message (not an attr hint)", () => {
const bad = doc({
type: "paragraph",
content: [{ text: "oops", marks: [] }], // missing "type":"text"
});
assert.throws(
() => assertYjsEncodable(bad),
(err) => {
assert.match(err.message, /Invalid node:/);
assert.match(err.message, /missing "type"/);
assert.match(err.message, /content\[0\]/);
// It must NOT fall through to the generic attribute sentence.
assert.doesNotMatch(err.message, /Offending attribute/);
assert.doesNotMatch(
err.message,
/attribute likely holds a value Yjs cannot store/,
);
return true;
},
);
});
test("PRECEDENCE: a genuine undefined-attr case is a node-SHAPE-clean case, so the attr fallback fires", () => {
// A doc whose node shapes are ALL valid but that carries a Yjs-unstorable
// undefined attribute. unstorableYjsError checks findInvalidNode FIRST (must
// miss here) and only then findUnstorableAttr (must hit) — this is exactly the
// division of labor that keeps a real attr problem from being mislabelled as a
// node-shape problem, and vice versa. We assert the two helpers directly (the
// wrapper is not exported) because sanitizeForYjs strips undefined attrs before
// the live encoder ever sees them, so this branch cannot be reached through
// assertYjsEncodable without also failing the clone.
const attrProblem = doc({
type: "paragraph",
attrs: { id: "p1", indent: undefined },
content: [{ type: "text", text: "hi" }],
});
// findInvalidNode: shape is clean -> null (so the wrapper does NOT emit
// "Invalid node").
assert.equal(findInvalidNode(attrProblem), null);
// findUnstorableAttr: pinpoints the undefined attr -> the fallback message.
assert.match(findUnstorableAttr(attrProblem) ?? "", /indent \(undefined\)/);
});
test("PRECEDENCE: a node-shape problem is caught by findInvalidNode even when an attr is also unstorable", () => {
// Both a shape problem (typeless nested leaf) AND an unstorable attr exist;
// findInvalidNode wins, so the model is pointed at the node shape (the real
// root cause of `Unknown node type: undefined`), not the attribute.
const both = doc({
type: "paragraph",
attrs: { id: "p1", indent: undefined },
content: [{ text: "oops" }],
});
const shape = findInvalidNode(both);
assert.notEqual(shape, null);
assert.match(shape.summary, /missing "type"/);
});
test("a fully valid document encodes without throwing", () => {
const good = doc({
type: "paragraph",
attrs: { id: "p1" },
content: [{ type: "text", text: "hi" }],
});
assert.doesNotThrow(() => assertYjsEncodable(good));
});