mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 02:43:21 +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
19
tests/api-url-field.test.mjs
Normal file
19
tests/api-url-field.test.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { apiUrlField } from '../src/client/card-form.ts'
|
||||
|
||||
test('apiUrlField accepts http(s) and normalizes trailing slash', () => {
|
||||
const field = apiUrlField('apiUrl')
|
||||
assert.deepEqual(field.parse('http://127.0.0.1:8890/'), {
|
||||
kind: 'set',
|
||||
value: 'http://127.0.0.1:8890',
|
||||
})
|
||||
assert.deepEqual(field.parse(''), { kind: 'clear' })
|
||||
})
|
||||
|
||||
test('apiUrlField rejects unsafe URLs', () => {
|
||||
const field = apiUrlField('apiUrl')
|
||||
assert.equal(field.parse('file:///tmp'), undefined)
|
||||
assert.equal(field.parse('http://user:pass@h/'), undefined)
|
||||
assert.equal(field.parse('not-a-url'), undefined)
|
||||
})
|
||||
112
tests/compatibility.test.mjs
Normal file
112
tests/compatibility.test.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const read = (path) => readFile(resolve(root, path), 'utf8')
|
||||
|
||||
test('package declares Desktop 0.2 client contract', async () => {
|
||||
const pkg = JSON.parse(await read('package.json'))
|
||||
assert.equal(pkg.version, '0.1.69')
|
||||
assert.equal(pkg.engines.node, '>=20')
|
||||
assert.equal(pkg.dsh.client.immediately, false)
|
||||
assert.deepEqual(pkg.dsh.client.inject, [
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-locale',
|
||||
])
|
||||
assert.ok(!pkg.dsh.client.inject.includes('@deepseek-ai/dsh-client-runtime'))
|
||||
assert.equal(pkg.exports['./cordis.patch.yml'], './cordis.bundle.patch.yml')
|
||||
assert.ok(!pkg.files.includes('lib/agent-tools-shared.js'))
|
||||
for (const name of [
|
||||
'@deepseek-ai/cordis',
|
||||
'@deepseek-ai/dsh-credentials',
|
||||
'@deepseek-ai/dsh-settings',
|
||||
'@deepseek-ai/dsh-tools',
|
||||
'@deepseek-ai/schemastery',
|
||||
]) {
|
||||
assert.equal(pkg.peerDependencies[name], '*', `${name} should be open peer *`)
|
||||
}
|
||||
})
|
||||
|
||||
test('agent-tools entries are self-contained (no shared chunk)', async () => {
|
||||
for (const name of [
|
||||
'agent-tools.js',
|
||||
'agent-tools-ops.js',
|
||||
'agent-tools-nms.js',
|
||||
'agent-tools-common.js',
|
||||
'agent-tools-topology.js',
|
||||
'agent-tools-biz-monitor.js',
|
||||
]) {
|
||||
const entry = await read(`lib/${name}`)
|
||||
assert.doesNotMatch(entry, /agent-tools-shared/)
|
||||
assert.match(entry, /applyGroupToolsPlugin|function apply/)
|
||||
const info = await stat(resolve(root, `lib/${name}`))
|
||||
assert.ok(info.size > 20_000, `${name} should be a full bundle, got ${info.size}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('browser half uses deferred configForms and distinct section id', async () => {
|
||||
const client = await read('lib/client.js')
|
||||
assert.match(client, /name:\s*["']settings\.section["']/)
|
||||
assert.match(client, /NETXOPS_SECTION_ID\s*=\s*["']dsh-netxops["']/)
|
||||
assert.match(client, /NETXOPS_NS\s*=\s*["']netxops["']/)
|
||||
assert.match(client, /id:\s*NETXOPS_SECTION_ID/)
|
||||
assert.match(client, /configForms poll/)
|
||||
assert.match(client, /createDeferredScope/)
|
||||
assert.match(client, /tools\.status|TOOLS_STATUS_ENDPOINT|toolsStatus/)
|
||||
assert.doesNotMatch(client, /inject\(\[["']configForms["']\]/)
|
||||
assert.doesNotMatch(client, /inject\(\[["']settingsScope["']\]/)
|
||||
assert.match(client, /name:\s*["']settings\.plugin\.item["']/)
|
||||
assert.match(client, /key:\s*id/)
|
||||
assert.doesNotMatch(client, /name:\s*["']plugins\.item["']/)
|
||||
assert.doesNotMatch(client, /dsh-client-runtime/)
|
||||
assert.doesNotMatch(client, /mcpServers|python -m netx_mcp/)
|
||||
})
|
||||
|
||||
test('host Config marks scalar fields volatile for 0.2 projection', async () => {
|
||||
const host = await read('lib/index.js')
|
||||
assert.match(host, /function vol\(/)
|
||||
assert.match(host, /\.volatile\s*\(/)
|
||||
assert.match(host, /NETXOPS_SETTINGS_NAMESPACE\s*=\s*["']netxops["']/)
|
||||
assert.match(host, /document-updated/)
|
||||
assert.match(host, /normalizeApiUrl/)
|
||||
assert.match(host, /mergeHostConfigForHeal/)
|
||||
assert.match(host, /writePatchIfUnchanged/)
|
||||
assert.match(host, /groupOpsPublic:\s*vol\(z\.boolean\(\)\.default\(false\)\)/)
|
||||
assert.doesNotMatch(host, /inject\(\[["']settingsScope["']\]/)
|
||||
})
|
||||
|
||||
test('client validates apiUrl and defaults ops public off', async () => {
|
||||
const client = await read('lib/client.js')
|
||||
assert.match(client, /apiUrlField\s*\(/)
|
||||
assert.match(client, /apiUrlInvalid/)
|
||||
assert.match(client, /groupOpsPublic:\s*false/)
|
||||
assert.match(client, /booleanFieldPersistFalse\s*\(\s*["']groupOpsPublic["']\s*\)/)
|
||||
})
|
||||
|
||||
test('tools plugin subscribes before first remount (connection race)', async () => {
|
||||
const tools = await read('lib/agent-tools.js')
|
||||
const watchIdx = tools.indexOf('watchNetxConnection')
|
||||
const remountIdx = tools.indexOf('remountTools()')
|
||||
assert.ok(watchIdx >= 0 && remountIdx >= 0)
|
||||
assert.ok(
|
||||
watchIdx < remountIdx,
|
||||
'watchNetxConnection must be registered before the first remountTools()',
|
||||
)
|
||||
assert.match(tools, /setInterval/)
|
||||
assert.match(tools, /requireNetxConnection|provisionalNetxConnection/)
|
||||
})
|
||||
|
||||
test('host hard-injects tools; soft credentials + provisional publish', async () => {
|
||||
const host = await read('lib/index.js')
|
||||
assert.match(host, /inject\s*=\s*\[\s*["']tools["']\s*\]/)
|
||||
assert.match(host, /provisional connection published/)
|
||||
assert.match(host, /plane:\s*["']host["']/)
|
||||
assert.match(host, /tools\.status/)
|
||||
assert.match(host, /netxops-mount-status\.json/)
|
||||
assert.match(host, /additionalProperties:\s*!0|additionalProperties:\s*true/)
|
||||
assert.match(host, /inject\(\[["']credentials["']\]/)
|
||||
assert.doesNotMatch(host, /export const inject = \[["']credentials["']\]/)
|
||||
})
|
||||
61
tests/connection-race.test.mjs
Normal file
61
tests/connection-race.test.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
getNetxConnection,
|
||||
publishNetxConnection,
|
||||
watchNetxConnection,
|
||||
} from '../src/netx/runtime.ts'
|
||||
import { DEFAULT_CAPABILITY_GROUPS } from '../src/netx/capability-groups.ts'
|
||||
|
||||
function clearConnection() {
|
||||
publishNetxConnection({
|
||||
apiUrl: '',
|
||||
token: '',
|
||||
lang: 'zh',
|
||||
toolCallTimeoutMs: 1000,
|
||||
groups: DEFAULT_CAPABILITY_GROUPS,
|
||||
groupKbInPreset: false,
|
||||
groupKbPublic: false,
|
||||
})
|
||||
// runtime has no reset — overwrite with a sentinel then ignore
|
||||
}
|
||||
|
||||
test('watch-then-remount does not miss an in-flight publish', () => {
|
||||
let mounts = 0
|
||||
const remount = () => {
|
||||
if (getNetxConnection()?.apiUrl) mounts += 1
|
||||
}
|
||||
|
||||
// Bad order (pre-0.1.58): remount → publish → watch → miss
|
||||
mounts = 0
|
||||
remount()
|
||||
publishNetxConnection({
|
||||
apiUrl: 'http://127.0.0.1:8890',
|
||||
token: 't',
|
||||
lang: 'zh',
|
||||
toolCallTimeoutMs: 1000,
|
||||
groups: DEFAULT_CAPABILITY_GROUPS,
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
})
|
||||
const stopBad = watchNetxConnection(remount)
|
||||
assert.equal(mounts, 0, 'bad order misses the publish')
|
||||
stopBad()
|
||||
|
||||
// Good order: watch → publish → remount (and watch also fires)
|
||||
mounts = 0
|
||||
clearConnection()
|
||||
const stopGood = watchNetxConnection(remount)
|
||||
publishNetxConnection({
|
||||
apiUrl: 'http://127.0.0.1:8890',
|
||||
token: 't',
|
||||
lang: 'zh',
|
||||
toolCallTimeoutMs: 1000,
|
||||
groups: DEFAULT_CAPABILITY_GROUPS,
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
})
|
||||
remount()
|
||||
assert.ok(mounts >= 1, 'good order sees connection')
|
||||
stopGood()
|
||||
})
|
||||
56
tests/heal-merge.test.mjs
Normal file
56
tests/heal-merge.test.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
mergeHostConfigForHeal,
|
||||
NETXOPS_HOST_CONFIG_DEFAULTS,
|
||||
writePatchIfUnchanged,
|
||||
} from '../src/netx/heal-profile-entry.ts'
|
||||
|
||||
test('mergeHostConfigForHeal fills missing defaults only', () => {
|
||||
const merged = mergeHostConfigForHeal({
|
||||
apiUrl: 'http://custom:8890',
|
||||
groupOpsPublic: false,
|
||||
})
|
||||
assert.equal(merged.apiUrl, 'http://custom:8890')
|
||||
assert.equal(merged.groupOpsPublic, false)
|
||||
assert.equal(merged.lang, NETXOPS_HOST_CONFIG_DEFAULTS.lang)
|
||||
assert.equal(merged.tokenCredentialRef, NETXOPS_HOST_CONFIG_DEFAULTS.tokenCredentialRef)
|
||||
assert.equal(merged.groupBizMonitorInPreset, NETXOPS_HOST_CONFIG_DEFAULTS.groupBizMonitorInPreset)
|
||||
})
|
||||
|
||||
test('mergeHostConfigForHeal preserves unknown user keys', () => {
|
||||
const merged = mergeHostConfigForHeal({
|
||||
apiUrl: 'http://127.0.0.1:8890',
|
||||
customSiteTag: 'hq-edge-1',
|
||||
})
|
||||
assert.equal(merged.customSiteTag, 'hq-edge-1')
|
||||
assert.equal(merged.apiUrl, 'http://127.0.0.1:8890')
|
||||
})
|
||||
|
||||
test('mergeHostConfigForHeal never overwrites empty-string user values with defaults', () => {
|
||||
const merged = mergeHostConfigForHeal({
|
||||
kbRoot: '',
|
||||
imBotId: '',
|
||||
})
|
||||
assert.equal(merged.kbRoot, '')
|
||||
assert.equal(merged.imBotId, '')
|
||||
})
|
||||
|
||||
test('NETXOPS_HOST_CONFIG_DEFAULTS keeps ops public off', () => {
|
||||
assert.equal(NETXOPS_HOST_CONFIG_DEFAULTS.groupOpsPublic, false)
|
||||
})
|
||||
|
||||
test('writePatchIfUnchanged aborts when file raced', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'netxops-heal-'))
|
||||
const path = join(dir, 'cordis.patch.yml')
|
||||
writeFileSync(path, 'v1\n', 'utf8')
|
||||
assert.equal(writePatchIfUnchanged(path, 'v1\n', 'v2\n'), true)
|
||||
assert.equal(readFileSync(path, 'utf8'), 'v2\n')
|
||||
// Caller still holds stale expectedRaw after another writer changed the file.
|
||||
writeFileSync(path, 'other\n', 'utf8')
|
||||
assert.equal(writePatchIfUnchanged(path, 'v2\n', 'v3\n'), false)
|
||||
assert.equal(readFileSync(path, 'utf8'), 'other\n')
|
||||
})
|
||||
41
tests/http-url.test.mjs
Normal file
41
tests/http-url.test.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { normalizeApiUrl } from '../src/netx/http.ts'
|
||||
import { alarmSubscribeUrl } from '../src/netx/alarm-push.ts'
|
||||
|
||||
test('normalizeApiUrl accepts http(s) roots and strips trailing slash', () => {
|
||||
assert.deepEqual(normalizeApiUrl('http://127.0.0.1:8890/'), {
|
||||
ok: true,
|
||||
url: 'http://127.0.0.1:8890',
|
||||
})
|
||||
assert.deepEqual(normalizeApiUrl('https://netx.example.com/api/'), {
|
||||
ok: true,
|
||||
url: 'https://netx.example.com/api',
|
||||
})
|
||||
assert.deepEqual(normalizeApiUrl(' http://192.168.1.10:8890 '), {
|
||||
ok: true,
|
||||
url: 'http://192.168.1.10:8890',
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeApiUrl rejects empty, bad schemes, and credentials-in-URL', () => {
|
||||
assert.equal(normalizeApiUrl('').error, 'empty')
|
||||
assert.equal(normalizeApiUrl('not a url').error, 'invalid_url')
|
||||
assert.equal(normalizeApiUrl('file:///etc/passwd').error, 'unsupported_scheme')
|
||||
assert.equal(normalizeApiUrl('javascript:alert(1)').error, 'unsupported_scheme')
|
||||
assert.equal(normalizeApiUrl('http://user:pass@127.0.0.1:8890').error, 'credentials_in_url')
|
||||
})
|
||||
|
||||
test('alarmSubscribeUrl only promotes http(s) to ws(s)', () => {
|
||||
assert.equal(
|
||||
alarmSubscribeUrl('http://127.0.0.1:8890'),
|
||||
'ws://127.0.0.1:8890/v1/integrations/dsh-alarm/ws',
|
||||
)
|
||||
assert.equal(
|
||||
alarmSubscribeUrl('https://netx.example.com/'),
|
||||
'wss://netx.example.com/v1/integrations/dsh-alarm/ws',
|
||||
)
|
||||
assert.equal(alarmSubscribeUrl('file:///tmp'), '')
|
||||
assert.equal(alarmSubscribeUrl('http://user:secret@127.0.0.1:8890'), '')
|
||||
assert.equal(alarmSubscribeUrl(''), '')
|
||||
})
|
||||
347
tests/smoke-register-tools.mjs
Normal file
347
tests/smoke-register-tools.mjs
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* Smoke: prove dsh-netxops/tools + host can register netx__* against a fake registry.
|
||||
* Run: node tests/smoke-register-tools.mjs
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const STORE_KEY = Symbol.for('dsh-netxops.connection-store')
|
||||
|
||||
function connectionStore() {
|
||||
return globalThis[STORE_KEY]
|
||||
}
|
||||
|
||||
function resetConnectionStore() {
|
||||
const state = connectionStore()
|
||||
if (state) {
|
||||
state.connection = undefined
|
||||
state.listeners.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function createLogger(tag) {
|
||||
const lines = []
|
||||
const log = (level, ...args) => {
|
||||
const msg = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')
|
||||
lines.push(`[${level}] ${msg}`)
|
||||
console.log(`[${tag}/${level}]`, ...args)
|
||||
}
|
||||
return {
|
||||
lines,
|
||||
info: (...a) => log('info', ...a),
|
||||
warn: (...a) => log('warn', ...a),
|
||||
error: (...a) => log('error', ...a),
|
||||
debug: (...a) => log('debug', ...a),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal cordis-like Context: tools.register records names;
|
||||
* inject runs when all named services exist on ctx; effect runs immediately.
|
||||
*/
|
||||
function createFakeCtx(services = {}) {
|
||||
const registered = []
|
||||
const effects = []
|
||||
const logger = createLogger(services._tag ?? 'ctx')
|
||||
|
||||
const ctx = {
|
||||
logger,
|
||||
tools: {
|
||||
register(entry) {
|
||||
const name = entry?.name ?? String(entry)
|
||||
registered.push(name)
|
||||
return () => {
|
||||
const i = registered.indexOf(name)
|
||||
if (i >= 0) registered.splice(i, 1)
|
||||
}
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
register() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
async resolve() {
|
||||
return { value: 'smoke-token' }
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
installSection() {},
|
||||
describe() {
|
||||
return []
|
||||
},
|
||||
get() {
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
systemPrompt: {
|
||||
section() {
|
||||
return () => {}
|
||||
},
|
||||
context() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
get(name) {
|
||||
return ctx[name]
|
||||
},
|
||||
inject(deps, callback) {
|
||||
const list = Array.isArray(deps) ? deps : [deps]
|
||||
const missing = list.filter((d) => ctx[d] === undefined && services[d] === undefined)
|
||||
if (missing.length > 0) {
|
||||
// Soft-inject semantics: wait forever (never call) when deps absent.
|
||||
return
|
||||
}
|
||||
// Merge optional service overrides, then invoke.
|
||||
for (const d of list) {
|
||||
if (services[d] !== undefined) ctx[d] = services[d]
|
||||
}
|
||||
try {
|
||||
callback(ctx)
|
||||
} catch (error) {
|
||||
logger.error('inject callback threw: %s', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
effect(factory, label) {
|
||||
try {
|
||||
const dispose = factory()
|
||||
effects.push({ label, dispose })
|
||||
return typeof dispose === 'function' ? dispose : () => {}
|
||||
} catch (error) {
|
||||
logger.error('effect %s threw: %s', label, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
on() {
|
||||
return () => {}
|
||||
},
|
||||
...services,
|
||||
}
|
||||
|
||||
// Ensure tools always present unless explicitly removed
|
||||
if (services.tools === undefined && ctx.tools) {
|
||||
// keep default
|
||||
}
|
||||
|
||||
return { ctx, registered, effects, logger }
|
||||
}
|
||||
|
||||
function disposeAll(effects) {
|
||||
for (const { dispose } of effects.splice(0)) {
|
||||
try {
|
||||
if (typeof dispose === 'function') dispose()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeAgentTools() {
|
||||
console.log('\n=== A) agent-tools apply (provisional connection) ===')
|
||||
resetConnectionStore()
|
||||
|
||||
const mod = await import(pathToFileURL(join(root, 'lib/agent-tools.js')).href)
|
||||
assert.equal(mod.name, 'netxops-tools')
|
||||
assert.deepEqual(mod.inject, ['tools'])
|
||||
assert.equal(typeof mod.apply, 'function')
|
||||
|
||||
const { ctx, registered, effects, logger } = createFakeCtx({ _tag: 'agent-tools' })
|
||||
// Only tools — skills inject should soft-skip (not block tool registration)
|
||||
delete ctx.skills
|
||||
|
||||
let thrown = null
|
||||
try {
|
||||
mod.apply(ctx)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
console.error('agent-tools apply threw:', error)
|
||||
}
|
||||
|
||||
const netx = registered.filter((n) => String(n).startsWith('netx__'))
|
||||
const hasQuery = netx.includes('netx__queryNmsAlarms')
|
||||
const sample = netx.slice(0, 12)
|
||||
|
||||
console.log('registered count:', registered.length)
|
||||
console.log('netx__ count:', netx.length)
|
||||
console.log('sample:', sample)
|
||||
console.log('has netx__queryNmsAlarms:', hasQuery)
|
||||
console.log('logger errors:', logger.lines.filter((l) => l.startsWith('[error]')))
|
||||
|
||||
disposeAll(effects)
|
||||
|
||||
return {
|
||||
ok: thrown === null && hasQuery && netx.length >= 10,
|
||||
thrown,
|
||||
count: netx.length,
|
||||
names: netx,
|
||||
sample,
|
||||
hasQuery,
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeHostPublish() {
|
||||
console.log('\n=== B) host apply (provisional publish + public tools) ===')
|
||||
resetConnectionStore()
|
||||
|
||||
const mod = await import(pathToFileURL(join(root, 'lib/index.js')).href)
|
||||
assert.equal(mod.name, 'netxops')
|
||||
assert.equal(typeof mod.apply, 'function')
|
||||
assert.equal(typeof mod.Config, 'function')
|
||||
|
||||
const publicRegistered = []
|
||||
const { ctx, effects, logger } = createFakeCtx({
|
||||
_tag: 'host',
|
||||
tools: {
|
||||
register(entry) {
|
||||
const name = entry?.name ?? String(entry)
|
||||
publicRegistered.push(name)
|
||||
return () => {
|
||||
const i = publicRegistered.indexOf(name)
|
||||
if (i >= 0) publicRegistered.splice(i, 1)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const config = mod.Config({
|
||||
apiUrl: 'http://127.0.0.1:8890',
|
||||
installAgentPreset: false,
|
||||
alarmPushEnabled: false,
|
||||
groupOpsInPreset: true,
|
||||
groupOpsPublic: true,
|
||||
groupBizMonitorInPreset: true,
|
||||
groupBizMonitorPublic: false,
|
||||
groupTopologyInPreset: false,
|
||||
groupTopologyPublic: false,
|
||||
groupKbInPreset: false,
|
||||
groupKbPublic: false,
|
||||
})
|
||||
|
||||
let thrown = null
|
||||
try {
|
||||
mod.apply(ctx, config)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
console.error('host apply threw:', error)
|
||||
}
|
||||
|
||||
// Allow async publish() (credentials resolve) one tick
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const store = connectionStore()
|
||||
const conn = store?.connection
|
||||
const provisionalOk = Boolean(conn?.apiUrl)
|
||||
const hostNetx = publicRegistered.filter((n) => String(n).startsWith('netx__'))
|
||||
const hasQueryHost = hostNetx.includes('netx__queryNmsAlarms')
|
||||
|
||||
console.log('connection published:', provisionalOk, conn?.apiUrl, 'tokenConfigured=', Boolean(conn?.token?.trim()))
|
||||
console.log('host netx__ count:', hostNetx.length)
|
||||
console.log('host sample:', hostNetx.slice(0, 8))
|
||||
console.log('has host netx__queryNmsAlarms:', hasQueryHost)
|
||||
console.log('logger errors:', logger.lines.filter((l) => l.startsWith('[error]')))
|
||||
|
||||
disposeAll(effects)
|
||||
|
||||
return {
|
||||
ok: thrown === null && provisionalOk && hasQueryHost && hostNetx.length >= 5,
|
||||
thrown,
|
||||
provisionalOk,
|
||||
apiUrl: conn?.apiUrl,
|
||||
tokenConfigured: Boolean(conn?.token?.trim()),
|
||||
publicCount: hostNetx.length,
|
||||
publicSample: hostNetx.slice(0, 8),
|
||||
hasQueryPublic: hasQueryHost,
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeHostThenAgent() {
|
||||
console.log('\n=== C) host publish then agent-tools remount ===')
|
||||
resetConnectionStore()
|
||||
|
||||
const host = await import(pathToFileURL(join(root, 'lib/index.js')).href)
|
||||
const tools = await import(pathToFileURL(join(root, 'lib/agent-tools.js')).href)
|
||||
|
||||
const hostBag = createFakeCtx({ _tag: 'host-c' })
|
||||
// Host without tools — only publish path
|
||||
delete hostBag.ctx.tools
|
||||
delete hostBag.ctx.skills
|
||||
|
||||
const agentBag = createFakeCtx({ _tag: 'agent-c' })
|
||||
delete agentBag.ctx.skills
|
||||
|
||||
host.apply(
|
||||
hostBag.ctx,
|
||||
host.Config({
|
||||
apiUrl: 'http://10.0.0.1:8890',
|
||||
installAgentPreset: false,
|
||||
alarmPushEnabled: false,
|
||||
groupOpsInPreset: true,
|
||||
groupOpsPublic: false,
|
||||
groupKbInPreset: false,
|
||||
}),
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
tools.apply(agentBag.ctx)
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
const netx = agentBag.registered.filter((n) => String(n).startsWith('netx__'))
|
||||
const store = connectionStore()?.connection
|
||||
console.log('live apiUrl:', store?.apiUrl)
|
||||
console.log('agent netx__ count:', netx.length)
|
||||
console.log('has query:', netx.includes('netx__queryNmsAlarms'))
|
||||
|
||||
disposeAll(hostBag.effects)
|
||||
disposeAll(agentBag.effects)
|
||||
|
||||
return {
|
||||
ok: netx.includes('netx__queryNmsAlarms') && store?.apiUrl?.includes('10.0.0.1'),
|
||||
count: netx.length,
|
||||
apiUrl: store?.apiUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const results = {
|
||||
agentTools: await smokeAgentTools(),
|
||||
host: await smokeHostPublish(),
|
||||
combined: await smokeHostThenAgent(),
|
||||
}
|
||||
|
||||
console.log('\n=== SUMMARY ===')
|
||||
console.log(JSON.stringify({
|
||||
agentTools: {
|
||||
pass: results.agentTools.ok,
|
||||
count: results.agentTools.count,
|
||||
hasQuery: results.agentTools.hasQuery,
|
||||
sample: results.agentTools.sample,
|
||||
error: results.agentTools.thrown ? String(results.agentTools.thrown) : null,
|
||||
},
|
||||
host: {
|
||||
pass: results.host.ok,
|
||||
provisionalOk: results.host.provisionalOk,
|
||||
apiUrl: results.host.apiUrl,
|
||||
tokenConfigured: results.host.tokenConfigured,
|
||||
publicCount: results.host.publicCount,
|
||||
publicSample: results.host.publicSample,
|
||||
error: results.host.thrown ? String(results.host.thrown) : null,
|
||||
},
|
||||
combined: {
|
||||
pass: results.combined.ok,
|
||||
count: results.combined.count,
|
||||
apiUrl: results.combined.apiUrl,
|
||||
},
|
||||
}, null, 2))
|
||||
|
||||
const pass = results.agentTools.ok && results.host.ok && results.combined.ok
|
||||
if (!pass) {
|
||||
console.error('\nSMOKE FAIL')
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log('\nSMOKE PASS')
|
||||
}
|
||||
93
tests/timeout-nullish.test.mjs
Normal file
93
tests/timeout-nullish.test.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
DEFAULT_API_URL,
|
||||
DEFAULT_LANG,
|
||||
DEFAULT_TOKEN_REF,
|
||||
DEFAULT_TOOL_CALL_TIMEOUT_MS,
|
||||
mergeConfigSkippingNullish,
|
||||
resolveApiUrlForConnection,
|
||||
resolveLang,
|
||||
resolveTokenCredentialRef,
|
||||
resolveToolCallTimeoutMs,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const base = {
|
||||
apiUrl: 'http://127.0.0.1:8890',
|
||||
lang: 'zh',
|
||||
thinkingLanguage: 'auto',
|
||||
replyLanguage: 'follow-user',
|
||||
tokenCredentialRef: 'NETX_API_TOKEN',
|
||||
toolCallTimeoutMs: 120_000,
|
||||
installAgentPreset: true,
|
||||
alarmPushEnabled: false,
|
||||
alarmDeliverDsh: true,
|
||||
alarmDeliverIm: false,
|
||||
imTargets: '',
|
||||
imBotId: '',
|
||||
imTargetId: '',
|
||||
nmsProvider: 'zte-ume',
|
||||
groupOpsInPreset: true,
|
||||
groupOpsPublic: false,
|
||||
groupTopologyInPreset: false,
|
||||
groupTopologyPublic: false,
|
||||
groupBizMonitorInPreset: true,
|
||||
groupBizMonitorPublic: false,
|
||||
kbRoot: '',
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
}
|
||||
|
||||
test('mergeConfigSkippingNullish ignores null/undefined volatile fields', () => {
|
||||
const merged = mergeConfigSkippingNullish(base, {
|
||||
apiUrl: null,
|
||||
toolCallTimeoutMs: null,
|
||||
lang: 'en',
|
||||
groupOpsPublic: undefined,
|
||||
})
|
||||
assert.equal(merged.apiUrl, 'http://127.0.0.1:8890')
|
||||
assert.equal(merged.toolCallTimeoutMs, 120_000)
|
||||
assert.equal(merged.lang, 'en')
|
||||
assert.equal(merged.groupOpsPublic, false)
|
||||
})
|
||||
|
||||
test('resolveToolCallTimeoutMs rejects null / NaN / non-positive', () => {
|
||||
assert.equal(resolveToolCallTimeoutMs(null), DEFAULT_TOOL_CALL_TIMEOUT_MS)
|
||||
assert.equal(resolveToolCallTimeoutMs(undefined), DEFAULT_TOOL_CALL_TIMEOUT_MS)
|
||||
assert.equal(resolveToolCallTimeoutMs(Number.NaN), DEFAULT_TOOL_CALL_TIMEOUT_MS)
|
||||
assert.equal(resolveToolCallTimeoutMs(0), DEFAULT_TOOL_CALL_TIMEOUT_MS)
|
||||
assert.equal(resolveToolCallTimeoutMs(-1), DEFAULT_TOOL_CALL_TIMEOUT_MS)
|
||||
assert.equal(resolveToolCallTimeoutMs(90_000), 90_000)
|
||||
})
|
||||
|
||||
test('resolveApiUrlForConnection falls back on null/empty; rejects bad schemes', () => {
|
||||
assert.deepEqual(resolveApiUrlForConnection(null), {
|
||||
url: DEFAULT_API_URL,
|
||||
usedDefault: true,
|
||||
})
|
||||
assert.deepEqual(resolveApiUrlForConnection(''), {
|
||||
url: DEFAULT_API_URL,
|
||||
usedDefault: true,
|
||||
})
|
||||
assert.deepEqual(resolveApiUrlForConnection('http://10.0.0.1:8890/'), {
|
||||
url: 'http://10.0.0.1:8890',
|
||||
usedDefault: false,
|
||||
})
|
||||
assert.equal(resolveApiUrlForConnection('file:///tmp').url, '')
|
||||
assert.equal(resolveApiUrlForConnection('file:///tmp').error, 'unsupported_scheme')
|
||||
})
|
||||
|
||||
test('resolveTokenCredentialRef heals null/invalid to NETX_API_TOKEN', () => {
|
||||
assert.equal(resolveTokenCredentialRef(null), DEFAULT_TOKEN_REF)
|
||||
assert.equal(resolveTokenCredentialRef(''), DEFAULT_TOKEN_REF)
|
||||
assert.equal(resolveTokenCredentialRef('bad-name'), DEFAULT_TOKEN_REF)
|
||||
assert.equal(resolveTokenCredentialRef('NETX_API_TOKEN'), 'NETX_API_TOKEN')
|
||||
assert.equal(resolveTokenCredentialRef('CUSTOM_TOKEN'), 'CUSTOM_TOKEN')
|
||||
})
|
||||
|
||||
test('resolveLang heals null/empty to zh', () => {
|
||||
assert.equal(resolveLang(null), DEFAULT_LANG)
|
||||
assert.equal(resolveLang(undefined), DEFAULT_LANG)
|
||||
assert.equal(resolveLang(''), DEFAULT_LANG)
|
||||
assert.equal(resolveLang('en'), 'en')
|
||||
})
|
||||
248
tests/volatile-startup.test.mjs
Normal file
248
tests/volatile-startup.test.mjs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import test, { after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtempSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import * as host from '../lib/index.js'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { parsePresetIds, capabilityGroupsFromSettings, kbEnabledForPlane } from '../src/netx/capability-groups.ts'
|
||||
|
||||
// Exercise the current official Schema implementation, not merely the installed
|
||||
// lib: this local Harness has newer source alongside an older scalar-only lib.
|
||||
const peer = createRequire(import.meta.resolve('@deepseek-ai/schemastery'))
|
||||
const cosmokitSource = peer.resolve('@deepseek-ai/cosmokit/src/index.ts')
|
||||
const schemaSource = peer.resolve('@deepseek-ai/schemastery/src/index.ts')
|
||||
const built = await Bun.build({ entrypoints: [schemaSource], target: 'bun', format: 'esm', plugins: [{
|
||||
name: 'official-volatile-source',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@deepseek-ai\/cosmokit$/ }, () => ({ path: cosmokitSource }))
|
||||
},
|
||||
}] })
|
||||
assert.equal(built.success, true, String(built.logs))
|
||||
const schemaUrl = `data:text/javascript;base64,${Buffer.from(await built.outputs[0].text()).toString('base64')}`
|
||||
// Load the shipped JS with the current official schema peer. Keep it beside
|
||||
// lib/index.js so skill and package discovery use the same paths as production.
|
||||
const hostFile = fileURLToPath(new URL('../lib/index.js', import.meta.url))
|
||||
const currentHostFile = fileURLToPath(new URL(`../lib/.volatile-startup-${randomUUID()}.mjs`, import.meta.url))
|
||||
const hostCode = readFileSync(hostFile, 'utf8')
|
||||
const withCurrentSchema = hostCode.replace(/from ["']@deepseek-ai\/schemastery["']/g, `from '${schemaUrl}'`)
|
||||
assert.notEqual(withCurrentSchema, hostCode, 'must replace the external schema peer')
|
||||
writeFileSync(currentHostFile, withCurrentSchema)
|
||||
after(() => unlinkSync(currentHostFile))
|
||||
const currentHost = await import(pathToFileURL(currentHostFile).href)
|
||||
const currentConfig = currentHost.Config
|
||||
const { createVolatile, updateVolatile, isVolatile } = await import(pathToFileURL(peer.resolve('@deepseek-ai/cosmokit/src/volatile.ts')).href)
|
||||
const store = () => globalThis[Symbol.for('dsh-netxops.connection-store')].connection
|
||||
const pause = () => new Promise(resolve => setTimeout(resolve, 40))
|
||||
|
||||
function context(t, settings = { describe: () => [] }) {
|
||||
const previous = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-volatile-'))
|
||||
const tools = new Map(), skills = new Map(), listeners = new Map(), prompts = new Map(), effects = [], injected = [], errors = []
|
||||
const register = map => entry => {
|
||||
assert.equal(map.has(entry.name), false, `duplicate ${entry.name}`)
|
||||
map.set(entry.name, entry)
|
||||
return () => map.delete(entry.name)
|
||||
}
|
||||
const ctx = {
|
||||
logger: { info() {}, warn() {}, error(...args) { errors.push(args) } },
|
||||
tools: { register: register(tools) }, skills: { register: register(skills) }, settings,
|
||||
credentials: { async resolve() { return { value: '' } } },
|
||||
systemPrompt: {
|
||||
section(spec) { prompts.set(spec.name, spec); return () => prompts.delete(spec.name) },
|
||||
context(spec) { prompts.set(spec.name, spec); return () => prompts.delete(spec.name) },
|
||||
},
|
||||
get(name) { return this[name] },
|
||||
inject(deps, callback) {
|
||||
injected.push([...deps])
|
||||
if (deps.every(name => this[name])) callback(this)
|
||||
},
|
||||
effect(fn) { const dispose = fn(); if (typeof dispose === 'function') effects.push(dispose); return dispose },
|
||||
on(event, fn) {
|
||||
let list = listeners.get(event)
|
||||
if (!list) listeners.set(event, list = new Set())
|
||||
list.add(fn)
|
||||
return () => list.delete(fn)
|
||||
},
|
||||
emit(event, ...args) { for (const fn of listeners.get(event) ?? []) fn(...args) },
|
||||
}
|
||||
t.after(async () => {
|
||||
for (const dispose of effects.reverse()) dispose()
|
||||
listeners.clear()
|
||||
await pause()
|
||||
if (previous === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previous
|
||||
})
|
||||
return { ctx, tools, skills, prompts, injected, errors }
|
||||
}
|
||||
|
||||
test('current Schema wraps defaults and partial profile starts the shipped Host bundle', async t => {
|
||||
// Existing profiles lack the new target-list fields. They arrive as references
|
||||
// containing empty defaults, which used to throw value.split on activation.
|
||||
const config = currentConfig({
|
||||
installAgentPreset: false, alarmPushEnabled: false, apiUrl: 'http://10.0.0.1:8890/',
|
||||
lang: 'en', toolCallTimeoutMs: 45000, tokenCredentialRef: 'CUSTOM_TOKEN',
|
||||
groupOpsInPreset: false, groupOpsPublic: true, groupKbInPreset: false,
|
||||
thinkingLanguage: 'en', replyLanguage: 'en',
|
||||
})
|
||||
assert.ok(isVolatile(config.groupOpsPresets))
|
||||
assert.equal(config.groupOpsPresets.get(), '')
|
||||
assert.ok(isVolatile(config.installAgentPreset))
|
||||
const mounted = context(t)
|
||||
assert.doesNotThrow(() => currentHost.apply(mounted.ctx, config))
|
||||
assert.equal(store().apiUrl, 'http://10.0.0.1:8890')
|
||||
assert.equal(store().lang, 'en')
|
||||
assert.equal(store().toolCallTimeoutMs, 45000)
|
||||
assert.equal(store().groups.ops.inPreset, false)
|
||||
assert.equal(store().groups.ops.public, true)
|
||||
assert.equal(store().groupKbInPreset, false)
|
||||
assert.equal(mounted.injected.some(deps => deps.length === 1 && deps[0] === 'agentPresets'), false,
|
||||
'false reference must not run the installer')
|
||||
assert.equal(mounted.tools.size, 15)
|
||||
await pause()
|
||||
assert.ok(mounted.skills.has('netx-ops'))
|
||||
assert.equal(globalThis[Symbol.for('dsh-netxops.alarm-push-status')].status.enabled, false)
|
||||
assert.equal(globalThis[Symbol.for('dsh-netxops.alarm-push-status')].status.phase, 'disabled')
|
||||
assert.equal(mounted.errors.length, 0)
|
||||
})
|
||||
|
||||
test('Loader updates read retained references and immediately revoke and republish tools and skills', async t => {
|
||||
const config = currentConfig({ installAgentPreset: false, alarmPushEnabled: false,
|
||||
groupOpsPublic: true, groupOpsPresets: 'standard,review', groupKbPresets: 'review' })
|
||||
const mounted = context(t)
|
||||
currentHost.apply(mounted.ctx, config)
|
||||
await pause()
|
||||
assert.equal(mounted.tools.size, 15)
|
||||
assert.ok(mounted.skills.has('netx-ops'))
|
||||
assert.deepEqual(store().groups.ops.presets, ['standard', 'review'])
|
||||
assert.equal(store().groupKbPresets, 'review')
|
||||
const original = config.toolsEnabled
|
||||
updateVolatile(original, createVolatile(false))
|
||||
updateVolatile(config.groupOpsPresets, createVolatile(''))
|
||||
mounted.ctx.emit('loader/volatile-update', [['toolsEnabled'], ['groupOpsPresets']])
|
||||
assert.equal(config.toolsEnabled, original, 'Loader retains identity')
|
||||
assert.equal(store().toolsEnabled, false)
|
||||
assert.equal(mounted.tools.size, 0, 'revocation is synchronous')
|
||||
await pause()
|
||||
assert.equal(mounted.skills.size, 0)
|
||||
assert.deepEqual(store().groups.ops.presets ?? [], [])
|
||||
updateVolatile(original, createVolatile(true))
|
||||
updateVolatile(config.groupOpsPublic, createVolatile(false))
|
||||
updateVolatile(config.groupOpsPresets, createVolatile('standard'))
|
||||
updateVolatile(config.apiUrl, createVolatile('http://10.0.0.2:8890'))
|
||||
mounted.ctx.emit('loader/volatile-update', [['toolsEnabled'], ['groupOpsPublic'], ['groupOpsPresets'], ['apiUrl']])
|
||||
assert.equal(store().toolsEnabled, true)
|
||||
assert.equal(store().apiUrl, 'http://10.0.0.2:8890')
|
||||
assert.equal(store().groups.ops.public, false)
|
||||
assert.deepEqual(store().groups.ops.presets, ['standard'])
|
||||
await pause()
|
||||
assert.equal(mounted.tools.size, 0)
|
||||
assert.equal(mounted.skills.size, 0)
|
||||
assert.equal(mounted.errors.length, 0)
|
||||
})
|
||||
|
||||
test('legacy scalar settings remain valid and a reference-returning settings scope is read on every change', async t => {
|
||||
let current = host.Config({ installAgentPreset: false, alarmPushEnabled: false, groupOpsPublic: true })
|
||||
let changed
|
||||
const mounted = context(t, {
|
||||
installSection(ctx, ns, schema, base, hooks) {
|
||||
assert.equal(typeof base.installAgentPreset, 'boolean')
|
||||
hooks.setSource(() => current)
|
||||
changed = hooks.onChange
|
||||
changed()
|
||||
},
|
||||
})
|
||||
host.apply(mounted.ctx, current)
|
||||
assert.equal(mounted.tools.size, 15)
|
||||
current = currentConfig({ installAgentPreset: false, alarmPushEnabled: false, toolsEnabled: false, groupOpsPublic: true })
|
||||
changed()
|
||||
assert.equal(store().toolsEnabled, false)
|
||||
assert.equal(mounted.tools.size, 0)
|
||||
await pause()
|
||||
assert.equal(mounted.skills.size, 0)
|
||||
assert.equal(mounted.errors.length, 0)
|
||||
})
|
||||
|
||||
test('policy helpers read official references and malformed preset lists cannot crash or select a target', () => {
|
||||
const config = currentConfig({ groupOpsInPreset: false, groupOpsPublic: true,
|
||||
groupOpsPresets: 'standard,review standard', groupKbPublic: false, groupKbInPreset: false })
|
||||
const groups = capabilityGroupsFromSettings(config)
|
||||
assert.equal(groups.ops.inPreset, false)
|
||||
assert.equal(groups.ops.public, true)
|
||||
assert.deepEqual(groups.ops.presets, ['standard', 'review'])
|
||||
assert.equal(kbEnabledForPlane(config, 'preset'), false)
|
||||
assert.equal(kbEnabledForPlane(config, 'host'), false)
|
||||
assert.equal(host.resolveTokenCredentialRef(createVolatile('CUSTOM_TOKEN')), 'CUSTOM_TOKEN')
|
||||
const merged = host.mergeConfigSkippingNullish(config, {
|
||||
groupOpsPublic: createVolatile(false), apiUrl: createVolatile(null), groupOpsPresets: createVolatile(''),
|
||||
})
|
||||
assert.equal(merged.groupOpsPublic, false)
|
||||
assert.equal(merged.groupOpsPresets, '')
|
||||
assert.equal(merged.apiUrl, 'http://127.0.0.1:8890')
|
||||
for (const value of [null, undefined, 0, true, {}, [], { get: () => 'standard' }]) {
|
||||
assert.deepEqual(parsePresetIds(value), [])
|
||||
}
|
||||
assert.deepEqual(parsePresetIds(createVolatile('standard,review standard')), ['standard', 'review'])
|
||||
})
|
||||
|
||||
test('real Cordis activates the current-schema bundle and delivers the owning fiber update', async t => {
|
||||
const previous = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-volatile-cordis-'))
|
||||
const root = new Context(), owners = [], tools = new Map(), skills = new Map()
|
||||
class Tools extends Service {
|
||||
constructor(ctx) { super(ctx, 'tools') }
|
||||
register(entry) {
|
||||
assert.equal(tools.has(entry.name), false)
|
||||
tools.set(entry.name, entry)
|
||||
return () => tools.delete(entry.name)
|
||||
}
|
||||
}
|
||||
class Skills extends Service {
|
||||
constructor(ctx) { super(ctx, 'skills') }
|
||||
register(entry) {
|
||||
assert.equal(skills.has(entry.name), false)
|
||||
skills.set(entry.name, entry)
|
||||
return () => skills.delete(entry.name)
|
||||
}
|
||||
}
|
||||
t.after(async () => {
|
||||
for (const owner of owners.reverse()) await owner.dispose()
|
||||
await pause()
|
||||
if (previous === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previous
|
||||
})
|
||||
owners.push(await root.plugin(Tools))
|
||||
owners.push(await root.plugin(Skills))
|
||||
const owner = await root.plugin({ ...currentHost, name: 'volatile-startup-integration' }, {
|
||||
installAgentPreset: false, alarmPushEnabled: false, groupOpsPublic: true,
|
||||
})
|
||||
owners.push(owner)
|
||||
// FiberState is a const enum and has no JS export in this installed Cordis.
|
||||
assert.equal(owner.state, 2 /* ACTIVE */)
|
||||
assert.ok(isVolatile(owner.config.groupOpsPresets))
|
||||
assert.equal(tools.size, 15)
|
||||
await pause()
|
||||
assert.ok(skills.has('netx-ops'))
|
||||
updateVolatile(owner.config.toolsEnabled, createVolatile(false))
|
||||
const self = Object.create(owner.ctx)
|
||||
self[Context.filter] = ctx => ctx.fiber === owner
|
||||
owner.ctx.emit(self, 'loader/volatile-update', [['toolsEnabled']])
|
||||
assert.equal(owner.state, 2 /* ACTIVE */, 'volatile edit keeps the active fiber')
|
||||
assert.equal(store().toolsEnabled, false)
|
||||
assert.equal(tools.size, 0)
|
||||
await pause()
|
||||
assert.equal(skills.size, 0)
|
||||
const unloadErrors = [], previousError = console.error
|
||||
try {
|
||||
console.error = (...args) => { unloadErrors.push(args) }
|
||||
await owner.dispose()
|
||||
owners.pop()
|
||||
await pause()
|
||||
} finally { console.error = previousError }
|
||||
assert.equal(tools.size, 0)
|
||||
assert.equal(skills.size, 0)
|
||||
assert.deepEqual(unloadErrors, [], 'unload must not remount on an inactive context')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue