mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
## Thinking Path > - Paperclip manages agent skills from GitHub repositories. > - Skills → Sources shows saved repositories and their sync status. > - A saved source gained a second repository row when other projects used the same repository and branch. > - That row repeated the GitHub icon and branch, and it exposed project names that did not help with source management. > - The page also repeated the Installed navigation link and showed refresh errors as separate lines of text. > - Connection management also appeared under each source, where it competed with source-specific actions. > - This pull request shows each saved source once, groups error details, and moves connection management into the menu beside Import from GitHub. > - The result is a clearer source list that keeps import and recovery actions available. ## Linked Issues or Issue Description **What happened?** When a saved source shared its repository and branch with other projects, Skills → Sources displayed a second GitHub header and the names of those projects. The page also displayed an extra Installed skills link and repeated Manage GitHub connection under source rows. A failed refresh placed its timestamp and error on separate lines. **Expected behavior** Show one row for each saved source. Keep project names out of that row. Keep the additional import action available in More. Put connection management in the page actions menu beside Import from GitHub. Present refresh errors as one readable notice with the time and review action. **Steps to reproduce** 1. Open Skills → Sources in a company with one saved GitHub source and another project that uses the same repository and branch. 2. Observe the repeated repository row and project names. 3. Open the Storybook journey `Existing source, more projects` to inspect the same state. Open `Retained skills and retry` to inspect a failed refresh. **Paperclip version or commit** The issue is present on base commit `739f9e40a4`. **Deployment mode** Built from source. The issue was also observed in a staging workspace. ## What Changed - Remove project names, the duplicate repository row, and the redundant Installed skills link from saved source rows. - Put imports for additional projects in the source More menu. Show brief import progress in the source metadata. - Group source errors, attempt time, and the review link in a compact notice. Add a Storybook journey and browser coverage for the shared-repository state. - Add a page actions menu beside Import from GitHub. Put Manage GitHub connection there and remove the repeated inline links. Cover the menu and keyboard focus in the Storybook browser suite. ## Verification - `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs --check` passed locally. The Skills sources unit suite passed 22 tests. - All 18 repository-skills Storybook browser tests passed, including the shared-source import, connection menu, mobile, and failed-refresh journeys. - A local full `pnpm test:run` was stopped after a server runner-API test timed out. An isolated diagnostic identified macOS embedded PostgreSQL shared-memory exhaustion. The PR passed all 55 CI checks, including server test shards and end-to-end tests. - Open the Storybook journeys `Existing source, more projects`, `Automatically imported skills`, and `Retained skills and retry` to inspect the compact source row, page actions menu, and grouped error notice. ## Risks Low risk. The visible import action for projects that share a saved source moves to More. Connection management moves to a page menu and opens the Apps catalog. People must open those menus to find the actions. The import API and access checks do not change. > I checked `ROADMAP.md`. This source-list correction does not duplicate planned core work. ## Model Used OpenAI Codex, GPT-6 family. The task runtime did not expose an exact serving model ID or context-window size. The model used tool calls, code edits, and browser verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
423 lines
32 KiB
TypeScript
423 lines
32 KiB
TypeScript
// @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 { SkillSources } from './SkillSources';
|
||
import { skillSourcesApi } from '@/api/skillSources';
|
||
import { ApiError } from '@/api/client';
|
||
import { sourceFixture } from '../../storybook/fixtures/githubSkillSources';
|
||
|
||
const context = vi.hoisted(() => ({ navigate: vi.fn(), breadcrumbs: vi.fn(), sourceId: 'new' }));
|
||
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 }) => <a href={to} {...props}>{children}</a>,
|
||
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() } }));
|
||
|
||
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: [] };
|
||
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(<QueryClientProvider client={client}><SkillSources /></QueryClientProvider>));
|
||
await flush(); await flush();
|
||
}
|
||
function button(text: string) { return [...document.querySelectorAll<HTMLButtonElement>('button')].find(el => el.textContent === text)!; }
|
||
async function input(selector: string, value: string) {
|
||
await act(async () => {
|
||
const element = document.querySelector<HTMLInputElement>(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';
|
||
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('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<HTMLButtonElement>('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<HTMLButtonElement>('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('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<HTMLButtonElement>('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<typeof skillSourcesApi.discoverStream>[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<HTMLButtonElement>('[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<HTMLInputElement>('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<HTMLInputElement>('[aria-label="Import skills/main/SKILL.md"]')?.checked).toBe(true);
|
||
expect(document.querySelector<HTMLInputElement>('[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<HTMLButtonElement>(`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<HTMLElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[role="radio"]')].find(el => el.textContent?.startsWith('Delete skills'))!;
|
||
await act(async () => other.click()); await flush();
|
||
expect(document.querySelector('[role="dialog"] [role="alert"]')).toBeNull();
|
||
});
|
||
|
||
});
|