fix(git-sync): close #119 blockers — dead edit-revert guard, cross-space guard, red suite (F5/S2/G1/A1/F7)
F5 (HIGH data-loss): guard #2 (GS-EDIT-REVERT) called a local key-sorting equality that never matched a real page (block ids + materialized defaults differ), so the guard was dead and a web edit on a git-sync space was silently reverted within one poll cycle. Use the package's authoritative docsCanonicallyEqual (strips block id + normalizes KNOWN_DEFAULTS), wired through the git-sync loader like sanitizeTitle; delete the dead local canonicalize/canonicalJsonEqual. S2 (security): importPageMarkdown targeted a page by the vault-file id without a spaceId check (deletePage had one) — a space-A vault file carrying space-B's page id could resurrect/overwrite/clear B's page. Mirror deletePage's guard: skip when the loaded page lives in a different space than ctx.spaceId. G1 (jest green): add sanitizeTitle + docsCanonicallyEqual to the loadGitSync mock; update the converter-gate + package golden expectations to the genuinely-fixed output (paragraph textAlign now round-trips, multi-block table cells emit HTML tables); fix the orchestrator spec's stale mock so the per-space enabled gate (added later) is satisfied. A1: the converter dropped heading textAlign on export (bare '## text'); emit a styled <hN> when aligned, symmetric to paragraphs — round-trips losslessly (level + align), no churn for unaligned headings. F7 (docs): reword the false 'single choke point' title-strip comment; correct push.ts docstrings that still described the removed standalone-CLI/daemon model. Adds regression tests: the F5 acceptance test (canonically-equal content with real uuids => writePageBody NOT called), the S2 cross-space import guard, and the A1 heading round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -256,9 +256,11 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
||||
expect(out).toBe('> ```js\n> a\n> b\n> ```');
|
||||
});
|
||||
|
||||
// 4. A GFM body cell with TWO block children (paragraph + bulletList): joined
|
||||
// by a space, the list's newline collapsed so the row stays intact.
|
||||
it('a GFM body cell with paragraph+list joins them by a space (no "p1- a")', () => {
|
||||
// 4. A body cell with TWO block children (paragraph + bulletList) cannot be a
|
||||
// GFM pipe row (inline-only). #8 emits the WHOLE table as HTML <table> so
|
||||
// the paragraph and the list each survive as their own block instead of
|
||||
// being lossily flattened into one "p1 - a" pipe cell.
|
||||
it('a table cell with paragraph+list emits an HTML <table> (blocks preserved)', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'table',
|
||||
@@ -285,7 +287,9 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('| h |\n| --- |\n| p1 - a |');
|
||||
expect(out).toBe(
|
||||
'<table><tbody><tr><th><p>h</p></th></tr><tr><td><p>p1</p><ul><li><p>a</p></li></ul></td></tr></tbody></table>',
|
||||
);
|
||||
});
|
||||
|
||||
// 5. code + link co-occur: the schema's `code` mark excludes all other marks
|
||||
@@ -391,8 +395,11 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
||||
expect(out).toBe('> - x\n> - y');
|
||||
});
|
||||
|
||||
// 11. GFM (non-spanned) cell: multi-block space-join + pipe-escape + newline-collapse.
|
||||
it('a GFM cell escapes a literal pipe and collapses newlines across two paragraphs', () => {
|
||||
// 11. A non-spanned cell with TWO block paragraphs: #8 emits the whole table
|
||||
// as HTML <table>, so each paragraph stays its own <p> and the literal
|
||||
// pipe needs no escaping inside HTML text (the old GFM path space-joined
|
||||
// the blocks into one line and escaped the pipe to \|).
|
||||
it('a table cell with two paragraphs emits an HTML <table> (blocks kept, no pipe-escape)', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'table',
|
||||
@@ -413,7 +420,9 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('| h |\n| --- |\n| a\\|b c |');
|
||||
expect(out).toBe(
|
||||
'<table><tbody><tr><th><p>h</p></th></tr><tr><td><p>a|b</p><p>c</p></td></tr></tbody></table>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -775,3 +784,62 @@ describe('converter gap coverage — raw-HTML container round-trips (specs 15–
|
||||
expect(md1).not.toContain('$x_i$');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// 30. heading.textAlign round-trip (A1). The paragraph case already exports a
|
||||
// non-default alignment as a styled `<p style="text-align:…">` that re-parses
|
||||
// losslessly; headings used to emit only the bare `## text` form, silently
|
||||
// DROPPING textAlign on export. The heading case is now symmetric: an aligned
|
||||
// heading exports as `<hN style="text-align:…">` and re-parses back to a heading
|
||||
// carrying BOTH the level and the textAlign, so the round-trip is lossless; an
|
||||
// UNaligned heading still emits the bare `## text` markdown form (no churn).
|
||||
// ===========================================================================
|
||||
const alignedHeading = (level: number, align: string, ...inline: any[]) => ({
|
||||
type: 'heading',
|
||||
attrs: { level, textAlign: align },
|
||||
content: inline,
|
||||
});
|
||||
|
||||
describe('heading.textAlign round-trip (A1)', () => {
|
||||
it('an aligned heading exports as <hN style="text-align:…"> (not bare ##)', () => {
|
||||
expect(convertProseMirrorToMarkdown(doc(alignedHeading(2, 'center', text('Title'))))).toBe(
|
||||
'<h2 style="text-align:center">Title</h2>',
|
||||
);
|
||||
});
|
||||
|
||||
it('survives export -> import -> export losslessly (level AND textAlign preserved)', async () => {
|
||||
const input = alignedHeading(2, 'center', text('Title'));
|
||||
const { md1, doc2, md2 } = await roundTrip(input);
|
||||
// Export direction: a styled <hN>, injection-safe via escapeAttr.
|
||||
expect(md1).toBe('<h2 style="text-align:center">Title</h2>');
|
||||
// Import direction: re-parses to a heading node with the level AND textAlign
|
||||
// (the raw <hN style> HTML block flows through marked -> generateJSON, where
|
||||
// the heading parse rule matches and the textAlign global attr reads the
|
||||
// style back). Byte-stable second export closes the loop.
|
||||
const h = doc2.content[0];
|
||||
expect(h.type).toBe('heading');
|
||||
expect(h.attrs.level).toBe(2);
|
||||
expect(h.attrs.textAlign).toBe('center');
|
||||
expect(md2).toBe(md1);
|
||||
// Canonical equality of the re-parsed doc against the original input doc.
|
||||
expect(docsCanonicallyEqual(doc2, doc(input))).toBe(true);
|
||||
});
|
||||
|
||||
it('a right-aligned h3 round-trips its level and alignment', async () => {
|
||||
const { doc2 } = await roundTrip(alignedHeading(3, 'right', text('Head')));
|
||||
const h = doc2.content[0];
|
||||
expect(h.type).toBe('heading');
|
||||
expect(h.attrs.level).toBe(3);
|
||||
expect(h.attrs.textAlign).toBe('right');
|
||||
});
|
||||
|
||||
it('an UNaligned heading still emits the bare "## text" form (no HTML churn)', () => {
|
||||
const bare = convertProseMirrorToMarkdown(doc(heading(2, text('Plain'))));
|
||||
expect(bare).toBe('## Plain');
|
||||
expect(bare).not.toContain('<h2');
|
||||
// The default "left" alignment is likewise NOT wrapped.
|
||||
expect(
|
||||
convertProseMirrorToMarkdown(doc(alignedHeading(2, 'left', text('Plain')))),
|
||||
).toBe('## Plain');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,10 +129,16 @@ describe('inline-mark matrix (underline/sub/sup/highlight±color/textStyle/comme
|
||||
});
|
||||
});
|
||||
|
||||
describe('paragraph.textAlign -> <div align>', () => {
|
||||
it('non-default alignment wraps the paragraph in <div align="...">', () => {
|
||||
describe('paragraph.textAlign -> <p style="text-align:...">', () => {
|
||||
it('non-default alignment emits an HTML <p style="text-align:...">', () => {
|
||||
// #7 fix: a non-default paragraph alignment now round-trips. It is exported
|
||||
// as an HTML `<p style="text-align:center">` (the schema's paragraph
|
||||
// parseHTML reads `style="text-align"` back onto `textAlign` on import), so
|
||||
// the alignment survives instead of collapsing to bare text. (The old
|
||||
// `<div align="center">` form was NOT re-parsed onto the paragraph and was
|
||||
// therefore lossy.)
|
||||
expect(c({ type: 'paragraph', attrs: { textAlign: 'center' }, content: [text('x')] })).toBe(
|
||||
'<div align="center">x</div>',
|
||||
'<p style="text-align:center">x</p>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,10 +196,14 @@ describe('escaping idempotence (SPEC §11 phantom-diff guard)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('table-cell sanitization (| and newline must not corrupt the GFM row)', () => {
|
||||
it('escapes a literal pipe and collapses an inter-block newline in a cell', () => {
|
||||
// A cell with a pipe in one paragraph and a second block paragraph: the pipe
|
||||
// is escaped to \| and the block join (a space) keeps the row intact.
|
||||
describe('multi-block table cell -> HTML <table> (#8: GFM pipes cannot hold block content)', () => {
|
||||
it('emits the whole table as HTML <table> so a multi-paragraph cell survives', () => {
|
||||
// A cell holding TWO block paragraphs cannot be represented by a GFM pipe
|
||||
// row (one inline line only) — the old GFM path collapsed the two blocks
|
||||
// into one line ("a\|b c"), losing the block boundary and forcing a fragile
|
||||
// pipe-escape. #8 emits the WHOLE table as raw HTML <table> instead: the
|
||||
// schema's table-family parseHTML round-trips it, each paragraph stays its
|
||||
// own <p>, and the literal pipe needs no escaping inside HTML text.
|
||||
const out = c({
|
||||
type: 'table',
|
||||
content: [
|
||||
@@ -205,7 +215,9 @@ describe('table-cell sanitization (| and newline must not corrupt the GFM row)',
|
||||
]},
|
||||
],
|
||||
});
|
||||
expect(out).toBe('| H |\n| --- |\n| a\\|b c |');
|
||||
expect(out).toBe(
|
||||
'<table><tbody><tr><th><p>H</p></th></tr><tr><td><p>a|b</p><p>c</p></td></tr></tbody></table>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user