// Tests for the minimal Markdown → DOM renderer (src/renderer/md-mini.js). // Two layers: (1) pure parse functions (parseBlocks / parseInline) exercised // directly, and (2) the DOM builder run against a hand-rolled fake `document` // so we can assert node types / textContent WITHOUT a browser — the whole // security claim is "every model char reaches the DOM as a text node", and the // fake document makes that observable. 'use strict' const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const md = require('../src/renderer/md-mini.js') // ---- fake DOM ------------------------------------------------------------ // Minimal enough for md-mini: createElement/createTextNode, appendChild, // textContent (get walks children), className, setAttribute, addEventListener. function makeNode(tag) { const node = { tagName: tag ? tag.toUpperCase() : undefined, nodeType: tag ? 1 : 3, children: [], childNodes: [], attrs: {}, listeners: {}, className: '', _text: '', appendChild(child) { this.childNodes.push(child) if (child.nodeType === 1) this.children.push(child) child.parentNode = this return child }, setAttribute(k, v) { this.attrs[k] = v }, getAttribute(k) { return this.attrs[k] }, addEventListener(ev, fn) { ;(this.listeners[ev] = this.listeners[ev] || []).push(fn) }, set textContent(v) { this._text = String(v) this.childNodes = [] this.children = [] }, get textContent() { if (this.nodeType === 3) return this._text if (this.childNodes.length === 0) return this._text return this.childNodes.map((c) => c.textContent).join('') }, } return node } function fakeDoc() { return { createElement: (tag) => makeNode(tag), createTextNode: (t) => { const n = makeNode(null) n._text = String(t) return n }, } } function render(src, onLink) { return md.render(src, { document: fakeDoc(), onLink }) } // walk helper: collect all descendant element nodes with a given tag function findAll(root, tag) { const out = [] const want = tag.toUpperCase() const walk = (n) => { for (const c of n.childNodes) { if (c.nodeType === 1) { if (c.tagName === want) out.push(c) walk(c) } } } walk(root) return out } // ---- parseBlocks --------------------------------------------------------- test('md-mini: headings h1..h6 parse with level and text', () => { const { blocks } = md.parseBlocks('# One\n## Two\n###### Six') assert.deepEqual( blocks.map((b) => [b.type, b.level, b.text]), [ ['heading', 1, 'One'], ['heading', 2, 'Two'], ['heading', 6, 'Six'], ], ) }) test('md-mini: 7 hashes is not a heading (paragraph)', () => { const { blocks } = md.parseBlocks('####### nope') assert.equal(blocks[0].type, 'paragraph') }) test('md-mini: fenced code captured verbatim, no inline parse', () => { const { blocks } = md.parseBlocks('```js\nconst x = **not bold**\n```') assert.equal(blocks.length, 1) assert.equal(blocks[0].type, 'code') assert.equal(blocks[0].lang, 'js') assert.equal(blocks[0].text, 'const x = **not bold**') }) test('md-mini: tilde fence closes only on tildes', () => { const { blocks } = md.parseBlocks('~~~\n```\nstill code\n~~~') assert.equal(blocks.length, 1) assert.equal(blocks[0].type, 'code') assert.equal(blocks[0].text, '```\nstill code') }) test('md-mini: unordered list groups consecutive items', () => { const { blocks } = md.parseBlocks('- a\n- b\n* c') assert.equal(blocks.length, 1) assert.equal(blocks[0].type, 'list') assert.equal(blocks[0].ordered, false) assert.deepEqual(blocks[0].items, ['a', 'b', 'c']) }) test('md-mini: ordered and unordered lists split into separate blocks', () => { const { blocks } = md.parseBlocks('1. a\n2. b\n- c') assert.equal(blocks.length, 2) assert.equal(blocks[0].ordered, true) assert.deepEqual(blocks[0].items, ['a', 'b']) assert.equal(blocks[1].ordered, false) assert.deepEqual(blocks[1].items, ['c']) }) test('md-mini: blockquote merges consecutive lines', () => { const { blocks } = md.parseBlocks('> line one\n> line two') assert.equal(blocks[0].type, 'quote') assert.equal(blocks[0].text, 'line one line two') }) test('md-mini: horizontal rule vs list disambiguation', () => { const hr = md.parseBlocks('---') assert.equal(hr.blocks[0].type, 'hr') const list = md.parseBlocks('- item') assert.equal(list.blocks[0].type, 'list') }) test('md-mini: paragraph joins soft-wrapped lines', () => { const { blocks } = md.parseBlocks('hello\nworld\n\nsecond') assert.equal(blocks.length, 2) assert.equal(blocks[0].text, 'hello world') assert.equal(blocks[1].text, 'second') }) test('md-mini: blank input yields no blocks, not truncated', () => { const { blocks, truncated } = md.parseBlocks('') assert.deepEqual(blocks, []) assert.equal(truncated, false) }) test('md-mini: length cap at MAX_LINES sets truncated', () => { const many = Array.from({ length: md.MAX_LINES + 50 }, (_, i) => 'line ' + i).join('\n') const { blocks, truncated } = md.parseBlocks(many) assert.equal(truncated, true) // Only the first MAX_LINES lines fed the parser; they collapse into one // paragraph (soft-wrapped), so assert the last surviving line made it and // the first dropped one did not. const text = blocks.map((b) => b.text || '').join(' ') assert.ok(text.includes('line ' + (md.MAX_LINES - 1))) assert.ok(!text.includes('line ' + md.MAX_LINES)) }) // ---- parseInline --------------------------------------------------------- test('md-mini: inline code is verbatim and beats other markup', () => { const toks = md.parseInline('a `**b**` c') assert.deepEqual( toks.map((t) => [t.type, t.text]), [ ['text', 'a '], ['code', '**b**'], ['text', ' c'], ], ) }) test('md-mini: strong and emphasis', () => { assert.equal(md.parseInline('**x**')[0].type, 'strong') assert.equal(md.parseInline('__x__')[0].type, 'strong') assert.equal(md.parseInline('*x*')[0].type, 'em') assert.equal(md.parseInline('_x_')[0].type, 'em') }) test('md-mini: link token carries text and href', () => { const toks = md.parseInline('see [docs](https://x.test/p)') const link = toks.find((t) => t.type === 'link') assert.equal(link.text, 'docs') assert.equal(link.href, 'https://x.test/p') }) test('md-mini: lone asterisk is literal text', () => { const toks = md.parseInline('2 * 3 = 6') assert.equal(toks.length, 1) assert.equal(toks[0].type, 'text') assert.equal(toks[0].text, '2 * 3 = 6') }) test('md-mini: isSafeHref whitelist', () => { assert.equal(md.isSafeHref('https://x.test'), true) assert.equal(md.isSafeHref('http://x.test'), true) assert.equal(md.isSafeHref('mailto:a@b.test'), true) assert.equal(md.isSafeHref('javascript:alert(1)'), false) assert.equal(md.isSafeHref('data:text/html, after' const root = render(evil) // No ')) assert.ok(root.textContent.includes('')) }) test('md-mini: HTML inside fenced code stays literal', () => { const root = render('```\n\n```') assert.equal(findAll(root, 'script').length, 0) const pre = findAll(root, 'pre')[0] assert.equal(pre.textContent, '') }) test('md-mini: safe link builds with click routed to onLink, default prevented', () => { const opened = [] const root = render('[go](https://x.test/p)', (href) => opened.push(href)) const a = findAll(root, 'a')[0] assert.ok(a) assert.equal(a.getAttribute('href'), 'https://x.test/p') assert.equal(a.getAttribute('rel'), 'noreferrer noopener') let prevented = false a.listeners.click[0]({ preventDefault: () => (prevented = true) }) assert.equal(prevented, true) assert.deepEqual(opened, ['https://x.test/p']) }) test('md-mini: unsafe link scheme renders inert text, no ', () => { const opened = [] const root = render('[click](javascript:alert(1))', (href) => opened.push(href)) assert.equal(findAll(root, 'a').length, 0) assert.ok(root.textContent.includes('click')) assert.deepEqual(opened, []) }) test('md-mini: nested emphasis inside strong', () => { const root = render('**bold _and italic_**') const strong = findAll(root, 'strong')[0] assert.ok(strong) assert.equal(findAll(strong, 'em').length, 1) }) test('md-mini: truncated note appended past cap', () => { const many = Array.from({ length: md.MAX_LINES + 10 }, () => 'x').join('\n') const root = render(many) const note = root.childNodes.find( (c) => c.nodeType === 1 && c.className === 'md-mini-truncated', ) assert.ok(note, 'expected a truncated note element') assert.ok(note.textContent.includes(String(md.MAX_LINES))) }) // ---- registration -------------------------------------------------------- test('md-mini: registered as a script in index.html before artifacts.js', () => { const html = fs.readFileSync( path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8', ) const mdIdx = html.indexOf('"./md-mini.js"') const artIdx = html.indexOf('"./artifacts.js"') assert.ok(mdIdx > -1, 'md-mini.js must be script-registered') assert.ok(mdIdx < artIdx, 'md-mini.js must load before artifacts.js') })