mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-19 20:23:19 +08:00
Fix preset publication, volatile settings and client form binding
This commit is contained in:
parent
fe0fe70d33
commit
0b9a3a82bc
66 changed files with 8546 additions and 3690 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
DEFAULT_CAPABILITY_GROUPS,
|
||||
groupsForPlane,
|
||||
groupsForced,
|
||||
kbEnabledForPlane,
|
||||
SKILL_DIR_BY_GROUP,
|
||||
toolNamesForGroups,
|
||||
TOOLS_BY_GROUP,
|
||||
|
|
@ -23,6 +24,75 @@ test('defaults: ops + bizMonitor in preset; topology and public off', () => {
|
|||
})
|
||||
assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops', 'bizMonitor'])
|
||||
assert.deepEqual(groupsForPlane(undefined, 'public'), [])
|
||||
assert.deepEqual(groupsForPlane(undefined, 'host'), [])
|
||||
})
|
||||
|
||||
test('host plane equals public only (not inPreset union)', () => {
|
||||
const groups = capabilityGroupsFromSettings({
|
||||
groupOpsInPreset: false,
|
||||
groupOpsPublic: true,
|
||||
groupBizMonitorInPreset: false,
|
||||
groupTopologyInPreset: true,
|
||||
})
|
||||
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
|
||||
assert.deepEqual(groupsForPlane(groups, 'public'), ['ops'])
|
||||
assert.deepEqual(groupsForPlane(groups, 'host'), ['ops'])
|
||||
})
|
||||
|
||||
test('unchecking public revokes host even when inPreset stays on', () => {
|
||||
const groups = capabilityGroupsFromSettings({
|
||||
groupOpsInPreset: true,
|
||||
groupOpsPublic: false,
|
||||
groupBizMonitorInPreset: true,
|
||||
groupBizMonitorPublic: false,
|
||||
})
|
||||
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
|
||||
assert.deepEqual(groupsForPlane(groups, 'host'), [])
|
||||
assert.deepEqual(groupsForPlane(groups, 'public'), [])
|
||||
})
|
||||
|
||||
test('preset and host planes are symmetric; only defaults differ', () => {
|
||||
const both = capabilityGroupsFromSettings({
|
||||
groupOpsInPreset: true,
|
||||
groupOpsPublic: true,
|
||||
groupTopologyInPreset: false,
|
||||
groupTopologyPublic: false,
|
||||
groupBizMonitorInPreset: false,
|
||||
groupBizMonitorPublic: true,
|
||||
})
|
||||
assert.deepEqual(groupsForPlane(both, 'preset'), ['ops'])
|
||||
assert.deepEqual(groupsForPlane(both, 'host'), ['ops', 'bizMonitor'])
|
||||
|
||||
const neither = capabilityGroupsFromSettings({
|
||||
groupOpsInPreset: false,
|
||||
groupOpsPublic: false,
|
||||
groupBizMonitorInPreset: false,
|
||||
groupBizMonitorPublic: false,
|
||||
})
|
||||
assert.deepEqual(groupsForPlane(neither, 'preset'), [])
|
||||
assert.deepEqual(groupsForPlane(neither, 'host'), [])
|
||||
})
|
||||
|
||||
test('null boolean flags do not resurrect defaults incorrectly', () => {
|
||||
// Settings projection may emit null for volatile booleans.
|
||||
const groups = capabilityGroupsFromSettings({
|
||||
groupOpsInPreset: /** @type {any} */ (null),
|
||||
groupOpsPublic: /** @type {any} */ (null),
|
||||
groupBizMonitorInPreset: /** @type {any} */ (null),
|
||||
groupBizMonitorPublic: /** @type {any} */ (null),
|
||||
})
|
||||
// null is not a boolean → fall back to defaults (ops+bizMonitor inPreset, public off)
|
||||
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
|
||||
assert.deepEqual(groupsForPlane(groups, 'host'), [])
|
||||
})
|
||||
|
||||
test('kbEnabledForPlane mirrors inPreset/public symmetry', () => {
|
||||
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: false }, 'preset'), true)
|
||||
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: false }, 'host'), false)
|
||||
assert.equal(kbEnabledForPlane({ groupKbInPreset: false, groupKbPublic: true }, 'preset'), false)
|
||||
assert.equal(kbEnabledForPlane({ groupKbInPreset: false, groupKbPublic: true }, 'host'), true)
|
||||
assert.equal(kbEnabledForPlane({}, 'preset'), true)
|
||||
assert.equal(kbEnabledForPlane({}, 'host'), false)
|
||||
})
|
||||
|
||||
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => {
|
||||
|
|
@ -38,7 +108,7 @@ test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas;
|
|||
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree'))
|
||||
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))
|
||||
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
|
||||
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'))
|
||||
assert.equal(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'), false)
|
||||
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext'))
|
||||
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors'))
|
||||
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches'))
|
||||
|
|
@ -55,12 +125,12 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults',
|
|||
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
|
||||
assert.deepEqual(groupsForPlane(groups, 'public'), ['topology'])
|
||||
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
|
||||
assert.ok(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'))
|
||||
assert.equal(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'), false)
|
||||
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
|
||||
assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false)
|
||||
})
|
||||
|
||||
test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into topology', () => {
|
||||
test('legacy groupNms/common/managedNe map to ops; absent topology flags use legacy layout', () => {
|
||||
const groups = capabilityGroupsFromSettings({
|
||||
groupNmsInPreset: false,
|
||||
groupCommonInPreset: false,
|
||||
|
|
@ -81,7 +151,7 @@ test('legacy any-true turns ops inPreset on', () => {
|
|||
assert.equal(groups.ops.inPreset, true)
|
||||
})
|
||||
|
||||
test('groupsForced ignores settings for per-export mounts', () => {
|
||||
test('groupsForced normalizes group allow-lists independently from publication', () => {
|
||||
assert.deepEqual(groupsForced(['ops']), ['ops'])
|
||||
assert.deepEqual(groupsForced(['ops', 'topology']), ['ops', 'topology'])
|
||||
assert.deepEqual(groupsForced(['bizMonitor']), ['bizMonitor'])
|
||||
|
|
|
|||
107
test/client-settings-binding.test.mjs
Normal file
107
test/client-settings-binding.test.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
// The shell seeds React from its own dependency tree, not the plugin's local
|
||||
// development junction. Use that same installed peer tree for this built smoke.
|
||||
const profileHome = process.env.DSH_HOME || join(homedir(), '.dsh')
|
||||
const renderer = ['', 'desktop', 'web'].map(profile => join(profileHome, 'profiles', profile, 'node_modules',
|
||||
'@deepseek-ai', 'dsh-client-ui-renderer', 'package.json')).find(existsSync)
|
||||
const sharedRequire = renderer ? createRequire(renderer) : require
|
||||
function loadClient() {
|
||||
let client
|
||||
runInNewContext(readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8'), {
|
||||
window: { __ModuleLoader__: { load({ factory }) { client = factory(spec =>
|
||||
spec === 'react' || spec.startsWith('react/') ? sharedRequire(spec) : require(spec)) } } },
|
||||
setTimeout, clearTimeout, setInterval, clearInterval, console, AbortController,
|
||||
})
|
||||
return client
|
||||
}
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
function hostForm() {
|
||||
let snapshot = { status: 'ready', writable: true, value: { toolsEnabled: true, groupOpsPresets: 'standard', apiUrl: 'http://127.0.0.1:8890' },
|
||||
user: {}, base: {}, revision: 1 }
|
||||
const listeners = new Set(), writes = []
|
||||
return {
|
||||
writes, getSnapshot: () => snapshot,
|
||||
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
|
||||
async set(field, value) {
|
||||
writes.push([field, value])
|
||||
snapshot = { ...snapshot, value: { ...snapshot.value, [field]: value }, user: { ...snapshot.user, [field]: value }, revision: snapshot.revision + 1 }
|
||||
for (const fn of listeners) fn()
|
||||
return true
|
||||
},
|
||||
async unset(field) {
|
||||
const user = { ...snapshot.user }; delete user[field]
|
||||
snapshot = { ...snapshot, user, revision: snapshot.revision + 1 }
|
||||
for (const fn of listeners) fn()
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture(t, legacy = false) {
|
||||
const root = new Context(), owners = [], slots = new Map(), actual = hostForm()
|
||||
class Slots extends Service {
|
||||
constructor(ctx) { super(ctx, 'slots') }
|
||||
inject(name, register) { return register() }
|
||||
register(spec) {
|
||||
const key = `${spec.name}:${spec.id ?? spec.key}`
|
||||
slots.set(key, spec)
|
||||
return () => slots.delete(key)
|
||||
}
|
||||
}
|
||||
class Locale extends Service {
|
||||
constructor(ctx) { super(ctx, 'locale') }
|
||||
register() { return () => {} }
|
||||
bind() { return key => key }
|
||||
}
|
||||
class Forms extends Service {
|
||||
constructor(ctx) { super(ctx, legacy ? 'settingsScope' : 'configForms') }
|
||||
get(id) { return id === 'netxops' ? actual : { getSnapshot: () => ({ status: 'unavailable', writable: false }) } }
|
||||
bind({ namespace }) { return this.get(namespace) }
|
||||
whileServed(ids, register) { return register(new Set(['netxops'])) }
|
||||
}
|
||||
t.after(async () => { for (const owner of owners.reverse()) await owner.dispose() })
|
||||
for (const Provider of [Slots, Locale, Forms]) owners.push(await root.plugin(Provider))
|
||||
const client = loadClient()
|
||||
const owner = await root.plugin({ ...client, name: 'netxops-client-binding' })
|
||||
owners.push(owner)
|
||||
return { root, owner, slots, actual }
|
||||
}
|
||||
|
||||
test('built settings card uses optional lookup in real Cordis and saves through the Host form', async t => {
|
||||
const { owner, slots, actual } = await fixture(t)
|
||||
assert.equal(owner.state, 2 /* ACTIVE */)
|
||||
assert.throws(() => owner.ctx.configForms, /without inject/)
|
||||
const section = slots.get('settings.section:dsh-netxops')
|
||||
assert.ok(section)
|
||||
const face = section.inject()
|
||||
assert.equal(face.hooks.netxopsCard.getSnapshot().available, false)
|
||||
await pause(80)
|
||||
assert.equal(face.hooks.netxopsCard.getSnapshot().available, true)
|
||||
assert.equal(face.hooks.netxopsCard.getSnapshot().writable, true)
|
||||
assert.equal(face.hooks.netxopsCard.getSnapshot().groupOpsPresets.text, 'standard')
|
||||
face.edit('toolsEnabled', 'false')
|
||||
await pause(20)
|
||||
assert.deepEqual(actual.writes, [['toolsEnabled', false]])
|
||||
assert.equal(face.hooks.netxopsCard.getSnapshot().toolsEnabled.text, 'false')
|
||||
assert.ok(slots.has('settings.plugin.item:netxops'))
|
||||
})
|
||||
|
||||
test('built settings card still follows the legacy Host settings scope', async t => {
|
||||
const { slots, actual } = await fixture(t, true)
|
||||
const face = slots.get('settings.section:dsh-netxops').inject()
|
||||
await pause(80)
|
||||
assert.equal(face.hooks.netxopsCard.getSnapshot().available, true)
|
||||
face.edit('groupOpsPresets', '')
|
||||
await pause(20)
|
||||
assert.deepEqual(actual.writes, [['groupOpsPresets', '']])
|
||||
})
|
||||
27
test/form-transport.test.mjs
Normal file
27
test/form-transport.test.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createWaitingSettingsScope, findReadySettingsForm } from '../src/client/settings-form.ts'
|
||||
import { CardForm, booleanFieldPersistFalse } from '../src/client/card-form.ts'
|
||||
|
||||
test('unconnected settings cannot report a successful local-only publication save', async () => {
|
||||
const scope = createWaitingSettingsScope({ toolsEnabled: true })
|
||||
const form = new CardForm(scope, [booleanFieldPersistFalse('toolsEnabled')])
|
||||
assert.equal(form.shell().available, false)
|
||||
assert.equal(form.shell().writable, false)
|
||||
form.actions().edit('toolsEnabled', 'false')
|
||||
await form.save(['toolsEnabled'])
|
||||
assert.equal(scope.getSnapshot().value.toolsEnabled, true)
|
||||
assert.equal(form.shell().failed, true)
|
||||
assert.equal(form.shell().dirty, true)
|
||||
await assert.rejects(scope.set('toolsEnabled', false), /settings_transport_unavailable/)
|
||||
})
|
||||
|
||||
test('form selection ignores unknown namespace placeholders and waits for the real namespace', () => {
|
||||
let ready = false
|
||||
const actual = { getSnapshot: () => ({ status: ready ? 'ready' : 'loading', writable: ready }) }
|
||||
const placeholder = { getSnapshot: () => ({ status: 'unavailable', writable: false }) }
|
||||
const forms = { get(id) { return id === 'netxops' ? actual : placeholder } }
|
||||
assert.equal(findReadySettingsForm(forms, ['client-entry-id', 'netxops', 'dsh-netxops']), undefined)
|
||||
ready = true
|
||||
assert.deepEqual(findReadySettingsForm(forms, ['client-entry-id', 'netxops', 'dsh-netxops']), { ns: 'netxops', scope: actual })
|
||||
})
|
||||
103
test/preset-install-safety.test.mjs
Normal file
103
test/preset-install-safety.test.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { composeNetxopsPresetArtifacts, hostResolveBases, installDirectoryPreset, resolveActiveProfile, resolveStandardPresetDir, syncDeclarativeIntoProfilePatch } from '../src/agent-preset-install.ts'
|
||||
|
||||
const log = { info() {}, warn() {}, error() {} }
|
||||
const patch = '- insert:\n - id: preset-netxops\n name: preset-plugin\n'
|
||||
function home(t) {
|
||||
const previous = process.env.DSH_HOME
|
||||
const root = mkdtempSync(join(tmpdir(), 'netxops-install-'))
|
||||
process.env.DSH_HOME = root
|
||||
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
|
||||
return root
|
||||
}
|
||||
const artifacts = { standardSource: 'fixture', standardKind: 'desktop-patch', standardDir: null,
|
||||
agentCordis: '- id: netxops-tools\n name: dsh-netxops/tools\n', name: 'Netx Ops', description: '', order: '50', declarativePatch: patch }
|
||||
|
||||
test('updating Desktop patch leaves Web patch intact', t => {
|
||||
const root = home(t)
|
||||
for (const profile of ['desktop', 'web']) {
|
||||
mkdirSync(join(root, 'profiles', profile), { recursive: true })
|
||||
writeFileSync(join(root, 'profiles', profile, 'cordis.patch.yml'), '# user configuration\n[]\n')
|
||||
}
|
||||
const web = join(root, 'profiles', 'web', 'cordis.patch.yml'), original = readFileSync(web, 'utf8')
|
||||
syncDeclarativeIntoProfilePatch('desktop', patch, log)
|
||||
assert.equal(readFileSync(web, 'utf8'), original)
|
||||
assert.match(readFileSync(join(root, 'profiles', 'desktop', 'cordis.patch.yml'), 'utf8'), /preset-netxops/)
|
||||
})
|
||||
|
||||
test('unterminated managed block does not truncate user configuration', t => {
|
||||
const root = home(t), folder = join(root, 'profiles', 'web')
|
||||
mkdirSync(folder, { recursive: true })
|
||||
const path = join(folder, 'cordis.patch.yml')
|
||||
const original = '# BEGIN dsh-netxops-preset (managed)\n- id: preserve-me\n'
|
||||
writeFileSync(path, original)
|
||||
assert.throws(() => syncDeclarativeIntoProfilePatch('web', patch, log), /unterminated/)
|
||||
assert.equal(readFileSync(path, 'utf8'), original)
|
||||
})
|
||||
|
||||
test('unmanaged preset declaration remains authoritative', t => {
|
||||
const root = home(t), folder = join(root, 'profiles', 'web')
|
||||
mkdirSync(folder, { recursive: true })
|
||||
const path = join(folder, 'cordis.patch.yml'), original = '- insert:\n - id: preset-netxops\n name: custom-preset\n'
|
||||
writeFileSync(path, original)
|
||||
syncDeclarativeIntoProfilePatch('web', patch, log)
|
||||
assert.equal(readFileSync(path, 'utf8'), original)
|
||||
})
|
||||
|
||||
test('managed directory update preserves edits and extra files', t => {
|
||||
const root = home(t), folder = join(root, '.agent-presets', 'netxops')
|
||||
installDirectoryPreset(artifacts, log)
|
||||
writeFileSync(join(folder, 'notes.md'), 'user notes')
|
||||
writeFileSync(join(folder, 'agent.cordis.yml'), '- id: user-edited\n')
|
||||
installDirectoryPreset({ ...artifacts, agentCordis: '- id: updated\n' }, log)
|
||||
assert.equal(readFileSync(join(folder, 'notes.md'), 'utf8'), 'user notes')
|
||||
assert.equal(readFileSync(join(folder, 'agent.cordis.yml'), 'utf8'), '- id: user-edited\n')
|
||||
assert.equal(existsSync(join(folder, 'skills')), false, 'runtime-controlled skills are not copied into auto-scanned roots')
|
||||
})
|
||||
|
||||
test('profile identity follows the running profile path', () => {
|
||||
const previous = process.env.DSH_PROFILE
|
||||
delete process.env.DSH_PROFILE
|
||||
try {
|
||||
assert.equal(resolveActiveProfile('file:///C:/dsh/profiles/desktop/cordis.yml'), 'desktop')
|
||||
assert.equal(resolveActiveProfile('C:\\dsh\\profiles\\web\\cordis.yml'), 'web')
|
||||
} finally { if (previous !== undefined) process.env.DSH_PROFILE = previous }
|
||||
})
|
||||
|
||||
test('fresh install overlays the seeded standard composition', t => {
|
||||
const root = home(t), standard = join(root, 'standard')
|
||||
mkdirSync(standard)
|
||||
writeFileSync(join(standard, 'agent.cordis.yml'), '- id: standard-only\n')
|
||||
installDirectoryPreset({ ...artifacts, standardDir: standard }, log)
|
||||
assert.equal(readFileSync(join(root, '.agent-presets', 'netxops', 'agent.cordis.yml'), 'utf8'), artifacts.agentCordis)
|
||||
})
|
||||
|
||||
test('each Host resolves its own standard before shared dependencies', t => {
|
||||
const root = home(t)
|
||||
for (const profile of ['desktop', 'web']) {
|
||||
const folder = join(root, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard')
|
||||
mkdirSync(folder, { recursive: true })
|
||||
writeFileSync(join(folder, 'agent.cordis.yml'), `- id: ${profile}\n`)
|
||||
writeFileSync(join(root, 'profiles', profile, 'package.json'), '{}')
|
||||
assert.equal(resolveStandardPresetDir(profile), folder)
|
||||
const other = profile === 'desktop' ? 'web' : 'desktop'
|
||||
assert.equal(hostResolveBases(profile).some(path => path.startsWith(join(root, 'profiles', other))), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('runtime composition preserves the Host standard dialect and workflow engine', t => {
|
||||
home(t)
|
||||
const original = "- id: tool-fs\n name: '@host-only/fs'\n disabled: !!js process.platform === 'win32'\n- id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n"
|
||||
const composed = composeNetxopsPresetArtifacts('web', original)
|
||||
assert.equal(composed.standardKind, 'host-registry')
|
||||
assert.match(composed.agentCordis, /@host-only\/fs/)
|
||||
assert.match(composed.agentCordis, /disabled: !!js process.platform === 'win32'/)
|
||||
assert.match(composed.agentCordis, /workflow-worker-thread/)
|
||||
assert.doesNotMatch(composed.agentCordis, /workflow-ptc/)
|
||||
assert.match(composed.agentCordis, /name: dsh-netxops\/tools/)
|
||||
assert.match(composed.agentCordis, /- id: persona/)
|
||||
})
|
||||
71
test/publication-form.test.mjs
Normal file
71
test/publication-form.test.mjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { CardForm, booleanFieldPersistFalse, textField, presetIdsField } from '../src/client/card-form.ts'
|
||||
|
||||
function formScope(options = {}) {
|
||||
const value = { groupOpsPublic: false, groupOpsPresets: 'standard', apiUrl: 'http://saved' }
|
||||
const listeners = new Set(), writes = []
|
||||
const scope = { getSnapshot: () => ({ status: 'ready', writable: true, value, base: { ...value }, user: { ...value } }),
|
||||
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
|
||||
async set(field, next) {
|
||||
writes.push([field, next]); await options.beforeWrite?.()
|
||||
if (options.fail) throw new Error('save failed')
|
||||
value[field] = next; for (const fn of listeners) fn()
|
||||
}, async unset(field) { delete value[field] } }
|
||||
return { scope, value, writes }
|
||||
}
|
||||
const specs = [booleanFieldPersistFalse('groupOpsPublic'), textField('apiUrl'), presetIdsField('groupOpsPresets')]
|
||||
|
||||
test('publication autosave does not commit unrelated drafts', async () => {
|
||||
const { scope, value, writes } = formScope()
|
||||
const form = new CardForm(scope, specs), actions = form.actions()
|
||||
actions.edit('apiUrl', 'http://draft')
|
||||
actions.edit('groupOpsPublic', 'true')
|
||||
await form.save(['groupOpsPublic'])
|
||||
assert.equal(value.apiUrl, 'http://saved')
|
||||
assert.equal(form.field('apiUrl').text, 'http://draft')
|
||||
assert.deepEqual(writes, [['groupOpsPublic', true]])
|
||||
assert.equal(form.shell().dirty, true)
|
||||
})
|
||||
|
||||
test('rapid on then off saves the latest edit without losing it', async () => {
|
||||
let resolveWrite, started
|
||||
const ready = new Promise(resolve => { started = resolve })
|
||||
const gate = new Promise(resolve => { resolveWrite = resolve })
|
||||
let first = true
|
||||
const { scope, value, writes } = formScope({ beforeWrite: async () => { if (first) { first = false; started(); await gate } } })
|
||||
const form = new CardForm(scope, specs), actions = form.actions()
|
||||
actions.edit('groupOpsPublic', 'true')
|
||||
const one = form.save(['groupOpsPublic'])
|
||||
await ready
|
||||
actions.edit('groupOpsPublic', 'false')
|
||||
const two = form.save(['groupOpsPublic'])
|
||||
resolveWrite()
|
||||
await Promise.all([one, two])
|
||||
assert.deepEqual(writes, [['groupOpsPublic', true], ['groupOpsPublic', false]])
|
||||
assert.equal(value.groupOpsPublic, false)
|
||||
assert.equal(form.shell().dirty, false)
|
||||
})
|
||||
|
||||
test('write failure releases saving state and retains draft for retry', async () => {
|
||||
const options = { fail: true }, { scope, value } = formScope(options)
|
||||
const form = new CardForm(scope, specs)
|
||||
form.actions().edit('groupOpsPublic', 'true')
|
||||
await form.save(['groupOpsPublic'])
|
||||
assert.equal(form.shell().saving, false)
|
||||
assert.equal(form.shell().failed, true)
|
||||
assert.equal(form.shell().dirty, true)
|
||||
options.fail = false
|
||||
await form.save(['groupOpsPublic'])
|
||||
assert.equal(value.groupOpsPublic, true)
|
||||
assert.equal(form.shell().failed, false)
|
||||
})
|
||||
|
||||
test('empty target selection persists an explicit override', async () => {
|
||||
const { scope, value, writes } = formScope()
|
||||
const form = new CardForm(scope, specs)
|
||||
form.actions().edit('groupOpsPresets', '')
|
||||
await form.save(['groupOpsPresets'])
|
||||
assert.equal(value.groupOpsPresets, '')
|
||||
assert.deepEqual(writes, [['groupOpsPresets', '']])
|
||||
})
|
||||
157
test/publication-lifecycle.test.mjs
Normal file
157
test/publication-lifecycle.test.mjs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { capabilityGroupsFromSettings, groupsForPreset, kbEnabledForPlane } from '../src/netx/capability-groups.ts'
|
||||
import { applyGroupToolsPlugin } from '../src/netx/group-tools-plugin.ts'
|
||||
import { registerNetxTools } from '../src/netx/tools.ts'
|
||||
import { registerKbLocalTools } from '../src/netx/kb-local-tools.ts'
|
||||
import { registerGroupSkills } from '../src/netx/group-skills.ts'
|
||||
import { publishNetxConnection } from '../src/netx/runtime.ts'
|
||||
import { getToolMountStatuses, publishToolMountStatus, resetToolMountStatus } from '../src/netx/tool-mount-status.ts'
|
||||
import { asToolsMountView } from '../src/client/tools-status-view.ts'
|
||||
|
||||
const log = { info() {}, warn() {}, error() {} }
|
||||
const pause = () => new Promise(resolve => setTimeout(resolve, 40))
|
||||
function connection(fields = {}) {
|
||||
return { apiUrl: 'http://127.0.0.1:8890', token: 'test-token', lang: 'zh', toolCallTimeoutMs: 1000,
|
||||
toolsEnabled: fields.toolsEnabled ?? true, groups: capabilityGroupsFromSettings(fields),
|
||||
groupKbInPreset: fields.groupKbInPreset ?? true, groupKbPublic: fields.groupKbPublic ?? false,
|
||||
groupKbPresets: fields.groupKbPresets ?? '' }
|
||||
}
|
||||
function fakeContext(failAt = -1) {
|
||||
const tools = new Map(), skills = new Map(), cleanups = []
|
||||
let registrations = 0
|
||||
const register = map => entry => {
|
||||
if (map === tools && ++registrations === failAt) throw new Error('registration refused')
|
||||
if (map.has(entry.name)) throw new Error(`duplicate: ${entry.name}`)
|
||||
map.set(entry.name, entry)
|
||||
return () => { if (map.get(entry.name) === entry) map.delete(entry.name) }
|
||||
}
|
||||
const ctx = { logger: log, tools: { register: register(tools) }, skills: { register: register(skills) },
|
||||
get(name) { return this[name] }, inject(deps, fn) { fn(this) },
|
||||
effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup); return cleanup } }
|
||||
return { ctx, tools, skills, dispose() { for (const fn of cleanups.reverse()) fn() } }
|
||||
}
|
||||
function diagnosticHome(t) {
|
||||
const previous = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-publication-'))
|
||||
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
|
||||
}
|
||||
|
||||
test('selected presets are isolated and global publication is inherited once', () => {
|
||||
const selected = connection({ groupOpsInPreset: false, groupOpsPresets: 'standard,review standard', groupBizMonitorInPreset: false })
|
||||
assert.deepEqual(groupsForPreset(selected, 'standard'), ['ops'])
|
||||
assert.deepEqual(groupsForPreset(selected, 'review'), ['ops'])
|
||||
assert.deepEqual(groupsForPreset(selected, 'netxops'), [])
|
||||
assert.deepEqual(groupsForPreset(selected, 'other'), [])
|
||||
selected.groups.ops.public = true
|
||||
assert.deepEqual(groupsForPreset(selected, 'standard'), [], 'public tools come from Host, with no duplicate local registration')
|
||||
assert.equal(kbEnabledForPlane({ groupKbInPreset: true, groupKbPublic: true }, 'preset'), false)
|
||||
})
|
||||
|
||||
test('master switch blocks selected, public, preset and explicit group mounts', async t => {
|
||||
diagnosticHome(t)
|
||||
const disabled = connection({ toolsEnabled: false, groupOpsPublic: true, groupOpsPresets: 'standard', groupKbPublic: true })
|
||||
assert.deepEqual(groupsForPreset(disabled, 'standard'), [])
|
||||
assert.equal(kbEnabledForPlane(disabled, 'host'), false)
|
||||
const { ctx, tools } = fakeContext()
|
||||
const dispose = registerNetxTools(ctx, disabled, { plane: 'host', forceGroups: ['ops'] })
|
||||
assert.equal(tools.size, 0)
|
||||
dispose()
|
||||
})
|
||||
|
||||
test('failed tool registration rolls back every earlier tool', t => {
|
||||
diagnosticHome(t)
|
||||
const { ctx, tools } = fakeContext(3)
|
||||
assert.throws(() => registerNetxTools(ctx, connection(), { plane: 'preset' }), /registration refused/)
|
||||
assert.equal(tools.size, 0)
|
||||
assert.ok(getToolMountStatuses().some(row => row.error === 'registration refused'))
|
||||
resetToolMountStatus()
|
||||
})
|
||||
|
||||
test('disabled group exports cannot force publication back on', async t => {
|
||||
diagnosticHome(t)
|
||||
publishNetxConnection(connection({ groupOpsInPreset: false, groupBizMonitorInPreset: false, groupKbInPreset: false }))
|
||||
const mount = fakeContext()
|
||||
applyGroupToolsPlugin(mount.ctx, { name: 'explicit-ops', mode: 'forced', only: ['ops'] })
|
||||
await pause()
|
||||
assert.equal(mount.tools.size, 0)
|
||||
assert.equal(mount.skills.size, 0)
|
||||
mount.dispose()
|
||||
})
|
||||
|
||||
test('turning off a mounted group revokes tools, KB context and pending skill loads', async t => {
|
||||
diagnosticHome(t)
|
||||
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
|
||||
const mount = fakeContext()
|
||||
applyGroupToolsPlugin(mount.ctx, { name: 'lifecycle', mode: 'settings' })
|
||||
assert.equal(mount.tools.size, 15)
|
||||
assert.equal(mount.skills.has('kb-context'), true)
|
||||
publishNetxConnection(connection({ toolsEnabled: false }))
|
||||
await pause()
|
||||
assert.equal(mount.tools.size, 0)
|
||||
assert.equal(mount.skills.size, 0)
|
||||
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
|
||||
await pause()
|
||||
assert.equal(mount.tools.size, 15)
|
||||
assert.equal(mount.skills.has('netx-ops'), true)
|
||||
mount.dispose()
|
||||
await pause()
|
||||
assert.equal(mount.tools.size, 0)
|
||||
assert.equal(mount.skills.size, 0)
|
||||
})
|
||||
|
||||
test('aborted skill load contributes nothing after asynchronous reads', async () => {
|
||||
const mount = fakeContext()
|
||||
const abort = new AbortController()
|
||||
const pending = registerGroupSkills(mount.ctx, ['ops', 'topology'], 'aborted', abort.signal)
|
||||
abort.abort()
|
||||
const dispose = await pending
|
||||
assert.equal(mount.skills.size, 0)
|
||||
dispose()
|
||||
})
|
||||
|
||||
test('mount inventory survives cross-bundle publishes and reports all scopes', async t => {
|
||||
diagnosticHome(t)
|
||||
resetToolMountStatus()
|
||||
publishToolMountStatus({ id: 'host-test', version: 'test', plane: 'host', source: 'host', groups: [], names: [], count: 0, at: '' })
|
||||
const entry = await import('../lib/agent-tools.js')
|
||||
publishNetxConnection(connection({ groupBizMonitorInPreset: false, groupKbInPreset: false }))
|
||||
const mount = fakeContext()
|
||||
entry.apply(mount.ctx)
|
||||
const statuses = getToolMountStatuses()
|
||||
assert.ok(statuses.some(row => row.id === 'host-test'))
|
||||
assert.ok(statuses.some(row => row.count === 15))
|
||||
const view = asToolsMountView({ mounts: statuses, connection: { toolsEnabled: true } })
|
||||
assert.equal(view.count, 15)
|
||||
mount.dispose()
|
||||
assert.equal(getToolMountStatuses().some(row => row.count > 0), false)
|
||||
resetToolMountStatus()
|
||||
})
|
||||
|
||||
test('write tools serialize while read tools allow concurrency', t => {
|
||||
diagnosticHome(t)
|
||||
const mount = fakeContext()
|
||||
const dispose = registerNetxTools(mount.ctx, connection(), { plane: 'preset', forceGroups: ['ops', 'topology'] })
|
||||
assert.equal(mount.tools.get('netx__queryNmsAlarms').isConcurrencySafe({}), true)
|
||||
assert.equal(mount.tools.get('netx__execManagedNe').isConcurrencySafe({}), false)
|
||||
assert.equal(mount.tools.get('netx__updateTopologyViewPositions').isConcurrencySafe({}), false)
|
||||
dispose()
|
||||
})
|
||||
|
||||
test('knowledge-base tools contribute to the mount inventory and disappear on disposal', t => {
|
||||
diagnosticHome(t)
|
||||
resetToolMountStatus()
|
||||
const mount = fakeContext()
|
||||
const snapshot = { status: 'configured', realRoot: process.env.DSH_HOME, paths: { local: '_local' } }
|
||||
const dispose = registerKbLocalTools(mount.ctx, snapshot, { plane: 'preset', source: 'kb-test' })
|
||||
assert.equal(mount.tools.size, 7)
|
||||
const statuses = getToolMountStatuses()
|
||||
assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 7 && row.groups.includes('kb')))
|
||||
assert.equal(asToolsMountView({ mounts: statuses }).count, 7)
|
||||
dispose()
|
||||
assert.equal(mount.tools.size, 0)
|
||||
assert.equal(getToolMountStatuses().some(row => row.source === 'kb-test'), false)
|
||||
})
|
||||
103
test/selected-presets.test.mjs
Normal file
103
test/selected-presets.test.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { installPresetPublication } from '../src/netx/preset-publication.ts'
|
||||
import { publishNetxConnection } from '../src/netx/runtime.ts'
|
||||
import { capabilityGroupsFromSettings } from '../src/netx/capability-groups.ts'
|
||||
|
||||
const layers = new Map()
|
||||
const skillLayers = new Map()
|
||||
let live = []
|
||||
class Tools extends Service {
|
||||
constructor(ctx) { super(ctx, 'tools') }
|
||||
register(entry) {
|
||||
const key = scopeOf(this.ctx)
|
||||
let layer = layers.get(key)
|
||||
if (!layer) layers.set(key, layer = new Map())
|
||||
if (layer.has(entry.name)) throw new Error(`duplicate tool ${entry.name}`)
|
||||
layer.set(entry.name, entry)
|
||||
return () => layer.delete(entry.name)
|
||||
}
|
||||
}
|
||||
class Skills extends Service {
|
||||
constructor(ctx) { super(ctx, 'skills') }
|
||||
register(entry) {
|
||||
const key = scopeOf(this.ctx)
|
||||
let layer = skillLayers.get(key)
|
||||
if (!layer) skillLayers.set(key, layer = new Map())
|
||||
if (layer.has(entry.name)) throw new Error(`duplicate skill ${entry.name}`)
|
||||
layer.set(entry.name, entry)
|
||||
return () => layer.delete(entry.name)
|
||||
}
|
||||
}
|
||||
class Agents extends Service {
|
||||
constructor(ctx) { super(ctx, 'agents') }
|
||||
list() { return live }
|
||||
}
|
||||
class Presets extends Service {
|
||||
constructor(ctx) { super(ctx, 'agentPresets') }
|
||||
composedPreset(ctx) { return ctx.presetId }
|
||||
}
|
||||
const wait = () => new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
test('selected publication uses each exact session scope and cleans up on off, switch, and unload', async t => {
|
||||
const previous = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-selected-'))
|
||||
const ctx = new Context()
|
||||
const owners = []
|
||||
t.after(async () => {
|
||||
for (const owner of owners.reverse()) await owner.dispose()
|
||||
if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous
|
||||
layers.clear(); skillLayers.clear(); live = []
|
||||
})
|
||||
owners.push(await ctx.plugin(Tools))
|
||||
owners.push(await ctx.plugin(Skills))
|
||||
owners.push(await ctx.plugin(Agents))
|
||||
owners.push(await ctx.plugin(Presets))
|
||||
const scopes = []
|
||||
owners.push(await ctx.plugin(Object.assign(inner => {
|
||||
for (const id of ['standard', 'review']) {
|
||||
const key = {}
|
||||
const scope = createScope(inner, key)
|
||||
const agent = { id, ctx: scope.ctx.extend({ presetId: id }) }
|
||||
scopes.push({ key, scope, agent })
|
||||
}
|
||||
}, { inject: ['tools', 'skills'] })))
|
||||
live = scopes.map(row => row.agent)
|
||||
const publish = (overrides = {}) => publishNetxConnection({
|
||||
apiUrl: 'http://127.0.0.1:8890', token: '', lang: 'zh', toolCallTimeoutMs: 1000,
|
||||
toolsEnabled: true,
|
||||
groups: capabilityGroupsFromSettings({ groupOpsPresets: 'standard', groupBizMonitorInPreset: false }),
|
||||
groupKbInPreset: false, groupKbPresets: '', ...overrides,
|
||||
})
|
||||
publish()
|
||||
const owner = await ctx.plugin(installPresetPublication)
|
||||
owners.push(owner)
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[0].key)?.size, 15)
|
||||
assert.equal(skillLayers.get(scopes[0].key)?.has('netx-ops'), true)
|
||||
assert.equal(layers.get(scopes[1].key)?.size ?? 0, 0)
|
||||
publish({ toolsEnabled: false })
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[0].key)?.size, 0)
|
||||
assert.equal(skillLayers.get(scopes[0].key)?.size, 0)
|
||||
publish()
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[0].key)?.size, 15)
|
||||
scopes[0].agent.ctx = scopes[0].agent.ctx.extend({ presetId: 'review' })
|
||||
await ctx.serial('agent-preset/selected', 'standard', 'review')
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[0].key)?.size, 0)
|
||||
scopes[1].agent.ctx = scopes[1].agent.ctx.extend({ presetId: 'standard' })
|
||||
await ctx.serial('agent/created', { agent: scopes[1].agent })
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[1].key)?.size, 15)
|
||||
await owner.dispose()
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[1].key)?.size, 0)
|
||||
assert.equal(skillLayers.get(scopes[1].key)?.size, 0)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue