197 lines
9.9 KiB
JavaScript
197 lines
9.9 KiB
JavaScript
// 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/)
|
||
})
|