Files
dsh-chat-split/test/client.test.mjs
T

163 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Headless checks for client.js: loads it through a fake __ModuleLoader__, captures the slot
// registrations, renders the components with react-dom/server against fake DSH services, and
// drives the "chat opened from the main sidebar while split view is showing" path.
// Needs a React install: REACT_DIR=<dir containing react/ and react-dom/> (skips otherwise).
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const candidates = [
process.env.REACT_DIR,
'/home/deepseek-harness/rizzy-wt/m3-history/node_modules/.pnpm/react-dom@19.3.0_react@19.3.0/node_modules',
].filter(Boolean)
const reactDir = candidates.find((d) => fs.existsSync(path.join(d, 'react')) && fs.existsSync(path.join(d, 'react-dom')))
const skip = reactDir ? false : 'no React install found (set REACT_DIR)'
function memStorage() {
const m = new Map()
return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) }
}
function snapshotStore(initial) {
let v = initial; const subs = new Set()
return { getSnapshot: () => v, subscribe: (f) => { subs.add(f); return () => subs.delete(f) }, set: (n) => { v = n; subs.forEach((f) => f()) } }
}
/** Load client.js and apply it against fakes; returns everything the test needs. */
function load() {
const req = createRequire(path.join(reactDir, 'react', 'package.json'))
const React = req('react')
const ReactDOMServer = req('react-dom/server')
globalThis.localStorage = memStorage()
globalThis.window = { innerWidth: 1600, innerHeight: 1000 }
globalThis.document = { head: { appendChild() {} }, createElement: () => ({ remove() {} }), querySelector: () => null }
globalThis.ResizeObserver = class { observe() {} disconnect() {} }
let mod
window.__ModuleLoader__ = { load: (m) => { mod = m } }
new Function(fs.readFileSync(path.join(here, '..', 'client.js'), 'utf8'))()
const plugin = mod.factory((name) => (name === 'react' ? React : {}))
const list = snapshotStore({
phase: 'ready',
ids: ['a', 'b', 'c', 'sub'],
byId: {
a: { id: 'a', displayTitle: 'Alpha chat', updatedAt: 3, retainedBy: {} },
b: { id: 'b', displayTitle: 'Beta chat', updatedAt: 2, retainedBy: {} },
c: { id: 'c', displayTitle: 'Gamma chat', updatedAt: 1, retainedBy: {} },
sub: { id: 'sub', displayTitle: 'child', updatedAt: 9, parentId: 'a', origin: 'subagent', retainedBy: {} },
},
})
const retained = []
const sessions = { list, retain: (id, opts) => { const r = { sessionId: id, source: opts.source, released: false, release() { this.released = true } }; retained.push(r); return r } }
const panel = snapshotStore({ activePanelId: null })
const layout = { panelInfo: panel, selectPanel: (id) => panel.set({ activePanelId: id }) }
const regs = {}
const effects = []
const ctx = {
sessions, layout,
effect: (fn) => { const d = fn(); effects.push(d) },
slots: {
inject: (name, make) => make(),
register: (opts, Comp) => { regs[opts.key ? `${opts.name}:${opts.key}` : opts.name] = { opts, Comp }; return () => {} },
},
}
assert.deepEqual(plugin.inject, ['slots', 'sessions', 'layout'])
plugin.apply(ctx)
return { React, ReactDOMServer, regs, list, panel, layout, retained }
}
test('registers panel, main entry with a session-scope child, menu item and settings', { skip }, () => {
const { regs } = load()
assert.equal(regs['sidebar.panellist'].opts.id, 'dsh-chat-split')
assert.equal(regs['sidebar.panellist'].opts.label, 'Split view')
const main = regs['main:dsh-chat-split']
assert.ok(main, 'main panel registered under key dsh-chat-split')
assert.deepEqual(main.opts.children, { 'dsh-chat-split.window': { kind: 'single', scope: 'session' } })
assert.ok(regs['dsh-chat-split.window'])
assert.ok(regs['sidebar.workspaces.session.menu.item'])
assert.equal(regs['settings.section'].opts.label, 'Chat split')
})
test('menu item includes/excludes, panel renders windows with top bars and own chat lists', { skip }, () => {
const { React, ReactDOMServer, regs } = load()
const h = React.createElement
const render = (el) => ReactDOMServer.renderToString(el)
const Menu = regs['sidebar.workspaces.session.menu.item'].Comp
const useMenuOpenState = () => [true, () => {}]
assert.match(render(h(Menu, { sessionId: 'a', useMenuOpenState })), /Include in split view/)
const Panel = regs['main:dsh-chat-split'].Comp
const empty = render(h(Panel, { renderSlot: () => null, SessionProvider: ({ children }) => children }))
assert.match(empty, /No chats in split view/)
assert.match(empty, /\+ Alpha chat/)
assert.doesNotMatch(empty, /child/, 'subagent children are not offered')
})
test('layout: side by side while each keeps the readability width, then wraps', { skip }, () => {
const { React, ReactDOMServer, regs } = load()
const h = React.createElement
const Settings = regs['settings.section'].Comp
const html = ReactDOMServer.renderToString(h(Settings, { close() {} }))
assert.match(html, /Minimum window height/)
assert.match(html, /396px/)
assert.match(html, /Readability width/)
assert.match(html, /Preview/)
// window.innerWidth 1600 → preview width 1300; 3 chats at 420+8 → 3 columns side by side.
assert.match(html, /3 columns × 1 row/)
assert.match(html, /all chats side by side/)
// Real chat titles appear in the preview windows.
assert.match(html, /Alpha chat/)
})
test('choosing an open chat in the main sidebar keeps split view and glows it', { skip }, async () => {
const { React, ReactDOMServer, regs, list, panel, layout } = load()
const h = React.createElement
const Menu = regs['sidebar.workspaces.session.menu.item'].Comp
// Capture the onSelect handler of the menu item by rendering its element tree shallowly.
let onSelect
const fakeMenuButton = Menu.length // keep lint quiet
void fakeMenuButton
// Use the plugin's own store through the panel: include 'b' via a synthetic menu render.
const MenuWithCapture = (props) => {
const el = Menu(props)
onSelect = el.props.onSelect || el.props.onClick
return el
}
ReactDOMServer.renderToString(h(MenuWithCapture, { sessionId: 'b', useMenuOpenState: () => [true, () => {}] }))
onSelect()
const Panel = regs['main:dsh-chat-split'].Comp
const html = ReactDOMServer.renderToString(h(Panel, { renderSlot: () => h('i', null, 'conv'), SessionProvider: ({ children }) => children }))
assert.match(html, /data-session-id="b"/)
assert.match(html, /Beta chat/)
assert.match(html, /Close window/)
assert.match(html, /Hide this window&#x27;s chat list/)
// Split view showing → user clicks 'b' in the stock sidebar: mainView retain changes, then panel → null.
layout.selectPanel('dsh-chat-split')
const s = list.getSnapshot()
list.set({ ...s, byId: { ...s.byId, b: { ...s.byId.b, retainedBy: { mainView: 1 } } } })
layout.selectPanel(null)
await new Promise((r) => setTimeout(r, 10))
assert.equal(panel.getSnapshot().activePanelId, 'dsh-chat-split', 'returned to split view')
const glowing = ReactDOMServer.renderToString(h(Panel, { renderSlot: () => null, SessionProvider: ({ children }) => children }))
assert.match(glowing, /dcs-win glow/)
// Leaving split view without opening a chat (no mainView change) stays left.
await new Promise((r) => setTimeout(r, 900))
layout.selectPanel('dsh-chat-split')
layout.selectPanel(null)
await new Promise((r) => setTimeout(r, 10))
assert.equal(panel.getSnapshot().activePanelId, null)
// Opening a chat that is NOT in the split leaves split view normally.
layout.selectPanel('dsh-chat-split')
const s2 = list.getSnapshot()
list.set({ ...s2, byId: { ...s2.byId, b: { ...s2.byId.b, retainedBy: {} }, c: { ...s2.byId.c, retainedBy: { mainView: 1 } } } })
layout.selectPanel(null)
await new Promise((r) => setTimeout(r, 10))
assert.equal(panel.getSnapshot().activePanelId, null)
})