mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip lets people manage agent work through the board. > - The board reads task, agent, settings, plugin, and run data from the server. > - A deploy can interrupt those reads after content is already visible. > - Local error branches can replace that content with an error or show raw codes. > - This pull request moves the remaining reads to the shared recovery behavior. > - The query-error guard prevents new local error patterns from returning. ## Linked Issues or Issue Description **What happened?** Loaded lists, settings, task panels, and manual reads could clear their content or show errors during a server restart. Some reads also disabled the shared retry policy. **Expected behavior** Keep loaded content during a temporary failure. Show the shared connection banner. Retry reads after recovery. Replace content after a real access denial or missing resource, with readable copy. **Steps to reproduce** Open a task, settings page, or run log. Return 503 with `tenant_app_unavailable` from the API. Restore the API and check that content refreshes. Repeat with 403 and 404 to check the failure state. Related merged work: Refs #15754, Refs #15772, Refs #17960. This change covers remaining reads and enforcement. Browser persistence and the outage browser suite follow separately. ## What Changed - Repair an existing PostgreSQL deadline fixture at UTC boundaries. A daily cap defers a heartbeat until both its original interval and the next UTC day allow it. Keep the counted run in its seeded day and cover noon, just before midnight and the first second after midnight with explicit clocks and real PostgreSQL. Production scheduling is unchanged. - Integrate the saved list, settings, shell, task, and manual-read recovery changes against current master. Preserve the current task, Inbox, and run-log structure. - Add `useResilientFetch` for component-local reads. Preserve loaded data, cancel stale requests, and retry through the shared connectivity policy. Keep browser and pairing capabilities in memory. - Retain run log chunks across stream changes and catch up after recovery without duplicate chunks or stalled requests. - Keep genuine authorization and missing-resource failures visible. Translate known single-word codes such as `forbidden`. Show known skills and disconnected-app failures before sibling loading states. Keep Retry bound to its failed request. - Make the query-error guard enforce by default and run it in the CI policy job. Document the narrow exceptions and the read recovery contract. - Invalidate pending OAuth discovery when the operator leaves setup or its context changes. Keep reserved-popup cleanup bound to the current attempt. ## Verification - Current read head `09d1db29dea` is merge-ready: all 59 check runs are completed (54 successful, three skipped, two neutral), the legacy Snyk status is successful, and both required `ci / verify` and `ci / e2e` gates pass. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads; the branch has no merge conflicts. The current CI run is `41748339602`. - Current read head `09d1db29dea` includes master `683fa4725e9`. The latest change touches only the existing server deadline test fixture. Application source remains the previously verified `86376fa751e` revision: all 60 affected skills/disconnected-app tests and both application UI typechecks passed, along with token gates and the enforcing query-error guard. Those UI suites were not repeated for this test-only repair. - The fixture repair passes all seven deadline integration cases and the complete server typecheck, including the server test layer. The old assertion deterministically fails at 23:59:06.888 UTC; its one-second-old run also fails the first-second-of-day probe. The corrected fixture passes noon and both midnight probes. The permanent three-case coverage uses seeded 2099 clocks. - Twelve new regressions fail against the previous implementation. Both skills variants and disconnected-app pages show either request's 403 or 429 while the sibling pauses or remains fetching. Permission failures stay visible. Retry stays usable for its failed request and recovers independently. An initial test-fixture query-key typo was corrected before the passing run. The finding is replied to and resolved. Greptile reviewed prior application revision `86376fa751e` at 5/5 with no new actionable findings or unresolved threads. The current test-only head has now received its own Apex 5/5 review with no unresolved threads, and its CI is successful as recorded above. - The preceding OAuth fix passed all 237 setup tests on both application branches. Its ten regressions prove that Back, Cancel, unmount, or context changes retire initial discovery without creating a connection, caching old identities, navigating an abandoned popup, or closing a newer attempt. - Earlier master integration `e992f770339` passed all 309 tests across eight affected Slack, recovery, retry, submission and task files, plus UI typecheck, token gates, enforcing query-error scan and seven guard tests. The integration preserves Slack workspace reconnect and shared recovery, readable execution errors, source-inspection company scoping, and current submission behavior. Newly inherited retained-file recovery controls keep cached data through transient failures and hide it after definitive denial. - The preceding cache head `97c0bc3966f` passed 1,038 focused cache/write/editor/account/task/API tests. All 22 built-UI outage/write/loading cases passed in 4.7 minutes on exact browser head `b7deaaee5da`, with strict checking of all three specs. The latest skills/app rendering fix is covered by its affected component suites; those broader cases were not repeated for this separate read path. - Historical broader UI, CLI and build results and unresolved aggregate server/DB/adapter failures remain in the PR discussion. Interrupted CI runs were audited and recorded separately; they are not counted as passes. Controlled local outages do not prove hosted authentication or a real Cloud restart/deploy. Real hosted acceptance remains required after merge and deployment. Commands for the latest fix: ```sh pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx pnpm --filter @paperclipai/ui typecheck pnpm check:token-gates node scripts/check-query-error-rendering.mjs --enforce --summary ``` ## Risks This migration touches many read surfaces. The main risks are initial loading, request cancellation, retained authorization state, and run-log paging. The tests cover those states. There are no server, schema, dependency, or API contract changes. The follow-up browser suite and hosted deploy acceptance are required before release acceptance. Recovered commits retain their original authors. If squash-merging, preserve `Co-Authored-By: Bender (Fable) <bender@agents.paperclip.ing>` and the Paperclip trailer. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool use, and code execution. The context window size is not exposed. Saved source commits keep their original authorship. ## 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 described the issue in-PR following the bug report template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] The affected local tests pass; broader limitations are recorded above - [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 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: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
479 lines
35 KiB
TypeScript
479 lines
35 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 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 }) => <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() } }));
|
||
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(<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';
|
||
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<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('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<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();
|
||
});
|
||
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|