import { beforeEach, describe, expect, mock, test } from 'bun:test'; const activeProjectPath = '/workspace/project-with-agents-skills'; let runtimeFetchCalls: Array<{ url: string; headers?: HeadersInit }> = []; let runtimeFetchImpl: (url: string, init?: RequestInit) => Promise = async () => ( new Response(JSON.stringify({ skills: [] }), { headers: { 'Content-Type': 'application/json' }, }) ); let getDirectoryImpl: () => string | undefined = () => undefined; const runtimeFetchMock = async (url: string, init?: RequestInit) => { runtimeFetchCalls.push({ url: String(url), headers: init?.headers }); return runtimeFetchImpl(url, init); }; mock.module('@/lib/opencode/client', () => ({ opencodeClient: { getDirectory: () => getDirectoryImpl(), checkHealth: async () => true, }, })); mock.module('@/stores/useProjectsStore', () => ({ useProjectsStore: { getState: () => ({ getActiveProject: () => ({ path: activeProjectPath }), }), }, })); mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: runtimeFetchMock, })); mock.module('@/lib/background-network', () => ({ runBackgroundNetworkTask: async (task: () => Promise) => task(), })); mock.module('@/lib/configUpdate', () => ({ startConfigUpdate: mock(() => undefined), finishConfigUpdate: mock(() => undefined), updateConfigUpdateMessage: mock(() => undefined), })); mock.module('@/lib/configSync', () => ({ emitConfigChange: mock(() => undefined), scopeMatches: mock(() => false), subscribeToConfigChanges: mock(() => () => undefined), })); mock.module('./utils/safeStorage', () => ({ createDeferredSafeJSONStorage: () => ({ getItem: async () => null, setItem: async () => undefined, removeItem: async () => undefined, }), })); const { invalidateSkillsLoadCache, useSkillsStore } = await import('./useSkillsStore'); describe('useSkillsStore directory resolution', () => { beforeEach(() => { runtimeFetchCalls = []; getDirectoryImpl = () => undefined; runtimeFetchImpl = async () => new Response(JSON.stringify({ skills: [{ name: 'repo-local-skill', path: `${activeProjectPath}/.agents/skills/repo-local-skill/SKILL.md`, scope: 'project', source: 'agents', sources: { md: { description: 'Repository local' } }, }], }), { headers: { 'Content-Type': 'application/json' }, }); invalidateSkillsLoadCache(activeProjectPath); useSkillsStore.setState({ selectedSkillName: null, skills: [], isLoading: false, skillDraft: null, }); }); test('loadSkills scopes discovery to the active project even when client directory is unset', async () => { const loaded = await useSkillsStore.getState().loadSkills(); expect(loaded).toBe(true); expect(runtimeFetchCalls.length).toBe(1); expect(runtimeFetchCalls[0]?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`); expect(useSkillsStore.getState().skills).toEqual([{ name: 'repo-local-skill', path: `${activeProjectPath}/.agents/skills/repo-local-skill/SKILL.md`, scope: 'project', source: 'agents', description: 'Repository local', group: undefined, renamable: false, }]); }); test('loadSkills maps authoritative renamable from the list response', async () => { runtimeFetchImpl = async () => new Response(JSON.stringify({ skills: [ { name: 'managed-skill', path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`, scope: 'project', source: 'opencode', renamable: true, sources: { md: { description: 'Managed' } }, }, { name: 'cache-skill', path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md', scope: 'user', source: 'opencode', renamable: false, sources: { md: { description: 'Cache' } }, }, ], }), { headers: { 'Content-Type': 'application/json' }, }); expect(await useSkillsStore.getState().loadSkills()).toBe(true); expect(useSkillsStore.getState().skills).toEqual([ { name: 'managed-skill', path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`, scope: 'project', source: 'opencode', description: 'Managed', group: undefined, renamable: true, }, { name: 'cache-skill', path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md', scope: 'user', source: 'opencode', description: 'Cache', group: 'hash', renamable: false, }, ]); }); test('renameSkill uses getRequestDirectory query and x-opencode-directory header', async () => { runtimeFetchImpl = async (_url, init) => { if (init?.method === 'PATCH') { return new Response(JSON.stringify({ success: true, requiresReload: false, }), { headers: { 'Content-Type': 'application/json' }, }); } return new Response(JSON.stringify({ skills: [{ name: 'new-skill', path: `${activeProjectPath}/.opencode/skills/new-skill/SKILL.md`, scope: 'project', source: 'opencode', renamable: true, sources: { md: { description: 'Renamed' } }, }], }), { headers: { 'Content-Type': 'application/json' }, }); }; const renamed = await useSkillsStore.getState().renameSkill('old-skill', 'new-skill'); expect(renamed).toBe(true); const renameCall = runtimeFetchCalls.find((call) => String(call.url).includes('/api/config/skills/old-skill')); expect(renameCall).toBeTruthy(); expect(renameCall?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`); const headers = new Headers(renameCall?.headers); expect(headers.get('content-type')).toBe('application/json'); expect(headers.get('x-opencode-directory')).toBe(activeProjectPath); }); test('invalidateSkillsLoadCache() with no argument clears the active-project cache key used by loadSkills', async () => { expect(await useSkillsStore.getState().loadSkills()).toBe(true); expect(runtimeFetchCalls.length).toBe(1); // Wrong key: client-directory-first null maps to __default__, not the active project. invalidateSkillsLoadCache(null); expect(await useSkillsStore.getState().loadSkills()).toBe(true); expect(runtimeFetchCalls.length).toBe(1); // Default resolution must match loadSkills (active project first). invalidateSkillsLoadCache(); expect(await useSkillsStore.getState().loadSkills()).toBe(true); expect(runtimeFetchCalls.length).toBe(2); expect(runtimeFetchCalls[1]?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`); }); });