| 1 | import { test } from 'node:test' |
| 2 | import assert from 'node:assert/strict' |
| 3 | import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' |
| 4 | import { tmpdir } from 'node:os' |
| 5 | import { join } from 'node:path' |
| 6 | import { activate, startHost } from './harness.mjs' |
| 7 | |
| 8 | function plugin(t, body) { |
| 9 | const directory = mkdtempSync(join(tmpdir(), 'cw-skill-root-')) |
| 10 | const entry = join(directory, 'index.mjs') |
| 11 | writeFileSync(entry, `export const inject = ['skills']\nexport function apply(ctx) {\n${body}\n}\n`) |
| 12 | t.after(() => rmSync(directory, { recursive: true, force: true })) |
| 13 | return entry |
| 14 | } |
| 15 | |
| 16 | test('real host admits detached skill root proposals and disposes only their owner', async (t) => { |
| 17 | const host = await startHost() |
| 18 | t.after(() => host.stop()) |
| 19 | const entry = plugin(t, `const root = { path: 'profiles/review-skills' }; ctx.skills.registerRoot(root); root.path = '../changed'`) |
| 20 | const a = await activate(host, 'skill-a', entry), b = await activate(host, 'skill-b', entry) |
| 21 | assert.equal(a.result.status, 'ok'); assert.equal(b.result.status, 'ok') |
| 22 | const registrations = host.registry.filter(item => item.op === 'register' && item.kind === 'skill_root') |
| 23 | assert.equal(registrations.length, 2) |
| 24 | assert.deepEqual(registrations[0].spec, { name: 'profiles/review-skills', description: '' }) |
| 25 | assert.deepEqual(await host.call('ext/deactivate', { owner: a.ref }), { disposed: true, leaked: [] }) |
| 26 | assert.ok(host.registry.some(item => item.op === 'unregister' && item.handle === registrations[0].handle)) |
| 27 | assert.ok(!host.registry.some(item => item.op === 'unregister' && item.handle === registrations[1].handle)) |
| 28 | }) |
| 29 | |
| 30 | test('invalid and duplicate skill roots fail activation and roll back proposals', async (t) => { |
| 31 | const host = await startHost() |
| 32 | t.after(() => host.stop()) |
| 33 | const cases = [ |
| 34 | [`ctx.skills.registerRoot({ path: '../outside' })`, /bundle-relative/], |
| 35 | [`ctx.skills.registerRoot({ path: 'skills', watch: true })`, /only path/], |
| 36 | [`ctx.skills.registerRoot({ path: 'skills' }); ctx.skills.registerRoot({ path: 'skills' })`, /already registered/], |
| 37 | ] |
| 38 | for (const [index, [body, reason]] of cases.entries()) { |
| 39 | const before = host.registry.length |
| 40 | const { result } = await activate(host, `bad-root-${index}`, plugin(t, body)) |
| 41 | assert.equal(result.status, 'failed'); assert.match(result.diagnostic, reason) |
| 42 | for (const item of host.registry.slice(before).filter(item => item.op === 'register')) { |
| 43 | if (!host.registry.some(later => later.op === 'unregister' && later.handle === item.handle)) { |
| 44 | await host.waitFor(message => message.method === 'registry/unregister' && message.params.handle === item.handle) |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | }) |
| 49 | |
| 50 | test('real host preserves Rust root admission refusal in activation result', async (t) => { |
| 51 | const host = await startHost({ admit: () => ({ refused: 'root is outside the reviewed inventory' }) }) |
| 52 | t.after(() => host.stop()) |
| 53 | const { result } = await activate(host, 'refused-root', plugin(t, `ctx.skills.registerRoot({ path: 'skills' })`)) |
| 54 | assert.equal(result.status, 'failed') |
| 55 | assert.match(result.diagnostic, /outside the reviewed inventory/) |
| 56 | }) |
| 57 |