Skip to content

Add the extension-mermaid playground and clean-room Cypress suite #525

Closed

Description

@HMarzban

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:25 lists 5173 to 5177 (read from code).
  • extensions/extension-inline-code holds the reference cypress.config.ts, support file and playground (read from code).

Files and interfaces

Files (under PKG)

  • Create: cypress.config.ts (inline-code copy, with baseUrl: 'http://127.0.0.1:5178'), cypress/tsconfig.json and test/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, _parseMarkdown and _customViews; the query flag ?view=custom; the test ids custom-view, custom-label, custom-state and custom-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-state and editor state. Assert hidden things with should('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 with replaceSource, save the root element first, and assert root.isConnected at the end.
  • One clock per test. Timing cases reach ready with real timers, then call cy.clock().
  • No realType, realPress or uncaught:exception handler. An escaped rejection must fail the test.

Implementation steps

  • Step 1: Copy the three config files. grep -n 5178 PKG/cypress.config.ts prints one line.

  • Step 2: Write test/playground/main.ts. Use package imports only: styles.css, Mermaid and its view types, setupPlayground from @docs.plus/playground/setup, Editor from @tiptap/core, Markdown from @tiptap/markdown and StarterKit from @tiptap/starter-kit.

    • setupPlayground({ title: '@docs.plus/extension-mermaid — clean-room playground', github: 'extension-mermaid' }).
    • A module-local createCustomView(context): dom is div[data-testid="custom-view"] holding custom-label ("Custom view"), custom-state and custom-diagram (diagram). update sets only the custom-state text. destroy sets dom.dataset.destroyed = 'true'. It pushes each view onto window._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, call editor.setEditable(false). Assign the four window globals.
    • Check: bun run build && bun run playground in PKG prints Playground listening on http://127.0.0.1:5178/.
  • Step 3: Write cypress/support/e2e.ts in the inline-code shape: one flat file with visitPlayground(query?), getEditor() and setEditorContent(html) through Cypress.Commands.add, plus registerPlaygroundMarkdownCommands. It imports type { MermaidView } from the package, which also loads the insertMermaid augmentation. Do not import cypress-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 whether pele/lazy and the flowchart chunk load as separate requests, and a URL glob that survives a rebuild. Then block the pele/lazy entry request, reload, insert, and confirm "Could not load the diagram renderer.". Unblock it, click Try again, and record whether it draws.

  • 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 calls deleteRange, which can remove the whole block.

    Cases, in describe('mermaid — render states'):

    1. it('draws a diagram under the source'): '<p>Intro</p>' + block(GOOD). VIEW has data-state="ready" and contenteditable="false". svg.pele.pele-flowchart has aria-label="flowchart diagram" and computed opacity 1. The error line and retry button are not visible. The source pre is visible and holds GOOD.
    2. 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. No mermaid-diagram:rendering: key is left in sessionStorage. #editor holds no [aria-live], [role=status], [role=alert] or output.
    3. it('holds an error until 1000 ms after the last edit, keeping the last diagram undimmed'): reach ready on GOOD, call cy.clock(), then replaceSource(BROKEN) with the caret inside. At cy.tick(300) and again at cy.tick(600): div.pele-error has computed display: none, svg.pele opacity is 1, data-state is ready, the error line is not visible, and the diagram host has no aria-describedby. At cy.tick(100): data-state="error", the message is visible, the opacity is 0.4, and aria-describedby equals the error line id.
    4. it('draws the new type after the source changes diagram type'): from ready on GOOD, replaceSource(SEQ) gives svg.pele.pele-sequence with aria-label="sequence diagram" and no flowchart SVG. replaceSource(GOOD) gives the flowchart back at ready.
    5. 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, run cy.then(() => Cypress.automation('remote:debugger:protocol', { command: 'Network.clearBrowserCache' })), because earlier cases cached the chunk. Then let 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". Then cy.then(() => { fail = false }) and click Try again: ready, with svg.pele, and the button hidden. Under Result B, click Try again while fail is still true instead: data-state stays load-error.
    6. it('mounts into a custom view and leaves the rest of view.dom alone'): ?view=custom and block(GOOD). custom-state reads ready, and custom-diagram svg.pele exists. custom-diagram has computed overflow: hidden, and its contain includes paint. No .mermaid-diagram__preview exists. replaceSource('') gives empty, an empty custom-diagram, and an unchanged custom-label. replaceSource(GOOD) gives ready. _customViews.length === 1. After setEditorContent('<p>gone</p>'), each view has dataset.destroyed === 'true', an empty diagram, and its label.
    7. it('shows a limit error for a source over the default character limit'): build a source from GOOD plus %% comment lines, longer than the characters default 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: the from - 2 caret fix breaks on a Tiptap bump. Each case sets the caret, asserts editor.commands.insertMermaid() is true, then asserts on the selection: empty, the parent is a codeBlock with language === 'mermaid' and text STARTER, and $from.pos === $from.end().

    1. it('puts the caret at the end of the source when inserted mid-paragraph'): <p>Hello world</p>, pos 6. The doc is Hello, the diagram, then world.
    2. 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, then Hello world.
    3. it('refuses to insert inside a code block'): <pre><code class="language-js">const a = 1</code></pre>, pos 2. can().insertMermaid() and insertMermaid() 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 exact mermaid (D3). One helper, expectRoundTrip(), saves the JSON, exports Markdown, imports it again, and deep-compares. For mermaid fixtures, wait for data-state to match /^(ready|error|empty)$/ first.

    1. A ```mermaid fence stays a diagram, and the export holds the same fence.
    2. A ~~~mermaid fence stays a diagram, and exports as ```.
    3. A ```Mermaid fence stays plain code, with no .mermaid-diagram.
    4. An empty mermaid fence stays empty, at data-state="empty".
    5. An invalid source survives byte for byte, at data-state="error".
  • Step 8: Write cypress/e2e/html-round-trip.cy.ts. One case, it('round-trips through getHTML with no SVG'): at ready, getHTML() holds no <svg and 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.

    1. it('folds the source and remembers it after a reload'): on block(GOOD), .focus().click() .mermaid-diagram__fold. The actions take no clicks until hover or focus, and Cypress cannot hover. The root has data-source="folded", pre is not visible, and the summary reads "Mermaid source · 2 lines hidden". Reload and set the same content: still folded.
    2. it('opens a folded block when the caret enters it, without remembering'): fold it, then setTextSelection inside the source. data-source="open" and pre is visible. Reload: folded again (the auto-open was not stored).
    3. it('starts folded in a read-only editor'): ?readonly and block(GOOD) give data-source="folded"; the Syntax link has the default href and target="_blank".
    4. it('shows no head with fold, copy and helpLink turned off'): ?bare gives no .mermaid-diagram__head.
    5. it('copies the whole source and confirms it'): stub win.navigator.clipboard.writeText to resolve. .focus().click() .mermaid-diagram__copy. The stub got exactly GOOD, and the button's aria-label reads "Copied". After cy.tick(2200) (with cy.clock() started after ready), it reads "Copy Mermaid source" again.
  • Step 9: Write cypress/e2e/README.md in the inline-code shape: one paragraph (playground, port 5178, built dist/, ?view=custom) and a Spec | What it proves table.

  • Step 10: Run the suite. In PKG: bun run test. Expected: the five specs run, and the run exits 0, and its output holds All specs passed!. Fix a failing case in src/, 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 PKG is bun 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.ts Delete the from - 2 statement. insert-command cases 1 and 2 (case 3 passes)
    S2 src/nodeView/createMermaidNodeView.ts Make the error branch publish at once. render-states case 3
    S3 src/nodeView/createMermaidNodeView.ts Call prepare only when the renderer was just created. render-states case 4
    S4 src/renderer/createRenderer.ts Make prepare rethrow the import rejection. render-states case 5, with an unhandled rejection (Results A and B)
    S6 src/nodeView/createMermaidNodeView.ts Remove the open-on-caret-enter check. fold case 2
    S5 src/renderer/createRenderer.ts Keep the rejected import('pele/lazy') promise in the module cache. render-states case 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 against offline-prod.

    After the last restore, run bun run test in PKG. 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 cmp exit 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 test exits 0 with All 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

  1. HMarzban commented on Oct 19, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-10-19

    realPress from cypress-real-events stays in fold.cy.ts, as a deliberate exception to Step 5. Only a real key press proves the browser caret path. R7 is confirmed: the #529 offline-prod row carries the Try again proof.

    The work is in the maintainer's working tree and is not committed or pushed yet.

  2. HMarzban commented on Oct 19, 2026

    @HMarzban
    CollaboratorAuthor

    realPress sabotage proof, 2026-10-19

    1. Control: the unmodified fold.cy.ts passed 9 of 9.
    2. Sabotage, in the spec only: a capture-phase keydown listener stopped ArrowDown and ArrowUp before ProseMirror saw them, while the native caret still moved.
    3. Result: 8 passed and 1 failed, the case "opens a folded block on a real ArrowDown and ArrowUp press". It timed out at the data-source check after cy.realPress('ArrowDown'), with folded where open was expected.
    4. Restored: cmp showed 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions