feat(desktop): in-stream inline md/html artifact preview (md-mini renderer + sandboxed iframe)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})()
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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