Files
gitmost/packages/prosemirror-markdown/test/schema-editor-ext-contract.test.ts
T
agent_coder e3eece78c3 test(converter): атрибутный contract-тест схем editor-ext <-> mirror
Name-level контракт ловил пропажу целой ноды/марки, но не дрейф АТРИБУТОВ
внутри вендоренной ноды — класс, из-за которого молча потерялся
subpages.recursive. Добавлен атрибутный контракт: для каждой ноды/марки
@docmost/editor-ext сравниваются её СОБСТВЕННЫЕ объявленные атрибуты (имена
+ дефолты, читаются из config.addAttributes) с spec.attrs собранной схемы
зеркала.

Направление editor-ext -> mirror: зеркало намеренно надмножество (глобальные
id/textAlign/indent, нормализация части дефолтов в null), поэтому обратное
сравнение — ложный дрейф. Значимый провал — атрибут, который зеркало РОНЯЕТ
(имя) или чей дефолт молча меняет. Два blessed вида расхождений вынесены в
обоснованные allowlist'ы (highlight.colorName — нет md-формы;
image.src/link.internal/pdf.width/height — null-нормализация непереносимых
атрибутов), оба со stale-guard, чтобы список не сгнил.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:49:03 +03:00

278 lines
13 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { getSchema } from "@tiptap/core";
import { docmostExtensions } from "../src/lib/docmost-schema.js";
import * as editorExt from "@docmost/editor-ext";
// CROSS-PACKAGE SCHEMA CONTRACT (data-loss-sensitive).
//
// `src/lib/docmost-schema.ts` is a hand-synced VENDORED MIRROR of the canonical
// Docmost schema in `@docmost/editor-ext`. The sibling `schema-surface-snapshot`
// test pins the mirror's FULL surface (names + attrs) against an inline
// reference, but that reference is hand-curated and does not mechanically tie to
// editor-ext. This test closes that gap from the other side: it reads the ACTUAL
// Tiptap node/mark definitions exported by `@docmost/editor-ext` and asserts the
// vendored mirror is a SUPERSET of their type NAMES — so a Docmost-specific node
// or mark added upstream that the mirror forgets to vendor fails CI loudly
// (otherwise it is silently dropped on the markdown <-> ProseMirror round-trip).
//
// This file now holds TWO contracts (see the two describe blocks): the original
// NAME-LEVEL type contract (no canonical node/mark TYPE goes unmirrored) AND, as
// of #493, an ATTRIBUTE-LEVEL contract that compares each editor-ext node/mark's
// OWN declared attributes (names + defaults) against the mirror's built schema.
// A full mechanical attribute-by-attribute EQUALITY would be fragile (the mirror
// is a deliberate superset: it injects the global id/textAlign/indent attrs and
// normalizes some editor-ext defaults to null), so the attribute contract is
// asymmetric — editor-ext -> mirror — with a small, reasoned, stale-guarded
// allowlist for the two blessed divergence kinds (non-round-trippable omissions
// and null-normalized defaults). StarterKit-provided types (paragraph, bold,
// heading, …) are contributed by @tiptap/starter-kit in the mirror rather than
// by editor-ext, so they are naturally covered by the mirror's superset.
//
// NOT COVERED here (deferred): (1) the THIRD copy in `packages/mcp` — a separate
// package guarded by its own surface snapshot; (2) attribute *behaviour* drift,
// e.g. the details `open` attr read via getAttribute vs hasAttribute (PR #119
// review #2) — a name-level compare cannot see parseHTML/renderHTML differences.
// Mechanically guarding behavioural parity across all THREE copies needs the
// single framework-free "schema core" refactor (deferred — see AGENTS.md); until
// then each copy's header carries the manual keep-in-sync requirement.
/** Tiptap Node/Mark instances expose a `.name` and a `.type` of 'node'|'mark'. */
function isTiptapNodeOrMark(
value: unknown,
): value is { name: string; type: "node" | "mark" } {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof (value as { name: unknown }).name === "string" &&
"type" in value &&
((value as { type: unknown }).type === "node" ||
(value as { type: unknown }).type === "mark")
);
}
/** The set of node/mark type names the vendored mirror actually registers. */
function vendoredNames(): Set<string> {
const schema = getSchema(docmostExtensions as never);
return new Set([
...Object.keys(schema.nodes),
...Object.keys(schema.marks),
]);
}
/** The Docmost-specific node/mark type names exported by @docmost/editor-ext. */
function editorExtNames(): Set<string> {
const names = new Set<string>();
for (const value of Object.values(editorExt)) {
if (isTiptapNodeOrMark(value)) names.add(value.name);
}
return names;
}
describe("docmost schema vs @docmost/editor-ext (name-level contract)", () => {
it("exposes Tiptap node/mark definitions from editor-ext (guards against the import going dark)", () => {
// If editor-ext ever stops exporting concrete node/mark objects (e.g. a
// barrel refactor), this contract would vacuously pass — assert it found a
// meaningful set so the test cannot silently become a no-op.
expect(editorExtNames().size).toBeGreaterThan(5);
});
it("vendors every Docmost-specific node/mark type defined in editor-ext (no silently-dropped types)", () => {
const vendored = vendoredNames();
const missing = [...editorExtNames()].filter((n) => !vendored.has(n)).sort();
// missing must be empty: any name here exists in editor-ext but NOT in the
// vendored mirror, so documents using it would lose that node/mark on a
// git-sync round-trip. Re-sync src/lib/docmost-schema.ts before clearing.
expect(missing).toEqual([]);
});
});
// ── ATTRIBUTE-LEVEL CONTRACT (#493 commit 2) ────────────────────────────────
//
// The name-level contract above catches a WHOLE node/mark type going unmirrored,
// but not ATTRIBUTE drift within a vendored type — the exact class that silently
// dropped `subpages.recursive`: editor-ext grew an attribute the hand-synced
// mirror forgot, so documents using it lost that attribute on a git-sync
// round-trip while CI stayed green. This closes that gap by comparing each
// editor-ext node/mark's OWN declared attributes (names + defaults) against the
// mirror's built ProseMirror schema `spec.attrs`.
//
// DIRECTION: editor-ext -> mirror. The mirror is deliberately a SUPERSET (it
// injects the global `id`/`textAlign`/`indent` attributes and normalizes some
// editor-ext "required" attrs to a `null` default), so a reverse compare would
// be pure false drift; the meaningful failure is an editor-ext attribute the
// mirror DROPS (name) or whose DEFAULT it silently changes. Both directions of
// staleness are guarded so the allowlists cannot rot.
/**
* The attributes an editor-ext Tiptap Node/Mark DECLARES itself, read from its
* `config.addAttributes()`. Global attributes injected by separate extensions
* (unique-id, indent, textAlign) are NOT included here — they are the mirror's
* superset and are not part of a per-type declaration — so this isolates each
* type's own contribution. A declared attribute with no explicit `default` is a
* required attr (Tiptap default `undefined`); we surface that as-is so the
* default compare can skip it (the mirror makes such attrs optional/`null`).
*/
function editorExtOwnAttrs(): Map<
string,
{ kind: "node" | "mark"; attrs: Record<string, unknown> }
> {
const out = new Map<
string,
{ kind: "node" | "mark"; attrs: Record<string, unknown> }
>();
for (const value of Object.values(editorExt)) {
if (!isTiptapNodeOrMark(value)) continue;
const ext = value as unknown as {
name: string;
type: "node" | "mark";
options?: unknown;
storage?: unknown;
config?: { addAttributes?: () => Record<string, { default?: unknown }> };
};
const fn = ext.config?.addAttributes;
// addAttributes reads `this.options`/`this.name`; bind a minimal context
// (verified sufficient for every editor-ext extension — none reach for
// `this.editor` here). A type with no addAttributes contributes no attrs.
const declared =
typeof fn === "function"
? fn.call({
options: ext.options ?? {},
name: ext.name,
parent: undefined,
storage: ext.storage ?? {},
} as never)
: {};
const attrs: Record<string, unknown> = {};
for (const [attr, spec] of Object.entries(declared || {})) {
// `undefined` marks a required (no-default) attr; keep it so the default
// compare can distinguish "no default declared" from "default is null".
attrs[attr] = (spec as { default?: unknown })?.default;
}
out.set(ext.name, { kind: ext.type, attrs });
}
return out;
}
/** The mirror's built-schema `spec.attrs` for a type: attr name -> default. */
function mirrorAttrs(
name: string,
kind: "node" | "mark",
): Record<string, unknown> | null {
const schema = getSchema(docmostExtensions as never);
const spec = kind === "node" ? schema.nodes[name]?.spec : schema.marks[name]?.spec;
if (!spec) return null;
const out: Record<string, unknown> = {};
for (const [attr, def] of Object.entries(spec.attrs || {})) {
out[attr] = (def as { default?: unknown }).default;
}
return out;
}
// An editor-ext attribute the mirror deliberately does NOT vendor because it has
// NO markdown round-trip representation — dropping it loses nothing on the
// git-sync cycle (the same rationale the flat-roundtrip property suite uses to
// allowlist e.g. `tableCell.backgroundColorName`). Blessed by the hand-curated
// surface snapshot (schema-surface-snapshot.test.ts), reviewed in every diff.
const ACCEPTED_ATTR_OMISSIONS = new Set<string>([
"highlight.colorName", // only `highlight.color` round-trips (==text==); the
// secondary palette-name is presentational and has no markdown form.
]);
// An editor-ext attribute the mirror vendors but with a DIFFERENT default: the
// mirror normalizes an "absent" value to `null` (its uniform optional-attr
// convention) rather than editor-ext's UI-oriented default. None of these attrs
// is emitted on the markdown surface (the converter round-trips only the
// serializable ones), so the default never round-trips and the divergence is
// inert — but pinned here so a NEW default change on either side forces review.
const ACCEPTED_DEFAULT_DIVERGENCE = new Set<string>([
"image.src", // mirror null vs editor "" (an image is never emitted src-less)
"link.internal", // mirror null vs editor false (routing attr, not in md link)
"pdf.width", // mirror null vs editor 800 (presentational sizing, not in md)
"pdf.height", // mirror null vs editor 600 (presentational sizing, not in md)
]);
describe("docmost schema vs @docmost/editor-ext (attribute-level contract)", () => {
it("vendors every editor-ext attribute (name) of every shared type — no silently-dropped attrs", () => {
const dropped: string[] = [];
for (const [name, { kind, attrs }] of editorExtOwnAttrs()) {
const mirror = mirrorAttrs(name, kind);
if (!mirror) continue; // whole-type omission is the name-level test's job
for (const attr of Object.keys(attrs)) {
const key = `${name}.${attr}`;
if (!(attr in mirror) && !ACCEPTED_ATTR_OMISSIONS.has(key)) {
dropped.push(key);
}
}
}
// Any entry here exists on the editor-ext node/mark but NOT in the mirror
// (and is not a blessed non-round-trippable omission): documents using it
// lose that attribute on a git-sync round-trip — the subpages.recursive
// class. Re-sync src/lib/docmost-schema.ts (and the surface snapshot) or add
// a reasoned ACCEPTED_ATTR_OMISSIONS entry before clearing.
expect(dropped.sort()).toEqual([]);
});
it("keeps every editor-ext attribute DEFAULT in sync — no silent default drift", () => {
const drift: string[] = [];
for (const [name, { kind, attrs }] of editorExtOwnAttrs()) {
const mirror = mirrorAttrs(name, kind);
if (!mirror) continue;
for (const [attr, extDefault] of Object.entries(attrs)) {
const key = `${name}.${attr}`;
// Skip attrs editor-ext declares WITHOUT a default (required attrs):
// the mirror deliberately makes them optional (`null`), a safe superset.
if (extDefault === undefined) continue;
if (!(attr in mirror)) continue; // a drop, reported by the name test
if (
JSON.stringify(mirror[attr]) !== JSON.stringify(extDefault) &&
!ACCEPTED_DEFAULT_DIVERGENCE.has(key)
) {
drift.push(
`${key}: mirror=${JSON.stringify(mirror[attr])} editor-ext=${JSON.stringify(extDefault)}`,
);
}
}
}
expect(drift.sort()).toEqual([]);
});
it("the attribute allowlists have no stale rows (each is really omitted / divergent)", () => {
const ext = editorExtOwnAttrs();
const staleOmission: string[] = [];
for (const key of ACCEPTED_ATTR_OMISSIONS) {
const [name, attr] = key.split(".");
const entry = ext.get(name);
const mirror = entry ? mirrorAttrs(name, entry.kind) : null;
// Stale if editor-ext no longer declares it, or the mirror now DOES vendor
// it (so it should be removed from the omission allowlist).
if (!entry || !(attr in entry.attrs) || (mirror && attr in mirror)) {
staleOmission.push(key);
}
}
expect(staleOmission, "stale ACCEPTED_ATTR_OMISSIONS rows").toEqual([]);
const staleDivergence: string[] = [];
for (const key of ACCEPTED_DEFAULT_DIVERGENCE) {
const [name, attr] = key.split(".");
const entry = ext.get(name);
const mirror = entry ? mirrorAttrs(name, entry.kind) : null;
const extDefault = entry?.attrs[attr];
// Stale if the divergence no longer exists (attr gone, or defaults now
// agree) — the row should be dropped so the allowlist stays honest.
if (
!entry ||
!mirror ||
!(attr in entry.attrs) ||
!(attr in mirror) ||
extDefault === undefined ||
JSON.stringify(mirror[attr]) === JSON.stringify(extDefault)
) {
staleDivergence.push(key);
}
}
expect(staleDivergence, "stale ACCEPTED_DEFAULT_DIVERGENCE rows").toEqual([]);
});
});