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.
239 lines
9.1 KiB
JavaScript
239 lines
9.1 KiB
JavaScript
/**
|
|
* 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')
|
|
})
|