// DOM-shape tests for the Session Tree page controller. Task #198: verify
// the readability rewrite lands the expected geometry — fork children get
// their own labels (never verbatim-copied parent titles), children live
// inside a nested
, the rail-kind data attribute reflects
// the edge kind so the CSS border-left picks the right style.
//
// Why this shape: session-tree-page.js is an IIFE guarded by `window.dsh`
// (see the file header). We can eval() the source inside a minimal
// window/document stub — same trick renderer-harness.js uses for
// renderer.js — and drive it through its public seam `window.__dshTree`.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { makeElement: baseMakeElement } = require('./renderer-harness.js')
// Small extension over the shared shim: give `style` a setProperty so the
// tree page's `li.style.setProperty('--depth', ...)` call doesn't throw.
function makeElement(tag) {
const el = baseMakeElement(tag)
el.style = { _s: {}, setProperty(k, v) { this._s[k] = String(v) }, getPropertyValue(k) { return this._s[k] || '' } }
return el
}
// Walk a DOM tree and return every node matching a predicate. The shared
// shim's querySelector doesn't understand `data-*` attribute selectors
// because dataset writes bypass el.attrs; a small predicate walk is
// cheaper than teaching the selector engine.
function walk(root, out = []) {
if (!root) return out
if (Array.isArray(root.children)) {
for (const c of root.children) {
out.push(c)
walk(c, out)
}
}
return out
}
function findRow(document, sessionId) {
for (const n of walk(document.body)) {
if (n && n.classList && n.classList.contains('tree-row') && n.dataset && n.dataset.sessionId === sessionId) return n
}
return null
}
function findChildrenUL(row) {
return (row.children || []).find((c) => c.tagName === 'UL' && c.classList.contains('tree-children'))
}
function findByClass(root, cls) {
for (const n of walk(root)) {
if (n && n.classList && n.classList.contains(cls)) return n
}
return null
}
// Small extension over the shared shim: give `style` a setProperty so the
// tree page's `li.style.setProperty('--depth', ...)` call doesn't throw.
function makeElement(tag) {
const el = baseMakeElement(tag)
el.style = { _s: {}, setProperty(k, v) { this._s[k] = String(v) }, getPropertyValue(k) { return this._s[k] || '' } }
return el
}
function loadTreePage() {
const docStub = {
body: makeElement('body'),
_byId: new Map(),
_listeners: {},
createElement(tag) { return makeElement(tag) },
createTextNode(txt) { const t = makeElement('#text'); t._text = String(txt); return t },
getElementById(id) {
const cached = this._byId.get(id)
if (cached) return cached
const el = makeElement('div')
el.setAttribute('id', id)
this._byId.set(id, el)
docStub.body.appendChild(el)
return el
},
querySelector(sel) {
// Walk body — reuse makeElement's own querySelector by pointing at body.
return docStub.body.querySelector(sel)
},
querySelectorAll(sel) { return docStub.body.querySelectorAll(sel) },
addEventListener(name, fn) {
if (!this._listeners[name]) this._listeners[name] = []
this._listeners[name].push(fn)
},
readyState: 'complete',
}
const windowStub = {
document: docStub,
dsh: {
async forkSession() { return { childSessionId: 'x' } },
async sessionEvents() { return { events: [] } },
},
// Sibling module handles the tree page reads defensively. The page
// only touches __dshPanelsC (relativeTime, smartSessionTitle) and
// __dshChat/__dshContextMeter — leave them undefined and the page
// uses its fallback path.
__dshChat: { getEntries() { return [] }, async refreshSessionList() {}, async selectSession() {} },
__dshTabs: { switchTo() {} },
setTimeout, clearTimeout,
}
// globalThis / CSS — the page reads CSS.escape defensively.
const globalStub = { SessionTree: require('../src/renderer/session-tree.js') }
const src = fs.readFileSync(
path.join(__dirname, '..', 'src', 'renderer', 'session-tree-page.js'),
'utf8',
)
// eslint-disable-next-line no-new-func
const fn = new Function(
'window', 'document', 'globalThis', 'CSS',
src,
)
fn(windowStub, docStub, globalStub, { escape: (s) => String(s).replace(/[^\w-]/g, '\\$&') })
return { window: windowStub, document: docStub, tree: windowStub.__dshTree }
}
// --- Rendering fork children ------------------------------------------------
test('emitNode: fork child with duplicate title renders "(no new messages yet)" label', () => {
const { window, document, tree } = loadTreePage()
// Seed a minimal fork-tree via a stubbed getEntries. Reproduces the exact
// bug the user hit — child.title == parent.title after seed replay.
window.__dshChat.getEntries = () => [
{ sessionId: 'p', title: 'parent turn one', header: { title: 'parent turn one' }, lastEventTime: Date.now() - 1000, hasUserMessage: true },
// Fork child that inherited the parent's title (the seed replay bug).
{ sessionId: 'c1', title: 'parent turn one', header: { title: 'parent turn one', parentSession: 'p', seedLength: 4 }, lastEventTime: Date.now() - 500, hasUserMessage: false },
// Fork child that has its own message → label surfaces that message.
{ sessionId: 'c2', title: 'try a different palette', header: { title: 'try a different palette', parentSession: 'p', seedLength: 4 }, lastEventTime: Date.now() - 100, hasUserMessage: true },
]
tree.render()
// Parent row title = original title (no rewrite for roots).
const parentRow = findRow(document, 'p')
assert.ok(parentRow, 'parent row rendered')
const parentTitle = findByClass(parentRow, 'tree-row-title')
assert.equal(parentTitle.textContent, 'parent turn one', 'root row keeps its own title')
// Fork child c1 — duplicate title path.
const c1Row = findRow(document, 'c1')
assert.ok(c1Row, 'first fork child rendered')
const c1Title = findByClass(c1Row, 'tree-row-title')
assert.equal(
c1Title.textContent,
'fork @ seq 3 · (no new messages yet)',
'duplicate-title fork child shows the "no new messages yet" placeholder',
)
assert.ok(c1Title.classList.contains('is-untitled'), 'no-own-message label is muted')
// Fork child c2 — own-message path.
const c2Row = findRow(document, 'c2')
const c2Title = findByClass(c2Row, 'tree-row-title')
assert.equal(
c2Title.textContent,
'fork @ seq 3 · try a different palette',
'own-message fork child shows its own signal',
)
assert.ok(!c2Title.classList.contains('is-untitled'), 'own-message label is not muted')
})
test('emitNode: fork children live inside with rail-kind=fork', () => {
const { window, document, tree } = loadTreePage()
window.__dshChat.getEntries = () => [
{ sessionId: 'p', title: 'root work', header: { title: 'root work' }, lastEventTime: Date.now(), hasUserMessage: true },
{ sessionId: 'c', title: 'root work', header: { title: 'root work', parentSession: 'p', seedLength: 2 }, lastEventTime: Date.now() - 100, hasUserMessage: false },
]
tree.render()
const parentRow = findRow(document, 'p')
const kids = findChildrenUL(parentRow)
assert.ok(kids, 'children rendered inside nested under the parent')
assert.equal(kids.dataset.railKind, 'fork', 'rail-kind = fork for a normal fork child')
// Child row lives inside the nested ul, NOT as a flat sibling of the parent.
const childInside = kids.children.find((r) => r.classList.contains('tree-row') && r.dataset.sessionId === 'c')
assert.ok(childInside, 'child row nests inside the children ul (not flat sibling)')
})
test('emitNode: subagent-origin children get rail-kind=subagent', () => {
const { window, document, tree } = loadTreePage()
window.__dshChat.getEntries = () => [
{ sessionId: 'p', title: 'parent', header: { title: 'parent' }, lastEventTime: Date.now(), hasUserMessage: true },
{ sessionId: 's', title: 'run lint', header: { title: 'run lint', parentSession: 'p', originKind: 'subagent', seedLength: 2 }, lastEventTime: Date.now() - 50, hasUserMessage: true },
]
tree.render()
const parentRow = findRow(document, 'p')
const kids = findChildrenUL(parentRow)
assert.ok(kids, 'children container rendered for subagent parent')
assert.equal(kids.dataset.railKind, 'subagent', 'rail-kind = subagent when the child\'s origin is subagent')
const subRow = kids.children.find((r) => r.classList.contains('tree-row') && r.dataset.sessionId === 's')
assert.ok(subRow, 'subagent row nests inside the parent\'s ul.tree-children')
assert.equal(subRow.dataset.edge, 'subagent', 'subagent row carries data-edge=subagent for CSS')
// Label prefix uses the "subagent @ seq" kind so the reader can tell at
// a glance the child was system-spawned, not user-forked.
const subTitle = findByClass(subRow, 'tree-row-title')
assert.ok(subTitle.textContent.startsWith('subagent @ seq'),
`expected subagent-prefixed label, got: ${subTitle.textContent}`)
})
test('emitNode: root rows never render when they have no forks', () => {
const { window, document, tree } = loadTreePage()
window.__dshChat.getEntries = () => [
{ sessionId: 'leaf', title: 'lone chat', header: { title: 'lone chat' }, lastEventTime: Date.now(), hasUserMessage: true },
]
tree.render()
const leafRow = findRow(document, 'leaf')
assert.ok(leafRow, 'leaf root rendered')
const kids = findChildrenUL(leafRow)
assert.equal(kids, undefined, 'leaf root does not spawn an empty ')
})