feat(desktop): in-stream inline md/html artifact preview (md-mini renderer + sandboxed iframe)
This commit is contained in:
391
examples/desktop/test/artifact-inline-preview.test.js
Normal file
391
examples/desktop/test/artifact-inline-preview.test.js
Normal file
@@ -0,0 +1,391 @@
|
||||
// Tests for the in-stream inline artifact preview (lane-artifact-inline).
|
||||
//
|
||||
// Two surfaces:
|
||||
// (a) Behavior — load src/renderer/artifacts.js against a handrolled DOM
|
||||
// stub (same approach as lane-ctx-deep-dom.test.js), drive the real
|
||||
// onArtifactEvent path, then click the preview toggle and assert the
|
||||
// region expands and builds the right content (md render / html
|
||||
// iframe / server-down fallback). The real md-mini module is wired in
|
||||
// as window.__dshMdMini so the md branch renders genuine DOM.
|
||||
// (b) Source + CSS locks — the iframe sandbox value (no allow-same-origin)
|
||||
// and the preview stylesheet block, so security-relevant drift trips
|
||||
// a gate even if the behavior stub is loosened.
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const ROOT = path.join(__dirname, '..')
|
||||
|
||||
// ---- DOM stub ------------------------------------------------------------
|
||||
// Covers exactly what artifacts.js touches: createElement/createTextNode,
|
||||
// append/appendChild, className/classList, dataset, hidden, open, textContent,
|
||||
// innerHTML (stored, never parsed), setAttribute/getAttribute,
|
||||
// addEventListener + a dispatch helper, and querySelector/querySelectorAll /
|
||||
// closest for the specific selectors the module uses.
|
||||
|
||||
function makeEl(tag, doc) {
|
||||
const el = {
|
||||
tagName: tag ? String(tag).toUpperCase() : undefined,
|
||||
nodeType: tag ? 1 : 3,
|
||||
ownerDocument: doc,
|
||||
className: '',
|
||||
_text: '',
|
||||
innerHTML: '',
|
||||
hidden: false,
|
||||
open: false,
|
||||
src: '',
|
||||
href: '',
|
||||
title: '',
|
||||
type: '',
|
||||
disabled: false,
|
||||
dataset: {},
|
||||
_attrs: {},
|
||||
_listeners: {},
|
||||
_children: [],
|
||||
parentNode: null,
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
offsetWidth: 0,
|
||||
classList: {
|
||||
_set: new Set(),
|
||||
add(c) { this._set.add(c) },
|
||||
remove(c) { this._set.delete(c) },
|
||||
contains(c) { return this._set.has(c) },
|
||||
},
|
||||
appendChild(c) { c.parentNode = el; el._children.push(c); return c },
|
||||
append(...kids) { for (const k of kids) { k.parentNode = el; el._children.push(k) } },
|
||||
removeChild(c) {
|
||||
const i = el._children.indexOf(c)
|
||||
if (i >= 0) el._children.splice(i, 1)
|
||||
return c
|
||||
},
|
||||
remove() { if (el.parentNode) el.parentNode.removeChild(el) },
|
||||
replaceWith(next) {
|
||||
if (!el.parentNode) return
|
||||
const i = el.parentNode._children.indexOf(el)
|
||||
if (i >= 0) el.parentNode._children[i] = next
|
||||
next.parentNode = el.parentNode
|
||||
},
|
||||
setAttribute(k, v) { el._attrs[k] = String(v) },
|
||||
getAttribute(k) { return k in el._attrs ? el._attrs[k] : null },
|
||||
removeAttribute(k) { delete el._attrs[k] },
|
||||
addEventListener(t, fn) { (el._listeners[t] = el._listeners[t] || []).push(fn) },
|
||||
dispatch(t, ev) { for (const fn of el._listeners[t] || []) fn(ev || {}) },
|
||||
set textContent(v) { el._text = String(v); el._children = [] },
|
||||
get textContent() {
|
||||
if (el.nodeType === 3) return el._text
|
||||
if (el._children.length === 0) return el._text
|
||||
return el._children.map((c) => c.textContent).join('')
|
||||
},
|
||||
closest(sel) {
|
||||
let n = el
|
||||
while (n) {
|
||||
if (matches(n, sel)) return n
|
||||
n = n.parentNode
|
||||
}
|
||||
return null
|
||||
},
|
||||
querySelector(sel) { return queryAll(el, sel)[0] || null },
|
||||
querySelectorAll(sel) { return queryAll(el, sel) },
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
// Minimal selector matcher: 'tag', '.class', '[attr]', '[attr="v"]'.
|
||||
function matches(el, sel) {
|
||||
if (!el || el.nodeType !== 1) return false
|
||||
sel = sel.trim()
|
||||
if (sel.startsWith('.')) {
|
||||
const cls = sel.slice(1)
|
||||
return el.classList._set.has(cls) || String(el.className).split(/\s+/).includes(cls)
|
||||
}
|
||||
const attrEq = /^\[([\w-]+)="([^"]*)"\]$/.exec(sel)
|
||||
if (attrEq) return (el.dataset[toCamel(attrEq[1])] ?? el._attrs[attrEq[1]]) === attrEq[2]
|
||||
const attr = /^\[([\w-]+)\]$/.exec(sel)
|
||||
if (attr) return el._attrs[attr[1]] != null || el.dataset[toCamel(attr[1])] != null
|
||||
return el.tagName === sel.toUpperCase()
|
||||
}
|
||||
function toCamel(s) { return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) }
|
||||
|
||||
// querySelectorAll supporting comma lists and ':scope > sel'. Descendant
|
||||
// search otherwise (children recursively).
|
||||
function queryAll(root, sel) {
|
||||
const out = []
|
||||
for (const part of sel.split(',').map((s) => s.trim())) {
|
||||
if (part.startsWith(':scope >')) {
|
||||
const child = part.slice(':scope >'.length).trim()
|
||||
for (const c of root._children) if (matches(c, child)) out.push(c)
|
||||
} else {
|
||||
const walk = (n) => {
|
||||
for (const c of n._children || []) {
|
||||
if (matches(c, part)) out.push(c)
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function makeDoc() {
|
||||
const doc = {
|
||||
readyState: 'complete',
|
||||
_byId: {},
|
||||
createElement(tag) { return makeEl(tag, doc) },
|
||||
createTextNode(t) { const n = makeEl(null, doc); n._text = String(t); return n },
|
||||
getElementById(id) { return doc._byId[id] || null },
|
||||
addEventListener() {},
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
function findAllByClass(root, cls, out) {
|
||||
out = out || []
|
||||
if (!root) return out
|
||||
const has = (root.classList && root.classList._set.has(cls)) ||
|
||||
String(root.className || '').split(/\s+/).includes(cls)
|
||||
if (has) out.push(root)
|
||||
for (const c of root._children || []) findAllByClass(c, cls, out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- module loader -------------------------------------------------------
|
||||
// Fresh module instance per test with the globals it reads. Returns the
|
||||
// exposed __dshArtifacts API plus the stubbed doc/window/stream so tests can
|
||||
// drive events and inspect the resulting tree.
|
||||
|
||||
function loadArtifacts(opts) {
|
||||
opts = opts || {}
|
||||
const doc = makeDoc()
|
||||
const stream = doc.createElement('div')
|
||||
doc._byId.stream = stream
|
||||
|
||||
const dsh = {
|
||||
onArtifact() {},
|
||||
openArtifact: async () => ({ ok: true }),
|
||||
getArtifactBase: opts.getArtifactBase || (async () => ({ url: null, dir: '/tmp/a' })),
|
||||
openExternalUrl: opts.openExternalUrl || (() => {}),
|
||||
}
|
||||
const win = { dsh }
|
||||
// Real md-mini so the md branch renders genuine DOM.
|
||||
const md = require('../src/renderer/md-mini.js')
|
||||
win.__dshMdMini = md
|
||||
|
||||
const sandbox = {
|
||||
window: win,
|
||||
document: doc,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
Promise,
|
||||
Date,
|
||||
module: { exports: {} },
|
||||
}
|
||||
win.window = win
|
||||
|
||||
const src = fs.readFileSync(path.join(ROOT, 'src/renderer/artifacts.js'), 'utf8')
|
||||
const vm = require('node:vm')
|
||||
vm.runInNewContext(src, sandbox)
|
||||
|
||||
return { api: win.__dshArtifacts, doc, win, stream, dsh }
|
||||
}
|
||||
|
||||
// The md/html card lands inside the auto-built panel's `.artifact-group`.
|
||||
function firstCard(stream) {
|
||||
return findAllByClass(stream, 'artifact-card')[0]
|
||||
}
|
||||
function tick() { return new Promise((r) => setTimeout(r, 0)) }
|
||||
|
||||
// ---- behavior: collapsed by default -------------------------------------
|
||||
|
||||
test('inline preview: md card starts collapsed (region hidden, caret ▸)', () => {
|
||||
const { api, stream } = loadArtifacts()
|
||||
api.onArtifactEvent({ artifactId: 'notes.md', kind: 'md', version: 1, blob: '# Hi\n\nbody' })
|
||||
const card = firstCard(stream)
|
||||
assert.ok(card, 'expected an artifact card in the stream')
|
||||
const region = findAllByClass(card, 'artifact-preview-region')[0]
|
||||
const toggle = findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
assert.ok(region && toggle, 'expected preview toggle + region')
|
||||
assert.equal(region.hidden, true)
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'false')
|
||||
})
|
||||
|
||||
test('inline preview: non-md/html kind gets NO preview strip', () => {
|
||||
const { api, stream } = loadArtifacts()
|
||||
api.onArtifactEvent({ artifactId: 'chart.svg', kind: 'svg', version: 1, blob: '<svg/>' })
|
||||
const card = firstCard(stream)
|
||||
assert.equal(findAllByClass(card, 'artifact-preview').length, 0)
|
||||
})
|
||||
|
||||
// ---- behavior: md expand renders real DOM -------------------------------
|
||||
|
||||
test('inline preview: expanding md builds rendered markdown, no raw HTML nodes', () => {
|
||||
const { api, stream } = loadArtifacts()
|
||||
api.onArtifactEvent({
|
||||
artifactId: 'doc.md',
|
||||
kind: 'md',
|
||||
version: 1,
|
||||
blob: '# Title\n\n**bold** and `code`\n\n<script>x</script>',
|
||||
})
|
||||
const card = firstCard(stream)
|
||||
const toggle = findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
toggle.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
|
||||
const region = findAllByClass(card, 'artifact-preview-region')[0]
|
||||
assert.equal(region.hidden, false)
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'true')
|
||||
const mdBlock = findAllByClass(card, 'artifact-preview-md')[0]
|
||||
assert.ok(mdBlock, 'expected rendered markdown block')
|
||||
assert.equal(findAllByClass(mdBlock, 'md-mini-h1').length, 1)
|
||||
// The <script> stayed literal text — no script element was created.
|
||||
const scripts = []
|
||||
;(function walk(n) { for (const c of n._children || []) { if (c.tagName === 'SCRIPT') scripts.push(c); walk(c) } })(mdBlock)
|
||||
assert.equal(scripts.length, 0)
|
||||
assert.ok(mdBlock.textContent.includes('<script>x</script>'))
|
||||
})
|
||||
|
||||
test('inline preview: md link routes through openExternalUrl', () => {
|
||||
const opened = []
|
||||
const { api, stream } = loadArtifacts({ openExternalUrl: (u) => opened.push(u) })
|
||||
api.onArtifactEvent({
|
||||
artifactId: 'links.md',
|
||||
kind: 'md',
|
||||
version: 1,
|
||||
blob: 'see [site](https://x.test/p)',
|
||||
})
|
||||
const card = firstCard(stream)
|
||||
findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
const link = findAllByClass(card, 'md-mini-link')[0]
|
||||
assert.ok(link, 'expected a rendered md link')
|
||||
link.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
assert.deepEqual(opened, ['https://x.test/p'])
|
||||
})
|
||||
|
||||
test('inline preview: md with no blob shows honest "content not supplied" note', () => {
|
||||
const { api, stream } = loadArtifacts()
|
||||
// Real ArtifactServer path — event carries no blob.
|
||||
api.onArtifactEvent({ artifactId: 'server.md', kind: 'md', version: 1, path: '/a/server.md' })
|
||||
const card = firstCard(stream)
|
||||
findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
assert.equal(findAllByClass(card, 'artifact-preview-md').length, 0)
|
||||
const note = findAllByClass(card, 'artifact-preview-note')[0]
|
||||
assert.ok(note, 'expected a fallback note')
|
||||
})
|
||||
|
||||
test('inline preview: collapse toggles region back to hidden', () => {
|
||||
const { api, stream } = loadArtifacts()
|
||||
api.onArtifactEvent({ artifactId: 't.md', kind: 'md', version: 1, blob: '# x' })
|
||||
const card = firstCard(stream)
|
||||
const toggle = findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
const region = findAllByClass(card, 'artifact-preview-region')[0]
|
||||
toggle.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
assert.equal(region.hidden, false)
|
||||
toggle.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
assert.equal(region.hidden, true)
|
||||
})
|
||||
|
||||
// ---- behavior: html iframe + fallback -----------------------------------
|
||||
|
||||
test('inline preview: html expand mounts sandboxed iframe when server URL present', async () => {
|
||||
const { api, stream } = loadArtifacts()
|
||||
api.onArtifactEvent({
|
||||
artifactId: 'page.html',
|
||||
kind: 'html',
|
||||
version: 1,
|
||||
url: 'http://127.0.0.1:9812/a/page.html/',
|
||||
})
|
||||
const card = firstCard(stream)
|
||||
findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
await tick()
|
||||
const frame = findAllByClass(card, 'artifact-preview-frame')[0]
|
||||
assert.ok(frame, 'expected an iframe')
|
||||
assert.equal(frame.tagName, 'IFRAME')
|
||||
assert.equal(frame.getAttribute('sandbox'), 'allow-scripts')
|
||||
assert.ok(!/allow-same-origin/.test(frame.getAttribute('sandbox')))
|
||||
assert.equal(frame.src, 'http://127.0.0.1:9812/a/page.html/')
|
||||
})
|
||||
|
||||
test('inline preview: html falls back to open-in-browser when server is down', async () => {
|
||||
const { api, stream } = loadArtifacts({ getArtifactBase: async () => ({ url: null }) })
|
||||
// No `url` on the event AND base.url null → server down.
|
||||
api.onArtifactEvent({ artifactId: 'down.html', kind: 'html', version: 1, path: '/a/down.html' })
|
||||
const card = firstCard(stream)
|
||||
findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
await tick()
|
||||
assert.equal(findAllByClass(card, 'artifact-preview-frame').length, 0)
|
||||
const note = findAllByClass(card, 'artifact-preview-note')[0]
|
||||
assert.ok(note, 'expected a fallback note when server is down')
|
||||
// The fallback offers an Open-in-browser button.
|
||||
const btns = findAllByClass(note, 'artifact-open')
|
||||
assert.ok(btns.length >= 1)
|
||||
})
|
||||
|
||||
test('inline preview: html url composed from base when event lacks url', async () => {
|
||||
const { api, stream } = loadArtifacts({
|
||||
getArtifactBase: async () => ({ url: 'http://127.0.0.1:7000' }),
|
||||
})
|
||||
api.onArtifactEvent({ artifactId: 'nested/page.html', kind: 'html', version: 1 })
|
||||
const card = firstCard(stream)
|
||||
findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
await tick()
|
||||
const frame = findAllByClass(card, 'artifact-preview-frame')[0]
|
||||
assert.ok(frame)
|
||||
// encodeURIComponent keeps `/` as a path separator (server contract).
|
||||
assert.equal(frame.src, 'http://127.0.0.1:7000/a/nested/page.html/')
|
||||
})
|
||||
|
||||
// ---- behavior: per-card open-state memory -------------------------------
|
||||
|
||||
test('inline preview: open state remembered in the session bucket', () => {
|
||||
const { api, stream } = loadArtifacts()
|
||||
api.onArtifactEvent({ artifactId: 'mem.md', kind: 'md', version: 1, blob: '# x' })
|
||||
const card = firstCard(stream)
|
||||
const toggle = findAllByClass(card, 'artifact-preview-toggle')[0]
|
||||
toggle.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
const bucket = api._bySession.get(api.getActiveSessionId())
|
||||
assert.ok(bucket.previewOpen.has('mem.md'), 'expanding should record open state')
|
||||
toggle.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
||||
assert.ok(!bucket.previewOpen.has('mem.md'), 'collapsing should clear it')
|
||||
})
|
||||
|
||||
// ---- source + css locks --------------------------------------------------
|
||||
|
||||
const artifactsSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/artifacts.js'), 'utf8')
|
||||
const styleCss = fs.readFileSync(path.join(ROOT, 'src/renderer/style.css'), 'utf8')
|
||||
|
||||
test('inline preview: iframe sandbox is allow-scripts only (no same-origin)', () => {
|
||||
assert.match(artifactsSrc, /setAttribute\(['"]sandbox['"],\s*['"]allow-scripts['"]\)/)
|
||||
// The sandbox VALUE must never grant same-origin. (The word may appear in
|
||||
// an explanatory comment; guard the actual setAttribute argument instead.)
|
||||
const sandboxCalls = artifactsSrc.match(/setAttribute\(['"]sandbox['"],\s*['"][^'"]*['"]\)/g) || []
|
||||
for (const call of sandboxCalls) {
|
||||
assert.ok(!/allow-same-origin/.test(call), 'sandbox must never grant allow-same-origin')
|
||||
}
|
||||
})
|
||||
|
||||
test('inline preview: md branch renders via __dshMdMini, never innerHTML', () => {
|
||||
assert.match(artifactsSrc, /window\.__dshMdMini/)
|
||||
// buildMdPreview / buildHtmlPreview must not assign innerHTML from content.
|
||||
const previewRegion = artifactsSrc.slice(
|
||||
artifactsSrc.indexOf('function buildMdPreview'),
|
||||
artifactsSrc.indexOf('function invokeOpen'),
|
||||
)
|
||||
assert.ok(!/\.innerHTML\s*=/.test(previewRegion), 'no innerHTML in preview builders')
|
||||
})
|
||||
|
||||
test('inline preview: CSS defines the preview + iframe blocks', () => {
|
||||
assert.match(styleCss, /\.artifact-preview-toggle\s*\{/)
|
||||
assert.match(styleCss, /\.artifact-preview-md\s*\{/)
|
||||
assert.match(styleCss, /\.artifact-preview-frame\s*\{/)
|
||||
assert.match(styleCss, /\.artifact-preview-frame[\s\S]{0,120}height:\s*360px/)
|
||||
})
|
||||
303
examples/desktop/test/md-mini.test.js
Normal file
303
examples/desktop/test/md-mini.test.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// 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,<script>'), false)
|
||||
assert.equal(md.isSafeHref('file:///etc/passwd'), false)
|
||||
})
|
||||
|
||||
// ---- DOM build + security ------------------------------------------------
|
||||
|
||||
test('md-mini: render produces expected element tags', () => {
|
||||
const root = render('# Title\n\npara\n\n- a\n- b\n\n> quote\n\n```\ncode\n```')
|
||||
assert.equal(findAll(root, 'h1').length, 1)
|
||||
assert.equal(findAll(root, 'p').length, 1)
|
||||
assert.equal(findAll(root, 'ul').length, 1)
|
||||
assert.equal(findAll(root, 'li').length, 2)
|
||||
assert.equal(findAll(root, 'blockquote').length, 1)
|
||||
assert.equal(findAll(root, 'pre').length, 1)
|
||||
})
|
||||
|
||||
test('md-mini: raw HTML in markdown stays literal text (no nodes)', () => {
|
||||
const evil = 'before <script>alert(1)</script> <img src=x onerror=alert(2)> after'
|
||||
const root = render(evil)
|
||||
// No <script> or <img> element was ever created.
|
||||
assert.equal(findAll(root, 'script').length, 0)
|
||||
assert.equal(findAll(root, 'img').length, 0)
|
||||
// The angle-bracket text survives verbatim in the paragraph textContent.
|
||||
assert.ok(root.textContent.includes('<script>alert(1)</script>'))
|
||||
assert.ok(root.textContent.includes('<img src=x onerror=alert(2)>'))
|
||||
})
|
||||
|
||||
test('md-mini: HTML inside fenced code stays literal', () => {
|
||||
const root = render('```\n<script>evil()</script>\n```')
|
||||
assert.equal(findAll(root, 'script').length, 0)
|
||||
const pre = findAll(root, 'pre')[0]
|
||||
assert.equal(pre.textContent, '<script>evil()</script>')
|
||||
})
|
||||
|
||||
test('md-mini: safe link builds <a> 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 <a>', () => {
|
||||
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')
|
||||
})
|
||||
Reference in New Issue
Block a user