Files
gitmost/packages/git-sync/test/docmost-schema-attrs.test.ts
a fe4adf23a0 fix(git-sync): unwedge per-page conflicts, preserve callout types, flush collab on disconnect
Addresses QA findings on PR #119 (issues #235/#236).

SYNC-WEDGE (HIGH): one same-line conflict on one page froze sync for the
WHOLE space in both directions forever. The pull's docmost->main merge left
the vault mid-merge, so every later cycle's isMergeInProgress() check returned
skipped:"merge-in-progress" and skipped the entire space with no recovery.
- pull.ts now COMMITS a conflicting merge with markers in place (commitMerge):
  cleanly-merged pages land, the conflicted page carries its markers on main and
  is isolated by the existing push-side conflict-marker skip (markers never reach
  Docmost), and the next cycle is no longer wedged. conflictedPaths is surfaced.
- cycle.ts now RECOVERS a vault left mid-merge by a prior/pre-fix cycle: it
  aborts the stale merge (merge --abort, hard-reset fallback) and continues,
  instead of skipping the space forever.
- git.ts: listUnmergedPaths / commitMerge / abortMerge / resetHardToHead.

CALLOUT TYPE FIDELITY: git-sync's CALLOUT_TYPES was missing "note" and "default"
(editor-canonical types), so [!note]/[!default] callouts flattened to [!info] on
every round-trip. Aligned the list with @docmost/editor-ext getValidCalloutType.

LOSS-ON-FAST-CLOSE: editing a page then closing the tab inside the collab
debounce window (~3-18s) lost the edit, because with unloadImmediately:false
Hocuspocus does not flush the debounced onStoreDocument on the last-client
disconnect. PersistenceExtension.onDisconnect now flushes the pending store
(debouncer.executeNow) on the last disconnect only, with no redundant write.

DUPLICATION re-verify (#1): the schema-default merge-key normalization is intact;
faithful toYdoc-based reproduction shows callout + rich content resync with 0 ops
and no growth/strip across cycles -> the re-report was leftover vault data, not a
live regression. Locked with a callout regression spec.

Tests: git-sync 688 pass (incl. real-VaultGit wedge-recovery integration); server
git-sync+collaboration 285 pass; new callout merge/fidelity + onDisconnect-flush
specs. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00

113 lines
4.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
sanitizeCssColor,
clampCalloutType,
encodeHtmlEmbedSource,
decodeHtmlEmbedSource,
} 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('PRESERVES every editor-canonical type (note/default no longer flattened)', () => {
// Regression for the QA "callout type -> [!info]" fidelity loss: `note` and
// `default` are valid editor callout types and must survive the git
// round-trip, not collapse to `info`.
expect(clampCalloutType('note')).toBe('note');
expect(clampCalloutType('default')).toBe('default');
expect(clampCalloutType('info')).toBe('info');
expect(clampCalloutType('warning')).toBe('warning');
expect(clampCalloutType('danger')).toBe('danger');
expect(clampCalloutType('success')).toBe('success');
});
it('falls back to "info" for genuinely unknown types', () => {
expect(clampCalloutType('tip')).toBe('info');
expect(clampCalloutType('banana')).toBe('info');
});
it('falls back to "info" for empty string and null', () => {
expect(clampCalloutType('')).toBe('info');
expect(clampCalloutType(null)).toBe('info');
});
});
// The htmlEmbed `source` rides the data-source attribute base64-encoded so the
// raw HTML/CSS/JS stays inert and double-encoding-free across a round trip.
// Encode/decode MUST be exact inverses (incl. UTF-8) or the embed body corrupts.
describe('encode/decodeHtmlEmbedSource', () => {
it('round-trips ASCII HTML losslessly', () => {
const src = '<b>hi</b>';
expect(decodeHtmlEmbedSource(encodeHtmlEmbedSource(src))).toBe(src);
});
it('round-trips multi-byte UTF-8 (Cyrillic + emoji) losslessly', () => {
const src = '<p>Привет, мир 🌍 — café</p>';
const encoded = encodeHtmlEmbedSource(src);
// It is actually encoded (not passed through verbatim).
expect(encoded).not.toBe(src);
expect(decodeHtmlEmbedSource(encoded)).toBe(src);
});
it('maps empty string to empty string both ways', () => {
expect(encodeHtmlEmbedSource('')).toBe('');
expect(decodeHtmlEmbedSource('')).toBe('');
});
});