Skip to content

Commit 878479c

Browse files
authored
test(onboarding): Add tests for useScmRepoSearch and ScmRepoSelector (#112127)
## Summary - Add test coverage for `useScmRepoSearch` hook (7 tests): debounced search, API param passing, response transformation into `reposByIdentifier` Map and `dropdownItems`, selected repo disabling, error states, and search clearing - Add test coverage for `ScmRepoSelector` component (8 tests): rendering, empty state messaging, error display, search results, selected repo context, repo selection triggering downstream lookup, clearing the selected repo, and verifying the options-prepend logic does not duplicate a selected repo already in search results These are the only two SCM onboarding components without test coverage. ## Test plan - [x] `CI=true pnpm test static/app/views/onboarding/components/useScmRepoSearch.spec.tsx` -- 7 passing - [x] `CI=true pnpm test static/app/views/onboarding/components/scmRepoSelector.spec.tsx` -- 8 passing Refs VDY-54
1 parent 33e5127 commit 878479c

File tree

2 files changed

+401
-0
lines changed

2 files changed

+401
-0
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
3+
import {RepositoryFixture} from 'sentry-fixture/repository';
4+
5+
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
6+
7+
import {
8+
OnboardingContextProvider,
9+
type OnboardingSessionState,
10+
} from 'sentry/components/onboarding/onboardingContext';
11+
12+
import {ScmRepoSelector} from './scmRepoSelector';
13+
14+
function makeOnboardingWrapper(initialState?: OnboardingSessionState) {
15+
return function OnboardingWrapper({children}: {children?: React.ReactNode}) {
16+
return (
17+
<OnboardingContextProvider initialValue={initialState}>
18+
{children}
19+
</OnboardingContextProvider>
20+
);
21+
};
22+
}
23+
24+
describe('ScmRepoSelector', () => {
25+
const organization = OrganizationFixture();
26+
27+
const mockIntegration = OrganizationIntegrationsFixture({
28+
id: '1',
29+
name: 'getsentry',
30+
domainName: 'github.com/getsentry',
31+
provider: {
32+
key: 'github',
33+
slug: 'github',
34+
name: 'GitHub',
35+
canAdd: true,
36+
canDisable: false,
37+
features: ['commits'],
38+
aspects: {},
39+
},
40+
});
41+
42+
afterEach(() => {
43+
MockApiClient.clearMockResponses();
44+
sessionStorage.clear();
45+
});
46+
47+
it('renders search placeholder', () => {
48+
render(<ScmRepoSelector integration={mockIntegration} />, {
49+
organization,
50+
wrapper: makeOnboardingWrapper(),
51+
});
52+
53+
expect(screen.getByText('Search repositories')).toBeInTheDocument();
54+
});
55+
56+
it('shows empty state message when search returns no results', async () => {
57+
MockApiClient.addMockResponse({
58+
url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`,
59+
body: {repos: []},
60+
});
61+
62+
render(<ScmRepoSelector integration={mockIntegration} />, {
63+
organization,
64+
wrapper: makeOnboardingWrapper(),
65+
});
66+
67+
await userEvent.type(screen.getByRole('textbox'), 'nonexistent');
68+
69+
expect(
70+
await screen.findByText(
71+
'No repositories found. Check your installation permissions to ensure your integration has access.'
72+
)
73+
).toBeInTheDocument();
74+
});
75+
76+
it('shows error message on API failure', async () => {
77+
MockApiClient.addMockResponse({
78+
url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`,
79+
statusCode: 500,
80+
body: {detail: 'Internal Error'},
81+
});
82+
83+
render(<ScmRepoSelector integration={mockIntegration} />, {
84+
organization,
85+
wrapper: makeOnboardingWrapper(),
86+
});
87+
88+
await userEvent.type(screen.getByRole('textbox'), 'sentry');
89+
90+
expect(
91+
await screen.findByText('Failed to search repositories. Please try again.')
92+
).toBeInTheDocument();
93+
});
94+
95+
it('displays repos returned by search', async () => {
96+
MockApiClient.addMockResponse({
97+
url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`,
98+
body: {
99+
repos: [
100+
{identifier: 'getsentry/sentry', name: 'sentry', isInstalled: false},
101+
{identifier: 'getsentry/relay', name: 'relay', isInstalled: false},
102+
],
103+
},
104+
});
105+
106+
render(<ScmRepoSelector integration={mockIntegration} />, {
107+
organization,
108+
wrapper: makeOnboardingWrapper(),
109+
});
110+
111+
await userEvent.type(screen.getByRole('textbox'), 'get');
112+
113+
expect(
114+
await screen.findByRole('menuitemradio', {name: 'sentry'})
115+
).toBeInTheDocument();
116+
expect(screen.getByRole('menuitemradio', {name: 'relay'})).toBeInTheDocument();
117+
});
118+
119+
it('shows selected repo value when one is in context', () => {
120+
const selectedRepo = RepositoryFixture({
121+
name: 'getsentry/old-repo',
122+
externalSlug: 'getsentry/old-repo',
123+
});
124+
125+
render(<ScmRepoSelector integration={mockIntegration} />, {
126+
organization,
127+
wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}),
128+
});
129+
130+
expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument();
131+
});
132+
133+
it('selects a repo from search results and triggers repo lookup', async () => {
134+
MockApiClient.addMockResponse({
135+
url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`,
136+
body: {
137+
repos: [{identifier: 'getsentry/sentry', name: 'sentry', isInstalled: false}],
138+
},
139+
});
140+
141+
const reposLookup = MockApiClient.addMockResponse({
142+
url: `/organizations/${organization.slug}/repos/`,
143+
body: [
144+
RepositoryFixture({
145+
name: 'getsentry/sentry',
146+
externalSlug: 'getsentry/sentry',
147+
}),
148+
],
149+
});
150+
151+
render(<ScmRepoSelector integration={mockIntegration} />, {
152+
organization,
153+
wrapper: makeOnboardingWrapper(),
154+
});
155+
156+
await userEvent.type(screen.getByRole('textbox'), 'get');
157+
await userEvent.click(await screen.findByRole('menuitemradio', {name: 'sentry'}));
158+
159+
await waitFor(() => expect(reposLookup).toHaveBeenCalled());
160+
});
161+
162+
it('clears the selected repo', async () => {
163+
const selectedRepo = RepositoryFixture({
164+
name: 'getsentry/old-repo',
165+
externalSlug: 'getsentry/old-repo',
166+
});
167+
168+
render(<ScmRepoSelector integration={mockIntegration} />, {
169+
organization,
170+
wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}),
171+
});
172+
173+
expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument();
174+
175+
// The clear indicator uses Sentry's IconClose which renders with
176+
// role="img" rather than aria-hidden, so use the test-id directly.
177+
await userEvent.click(screen.getByTestId('icon-close'));
178+
179+
await waitFor(() => {
180+
expect(screen.queryByText('getsentry/old-repo')).not.toBeInTheDocument();
181+
});
182+
});
183+
184+
it('does not duplicate selected repo when it appears in search results', async () => {
185+
const selectedRepo = RepositoryFixture({
186+
name: 'getsentry/sentry',
187+
externalSlug: 'getsentry/sentry',
188+
});
189+
190+
MockApiClient.addMockResponse({
191+
url: `/organizations/${organization.slug}/integrations/${mockIntegration.id}/repos/`,
192+
body: {
193+
repos: [
194+
{identifier: 'getsentry/sentry', name: 'sentry', isInstalled: false},
195+
{identifier: 'getsentry/relay', name: 'relay', isInstalled: false},
196+
],
197+
},
198+
});
199+
200+
render(<ScmRepoSelector integration={mockIntegration} />, {
201+
organization,
202+
wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}),
203+
});
204+
205+
await userEvent.type(screen.getByRole('textbox'), 'get');
206+
207+
// Wait for search results to arrive
208+
expect(await screen.findByRole('menuitemradio', {name: 'relay'})).toBeInTheDocument();
209+
expect(screen.getByRole('menuitemradio', {name: 'sentry'})).toBeInTheDocument();
210+
211+
// If the options-prepend logic fires incorrectly, it adds an extra option
212+
// with label 'getsentry/sentry' (selectedRepository.name) alongside the
213+
// search result option with label 'sentry' (repo.name).
214+
expect(
215+
screen.queryByRole('menuitemradio', {name: 'getsentry/sentry'})
216+
).not.toBeInTheDocument();
217+
});
218+
});

0 commit comments

Comments
 (0)