// @vitest-environment jsdom import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SkillSource } from '@paperclipai/shared'; import { SkillSources } from './SkillSources'; import { ApiError } from '@/api/client'; import { skillSourcesApi } from '@/api/skillSources'; import { sourceFixture } from '../../storybook/fixtures/githubSkillSources'; const context = vi.hoisted(() => ({ navigate: vi.fn(), breadcrumbs: vi.fn(), sourceId: 'new', streamlinedEnabled: true })); vi.mock('@/context/CompanyContext', () => ({ useCompany: () => ({ selectedCompanyId: 'company-1', selectedCompany: { name: 'Acme' } }) })); vi.mock('@/context/BreadcrumbContext', () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: context.breadcrumbs }) })); vi.mock('@/lib/router', () => ({ Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => {children}, useNavigate: () => context.navigate, useParams: () => ({ sourceId: context.sourceId }), })); vi.mock('@/api/skillSources', () => ({ skillSourcesApi: { list: vi.fn(), projectRepositories: vi.fn(), adoptProject: vi.fn(), settings: vi.fn(), branches: vi.fn(), repositories: vi.fn(), discoverStream: vi.fn(), create: vi.fn(), select: vi.fn(), refresh: vi.fn(), disconnect: vi.fn() } })); vi.mock('@/hooks/useStreamlinedUiEnabled', () => ({ useStreamlinedUiEnabled: () => ({ enabled: context.streamlinedEnabled, loaded: true }) })); const repos = { connections: [{ id: 'personal', name: 'Personal' }, { id: 'shared', name: 'Engineering' }], connectionCount: 2, failedConnectionCount: 0, repositories: [ { id: '1', fullName: 'acme/team-skills', url: 'https://github.com/acme/team-skills', private: true, connections: ['Personal', 'Engineering'], connectionIds: ['personal', 'shared'] }, { id: '2', fullName: 'acme/design', url: 'https://github.com/acme/design', private: true, connections: ['Engineering'], connectionIds: ['shared'] }, ], }; const discovery = { repositoryId: '1', repositoryUrl: 'https://github.com/acme/team-skills', fullName: 'acme/team-skills', trackingRef: 'feature/new-skills', commitSha: 'a'.repeat(40), candidates: [], warnings: [] }; const connectedSource: SkillSource = { id: 'source-1', companyId: 'company-1', repositoryId: '1', repositoryUrl: 'https://github.com/acme/team-skills', fullName: 'acme/team-skills', trackingRef: 'HEAD', connectionId: 'shared', excludedFolders: [], enabled: true, revision: 1, lastAttemptAt: null, lastSuccessAt: null, lastScanCommit: null, lastError: null, entries: [], }; let root: Root; let host: HTMLDivElement; let client: QueryClient; async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); } async function mount() { host = document.createElement('div'); document.body.append(host); root = createRoot(host); client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); await act(async () => root.render()); await flush(); await flush(); } function button(text: string) { return [...document.querySelectorAll('button')].find(el => el.textContent === text)!; } async function input(selector: string, value: string) { await act(async () => { const element = document.querySelector(selector)!; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); }); } beforeEach(() => { (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(() => {}); vi.mocked(skillSourcesApi.list).mockResolvedValue([]); vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([]); vi.mocked(skillSourcesApi.repositories).mockResolvedValue(structuredClone(repos)); vi.mocked(skillSourcesApi.branches).mockResolvedValue({ defaultBranch: 'master', branches: [{ name: 'master', isDefault: true }], truncated: false }); vi.mocked(skillSourcesApi.discoverStream).mockResolvedValue(discovery); context.sourceId = 'new'; context.streamlinedEnabled = true; sessionStorage.clear(); }); afterEach(async () => { if (root) await act(async () => root.unmount()); host?.remove(); client?.clear(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.unstubAllGlobals(); }); describe('GitHub skill source import', () => { it('keeps the Skills views available while managing Sources', async () => { context.sourceId = ''; await mount(); const navigation = document.querySelector('nav[aria-label="Skills views"]'); expect([...navigation!.querySelectorAll('a')].map(link => [link.textContent, link.getAttribute('href')])).toEqual([ ['Installed', '/skills'], ['Discover', '/skills?tab=discover'], ['My Skills', '/skills/studio'], ]); expect(navigation!.querySelector('[aria-current="page"]')).toBeNull(); }); it('groups the same repository and ref across projects and shows import progress until it is installed', async () => { context.sourceId = ''; const repository = { projectId: 'one', projectName: 'Paperclip', projectVisibility: 'open' as const, workspaceId: 'workspace-one', repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'HEAD', bindingId: null, sourceId: null, status: 'available' as const, lastError: null }; vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([repository, { ...repository, projectId: 'two', projectName: 'Sentry', workspaceId: 'workspace-two' }]); let finish!: (value: { bindingId: string }) => void; vi.mocked(skillSourcesApi.adoptProject).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })) .mockResolvedValue({ bindingId: 'binding-two' }); await mount(); expect(document.querySelectorAll('[aria-label="Project repositories"] section')).toHaveLength(1); expect(document.body.textContent).toContain('Paperclip, Sentry'); await act(async () => button('Import skills').click()); await flush(); expect(document.querySelector('[aria-busy="true"]')).not.toBeNull(); expect(document.querySelector('[role="status"]')?.textContent).toContain('Starting import'); vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([ { ...repository, status: 'pending' }, { ...repository, projectId: 'two', projectName: 'Sentry', workspaceId: 'workspace-two', status: 'pending' }, ]); await act(async () => finish({ bindingId: 'binding-one' })); await flush(); expect(skillSourcesApi.adoptProject).toHaveBeenCalledWith('company-1', 'workspace-two'); expect(document.querySelector('[role="status"]')?.textContent).toContain('queued'); expect(button('Importing…').disabled).toBe(true); }); it('offers branch repair rather than connection repair for an unavailable tracked ref', async () => { context.sourceId = ''; const source = { ...sourceFixture(), trackingRef: 'main', lastError: 'The tracked GitHub branch or revision is unavailable. Use the repository’s default branch or update its configured ref.' }; vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...source, repairAction: 'branch' }]); vi.mocked(skillSourcesApi.settings).mockResolvedValue({ ...source, trackingRef: 'master', lastError: null }); await mount(); expect(button('Choose connection')).toBeUndefined(); await act(async () => button('Choose a new branch').click()); await flush(); await act(async () => document.querySelector('button[aria-label="Branch"]')!.click()); await flush(); await act(async () => (document.querySelector('[cmdk-item][data-value="master"]') as HTMLElement).click()); await flush(); await act(async () => button('Change branch').click()); await flush(); expect(skillSourcesApi.branches).toHaveBeenCalledWith('company-1', source.id, {}, expect.any(AbortSignal)); expect(skillSourcesApi.settings).toHaveBeenCalledWith('company-1', source.id, { revision: source.revision, trackingRef: 'master' }); }); it.each([ 'This repository and branch are already in Sources. Use that source; installed skills were kept.', 'The repository at this URL has changed identity. Add it as a new source.', ])('preserves actionable branch conflict advice: %s', async message => { context.sourceId = ''; const source = { ...sourceFixture(), trackingRef: 'main', lastError: 'Unavailable branch.', repairAction: 'branch' as const }; vi.mocked(skillSourcesApi.list).mockResolvedValue([source]); vi.mocked(skillSourcesApi.settings).mockRejectedValue(new ApiError(message, 409, {})); await mount(); await act(async () => button('Choose a new branch').click()); await flush(); await act(async () => document.querySelector('button[aria-label="Branch"]')!.click()); await flush(); await act(async () => (document.querySelector('[cmdk-item][data-value="master"]') as HTMLElement).click()); await flush(); await act(async () => button('Change branch').click()); await flush(); expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toBe(message); expect(document.querySelector('button[aria-label="Branch"]')?.textContent).toContain('master'); }); it('keeps an existing source compact while project import is pending', async () => { context.sourceId = ''; vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...sourceFixture(), trackingRef: 'HEAD', projects: [{ id: 'paperclip', name: 'Paperclip', visibility: 'open', active: true }] }]); vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([{ projectId: 'sentry', projectName: 'Sentry', projectVisibility: 'open', workspaceId: 'second-workspace', repositoryUrl: sourceFixture().repositoryUrl, trackingRef: 'HEAD', bindingId: 'binding', sourceId: null, status: 'pending', lastError: null }]); await mount(); expect(document.querySelectorAll(`a[href="${sourceFixture().repositoryUrl}"]`)).toHaveLength(1); expect(document.querySelector('[aria-label="Project repositories"]')).toBeNull(); expect(document.querySelector('[aria-label="Skills from Sentry"]')).toBeNull(); expect(document.body.textContent).not.toContain('Other projects with this repo'); expect(document.body.textContent).not.toContain('Sentry'); expect(document.body.textContent).not.toContain('Paperclip'); expect(document.querySelector('section a[href="/skills"]')).toBeNull(); expect(document.querySelector('[role="status"]')?.textContent).toContain('Importing skills'); }); it('allows remaining project imports while another matching workspace is pending', async () => { context.sourceId = ''; const repository = { projectId: 'one', projectName: 'Paperclip', projectVisibility: 'open' as const, workspaceId: 'already-pending', repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'HEAD', bindingId: 'binding', sourceId: null, status: 'pending' as const, lastError: null }; vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([repository, { ...repository, projectId: 'two', projectName: 'Sentry', workspaceId: 'available-workspace', status: 'available', bindingId: null }]); vi.mocked(skillSourcesApi.adoptProject).mockResolvedValue({ bindingId: 'second-binding' }); await mount(); expect(document.querySelector('[role="status"]')?.textContent).toContain('queued'); expect(button('Import remaining skills')?.disabled).toBe(false); await act(async () => button('Import remaining skills').click()); await flush(); expect(skillSourcesApi.adoptProject).toHaveBeenCalledTimes(1); expect(skillSourcesApi.adoptProject).toHaveBeenCalledWith('company-1', 'available-workspace'); }); it('retains the selected connection and repairs a missing branch directly from connection setup', async () => { context.sourceId = ''; const source = { ...sourceFixture(), connectionId: 'personal', lastError: 'Choose a GitHub connection to enable automatic updates.', repairAction: 'connection' as const }; vi.mocked(skillSourcesApi.list).mockResolvedValue([source]); vi.mocked(skillSourcesApi.settings).mockRejectedValueOnce(new ApiError('The tracked GitHub branch or revision is unavailable.', 422, { code: 'skill_source_ref_unavailable' })) .mockResolvedValue({ ...source, trackingRef: 'master', lastError: null }); await mount(); await act(async () => button('Choose connection').click()); await flush(); await act(async () => button('Save connection').click()); await flush(); expect(skillSourcesApi.settings).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision, connectionId: 'personal' }); await act(async () => button('Choose a new branch').click()); await flush(); await act(async () => document.querySelector('button[aria-label="Branch"]')!.click()); await flush(); await act(async () => (document.querySelector('[cmdk-item][data-value="master"]') as HTMLElement).click()); await flush(); await act(async () => button('Change branch').click()); await flush(); expect(skillSourcesApi.branches).toHaveBeenLastCalledWith('company-1', source.id, { connectionId: 'personal' }, expect.any(AbortSignal)); expect(skillSourcesApi.settings).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision, connectionId: 'personal', trackingRef: 'master' }); expect(document.querySelector('[role="dialog"]')).toBeNull(); }); it('shows measured Git download progress before skill discovery and retains the automatically chosen connection', async () => { let finish!: (value: typeof discovery & { connectionId: string }) => void; vi.mocked(skillSourcesApi.discoverStream).mockImplementation((_company, _input, update) => { update({ type: 'progress', phase: 'downloading', download: { stage: 'receiving', percent: 64, receivedBytes: 18 * 1024 * 1024 }, totalSkills: null, checkedSkills: 0, currentPath: null, checkedFiles: 0, totalFiles: null }); return new Promise(resolve => { finish = resolve; }); }); await mount(); await act(async () => button('... or add public repo by URL').click()); await input('input[placeholder="https://github.com/owner/repository"]', 'https://github.com/public/skills'); await act(async () => button('Find skills').click()); await flush(); expect(document.body.textContent).toContain('Downloading repository'); expect(document.body.textContent).toContain('64% received · 18 MB'); expect(document.querySelector('progress')?.value).toBe(64); expect(skillSourcesApi.create).not.toHaveBeenCalled(); await act(async () => finish({ ...discovery, connectionId: 'shared' })); await flush(); expect(JSON.parse(sessionStorage.getItem('paperclip.skill-source-draft:company-1:new')!).connectionId).toBe('shared'); }); it('shows streamed candidates without allowing a partial import, then discards them on failure', async () => { let fail!: (error: Error) => void; vi.mocked(skillSourcesApi.discoverStream).mockImplementation((_company, _input, update) => { update({ type: 'progress', phase: 'checking', totalSkills: 20, checkedSkills: 1, currentPath: 'one/references/guide.md', checkedFiles: 2, totalFiles: 10 }); update({ type: 'candidate', candidate: { path: 'one/SKILL.md', name: 'Code review', description: null, fileCount: 10, error: null } }); return new Promise((_resolve, reject) => { fail = reject; }); }); await mount(); await act(async () => (document.querySelector('[cmdk-item]') as HTMLElement).click()); await act(async () => button('Find skills').click()); await flush(); expect(document.body.textContent).toContain('20 skills found'); expect(document.body.textContent).toContain('1 of 20 checked'); expect(document.body.textContent).toContain('Code review'); expect(document.querySelector('progress')?.value).toBe(1); expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(0); expect(skillSourcesApi.create).not.toHaveBeenCalled(); await act(async () => fail(new Error('Network interrupted'))); await flush(); expect(document.querySelector('[role="alert"]')?.textContent).toContain('Network interrupted'); expect(document.body.textContent).not.toContain('Code review'); expect(button('Find skills').disabled).toBe(false); }); it('bounds the live feed while keeping the full server progress count', async () => { vi.mocked(skillSourcesApi.discoverStream).mockImplementation((_company, _input, update, signal) => { for (let index = 0; index < 40; index++) update({ type: 'candidate', candidate: { path: `${index}/SKILL.md`, name: `Skill ${index}`, description: null, fileCount: 1, error: null } }); update({ type: 'progress', phase: 'checking', totalSkills: 100, checkedSkills: 40, currentPath: '40/SKILL.md', checkedFiles: 0, totalFiles: 1 }); return new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })); }); await mount(); await act(async () => (document.querySelector('[cmdk-item]') as HTMLElement).click()); await act(async () => button('Find skills').click()); await flush(); expect(document.querySelectorAll('[aria-label="Skills checked so far"] li')).toHaveLength(5); expect(document.body.textContent).toContain('40 of 100 checked'); expect(document.body.textContent).toContain('40 checked'); expect(document.body.textContent).not.toContain('Skill 0'); expect(document.body.textContent).toContain('Skill 39'); }); it('cancels a scan, preserves the repository, and ignores late progress and completion', async () => { let finish!: (result: typeof discovery) => void; let update!: Parameters[2]; let signal: AbortSignal | undefined; vi.mocked(skillSourcesApi.discoverStream).mockImplementation((_company, _input, callback, active) => { update = callback; signal = active; return new Promise(resolve => { finish = resolve; }); }); await mount(); await act(async () => (document.querySelector('[cmdk-item]') as HTMLElement).click()); await act(async () => button('Find skills').click()); await flush(); await act(async () => button('Cancel scan').click()); expect(signal?.aborted).toBe(true); await act(async () => { update({ type: 'candidate', candidate: { path: 'late/SKILL.md', name: 'Late result', description: null, error: null, fileCount: 1 } }); finish(discovery); }); await flush(); expect(document.body.textContent).not.toContain('Late result'); expect(button('Find skills').disabled).toBe(false); expect(JSON.parse(sessionStorage.getItem('paperclip.skill-source-draft:company-1:new')!).repositoryUrl).toBe('https://github.com/acme/team-skills'); }); it('searches the combined repository inventory and uses the selected repository’s authorized connection', async () => { await mount(); expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(2); expect(document.body.textContent).toContain('Personal · Engineering'); expect([...document.querySelectorAll('a')].find(el => el.textContent === 'Add repos')?.getAttribute('href')).toBe('/apps/connect?source=github'); await act(async () => document.querySelector('[aria-label="Refresh repositories"]')!.click()); await flush(); expect(skillSourcesApi.repositories).toHaveBeenCalledTimes(2); expect(document.querySelector('select')).toBeNull(); expect(document.body.textContent).not.toContain('Choose a branch'); expect(document.querySelector('input[placeholder="https://github.com/owner/repository"]')).toBeNull(); await input('[aria-label="Search repositories"]', 'design'); expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1); await act(async () => (document.querySelector('[cmdk-item]') as HTMLElement).click()); await act(async () => button('Find skills').click()); expect(skillSourcesApi.discoverStream).toHaveBeenCalledWith('company-1', { repositoryUrl: 'https://github.com/acme/design', packageMode: 'repository', trackingRef: undefined, connectionId: 'shared' }, expect.any(Function), expect.any(AbortSignal)); }); it('recognizes pasted branch URLs and does not reuse their branch or credentials for another repository', async () => { await mount(); await act(async () => button('... or add public repo by URL').click()); await input('input[placeholder="https://github.com/owner/repository"]', 'https://github.com/ACME/team-skills/tree/feature/new-skills'); await act(async () => button('Find skills').click()); expect(skillSourcesApi.discoverStream).toHaveBeenLastCalledWith('company-1', { repositoryUrl: 'https://github.com/ACME/team-skills/tree/feature/new-skills', packageMode: 'repository', trackingRef: undefined, connectionId: 'personal' }, expect.any(Function), expect.any(AbortSignal)); await flush(); expect(document.body.textContent).toContain('feature/new-skills'); await act(async () => button('Back').click()); await input('input[placeholder="https://github.com/owner/repository"]', 'https://github.com/public/skills'); await act(async () => button('Find skills').click()); expect(skillSourcesApi.discoverStream).toHaveBeenLastCalledWith('company-1', { repositoryUrl: 'https://github.com/public/skills', packageMode: 'repository', trackingRef: undefined, connectionId: null }, expect.any(Function), expect.any(AbortSignal)); }); it('links empty repositories to standard GitHub setup in Apps while preserving the import draft', async () => { vi.mocked(skillSourcesApi.repositories).mockResolvedValue({ repositories: [], connections: [], connectionCount: 0, failedConnectionCount: 0 }); await mount(); const link = [...document.querySelectorAll('a')].find(el => el.textContent === 'Connect GitHub to see your repos'); expect(link?.getAttribute('href')).toBe('/apps/connect?source=github'); expect(document.querySelector('input[placeholder="https://github.com/owner/repository"]')).toBeNull(); expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain('Imported skills will be available'); await act(async () => button('... or add public repo by URL').click()); expect(document.body.textContent).not.toContain('Uses the default branch'); await input('input[placeholder="https://github.com/owner/repository"]', 'https://github.com/acme/team-skills/tree/release'); await act(async () => root.unmount()); expect(JSON.parse(sessionStorage.getItem('paperclip.skill-source-draft:company-1:new')!).repositoryUrl).toBe('https://github.com/acme/team-skills/tree/release'); // Returning from Apps remounts the importer and rechecks the new inventory. host.remove(); await mount(); expect(document.querySelector('input[placeholder="https://github.com/owner/repository"]')?.value).toBe('https://github.com/acme/team-skills/tree/release'); expect(skillSourcesApi.repositories).toHaveBeenCalledTimes(2); }); it('keeps usable repositories visible during partial failure and provides recovery in Apps', async () => { vi.mocked(skillSourcesApi.repositories).mockResolvedValue({ ...repos, failedConnectionCount: 1 }); await mount(); expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(2); expect(document.querySelector('[role="alert"]')?.textContent).toContain('Some GitHub connections could not load repositories.'); expect([...document.querySelectorAll('a')].find(el => el.textContent === 'Manage connections')?.getAttribute('href')).toBe('/apps'); expect(button('Try again')).toBeTruthy(); }); it('defaults to the repository, selects every discovered skill without a packaging menu', async () => { const main = { path: 'skills/main/SKILL.md', name: 'Main', description: 'Work', fileCount: 1, error: null, warnings: [] }; const helper = { ...main, path: 'automations/helper/SKILL.md', name: 'Helper' }; vi.mocked(skillSourcesApi.discoverStream).mockImplementation(async (_company, input) => ({ ...discovery, packageMode: input.packageMode, candidates: [main, helper], ...(input.packageMode === 'repository' ? { repositoryPackage: { files: [{ path: 'agents/worker.md', kind: 'markdown', encoding: 'utf8' as const, executable: false, sizeBytes: 10 }], requirements: 'Bun for orchestration', error: null, warnings: [], skillPaths: [main.path], } } : {}) })); await mount(); await act(async () => (document.querySelector('[cmdk-item]') as HTMLElement).click()); await act(async () => button('Find skills').click()); await flush(); expect(skillSourcesApi.discoverStream).toHaveBeenLastCalledWith('company-1', expect.objectContaining({ packageMode: 'repository' }), expect.any(Function), expect.any(AbortSignal)); expect(skillSourcesApi.discoverStream).toHaveBeenCalledTimes(1); expect(document.body.textContent).toContain('2 of 2 selected'); expect([...document.querySelectorAll('summary')].some(summary => summary.textContent?.includes('Choose skills'))).toBe(false); expect(document.querySelector('[aria-label="Import skills/main/SKILL.md"]')?.checked).toBe(true); expect(document.querySelector('[aria-label="Import automations/helper/SKILL.md"]')?.checked).toBe(true); expect(document.body.textContent).not.toContain('Select whole skill packages.'); expect(document.body.textContent).not.toContain('The full repository travels'); expect(document.body.textContent).not.toContain('Importing does not install'); expect(button('View 1 file')).toBeTruthy(); expect(button('Import repository').disabled).toBe(false); const draft = JSON.parse(sessionStorage.getItem('paperclip.skill-source-draft:company-1:new')!); expect(draft.packageMode).toBe('repository'); expect(draft.selectedPaths).toEqual([main.path, helper.path]); expect(document.querySelector('[aria-label="More import options"]')).toBeNull(); expect(document.body.textContent).not.toContain('Import individual skills'); }); it('warns about omitted files while allowing import without a confirmation', async () => { sessionStorage.setItem('paperclip.skill-source-draft:company-1:new', JSON.stringify({ packageMode: 'repository', selectedPaths: ['skills/main/SKILL.md'], discovery: { ...discovery, packageMode: 'repository', repositoryPackage: { files: [], requirements: null, error: null, warnings: ['shared/tools — Symlink couldn’t be included.'] }, candidates: [{ path: 'skills/main/SKILL.md', name: 'Main', description: null, fileCount: 1, error: null, warnings: [] }] } })); vi.mocked(skillSourcesApi.create).mockImplementation(() => new Promise(() => {})); await mount(); expect(document.body.textContent).toContain('1 import warning'); expect(document.querySelector('[aria-label="Import warnings"]')?.hasAttribute('open')).toBe(false); expect(button('Import repository').disabled).toBe(false); await act(async () => button('Import repository').click()); expect(skillSourcesApi.create).toHaveBeenCalledWith('company-1', expect.objectContaining({ packageMode: 'repository', selectedPaths: ['skills/main/SKILL.md'] })); }); it('uses repository packaging when returning to an old individual-import draft', async () => { sessionStorage.setItem('paperclip.skill-source-draft:company-1:new', JSON.stringify({ packageMode: 'skills', repositoryUrl: discovery.repositoryUrl })); const candidate = { path: 'skills/main/SKILL.md', name: 'Main', description: 'A useful skill', error: null, fileCount: 1, warnings: [], inspection: { files: [], requirements: null, warnings: [], references: [{ fromPath: 'SKILL.md', target: '../../shared.md', resolvedPath: 'shared.md', kind: 'outside_package' as const }] } }; vi.mocked(skillSourcesApi.discoverStream).mockResolvedValueOnce({ ...discovery, packageMode: 'repository', candidates: [{ ...candidate, inspection: { ...candidate.inspection, references: [] } }], repositoryPackage: { files: [], requirements: null, warnings: [], error: null } }); await mount(); await act(async () => button('Find skills').click()); await flush(); expect(skillSourcesApi.discoverStream).toHaveBeenCalledTimes(1); expect(skillSourcesApi.discoverStream).toHaveBeenLastCalledWith('company-1', expect.objectContaining({ packageMode: 'repository' }), expect.any(Function), expect.any(AbortSignal)); expect(button('Import repository').disabled).toBe(false); expect(document.body.textContent).not.toContain('Include repository files'); }); async function openRemoval(disconnected = false) { context.sourceId = ''; const source = { ...sourceFixture(), companyId: 'company-1', enabled: !disconnected }; vi.mocked(skillSourcesApi.list).mockResolvedValue([source]); await mount(); const trigger = document.querySelector(`button[aria-label="More actions for ${source.fullName}"]`)!; await act(async () => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))); await flush(); const menu = [...document.querySelectorAll('[role="menuitem"]')].find(el => el.textContent === (disconnected ? 'Remove source' : 'Disconnect source'))!; await act(async () => menu.click()); await flush(); return source; } it('requires a keep-or-delete choice and cancellation leaves the source intact', async () => { await openRemoval(); expect(button('Disconnect source').disabled).toBe(true); expect(document.querySelector('[role="radio"][tabindex="0"]')?.textContent).toContain('Keep editable copies'); expect(skillSourcesApi.disconnect).not.toHaveBeenCalled(); await act(async () => button('Cancel').click()); expect(document.querySelector('[role="dialog"]')).toBeNull(); expect(document.body.textContent).toContain('acme/team-skills'); }); it('removes an already-disconnected source and sends the explicit delete choice and revision', async () => { const source = await openRemoval(true); vi.mocked(skillSourcesApi.disconnect).mockImplementation(async () => { vi.mocked(skillSourcesApi.list).mockResolvedValue([]); return { id: source.id, copiedCount: 0, deletedCount: 2 }; }); const choice = [...document.querySelectorAll('[role="radio"]')].find(el => el.textContent?.startsWith('Delete skills'))!; await act(async () => choice.click()); await act(async () => button('Remove source').click()); await flush(); await flush(); expect(skillSourcesApi.disconnect).toHaveBeenCalledWith('company-1', source.id, { revision: source.revision, skills: 'delete' }); expect(document.querySelector('[role="dialog"]')).toBeNull(); expect(document.body.textContent).not.toContain('acme/team-skills'); }); it('reloads a stale confirmation and requires a new choice for the updated skill count', async () => { const source = await openRemoval(); vi.mocked(skillSourcesApi.disconnect).mockRejectedValueOnce(new ApiError('Source changed. Reload before disconnecting.', 409, {})); await act(async () => document.querySelector('[role="radio"]')!.click()); await act(async () => button('Disconnect source').click()); await flush(); expect(button('Disconnect source').disabled).toBe(true); vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...source, revision: source.revision + 1, entries: source.entries.slice(0, 1) }]); await act(async () => button('Reload source').click()); await flush(); await flush(); expect(document.querySelector('[role="dialog"]')?.textContent).toContain('1 imported skill'); expect(document.querySelector('[role="dialog"] [role="alert"]')).toBeNull(); expect(button('Disconnect source').disabled).toBe(true); await act(async () => document.querySelector('[role="radio"]')!.click()); vi.mocked(skillSourcesApi.disconnect).mockImplementation(async () => { vi.mocked(skillSourcesApi.list).mockResolvedValue([]); return { id: source.id, copiedCount: 1, deletedCount: 0 }; }); await act(async () => button('Disconnect source').click()); await flush(); await flush(); expect(skillSourcesApi.disconnect).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision + 1, skills: 'copy' }); expect(document.querySelector('[role="dialog"]')).toBeNull(); }); it('sends the keep-copy choice and keeps failures in the confirmation for retry', async () => { const source = await openRemoval(); vi.mocked(skillSourcesApi.disconnect).mockRejectedValue(new Error('Source changed. Reload before disconnecting.')); const choice = [...document.querySelectorAll('[role="radio"]')].find(el => el.textContent?.startsWith('Keep editable copies'))!; await act(async () => choice.click()); await act(async () => button('Disconnect source').click()); await flush(); expect(skillSourcesApi.disconnect).toHaveBeenCalledWith('company-1', source.id, { revision: source.revision, skills: 'copy' }); expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('Source changed'); expect(button('Disconnect source').disabled).toBe(false); expect(document.querySelector('section[aria-label="acme/team-skills"]')).not.toBeNull(); const other = [...document.querySelectorAll('[role="radio"]')].find(el => el.textContent?.startsWith('Delete skills'))!; await act(async () => other.click()); await flush(); expect(document.querySelector('[role="dialog"] [role="alert"]')).toBeNull(); }); }); describe('Skill sources list read resilience', () => { it('keeps loaded sources visible when a refetch fails during an outage', async () => { context.sourceId = ''; vi.mocked(skillSourcesApi.list).mockResolvedValue([connectedSource]); await mount(); expect(document.body.textContent).toContain('acme/team-skills'); vi.mocked(skillSourcesApi.list).mockRejectedValue(new ApiError('Paperclip is restarting.', 503, { error: 'tenant_app_unavailable' })); await act(async () => { await client.invalidateQueries(); }); await flush(); await flush(); expect(skillSourcesApi.list).toHaveBeenCalledTimes(2); expect(document.body.textContent).toContain('acme/team-skills'); expect(document.querySelector('[data-query-view="error"]')).toBeNull(); expect(document.body.textContent).not.toContain('Paperclip is restarting.'); }); it('shows readable copy and a Retry button when the sources list fails to load', async () => { context.sourceId = ''; vi.mocked(skillSourcesApi.list).mockRejectedValueOnce(new ApiError('Boom', 500, { error: 'Boom' })).mockResolvedValue([connectedSource]); await mount(); const errorState = document.querySelector('[data-query-view="error"]'); expect(errorState?.textContent).toContain("Couldn't load skill sources"); expect(errorState?.textContent).toContain('Boom'); expect(document.body.textContent).not.toContain('No repositories added yet'); expect(button('Retry')).toBeTruthy(); await act(async () => button('Retry').click()); await flush(); await flush(); expect(skillSourcesApi.list).toHaveBeenCalledTimes(2); expect(document.body.textContent).toContain('acme/team-skills'); expect(document.querySelector('[data-query-view="error"]')).toBeNull(); }); });