@shipfox/client-projects 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +7 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +207 -0
  8. package/LICENSE +21 -0
  9. package/dist/chrome.d.ts +3 -0
  10. package/dist/chrome.d.ts.map +1 -0
  11. package/dist/chrome.js +42 -0
  12. package/dist/chrome.js.map +1 -0
  13. package/dist/components/model-provider-reminder-banner.d.ts +4 -0
  14. package/dist/components/model-provider-reminder-banner.d.ts.map +1 -0
  15. package/dist/components/model-provider-reminder-banner.js +73 -0
  16. package/dist/components/model-provider-reminder-banner.js.map +1 -0
  17. package/dist/components/project-crumb.d.ts +7 -0
  18. package/dist/components/project-crumb.d.ts.map +1 -0
  19. package/dist/components/project-crumb.js +93 -0
  20. package/dist/components/project-crumb.js.map +1 -0
  21. package/dist/components/project-switcher.d.ts +7 -0
  22. package/dist/components/project-switcher.d.ts.map +1 -0
  23. package/dist/components/project-switcher.js +138 -0
  24. package/dist/components/project-switcher.js.map +1 -0
  25. package/dist/components/source-strip.d.ts +14 -0
  26. package/dist/components/source-strip.d.ts.map +1 -0
  27. package/dist/components/source-strip.js +98 -0
  28. package/dist/components/source-strip.js.map +1 -0
  29. package/dist/components/workspace-setup-guard.d.ts +9 -0
  30. package/dist/components/workspace-setup-guard.d.ts.map +1 -0
  31. package/dist/components/workspace-setup-guard.js +160 -0
  32. package/dist/components/workspace-setup-guard.js.map +1 -0
  33. package/dist/feature.d.ts +35 -0
  34. package/dist/feature.d.ts.map +1 -0
  35. package/dist/feature.js +45 -0
  36. package/dist/feature.js.map +1 -0
  37. package/dist/hooks/api/definitions.d.ts +97 -0
  38. package/dist/hooks/api/definitions.d.ts.map +1 -0
  39. package/dist/hooks/api/definitions.js +55 -0
  40. package/dist/hooks/api/definitions.js.map +1 -0
  41. package/dist/hooks/api/projects.d.ts +93 -0
  42. package/dist/hooks/api/projects.d.ts.map +1 -0
  43. package/dist/hooks/api/projects.js +79 -0
  44. package/dist/hooks/api/projects.js.map +1 -0
  45. package/dist/index.d.ts +13 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +13 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/pages/create-project-page.d.ts +2 -0
  50. package/dist/pages/create-project-page.d.ts.map +1 -0
  51. package/dist/pages/create-project-page.js +406 -0
  52. package/dist/pages/create-project-page.js.map +1 -0
  53. package/dist/pages/create-project-page.stories.d.ts +24 -0
  54. package/dist/pages/create-project-page.stories.d.ts.map +1 -0
  55. package/dist/pages/create-project-page.stories.js +345 -0
  56. package/dist/pages/create-project-page.stories.js.map +1 -0
  57. package/dist/pages/home-router.d.ts +2 -0
  58. package/dist/pages/home-router.d.ts.map +1 -0
  59. package/dist/pages/home-router.js +7 -0
  60. package/dist/pages/home-router.js.map +1 -0
  61. package/dist/pages/project-workflows-page.d.ts +4 -0
  62. package/dist/pages/project-workflows-page.d.ts.map +1 -0
  63. package/dist/pages/project-workflows-page.js +481 -0
  64. package/dist/pages/project-workflows-page.js.map +1 -0
  65. package/dist/pages/projects-hub-page.d.ts +2 -0
  66. package/dist/pages/projects-hub-page.d.ts.map +1 -0
  67. package/dist/pages/projects-hub-page.js +256 -0
  68. package/dist/pages/projects-hub-page.js.map +1 -0
  69. package/dist/project-error.d.ts +7 -0
  70. package/dist/project-error.d.ts.map +1 -0
  71. package/dist/project-error.js +93 -0
  72. package/dist/project-error.js.map +1 -0
  73. package/dist/routes/create-project.d.ts +6 -0
  74. package/dist/routes/create-project.d.ts.map +1 -0
  75. package/dist/routes/create-project.js +7 -0
  76. package/dist/routes/create-project.js.map +1 -0
  77. package/dist/routes/home.d.ts +6 -0
  78. package/dist/routes/home.d.ts.map +1 -0
  79. package/dist/routes/home.js +7 -0
  80. package/dist/routes/home.js.map +1 -0
  81. package/dist/routes/project-index.d.ts +10 -0
  82. package/dist/routes/project-index.d.ts.map +1 -0
  83. package/dist/routes/project-index.js +15 -0
  84. package/dist/routes/project-index.js.map +1 -0
  85. package/dist/routes/workflows.d.ts +5 -0
  86. package/dist/routes/workflows.d.ts.map +1 -0
  87. package/dist/routes/workflows.js +16 -0
  88. package/dist/routes/workflows.js.map +1 -0
  89. package/dist/tsconfig.test.tsbuildinfo +1 -0
  90. package/package.json +120 -0
  91. package/src/chrome.tsx +26 -0
  92. package/src/components/model-provider-reminder-banner.tsx +78 -0
  93. package/src/components/project-crumb.tsx +70 -0
  94. package/src/components/project-switcher.test.tsx +91 -0
  95. package/src/components/project-switcher.tsx +127 -0
  96. package/src/components/source-strip.tsx +91 -0
  97. package/src/components/workspace-setup-guard.test.tsx +534 -0
  98. package/src/components/workspace-setup-guard.tsx +185 -0
  99. package/src/feature.ts +44 -0
  100. package/src/hooks/api/definitions.ts +50 -0
  101. package/src/hooks/api/projects.test.ts +78 -0
  102. package/src/hooks/api/projects.ts +75 -0
  103. package/src/index.ts +15 -0
  104. package/src/pages/create-project-page.stories.tsx +344 -0
  105. package/src/pages/create-project-page.test.tsx +317 -0
  106. package/src/pages/create-project-page.tsx +386 -0
  107. package/src/pages/home-router.test.tsx +27 -0
  108. package/src/pages/home-router.tsx +5 -0
  109. package/src/pages/project-workflows-page.test.tsx +258 -0
  110. package/src/pages/project-workflows-page.tsx +445 -0
  111. package/src/pages/projects-hub-page.test.tsx +313 -0
  112. package/src/pages/projects-hub-page.tsx +246 -0
  113. package/src/project-error.test.ts +59 -0
  114. package/src/project-error.ts +106 -0
  115. package/src/routes/create-project.tsx +4 -0
  116. package/src/routes/home.tsx +4 -0
  117. package/src/routes/project-index.tsx +11 -0
  118. package/src/routes/workflows.tsx +10 -0
  119. package/test/pages.tsx +151 -0
  120. package/test/setup.ts +3 -0
  121. package/tsconfig.build.json +9 -0
  122. package/tsconfig.build.tsbuildinfo +1 -0
  123. package/tsconfig.json +3 -0
  124. package/tsconfig.test.json +8 -0
  125. package/vitest.config.ts +70 -0
@@ -0,0 +1,344 @@
1
+ import type {IntegrationConnectionDto, RepositoryDto} from '@shipfox/api-integration-core-dto';
2
+ import {configureApiClient} from '@shipfox/client-api';
3
+ import {type AuthState, authStateAtom} from '@shipfox/client-auth';
4
+ import {Toaster} from '@shipfox/react-ui/toast';
5
+ import type {Meta, StoryObj} from '@storybook/react';
6
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
7
+ import {
8
+ createMemoryHistory,
9
+ createRootRoute,
10
+ createRoute,
11
+ createRouter,
12
+ Outlet,
13
+ RouterProvider,
14
+ } from '@tanstack/react-router';
15
+ import {createStore, Provider as JotaiProvider} from 'jotai';
16
+ import {useMemo} from 'react';
17
+ import {CreateProjectPage} from './create-project-page.js';
18
+
19
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
20
+ const WORKSPACE_PATH = `/workspaces/${WORKSPACE_ID}/projects/new`;
21
+ const GITHUB_CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
22
+ const GITEA_CONNECTION_ID = '44444444-4444-4444-8444-444444444444';
23
+
24
+ type Scenario =
25
+ | 'playground'
26
+ | 'long-names'
27
+ | 'repository-loading'
28
+ | 'empty-repositories'
29
+ | 'connections-error';
30
+
31
+ interface CreateProjectPageStoryProps {
32
+ scenario: Scenario;
33
+ }
34
+
35
+ const authState: AuthState = {
36
+ status: 'authenticated',
37
+ token: 'token',
38
+ user: {
39
+ id: '22222222-2222-4222-8222-222222222222',
40
+ email: 'platform@example.com',
41
+ name: 'Platform Engineer',
42
+ email_verified_at: '2026-01-01T00:00:00.000Z',
43
+ status: 'active',
44
+ created_at: '2026-01-01T00:00:00.000Z',
45
+ updated_at: '2026-01-01T00:00:00.000Z',
46
+ },
47
+ workspaces: [{id: WORKSPACE_ID, name: 'Acme', membershipId: 'membership-1'}],
48
+ };
49
+
50
+ function CreateProjectPageStory({scenario}: CreateProjectPageStoryProps) {
51
+ configureApiClient({
52
+ baseUrl: 'https://api.example.test',
53
+ fetchImpl: fetchForScenario(scenario),
54
+ });
55
+
56
+ const queryClient = useMemo(
57
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
58
+ [],
59
+ );
60
+ const store = useMemo(() => {
61
+ const nextStore = createStore();
62
+ nextStore.set(authStateAtom, authState);
63
+ return nextStore;
64
+ }, []);
65
+ const router = useMemo(() => createStoryRouter(), []);
66
+
67
+ return (
68
+ <QueryClientProvider client={queryClient}>
69
+ <JotaiProvider store={store}>
70
+ <div className="min-h-screen bg-background-neutral-background px-24 py-32">
71
+ <div className="mx-auto w-full max-w-[1120px]">
72
+ <RouterProvider router={router} />
73
+ </div>
74
+ </div>
75
+ <Toaster />
76
+ </JotaiProvider>
77
+ </QueryClientProvider>
78
+ );
79
+ }
80
+
81
+ const meta = {
82
+ title: 'Projects/CreateProjectPage',
83
+ component: CreateProjectPageStory,
84
+ parameters: {layout: 'fullscreen'},
85
+ args: {scenario: 'playground'},
86
+ } satisfies Meta<typeof CreateProjectPageStory>;
87
+
88
+ export default meta;
89
+ type Story = StoryObj<typeof meta>;
90
+
91
+ export const Playground: Story = {};
92
+
93
+ export const LongNames: Story = {
94
+ args: {scenario: 'long-names'},
95
+ };
96
+
97
+ export const RepositoryLoading: Story = {
98
+ args: {scenario: 'repository-loading'},
99
+ };
100
+
101
+ export const EmptyRepositories: Story = {
102
+ args: {scenario: 'empty-repositories'},
103
+ };
104
+
105
+ export const ConnectionsError: Story = {
106
+ args: {scenario: 'connections-error'},
107
+ };
108
+
109
+ function createStoryRouter() {
110
+ const rootRoute = createRootRoute({component: Outlet});
111
+ const workspaceRoute = createRoute({
112
+ getParentRoute: () => rootRoute,
113
+ path: '/workspaces/$wid',
114
+ component: Outlet,
115
+ });
116
+ const createProjectRoute = createRoute({
117
+ getParentRoute: () => workspaceRoute,
118
+ path: 'projects/new',
119
+ component: CreateProjectPage,
120
+ });
121
+ const integrationsRoute = createRoute({
122
+ getParentRoute: () => workspaceRoute,
123
+ path: 'integrations',
124
+ component: () => <div />,
125
+ });
126
+ const modelProvidersRoute = createRoute({
127
+ getParentRoute: () => workspaceRoute,
128
+ path: 'settings/agents',
129
+ component: () => <div />,
130
+ });
131
+ const projectRoute = createRoute({
132
+ getParentRoute: () => workspaceRoute,
133
+ path: 'projects/$pid',
134
+ component: () => <div />,
135
+ });
136
+
137
+ return createRouter({
138
+ history: createMemoryHistory({initialEntries: [WORKSPACE_PATH]}),
139
+ routeTree: rootRoute.addChildren([
140
+ workspaceRoute.addChildren([
141
+ createProjectRoute,
142
+ integrationsRoute,
143
+ modelProvidersRoute,
144
+ projectRoute,
145
+ ]),
146
+ ]),
147
+ });
148
+ }
149
+
150
+ function fetchForScenario(scenario: Scenario): typeof fetch {
151
+ return (input, init) => {
152
+ const url = requestUrl(input);
153
+ const method = input instanceof Request ? input.method : (init?.method ?? 'GET');
154
+ if (url.pathname === `/workspaces/${WORKSPACE_ID}/agent/model-providers`) {
155
+ return Promise.resolve(
156
+ jsonResponse({configs: [modelProviderConfig()], default_provider_id: 'openai'}),
157
+ );
158
+ }
159
+ if (url.pathname === '/integration-connections') {
160
+ if (scenario === 'connections-error') return Promise.resolve(errorResponse());
161
+ return Promise.resolve(jsonResponse({connections: connectionsForScenario(scenario)}));
162
+ }
163
+ if (
164
+ url.pathname.startsWith('/integration-connections/') &&
165
+ url.pathname.endsWith('/repositories')
166
+ ) {
167
+ if (scenario === 'repository-loading') return new Promise<Response>(() => undefined);
168
+ return Promise.resolve(
169
+ jsonResponse({repositories: repositoriesForScenario(scenario), next_cursor: null}),
170
+ );
171
+ }
172
+ if (url.pathname === '/projects' && method === 'POST') {
173
+ return Promise.resolve(
174
+ jsonResponse({
175
+ id: '99999999-9999-4999-8999-999999999999',
176
+ workspace_id: WORKSPACE_ID,
177
+ name: 'Platform',
178
+ source: {
179
+ connection_id: GITHUB_CONNECTION_ID,
180
+ external_repository_id: 'platform',
181
+ },
182
+ created_at: '2026-01-01T00:00:00.000Z',
183
+ updated_at: '2026-01-01T00:00:00.000Z',
184
+ }),
185
+ );
186
+ }
187
+ return Promise.resolve(jsonResponse({}, {status: 404}));
188
+ };
189
+ }
190
+
191
+ function connectionsForScenario(scenario: Scenario): IntegrationConnectionDto[] {
192
+ if (scenario === 'long-names') {
193
+ return [
194
+ connection({
195
+ id: GITHUB_CONNECTION_ID,
196
+ provider: 'github',
197
+ display_name: 'acme-platform-engineering-production',
198
+ external_account_id: 'acme-platform-engineering-production',
199
+ }),
200
+ connection({
201
+ id: GITEA_CONNECTION_ID,
202
+ provider: 'gitea',
203
+ display_name: 'git.internal.acme.example/platform-tools',
204
+ external_account_id: 'git.internal.acme.example/platform-tools',
205
+ }),
206
+ ];
207
+ }
208
+
209
+ return [
210
+ connection({
211
+ id: GITHUB_CONNECTION_ID,
212
+ provider: 'github',
213
+ display_name: 'acme',
214
+ external_account_id: 'acme',
215
+ }),
216
+ connection({
217
+ id: GITEA_CONNECTION_ID,
218
+ provider: 'gitea',
219
+ display_name: 'git.acme.internal',
220
+ external_account_id: 'git.acme.internal',
221
+ }),
222
+ ];
223
+ }
224
+
225
+ function repositoriesForScenario(scenario: Scenario): RepositoryDto[] {
226
+ if (scenario === 'empty-repositories') return [];
227
+ if (scenario === 'long-names') {
228
+ return [
229
+ repository({
230
+ external_repository_id: 'acme-platform-infrastructure-control-plane',
231
+ name: 'acme-platform-infrastructure-control-plane',
232
+ full_name: 'acme-engineering/acme-platform-infrastructure-control-plane',
233
+ default_branch: 'production',
234
+ }),
235
+ repository({
236
+ external_repository_id: 'workflow-automation-and-runner-orchestration',
237
+ name: 'workflow-automation-and-runner-orchestration',
238
+ full_name: 'acme-engineering/workflow-automation-and-runner-orchestration',
239
+ default_branch: 'main',
240
+ }),
241
+ repository({
242
+ external_repository_id: 'customer-deployment-pipelines',
243
+ name: 'customer-deployment-pipelines',
244
+ full_name: 'acme-solutions/customer-deployment-pipelines',
245
+ default_branch: 'release',
246
+ }),
247
+ ];
248
+ }
249
+
250
+ return [
251
+ repository({
252
+ external_repository_id: 'platform',
253
+ name: 'platform',
254
+ full_name: 'acme/platform',
255
+ default_branch: 'main',
256
+ }),
257
+ repository({
258
+ external_repository_id: 'api',
259
+ name: 'api',
260
+ full_name: 'acme/api',
261
+ default_branch: 'main',
262
+ }),
263
+ repository({
264
+ external_repository_id: 'client',
265
+ name: 'client',
266
+ full_name: 'acme/client',
267
+ default_branch: 'main',
268
+ }),
269
+ repository({
270
+ external_repository_id: 'runners',
271
+ name: 'runners',
272
+ full_name: 'acme/runners',
273
+ default_branch: 'main',
274
+ }),
275
+ repository({
276
+ external_repository_id: 'workflows',
277
+ name: 'workflows',
278
+ full_name: 'acme/workflows',
279
+ default_branch: 'develop',
280
+ }),
281
+ ];
282
+ }
283
+
284
+ function connection(
285
+ overrides: Partial<IntegrationConnectionDto> & Pick<IntegrationConnectionDto, 'id'>,
286
+ ): IntegrationConnectionDto {
287
+ return {
288
+ workspace_id: WORKSPACE_ID,
289
+ provider: 'github',
290
+ external_account_id: 'acme',
291
+ slug: 'github_acme',
292
+ display_name: 'GitHub',
293
+ lifecycle_status: 'active',
294
+ capabilities: ['source_control'],
295
+ created_at: '2026-01-01T00:00:00.000Z',
296
+ updated_at: '2026-01-01T00:00:00.000Z',
297
+ ...overrides,
298
+ };
299
+ }
300
+
301
+ function repository(overrides: Partial<RepositoryDto>): RepositoryDto {
302
+ return {
303
+ connection_id: GITHUB_CONNECTION_ID,
304
+ external_repository_id: 'platform',
305
+ owner: 'acme',
306
+ name: 'platform',
307
+ full_name: 'acme/platform',
308
+ default_branch: 'main',
309
+ visibility: 'private',
310
+ clone_url: 'https://github.example.test/acme/platform.git',
311
+ html_url: 'https://github.example.test/acme/platform',
312
+ ...overrides,
313
+ };
314
+ }
315
+
316
+ function modelProviderConfig() {
317
+ return {
318
+ workspace_id: WORKSPACE_ID,
319
+ provider_id: 'openai',
320
+ default_model: 'gpt-5.5-pro',
321
+ credential_fields: ['api_key'],
322
+ lifecycle_status: 'active',
323
+ created_at: '2026-01-01T00:00:00.000Z',
324
+ updated_at: '2026-01-01T00:00:00.000Z',
325
+ };
326
+ }
327
+
328
+ function requestUrl(input: RequestInfo | URL): URL {
329
+ if (input instanceof Request) return new URL(input.url);
330
+ if (input instanceof URL) return input;
331
+ return new URL(input, 'https://api.example.test');
332
+ }
333
+
334
+ function jsonResponse(body: unknown, init: ResponseInit = {}) {
335
+ return new Response(JSON.stringify(body), {
336
+ status: 200,
337
+ headers: {'content-type': 'application/json'},
338
+ ...init,
339
+ });
340
+ }
341
+
342
+ function errorResponse() {
343
+ return jsonResponse({code: 'server-error'}, {status: 500});
344
+ }
@@ -0,0 +1,317 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {fireEvent, screen, waitFor} from '@testing-library/react';
3
+ import {jsonResponse, PROJECT_TEST_WID, renderProjectPage} from '#test/pages.js';
4
+ import {CreateProjectPage} from './create-project-page.js';
5
+
6
+ const CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
7
+ const SECOND_CONNECTION_ID = '66666666-6666-4666-8666-666666666666';
8
+ const REPOSITORY_NOT_FOUND_RE = /Repository not found/;
9
+ const GITEA_RADIO_LABEL_RE = /^Gitea Source$/;
10
+
11
+ describe('CreateProjectPage', () => {
12
+ beforeEach(() => {
13
+ window.sessionStorage.clear();
14
+ });
15
+
16
+ test('with a single connection: pre-selects, renders repos, creates a project', async () => {
17
+ let createProjectBody: unknown;
18
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
19
+ const request = input as Request;
20
+ if (request.url.includes('/integration-connections?')) {
21
+ return jsonResponse({connections: [connectionDto()]});
22
+ }
23
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
24
+ return jsonResponse({repositories: [repositoryDto()], next_cursor: null});
25
+ }
26
+ if (request.url.endsWith('/projects') && request.method === 'POST') {
27
+ createProjectBody = await request.json();
28
+ return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
29
+ }
30
+ return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
31
+ });
32
+ configureApiClient({fetchImpl});
33
+
34
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
35
+ const nameInput = await screen.findByLabelText('Project name');
36
+ await waitFor(() => expect(nameInput).toHaveValue('Platform'));
37
+ expect(screen.getByRole('radio', {name: GITEA_RADIO_LABEL_RE})).toBeChecked();
38
+ expect(screen.getAllByText('gitea-owner/platform').length).toBeGreaterThan(0);
39
+ fireEvent.change(nameInput, {
40
+ target: {value: ' Launch Pad '},
41
+ });
42
+ fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
43
+
44
+ expect(await screen.findByRole('heading', {name: 'Runs'})).toBeInTheDocument();
45
+ expect(createProjectBody).toEqual({
46
+ workspace_id: PROJECT_TEST_WID,
47
+ name: 'Launch Pad',
48
+ source: {
49
+ connection_id: CONNECTION_ID,
50
+ external_repository_id: 'platform',
51
+ },
52
+ });
53
+ }, 10_000);
54
+
55
+ test('uses the current repository-derived name when submitted before touching the field', async () => {
56
+ let createProjectBody: unknown;
57
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
58
+ const request = input as Request;
59
+ if (request.url.includes('/integration-connections?')) {
60
+ return jsonResponse({connections: [connectionDto()]});
61
+ }
62
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
63
+ return jsonResponse({repositories: [repositoryDto()], next_cursor: null});
64
+ }
65
+ if (request.url.endsWith('/projects') && request.method === 'POST') {
66
+ createProjectBody = await request.json();
67
+ return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
68
+ }
69
+ return jsonResponse(projectDto({id: '44444444-4444-4444-8444-444444444444'}));
70
+ });
71
+ configureApiClient({fetchImpl});
72
+
73
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
74
+ expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
75
+ fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
76
+
77
+ expect(await screen.findByRole('heading', {name: 'Runs'})).toBeInTheDocument();
78
+ expect(createProjectBody).toEqual({
79
+ workspace_id: PROJECT_TEST_WID,
80
+ name: 'Platform',
81
+ source: {
82
+ connection_id: CONNECTION_ID,
83
+ external_repository_id: 'platform',
84
+ },
85
+ });
86
+ });
87
+
88
+ test('with multiple connections: hides repo picker until a connection is selected', async () => {
89
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
90
+ const request = input as Request;
91
+ if (request.url.includes('/integration-connections?')) {
92
+ return Promise.resolve(
93
+ jsonResponse({connections: [connectionDto(), secondConnectionDto()]}),
94
+ );
95
+ }
96
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
97
+ return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
98
+ }
99
+ return Promise.resolve(jsonResponse({}));
100
+ });
101
+ configureApiClient({fetchImpl});
102
+
103
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
104
+ expect((await screen.findAllByText('Gitea Source')).length).toBeGreaterThan(0);
105
+ expect(await screen.findByText('Other Gitea Source')).toBeInTheDocument();
106
+ expect(screen.queryByText('gitea-owner/platform')).not.toBeInTheDocument();
107
+
108
+ fireEvent.click(screen.getByRole('radio', {name: GITEA_RADIO_LABEL_RE}));
109
+
110
+ expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
111
+ });
112
+
113
+ test('rejects invalid custom project names locally', async () => {
114
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
115
+ const request = input as Request;
116
+ if (request.url.includes('/integration-connections?')) {
117
+ return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
118
+ }
119
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
120
+ return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
121
+ }
122
+ return Promise.resolve(jsonResponse({}));
123
+ });
124
+ configureApiClient({fetchImpl});
125
+
126
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
127
+ expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
128
+ fireEvent.change(await screen.findByLabelText('Project name'), {
129
+ target: {value: 'Bad\u202eName'},
130
+ });
131
+ fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
132
+
133
+ expect(
134
+ await screen.findByText(
135
+ 'Project name cannot include line breaks, tabs, or hidden formatting characters.',
136
+ ),
137
+ ).toBeInTheDocument();
138
+ expect(projectPostCount(fetchImpl)).toBe(0);
139
+ });
140
+
141
+ test('with a single connection: shows workspace-scoped "Add another integration" link', async () => {
142
+ configureApiClient({
143
+ fetchImpl: vi.fn((input: RequestInfo | URL) => {
144
+ const request = input as Request;
145
+ if (request.url.includes('/integration-connections?')) {
146
+ return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
147
+ }
148
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
149
+ return Promise.resolve(
150
+ jsonResponse({repositories: [repositoryDto()], next_cursor: null}),
151
+ );
152
+ }
153
+ return Promise.resolve(jsonResponse({}));
154
+ }),
155
+ });
156
+
157
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
158
+ const link = await screen.findByRole('link', {name: 'Add another integration'});
159
+ expect(link).toHaveAttribute('href', `/workspaces/${PROJECT_TEST_WID}/integrations`);
160
+ });
161
+
162
+ test('shows the model provider reminder on project creation when no provider is configured', async () => {
163
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
164
+ const request = input as Request;
165
+ if (request.url.endsWith('/agent/model-providers')) {
166
+ return Promise.resolve(jsonResponse({configs: [], default_provider_id: null}));
167
+ }
168
+ if (request.url.includes('/integration-connections?')) {
169
+ return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
170
+ }
171
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
172
+ return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
173
+ }
174
+ return Promise.resolve(jsonResponse({}));
175
+ });
176
+ configureApiClient({fetchImpl});
177
+
178
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
179
+
180
+ expect(await screen.findByText('Finish setting up a model provider')).toBeInTheDocument();
181
+ expect(screen.getByRole('link', {name: 'Agents'})).toHaveAttribute(
182
+ 'href',
183
+ `/workspaces/${PROJECT_TEST_WID}/settings/agents`,
184
+ );
185
+ });
186
+
187
+ test('navigates to the existing project for duplicate recovery', async () => {
188
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
189
+ const request = input as Request;
190
+ if (request.url.includes('/integration-connections?')) {
191
+ return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
192
+ }
193
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
194
+ return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
195
+ }
196
+ if (request.url.endsWith('/projects')) {
197
+ return Promise.resolve(
198
+ jsonResponse(
199
+ {
200
+ code: 'project-already-exists',
201
+ details: {existing_project_id: '55555555-5555-4555-8555-555555555555'},
202
+ },
203
+ {status: 409},
204
+ ),
205
+ );
206
+ }
207
+ if (request.url.includes('/projects?')) {
208
+ return Promise.resolve(
209
+ jsonResponse({
210
+ projects: [projectDto({id: '55555555-5555-4555-8555-555555555555'})],
211
+ next_cursor: null,
212
+ }),
213
+ );
214
+ }
215
+ if (request.url.includes('/definitions?')) {
216
+ return Promise.resolve(jsonResponse({definitions: [], next_cursor: null, sync: null}));
217
+ }
218
+ return Promise.resolve(
219
+ jsonResponse(projectDto({id: '55555555-5555-4555-8555-555555555555'})),
220
+ );
221
+ });
222
+ configureApiClient({fetchImpl});
223
+
224
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
225
+ expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
226
+ fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
227
+
228
+ // After duplicate recovery, navigation lands on the workspace-scoped project
229
+ // URL. Production redirects that URL to the Runs tab.
230
+ expect(await screen.findByRole('heading', {name: 'Runs'})).toBeInTheDocument();
231
+ });
232
+
233
+ test('shows provider-specific submit errors', async () => {
234
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
235
+ const request = input as Request;
236
+ if (request.url.includes('/integration-connections?')) {
237
+ return Promise.resolve(jsonResponse({connections: [connectionDto()]}));
238
+ }
239
+ if (request.url.includes(`/integration-connections/${CONNECTION_ID}/repositories`)) {
240
+ return Promise.resolve(jsonResponse({repositories: [repositoryDto()], next_cursor: null}));
241
+ }
242
+ return Promise.resolve(jsonResponse({code: 'repository-not-found'}, {status: 422}));
243
+ });
244
+ configureApiClient({fetchImpl});
245
+
246
+ renderProjectPage(`/workspaces/${PROJECT_TEST_WID}/projects/new`, <CreateProjectPage />);
247
+ expect((await screen.findAllByText('gitea-owner/platform')).length).toBeGreaterThan(0);
248
+ fireEvent.click(screen.getByRole('button', {name: 'Create project'}));
249
+
250
+ expect(await screen.findByText(REPOSITORY_NOT_FOUND_RE)).toBeInTheDocument();
251
+ });
252
+ });
253
+
254
+ function connectionDto() {
255
+ return {
256
+ id: CONNECTION_ID,
257
+ workspace_id: '11111111-1111-4111-8111-111111111111',
258
+ provider: 'gitea',
259
+ external_account_id: 'gitea-owner',
260
+ slug: 'gitea_owner',
261
+ display_name: 'Gitea Source',
262
+ lifecycle_status: 'active',
263
+ capabilities: ['source_control'],
264
+ created_at: new Date().toISOString(),
265
+ updated_at: new Date().toISOString(),
266
+ };
267
+ }
268
+
269
+ function secondConnectionDto() {
270
+ return {
271
+ id: SECOND_CONNECTION_ID,
272
+ workspace_id: '11111111-1111-4111-8111-111111111111',
273
+ provider: 'gitea',
274
+ external_account_id: 'gitea-owner-2',
275
+ slug: 'gitea_owner_2',
276
+ display_name: 'Other Gitea Source',
277
+ lifecycle_status: 'active',
278
+ capabilities: ['source_control'],
279
+ created_at: new Date().toISOString(),
280
+ updated_at: new Date().toISOString(),
281
+ };
282
+ }
283
+
284
+ function repositoryDto() {
285
+ return {
286
+ connection_id: CONNECTION_ID,
287
+ external_repository_id: 'platform',
288
+ owner: 'gitea-owner',
289
+ name: 'platform',
290
+ full_name: 'gitea-owner/platform',
291
+ default_branch: 'main',
292
+ visibility: 'private',
293
+ clone_url: 'https://gitea.local/gitea-owner/platform.git',
294
+ html_url: 'https://gitea.local/gitea-owner/platform',
295
+ };
296
+ }
297
+
298
+ function projectDto({id}: {id: string}) {
299
+ return {
300
+ id,
301
+ workspace_id: '11111111-1111-4111-8111-111111111111',
302
+ name: 'Project Detail',
303
+ source: {
304
+ connection_id: CONNECTION_ID,
305
+ external_repository_id: 'platform',
306
+ },
307
+ created_at: new Date().toISOString(),
308
+ updated_at: new Date().toISOString(),
309
+ };
310
+ }
311
+
312
+ function projectPostCount(fetchImpl: ReturnType<typeof vi.fn>): number {
313
+ return fetchImpl.mock.calls.filter(([input]) => {
314
+ const request = input as Request;
315
+ return request.url.endsWith('/projects') && request.method === 'POST';
316
+ }).length;
317
+ }