Files
dsh-recall/test/client.test.js
T
2026-09-26 05:35:29 +01:00

411 lines
15 KiB
JavaScript

/**
* The browser half, without a browser.
*
* `client/client.js` is a plain script that hands a factory to the platform's
* module loader, so it can be exercised in Node by stubbing `window` and
* `require('react')`. That is worth doing rather than leaving the card to be
* eyeballed in a running app, because the two things most likely to be wrong are
* both structural and both invisible until someone opens Settings:
*
* - the root element must be an `<li>`, because the tab renders every card
* into a `<ul>` and a `<div>` never receives the list styling;
* - the card must start collapsed and disclose on click, because that is the
* framework's card contract and the thing that was wrong the first time.
*/
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
/** A React good enough to run one component: element, state, effect, memo. */
function makeReact() {
const hooks = []
let cursor = 0
return {
createElement(type, props, ...children) {
const merged = { ...(props ?? {}) }
if (children.length > 0) merged.children = children.length === 1 ? children[0] : children
return { type, props: merged }
},
useState(initial) {
const at = cursor
cursor += 1
if (!(at in hooks)) hooks[at] = typeof initial === 'function' ? initial() : initial
return [
hooks[at],
(next) => {
hooks[at] = typeof next === 'function' ? next(hooks[at]) : next
},
]
},
useEffect(fn) {
fn()
},
useMemo(fn) {
return fn()
},
/** Prepare for one more render, keeping hook state. */
__render() {
cursor = 0
},
}
}
/**
* Expand a function element the way React would.
*
* The card renders each control as `h(Field, …)`, and those elements are only
* real DOM once React calls them — so a walk that stops at `type === 'input'`
* finds nothing without this.
*/
function expand(node) {
if (node === null || typeof node !== 'object') return node
return typeof node.type === 'function' ? expand(node.type(node.props)) : node
}
/** Depth-first search over a rendered tree, expanding function components. */
function find(node, predicate, found = []) {
const current = expand(node)
if (current === null || typeof current !== 'object') return found
if (predicate(current)) found.push(current)
const children = current.props?.children
if (Array.isArray(children)) children.forEach((child) => find(child, predicate, found))
else find(children, predicate, found)
return found
}
/** The input carrying a given accessible label. */
function inputFor(tree, label) {
return find(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
}
/** The save button, whatever its current label. */
function saveButton(tree) {
return find(tree, (node) => node.type === 'button' && String(node.props.children).startsWith('Save'))[0]
}
/** A settings scope that records writes instead of performing them. */
function makeScope(options = {}) {
const { value = {}, user = null, status = 'ready', writable = true } = options
const calls = []
const snapshot = { status, value, user, writable }
return {
calls,
getSnapshot: () => snapshot,
subscribe: () => () => {},
set: async (key, next) => {
calls.push({ op: 'set', key, value: next })
},
unset: async (key) => {
calls.push({ op: 'unset', key })
},
}
}
// Load the browser bundle once, the way the platform loader would.
const loaded = []
globalThis.window = { __ModuleLoader__: { load: (entry) => loaded.push(entry) } }
await import('../client/client.js')
const entry = loaded[0]
/** Instantiate the module with a stubbed `react`. */
function makeModule() {
const react = makeReact()
const factory = entry.factory((name) => {
if (name === 'react') return react
throw new Error(`unexpected import: ${name}`)
})
return { react, module: factory }
}
/** Register every slot the browser half contributes, and render any of them. */
function mount(scope) {
const { react, module } = makeModule()
const registered = []
const slots = {
inject: (name, fn) => fn(),
register: (meta, component) => {
registered.push({ meta, component })
return () => {}
},
}
const ctx = {
slots,
inject: (deps, cb) =>
cb({
settingsScope: {
bind: (options) => {
assert.equal(options.namespace, 'dsh-recall')
return scope
},
},
slots,
}),
}
module.apply(ctx)
const byName = new Map(registered.map((entry) => [entry.meta.name, entry]))
/** Render one registered slot with the props its occupant would receive. */
const renderSlot = (name, props = {}) => {
const entry = byName.get(name)
assert.ok(entry !== undefined, `nothing registered for ${name}`)
react.__render()
const element = entry.component(props)
if (element === null) return null
return element.type(element.props)
}
/** Render the settings card, at its current state. */
const render = () => {
react.__render()
const element = byName.get('settings.plugin.item').component({ scope })
return element.type(element.props)
}
return { react, module, meta: byName.get('settings.plugin.item').meta, registered, render, renderSlot }
}
/** Open the card, and hand back a function returning the CURRENT body. */
function openCard(render) {
render().props.children[0].props.onClick()
// Call this after any edit: React re-renders then, and the Save button the
// user actually clicks is the one created by that later render.
return () => render().props.children[1]
}
test('the bundle registers under the loader with the plugin id', () => {
assert.equal(loaded.length, 1)
assert.equal(entry.id, 'dsh-recall')
assert.equal(typeof entry.factory, 'function')
})
test('the client module claims slots and reports the host namespace', () => {
const { module } = makeModule()
assert.equal(module.name, 'dsh-recall')
assert.deepEqual(module.inject, ['slots'])
})
test('the card is registered under the same key the host half serves', () => {
const { meta } = mount(makeScope())
assert.equal(meta.name, 'settings.plugin.item')
assert.equal(meta.key, 'dsh-recall', 'the host namespace and the card key must match, or nothing renders')
assert.equal(typeof meta.inject, 'function')
})
test('the root is an <li>, because the tab renders cards into a <ul>', () => {
const { render } = mount(makeScope())
assert.equal(render().type, 'li')
})
test('the card starts collapsed and discloses on click', () => {
const { render } = mount(makeScope())
const closed = render()
const [closedHeader, closedBody] = closed.props.children
assert.equal(closedHeader.type, 'button')
assert.equal(closedHeader.props['aria-expanded'], false, 'the card must start collapsed')
assert.equal(closedBody, null, 'a collapsed card renders no body')
// Click the header, then render again — hook state persists across renders.
closedHeader.props.onClick()
const [openHeader, openBodyNode] = render().props.children
assert.equal(openHeader.props['aria-expanded'], true)
assert.notEqual(openBodyNode, null, 'an expanded card renders its controls')
assert.equal(openBodyNode.type, 'div')
})
test('every host setting appears as a control', () => {
const { render } = mount(makeScope())
const body = openCard(render)
const labels = find(body(), (node) => node.type === 'input').map((node) => node.props['aria-label'])
for (const label of [
'Embedding endpoint',
'Embedding model',
'Embedding dimensions',
'Embedding API key',
'Lines per excerpt',
'Index warm-up budget per search (ms)',
]) {
assert.ok(labels.includes(label), `expected a control labelled ${label}, saw ${labels.join(' | ')}`)
}
})
test('an unavailable namespace renders nothing at all', () => {
const { render } = mount(makeScope({ status: 'unavailable' }))
assert.equal(render(), null)
})
test('a read-only namespace says so rather than offering a dead form', () => {
const { render } = mount(makeScope({ writable: false }))
const body = openCard(render)
assert.ok(find(body(), (node) => node.props?.role === 'status').length > 0)
})
test('a save writes only the fields the user changed', async () => {
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434', excerptLines: 40 } })
const { render } = mount(scope)
const body = openCard(render)
inputFor(body(), 'Lines per excerpt').props.onChange({ target: { value: '25' } })
const button = saveButton(body())
assert.match(String(button.props.children), /Save 1 change/)
await button.props.onClick()
assert.deepEqual(scope.calls, [{ op: 'set', key: 'excerptLines', value: 25 }])
})
test('clearing a non-secret field unsets it rather than writing an empty value', async () => {
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434' } })
const { render } = mount(scope)
const body = openCard(render)
inputFor(body(), 'Embedding endpoint').props.onChange({ target: { value: '' } })
await saveButton(body()).props.onClick()
assert.deepEqual(scope.calls, [{ op: 'unset', key: 'embeddingApiBase' }])
})
test('a list field round-trips as a comma separated string', async () => {
const scope = makeScope({ value: { exclude: ['dist/', 'vendor/'] } })
const { render } = mount(scope)
const body = openCard(render)
const input = inputFor(body(), 'Extra exclude patterns')
assert.equal(input.props.value, 'dist/, vendor/')
input.props.onChange({ target: { value: 'dist/, vendor/, tmp/' } })
await saveButton(body()).props.onClick()
assert.deepEqual(scope.calls, [{ op: 'set', key: 'exclude', value: ['dist/', 'vendor/', 'tmp/'] }])
})
test('the API key is never sent back to the page, and blank does not clear it', async () => {
// The host marks this field role('secret'), so it arrives absent. A save with
// the field untouched must not write anything for it — otherwise opening the
// card and pressing Save would delete the key.
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434' }, user: { embeddingApiKey: 'stored' } })
const { render } = mount(scope)
const body = openCard(render)
const keyInput = inputFor(body(), 'Embedding API key')
assert.ok(keyInput !== undefined)
assert.equal(keyInput.props.type, 'password')
assert.equal(keyInput.props.value, '', 'a redacted secret must render blank')
assert.equal(keyInput.props.placeholder, '(unchanged)')
// Nothing is dirty, so there is nothing to save and the button is inert.
const button = saveButton(body())
assert.equal(button.props.disabled, true)
await button.props.onClick()
assert.deepEqual(scope.calls, [], 'an untouched secret must never be written or unset')
})
test('an unsaved edit is marked on the header while collapsed', () => {
const { render } = mount(makeScope({ value: { excerptLines: 40 } }))
const body = openCard(render)
inputFor(body(), 'Lines per excerpt').props.onChange({ target: { value: '25' } })
// Collapse again and look at the header.
render().props.children[0].props.onClick()
const collapsed = render()
const marked = find(collapsed.props.children[0], (node) => node.props?.children === 'unsaved')
assert.equal(marked.length, 1, 'unsaved edits must be visible while the card is folded away')
})
test('the browser half contributes four slots, one of them the header corner', () => {
const { registered } = mount(makeScope())
const names = registered.map((entry) => entry.meta.name).sort()
assert.deepEqual(names, [
'conversation.session.header.corner',
'settings.plugin.item',
'sidebar.right.pane.tab',
'sidebar.right.pane.tab.title',
])
})
/** A projection value shaped like the host unit's wire view. */
function usageView(overrides = {}) {
return {
searches: 3,
indexes: 0,
failed: 0,
excerpts: 9,
files: 5,
weak: 0,
returnedBytes: 12_000,
sourceBytes: 480_000,
avoidedBytes: 468_000,
avoidedTokens: 117_000,
compression: 40,
rereadTokens: 0,
avoidedUsd: 0.0008,
lastFocus: '.',
...overrides,
}
}
test('the header chip renders nothing when the conversation never used it', () => {
const { renderSlot } = mount(makeScope())
// The corner is laid out only while its occupant renders something, so this
// is the state that must cost no space at all.
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => undefined }), null)
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null)
// And a host without the projection service at all.
assert.equal(renderSlot('conversation.session.header.corner', {}), null)
})
test('the header chip leads with what was kept out, not what it cost', () => {
const { renderSlot } = mount(makeScope())
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() })
assert.ok(chip !== null)
const text = String(chip.props.children)
assert.match(text, /Recall 3/)
assert.match(text, /40/)
assert.match(text, /kept out/)
// Money is deliberately absent from the chip: a fraction of a penny reads as
// the tool being useless rather than as the arithmetic being honest.
assert.ok(!text.includes('$'), `chip showed a price: ${text}`)
})
test('the chip tooltip carries the figures the chip has no room for', () => {
const { renderSlot } = mount(makeScope())
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ rereadTokens: 500_000 }) })
const detail = chip.props.title
assert.match(detail, /3 searches/)
assert.match(detail, /9 excerpts from 5 files/)
assert.match(detail, /would have been read/)
assert.match(detail, /estimate/i, 'the comparison must be labelled as one')
})
test('the sidebar panel explains an unused conversation instead of showing zeroes', () => {
const { renderSlot } = mount(makeScope())
const empty = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ searches: 0, indexes: 0, excerpts: 0 }) })
const text = JSON.stringify(empty)
assert.match(text, /has not used recall yet/)
assert.ok(!/\b0 B\b/.test(text), 'zeroes would read as "this did nothing"')
})
test('the sidebar panel says so when the projection is absent', () => {
const { renderSlot } = mount(makeScope())
const missing = renderSlot('sidebar.right.pane.tab', {})
assert.match(JSON.stringify(missing), /not available for this conversation/)
})
test('the sidebar panel reports the comparison and the compounding', () => {
const { renderSlot } = mount(makeScope())
const panel = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ rereadTokens: 1_200_000 }) })
const text = JSON.stringify(panel)
assert.match(text, /Kept out of this conversation/)
assert.match(text, /Smaller than reading the files/)
assert.match(text, /Re-read since/)
assert.match(text, /1,200,000 tokens/)
assert.match(text, /upper bound/, 'the panel must say what the figure is not')
})
test('the tab title is registered under the same key as the panel', () => {
const { registered } = mount(makeScope())
const panel = registered.find((entry) => entry.meta.name === 'sidebar.right.pane.tab')
const title = registered.find((entry) => entry.meta.name === 'sidebar.right.pane.tab.title')
assert.equal(panel.meta.key, 'dsh-recall')
assert.equal(title.meta.key, panel.meta.key, 'the title must bind to the panel it names')
})