返回 CodeWhale
skills.test.mjs
根目录 / crates / tui / extension-host / test / skills.test.mjs
1 import test from 'node:test'
2 import assert from 'node:assert/strict'
3 import { SkillRoots, normalizeSkillRoot } from '../dist/skills.mjs'
4
5 function fixture({ deferred = false, refused } = {}) {
6 const calls = [], pending = []
7 let next = 0
8 const rpc = { request(method, params) {
9 calls.push({ method, params })
10 if (method === 'registry/unregister') return Promise.resolve({})
11 const result = refused ? { refused } : { handle: ++next }
12 if (!deferred) return Promise.resolve(result)
13 return new Promise(resolve => pending.push(() => resolve(result)))
14 } }
15 const roots = new SkillRoots(rpc, owner => owner.roots, () => {})
16 const owner = (id) => ({ ref: { plugin_id: id, generation: 1, owner_token: id }, state: 'active', pendingRegistrations: new Set(), refusals: [], roots: new Map() })
17 return { roots, calls, pending, owner }
18 }
19 const settled = async owner => Promise.all([...owner.pendingRegistrations])
20
21 test('skill roots snapshot only bounded clean bundle-relative paths', () => {
22 const input = { path: 'profiles/review-skills' }
23 const normalized = normalizeSkillRoot(input)
24 input.path = '../outside'
25 assert.deepEqual(normalized, { path: 'profiles/review-skills' })
26 assert.ok(Object.isFrozen(normalized))
27 for (const path of ['', '/absolute', '../outside', './same', 'a/../b', 'a//b', 'a/', 'C:/disk', 'a\\b', 'a\u001bb', '界'.repeat(171)]) {
28 assert.throws(() => normalizeSkillRoot({ path }), /bounded bundle-relative/)
29 }
30 for (const value of [null, [], 'path', { path: 'good', priority: 1 }]) assert.throws(() => normalizeSkillRoot(value))
31 })
32
33 test('owned skill roots dispose exact handles without crossing equal-path owners', async () => {
34 const { roots, calls, owner } = fixture()
35 const a = owner('a'), b = owner('b')
36 const undo = roots.register(a, { path: 'profiles/skills' })
37 roots.register(b, { path: 'profiles/skills' })
38 await Promise.all([settled(a), settled(b)])
39 assert.deepEqual(calls.filter(call => call.method === 'registry/register').map(call => call.params.spec), [
40 { name: 'profiles/skills', description: '' }, { name: 'profiles/skills', description: '' },
41 ])
42 undo(); undo()
43 await Promise.resolve()
44 assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1])
45 assert.equal(b.roots.size, 1)
46 roots.forget(b)
47 await Promise.resolve()
48 assert.deepEqual(calls.filter(call => call.method === 'registry/unregister').map(call => call.params.handle), [1, 2])
49 })
50
51 test('pending roots count toward limits and disposal before admission cannot remove replacement', async () => {
52 const { roots, calls, pending, owner } = fixture({ deferred: true })
53 const a = owner('a')
54 const old = roots.register(a, { path: 'skills' })
55 assert.throws(() => roots.register(a, { path: 'skills' }), /already registered/)
56 old()
57 roots.register(a, { path: 'skills' })
58 for (let i = 0; i < 7; i++) roots.register(a, { path: `s${i}` })
59 assert.throws(() => roots.register(a, { path: 'ninth' }), /limit/)
60 pending.forEach(resolve => resolve())
61 await settled(a)
62 assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1))
63 assert.ok(!calls.some(call => call.method === 'registry/unregister' && call.params.handle === 2))
64 roots.forget(a)
65 roots.register(a, { path: 'after-disposal' })
66 pending.at(-1)()
67 await settled(a)
68 assert.equal(a.roots.size, 1)
69 })
70
71 test('host root cap includes multiple owners and recovers after owner teardown', async () => {
72 const { roots, owner } = fixture()
73 const owners = Array.from({ length: 8 }, (_, i) => owner(String(i)))
74 for (const current of owners) for (let i = 0; i < 8; i++) roots.register(current, { path: `s${i}` })
75 assert.throws(() => roots.register(owner('overflow'), { path: 's' }), /limit/)
76 await Promise.all(owners.map(settled))
77 roots.forget(owners[0])
78 const replacement = owner('replacement')
79 roots.register(replacement, { path: 's' })
80 await settled(replacement)
81 assert.equal(replacement.roots.size, 1)
82 })
83
84 test('revoked owner cannot register and a late admitted root is withdrawn', async () => {
85 const { roots, calls, pending, owner } = fixture({ deferred: true })
86 const a = owner('a')
87 roots.register(a, { path: 'skills' })
88 a.state = 'disposed'
89 roots.forget(a)
90 assert.throws(() => roots.register(a, { path: 'late' }), /not live/)
91 pending.forEach(resolve => resolve())
92 await settled(a)
93 assert.equal(a.roots.size, 0)
94 assert.ok(calls.some(call => call.method === 'registry/unregister' && call.params.handle === 1))
95 })
96
97 test('core refusal is preserved and local reservations retire with owner', async () => {
98 const { roots, owner } = fixture({ refused: 'reviewed bundle was changed' })
99 const a = owner('a')
100 roots.register(a, { path: 'skills' })
101 await settled(a)
102 assert.match(a.refusals[0], /skill_root.*reviewed bundle was changed/)
103 assert.equal(a.roots.size, 0)
104 roots.forget(a)
105 roots.register(a, { path: 'skills' })
106 await settled(a)
107 })
108
108 lines Plain Text