fix: put the settings card on the Plugins page with a browser half
Release / release (push) Successful in 6s

The `volatile()` markers made the Host serve the namespace, and that was as
far as it went: the Plugins page does not build a card from a `Config` schema.
It draws only what browser halves register into the slots it declares, which is
why dsh-slopo, dsh-recall and dsh-context all ship a `client/` — and why this
plugin, with two served fields, still showed nothing.

The card registers into `plugins.bundle.config` while
`configForms.whileServed` reports the namespace served. The two keys are
deliberately different strings and that is the trap in the file: the settings
document is filed under the profile entry id (`facet-policy`) while the card is
keyed by the package name (`dsh-facet`). Every sibling plugin happens to name
them identically. Binding the form to the wrong one yields a card that renders,
saves, and writes nowhere, so `test/client.test.js` pins both, holds the card's
field list to the schema's volatile fields, and refuses to register at all when
the namespace is not served.
This commit is contained in:
NotExpectedYet committed 2026-09-26 07:39:57 +01:00
1 parent e860ac2989
commit 1be3a67b97
4 files changed
+563 -7

No files matched your search

+238
View File
@@ -0,0 +1,238 @@
/**
* The browser half: the two keys it binds, and the fields it stages.
*
* This file exists because of a bug that no host-side test could catch. Marking
* the schema's fields `volatile()` makes the Host *serve* the namespace, but the
* Plugins page draws only what a browser half registers — so the plugin showed no
* settings card at all, and every host-side test passed.
*
* The wiring is therefore the thing worth testing, and the two keys are the trap:
* the settings DOCUMENT is keyed by the profile entry id (`facet-policy`) while
* the CARD is keyed by the package name (`dsh-facet`). Every sibling plugin
* happens to name those identically, which is exactly why this drifted. A card
* bound to the wrong id renders, saves, and writes nowhere.
*
* `client/client.js` is a `window.__ModuleLoader__` module rather than an ES
* module — that is how the harness loads a browser half without a bundler — so
* the test stands up the two globals it reads and drives it from there.
*/
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import { Config } from '../index.js'
/** Load the browser half with `window` and `require` faked, as the seed would. */
async function loadClient() {
const source = await readFile(new URL('../client/client.js', import.meta.url), 'utf8')
/** Every spec a card staged, in construction order. */
const staged = []
const primitives = {
SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl',
/** Stands in for the real model: records the specs and answers with the value. */
SettingsFormModel: class {
constructor(scope, specs) {
this.scope = scope
this.specs = specs
staged.push(...specs)
}
bind(project) {
this.project = project
return { project }
}
shell() {
return { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false }
}
field(name) {
const spec = this.specs.find((candidate) => candidate.field === name)
return { text: spec?.format(this.scope.value?.[name]) ?? '', overridden: false, invalid: false }
}
actions() {
return { edit: () => {}, resetField: () => {}, save: () => {}, discard: () => {} }
}
dispose() {}
},
}
const require = (id) => {
if (id === 'react') return { createElement: (type, props, ...children) => ({ type, props, children }) }
if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`client.js required an unexpected module: ${id}`)
}
let registered
const loader = { load: (value) => (registered = value) }
// eslint-disable-next-line no-new-func -- the file is a ModuleLoader script, not an ES module.
new Function('window', 'require', source)({ __ModuleLoader__: loader }, require)
assert.notEqual(registered, undefined, 'client.js never called window.__ModuleLoader__.load')
return { module: registered.factory(require), staged, primitives }
}
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {} } = {}) {
const seen = { formNs: undefined, slot: undefined, entry: undefined, card: undefined, watched: undefined }
const disposers = []
const ctx = {
inject: (names, scope) => {
const scoped = {
configForms: {
get: (ns) => {
seen.formNs = ns
return { value }
},
whileServed: (namespaces, register) => {
seen.watched = namespaces
// The real `whileServed` registers only while the Host serves the
// namespace; `served` is what the Host's `settings.describe` answers.
if (!served) return () => {}
return register(new Set(namespaces))
},
},
slots: {
inject: (name, register) => {
seen.slot = name
register()
},
register: (options, component) => {
seen.entry = options
seen.card = component
return () => {}
},
},
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') disposers.push(dispose)
return dispose
},
}
scope(scoped)
},
}
mod.apply(ctx)
return { seen, disposers }
}
test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => {
const { module: mod } = await loadClient()
assert.equal(mod.name, 'dsh-facet')
assert.deepEqual(mod.inject, ['slots'])
assert.equal(typeof mod.apply, 'function')
})
test('the form binds to the profile entry id while the card registers under the package name', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
// These two are deliberately different strings. `facet-policy` is the `id` of
// this bundle's row in cordis.patch.yml — what `Config` is filed under. The
// card is dispatched by the BUNDLE's package name.
assert.equal(seen.formNs, 'facet-policy')
assert.equal(seen.entry.key, 'dsh-facet')
assert.notEqual(seen.formNs, seen.entry.key, 'if these ever coincide, the trap this test guards has moved')
assert.equal(seen.slot, 'plugins.bundle.config')
assert.deepEqual(seen.watched, ['facet-policy'])
})
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
const patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
const { module: mod } = await loadClient()
const { seen } = drive(mod)
// A rename in the patch without a rename here is a card that writes nowhere,
// so the id is read out of the patch rather than trusted to the test.
assert.match(patch, new RegExp(`id:\\s*${seen.formNs}\\b`))
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, seen.entry.key)
})
test('nothing is registered while the Host does not serve the namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod, { served: false })
assert.equal(seen.formNs, 'facet-policy', 'the form is still bound')
assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves')
})
test('the card stages exactly the fields the host marks volatile', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const declared = Object.entries(Config.dict)
.filter(([, schema]) => schema.meta.volatile === true)
.map(([key]) => key)
// A field served but never staged is invisible in the UI; a field staged but
// never served makes `whileServed` withhold the whole card.
assert.deepEqual(
staged.map((spec) => spec.field).sort(),
declared.sort(),
'the card and the host schema have drifted apart',
)
})
test('the choice field refuses a value the schema would reject, and clears on empty', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const mode = staged.find((spec) => spec.field === 'mode')
assert.equal(mode.parse('deny').value, 'deny')
assert.equal(mode.parse(' DENY ').value, 'deny')
assert.deepEqual(mode.parse(''), { kind: 'clear' })
// Undefined marks the draft invalid, which blocks the save rather than
// coercing a typo into a mode the Host would reject anyway.
assert.equal(mode.parse('maybe'), undefined)
})
test('the list field clears rather than storing an empty array that shadows the default', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const roots = staged.find((spec) => spec.field === 'extraRoots')
assert.deepEqual(roots.parse('a, b\nc').value, ['a', 'b', 'c'])
assert.deepEqual(roots.parse(' '), { kind: 'clear' })
assert.equal(roots.format(['a', 'b']), 'a, b')
assert.equal(roots.format(undefined), '')
})
test('the card renders both views without reaching for anything it was not given', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
assert.equal(typeof seen.card, 'function')
assert.equal(seen.card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
const tree = seen.card({
view: 'page',
useFacetCard: (select) =>
select({
available: true,
writable: true,
dirty: false,
invalid: false,
saving: false,
failed: false,
mode: { text: 'deny', overridden: false, invalid: false },
extraRoots: { text: 'src/shared', overridden: true, invalid: false },
}),
edit: () => {},
resetField: () => {},
save: () => {},
discard: () => {},
})
// The form frame, with the segmented control reading the staged mode. Children
// ride the `children` prop, which is how the platform's own cards pass them.
assert.equal(tree.type, 'SettingsForm')
const controls = tree.props.children.children
assert.equal(controls[0].type, 'div')
assert.equal(controls[1].type, 'SettingsValueField')
assert.equal(controls[0].children[1].props.value, 'deny')
assert.equal(controls[1].props.text, 'src/shared')
})