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

197 lines
9.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
// (sessions list, layout panels, workspace navigation) and drives the user flows:
// include a chat beside the current one, pick chats in the main sidebar, close back to one.
// Needs a React install: REACT_DIR=<dir containing react/ and react-dom/>, otherwise one is
// searched for under the home directory; the tests skip when none is found.
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
function findReact() {
const ok = (d) => d && fs.existsSync(path.join(d, 'react', 'package.json')) && fs.existsSync(path.join(d, 'react-dom', 'server.js'))
if (ok(process.env.REACT_DIR)) return process.env.REACT_DIR
const roots = [path.join(here, '..', 'node_modules'), path.join(os.homedir(), 'rizzy-wt'), path.join(os.homedir(), 'Python')]
const seen = new Set()
const walk = (dir, depth) => {
if (depth < 0 || seen.has(dir)) return null
seen.add(dir)
if (ok(dir)) return dir
let ents = []
try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch { return null }
for (const e of ents) {
if (!e.isDirectory() || e.name === '.git') continue
const pnpm = e.name === '.pnpm'
if (pnpm) {
for (const p of fs.readdirSync(path.join(dir, e.name))) if (p.startsWith('react-dom@')) { const d = path.join(dir, e.name, p, 'node_modules'); if (ok(d)) return d }
continue
}
const r = walk(path.join(dir, e.name), depth - 1)
if (r) return r
}
return null
}
for (const r of roots) { const d = walk(r, 4); if (d) return d }
return null
}
const reactDir = findReact()
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()) } }
}
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms))
/** Load client.js and apply it against fakes that behave like DSH's services. */
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, addEventListener() {}, removeEventListener() {} }
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', 'd', 'sub'],
byId: {
a: { id: 'a', displayTitle: 'Alpha chat', updatedAt: 4, retainedBy: {} },
b: { id: 'b', displayTitle: 'Beta chat', updatedAt: 3, retainedBy: {} },
c: { id: 'c', displayTitle: 'Gamma chat', updatedAt: 2, retainedBy: {} },
d: { id: 'd', displayTitle: 'Delta chat', updatedAt: 1, retainedBy: {} },
sub: { id: 'sub', displayTitle: 'child', updatedAt: 9, parentId: 'a', origin: 'subagent', retainedBy: {} },
},
})
const setMainView = (id, count = 1) => {
const s = list.getSnapshot()
const byId = Object.fromEntries(Object.entries(s.byId).map(([k, v]) => [k, { ...v, retainedBy: k === id ? { mainView: count } : {} }]))
list.set({ ...s, byId })
}
const sessions = { list, retain: (id, opts) => ({ sessionId: id, source: opts.source, release() {} }) }
const panel = snapshotStore({ activePanelId: null })
const layout = { panelInfo: panel, selectPanel: (id) => panel.set({ activePanelId: id }) }
/** Like the stock workspace: retain as mainView (sync), release the old one, back to the conversation view. */
const uiWorkspace = {
opened: [],
openSession(id) { this.opened.push(id); setMainView(id, 2); setMainView(id, 1); layout.selectPanel(null) },
}
const regs = {}
const ctx = {
sessions, layout, uiWorkspace,
effect: (fn) => { fn() },
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', 'uiWorkspace'])
plugin.apply(ctx)
const h = React.createElement
const renderPanel = () => ReactDOMServer.renderToString(h(regs['main:dsh-chat-split'].Comp, { renderSlot: () => null, SessionProvider: ({ children }) => children }))
/** Click "Include in / Exclude from split view" in a chat's "…" menu. */
const menu = (sessionId) => {
let onSelect
let label
const Menu = regs['sidebar.workspaces.session.menu.item'].Comp
const Capture = (props) => { const el = Menu(props); onSelect = el.props.onSelect || el.props.onClick; label = String(el.props.children); return el }
ReactDOMServer.renderToString(h(Capture, { sessionId, useMenuOpenState: () => [true, () => {}] }))
return { label, click: () => onSelect() }
}
const windows = (html) => [...html.matchAll(/data-session-id="([^"]+)"/g)].map((m) => m[1])
return { React, ReactDOMServer, regs, list, panel, layout, uiWorkspace, setMainView, renderPanel, menu, windows }
}
test('no separate split button: only the main area, the window slot, the "…" menu item and settings', { skip }, () => {
const { regs } = load()
assert.equal(regs['sidebar.panellist'], undefined, 'no panel-list entry')
const main = regs['main:dsh-chat-split']
assert.ok(main)
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('"Include in split view" puts the chat beside the one being viewed, in place', { skip }, async () => {
const t = load()
t.setMainView('a') // viewing Alpha normally
const m = t.menu('c')
assert.equal(m.label, 'Include in split view')
m.click()
assert.equal(t.panel.getSnapshot().activePanelId, 'dsh-chat-split', 'the conversation area turned into the split')
await tick(200)
const html = t.renderPanel()
assert.deepEqual(t.windows(html), ['a', 'c'], 'viewed chat first, included chat beside it')
assert.match(html, /dcs-win focused glow" data-session-id="c"/, 'the new window is focused and glows')
assert.match(html, /Show only this chat/)
assert.match(html, /Open another chat beside/)
assert.equal(t.menu('c').label, 'Exclude from split view')
})
test('picking chats in the main sidebar while split: already open → glow, otherwise opens in the last used window', { skip }, async () => {
const t = load()
t.setMainView('a'); t.menu('b').click(); await tick(150) // split: a | b (b focused)
// Pick Alpha (already open) in the sidebar → stays split, Alpha glows.
t.uiWorkspace.openSession('a'); await tick(10)
assert.equal(t.panel.getSnapshot().activePanelId, 'dsh-chat-split')
let html = t.renderPanel()
assert.deepEqual(t.windows(html), ['a', 'b'])
assert.match(html, /glow" data-session-id="a"/)
// Pick Delta (not open) → it replaces the last used window (Alpha, just focused by the pick).
t.uiWorkspace.openSession('d'); await tick(200)
assert.equal(t.panel.getSnapshot().activePanelId, 'dsh-chat-split')
html = t.renderPanel()
assert.deepEqual(t.windows(html), ['d', 'b'])
assert.match(html, /glow" data-session-id="d"/)
// Leaving to another panel and coming back without picking a chat changes nothing.
t.layout.selectPanel('plugins'); await tick(900); t.layout.selectPanel(null); await tick(10)
assert.equal(t.panel.getSnapshot().activePanelId, 'dsh-chat-split')
assert.deepEqual(t.windows(t.renderPanel()), ['d', 'b'])
})
test('closing windows down to one returns to the normal single-chat view with that chat', { skip }, async () => {
const t = load()
t.setMainView('a'); t.menu('b').click(); await tick(150)
t.menu('c').click(); await tick(150)
assert.deepEqual(t.windows(t.renderPanel()), ['a', 'b', 'c'])
t.menu('a').click() // "Exclude from split view"
assert.deepEqual(t.windows(t.renderPanel()), ['b', 'c'])
t.menu('c').click()
await tick(10)
assert.deepEqual(t.uiWorkspace.opened.at(-1), 'b', 'the remaining chat opens normally')
assert.equal(t.panel.getSnapshot().activePanelId, null, 'normal conversation view')
assert.equal(t.menu('b').label, 'Include in split view')
})
test('layout: windows wrap into a grid when the conversation would get narrower than the readability width', { skip }, () => {
const { React, ReactDOMServer, regs } = load()
const h = React.createElement
const html = ReactDOMServer.renderToString(h(regs['settings.section'].Comp, { close() {} }))
assert.match(html, /Minimum window height/)
assert.match(html, /396px/)
// Preview 1300 px wide: a window needs 420 (conversation) + 180 (its chat list) → 2 columns × 2 rows for 3 chats.
assert.match(html, /2 columns × 2 rows/)
assert.match(html, /too narrow for all chats side by side/)
assert.match(html, /Alpha chat/, 'preview uses real chat titles')
window.innerWidth = 2400 // 2100 px → 3 × 600 fits in one row
assert.match(ReactDOMServer.renderToString(h(regs['settings.section'].Comp, { close() {} })), /3 columns × 1 row/)
})