163 lines
7.9 KiB
JavaScript
163 lines
7.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, 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'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)
|
||
})
|