Files
dsh-recall/test/client.test.js
T
NotExpectedYet 28df7a0e56
Release / release (push) Successful in 6s
fix: put the recall chip in the header utilities, not the cell the sidebar's expand button owns
The chip registered into `conversation.session.header.corner`, which is a
SINGLE cell the product already occupies: the client catalog lists its
occupants as `client-ui-sidebar-right ExpandButton` and its replaceRisk as
`shadows-shipped-ui`. Two registrations at priority 0 in a single cell are a
hard error inside the slot registry, and the error lands in the OTHER
plugin's `slots.inject()` callback: the renderer's `changed` handler catches
it, calls `stop()` on that inject controller — killing that contribution for
the rest of the fiber's life — and rethrows it from a microtask, which is the
uncaught `single slot ... already has a registration at priority 0 (registered
by dsh-recall)` in the console. So the chip was not merely racing the shipped
right-sidebar expand control; it was taking it down.

It now registers into `conversation.session.header.utilities`, a list cell
(`replaceRisk: none`, right-aligned, same Session scope and therefore the same
`useProjection` prop) as `{ id: 'dsh-recall', order: 20 }`, so it stays at the
right-hand end without competing for a seat.

The suite could not have caught this: the slot stub accepts any registration
and knows nothing about cardinality, keys or collisions. It now asserts the
chip carries a list id and that this plugin registers nothing into the corner.
2026-09-27 06:36:49 +01:00

663 lines
25 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
// Faithful to the framework: `bind` is not a subscription setter, it
// builds the snapshot store from ONE immediate projection. A stub that
// only stored the projection would let a controller read a field it has
// not assigned yet — which is exactly the bug this reproduces.
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.utilities',
'sidebar.right.pane.tab',
])
assert.equal(tabTypes.length, 1, 'the tab type is independent of settings')
})
test('the header chip is an additive list entry carrying its own id', () => {
// The header's far-right corner is a SINGLE cell, and the product already
// occupies it with its right-sidebar expand control — the client catalog
// reports that key as `replaceRisk: "shadows-shipped-ui"`. A second
// registration at the same priority is a hard error inside the registry,
// thrown while the OTHER plugin's `slots.inject()` callback runs: the
// renderer catches it, stops that controller for the rest of the fiber's
// life, and rethrows it uncaught. So the chip belongs in a list cell, where
// a unique id makes it additive.
const { registered } = mount()
const chip = registered.find((item) => item.meta.name === 'conversation.session.header.utilities')
assert.ok(chip !== undefined)
assert.equal(chip.meta.id, 'dsh-recall', 'a list cell is addressed by its id')
assert.equal(typeof chip.meta.order, 'number', 'the order places the cell within the list')
assert.equal(chip.meta.key, undefined, 'a list cell takes an id, not a key')
assert.deepEqual(
registered.filter((item) => item.meta.name === 'conversation.session.header.corner'),
[],
'the corner is a single cell the product owns; a registration there shadows shipped UI',
)
})
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.utilities',
'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 utilities list draws nothing for an entry that renders nothing, so
// this is the state that must cost no space at all.
assert.equal(renderSlot('conversation.session.header.utilities', { useProjection: () => undefined }), null)
assert.equal(renderSlot('conversation.session.header.utilities', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null)
// And a host without the projection service at all.
assert.equal(renderSlot('conversation.session.header.utilities', {}), null)
})
test('the header chip leads with what was kept out, not what it cost', () => {
const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.utilities', { 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.utilities', { 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')
})