test(git-sync): exhaustive converter coverage + fix 3 round-trip data-loss bugs
Coder↔reviewer design loop (9 rounds, reviewer verdict: exhaustive) produced 92 specs; implemented +123 tests (465 -> 588 passing). The new round-trip coverage exposed three genuine data-loss bugs in the Markdown<->ProseMirror converter, all now FIXED (round-trip is lossless for these): 1. pageBreak was lost on export (no converter case -> rendered to "" and the node vanished). Now emits <div data-type="pageBreak"></div>, which the schema parses back -> round-trips. 2. A block image between blocks left an empty <p> artifact after import-hoisting, producing a phantom blank-gap diff on every sync. markdownToProseMirror now strips content-less paragraphs after generateJSON — with a schema-validity guard that keeps the obligatory single empty paragraph in `content: "block+"` containers (tableCell/tableHeader/blockquote/column/callout/doc), so empty cells/quotes never become an invalid `content: []`. 3. The `code` mark combined with another mark was not byte-stable (emitted nested HTML that the schema's `code` `excludes:"_"` collapsed on import). The converter now emits code-only when `code` co-occurs, matching the editor. New coverage spans media/diagram/details/columns/math/mention attribute round-trips, converter emission branches, git error paths, and engine decision branches. A dedicated test pins the empty-container schema validity (the review catch on the bug-2 fix). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
75
packages/git-sync/test/docmost-schema-attrs.test.ts
Normal file
75
packages/git-sync/test/docmost-schema-attrs.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
sanitizeCssColor,
|
||||
clampCalloutType,
|
||||
} from '../src/lib/docmost-schema.js';
|
||||
|
||||
// These tests pin the two security/normalization helpers that Docmost
|
||||
// interpolates into inline style and the callout banner type on re-render.
|
||||
// They are the allowlist guard (XSS/style-breakout boundary) and the
|
||||
// case-insensitive callout normalizer, both otherwise only exercised
|
||||
// indirectly through parseHTML/renderHTML.
|
||||
|
||||
describe('sanitizeCssColor', () => {
|
||||
it('accepts a plain named color unchanged', () => {
|
||||
expect(sanitizeCssColor('red')).toBe('red');
|
||||
});
|
||||
|
||||
it('accepts 3-digit and 6-digit hex colors unchanged', () => {
|
||||
expect(sanitizeCssColor('#abc')).toBe('#abc');
|
||||
expect(sanitizeCssColor('#aabbcc')).toBe('#aabbcc');
|
||||
});
|
||||
|
||||
it('accepts well-formed functional notation unchanged', () => {
|
||||
expect(sanitizeCssColor('rgb(1,2,3)')).toBe('rgb(1,2,3)');
|
||||
expect(sanitizeCssColor('rgba(0,0,0,0.5)')).toBe('rgba(0,0,0,0.5)');
|
||||
expect(sanitizeCssColor('hsl(120,50%,50%)')).toBe('hsl(120,50%,50%)');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before matching', () => {
|
||||
// ' blue ' trims to 'blue', which is a valid named color.
|
||||
expect(sanitizeCssColor(' blue ')).toBe('blue');
|
||||
});
|
||||
|
||||
it('rejects a style-injection payload (returns null)', () => {
|
||||
expect(sanitizeCssColor('red; --x: url(x)')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an attribute-breakout payload (returns null)', () => {
|
||||
expect(sanitizeCssColor('red"><script>')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects the empty string (returns null)', () => {
|
||||
expect(sanitizeCssColor('')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-string input via the typeof guard (returns null)', () => {
|
||||
// @ts-expect-error deliberately passing a non-string to exercise the guard
|
||||
expect(sanitizeCssColor(123)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampCalloutType', () => {
|
||||
it('lowercases an uppercase valid type', () => {
|
||||
expect(clampCalloutType('INFO')).toBe('info');
|
||||
});
|
||||
|
||||
it('lowercases a mixed-case valid type', () => {
|
||||
expect(clampCalloutType('Warning')).toBe('warning');
|
||||
});
|
||||
|
||||
it('passes through already-lowercase valid types', () => {
|
||||
expect(clampCalloutType('danger')).toBe('danger');
|
||||
expect(clampCalloutType('success')).toBe('success');
|
||||
});
|
||||
|
||||
it('falls back to "info" for unknown types', () => {
|
||||
expect(clampCalloutType('note')).toBe('info');
|
||||
expect(clampCalloutType('tip')).toBe('info');
|
||||
});
|
||||
|
||||
it('falls back to "info" for empty string and null', () => {
|
||||
expect(clampCalloutType('')).toBe('info');
|
||||
expect(clampCalloutType(null)).toBe('info');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user