// Lock the lane-artifact-v2 additions (2026-07-19): version-evolution // chain + Board/Timeline views + panel tab bar. Complements the existing // artifact-compact-row.test.js which locks the L0 row shape. // // Three surfaces: // (a) src/renderer/artifacts-board.js — Board/Timeline/Evolution // renderers + kind-bucket + diffLines. Testable directly via // CommonJS export. // (b) src/renderer/artifacts.js — history tracking, panel // building, view switcher. Only source-string assertions here // (the module is an IIFE that touches window at load). // (c) src/renderer/style.css — panel + tab + tile + evolution // + timeline styling. '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, '..') const artifactsSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/artifacts.js'), 'utf8') const styleCss = fs.readFileSync(path.join(ROOT, 'src/renderer/style.css'), 'utf8') // Load artifacts-board.js as a real module — it emits a CommonJS export // alongside the window.__dshArtifactsBoard shim (mirrors tool-cards.js). const board = require(path.join(ROOT, 'src/renderer/artifacts-board.js')) // -- (a) artifacts-board.js module surface -------------------------------- test('artifacts-board: exports the seven-piece API', () => { for (const key of ['KINDS', 'bucketOf', 'groupByKind', 'renderBoard', 'renderTimeline', 'renderEvolution', 'diffLines']) { assert.ok(board[key], `missing export: ${key}`) } }) test('artifacts-board: bucketOf normalizes freeform kinds to six buckets', () => { assert.equal(board.bucketOf('md'), 'md') assert.equal(board.bucketOf('markdown'), 'md') assert.equal(board.bucketOf('html'), 'html') assert.equal(board.bucketOf('htm'), 'html') assert.equal(board.bucketOf('svg'), 'svg') assert.equal(board.bucketOf('json'), 'json') assert.equal(board.bucketOf('py'), 'code') assert.equal(board.bucketOf('sh'), 'code') assert.equal(board.bucketOf('bin'), 'other') assert.equal(board.bucketOf(''), 'other') assert.equal(board.bucketOf(null), 'other') }) test('artifacts-board: groupByKind drops empty buckets so headers do not render blank', () => { const groups = board.groupByKind([ { artifactId: 'a.md', kind: 'md', version: 1 }, { artifactId: 'b.html', kind: 'html', version: 1 }, ]) assert.ok(groups.has('md')) assert.ok(groups.has('html')) assert.ok(!groups.has('svg'), 'empty buckets must be dropped') assert.ok(!groups.has('json')) assert.ok(!groups.has('code')) assert.ok(!groups.has('other')) }) test('artifacts-board: diffLines emits ordered add / del / ctx lines', () => { const prev = 'a\nb\nc' const next = 'a\nx\nc' const lines = board.diffLines(prev, next) // Must preserve first line as context, then produce a del+add for b→x, // then keep last line as context. assert.deepEqual( lines.map((l) => `${l.kind}:${l.text}`), ['ctx:a', 'del:b', 'add:x', 'ctx:c'], ) }) test('artifacts-board: diffLines handles pure insertion / pure deletion', () => { // Note: '' splits to [''] (one empty line), so an empty→two-line // transition is one del (empty) plus two adds, not two bare adds. // That's the honest LCS answer — the fixture never diffs empty // blobs in real usage, but the shape matters for regressions. const ins = board.diffLines('', 'a\nb') assert.deepEqual(ins.map((l) => l.kind), ['del', 'add', 'add']) const del = board.diffLines('a\nb', '') assert.deepEqual(del.map((l) => l.kind), ['del', 'del', 'add']) }) // -- (a2) artifacts-board.js: DOM-shape asserts --------------------------- // // Board / Timeline / Evolution renderers return DOM nodes, so we run // them against a minimal DOM stub (same idea as renderer-harness.js). // Kept inline + tiny so the test file stays hermetic. function makeDom() { // Bare-bones stub matching what the renderers touch: createElement, // append(), appendChild, dataset, className, textContent, innerHTML, // setAttribute, addEventListener, querySelector, querySelectorAll, // classList (used only via className strings here), title. const install = (tag, extra = {}) => { const el = { tagName: tag.toUpperCase(), children: [], dataset: {}, className: '', textContent: '', innerHTML: '', title: '', hidden: false, _attrs: {}, _listeners: {}, style: {}, classList: { _s: new Set(), add(...n) { for (const x of n) this._s.add(x) }, remove(...n) { for (const x of n) this._s.delete(x) }, contains(x) { return this._s.has(x) }, }, appendChild(child) { child.parentElement = this; this.children.push(child); return child }, append(...cs) { for (const c of cs) this.appendChild(c) }, setAttribute(k, v) { this._attrs[k] = String(v) }, getAttribute(k) { return this._attrs[k] }, addEventListener(name, fn) { (this._listeners[name] = this._listeners[name] || []).push(fn) }, dispatchEvent(name) { for (const fn of (this._listeners[name] || [])) { fn({ preventDefault() {}, stopPropagation() {} }) } }, querySelector(sel) { return findFirst(this, sel) }, querySelectorAll(sel) { return findAll(this, sel) }, ...extra, } return el } function selMatch(el, sel) { if (sel.startsWith('.')) return el.className && el.className.split(/\s+/).includes(sel.slice(1)) if (sel.startsWith('[')) { // Only used in this test for `[data-artifact-id="..."]` shape, // which isn't hit by these assertions. Return false safely. return false } return el.tagName === sel.toUpperCase() } function findFirst(root, sel) { for (const c of (root.children || [])) { if (selMatch(c, sel)) return c const nested = findFirst(c, sel) if (nested) return nested } return null } function findAll(root, sel) { const out = [] const walk = (n) => { for (const c of (n.children || [])) { if (selMatch(c, sel)) out.push(c) walk(c) } } walk(root) return out } global.document = { createElement: (tag) => install(tag) } return install } test('artifacts-board.renderBoard: one group per kind, tile count matches bucket size', () => { makeDom() const entries = [ { artifactId: 'a.md', kind: 'md', version: 3, blob: '# hi' }, { artifactId: 'b.md', kind: 'md', version: 1, blob: 'hi' }, { artifactId: 'c.html', kind: 'html', version: 2, blob: '
x
' }, ] const el = board.renderBoard(entries, { openArtifact: () => {} }) assert.equal(el.className, 'artifact-board') const groups = el.querySelectorAll('.artifact-board-group') assert.equal(groups.length, 2, 'md + html only — svg/json/code/other are empty and must be dropped') const mdGroup = groups.find ? groups.find((g) => g.dataset.kind === 'md') : groups[0] const mdTiles = mdGroup.querySelectorAll('.artifact-tile') assert.equal(mdTiles.length, 2, 'both md entries live under the md group') // Kind badge on the tile mirrors the bucket so CSS can theme per-kind. for (const tile of mdTiles) assert.equal(tile.dataset.kind, 'md') }) test('artifacts-board.renderTimeline: newest-first ordering across all versions', () => { makeDom() const entries = [ { artifactId: 'a.md', kind: 'md', version: 2, seenAt: 100 }, ] const history = new Map([ ['a.md', [ { artifactId: 'a.md', version: 1, kind: 'md', seenAt: 50 }, { artifactId: 'a.md', version: 2, kind: 'md', seenAt: 100 }, ]], ]) const el = board.renderTimeline(entries, { history, openArtifact: () => {} }) const rows = el.querySelectorAll('.artifact-timeline-row') assert.equal(rows.length, 2, 'both history versions must appear as rows') assert.equal(rows[0].dataset.version, '2', 'newest version appears first') assert.equal(rows[1].dataset.version, '1') }) test('artifacts-board.renderEvolution: N versions → N-1 diff panes + honest fallback', () => { makeDom() const entry = { artifactId: 'a.md', kind: 'md', version: 3 } // Chain with two hops. First hop has both blobs (real diff); second // hop has one missing (fallback note path). const history = [ { version: 1, seenAt: 100, kind: 'md', blob: 'a\nb' }, { version: 2, seenAt: 200, kind: 'md', blob: 'a\nB' }, { version: 3, seenAt: 300, kind: 'md' /* no blob */ }, ] const el = board.renderEvolution(entry, history) const steps = el.querySelectorAll('.artifact-evolution-step') assert.equal(steps.length, 3, 'one step per version') const diffs = el.querySelectorAll('.artifact-evolution-diff') assert.equal(diffs.length, 2, 'N-1 diff panes for a chain of N versions') // The first diff pane has real content; the second (v2 has blob, v3 // does not) must fall back to the not-preserved note. const notes = el.querySelectorAll('.artifact-evolution-diff-note') assert.equal(notes.length, 1, 'exactly one hop falls back to the honest note') }) test('artifacts-board.renderEvolution: chain summary reads v1 → v2 → v3', () => { makeDom() const entry = { artifactId: 'a.md', kind: 'md', version: 3 } const history = [ { version: 1, seenAt: 1 }, { version: 2, seenAt: 2 }, { version: 3, seenAt: 3 }, ] const el = board.renderEvolution(entry, history) const sum = el.querySelector('.artifact-evolution-summary') assert.ok(sum, 'summary line must render') assert.equal(sum.textContent, 'v1 → v2 → v3', 'chain summary must list every version in order') }) // -- (b) artifacts.js source-string locks --------------------------------- test('artifacts.js: builds an .artifact-panel with a role="tablist" tab bar', () => { assert.match( artifactsSrc, /panel\.className\s*=\s*['"]artifact-panel['"]/, 'panel container must exist so the tab bar has a scope', ) assert.match( artifactsSrc, /tabBar\.setAttribute\(['"]role['"],\s*['"]tablist['"]\)/, 'tab-bar container must carry role="tablist" for a11y', ) assert.match( artifactsSrc, /['"]list['"],\s*['"]board['"],\s*['"]timeline['"]/, 'exactly three tabs must exist in the exact order List / Board / Timeline', ) }) test('artifacts.js: version chip is a