Files
dsh-recall/test/client.test.js
T
NotExpectedYet 233215e6c9 fix: draw the settings on the plugin card, not a click deeper
The page was registered into `plugins.row.config`, keyed
`<package>#<row id>`. That seat puts a page behind the ROW's title button on the
bundle's card — the right home for one row of a bundle that declares several,
and the reasoning the previous commit wrote down. What it missed is that this
bundle declares exactly one row, so its settings became reachable only by
opening the card and noticing that the row title had turned into a button.
Reported from a real install as "it shows the plugin just no settings", while
the sibling plugin's settings were visible: that one registers into
`plugins.bundle.config`.

`plugins.bundle.config` is the same document, dispatched on the card itself:
`PackageDetail` gates a section on `ledger.bundles.has(pkg.name)` and renders
`renderSlot("plugins.bundle.config", { view: "page" }, { entryKey: pkg.name })`
between the description and the rows. So the key becomes the bundle's package
name, and the form draws where someone opening "dsh-recall" is already looking.

The section gets no `form` prop at this seat, unlike the row page — which is
fine, because the card reads its own form from `configForms` rather than
depending on the one handed in.

Verified by serving the bundle from a profile that links this checkout and
reading the artifact back: the served `client.js` carries
`plugins.bundle.config` at the registration and names the retired seat only in
prose. The tests pin the new key and now fail if either the seat or the key
drifts.
2026-09-26 07:05:52 +01:00

643 lines
24 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`, `react`,
* and the one primitives package it imports. That is worth doing rather than
* leaving the page to be eyeballed in a running app, because the failures this
* file exists to catch are structural and invisible until someone opens Plugins:
*
* - the configuration page is registered into `plugins.bundle.config` under
* the bundle's package name, which is the seat the Plugins page draws as a
* section on the plugin's own card. The 0.1.5 seat (`settings.plugin.item`,
* bound through `settingsScope`) does not exist in 0.1.7, so a page left in
* it registers against a slot nobody declares and simply never appears; the
* sibling seat `plugins.row.config` renders one click deeper instead, on a
* single row's page, which is not where someone opening the card looks.
* - the right-sidebar tab needs a TYPE registered before its body means
* anything: the keyed body slot is dispatched under the definition's `id`.
* - the field list here and the `Config` schema in index.js must name the same
* settings, or a setting is silently uneditable.
*
* Two things are stubbed rather than real, and the tests that depend on them say
* so. `SettingsFormModel` and the field controls come from
* `@deepseek-ai/dsh-client-ui-primitives`, whose bundle imports CSS and is
* therefore not loadable in bare Node; the stub records what this plugin hands
* the framework and answers the same questions the real one does. What the
* framework is trusted for — staging, save ordering, revision fencing, the
* rendered chrome — is covered by the harness's own suite, not here.
*/
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { SETTING_KEYS } from '../index.js'
/** 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 its controls as `h(Field, …)`, and those elements are only
* real nodes 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
}
/**
* Stand-ins for the framework's settings-form kit.
*
* The model records the specs and secret writers this plugin hands it, and
* answers `shell`/`field`/`plan` the way the real one does; the controls render
* plain nodes carrying the props they were given.
*/
function makePrimitives(record) {
class SettingsFormModel {
constructor(scope, specs, secrets = []) {
this.scope = scope
this.specs = specs
this.secrets = secrets
this.staged = new Map()
record.model = this
}
bind(project) {
this.project = project
return { getSnapshot: () => project(), subscribe: () => () => {} }
}
shell() {
return {
available: this.scope.getSnapshot().status === 'ready',
writable: this.scope.getSnapshot().writable,
dirty: this.staged.size > 0,
invalid: [...this.staged].some(([name, text]) => {
if (this.secrets.some((entry) => entry.field === name)) return false
return this.specs.find((entry) => entry.field === name).parse(text) === undefined
}),
saving: false,
failed: false,
}
}
field(name) {
if (this.secrets.some((entry) => entry.field === name)) {
return { text: this.staged.get(name) ?? '', overridden: false, invalid: false }
}
const spec = this.specs.find((entry) => entry.field === name)
const text = this.staged.has(name)
? this.staged.get(name)
: spec.format(this.scope.getSnapshot().value?.[name])
const write = spec.parse(text)
const user = this.scope.getSnapshot().user
return {
text,
overridden: this.staged.has(name) ? write?.kind === 'set' : Object.hasOwn(user ?? {}, name),
invalid: write === undefined,
}
}
actions() {
return {
edit: (name, text) => {
this.staged.set(name, text)
record.edits.push({ name, text })
},
resetField: (name) => this.staged.set(name, undefined),
save: () => {
record.saved = true
},
discard: () => this.staged.clear(),
}
}
dispose() {
record.disposed = true
}
/** What a save would write, built from this plugin's own spec objects. */
plan() {
const plan = []
for (const [name, text] of this.staged) {
if (this.secrets.some((entry) => entry.field === name)) {
plan.push({ field: name, run: () => this.secrets.find((s) => s.field === name).write(String(text)) })
continue
}
const write = this.specs.find((entry) => entry.field === name).parse(String(text))
if (write === undefined) plan.push({ field: name })
else if (write.kind === 'clear') plan.push({ field: name, op: { op: 'unset', path: [name] } })
else plan.push({ field: name, op: { op: 'set', path: [name], value: write.value } })
}
return plan
}
}
return {
SettingsForm: (props) => ({ type: 'form', props }),
SettingsValueField: (props) => ({ type: 'input', props }),
SettingsSecretField: (props) => ({ type: 'input', props: { ...props, type: 'password' } }),
SettingsFormModel,
settingsNumberField: (field) => ({
field,
format: (value) => (value === undefined || value === null ? '' : String(value)),
parse: (text) =>
String(text).trim() === ''
? { kind: 'clear' }
: Number.isFinite(Number(text))
? { kind: 'set', value: Number(text) }
: undefined,
}),
settingsTextField: (field) => ({
field,
format: (value) => (value === undefined || value === null ? '' : String(value)),
parse: (text) => (String(text).trim() === '' ? { kind: 'clear' } : { kind: 'set', value: text }),
}),
}
}
// 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 bundle, mount it on a stubbed context, and expose what it
* registered.
*
* @param {object} options `services` mounts extra optional dependencies,
* `served` is what the (stubbed) describe mirror reports, and
* `withConfigForms: false` models a deployment with no settings provider.
*/
function mount(options = {}) {
const { services = {}, served = ['dsh-recall'], withConfigForms = true } = options
const record = { edits: [], saved: false, disposed: false, model: undefined }
const react = makeReact()
const primitives = makePrimitives(record)
const factory = entry.factory((name) => {
if (name === 'react') return react
if (name === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`unexpected import: ${name}`)
})
const registered = []
const tabTypes = []
const whileServedCalls = []
const slots = {
inject: (name, fn) => fn(),
register: (meta, component) => {
registered.push({ meta, component })
return () => {}
},
}
const form = {
calls: [],
snapshot: { status: 'ready', value: {}, base: {}, user: {}, writable: true, revision: 9 },
getSnapshot() {
return this.snapshot
},
subscribe: () => () => {},
async mutate(ops, revision) {
this.calls.push({ ops, revision })
return true
},
}
const describe = {
getSnapshot: () => ({
status: 'ready',
view: {
writable: true,
namespaces: served.map((ns) => ({ ns, secrets: [{ path: ['embeddingApiKey'], set: true }] })),
},
}),
subscribe: () => () => {},
}
const configForms = {
requested: undefined,
get(id) {
this.requested = id
return form
},
describe: () => describe,
whileServed: (namespaces, register) => {
whileServedCalls.push({ namespaces })
const live = namespaces.some((ns) => served.includes(ns))
const off = live ? register(new Set(served)) : undefined
return () => off?.()
},
}
const sidebarRightTabs =
services.sidebarRightTabs ??
{
register: (definition) => {
tabTypes.push(definition)
return () => {}
},
}
const ctx = {
slots,
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') record.disposables = [...(record.disposables ?? []), dispose]
},
inject: (deps, callback) => {
const available = {
...services,
sidebarRightTabs,
...(withConfigForms ? { configForms } : {}),
}
const missing = deps.filter((dep) => available[dep] === undefined)
if (missing.length > 0) return
callback({ ...ctx, ...Object.fromEntries(deps.map((dep) => [dep, available[dep]])) })
},
}
factory.apply(ctx)
const byName = new Map(registered.map((item) => [item.meta.name, item]))
/** Render one registered slot with the props its occupant would receive. */
const renderSlot = (name, props = {}) => {
const item = byName.get(name)
assert.ok(item !== undefined, `nothing registered for ${name}`)
react.__render()
const element = item.component(props)
if (element === null) return null
return element.type(element.props)
}
/**
* The settings page's element plus the props it would receive.
*
* `hooks` is resolved into `use<Name>` here exactly as the slot machinery
* does, so the component under test sees the props a real render would give it.
*/
const card = (view) => {
const item = byName.get('plugins.bundle.config')
assert.ok(item !== undefined, 'no settings page was registered')
const props = { ...item.meta.inject(), view }
delete props.hooks
props.useRecallSettings = (selector) => selector(record.model.project())
return { item, props, element: item.component(props) }
}
return {
react,
module: factory,
record,
form,
registered,
tabTypes,
whileServedCalls,
renderSlot,
card,
configForms,
}
}
/** The rendered page's control nodes, keyed by their HTML id. */
function controlsOf(element) {
const form = expand(element)
const controls = new Map()
for (const node of find(form, (candidate) => candidate.type === 'input')) controls.set(node.props.id, node)
return controls
}
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 the slot registry in its inject list', () => {
const { module } = mount()
assert.equal(module.name, 'dsh-recall')
assert.deepEqual(module.inject, ['slots'])
})
test('the configuration page claims the seat the plugin card draws', () => {
const { registered } = mount()
const page = registered.find((item) => item.meta.name === 'plugins.bundle.config')
assert.ok(page !== undefined, 'the page must register into plugins.bundle.config')
// Keyed by the BUNDLE's package name, which is what the Plugins page
// dispatches this seat with (`entryKey: pkg.name`). The sibling seat,
// `plugins.row.config`, would be keyed `<package>#<row id>` and would render
// one click deeper, on the row's own page.
assert.equal(page.meta.key, 'dsh-recall')
assert.equal(typeof page.meta.inject, 'function')
})
test('the page edits the settings entry the Host keys this plugin under', () => {
const { configForms, whileServedCalls } = mount()
assert.equal(configForms.requested, 'dsh-recall')
assert.deepEqual(whileServedCalls, [{ namespaces: ['dsh-recall'] }])
})
test('no page is registered for a namespace the Host does not serve', () => {
// A control that writes nowhere is worse than no control: the page would open
// onto fields nothing would accept.
const { registered } = mount({ served: ['some-other-plugin'] })
assert.equal(
registered.some((item) => item.meta.name === 'plugins.bundle.config'),
false,
)
})
test('the chip and the sidebar tab survive a deployment with no settings provider', () => {
// The page is reached through `ctx.inject`, not a top-level `inject`, so a
// profile without a settings provider loses the page and keeps the rest.
const { registered, tabTypes } = mount({ withConfigForms: false })
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner',
'sidebar.right.pane.tab',
])
assert.equal(tabTypes.length, 1, 'the tab type is independent of settings')
})
test('the header chip is a single-seat contribution with no key', () => {
const { registered } = mount()
const chip = registered.find((item) => item.meta.name === 'conversation.session.header.corner')
assert.ok(chip !== undefined)
assert.equal(chip.meta.key, undefined, 'the corner is a single seat, so it takes no key')
})
test('the sidebar tab registers a type AND a body under the same id', () => {
// The body slot is dispatched under the id of the type in force for a tab's
// kind. A body with no type is a page nothing can open.
const { registered, tabTypes } = mount()
const body = registered.find((item) => item.meta.name === 'sidebar.right.pane.tab')
assert.ok(body !== undefined)
assert.equal(body.meta.key, 'dsh-recall')
assert.equal(tabTypes.length, 1, 'exactly one tab type')
assert.equal(tabTypes[0].id, 'dsh-recall', "the body's key must be the definition's id")
assert.equal(tabTypes[0].kind, 'dsh-recall')
assert.equal(tabTypes[0].priority, 'extension', 'a type from outside the product sits in that band')
assert.equal(typeof tabTypes[0].title, 'function')
assert.equal(tabTypes[0].title(), 'Recall')
// A page type recognises no address, so the guide entry is the only way in.
assert.equal(tabTypes[0].guide.length, 1)
assert.equal(tabTypes[0].guide[0].title(), 'Recall')
assert.match(tabTypes[0].guide[0].description(), /kept out/)
})
test('the browser half contributes three seats, none of them the retired 0.1.5 one', () => {
const { registered } = mount()
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner',
'plugins.bundle.config',
'sidebar.right.pane.tab',
])
})
test('the page asks for nothing to be edited before the user types', () => {
const { card } = mount()
const { element } = card('page')
const state = find(element, (node) => node.type === 'form')[0]
assert.equal(state.props.state.dirty, false)
assert.equal(state.props.state.invalid, false)
assert.equal(state.props.state.writable, true)
assert.equal(typeof state.props.onSave, 'function')
assert.equal(typeof state.props.onDiscard, 'function')
assert.equal(state.props.labels.save, 'Save')
})
test('the summary view is the one-liner the Plugins page falls back to', () => {
const { card } = mount()
// The page asks for `summary` to fill a row with no description of its own,
// so it must be text, and it must describe the plugin rather than the form.
const summary = card('summary').element
assert.equal(typeof summary, 'string')
assert.match(summary, /Local embedding endpoint/)
// And the page view is the form, not that string.
assert.notEqual(typeof card('page').element, 'string')
})
test('every declared setting renders a control, and the API key renders as a secret', () => {
const { card } = mount()
const controls = controlsOf(card('page').element)
assert.deepEqual(
[...controls.keys()].sort(),
SETTING_KEYS.map((key) => `dsh-recall-${key}`).sort(),
'every setting in the host schema must be editable here, and nothing else',
)
const secret = controls.get('dsh-recall-embeddingApiKey')
assert.equal(secret.props.type, 'password')
assert.equal(secret.props.text, '', 'a redacted secret renders blank')
// The page learns whether a key is stored, never the key itself.
assert.equal(secret.props.configured, true)
assert.match(secret.props.stateLabel, /stored/)
})
test('the field list and the schema cannot drift apart', () => {
const { record } = mount()
assert.deepEqual(
record.model.specs.map((spec) => spec.field).sort(),
SETTING_KEYS.filter((key) => key !== 'embeddingApiKey').sort(),
'every non-secret setting needs a conversion spec',
)
assert.deepEqual(record.model.secrets.map((spec) => spec.field), ['embeddingApiKey'])
})
test('a list setting round-trips as comma separated text', () => {
const { record } = mount()
const spec = record.model.specs.find((entry) => entry.field === 'exclude')
assert.equal(spec.format(['dist/', 'vendor/']), 'dist/, vendor/')
assert.deepEqual(spec.parse('dist/, vendor/, tmp/'), { kind: 'set', value: ['dist/', 'vendor/', 'tmp/'] })
assert.deepEqual(spec.parse(''), { kind: 'clear' }, 'emptying the control reverts to the schema default')
})
test('a boolean setting accepts the obvious spellings and refuses the rest', () => {
const { record } = mount()
const spec = record.model.specs.find((entry) => entry.field === 'rerankEnabled')
assert.equal(spec.format(true), 'true')
assert.equal(spec.format(false), 'false')
assert.deepEqual(spec.parse('TRUE'), { kind: 'set', value: true })
assert.deepEqual(spec.parse('off'), { kind: 'set', value: false })
assert.deepEqual(spec.parse(''), { kind: 'clear' })
// Anything else blocks the save rather than guessing which way the user meant.
assert.equal(spec.parse('maybe'), undefined)
})
test('an invalid draft is reported, so the framework can block the save', () => {
const { record } = mount()
record.model.actions().edit('embeddingConcurrency', 'lots')
assert.equal(record.model.field('embeddingConcurrency').invalid, true)
assert.equal(record.model.shell().invalid, true)
})
test('an edit is staged in the model, never written to the document on the spot', () => {
const { record, form } = mount()
record.model.actions().edit('embeddingConcurrency', '4')
assert.deepEqual(record.edits, [{ name: 'embeddingConcurrency', text: '4' }])
assert.deepEqual(form.calls, [], 'only a save writes')
})
test('the API key is written through the document, fenced by the current revision', async () => {
const { record, form } = mount()
const secret = record.model.secrets[0]
assert.equal(secret.field, 'embeddingApiKey')
await secret.write('sk-secret')
assert.deepEqual(form.calls, [
{ ops: [{ op: 'set', path: ['embeddingApiKey'], value: 'sk-secret' }], revision: 9 },
])
})
test('a blank secret is never staged, so opening the page cannot delete the key', () => {
const { record } = mount()
assert.deepEqual(record.model.plan(), [], 'nothing typed means nothing to write')
})
test('a typed secret is planned as a write outside the section, not as a section op', () => {
const { record } = mount()
record.model.actions().edit('embeddingApiKey', 'sk-secret')
const plan = record.model.plan()
assert.equal(plan.length, 1)
assert.equal(plan[0].field, 'embeddingApiKey')
assert.equal(plan[0].op, undefined, 'a secret rides its own writer, because the wire never returns it')
})
test('a later revision is carried by the next write rather than the one staged from', async () => {
const { record, form } = mount()
form.snapshot = { ...form.snapshot, revision: 12 }
await record.model.secrets[0].write('sk-newer')
assert.equal(form.calls[0].revision, 12, 'every write carries the revision it was staged against')
})
test('the form subscription is released with the plugin', () => {
const { record } = mount()
for (const dispose of record.disposables ?? []) dispose()
// The controller's disposer runs the model's, so a reload leaks no listener.
assert.equal(record.disposed, true)
})
/** 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()
// 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()
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()
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()
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()
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()
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')
})