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.
638 lines
24 KiB
JavaScript
638 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.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 = []
|
|
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.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: 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 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 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.row.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.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. */
|
|
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')
|
|
})
|