Release / release (push) Successful in 6s
Two things a user setting this up hit immediately. The card drew `embeddingApiKey` twice. The render loop walked every entry in FIELDS, and the secret — which is drawn by hand, as a write-only password control — was one of them, so a second control appeared beside it: an ordinary text input, echoing the key in clear as you typed. One key, two boxes, one of them visible. The loop skips secrets now, and the tests count controls rather than checking that each expected label is present, because a duplicate beside the real control passed the old assertions. `status` could not answer the one question a new user cannot look up: how wide is this model? The probe that reports it was gated on every required setting being present, so in exactly the state that needs the answer — dimensions unset — it reported nothing and said "Missing: embedding_dimensions". It now runs whenever the endpoint can be named (a model and a base URL), and the status message names the width it observed: Missing: embedding_dimensions. The endpoint is reachable and returned 768 dimensions, so set embeddingDimensions to 768. The probe also tells "not set yet" apart from "set wrong", because the two have different remedies. Also documents both ways to read the width by hand, since a wrong value is frozen into the index at first use and costs a full re-embed to correct.
850 lines
32 KiB
JavaScript
850 lines
32 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'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
|
|
|
|
/**
|
|
* The installed harness's packages, reached through this repository's own
|
|
* `node_modules/@deepseek-ai` symlink rather than a committed absolute path —
|
|
* the link is what makes the `@deepseek-ai/*` imports resolve during tests in
|
|
* the first place, so it is the one address that is already relative to the
|
|
* checkout.
|
|
*/
|
|
const DSH_PACKAGES = fileURLToPath(new URL('../node_modules/@deepseek-ai/', import.meta.url))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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('the API key has exactly one control, and it is never a plain text field', () => {
|
|
// The regression: the render loop walked every entry in FIELDS, so the secret
|
|
// drew a second control — an ordinary text input, echoing the key in clear —
|
|
// beside the write-only password control. One key, two boxes, one of them
|
|
// visible.
|
|
const { render } = registered()
|
|
const tree = render()
|
|
const labelled = findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === 'Embedding API key')
|
|
assert.equal(labelled.length, 1, 'the key must have exactly one control')
|
|
assert.equal(labelled[0].props.type, 'password')
|
|
})
|
|
|
|
test('the card draws one control per schema field, and no more', () => {
|
|
// A count, not a list of names: the label assertions above pass just as
|
|
// happily with a duplicate row sitting beside the real one.
|
|
const { render } = registered()
|
|
const controls = findAll(render(), (node) => node.type === 'input')
|
|
assert.equal(controls.length, 15, 'the schema declares 15 fields, so each gets one control')
|
|
const labels = controls.map((node) => node.props['aria-label'])
|
|
assert.equal(new Set(labels).size, labels.length, `duplicate controls: ${labels.join(', ')}`)
|
|
})
|
|
|
|
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 \}\)/)
|
|
},
|
|
)
|