Skip to content

Determine whether the Markdown corpus can drive automated round-trip tests #135

Description

@Azganoth

Question

Does the editor converge on a stable serialization for the corpus subtrees whose syntax Leafdown supports, and is that property worth asserting automatically?

Convergence means serialize(parse(source)) and a second pass over that result produce the same string. The editor is allowed to normalize on first open; it must not keep changing the document afterwards.

Context

src/AGENTS.md requires preserving Markdown round-trip behavior when schemas, parsing, serialization, clipboard handling, or document transformations change. Today that requirement is enforced by about 30 hand-written cases in src/features/editor/tests/markdownCompatibility.test.tsx.

The corpus/ directory holds 70 committed Markdown files maintained against CommonMark 0.31.2 and the GFM specification, but nothing reads it. CONTRIBUTING.md directs contributors to open it manually. The source-projection and serialization cluster is about 4,800 source lines and is the most intricate part of the codebase, so a silent round-trip regression there is currently caught only when a human opens the app.

Related context

Validate

Prototype the test and count failures. The design and its supporting measurements:

  • Assert convergence, not byte identity. Nine of the fifteen candidate files contain a construct the serializer normalizes: - and + bullets, non-*** thematic breaks, underscore emphasis, indented code, reference definitions, raw HTML blocks, and trailing-two-space hard breaks. A strict-identity assertion would therefore need a per-file expected output, which is a generated baseline in all but name and would be rubber-stamped on review. Convergence needs no baseline and no deviation list.
  • Scope to supported syntax: commonmark/ (7 files), gfm/ (5), and isolated/end-of-file/ (3). The last three are the highest-value fixtures in the corpus, covering an unclosed code fence, an unclosed HTML comment, and an unclosed directive at end of file.
  • Cost is measured, not estimated. markdownCompatibility.test.tsx runs about 24 editor mounts in 1.17s of tests phase, roughly 50ms per mount. Fifteen files at two mounts each is about 1.5s, plus the roughly 4.8s editor import any editor test file already pays.
  • Existing infrastructure covers it: setupMilkdownEditorMount, getMarkdown, createMarkdownReferenceContext, and mockTauriApiCommand. Reading corpus files from disk is precedented by tests/editorPresentation.test.tsx, which reads MilkdownEditor.css. .gitattributes normalizes every file outside corpus/boundaries/bytes/** to LF, so the loader needs no line-ending handling.
  • Two of the fifteen files reference images and need mockTauriApiCommand("resolveMarkdownImageTarget", ...), or the resolver produces noise instead of a clean failure.

Interpreting the result:

  • No failures: ship it as a permanent regression guard.
  • A few failures: each is a finding, either a serializer bug worth its own issue or a deviation that earns a documented exception.
  • Most failures: the editor does not converge, which is a larger finding than this test, and the test waits behind fixing that.

Exit criteria

  • The question is answered.
  • The outcome below records the conclusion, tradeoffs, and supporting evidence links.
  • Follow-up issues are created only for selected work.
  • Owning documentation updates are identified when the outcome changes durable direction.

Outcome

Run against main at 07f5893, 16 files, two mounts each: 13 converge, 3 do not.
That lands in the "a few failures" band. The three failures are one serializer bug,
not three, and the test is worth keeping once that bug is fixed.

The failure

commonmark/lists-and-blockquotes.md, commonmark/text-and-breaks.md, and
gfm/tables.md fail with the same shape. When a list item's first child is a
non-paragraph block, the serializer has no text for the marker line and emits a
bare marker, a blank line, and then the indented block:

- ```          serializes to      *
  code                            
  ```                               ```
                                    code
                                    ```

Per CommonMark 0.31.2 an item may begin with at most one blank line, so that output
reparses as an empty list item plus a detached top-level block. The second pass
then serializes the block at column zero and the nesting is gone.

Confirmed against micromark 4.0.2 as an independent reference:

  • *\n\n + fence → <ul><li></li></ul> followed by a sibling <pre>
  • *\n + fence (no blank line) → <ul><li><pre> — the block stays in the item
  • * + fence on the marker line → the block stays in the item

So the parser is correct and the serializer is at fault. Dropping the blank line
after a bare marker fixes it; putting the first child on the marker line also fixes it.

The bug is wider than the corpus shows

The corpus exercises three cases. A minimal sweep shows every non-paragraph first
child loses its nesting on the second open, ordered and unordered alike:

First child of the list item Converges
Paragraph yes
Fenced code no
Indented code no
Table no
Blockquote no
Nested list no
Heading no
Thematic break no

A non-paragraph block in any later position converges — the trigger is specifically
the first child. 1. + fence fails identically, so it is not marker-type specific.

This is reachable data loss, not a test artifact: the save path writes
getMilkdownEditorMarkdown output verbatim (src/features/editor/hooks/useMilkdownEditorInstance.ts:109),
so one open-edit-save destroys the nesting on disk and the loss surfaces on the next
open. Nothing in markdownCompatibility.test.tsx covers a list item with a
non-paragraph first child, which is why 4,800 lines of projection and serialization
carried this uncaught.

Tradeoffs

  • Convergence over byte identity works, but only as an interim. See the measured
    identity gap below. The design absorbed the normalization it was chosen to absorb
    without a single baseline file, but "no baseline needed" turned out to be a weaker
    justification than the analysis assumed, because identity is not merely
    inconvenient to assert today — it is far out of reach.
  • Convergence is a weak assertion by construction. It caught a structural loss
    here, but it cannot catch a normalization that is stable and wrong. **one /
    ** collapsing to escaped literal text converges cleanly, as predicted in the
    comment above. A green run means "stops changing", never "changes correctly".
  • Cost matched the estimate. 7.82s wall for the file: 1.59s tests phase, 3.92s
    import, the rest transform and environment. Two mounts of sixteen files cost about
    what twenty-four mounts cost in markdownCompatibility.test.tsx.
  • No new infrastructure. setupMilkdownEditorMount, createMarkdownReferenceContext,
    and mockTauriApiCommand covered it; disk reads follow editorPresentation.test.tsx.
    The whole prototype is 48 lines.

The identity gap, measured

Added after the fact, prompted by the fair question of why the editor normalizes at
all rather than saving the corpus unchanged. The analysis above claimed nine of
fifteen files "contain a construct the serializer normalizes". Measured on the
scoped set: 12 of 16 differ from source on first open, several by around 100
lines. Three distinct causes, and the listed constructs are not the main one:

  1. Escaping applied out of context — dominant by volume. The serializer escapes
    characters that would not parse as syntax where they sit: garden_sensor_name
    garden\_sensor\_name (intraword _ is never emphasis in CommonMark),
    *opening-only\*opening-only (an unpaired * is already literal),
    name@examplename\@example, www.example_.comwww\.example\_.com.
    Not forced by the document model. This is How a Markdown escape should be represented in the editor #245's subject.
  2. Delimiter form the model does not retain- bullets → *, ~strike~
    ~~strike~~, _em_*em*. Fixable per construct, as feat: preserve the authored autolink form on save #243 did for autolinks.
  3. Structural spelling the model does not retain — lazy blockquote continuation
    gains an explicit >; tight lists are written loose.

The 4 identical files (gfm/tagfilter.md and the three isolated/end-of-file/
fixtures) are identical because they hold almost no inline constructs to escape, not
because anything preserves authored form. So there is no identity-holding subset
worth pinning, and no useful hybrid assertion between identity and convergence.

This does not invalidate the test — convergence still caught #247 — but it does
change its standing. Byte identity is the property actually worth wanting, the
project is already moving toward it (#243), and convergence is the interim that fits
while #245 is open. A green run must not be read as "the corpus saves unchanged".

Inspected samples all preserved meaning while changing text; that was not checked
across all 12 files and is not claimed.

Corrections to the analysis above

  • isolated/end-of-file/ holds 4 files, not 3. unclosed-html-block.md was
    missing from the count; it converges. Scoped total is 16, not 15.
  • One scoped file references images, not two — only commonmark/links-and-images.md.
    The resolveMarkdownImageTarget mock is still required, for that one file.
  • Nine of fifteen normalizing understates the gap. It is 12 of 16, and the
    enumerated constructs are not the dominant driver.

Rejected along the way

  • Per-file expected output. Rejected before the run for the reason recorded above,
    and the run confirmed it: the three failures are one bug, and a baseline would have
    frozen the broken output of all three as expected instead of surfacing the cause.
  • Landing the test with the three files skipped. A permanent guard shipping with
    three skips is rot at birth, and the skips would outlive the memory of why.
  • Widening the spike to practical/ or extensions/ once failures appeared.
    Out of scope above, and the failures were already one root cause; more files would
    have added fixture wrangling, not evidence.

Recommended follow-up

  1. Fix the serializer first — filed as A list item's non-paragraph first child loses its nesting on save #247. A list item whose first child is a
    non-paragraph block loses its nesting on save. Reachable data loss, wider than the
    three corpus files, and the table above is the acceptance surface.
  2. Then land the convergence test green over all 16 files, not before. It is the
    regression guard that proves the fix and keeps it fixed. Landed in test: assert corpus round-trip convergence for supported syntax #249, framed
    there as an interim assertion rather than a settled one.
  3. CONTRIBUTING.md:134 points contributors at corpus/ for manual testing. Once
    the test lands, note that commonmark/, gfm/, and isolated/end-of-file/ carry
    automated convergence coverage and the manual pass is for rendering, interaction,
    and navigator behavior. The out-of-scope subtrees stay fully manual.

Residual uncertainty

  • The fix shape is not decided. Dropping the blank line and hoisting the first
    child onto the marker line both satisfy CommonMark; which one the Milkdown
    serializer can express cleanly was not investigated. That is the fix's design work,
    deliberately not started here.
  • Convergence beyond pass two is assumed, not proven. The prototype asserts
    pass 1 equals pass 2. The minimal sweep ran a third pass and every case was stable
    by then, but the shipped test only checks the first two.
  • Third-pass stability of the failing cases is a red herring worth naming. All
    three settle by pass three — into the wrong document. Stability is not correctness.
  • Nothing here covers typing, undo, or clipboard. The scope check in the comment
    above stands: this is a guard for the change How a Markdown escape should be represented in the editor #245 produces, not a net for the open
    editing-surface defects.
  • Tight lists are serialized loose — settled after the fact and filed as Tight bullet lists are saved as loose lists #250.
    - A\n- B becomes * A\n\n* B, which changes rendered output (<li>A</li> versus
    <li><p>A</p></li>). The schema does carry tightness; it is lost on serialization,
    where bullet_list and list_item forward a string attribute into a boolean field
    and "false" is truthy. Upstream defect in @milkdown/preset-commonmark@7.21.3,
    fixable here by local schema override. Cause 3 in the identity gap above is therefore
    partly a defect rather than a model limitation.

Prototype

Deleted after the run rather than committed, since implementation was out of scope
for this spike. Reproduce with src/features/editor/tests/corpusRoundTrip.spike.test.tsx:

import { readFileSync } from "node:fs";
import { resolve } from "node:path";

import { beforeEach, describe, expect, it } from "vitest";

import { createMarkdownReferenceContext } from "@/test/factories/editor";
import { setupMilkdownEditorMount } from "@/test/utils/milkdown";
import { mockTauriApiCommand } from "@/test/utils/tauriApi";

const mountEditor = setupMilkdownEditorMount(createMarkdownReferenceContext());

const corpusFiles = [
  "commonmark/blocks.md",
  "commonmark/code.md",
  "commonmark/emphasis.md",
  "commonmark/html.md",
  "commonmark/links-and-images.md",
  "commonmark/lists-and-blockquotes.md",
  "commonmark/text-and-breaks.md",
  "gfm/autolinks.md",
  "gfm/strikethrough.md",
  "gfm/tables.md",
  "gfm/tagfilter.md",
  "gfm/task-lists.md",
  "isolated/end-of-file/incomplete-html-comment.md",
  "isolated/end-of-file/unclosed-code-fence.md",
  "isolated/end-of-file/unclosed-directive.md",
  "isolated/end-of-file/unclosed-html-block.md",
];

const readCorpusFile = (relativePath: string) =>
  readFileSync(resolve(process.cwd(), "corpus", relativePath), "utf8");

describe("corpus round trip", () => {
  beforeEach(() => {
    mockTauriApiCommand("resolveMarkdownImageTarget", ({ target }) => ({
      kind: "renderable",
      path: `C:/Notes/${target}`,
    }));
  });

  it.each(corpusFiles)("converges on a stable serialization for %s", async (relativePath) => {
    const source = readCorpusFile(relativePath);

    const first = (await mountEditor(source)).getMarkdown();
    const second = (await mountEditor(first)).getMarkdown();

    expect(second).toBe(first);
  });
});

Notes

Out of scope

  • corpus/boundaries/bytes/ (13 files). These exist to preserve CR, CRLF, BOM, NUL, and a missing final newline exactly, and reading plus serializing normalizes precisely what they are there to pin. They stay manual.
  • corpus/environment/ (11 files). The subject is folder shape, supported extensions, and local resource resolution, which needs the Rust backend. The frontend already covers this with the createArticleTree and createFolderContext factories.
  • corpus/extensions/ (18 files). Automating these mostly asserts that unsupported syntax survives as escaped literal text, a property already covered by three cases in markdownCompatibility.test.tsx.
  • corpus/practical/ initially. Those files carry local image and link references needing per-path resolution mocks, which turns the test into fixture wrangling.
  • Rendering, interaction, and navigator behavior. Convergence says nothing about them, so the manual corpus pass still happens; this only shrinks it.

Metadata

Metadata

Assignees

Labels

SpikeInvestigation needed before committing to implementation

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions