Three more git-sync QA defects from the 2nd live pass on PR #119, plus a callout-fidelity nit: 1. SPURIOUS conflict leaked raw markers into canonical main (root cause). On an ordinary round-trip the only difference between the docmost mirror (normalize- on-write) and a user's raw push is trailing/empty-line normalization, which made git's line-based docmost->main merge CONFLICT, and the wedge fix then committed the file WITH literal <<<<<<< / ======= / >>>>>>> markers onto main (git and the DB silently diverged for cycles). Fix: on a conflict, normalize trailing/empty lines on BOTH sides (showStage :2:/:3:) before comparing — a trailing-only diff is recognized as spurious and resolved to the clean normalized form. A GENUINE same-block conflict is auto-resolved to OURS (git wins, mirroring the live-doc 3-way rule); the docmost side stays on the `docmost` branch + page history. Raw markers NEVER reach main again. 2. Concurrent UI<->git edit silently lost the UI side. The git->Docmost 3-way merge ran against a live Y.Doc that hadn't yet received the user's debounced in-flight edit, so git clean-applied (no conflict detected) and the edit vanished even on a different block. Fix: flush the pending debounced store before the merge so the in-flight edit is drained into the live doc first — a different-block edit is merged, a same-block one is detected and pinned to history (recoverable). 3. Smart-HTTP HEAD flapped to the read-only `docmost` mirror (~1/4 of clones). The engine transiently checks out `docmost` mid-pull and the host advertises whatever HEAD resolves to. Fix: VaultGit.pinHeadToMain(); the cycle restores HEAD->main in a finally; and the upload-pack ref advertisement is served HEAD-pinned under the per-space lock so it can never observe a mid-cycle HEAD. 4. (callout) clampCalloutType now mirrors the editor's GITHUB_ALERT_TYPE_MAP for non-schema aliases (tip->success, caution->danger, important->info) instead of flatly collapsing to info. The editor schema genuinely supports only the six banner types, so unknown types still fall back to info (by design). Tests: deterministic real-git trailing-blank round-trip (no conflict, no markers, in sync over 2 cycles) + genuine-conflict no-marker-leak; HEAD advertisement stability; pre/post-flush concurrent-edit survival; serveReadAdvertisement lock pin; widened callout-alias coverage. Engine vitest + server tsc + collaboration / git-http / orchestrator specs all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
4.8 KiB
TypeScript
125 lines
4.8 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('maps GitHub/Obsidian alert ALIASES to the editor banner (not flatly info)', () => {
|
|
// The editor schema has no tip/caution/important callout node — they are input
|
|
// aliases the editor's own paste path maps onto the supported set
|
|
// (GITHUB_ALERT_TYPE_MAP in editor-ext). git-sync mirrors that aliasing so an
|
|
// ingested `> [!tip]` / `> [!caution]` lands on the closest real banner instead
|
|
// of collapsing everything to `info`.
|
|
expect(clampCalloutType('tip')).toBe('success');
|
|
expect(clampCalloutType('TIP')).toBe('success');
|
|
expect(clampCalloutType('caution')).toBe('danger');
|
|
expect(clampCalloutType('important')).toBe('info');
|
|
});
|
|
|
|
it('falls back to "info" for genuinely unknown types', () => {
|
|
expect(clampCalloutType('question')).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('');
|
|
});
|
|
});
|