fix!: restore the settings page against the harness 0.1.7 plugin contracts
The configuration page registered into seats that 0.1.7 removed, so it could
not appear at all — and the schema carried none of the metadata the new
settings document requires.
Host half. `ctx.settings.installSection` no longer exists: a plugin exports a
`Config` schema, the Harness keys one form per composed entry by that row's
`id`, and `ctx.settings.describe` skips an entry with no `volatile()` field
outright. Every field is now volatile, which is also what makes a saved edit
apply to the running instance instead of remounting it — so `apply` reads the
section through `settingsSnapshot`, which unwraps the live references a
volatile field arrives as. Writes go through `ctx.settings.update` fenced by
the revision `describe` reports. The `.min`/`.max` bounds the schema can state
are stated there, so the form refuses an impossible draft in front of the
person typing it; `validateSettings` keeps the cross-field rules no single
field can express, and now runs on the section a call is about to act on
rather than at write time, because 0.1.7 has no write-time validator hook.
Browser half. `settingsScope` and the `settings.plugin.item` slot are gone. The
page renders through `plugins.row.config`, keyed `<bundle package>#<row id>`
(`dsh-recall#dsh-recall`), which is what puts a Configure control on the
plugin's row in the Plugins panel. It uses the framework's own staged form —
`SettingsFormModel` over `ctx.configForms`, drawn by `SettingsForm` and
`SettingsValueField`/`SettingsSecretField` — instead of reproducing card chrome
by hand, so the unsaved, overridden, invalid, and save states come from the
same code as every other plugin's page. All 33 settings are editable now; the
old card exposed 23. The right-sidebar tab also needed the stage-one tab TYPE
(`ctx.sidebarRightTabs.register`) that its keyed body slot is dispatched under,
so a tab can finally be opened. `--dsh-border`, defined nowhere, becomes the
product's `--dsw-alias-border-l2`.
Both halves are now pinned by tests: every field is volatile, the field list
and the schema cannot drift, the page claims the key the patch declares, and a
blank API key is never written — the secret control reports configured-and-
absent instead.
Verified against the harness rather than only in unit tests: `--dump-config-
schema` emits the volatile and secret metadata, `apply` registers `recall` on
the real tool registry, `settings.describe({redactSecrets:true})` serves
`dsh-recall` with 33 fields and the key reported set-but-redacted, and a
scratch profile boots the web app with the plugin composed and its bundle
served byte-for-byte as this checkout has it.
This commit is contained in:
1 parent
0964819cdc
commit
040c69b57d
7 files changed
+1352
-668
No files matched your search
+434
-207
@@ -2,20 +2,34 @@
|
||||
* 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:
|
||||
* 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 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.
|
||||
* - the configuration page is registered into `plugins.row.config` under
|
||||
* `<bundle package>#<row id>`. 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 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 = []
|
||||
@@ -53,8 +67,8 @@ function makeReact() {
|
||||
/**
|
||||
* 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'`
|
||||
* 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) {
|
||||
@@ -73,31 +87,114 @@ function find(node, 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]
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** The save button, whatever its current label. */
|
||||
function saveButton(tree) {
|
||||
return find(tree, (node) => node.type === 'button' && String(node.props.children).startsWith('Save'))[0]
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 })
|
||||
},
|
||||
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 }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,20 +205,28 @@ await import('../client/client.js')
|
||||
|
||||
const entry = loaded[0]
|
||||
|
||||
/** Instantiate the module with a stubbed `react`. */
|
||||
function makeModule() {
|
||||
/**
|
||||
* 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}`)
|
||||
})
|
||||
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 tabTypes = []
|
||||
const whileServedCalls = []
|
||||
const slots = {
|
||||
inject: (name, fn) => fn(),
|
||||
register: (meta, component) => {
|
||||
@@ -129,49 +234,122 @@ function mount(scope) {
|
||||
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,
|
||||
inject: (deps, cb) =>
|
||||
cb({
|
||||
settingsScope: {
|
||||
bind: (options) => {
|
||||
assert.equal(options.namespace, 'dsh-recall')
|
||||
return scope
|
||||
},
|
||||
},
|
||||
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]])) })
|
||||
},
|
||||
}
|
||||
module.apply(ctx)
|
||||
|
||||
const byName = new Map(registered.map((entry) => [entry.meta.name, entry]))
|
||||
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 entry = byName.get(name)
|
||||
assert.ok(entry !== undefined, `nothing registered for ${name}`)
|
||||
const item = byName.get(name)
|
||||
assert.ok(item !== undefined, `nothing registered for ${name}`)
|
||||
react.__render()
|
||||
const element = entry.component(props)
|
||||
const element = item.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)
|
||||
/**
|
||||
* 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.row.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, meta: byName.get('settings.plugin.item').meta, registered, render, renderSlot }
|
||||
return {
|
||||
react,
|
||||
module: factory,
|
||||
record,
|
||||
form,
|
||||
registered,
|
||||
tabTypes,
|
||||
whileServedCalls,
|
||||
renderSlot,
|
||||
card,
|
||||
configForms,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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]
|
||||
/** 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', () => {
|
||||
@@ -180,146 +358,203 @@ test('the bundle registers under the loader with the plugin id', () => {
|
||||
assert.equal(typeof entry.factory, 'function')
|
||||
})
|
||||
|
||||
test('the client module claims slots and reports the host namespace', () => {
|
||||
const { module } = makeModule()
|
||||
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 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 configuration page claims the 0.1.7 seat, keyed as the patch declares it', () => {
|
||||
const { registered } = mount()
|
||||
const page = registered.find((item) => item.meta.name === 'plugins.row.config')
|
||||
assert.ok(page !== undefined, 'the page must register into plugins.row.config')
|
||||
// `<bundle package name>#<row id>`: the row id is the one this bundle's own
|
||||
// cordis.patch.yml declares, which is what the Plugins page dispatches by.
|
||||
assert.equal(page.meta.key, 'dsh-recall#dsh-recall')
|
||||
assert.equal(typeof page.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 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('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('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.row.config'),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
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, [
|
||||
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',
|
||||
'settings.plugin.item',
|
||||
'sidebar.right.pane.tab',
|
||||
'sidebar.right.pane.tab.title',
|
||||
])
|
||||
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.row.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. */
|
||||
@@ -344,7 +579,7 @@ function usageView(overrides = {}) {
|
||||
}
|
||||
|
||||
test('the header chip renders nothing when the conversation never used it', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
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)
|
||||
@@ -354,7 +589,7 @@ test('the header chip renders nothing when the conversation never used it', () =
|
||||
})
|
||||
|
||||
test('the header chip leads with what was kept out, not what it cost', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
const { renderSlot } = mount()
|
||||
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() })
|
||||
assert.ok(chip !== null)
|
||||
const text = String(chip.props.children)
|
||||
@@ -367,7 +602,7 @@ test('the header chip leads with what was kept out, not what it cost', () => {
|
||||
})
|
||||
|
||||
test('the chip tooltip carries the figures the chip has no room for', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
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/)
|
||||
@@ -377,7 +612,7 @@ test('the chip tooltip carries the figures the chip has no room for', () => {
|
||||
})
|
||||
|
||||
test('the sidebar panel explains an unused conversation instead of showing zeroes', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
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/)
|
||||
@@ -385,13 +620,13 @@ test('the sidebar panel explains an unused conversation instead of showing zeroe
|
||||
})
|
||||
|
||||
test('the sidebar panel says so when the projection is absent', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
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(makeScope())
|
||||
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/)
|
||||
@@ -400,11 +635,3 @@ test('the sidebar panel reports the comparison and the compounding', () => {
|
||||
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')
|
||||
})
|
||||
Reference in new issue
Block a user