Skip to content

Commit d6abda7

Browse files
committed
feat(gitlab): Add frontend pipeline steps for GitLab integration setup
Add GitLab pipeline step components and register them in the pipeline registry. The config step uses GuidedSteps to walk users through creating a GitLab OAuth application, then collects instance URL, group path, and OAuth credentials. The OAuth step reuses the shared OAuthLoginStep. Includes snapshot tests for the config step. Ref [VDY-39](https://linear.app/getsentry/issue/VDY-39/gitlab-api-driven-integration-setup)
1 parent b7608da commit d6abda7

File tree

3 files changed

+674
-0
lines changed

3 files changed

+674
-0
lines changed
Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
2+
3+
import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab';
4+
import type {PipelineStepProps} from './types';
5+
6+
const InstallationConfigStep = gitlabIntegrationPipeline.steps[0].component;
7+
const GitLabOAuthLoginStep = gitlabIntegrationPipeline.steps[1].component;
8+
9+
function makeStepProps<D, A>(
10+
overrides: Partial<PipelineStepProps<D, A>> & {stepData: D}
11+
): PipelineStepProps<D, A> {
12+
return {
13+
advance: jest.fn(),
14+
advanceError: null,
15+
isAdvancing: false,
16+
stepIndex: 0,
17+
totalSteps: 2,
18+
...overrides,
19+
};
20+
}
21+
22+
let mockPopup: Window;
23+
24+
function dispatchPipelineMessage({
25+
data,
26+
origin = document.location.origin,
27+
source = mockPopup,
28+
}: {
29+
data: Record<string, string>;
30+
origin?: string;
31+
source?: Window | MessageEventSource | null;
32+
}) {
33+
act(() => {
34+
const event = new MessageEvent('message', {data, origin});
35+
Object.defineProperty(event, 'source', {value: source});
36+
window.dispatchEvent(event);
37+
});
38+
}
39+
40+
beforeEach(() => {
41+
mockPopup = {
42+
closed: false,
43+
close: jest.fn(),
44+
focus: jest.fn(),
45+
} as unknown as Window;
46+
jest.spyOn(window, 'open').mockReturnValue(mockPopup);
47+
});
48+
49+
afterEach(() => {
50+
jest.restoreAllMocks();
51+
});
52+
53+
describe('InstallationConfigStep', () => {
54+
it('renders the guided steps and config form', () => {
55+
render(
56+
<InstallationConfigStep
57+
{...makeStepProps({
58+
stepData: {
59+
setupValues: [
60+
{
61+
label: 'Redirect URI',
62+
value: 'https://sentry.io/extensions/gitlab/setup/',
63+
},
64+
],
65+
},
66+
})}
67+
/>
68+
);
69+
70+
expect(
71+
screen.getByText(
72+
'To connect Sentry with your GitLab instance, you need to create an OAuth application in GitLab.'
73+
)
74+
).toBeInTheDocument();
75+
76+
expect(screen.getByText('Open GitLab application settings')).toBeInTheDocument();
77+
expect(screen.getByText('Create a new application')).toBeInTheDocument();
78+
expect(screen.getByText('Configure the integration')).toBeInTheDocument();
79+
});
80+
81+
it('renders setup values in the create step', async () => {
82+
render(
83+
<InstallationConfigStep
84+
{...makeStepProps({
85+
stepData: {
86+
setupValues: [
87+
{
88+
label: 'Redirect URI',
89+
value: 'https://sentry.io/extensions/gitlab/setup/',
90+
},
91+
{label: 'Scopes', value: 'api'},
92+
],
93+
},
94+
})}
95+
/>
96+
);
97+
98+
// Navigate to the create step
99+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
100+
101+
expect(screen.getByText('Redirect URI')).toBeInTheDocument();
102+
expect(
103+
screen.getByText('https://sentry.io/extensions/gitlab/setup/')
104+
).toBeInTheDocument();
105+
expect(screen.getByText('Scopes')).toBeInTheDocument();
106+
expect(screen.getByText('api')).toBeInTheDocument();
107+
});
108+
109+
it('submits config with required fields and calls advance', async () => {
110+
const advance = jest.fn();
111+
render(
112+
<InstallationConfigStep
113+
{...makeStepProps({
114+
stepData: {setupValues: []},
115+
advance,
116+
})}
117+
/>
118+
);
119+
120+
// Navigate through guided steps to the configure step
121+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
122+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
123+
124+
await userEvent.type(
125+
screen.getByRole('textbox', {name: 'GitLab Application ID'}),
126+
'my-app-id'
127+
);
128+
await userEvent.type(
129+
screen.getByRole('textbox', {name: 'GitLab Application Secret'}),
130+
'my-secret'
131+
);
132+
133+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
134+
135+
await waitFor(() => {
136+
expect(advance).toHaveBeenCalledWith({
137+
url: undefined,
138+
verify_ssl: undefined,
139+
group: '',
140+
include_subgroups: undefined,
141+
client_id: 'my-app-id',
142+
client_secret: 'my-secret',
143+
});
144+
});
145+
});
146+
147+
it('submits with group and include_subgroups when group is set', async () => {
148+
const advance = jest.fn();
149+
render(
150+
<InstallationConfigStep
151+
{...makeStepProps({
152+
stepData: {setupValues: []},
153+
advance,
154+
})}
155+
/>
156+
);
157+
158+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
159+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
160+
161+
await userEvent.type(
162+
screen.getByRole('textbox', {name: 'GitLab Application ID'}),
163+
'my-app-id'
164+
);
165+
await userEvent.type(
166+
screen.getByRole('textbox', {name: 'GitLab Application Secret'}),
167+
'my-secret'
168+
);
169+
await userEvent.type(
170+
screen.getByRole('textbox', {name: 'GitLab Group Path'}),
171+
'my-group/sub'
172+
);
173+
174+
// Include Subgroups toggle should now be visible
175+
await userEvent.click(screen.getByRole('checkbox', {name: 'Include Subgroups'}));
176+
177+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
178+
179+
await waitFor(() => {
180+
expect(advance).toHaveBeenCalledWith(
181+
expect.objectContaining({
182+
group: 'my-group/sub',
183+
include_subgroups: true,
184+
client_id: 'my-app-id',
185+
client_secret: 'my-secret',
186+
})
187+
);
188+
});
189+
});
190+
191+
it('does not show include_subgroups toggle when group is empty', async () => {
192+
render(<InstallationConfigStep {...makeStepProps({stepData: {setupValues: []}})} />);
193+
194+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
195+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
196+
197+
expect(
198+
screen.queryByRole('checkbox', {name: 'Include Subgroups'})
199+
).not.toBeInTheDocument();
200+
});
201+
202+
it('shows self-hosted fields when self-hosted toggle is enabled', async () => {
203+
render(<InstallationConfigStep {...makeStepProps({stepData: {setupValues: []}})} />);
204+
205+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
206+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
207+
208+
// Self-hosted fields should not be visible initially
209+
expect(screen.queryByRole('textbox', {name: 'GitLab URL'})).not.toBeInTheDocument();
210+
211+
await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'}));
212+
213+
expect(screen.getByRole('textbox', {name: 'GitLab URL'})).toBeInTheDocument();
214+
expect(screen.getByRole('checkbox', {name: 'Verify SSL'})).toBeInTheDocument();
215+
});
216+
217+
it('submits self-hosted config with URL and verify_ssl', async () => {
218+
const advance = jest.fn();
219+
render(
220+
<InstallationConfigStep
221+
{...makeStepProps({
222+
stepData: {setupValues: []},
223+
advance,
224+
})}
225+
/>
226+
);
227+
228+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
229+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
230+
231+
await userEvent.type(
232+
screen.getByRole('textbox', {name: 'GitLab Application ID'}),
233+
'my-app-id'
234+
);
235+
await userEvent.type(
236+
screen.getByRole('textbox', {name: 'GitLab Application Secret'}),
237+
'my-secret'
238+
);
239+
240+
await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'}));
241+
242+
await userEvent.type(
243+
screen.getByRole('textbox', {name: 'GitLab URL'}),
244+
'https://gitlab.example.com/'
245+
);
246+
247+
// Verify SSL is on by default, turn it off
248+
await userEvent.click(screen.getByRole('checkbox', {name: 'Verify SSL'}));
249+
250+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
251+
252+
await waitFor(() => {
253+
expect(advance).toHaveBeenCalledWith(
254+
expect.objectContaining({
255+
url: 'https://gitlab.example.com',
256+
verify_ssl: false,
257+
client_id: 'my-app-id',
258+
client_secret: 'my-secret',
259+
})
260+
);
261+
});
262+
});
263+
264+
it('strips trailing slashes from self-hosted URL', async () => {
265+
const advance = jest.fn();
266+
render(
267+
<InstallationConfigStep
268+
{...makeStepProps({
269+
stepData: {setupValues: []},
270+
advance,
271+
})}
272+
/>
273+
);
274+
275+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
276+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
277+
278+
await userEvent.type(
279+
screen.getByRole('textbox', {name: 'GitLab Application ID'}),
280+
'id'
281+
);
282+
await userEvent.type(
283+
screen.getByRole('textbox', {name: 'GitLab Application Secret'}),
284+
'secret'
285+
);
286+
287+
await userEvent.click(screen.getByRole('checkbox', {name: 'Self-Hosted Instance'}));
288+
289+
await userEvent.type(
290+
screen.getByRole('textbox', {name: 'GitLab URL'}),
291+
'https://gitlab.example.com///'
292+
);
293+
294+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
295+
296+
await waitFor(() => {
297+
expect(advance).toHaveBeenCalledWith(
298+
expect.objectContaining({
299+
url: 'https://gitlab.example.com',
300+
})
301+
);
302+
});
303+
});
304+
305+
it('shows submitting state when isAdvancing is true', async () => {
306+
render(
307+
<InstallationConfigStep
308+
{...makeStepProps({
309+
stepData: {setupValues: []},
310+
isAdvancing: true,
311+
})}
312+
/>
313+
);
314+
315+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
316+
await userEvent.click(screen.getByRole('button', {name: 'Next'}));
317+
318+
expect(screen.getByRole('button', {name: 'Submitting...'})).toBeDisabled();
319+
});
320+
});
321+
322+
describe('GitLabOAuthLoginStep', () => {
323+
it('renders the OAuth login step for GitLab', () => {
324+
render(
325+
<GitLabOAuthLoginStep
326+
{...makeStepProps({stepData: {oauthUrl: 'https://gitlab.com/oauth/authorize'}})}
327+
/>
328+
);
329+
330+
expect(screen.getByRole('button', {name: 'Authorize GitLab'})).toBeInTheDocument();
331+
});
332+
333+
it('calls advance with code and state on OAuth callback', async () => {
334+
const advance = jest.fn();
335+
render(
336+
<GitLabOAuthLoginStep
337+
{...makeStepProps({
338+
stepData: {oauthUrl: 'https://gitlab.com/oauth/authorize'},
339+
advance,
340+
})}
341+
/>
342+
);
343+
344+
await userEvent.click(screen.getByRole('button', {name: 'Authorize GitLab'}));
345+
346+
dispatchPipelineMessage({
347+
data: {
348+
_pipeline_source: 'sentry-pipeline',
349+
code: 'auth-code-123',
350+
state: 'state-xyz',
351+
},
352+
});
353+
354+
expect(advance).toHaveBeenCalledWith({
355+
code: 'auth-code-123',
356+
state: 'state-xyz',
357+
});
358+
});
359+
360+
it('shows loading state when isAdvancing is true', () => {
361+
render(
362+
<GitLabOAuthLoginStep
363+
{...makeStepProps({
364+
stepData: {oauthUrl: 'https://gitlab.com/oauth/authorize'},
365+
isAdvancing: true,
366+
})}
367+
/>
368+
);
369+
370+
expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled();
371+
});
372+
373+
it('disables authorize button when oauthUrl is not provided', () => {
374+
render(<GitLabOAuthLoginStep {...makeStepProps({stepData: {}})} />);
375+
376+
expect(screen.getByRole('button', {name: 'Authorize GitLab'})).toBeDisabled();
377+
});
378+
});

0 commit comments

Comments
 (0)