From 3bef6458a0231870f55ca9e97ae5c31cb0b128d8 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:15:07 +0000 Subject: [PATCH] fix: recover source-control provider status reads --- .../SourceControl.recovery.client.test.tsx | 190 ++++++++++++++++++ .../src/components/settings/SourceControl.tsx | 44 +++- 2 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/settings/SourceControl.recovery.client.test.tsx diff --git a/apps/web/src/components/settings/SourceControl.recovery.client.test.tsx b/apps/web/src/components/settings/SourceControl.recovery.client.test.tsx new file mode 100644 index 0000000000..00d716fd9f --- /dev/null +++ b/apps/web/src/components/settings/SourceControl.recovery.client.test.tsx @@ -0,0 +1,190 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query'; +import type { SetupSourceControlStatus } from '@roomote/types'; + +import { SourceControl } from './SourceControl'; + +const { loadStatus, mutate } = vi.hoisted(() => ({ + loadStatus: vi.fn(), + mutate: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + usePathname: () => '/settings/source-control', + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ isAdmin: true }), +})); + +vi.mock('@/hooks/github', () => ({ + useGitHubInstallations: () => ({ data: [], isPending: false }), + useEnableGitHubApp: () => ({ mutate, isPending: false }), + useSyncGitHubInstallations: () => ({ mutate, isPending: false }), + useCreateGitHubAppManifest: () => ({ mutate, isPending: false }), +})); + +vi.mock('@/hooks/source-control', () => ({ + useSourceControlConfigStatus: () => + useQuery({ queryKey: ['provider-status'], queryFn: loadStatus }), + useRepositories: () => ({ data: [], isPending: false }), + useSyncRepositories: () => ({ mutate, isPending: false }), + usePrAction: () => ({ data: { prAction: 'draft' }, isLoading: false }), + useSetPrAction: () => ({ mutate, isPending: false }), + useMarkRoomotePrReadyAfterCleanReview: () => ({ + data: { enabled: false }, + isLoading: false, + }), + useSetMarkRoomotePrReadyAfterCleanReview: () => ({ + mutate, + isPending: false, + }), + useGitHubRoomoteMention: () => ({ + data: { enabled: true }, + isLoading: false, + }), + useSetGitHubRoomoteMention: () => ({ mutate, isPending: false }), +})); + +vi.mock('./SourceControlConfigForm', () => ({ + SourceControlConfigForm: ({ provider }: { provider: string }) => ( + + ), +})); + +const status: SetupSourceControlStatus = { + selectedProvider: null, + preselectedProvider: 'github', + runtimeConfiguredProvider: null, + runtimeConfiguredProviders: [], + lockReason: null, + connectedProvider: null, + providers: [ + { + provider: 'gitlab', + label: 'GitLab', + connectionMode: 'token', + fields: [], + runtimeConfigSatisfied: false, + savedConfigSatisfied: true, + configSatisfied: true, + configStepSatisfied: true, + configSatisfiedByRuntimeEnv: false, + connected: false, + repositoryCount: 0, + }, + ], + setupSatisfied: false, + setupSatisfiedByRuntimeEnv: false, +}; + +function renderSettings() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + render( + + + , + ); + return client; +} + +describe('source-control provider status recovery', () => { + beforeEach(() => { + loadStatus.mockReset().mockResolvedValue(status); + mutate.mockReset(); + }); + + it('does not offer setup from an unavailable status and retries the actual query', async () => { + loadStatus.mockRejectedValueOnce(new Error('Read unavailable')); + renderSettings(); + + expect( + await screen.findByText('Failed to load source control provider status.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Set it up for/ })).toBeNull(); + expect(screen.queryByLabelText('gitlab draft')).toBeNull(); + expect( + screen.getByRole('combobox', { name: 'Pull request delivery' }), + ).toBeEnabled(); + + fireEvent.click( + screen.getByRole('button', { name: 'Retry provider status' }), + ); + + expect(await screen.findByLabelText('gitlab draft')).toBeInTheDocument(); + expect(loadStatus).toHaveBeenCalledTimes(2); + expect(mutate).not.toHaveBeenCalled(); + }); + + it('keeps failed retries recoverable and prevents duplicate requests while pending', async () => { + loadStatus.mockRejectedValue(new Error('Read unavailable')); + renderSettings(); + const retry = await screen.findByRole('button', { + name: 'Retry provider status', + }); + + let reject!: (error: Error) => void; + loadStatus.mockImplementationOnce( + () => + new Promise((_, fail) => { + reject = fail; + }), + ); + fireEvent.click(retry); + await waitFor(() => expect(loadStatus).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('button', { name: /Set it up for/ })).toBeNull(); + expect( + screen.queryByRole('button', { name: 'Retry provider status' }), + ).not.toBeEnabled(); + + await act(async () => reject(new Error('Still unavailable'))); + expect( + await screen.findByRole('button', { name: 'Retry provider status' }), + ).toBeEnabled(); + loadStatus.mockResolvedValue(status); + fireEvent.click( + screen.getByRole('button', { name: 'Retry provider status' }), + ); + expect(await screen.findByLabelText('gitlab draft')).toBeInTheDocument(); + expect(loadStatus).toHaveBeenCalledTimes(3); + }); + + it('preserves the mounted provider form and draft through a failed background read and Retry', async () => { + const client = renderSettings(); + const draft = await screen.findByLabelText('gitlab draft'); + fireEvent.change(draft, { target: { value: 'unsaved local edit' } }); + + loadStatus.mockRejectedValueOnce(new Error('Refresh unavailable')); + await act(async () => { + await client.refetchQueries({ queryKey: ['provider-status'] }); + }); + const retry = await screen.findByRole('button', { + name: 'Retry provider status', + }); + expect(screen.getByLabelText('gitlab draft')).toBe(draft); + expect(draft).toHaveValue('unsaved local edit'); + fireEvent.click(retry); + await waitFor(() => expect(loadStatus).toHaveBeenCalledTimes(3)); + await waitFor(() => + expect( + screen.queryByRole('button', { name: 'Retry provider status' }), + ).toBeNull(), + ); + expect(screen.getByLabelText('gitlab draft')).toBe(draft); + expect(draft).toHaveValue('unsaved local edit'); + expect(mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/SourceControl.tsx b/apps/web/src/components/settings/SourceControl.tsx index c647b4e218..2767c729cd 100644 --- a/apps/web/src/components/settings/SourceControl.tsx +++ b/apps/web/src/components/settings/SourceControl.tsx @@ -52,6 +52,7 @@ import { SelectTrigger, SelectValue, Sparkles, + Skeleton, Spinner, Switch, } from '@/components/system'; @@ -191,6 +192,7 @@ export function SourceControl() { const gitHubInstallations = useGitHubInstallations(); const sourceControlConfigStatus = useSourceControlConfigStatus(); + const [retryingProviderStatus, setRetryingProviderStatus] = useState(false); const gitHubRepositories = useRepositories({ sourceControlProvider: 'github', }); @@ -465,12 +467,42 @@ export function SourceControl() { {isAdmin ? : null} - {providerBlocks.map((providerBlock) => ( - - ))} + {sourceControlConfigStatus.isError || retryingProviderStatus ? ( +
+

+ Failed to load source control provider status. +

+ +
+ ) : null} + {sourceControlConfigStatus.data + ? providerBlocks.map((providerBlock) => ( + + )) + : sourceControlConfigStatus.isPending && !retryingProviderStatus + ? providerBlocks.map(({ provider }) => ( + + )) + : null} ); }