Repository navigation
Add the extension-mermaid playground and clean-room Cypress suite #525
Description
Part of #521. Depends on #524. Read the tracker first: its §Shared contract binds this issue. Work on main.
Summary
The clean-room playground on port 5178 and four Cypress specs against built dist/. Each case names one failure mode. Every sabotage proof for the package lives here.
Rulings: 10, 14 (#521 §Maintainer rulings).
Current behaviour
- Port 5178 is free:
scripts/run-tests.sh:25lists 5173 to 5177 (read from code). extensions/extension-inline-codeholds the referencecypress.config.ts, support file and playground (read from code).
Files and interfaces
Files (under PKG)
- Create:
cypress.config.ts(inline-code copy, withbaseUrl: 'http://127.0.0.1:5178'),cypress/tsconfig.jsonandtest/playground/tsconfig.json(verbatim copies). - Create:
test/playground/main.ts,cypress/support/e2e.ts,cypress/e2e/render-states.cy.ts,cypress/e2e/insert-command.cy.ts,cypress/e2e/markdown-round-trip.cy.ts,cypress/e2e/html-round-trip.cy.ts,cypress/e2e/fold.cy.ts,cypress/e2e/README.md.
Interfaces
- Produces (test only): the window globals
_editor,_getMarkdown,_parseMarkdownand_customViews; the query flag?view=custom; the test idscustom-view,custom-label,custom-stateandcustom-diagram.
Fixtures (module-local in each spec)
GOOD = 'flowchart LR\n A[Write] --> B[See it]';BROKEN = GOOD + ' -->'(if Pele accepts it, choose another one-edit break and use it everywhere);SEQ = 'sequenceDiagram\n A->>B: Hi';STARTER([Tracking] Mermaid diagrams in docs.plus through a headless Pele extension #521 §Commands);block(src)='<pre><code class="language-mermaid">' + src + '</code></pre>';VIEW = '.mermaid-diagram > [data-state]'.
Authoring rules
- Assert computed DOM,
data-stateand editor state. Assert hidden things withshould('not.be.visible'). - Every case starts with
cy.visitPlayground(), so each starts with a fresh node view. Cases on the update path change text in place withreplaceSource, save the root element first, and assertroot.isConnectedat the end. - One clock per test. Timing cases reach
readywith real timers, then callcy.clock(). - No
realType,realPressoruncaught:exceptionhandler. An escaped rejection must fail the test.
Implementation steps
-
Step 1: Copy the three config files.
grep -n 5178 PKG/cypress.config.tsprints one line. -
Step 2: Write
test/playground/main.ts. Use package imports only:styles.css,Mermaidand its view types,setupPlaygroundfrom@docs.plus/playground/setup,Editorfrom@tiptap/core,Markdownfrom@tiptap/markdownandStarterKitfrom@tiptap/starter-kit.setupPlayground({ title: '@docs.plus/extension-mermaid — clean-room playground', github: 'extension-mermaid' }).- A module-local
createCustomView(context):domisdiv[data-testid="custom-view"]holdingcustom-label("Custom view"),custom-stateandcustom-diagram(diagram).updatesets only thecustom-statetext.destroysetsdom.dataset.destroyed = 'true'. It pushes each view ontowindow._customViews. It sets no inline style. new Editor({ element, extensions: [StarterKit, Markdown.configure(), Mermaid.configure(params.get('view') === 'custom' ? { view: createCustomView } : params.has('bare') ? { fold: false, helpLink: null, copy: false } : {})] }). With?readonly, calleditor.setEditable(false). Assign the four window globals.- Check:
bun run build && bun run playgroundinPKGprintsPlayground listening on http://127.0.0.1:5178/.
-
Step 3: Write
cypress/support/e2e.tsin the inline-code shape: one flat file withvisitPlayground(query?),getEditor()andsetEditorContent(html)throughCypress.Commands.add, plusregisterPlaygroundMarkdownCommands. It importstype { MermaidView }from the package, which also loads theinsertMermaidaugmentation. Do not importcypress-real-events. -
Step 4: Discover the load-error path, by hand. Run the playground with the DevTools Network panel open, and run
_editor.commands.insertMermaid(). Record whetherpele/lazyand the flowchart chunk load as separate requests, and a URL glob that survives a rebuild. Then block thepele/lazyentry request, reload, insert, and confirm "Could not load the diagram renderer.". Unblock it, click Try again, and record whether it draws.- Result A (separate request; Try again recovers): case 5, S4 and S5 run as written.
- Result B (separate request; the browser reuses the failed import): case 5 asserts only that Try again while still failing keeps
load-error. S4 runs. S5 and the recovery proof move to Run the Mermaid browser pass and security probes #529 rowoffline-prod(a production build). - Result C (no separate request): drop case 5, S4 and S5. Run the Mermaid browser pass and security probes #529 rows
offlineandoffline-prodare the only proof (R7).
-
Step 5: Write
cypress/e2e/render-states.cy.ts. Its one helper changes the first mermaid block's text in place, so the node view takes the update path:function replaceSource(text: string): void { cy.getEditor().then((editor) => { let from = -1, to = -1 editor.state.doc.descendants((n, pos) => { if (from < 0 && n.type.name === 'codeBlock' && n.attrs.language === 'mermaid') { from = pos + 1; to = pos + n.nodeSize - 1 } return from < 0 }) editor.chain() .command(({ tr }) => { if (text) tr.replaceWith(from, to, editor.schema.text(text)); else tr.delete(from, to); return true }) .setTextSelection(from + text.length) .run() }) }
Never use
tr.insertText(''): it callsdeleteRange, which can remove the whole block.Cases, in
describe('mermaid — render states'):it('draws a diagram under the source'):'<p>Intro</p>' + block(GOOD).VIEWhasdata-state="ready"andcontenteditable="false".svg.pele.pele-flowcharthasaria-label="flowchart diagram"and computed opacity1. The error line and retry button are not visible. The sourcepreis visible and holdsGOOD.it('shows the error at once when a broken diagram loads'):block(BROKEN).data-state="error". The message matches/^(Line \d+, column \d+: this line has a mistake\.|This diagram has a mistake\.)$/, and the detail is not empty. Nomermaid-diagram:rendering:key is left in sessionStorage.#editorholds no[aria-live],[role=status],[role=alert]oroutput.it('holds an error until 1000 ms after the last edit, keeping the last diagram undimmed'): reachreadyonGOOD, callcy.clock(), thenreplaceSource(BROKEN)with the caret inside. Atcy.tick(300)and again atcy.tick(600):div.pele-errorhas computeddisplay: none,svg.peleopacity is1,data-stateisready, the error line is not visible, and the diagram host has noaria-describedby. Atcy.tick(100):data-state="error", the message is visible, the opacity is0.4, andaria-describedbyequals the error lineid.it('draws the new type after the source changes diagram type'): fromreadyonGOOD,replaceSource(SEQ)givessvg.pele.pele-sequencewitharia-label="sequence diagram"and no flowchart SVG.replaceSource(GOOD)gives the flowchart back atready.it('shows load-error with Try again when the Pele chunk fails')(Results A and B). The order is: clear the cache, add the intercept, visit,setEditorContent(block(GOOD)),cy.wait('@peleLazy'), then assert. Before the visit, runcy.then(() => Cypress.automation('remote:debugger:protocol', { command: 'Network.clearBrowserCache' })), because earlier cases cached the chunk. Thenlet fail = true; cy.intercept(<glob>, (req) => { if (fail) req.destroy() }).as('peleLazy').data-state="load-error", the message "Could not load the diagram renderer." and a visible "Try again". Thency.then(() => { fail = false })and click Try again:ready, withsvg.pele, and the button hidden. Under Result B, click Try again whilefailis still true instead:data-statestaysload-error.it('mounts into a custom view and leaves the rest of view.dom alone'):?view=customandblock(GOOD).custom-statereadsready, andcustom-diagram svg.peleexists.custom-diagramhas computedoverflow: hidden, and itscontainincludespaint. No.mermaid-diagram__previewexists.replaceSource('')givesempty, an emptycustom-diagram, and an unchangedcustom-label.replaceSource(GOOD)givesready._customViews.length === 1. AftersetEditorContent('<p>gone</p>'), each view hasdataset.destroyed === 'true', an emptydiagram, and its label.it('shows a limit error for a source over the default character limit'): build a source fromGOODplus%%comment lines, longer than thecharactersdefault and shorter than 50000. Drop this case if the default is 45000 or more.data-state="error", the message "This diagram is too large to draw.", and the detail matches/longer than the limit of \d+ characters/.
-
Step 6: Write
cypress/e2e/insert-command.cy.ts. Failure mode: thefrom - 2caret fix breaks on a Tiptap bump. Each case sets the caret, assertseditor.commands.insertMermaid()istrue, then asserts on the selection:empty, the parent is acodeBlockwithlanguage === 'mermaid'and textSTARTER, and$from.pos === $from.end().it('puts the caret at the end of the source when inserted mid-paragraph'):<p>Hello world</p>, pos 6. The doc isHello, the diagram, thenworld.it('puts the caret at the end of the source when inserted at the start of a paragraph'):<p>Hello world</p>, pos 1. The doc is the diagram, thenHello world.it('refuses to insert inside a code block'):<pre><code class="language-js">const a = 1</code></pre>, pos 2.can().insertMermaid()andinsertMermaid()are both false, and the JSON is unchanged.
-
Step 7: Write
cypress/e2e/markdown-round-trip.cy.ts. Failure mode: a fence is lost or rewritten, or detection grows past the exactmermaid(D3). One helper,expectRoundTrip(), saves the JSON, exports Markdown, imports it again, and deep-compares. For mermaid fixtures, wait fordata-stateto match/^(ready|error|empty)$/first.- A
```mermaidfence stays a diagram, and the export holds the same fence. - A
~~~mermaidfence stays a diagram, and exports as```. - A
```Mermaidfence stays plain code, with no.mermaid-diagram. - An empty mermaid fence stays empty, at
data-state="empty". - An invalid source survives byte for byte, at
data-state="error".
- A
-
Step 8: Write
cypress/e2e/html-round-trip.cy.ts. One case,it('round-trips through getHTML with no SVG'): atready,getHTML()holds no<svgand holds<code class="language-mermaid">.setContent(html)gives JSON deep-equal to before. -
Step 8b: Write
cypress/e2e/fold.cy.ts. Failure mode: hidden source holds the caret, or the fold is lost or leaks. Clear localStorage before each case.it('folds the source and remembers it after a reload'): onblock(GOOD),.focus().click().mermaid-diagram__fold. The actions take no clicks until hover or focus, and Cypress cannot hover. The root hasdata-source="folded",preis not visible, and the summary reads "Mermaid source · 2 lines hidden". Reload and set the same content: still folded.it('opens a folded block when the caret enters it, without remembering'): fold it, thensetTextSelectioninside the source.data-source="open"andpreis visible. Reload: folded again (the auto-open was not stored).it('starts folded in a read-only editor'):?readonlyandblock(GOOD)givedata-source="folded"; the Syntax link has the defaulthrefandtarget="_blank".it('shows no head with fold, copy and helpLink turned off'):?baregives no.mermaid-diagram__head.it('copies the whole source and confirms it'): stubwin.navigator.clipboard.writeTextto resolve..focus().click().mermaid-diagram__copy. The stub got exactlyGOOD, and the button'saria-labelreads "Copied". Aftercy.tick(2200)(withcy.clock()started afterready), it reads "Copy Mermaid source" again.
-
Step 9: Write
cypress/e2e/README.mdin the inline-code shape: one paragraph (playground, port 5178, builtdist/,?view=custom) and aSpec | What it provestable. -
Step 10: Run the suite. In
PKG:bun run test. Expected: the five specs run, and the run exits 0, and its output holdsAll specs passed!. Fix a failing case insrc/, never by weakening the case. A fix that changes a design ([Tracking] Mermaid diagrams in docs.plus through a headless Pele extension #521 §Design) value is a design change: stop and report. -
Step 11: Prove the specs by sabotage. Use the sabotage form. The run command from
PKGisbun run build && bunx start-server-and-test playground http://127.0.0.1:5178 'bunx cypress run --spec cypress/e2e/<spec>'.# File Sabotage Must fail S1 src/mermaid.tsDelete the from - 2statement.insert-commandcases 1 and 2 (case 3 passes)S2 src/nodeView/createMermaidNodeView.tsMake the errorbranch publish at once.render-statescase 3S3 src/nodeView/createMermaidNodeView.tsCall prepareonly when the renderer was just created.render-statescase 4S4 src/renderer/createRenderer.tsMake preparerethrow the import rejection.render-statescase 5, with an unhandled rejection (Results A and B)S6 src/nodeView/createMermaidNodeView.tsRemove the open-on-caret-enter check. foldcase 2S5 src/renderer/createRenderer.tsKeep the rejected import('pele/lazy')promise in the module cache.render-statescase 5 at the Try again assertion (Result A). Under Result B, S5 runs in Run the Mermaid browser pass and security probes #529 Step 5 againstoffline-prod.After the last restore, run
bun run testinPKG. Expected:All specs passed!. -
Step 12: Root checks, then the Review checkpoint. Record the Step 4 result and glob, and each sabotage outcome with its
cmpexit code. Record the case 7 source length, or that case 7 was dropped.
Decisions
- R7. Under Result B or C, may Run the Mermaid browser pass and security probes #529 row
offline-prod(a production build) carry the Try again proof? Recommended: yes. Dev runs Turbopack, which never retries a failed chunk, so only a production build can prove recovery.
Acceptance criteria
bun run testexits 0 withAll specs passed!in its output.- Every sabotage in Step 11 that the Step 4 result allows fails its named case, and every restore passes
cmp.
Activity
HMarzban commentedon Oct 19, 2026
Maintainer ruling, 2026-10-19
realPressfromcypress-real-eventsstays infold.cy.ts, as a deliberate exception to Step 5. Only a real key press proves the browser caret path. R7 is confirmed: the #529offline-prodrow carries the Try again proof.The work is in the maintainer's working tree and is not committed or pushed yet.
feat(extension-mermaid): add the Mermaid diagram extension
HMarzban commentedon Oct 19, 2026
realPresssabotage proof, 2026-10-19fold.cy.tspassed 9 of 9.keydownlistener stopped ArrowDown and ArrowUp before ProseMirror saw them, while the native caret still moved.data-sourcecheck aftercy.realPress('ArrowDown'), withfoldedwhereopenwas expected.cmpshowed the file identical, and it passed 9 of 9 again.This proves the real key path. The native caret alone never opens a folded block. This covers the ArrowDown half; an ArrowUp-only run would add the other half.