Release / release (push) Successful in 6s
0.1.7-rc.2 moved four things this bundle was built on, and every one of them
failed quietly rather than loudly, so the plugin looked installed and did
nothing:
* `ctx.settings.installSection(...)` is gone. The call threw into a
`try/catch` that only logged a warning, so the settings namespace was never
registered and `configure` had nowhere to persist. An entry's exported
Config IS the form now; the namespace is the Loader entry id the bundle's
patch declares, and `ctx.settings.configure({ auto: false })` is what claims
that entry's own page.
* `settingsScope` and the `settings.plugin.item` slot are gone. The card
registered into both, so it never rendered — the tab draws the intersection
of served namespaces and registered cards, and that intersection was empty.
It now registers into `plugins.bundle.config` keyed by the bundle's package
name, over the `configForms` service, and uses the framework's own form
chrome (`SettingsFormModel` / `SettingsForm` / `SettingsValueField` /
`SettingsSecretField`) instead of the hand-rolled equivalent. A save is one
revision-fenced write.
* `ctx.shell.run(spec)` is gone. Every action that runs a command threw
`shell.run is not a function`; the seam is now
`await ctx.shell.execute(ctx.shell.resolve(request)).result()`.
* A job's `owner` is a session id, not the agent object. The wrong owner
registered a job no session could collect or cancel, and the producer's
`done` resolved `output` where the registry reads `result`, so a finished
job handed back nothing.
Two conformance gaps went with them: a Config field that is not marked
`volatile()` is invisible to every settings UI — the form projection keeps only
volatile fields, and an entry with no form is dropped from `settings.describe`
entirely — and `embeddingApiKey` was a plain field, so the key rode every
settings read back to the browser. It is `role('secret')` now: the browser is
told whether a key is stored and never receives the key.
Also declares the `@deepseek-ai/dsh-*` peerDependencies the compatibility gate
reads, so an incompatible runtime refuses this plugin at startup instead of
half-loading it.
Verified against a real 0.1.7-rc.2 boot: `settings.describe` serves the entry
with `autoGenerate: false`, the key redacted and reported only as `set: true`,
and no startup diagnostics. The host mock in the tests now has no `run` and no
`installSection`, so those seams are contract-tested rather than assumed, and
the browser tests read the installed harness's own slot catalog and package
exports — a rename fails a test instead of quietly emptying a page.
821 lines
30 KiB
JavaScript
821 lines
30 KiB
JavaScript
/**
|
|
* Tests for the browser half.
|
|
*
|
|
* There is no browser here, so the bundle is evaluated the way the real module
|
|
* loader would — inside `window.__ModuleLoader__.load` — with a fake `require`
|
|
* that supplies React and `@deepseek-ai/dsh-client-ui-primitives` and THROWS on
|
|
* anything else. That last part is the useful one: it proves the bundle stays
|
|
* inside the platform's seed table, which is the constraint a hand-written
|
|
* client half is most likely to break.
|
|
*
|
|
* Two things are therefore stubbed, and it is worth being plain about what that
|
|
* does and does not prove:
|
|
*
|
|
* * React. The card itself calls no hooks — the shared primitives do — so the
|
|
* double only has to implement `createElement`, and component resolution is
|
|
* the tree walk below.
|
|
* * `@deepseek-ai/dsh-client-ui-primitives`. It is a real ESM package that
|
|
* imports React, `clsx`, zustand and thirty stylesheets, so it cannot be
|
|
* loaded under plain Node; the double reproduces the shipped
|
|
* `SettingsFormModel` staging semantics (read back from the package's own
|
|
* `lib/index.js`) closely enough that this card's field specs — which are
|
|
* the part this repo owns — are exercised for real, and renders the shared
|
|
* components as their documented props describe.
|
|
*
|
|
* `docs-contract` at the bottom of this file covers the other direction: that
|
|
* the slot and service names this bundle binds to are the ones the installed
|
|
* harness actually ships.
|
|
*/
|
|
|
|
import assert from 'node:assert/strict'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { test } from 'node:test'
|
|
|
|
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
|
|
|
|
/** Where a global dsh installation keeps its packages, if there is one. */
|
|
const DSH_PACKAGES = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The two doubles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The shipped `SettingsFormModel`, reduced to the reads and the one write this
|
|
* card drives. Staging, the plan, the revision fence and the "a refused save
|
|
* keeps its drafts" rule are the shipped behaviour, not an invention: they are
|
|
* the reason the card uses this model instead of writing on every keystroke.
|
|
*
|
|
* A store is a plain snapshot holder here; the shipped one is a uSES-compatible
|
|
* observable, which is all `store.set`/`getSnapshot` need to stand in for.
|
|
*/
|
|
class SettingsFormModel {
|
|
constructor(scope, specs, secrets = []) {
|
|
this.scope = scope
|
|
this.specs = new Map(specs.map((spec) => [spec.field, spec]))
|
|
this.secretSpecs = new Map(secrets.map((spec) => [spec.field, spec]))
|
|
this.staged = new Map()
|
|
this.listeners = new Set()
|
|
this.saving = false
|
|
this.failed = false
|
|
this.unsubscribe = scope.subscribe(() => this.publish())
|
|
}
|
|
|
|
bind(project) {
|
|
const store = {
|
|
snapshot: project(),
|
|
getSnapshot() {
|
|
return this.snapshot
|
|
},
|
|
set(next) {
|
|
this.snapshot = next
|
|
},
|
|
subscribe() {
|
|
return () => {}
|
|
},
|
|
}
|
|
this.listeners.add(() => store.set(project()))
|
|
return store
|
|
}
|
|
|
|
shell() {
|
|
const snapshot = this.scope.getSnapshot()
|
|
const plan = this.plan()
|
|
return {
|
|
available: snapshot.status === 'ready',
|
|
writable: snapshot.writable,
|
|
dirty: plan.length > 0,
|
|
invalid: plan.some((item) => item.run === undefined && item.op === undefined),
|
|
saving: this.saving,
|
|
failed: this.failed,
|
|
}
|
|
}
|
|
|
|
field(field) {
|
|
const staged = this.staged.get(field)
|
|
if (this.secretSpecs.has(field)) {
|
|
return { text: staged?.text ?? '', overridden: false, invalid: false }
|
|
}
|
|
const spec = this.spec(field)
|
|
if (staged === undefined) {
|
|
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
|
}
|
|
const write = staged.clear ? { kind: 'clear' } : spec.parse(staged.text)
|
|
return { text: staged.text, overridden: write?.kind === 'set', invalid: write === undefined }
|
|
}
|
|
|
|
actions() {
|
|
return {
|
|
edit: (field, text) => this.stage(field, { text, clear: false }),
|
|
resetField: (field) => this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true }),
|
|
save: () => this.save(),
|
|
discard: () => {
|
|
if (this.staged.size === 0 && !this.failed) return
|
|
this.staged.clear()
|
|
this.baseline = undefined
|
|
this.failed = false
|
|
this.publish()
|
|
},
|
|
}
|
|
}
|
|
|
|
async save() {
|
|
const plan = this.plan()
|
|
if (!plan.length || this.saving || !this.scope.getSnapshot().writable) return
|
|
if (plan.some((item) => item.run === undefined && item.op === undefined)) return
|
|
this.saving = true
|
|
this.failed = false
|
|
this.publish()
|
|
try {
|
|
const ops = plan.flatMap((item) => (item.op === undefined ? [] : [item.op]))
|
|
let landed = !ops.length || (await this.scope.mutate(ops, this.baseline?.revision))
|
|
if (!landed) {
|
|
this.failed = true
|
|
return
|
|
}
|
|
for (const item of plan) if (item.run) landed = (await item.run()) && landed
|
|
if (landed) {
|
|
this.staged.clear()
|
|
this.baseline = undefined
|
|
}
|
|
this.failed = !landed
|
|
} catch {
|
|
this.failed = true
|
|
} finally {
|
|
this.saving = false
|
|
this.publish()
|
|
}
|
|
}
|
|
|
|
plan() {
|
|
const plan = []
|
|
for (const [field, staged] of this.staged) {
|
|
const secret = this.secretSpecs.get(field)
|
|
if (secret !== undefined) {
|
|
const value = staged.text.trim()
|
|
if (value !== '') plan.push({ field, run: () => secret.write(value) })
|
|
continue
|
|
}
|
|
const spec = this.spec(field)
|
|
if (staged.clear) {
|
|
if (this.stored(field)) plan.push({ field, op: { op: 'unset', path: [field] } })
|
|
continue
|
|
}
|
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
const write = spec.parse(staged.text)
|
|
if (write === undefined) plan.push({ field })
|
|
else if (write.kind === 'clear') plan.push({ field, op: { op: 'unset', path: [field] } })
|
|
else plan.push({ field, op: { op: 'set', path: [field], value: write.value } })
|
|
}
|
|
return plan
|
|
}
|
|
|
|
stage(field, edit) {
|
|
this.baseline ??= this.scope.getSnapshot()
|
|
this.staged.set(field, edit)
|
|
this.failed = false
|
|
this.publish()
|
|
}
|
|
|
|
publish() {
|
|
for (const listener of this.listeners) listener()
|
|
}
|
|
|
|
dispose() {
|
|
this.unsubscribe()
|
|
this.listeners.clear()
|
|
}
|
|
|
|
spec(field) {
|
|
const spec = this.specs.get(field)
|
|
if (spec === undefined) throw new Error(`no spec for "${field}"`)
|
|
return spec
|
|
}
|
|
|
|
sectionValue(field) {
|
|
return this.scope.getSnapshot().value?.[field]
|
|
}
|
|
|
|
baseValue(field) {
|
|
return this.scope.getSnapshot().base?.[field]
|
|
}
|
|
|
|
userLayer() {
|
|
const user = this.scope.getSnapshot().user
|
|
return user !== null && typeof user === 'object' ? user : {}
|
|
}
|
|
|
|
stored(field) {
|
|
return Object.hasOwn(this.userLayer(), field)
|
|
}
|
|
}
|
|
|
|
/** The shipped number and text specs, verbatim. */
|
|
const numberField = (field) => ({
|
|
field,
|
|
format: (value) => (typeof value === 'number' ? String(value) : ''),
|
|
parse: (text) => {
|
|
const trimmed = text.trim()
|
|
if (trimmed === '') return { kind: 'clear' }
|
|
const parsed = Number(trimmed)
|
|
return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
|
|
},
|
|
})
|
|
|
|
const textField = (field) => ({
|
|
field,
|
|
format: (value) => (typeof value === 'string' ? value : ''),
|
|
parse: (text) => {
|
|
const trimmed = text.trim()
|
|
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
|
},
|
|
})
|
|
|
|
/**
|
|
* The shared components, rendered as the props they document.
|
|
*
|
|
* Every one of them is a plain function, so expanding the tree never needs a
|
|
* hook runtime: the card's own composition is what is under test.
|
|
*/
|
|
function primitivesDouble() {
|
|
const h = (type, props, ...children) => ({ type, props: props ?? {}, children })
|
|
|
|
const SettingsForm = (props) =>
|
|
h(
|
|
'form',
|
|
{
|
|
'data-labels': JSON.stringify(props.labels),
|
|
// The real form disables Save until something is staged, nothing is
|
|
// invalid, no write is in flight, and the document accepts writes.
|
|
'data-can-save': String(props.state.dirty && !props.state.invalid && !props.state.saving && props.state.writable),
|
|
onSave: props.onSave,
|
|
onDiscard: props.onDiscard,
|
|
},
|
|
h('button', { type: 'button', disabled: !(props.state.dirty && !props.state.invalid && props.state.writable), onClick: props.onSave }, props.labels.save),
|
|
props.children,
|
|
)
|
|
|
|
const SettingsValueField = (props) =>
|
|
h(
|
|
'div',
|
|
{ 'data-field': props.id },
|
|
h('label', { htmlFor: props.id }, props.label),
|
|
h('input', {
|
|
id: props.id,
|
|
type: 'text',
|
|
value: props.text,
|
|
disabled: props.disabled,
|
|
'aria-label': props.label,
|
|
'data-invalid': String(props.invalid),
|
|
'data-overridden': String(props.overridden),
|
|
onChange: (event) => props.onEdit(event.target.value),
|
|
}),
|
|
props.overridden ? h('button', { type: 'button', disabled: props.disabled, onClick: props.onReset }, props.resetLabel) : null,
|
|
h('div', { 'data-hint': true }, props.invalid ? props.invalidLabel : props.hint),
|
|
)
|
|
|
|
const SettingsSecretField = (props) =>
|
|
h(
|
|
'div',
|
|
{ 'data-field': props.id },
|
|
h('label', { htmlFor: props.id }, props.label),
|
|
h('input', {
|
|
id: props.id,
|
|
type: 'password',
|
|
value: props.text,
|
|
disabled: props.disabled,
|
|
'aria-label': props.label,
|
|
autoComplete: 'off',
|
|
onChange: (event) => props.onEdit(event.target.value),
|
|
}),
|
|
h('div', { 'data-state': true }, props.stateLabel),
|
|
h('div', { 'data-configured': String(props.configured) }),
|
|
)
|
|
|
|
// The real primitive is a labelled checkbox: the label WRAPS the control.
|
|
const Checkbox = (props) =>
|
|
h(
|
|
'label',
|
|
{},
|
|
h('input', {
|
|
type: 'checkbox',
|
|
checked: Boolean(props.checked),
|
|
disabled: props.disabled,
|
|
'aria-label': props.label,
|
|
onChange: (event) => props.onChange(event.target.checked),
|
|
}),
|
|
props.label,
|
|
)
|
|
|
|
return { SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField, Checkbox, settingsNumberField: numberField, settingsTextField: textField }
|
|
}
|
|
|
|
/**
|
|
* Minimal React: `createElement` is the only hook-free seam the card needs.
|
|
*
|
|
* Children land in `props.children` as well as on the wrapper, because that is
|
|
* where a real element carries them and what the shared components read.
|
|
*/
|
|
const fakeReact = {
|
|
createElement: (type, props, ...children) => {
|
|
const merged = { ...(props ?? {}) }
|
|
if (children.length > 0) merged.children = children.length === 1 ? children[0] : children
|
|
return { type, props: merged, children }
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Evaluate the bundle against a fake loader and return its exports. */
|
|
function loadBundle(requireImpl) {
|
|
let loaded
|
|
const window = { __ModuleLoader__: { load: (spec) => { loaded = spec } } }
|
|
new Function('window', SOURCE)(window)
|
|
assert.ok(loaded !== undefined, 'the bundle must call window.__ModuleLoader__.load')
|
|
const primitives = primitivesDouble()
|
|
const exported = loaded.factory(
|
|
requireImpl ??
|
|
((name) => {
|
|
if (name === 'react') return fakeReact
|
|
if (name === 'react/jsx-runtime') return { jsx: fakeReact.createElement, jsxs: fakeReact.createElement, Fragment: 'Fragment' }
|
|
if (name === '@deepseek-ai/dsh-client-ui-primitives') return primitives
|
|
throw new Error(`the client bundle required "${name}", which is not in the platform seed table`)
|
|
}),
|
|
)
|
|
return { id: loaded.id, exported, primitives }
|
|
}
|
|
|
|
const READY = {
|
|
status: 'ready',
|
|
value: {
|
|
embeddingModel: 'ollama/example:latest',
|
|
embeddingDimensions: 768,
|
|
embeddingApiBase: 'http://127.0.0.1:11434',
|
|
embeddingParamsJson: '{}',
|
|
binary: '',
|
|
autoInstall: true,
|
|
sourceDir: '',
|
|
sourceDirExclude: ['**/dist/**'],
|
|
useDefaultExcludes: true,
|
|
maxIndexUnits: 5000,
|
|
ignoreFile: '',
|
|
stateDir: '',
|
|
embeddingRequestDelay: 0,
|
|
timeoutMs: 600_000,
|
|
},
|
|
base: {},
|
|
user: {},
|
|
revision: 7,
|
|
writable: true,
|
|
mode: 'host',
|
|
}
|
|
|
|
/** A settings scope stand-in recording the mutations it was asked to make. */
|
|
function fakeScope(snapshot = READY, { landed = true } = {}) {
|
|
const calls = { mutate: [], subscriptions: 0 }
|
|
return {
|
|
calls,
|
|
getSnapshot: () => snapshot,
|
|
subscribe(listener) {
|
|
calls.subscriptions += 1
|
|
return () => {}
|
|
},
|
|
async mutate(ops, revision) {
|
|
calls.mutate.push({ ops, revision })
|
|
return landed
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register the card the way the host would, and hand back everything a test
|
|
* needs to drive it: the registration options, the injected face, and a render
|
|
* function.
|
|
*/
|
|
function registered({ snapshot = READY, served = true, secrets = [], landed = true } = {}) {
|
|
const { id, exported, primitives } = loadBundle()
|
|
const scope = fakeScope(snapshot, { landed })
|
|
const effects = []
|
|
const injects = []
|
|
const whileServed = []
|
|
const dictionaries = new Map()
|
|
let registration
|
|
let component
|
|
|
|
const configForms = {
|
|
get: (ns) => {
|
|
assert.equal(ns, 'dsh-slopo')
|
|
return scope
|
|
},
|
|
whileServed: (namespaces, register) => {
|
|
whileServed.push(namespaces)
|
|
const off = served ? register(new Set(namespaces)) : undefined
|
|
return () => off?.()
|
|
},
|
|
describe: () => ({
|
|
getSnapshot: () => ({ view: { namespaces: [{ ns: 'dsh-slopo', secrets }] } }),
|
|
}),
|
|
}
|
|
|
|
/** A `ctx.inject` child: an effect scope plus the slots registry. */
|
|
const childEffect = (effect, _label) => {
|
|
effects.push(effect())
|
|
}
|
|
const ctx = {
|
|
effect: childEffect,
|
|
locale: {
|
|
register(ns, dicts) {
|
|
dictionaries.set(ns, dicts.en)
|
|
return () => {}
|
|
},
|
|
bind: (ns) => {
|
|
const dict = dictionaries.get(ns) ?? {}
|
|
return (key) => dict[key] ?? key
|
|
},
|
|
},
|
|
inject(deps, callback) {
|
|
injects.push(deps)
|
|
callback({
|
|
configForms,
|
|
effect: childEffect,
|
|
slots: {
|
|
inject(_name, produce) {
|
|
produce()
|
|
return () => {}
|
|
},
|
|
register(options, render) {
|
|
registration = options
|
|
component = render
|
|
},
|
|
},
|
|
})
|
|
},
|
|
}
|
|
|
|
exported.apply(ctx)
|
|
|
|
const t = ctx.locale.bind('dsh-slopo')
|
|
const injectFace = () => ({ t: (key) => t(key), ...registration.inject() })
|
|
const render = (extra = {}) => {
|
|
const face = { ...injectFace(), ...extra }
|
|
const state = face.hooks.slopoCard.getSnapshot()
|
|
return component({ ...face, view: 'page', useSlopoCard: (select) => select(state) })
|
|
}
|
|
|
|
return { id, exported, primitives, scope, effects, injects, whileServed, registration: () => registration, render, t }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tree helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Resolve function components by calling them, until a host element or null. */
|
|
function expand(node, depth = 0) {
|
|
if (depth > 60) throw new Error('the component tree did not settle')
|
|
if (node === null || node === undefined) return null
|
|
if (typeof node !== 'object') return node
|
|
if (Array.isArray(node)) return node
|
|
if (typeof node.type === 'function') return expand(node.type(node.props), depth + 1)
|
|
return node
|
|
}
|
|
|
|
/** Depth-first walk over a rendered tree, expanding components as it goes. */
|
|
function findAll(node, predicate, out = []) {
|
|
if (node === null || node === undefined) return out
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) findAll(child, predicate, out)
|
|
return out
|
|
}
|
|
if (typeof node !== 'object') return out
|
|
if (typeof node.type === 'function') {
|
|
findAll(expand(node), predicate, out)
|
|
return out
|
|
}
|
|
if (predicate(node)) out.push(node)
|
|
for (const child of node.children ?? []) findAll(child, predicate, out)
|
|
return out
|
|
}
|
|
|
|
const inputFor = (tree, label) =>
|
|
findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
|
|
|
|
const saveButton = (tree) => findAll(tree, (node) => node.type === 'button' && node.children.includes('Save'))[0]
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bundle identity and the seed table
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('the bundle id matches the package name the loader is told about', () => {
|
|
const { id } = loadBundle()
|
|
assert.equal(id, 'dsh-slopo')
|
|
})
|
|
|
|
test('the client bundle requires nothing outside the platform seed table', () => {
|
|
// loadBundle() throws on any specifier other than the two seed words.
|
|
assert.doesNotThrow(() => loadBundle())
|
|
})
|
|
|
|
test('the bundle exports the package name and the services it needs', () => {
|
|
const { exported } = loadBundle()
|
|
assert.equal(exported.name, 'dsh-slopo')
|
|
// `locale` is required because the card's copy rides a dictionary; `slots`
|
|
// because the registration is a slot.
|
|
assert.deepEqual(exported.inject, ['slots', 'locale'])
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The registration contract — the part that regressed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('the card registers into plugins.bundle.config, keyed by the bundle package name', () => {
|
|
const { registration, exported } = registered()
|
|
assert.equal(exported.name, 'dsh-slopo')
|
|
assert.equal(registration().name, 'plugins.bundle.config')
|
|
// The key must equal the bundle's package name: the Plugins page dispatches
|
|
// this slot by `pkg.name`, and nothing else.
|
|
assert.equal(registration().key, 'dsh-slopo')
|
|
// `locale` is what injects the card's `t`.
|
|
assert.equal(registration().locale, 'dsh-slopo')
|
|
})
|
|
|
|
test('the card no longer binds the pre-0.1.7 settings surface', () => {
|
|
// `settings.plugin.item` and the `settingsScope` service were removed from the
|
|
// harness; a bundle that still names them renders nothing at all, and does it
|
|
// silently. The header comment names them on purpose, so the scan is over the
|
|
// code: strip comments first, then look.
|
|
const code = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
assert.doesNotMatch(code, /settingsScope/)
|
|
assert.doesNotMatch(code, /settings\.plugin\.item/)
|
|
assert.doesNotMatch(code, /installSection/)
|
|
})
|
|
|
|
test('the card asks for configForms, and does it optionally', () => {
|
|
const { injects } = registered()
|
|
assert.deepEqual(injects, [['configForms']])
|
|
})
|
|
|
|
test('the registration stands only while the Host serves the namespace', () => {
|
|
const served = registered({ served: true })
|
|
assert.deepEqual(served.whileServed, [['dsh-slopo']])
|
|
assert.ok(served.registration() !== undefined)
|
|
|
|
// A deployment that does not serve the entry leaves no card behind.
|
|
const unserved = registered({ served: false })
|
|
assert.equal(unserved.registration(), undefined)
|
|
})
|
|
|
|
test('the injected face carries the card store and the form actions', () => {
|
|
const { registration } = registered()
|
|
const face = registration().inject()
|
|
assert.ok(face.hooks.slopoCard !== undefined, 'the card reads its state through a hook')
|
|
for (const action of ['edit', 'resetField', 'save', 'discard']) {
|
|
assert.equal(typeof face[action], 'function', `missing form action "${action}"`)
|
|
}
|
|
})
|
|
|
|
test('teardown releases the form subscription', () => {
|
|
const { scope, effects } = registered()
|
|
assert.equal(scope.calls.subscriptions, 1)
|
|
// The second effect is the whileServed one; the first disposes the form.
|
|
assert.equal(typeof effects[0], 'function')
|
|
effects[0]()
|
|
// Disposing twice is not the card's business, but the disposer must exist and
|
|
// must not throw before the form's own disposer has a chance to run.
|
|
assert.doesNotThrow(() => effects[1]())
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The card's controls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('the card renders every setting the Host schema declares', () => {
|
|
const { render } = registered()
|
|
const tree = render()
|
|
const labels = findAll(tree, (node) => node.type === 'label').map((node) => node.children.join(''))
|
|
for (const expected of [
|
|
'Embedding model',
|
|
'Embedding dimensions',
|
|
'Embedding API base',
|
|
'Embedding API key',
|
|
'Extra embedding parameters',
|
|
'slopo executable',
|
|
'Install slopo with uv when missing',
|
|
'Source directory',
|
|
'Extra exclude patterns',
|
|
'Exclude node_modules and build output',
|
|
'Refuse to embed more than this many units',
|
|
'Ignore file',
|
|
'State directory',
|
|
'Delay between embedding requests',
|
|
'Foreground timeout',
|
|
]) {
|
|
assert.ok(labels.some((label) => label.includes(expected)), `no control labelled "${expected}"`)
|
|
}
|
|
})
|
|
|
|
test('the API key is a write-only password control that reports only whether one is stored', () => {
|
|
const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: true }] })
|
|
const tree = render()
|
|
const input = inputFor(tree, 'Embedding API key')
|
|
assert.equal(input.props.type, 'password')
|
|
assert.equal(input.props.value, '', 'a secret never rides back into the control')
|
|
assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /A key is stored/)
|
|
})
|
|
|
|
test('a deployment with no key says so', () => {
|
|
const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: false }] })
|
|
const tree = render()
|
|
assert.equal(findAll(tree, (node) => node.props['data-configured'] === 'true').length, 0)
|
|
assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /No key is stored/)
|
|
})
|
|
|
|
test('the current value of every ordinary field is rendered', () => {
|
|
const { render } = registered()
|
|
const tree = render()
|
|
assert.equal(inputFor(tree, 'Embedding model').props.value, 'ollama/example:latest')
|
|
assert.equal(inputFor(tree, 'Embedding dimensions').props.value, '768')
|
|
assert.equal(inputFor(tree, 'Extra exclude patterns').props.value, '**/dist/**')
|
|
assert.equal(inputFor(tree, 'Install slopo with uv when missing').props.checked, true)
|
|
})
|
|
|
|
test('a read-only deployment disables every control', () => {
|
|
const { render } = registered({ snapshot: { ...READY, writable: false, mode: 'memory' } })
|
|
const tree = render()
|
|
const controls = findAll(tree, (node) => node.type === 'input')
|
|
assert.ok(controls.length >= 15)
|
|
for (const control of controls) assert.equal(control.props.disabled, true, control.props['aria-label'])
|
|
assert.equal(saveButton(tree).props.disabled, true)
|
|
})
|
|
|
|
test('the card hands an unavailable namespace to the shared form instead of hiding', () => {
|
|
// The form is what swaps in the "not served" line, from `state.available`.
|
|
// Hiding here would leave the Plugins page with a configured-but-empty card.
|
|
const { render } = registered({ snapshot: { ...READY, status: 'unavailable' } })
|
|
const form = expand(render())
|
|
assert.equal(JSON.parse(form.props['data-labels']).unavailable.startsWith('This plugin is not loaded'), true)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Saving: the field specs this repo owns
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Stage one edit, then click Save the way the form does. */
|
|
async function stageAndSave(render, label, next) {
|
|
const input = inputFor(render(), label)
|
|
input.props.onChange({ target: { value: next } })
|
|
const form = render()
|
|
const save = saveButton(form)
|
|
assert.equal(save.props.disabled, false, 'a staged edit must enable Save')
|
|
await save.props.onClick()
|
|
}
|
|
|
|
test('Save sends one fenced mutation, at the revision the drafts were read from', async () => {
|
|
const { render, scope } = registered()
|
|
await stageAndSave(render, 'Embedding model', 'ollama/other:latest')
|
|
assert.equal(scope.calls.mutate.length, 1)
|
|
assert.deepEqual(scope.calls.mutate[0].ops, [
|
|
{ op: 'set', path: ['embeddingModel'], value: 'ollama/other:latest' },
|
|
])
|
|
assert.equal(scope.calls.mutate[0].revision, 7)
|
|
})
|
|
|
|
test('a whole number field is sent as a number, not the string the input holds', async () => {
|
|
const { render, scope } = registered()
|
|
await stageAndSave(render, 'Embedding dimensions', '1024')
|
|
assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'set', path: ['embeddingDimensions'], value: 1024 }])
|
|
})
|
|
|
|
test('a number that is not a number blocks the save instead of being dropped', async () => {
|
|
const { render, scope } = registered()
|
|
const tree = render()
|
|
inputFor(tree, 'Embedding dimensions').props.onChange({ target: { value: 'not-a-number' } })
|
|
const form = render()
|
|
assert.equal(saveButton(form).props.disabled, true, 'an invalid draft must disable Save')
|
|
await saveButton(form).props.onClick()
|
|
assert.deepEqual(scope.calls.mutate, [])
|
|
// …and the field says why.
|
|
assert.equal(inputFor(form, 'Embedding dimensions').props['data-invalid'], 'true')
|
|
})
|
|
|
|
test('an exclude list round-trips through comma-separated text', async () => {
|
|
const { render, scope } = registered()
|
|
await stageAndSave(render, 'Extra exclude patterns', '**/test/**, *.min.js')
|
|
assert.deepEqual(scope.calls.mutate[0].ops, [
|
|
{ op: 'set', path: ['sourceDirExclude'], value: ['**/test/**', '*.min.js'] },
|
|
])
|
|
})
|
|
|
|
test('a switch writes a real boolean', async () => {
|
|
const { render, scope } = registered()
|
|
const tree = render()
|
|
inputFor(tree, 'Install slopo with uv when missing').props.onChange({ target: { checked: false } })
|
|
await saveButton(render()).props.onClick()
|
|
assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'set', path: ['autoInstall'], value: false }])
|
|
})
|
|
|
|
test('clearing a field is an unset, so it re-inherits rather than becoming empty', async () => {
|
|
const { render, scope } = registered({
|
|
snapshot: { ...READY, user: { embeddingModel: 'ollama/example:latest' } },
|
|
})
|
|
await stageAndSave(render, 'Embedding model', '')
|
|
assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'unset', path: ['embeddingModel'] }])
|
|
})
|
|
|
|
test('an overridden field is marked and offers its own reset', async () => {
|
|
const { render, scope } = registered({
|
|
snapshot: {
|
|
...READY,
|
|
base: { embeddingDimensions: 0 },
|
|
user: { embeddingDimensions: 768 },
|
|
},
|
|
})
|
|
const tree = render()
|
|
const field = inputFor(tree, 'Embedding dimensions')
|
|
assert.equal(field.props['data-overridden'], 'true')
|
|
const reset = findAll(tree, (node) => node.type === 'button' && node.children.includes('Reset to default'))[0]
|
|
assert.ok(reset !== undefined, 'an overridden field must offer a reset')
|
|
reset.props.onClick()
|
|
await saveButton(render()).props.onClick()
|
|
assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'unset', path: ['embeddingDimensions'] }])
|
|
})
|
|
|
|
test('a secret draft is written one way, and a blank one writes nothing', async () => {
|
|
const { render, scope } = registered()
|
|
inputFor(render(), 'Embedding API key').props.onChange({ target: { value: 'sk-secret' } })
|
|
await saveButton(render()).props.onClick()
|
|
assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'set', path: ['embeddingApiKey'], value: 'sk-secret' }])
|
|
|
|
const blank = registered()
|
|
inputFor(blank.render(), 'Embedding API key').props.onChange({ target: { value: ' ' } })
|
|
await saveButton(blank.render()).props.onClick()
|
|
assert.deepEqual(blank.scope.calls.mutate, [], 'a blank secret draft must not clear the stored key')
|
|
})
|
|
|
|
test('a refused save keeps its drafts so they can be corrected', async () => {
|
|
const { render, scope } = registered({ landed: false })
|
|
const tree = render()
|
|
inputFor(tree, 'Embedding model').props.onChange({ target: { value: 'ollama/try-again:latest' } })
|
|
await saveButton(render()).props.onClick()
|
|
assert.equal(scope.calls.mutate.length, 1)
|
|
// Still dirty: nothing was accepted, so nothing is thrown away.
|
|
assert.equal(saveButton(render()).props.disabled, false)
|
|
assert.equal(inputFor(render(), 'Embedding model').props.value, 'ollama/try-again:latest')
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Contract against the installed harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const harnessPresent = existsSync(DSH_PACKAGES)
|
|
|
|
test(
|
|
'the slot this card binds to is declared by the installed harness',
|
|
{ skip: harnessPresent ? false : 'no global dsh installation to read the slot catalog from' },
|
|
() => {
|
|
const catalog = readFileSync(join(DSH_PACKAGES, 'dsh-cordis-client-runner/lib/client.js'), 'utf8')
|
|
assert.match(catalog, /key: "plugins\.bundle\.config"/, 'plugins.bundle.config must exist in the slot catalog')
|
|
assert.doesNotMatch(catalog, /key: "settings\.plugin\.item"/, 'settings.plugin.item must be gone')
|
|
},
|
|
)
|
|
|
|
test(
|
|
'every primitive the card requires is exported by the installed package',
|
|
{ skip: harnessPresent ? false : 'no global dsh installation to read the primitives package from' },
|
|
() => {
|
|
const bundle = readFileSync(join(DSH_PACKAGES, 'dsh-client-ui-primitives/lib/index.js'), 'utf8')
|
|
const exported = new Set(
|
|
(bundle.match(/export \{([^}]*)\};/g) ?? [])
|
|
.flatMap((line) => line.slice(8, -2).split(','))
|
|
.map((name) => name.trim().split(/\s+as\s+/).pop()),
|
|
)
|
|
for (const name of [
|
|
'Checkbox',
|
|
'SettingsForm',
|
|
'SettingsFormModel',
|
|
'SettingsSecretField',
|
|
'SettingsValueField',
|
|
'settingsNumberField',
|
|
'settingsTextField',
|
|
]) {
|
|
assert.ok(exported.has(name), `@deepseek-ai/dsh-client-ui-primitives does not export "${name}"`)
|
|
}
|
|
// The card reads the entry's secret slots off the shared describe mirror.
|
|
const settings = readFileSync(join(DSH_PACKAGES, 'dsh-client-ui-settings/lib/client.js'), 'utf8')
|
|
assert.match(settings, /super\(ctx, "configForms"\)/, 'configForms must be the settings domain service')
|
|
},
|
|
)
|
|
|
|
test(
|
|
'the installed plugin-manager declares plugins.bundle.config as a keyed child of the Plugins page',
|
|
{ skip: harnessPresent ? false : 'no global dsh installation to read the plugin manager from' },
|
|
() => {
|
|
const manager = readFileSync(join(DSH_PACKAGES, 'dsh-client-ui-plugin-manager/lib/client.js'), 'utf8')
|
|
assert.match(manager, /"plugins\.bundle\.config": \{\s*kind: "keyed"/)
|
|
// …and dispatches it by the bundle's package name.
|
|
assert.match(manager, /renderSlot\("plugins\.bundle\.config", \{ view: "page" \}, \{ entryKey: pkg\.name \}\)/)
|
|
},
|
|
)
|