feat(desktop): in-stream inline md/html artifact preview (md-mini renderer + sandboxed iframe)

This commit is contained in:
ZiyaZhang
2026-07-20 02:57:56 -07:00
parent 64397f5b39
commit d5e2115aba
12 changed files with 1609 additions and 9 deletions
+17 -7
View File
@@ -505,10 +505,17 @@ in the target profile.
- **Mission Control has no persistence.** A page reload wipes the
aggregate and reseeds from the next `session/list` refresh — the
view is a live overlay, not a store.
- **Artifact preview opens in your default browser.** No embedded
webview, no cloud tunnel; the shell hosts a `127.0.0.1` static
server and drops a card into chat when a new file appears in the
artifact dir.
- **Artifact preview: inline peek in-stream, full view in the browser.**
Each artifact card can expand a low-key inline preview — Markdown
rendered read-only by a tiny dependency-free renderer (`md-mini.js`,
text-node-only, so any raw HTML inside the doc stays literal), and
`.html` framed in a `sandbox="allow-scripts"` iframe pointed at the
existing `127.0.0.1` server. There is still **no embedded webview for
full pages**: the inline frame is a fixed-height peek, and "Open in
browser" remains the path for the real, full-size artifact. Previews
are collapsed by default and lazy (content builds on first expand);
when the artifact server isn't up the `.html` expand falls back to the
open-in-browser action instead of a broken frame.
- **Growth reads jsonl + `session/list`.** A follow-up will migrate to
the `session/list` + `session/events` aggregation so events that
never touch the overlay (pure chat activity) also show up.
@@ -659,9 +666,12 @@ under `docs/`:
`src/renderer/widgets.js` and the three header mocks.
- **Artifact preview** — `127.0.0.1` static server + SSE live-reload
that watches `~/Library/Application Support/dsh-desktop-demo/.artifacts/`
(override with `DSH_ARTIFACT_DIR=…`). Opens in your default browser;
no embedded webview. Tool-driven and debug-mock paths both exercised.
See `src/main/artifact-server.js`.
(override with `DSH_ARTIFACT_DIR=…`). Cards carry an inline peek —
`.md` rendered read-only by `src/renderer/md-mini.js`, `.html` framed
in a `sandbox="allow-scripts"` iframe — while the full view still opens
in your default browser (no embedded webview for full pages).
Tool-driven and debug-mock paths both exercised. See
`src/main/artifact-server.js` and `src/renderer/md-mini.js`.
### Design docs
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

@@ -0,0 +1,266 @@
// QA verification for lane-artifact-inline: the in-stream inline md/html
// artifact preview. Boots one isolated Electron on CDP :9320, seeds the
// artifact board fixture (md cards carry blob content) + fires the debug
// mock-artifact path (writes a real .html the artifact server serves), then
// captures three shots into docs/qa-artifact-inline/:
//
// 01-collapsed-default — cards present, previews collapsed (quiet rows)
// 02-md-preview-expanded — an .md card with its markdown preview open
// (headings / bold / fenced code visibly rendered)
// 03-html-iframe-expanded— the mock .html card with its sandboxed iframe
// preview open, framing the live 127.0.0.1 page
//
// Isolation mirrors scripts/qa-cdp-shoot-nav-optional.mjs (the 2026-07-18
// postmortem pattern): tmp DSH_DESKTOP_HOME + tmp --user-data-dir, single
// CDP port, electron resolved from the PARENT repo (this worktree has no
// node_modules). DSH_QA=1 is deliberately NOT set (qa-harness auto-clicks
// onboarding). The artifact panel auto-mounts on stream init, so we drive
// the real onArtifactEvent path the production wire uses.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs'
import { resolve, join } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { tmpdir } from 'node:os'
const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd())
const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo')
const ELECTRON = join(PARENT, 'node_modules/.bin/electron')
const CDP_PORT = Number(process.env.DSH_ARTIFACT_INLINE_PORT || 9320)
const OUTDIR = join(WORKTREE, 'docs/qa-artifact-inline')
if (!existsSync(ELECTRON)) {
console.error(`electron binary not found at ${ELECTRON}`)
process.exit(2)
}
mkdirSync(OUTDIR, { recursive: true })
function seedHome(dshHome) {
const seedOverlay = [
'# QA artifact-inline-shoot seed overlay (tmp, per-run).',
'plugins:',
` - "@cordisjs/plugin-include":`,
` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`,
'',
].join('\n')
writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay)
writeFileSync(join(dshHome, 'config.json'),
JSON.stringify({ role: 'coding', approvalMode: 'never' }, null, 2))
writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString())
}
async function bootElectron(dshHome, userData, port) {
const child = spawn(ELECTRON, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userData}`,
'--disable-gpu',
'--no-sandbox',
'.',
], {
cwd: WORKTREE,
env: { ...process.env, DSH_DESKTOP_HOME: dshHome, DSH_MAXIMIZE: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
})
const logs = []
child.stdout.on('data', d => logs.push(String(d)))
child.stderr.on('data', d => logs.push(String(d)))
for (let i = 0; i < 40; i++) {
await sleep(500)
try {
const r = await fetch(`http://localhost:${port}/json/list`)
if (r.ok) return { child, logs }
} catch {}
}
child.kill('SIGKILL')
console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
process.exit(3)
}
async function newCdp(port) {
const targets = await (await fetch(`http://localhost:${port}/json/list`)).json()
const target = targets.find(t => t.type === 'page')
if (!target) throw new Error('no page target on port ' + port)
const ws = new WebSocket(target.webSocketDebuggerUrl)
await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) })
let id = 1
const pending = new Map()
ws.onmessage = ev => {
const data = typeof ev.data === 'string' ? ev.data : String(ev.data)
let msg
try { msg = JSON.parse(data) } catch { return }
if (msg.id != null && pending.has(msg.id)) {
const [ok, err] = pending.get(msg.id); pending.delete(msg.id)
if (msg.error) err(new Error(msg.error.message)); else ok(msg.result)
}
}
const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => {
const _id = id++
const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms)
pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }])
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
})
const evj = async expr => {
const r = await call('Runtime.evaluate', {
expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`,
returnByValue: true, awaitPromise: true,
})
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
return r.result?.value
}
return { ws, call, evj }
}
async function shoot(call, name) {
const shot = await call('Page.captureScreenshot',
{ format: 'png', captureBeyondViewport: true }, 30000)
if (!shot || !shot.data) throw new Error('captureScreenshot returned no data for ' + name)
const outPath = join(OUTDIR, `${name}.png`)
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
const kb = (statSync(outPath).size / 1024).toFixed(1)
console.log(` wrote ${outPath} (${kb} KB)`)
return outPath
}
async function main() {
const dshHome = join(tmpdir(), 'dsh-artifact-inline-home')
const userData = join(tmpdir(), 'dsh-artifact-inline-userdata')
for (const dir of [dshHome, userData]) {
try { rmSync(dir, { recursive: true, force: true }) } catch {}
mkdirSync(dir, { recursive: true })
}
seedHome(dshHome)
console.log(`booting on port ${CDP_PORT}`)
const { child } = await bootElectron(dshHome, userData, CDP_PORT)
const results = {}
try {
await sleep(1800)
const { call, evj } = await newCdp(CDP_PORT)
await call('Page.enable')
await call('Runtime.enable')
// Make sure we're on the Chat view so the artifact stream/panel is live.
await evj(`(() => {
const chatBtn = document.querySelector('.tab-btn[data-tab="chat"]')
if (chatBtn) chatBtn.click()
return true
})()`)
await sleep(400)
// Seed the board fixture through the production onArtifactEvent path —
// md entries (session.md / README.md) carry blob content so the inline
// markdown preview renders real formatting.
const seeded = await evj(`(() => {
const A = window.__dshArtifacts
const seed = window.__dshArtifactBoardSeed
if (!A || !seed) return { ok: false, reason: 'no seed/api' }
A.seedBoardFixture(seed.artifacts)
const md = seed.artifacts.filter(a => a.kind === 'md').map(a => a.artifactId)
return { ok: true, mdIds: [...new Set(md)] }
})()`)
console.log(' seeded:', JSON.stringify(seeded))
await sleep(600)
// --- shot 1: collapsed default (quiet rows, no preview open) ----------
// Scroll the stream to the artifact panel first.
await evj(`(() => {
const p = document.querySelector('.artifact-panel')
if (p && p.scrollIntoView) p.scrollIntoView({ block: 'center' })
return true
})()`)
await sleep(300)
results.collapsed = await shoot(call, '01-collapsed-default')
// --- shot 2: md preview expanded --------------------------------------
// Open a specific .md card's <details>, then click its preview toggle.
// README.md is a single-version md with headings + a fenced code block —
// the richest formatting to show.
const mdOpen = await evj(`(() => {
const card = document.querySelector('.artifact-card[data-artifact-id="README.md"]')
|| document.querySelector('.artifact-card[data-artifact-id="session.md"]')
if (!card) return { ok: false, reason: 'no md card' }
card.open = true
const toggle = card.querySelector('.artifact-preview-toggle')
if (!toggle) return { ok: false, reason: 'no toggle' }
toggle.click()
const region = card.querySelector('.artifact-preview-region')
const mdBlock = card.querySelector('.artifact-preview-md')
if (card.scrollIntoView) card.scrollIntoView({ block: 'center' })
return {
ok: true,
id: card.dataset.artifactId,
regionShown: region ? !region.hidden : null,
hasMd: !!mdBlock,
headings: mdBlock ? mdBlock.querySelectorAll('.md-mini-h').length : 0,
codeBlocks: mdBlock ? mdBlock.querySelectorAll('.md-mini-pre').length : 0,
}
})()`)
console.log(' md preview:', JSON.stringify(mdOpen))
await sleep(400)
results.md = await shoot(call, '02-md-preview-expanded')
// --- shot 3: html iframe expanded -------------------------------------
// Fire the debug mock-artifact path: it writes a real mock-artifact.html
// into the artifact dir and the ArtifactServer serves it on 127.0.0.1,
// so the event carries a live `url` the sandboxed iframe can frame.
const htmlOpen = await evj(`(async () => {
if (!(window.dsh && typeof window.dsh.mockArtifact === 'function'))
return { ok: false, reason: 'no mockArtifact' }
await window.dsh.mockArtifact()
await new Promise(r => setTimeout(r, 700))
const card = document.querySelector('.artifact-card[data-artifact-id="mock-artifact.html"]')
if (!card) return { ok: false, reason: 'no mock html card' }
card.open = true
const toggle = card.querySelector('.artifact-preview-toggle')
if (!toggle) return { ok: false, reason: 'no toggle' }
toggle.click()
if (card.scrollIntoView) card.scrollIntoView({ block: 'center' })
return { ok: true }
})()`)
console.log(' html mock+open:', JSON.stringify(htmlOpen))
// Wait for the iframe to actually fire `load` (cross-origin sandboxed
// frame paints async; capturing before load leaves a blank frame).
const loaded = await evj(`(async () => {
const card = document.querySelector('.artifact-card[data-artifact-id="mock-artifact.html"]')
const frame = card ? card.querySelector('.artifact-preview-frame') : null
if (!frame) return { ok: false, reason: 'no frame' }
if (frame.scrollIntoView) frame.scrollIntoView({ block: 'center' })
await new Promise((resolve) => {
let done = false
const finish = () => { if (!done) { done = true; resolve() } }
frame.addEventListener('load', finish)
// Fallback cap in case load already fired before this listener.
setTimeout(finish, 2500)
})
return { ok: true }
})()`)
console.log(' iframe load:', JSON.stringify(loaded))
const frameState = await evj(`(() => {
const card = document.querySelector('.artifact-card[data-artifact-id="mock-artifact.html"]')
const frame = card ? card.querySelector('.artifact-preview-frame') : null
const note = card ? card.querySelector('.artifact-preview-note') : null
return {
hasFrame: !!frame,
sandbox: frame ? frame.getAttribute('sandbox') : null,
src: frame ? frame.src : null,
fallbackNote: note ? note.textContent.trim().slice(0, 60) : null,
}
})()`)
console.log(' iframe state:', JSON.stringify(frameState))
// Extra beat for the framed page's own paint after load.
await sleep(900)
results.html = await shoot(call, '03-html-iframe-expanded')
console.log('\n--- SUMMARY ---')
console.log(' md preview built :', mdOpen && mdOpen.ok, '(headings', mdOpen && mdOpen.headings, ', code', mdOpen && mdOpen.codeBlocks, ')')
console.log(' html iframe :', frameState.hasFrame, 'sandbox=', frameState.sandbox)
console.log(' shots :', Object.values(results).join(', '))
} finally {
try { child.kill('SIGKILL') } catch {}
for (let i = 0; i < 20; i++) {
await sleep(500)
try { await fetch(`http://localhost:${CDP_PORT}/json/list`) } catch { break }
}
}
}
main().catch(err => { console.error(err); process.exit(1) })
@@ -165,6 +165,14 @@
}
function renderTile(entry, openArtifact) {
// Board reuse decision (lane-artifact-inline, 2026-07-20): the List row
// grew an expandable inline md/html preview, but the Board tile does NOT
// reuse it. A tile is a single <button> whose whole click target opens
// the artifact in the browser; the inline preview needs interactive
// links (<a>) and an expand toggle, and nesting those inside a <button>
// is invalid HTML and would hijack the tile's open-on-click. The tile
// keeps its cheap first-line thumbnail (thumbnailPreview) instead; deep
// reading happens on the List row or in the browser.
const tile = document.createElement('button')
tile.type = 'button'
tile.className = 'artifact-tile'
+213 -2
View File
@@ -53,7 +53,17 @@
function ensureBucket(sid) {
let b = bySession.get(sid)
if (!b) {
b = { cards: new Map(), history: new Map(), panelEl: null, currentView: 'list' }
b = {
cards: new Map(),
history: new Map(),
panelEl: null,
currentView: 'list',
// Per-card inline-preview open state, keyed by artifactId. Cards
// persist in the transcript DOM so their expanded/collapsed state
// survives naturally, but tracking it here lets a rebuilt card
// (and the unit tests) restore the last state deterministically.
previewOpen: new Set(),
}
bySession.set(sid, b)
}
return b
@@ -285,8 +295,201 @@
return out
}
// Classify an artifact for inline preview. `.md` gets the mini markdown
// renderer; `.html` gets a sandboxed iframe pointed at the artifact
// server. Everything else has no inline preview (open-in-browser only).
// Kind is taken from the event's `kind` when present, else sniffed off
// the path extension so fixture events that only carry a path still work.
function previewKind(entry) {
const kind = String(entry.kind || '').toLowerCase()
if (kind === 'md' || kind === 'markdown') return 'md'
if (kind === 'html') return 'html'
const p = String(entry.path || entry.artifactId || '').toLowerCase()
if (/\.(md|markdown)$/.test(p)) return 'md'
if (/\.html?$/.test(p)) return 'html'
return null
}
// Latest captured blob for an artifact, if any. The real ArtifactServer
// does not ship content on the wire, but fixtures (and the debug seed)
// do; recordHistory keeps it. Used as the md preview source so the
// fixture path renders real content without a network round-trip.
function latestBlob(entry) {
if (typeof entry.blob === 'string') return entry.blob
const arr = history.get(entry.artifactId)
if (!arr || !arr.length) return null
const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b))
return typeof latest.blob === 'string' ? latest.blob : null
}
// Build the artifact server URL for an .html artifact. Prefer the URL the
// event already carries (real ArtifactServer stamps `url`); otherwise ask
// the preload bridge for the base and compose it the same way the server
// does (encodeURIComponent, but keep `/` path separators). Returns null
// when no server is up so callers fall back to open-in-browser.
async function resolveHtmlUrl(entry) {
if (typeof entry.url === 'string' && entry.url) return entry.url
if (!(window.dsh && typeof window.dsh.getArtifactBase === 'function')) return null
try {
const base = await window.dsh.getArtifactBase()
if (!base || !base.url) return null
const id = encodeURIComponent(entry.artifactId).replace(/%2F/gi, '/')
return base.url.replace(/\/$/, '') + '/a/' + id + '/'
} catch (err) {
console.error('getArtifactBase failed', err)
return null
}
}
// Inline preview strip: a <details>-free expandable region (we can't nest
// a <details> inside the card's <details> and get independent toggle
// state, so this is a button + region pair). Collapsed by default;
// content builds lazily on first expand. Open state is mirrored into the
// session bucket's previewOpen set so it's restorable.
function buildPreview(entry) {
const kind = previewKind(entry)
if (kind !== 'md' && kind !== 'html') return null
const wrap = document.createElement('div')
wrap.className = 'artifact-preview'
wrap.dataset.previewKind = kind
const toggle = document.createElement('button')
toggle.type = 'button'
toggle.className = 'artifact-preview-toggle'
toggle.setAttribute('aria-expanded', 'false')
const caret = document.createElement('span')
caret.className = 'artifact-preview-caret'
caret.setAttribute('aria-hidden', 'true')
caret.textContent = '▸'
const label = document.createElement('span')
label.className = 'artifact-preview-label'
label.textContent = kind === 'md' ? 'preview markdown' : 'preview page'
toggle.append(caret, label)
const region = document.createElement('div')
region.className = 'artifact-preview-region'
region.hidden = true
let built = false
const expand = () => {
wrap.classList.add('is-open')
toggle.setAttribute('aria-expanded', 'true')
caret.textContent = '▾'
region.hidden = false
bucket().previewOpen.add(entry.artifactId)
if (!built) {
built = true
if (kind === 'md') buildMdPreview(region, entry)
else buildHtmlPreview(region, entry)
}
}
const collapse = () => {
wrap.classList.remove('is-open')
toggle.setAttribute('aria-expanded', 'false')
caret.textContent = '▸'
region.hidden = true
bucket().previewOpen.delete(entry.artifactId)
}
toggle.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
if (region.hidden) expand()
else collapse()
})
wrap.append(toggle, region)
// Restore prior open state (rebuilt card / test-driven restore).
// Opening the preview also means opening the parent <details> so the
// region is visible (a closed <details> hides non-summary children).
// Deferred to a microtask so the card is appended and `.closest` can
// find the parent.
if (bucket().previewOpen.has(entry.artifactId)) {
Promise.resolve().then(() => {
const parentCard = wrap.closest ? wrap.closest('.artifact-card') : null
if (parentCard) parentCard.open = true
expand()
})
}
return wrap
}
function buildMdPreview(region, entry) {
const md = window.__dshMdMini
const blob = latestBlob(entry)
if (!md) {
const note = document.createElement('div')
note.className = 'artifact-preview-note muted small'
note.textContent = 'markdown renderer unavailable'
region.appendChild(note)
return
}
if (typeof blob !== 'string') {
// No content on the wire (real ArtifactServer path). Be honest and
// point at the browser rather than fake a render.
const note = document.createElement('div')
note.className = 'artifact-preview-note muted small'
note.textContent = '内容未随事件传入 · 用「在浏览器打开」查看'
region.appendChild(note)
return
}
const onLink = (href) => {
if (window.dsh && typeof window.dsh.openExternalUrl === 'function') {
window.dsh.openExternalUrl(href)
}
}
const rendered = md.render(blob, { document, onLink })
rendered.classList.add('artifact-preview-md')
region.appendChild(rendered)
}
function buildHtmlPreview(region, entry) {
// Show a placeholder while we resolve whether a server is up; swap in
// the sandboxed iframe or the fallback once known.
const pending = document.createElement('div')
pending.className = 'artifact-preview-note muted small'
pending.textContent = 'loading preview…'
region.appendChild(pending)
resolveHtmlUrl(entry).then((url) => {
pending.remove()
if (!url) {
// Server not up (e.g. stdio profile without artifacts). Offer the
// existing open-in-browser action rather than a broken frame.
const note = document.createElement('div')
note.className = 'artifact-preview-note muted small'
note.textContent = 'artifact 服务未启动 · '
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'artifact-open ghost small'
btn.textContent = 'Open in browser'
btn.addEventListener('click', (e) => {
e.stopPropagation()
if (btn.getAttribute('aria-disabled') === 'true') return
invokeOpen(entry, btn, 'Open in browser')
})
note.appendChild(btn)
region.appendChild(note)
return
}
const frame = document.createElement('iframe')
frame.className = 'artifact-preview-frame'
// Sandbox: allow the page's own scripts to run (many artifacts are
// interactive) but withhold allow-same-origin so the framed doc can't
// reach back into the 127.0.0.1 origin's storage/cookies, and grant
// nothing else (no top-nav, popups, forms, downloads).
frame.setAttribute('sandbox', 'allow-scripts')
frame.setAttribute('loading', 'lazy')
frame.setAttribute('referrerpolicy', 'no-referrer')
frame.setAttribute('title', 'Artifact preview: ' + entry.artifactId)
frame.src = url
region.appendChild(frame)
})
}
function invokeOpen(entry, actionEl, restoreLabel) {
if (!actionEl) return
actionEl.setAttribute('aria-disabled', 'true')
actionEl.classList.add('is-busy')
const done = (label) => {
@@ -404,6 +607,14 @@
body.append(pathRow, actionRow)
// ---- inline preview (md / html) -----------------------------------
// A low-key expandable strip under the actions. Collapsed by default so
// the card stays a single quiet row; lazy — content only builds on
// first expand. Only .md and .html artifacts get it; other kinds
// (svg/binary/etc) keep the open-in-browser affordance alone.
const preview = buildPreview(entry)
if (preview) body.append(preview)
el.append(summary, body)
// Kick a fresh-flash so the arrival is noticeable.
flash(el)
+5
View File
@@ -1474,6 +1474,11 @@
reach the Board/Timeline/Evolution views without waiting for a
real run to emit fs artifacts. -->
<script src="./artifact-board-seed.js"></script>
<!-- Minimal Markdown → DOM renderer (window.__dshMdMini) for in-stream
inline .md artifact previews. MUST load before artifacts.js, which
calls it when expanding an .md card's preview. Dependency-free and
innerHTML-free: model text reaches the DOM only as text nodes. -->
<script src="./md-mini.js"></script>
<script src="./artifacts.js"></script>
<script src="./plugin-runtime-fold.js"></script>
<script src="./mcp-tool-name.js"></script>
+332
View File
@@ -0,0 +1,332 @@
// Minimal, dependency-free Markdown → DOM renderer for in-stream artifact
// previews (张子雅's "把 md 文档在流里展示出来" request). Deliberately NOT a
// full CommonMark implementation — it covers the constructs that show up in
// agent-authored .md artifacts (headings, emphasis, inline code, fenced code,
// lists, blockquotes, links, paragraphs, horizontal rules) and nothing else.
//
// SECURITY CONTRACT (the whole reason this file exists instead of `marked`):
// The renderer NEVER interprets HTML. Every scrap of model-authored text
// reaches the DOM through document.createTextNode / .textContent only — so a
// literal `<script>` or `<img onerror=…>` inside the markdown renders as the
// visible characters "<script>…", never as a node. There is no innerHTML
// path anywhere below. Links are the one interactive affordance: an <a> is
// created ONLY for http(s)/mailto hrefs, its navigation is cancelled
// (preventDefault) and handed to the caller's onLink() — which routes through
// the shell:openExternal whitelist. Any other scheme (javascript:, data:, …)
// degrades to inert text.
//
// Public API (window.__dshMdMini / module.exports):
// parseBlocks(src) → { blocks, truncated } (pure, testable)
// parseInline(text) → token[] (pure, testable)
// render(src, opts) → HTMLElement (opts.document, opts.onLink)
// MAX_LINES → number (preview length cap)
'use strict'
;(function () {
// Preview cap: agent artifacts can be thousands of lines; the in-stream card
// is a peek, not a reader. Past this we stop and show an "open full" note.
const MAX_LINES = 200
// ---- block-level parse ---------------------------------------------------
const RE_FENCE = /^(`{3,}|~{3,})(.*)$/
const RE_FENCE_CLOSE = /^(`{3,}|~{3,})\s*$/
const RE_HEADING = /^(#{1,6})\s+(.*)$/
const RE_LIST = /^\s*([-*+]|\d+[.)])\s+/
const RE_LIST_ORDERED = /^\s*\d+[.)]\s+/
const RE_QUOTE = /^\s*>\s?/
const RE_HR = /^\s*([-*_])\1{2,}\s*$/
const RE_BLANK = /^\s*$/
function isBlockStart(line) {
return (
RE_BLANK.test(line) ||
RE_FENCE.test(line) ||
RE_HEADING.test(line) ||
RE_LIST.test(line) ||
RE_QUOTE.test(line) ||
RE_HR.test(line)
)
}
function parseBlocks(src) {
const allLines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n')
const truncated = allLines.length > MAX_LINES
const lines = truncated ? allLines.slice(0, MAX_LINES) : allLines
const blocks = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (RE_BLANK.test(line)) {
i++
continue
}
// Fenced code block — content is captured verbatim (no inline parse).
const fence = RE_FENCE.exec(line)
if (fence) {
const fenceCh = fence[1][0]
const lang = fence[2].trim()
const code = []
i++
while (i < lines.length) {
const close = RE_FENCE_CLOSE.exec(lines[i])
if (close && close[1][0] === fenceCh) {
i++
break
}
code.push(lines[i])
i++
}
blocks.push({ type: 'code', lang, text: code.join('\n') })
continue
}
const heading = RE_HEADING.exec(line)
if (heading) {
blocks.push({
type: 'heading',
level: heading[1].length,
text: heading[2].replace(/\s+#+\s*$/, '').trim(),
})
i++
continue
}
// Horizontal rule (checked before list so `***`/`---` don't parse as a
// bullet item with empty content).
if (RE_HR.test(line)) {
blocks.push({ type: 'hr' })
i++
continue
}
// List — gather consecutive items of the SAME ordered/unordered flavour.
if (RE_LIST.test(line)) {
const ordered = RE_LIST_ORDERED.test(line)
const items = []
while (i < lines.length && RE_LIST.test(lines[i]) && !RE_HR.test(lines[i])) {
if (RE_LIST_ORDERED.test(lines[i]) !== ordered) break
items.push(lines[i].replace(RE_LIST, ''))
i++
}
blocks.push({ type: 'list', ordered, items })
continue
}
// Blockquote — one level, consecutive `>` lines merged into one text.
if (RE_QUOTE.test(line)) {
const buf = []
while (i < lines.length && RE_QUOTE.test(lines[i])) {
buf.push(lines[i].replace(RE_QUOTE, ''))
i++
}
blocks.push({ type: 'quote', text: buf.join(' ') })
continue
}
// Paragraph — run of non-blank, non-block-start lines joined by spaces.
const para = [line]
i++
while (i < lines.length && !isBlockStart(lines[i])) {
para.push(lines[i])
i++
}
blocks.push({ type: 'paragraph', text: para.join(' ') })
}
return { blocks, truncated }
}
// ---- inline parse --------------------------------------------------------
// Scanning tokenizer. At each position we try the inline constructs in
// precedence order; anything else accumulates into a plain-text token. The
// token list is flat, but strong/em/link inner text is re-parsed at build
// time so `**bold `code`**` nests correctly.
const RE_CODE = /^(`+)([\s\S]*?)\1/
const RE_LINK = /^\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/
function parseInline(text) {
const s = String(text == null ? '' : text)
const tokens = []
let buf = ''
const flush = () => {
if (buf) {
tokens.push({ type: 'text', text: buf })
buf = ''
}
}
let i = 0
while (i < s.length) {
const c = s[i]
const tail = s.slice(i)
// Inline code: backtick-delimited, verbatim, highest precedence so
// markup inside a code span stays literal.
if (c === '`') {
const m = RE_CODE.exec(tail)
if (m) {
flush()
tokens.push({ type: 'code', text: m[2] })
i += m[0].length
continue
}
}
// Link [text](href) — href stops at whitespace/`)`; optional "title" dropped.
if (c === '[') {
const m = RE_LINK.exec(tail)
if (m) {
flush()
tokens.push({ type: 'link', text: m[1], href: m[2] })
i += m[0].length
continue
}
}
// Strong (**/__) then emphasis (*/_).
if (c === '*' || c === '_') {
const pair = c + c
if (tail.slice(0, 2) === pair) {
const strongRe = new RegExp('^\\' + c + '\\' + c + '([\\s\\S]+?)\\' + c + '\\' + c)
const m = strongRe.exec(tail)
if (m) {
flush()
tokens.push({ type: 'strong', text: m[1] })
i += m[0].length
continue
}
}
// Emphasis: require a non-space, non-delimiter char right after the
// marker so `a * b` and bare `*` don't open a run.
const emRe = new RegExp('^\\' + c + '(?![\\s' + '\\' + c + '])([\\s\\S]*?)\\' + c)
const m = emRe.exec(tail)
if (m && m[1].trim()) {
flush()
tokens.push({ type: 'em', text: m[1] })
i += m[0].length
continue
}
}
buf += c
i++
}
flush()
return tokens
}
function isSafeHref(href) {
return /^(https?:|mailto:)/i.test(String(href).trim())
}
// ---- DOM build -----------------------------------------------------------
function buildInline(parent, tokens, doc, onLink) {
for (const t of tokens) {
if (t.type === 'text') {
parent.appendChild(doc.createTextNode(t.text))
} else if (t.type === 'code') {
const el = doc.createElement('code')
el.className = 'md-mini-code'
el.textContent = t.text
parent.appendChild(el)
} else if (t.type === 'strong') {
const el = doc.createElement('strong')
buildInline(el, parseInline(t.text), doc, onLink)
parent.appendChild(el)
} else if (t.type === 'em') {
const el = doc.createElement('em')
buildInline(el, parseInline(t.text), doc, onLink)
parent.appendChild(el)
} else if (t.type === 'link') {
if (isSafeHref(t.href)) {
const a = doc.createElement('a')
a.className = 'md-mini-link'
a.textContent = t.text || t.href
a.setAttribute('href', t.href)
a.setAttribute('rel', 'noreferrer noopener')
a.addEventListener('click', (ev) => {
if (ev && typeof ev.preventDefault === 'function') ev.preventDefault()
if (onLink) onLink(t.href)
})
parent.appendChild(a)
} else {
// Disallowed scheme (javascript:, data:, …): render the visible text
// inert. No node that could navigate is ever created.
parent.appendChild(doc.createTextNode(t.text || t.href))
}
}
}
}
function render(src, opts) {
opts = opts || {}
const doc = opts.document || (typeof document !== 'undefined' ? document : null)
if (!doc) throw new Error('md-mini.render: no document available')
const onLink = typeof opts.onLink === 'function' ? opts.onLink : null
const parsed = parseBlocks(src)
const root = doc.createElement('div')
root.className = 'md-mini'
for (const b of parsed.blocks) {
if (b.type === 'heading') {
const el = doc.createElement('h' + b.level)
el.className = 'md-mini-h md-mini-h' + b.level
buildInline(el, parseInline(b.text), doc, onLink)
root.appendChild(el)
} else if (b.type === 'paragraph') {
const el = doc.createElement('p')
el.className = 'md-mini-p'
buildInline(el, parseInline(b.text), doc, onLink)
root.appendChild(el)
} else if (b.type === 'code') {
const pre = doc.createElement('pre')
pre.className = 'md-mini-pre'
const code = doc.createElement('code')
if (b.lang) code.className = 'md-mini-lang-' + b.lang.replace(/[^\w-]/g, '')
code.textContent = b.text
pre.appendChild(code)
root.appendChild(pre)
} else if (b.type === 'list') {
const listEl = doc.createElement(b.ordered ? 'ol' : 'ul')
listEl.className = 'md-mini-list'
for (const item of b.items) {
const li = doc.createElement('li')
buildInline(li, parseInline(item), doc, onLink)
listEl.appendChild(li)
}
root.appendChild(listEl)
} else if (b.type === 'quote') {
const q = doc.createElement('blockquote')
q.className = 'md-mini-quote'
buildInline(q, parseInline(b.text), doc, onLink)
root.appendChild(q)
} else if (b.type === 'hr') {
const hr = doc.createElement('hr')
hr.className = 'md-mini-hr'
root.appendChild(hr)
}
}
if (parsed.truncated) {
const note = doc.createElement('div')
note.className = 'md-mini-truncated'
note.textContent = '仅预览前 ' + MAX_LINES + ' 行 · 用「在浏览器打开」查看全文'
root.appendChild(note)
}
return root
}
const API = { parseBlocks, parseInline, render, isSafeHref, MAX_LINES }
if (typeof module !== 'undefined' && module.exports) module.exports = API
if (typeof window !== 'undefined') window.__dshMdMini = API
})()
+74
View File
@@ -761,6 +761,80 @@ body.layout-monitor .stream {
.artifact-body-actions { display: flex; gap: 8px; }
.artifact-open { font-size: 12px; padding: 4px 10px; }
/* Inline preview (lane-artifact-inline): md / html artifacts get a low-key
* expandable strip under the L1 actions. Collapsed by default so the card
* stays a quiet row; the region only builds its content on first expand. */
.artifact-preview { display: flex; flex-direction: column; gap: 0; }
.artifact-preview-toggle {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
background: none; border: none; cursor: pointer;
padding: 2px 4px; margin: 0;
font-size: 11px; color: var(--text-tertiary);
border-radius: 3px;
}
.artifact-preview-toggle:hover { color: var(--accent); background: var(--accent-soft); }
.artifact-preview-caret {
font-size: 9px; line-height: 1; display: inline-block;
transform: translateY(0.5px);
}
.artifact-preview-label { letter-spacing: 0.02em; }
.artifact-preview-region {
margin-top: 6px;
border: 1px solid var(--border); border-radius: 5px;
background: var(--bg-elev-1);
overflow: hidden;
}
.artifact-preview-note {
padding: 8px 10px; display: flex; align-items: center; gap: 6px;
}
/* Rendered markdown block tight, readable, capped visual scale so the
* inline peek doesn't try to be a full document reader. */
.artifact-preview-md {
padding: 4px 12px 10px; max-height: 360px; overflow: auto;
font-size: 12px; line-height: 1.55; color: var(--text);
}
.artifact-preview-md .md-mini-h { margin: 10px 0 4px; line-height: 1.3; font-weight: 600; }
.artifact-preview-md .md-mini-h1 { font-size: 16px; }
.artifact-preview-md .md-mini-h2 { font-size: 14px; }
.artifact-preview-md .md-mini-h3 { font-size: 13px; }
.artifact-preview-md .md-mini-h4,
.artifact-preview-md .md-mini-h5,
.artifact-preview-md .md-mini-h6 { font-size: 12px; color: var(--muted); }
.artifact-preview-md .md-mini-p { margin: 4px 0; }
.artifact-preview-md .md-mini-list { margin: 4px 0; padding-left: 20px; }
.artifact-preview-md .md-mini-list li { margin: 2px 0; }
.artifact-preview-md .md-mini-code {
font-family: var(--mono); font-size: 11px;
background: var(--bg-elev-2); padding: 1px 5px; border-radius: 3px;
}
.artifact-preview-md .md-mini-pre {
background: var(--bg-elev-2); border-radius: 4px;
padding: 8px 10px; margin: 6px 0; overflow: auto;
}
.artifact-preview-md .md-mini-pre code {
font-family: var(--mono); font-size: 11px; color: var(--text);
background: none; padding: 0;
}
.artifact-preview-md .md-mini-quote {
margin: 6px 0; padding: 2px 0 2px 10px;
border-left: 3px solid var(--border); color: var(--muted);
}
.artifact-preview-md .md-mini-hr {
border: none; border-top: 1px solid var(--border); margin: 10px 0;
}
.artifact-preview-md .md-mini-link { color: var(--accent); cursor: pointer; }
.artifact-preview-md .md-mini-link:hover { text-decoration: underline; }
.artifact-preview-md .md-mini-truncated {
margin-top: 8px; padding-top: 6px; border-top: 1px dashed var(--border);
font-size: 10px; color: var(--text-tertiary);
}
/* Sandboxed html preview iframe — fixed height, full width of the region. */
.artifact-preview-frame {
display: block; width: 100%; height: 360px; border: none;
background: #fff;
}
/* Auto-group: consecutive artifact cards get wrapped in an
* `.artifact-group` container by the renderer so the group owns its own
* zero-gap layout (the stream's 12px flex-gap would otherwise dominate).
@@ -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
View 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')
})