@shipfox/client-onboarding 5.0.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.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-check.log +24 -0
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/workspace-setup-route.d.ts +9 -0
- package/dist/workspace-setup-route.d.ts.map +1 -0
- package/dist/workspace-setup-route.js +128 -0
- package/dist/workspace-setup-route.js.map +1 -0
- package/package.json +66 -0
- package/src/index.ts +4 -0
- package/src/workspace-setup-route.test.tsx +561 -0
- package/src/workspace-setup-route.ts +157 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +31 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import {
|
|
4
|
+
dismissModelProviderOnboarding,
|
|
5
|
+
modelProviderConfigsQueryOptions,
|
|
6
|
+
} from '@shipfox/client-agent';
|
|
7
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
8
|
+
import {sourceConnectionsQueryOptions} from '@shipfox/client-integrations';
|
|
9
|
+
import {projectExistenceQueryOptions} from '@shipfox/client-projects';
|
|
10
|
+
import {
|
|
11
|
+
WorkspaceLayoutErrorRoute,
|
|
12
|
+
WorkspaceSetupPending,
|
|
13
|
+
type WorkspaceSetupState,
|
|
14
|
+
} from '@shipfox/client-shell/runtime';
|
|
15
|
+
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
16
|
+
import {afterEach, beforeEach, describe, expect, test, vi} from '@shipfox/vitest/vi';
|
|
17
|
+
import {QueryClient} from '@tanstack/react-query';
|
|
18
|
+
import {
|
|
19
|
+
createMemoryHistory,
|
|
20
|
+
createRootRouteWithContext,
|
|
21
|
+
createRoute,
|
|
22
|
+
createRouter,
|
|
23
|
+
Outlet,
|
|
24
|
+
RouterProvider,
|
|
25
|
+
useRouteContext,
|
|
26
|
+
} from '@tanstack/react-router';
|
|
27
|
+
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
|
28
|
+
import {loadWorkspaceSetupRoute} from './workspace-setup-route.js';
|
|
29
|
+
|
|
30
|
+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
|
|
31
|
+
|
|
32
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
|
33
|
+
return new Response(JSON.stringify(body), {
|
|
34
|
+
status: 200,
|
|
35
|
+
headers: {'content-type': 'application/json'},
|
|
36
|
+
...init,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sourceConnection(overrides: {lifecycle_status?: string} = {}) {
|
|
41
|
+
return {
|
|
42
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
43
|
+
workspace_id: WORKSPACE_ID,
|
|
44
|
+
provider: 'github',
|
|
45
|
+
external_account_id: 'acct',
|
|
46
|
+
slug: 'github_acct',
|
|
47
|
+
display_name: 'GitHub',
|
|
48
|
+
lifecycle_status: 'active',
|
|
49
|
+
capabilities: ['source_control'],
|
|
50
|
+
created_at: new Date().toISOString(),
|
|
51
|
+
updated_at: new Date().toISOString(),
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The query cache stores the mapped domain shape (camelCase), not the wire
|
|
57
|
+
// DTO `sourceConnection()` mocks for fetch responses — keep this seed in sync
|
|
58
|
+
// with `toIntegrationConnection` in client-integrations.
|
|
59
|
+
function cachedSourceConnection() {
|
|
60
|
+
return {
|
|
61
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
62
|
+
workspaceId: WORKSPACE_ID,
|
|
63
|
+
provider: 'github',
|
|
64
|
+
externalAccountId: 'acct',
|
|
65
|
+
slug: 'github_acct',
|
|
66
|
+
displayName: 'GitHub',
|
|
67
|
+
lifecycleStatus: 'active' as const,
|
|
68
|
+
capabilities: ['source_control' as const],
|
|
69
|
+
createdAt: new Date().toISOString(),
|
|
70
|
+
updatedAt: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface SetupFetchOptions {
|
|
75
|
+
projects?: unknown[];
|
|
76
|
+
connections?: unknown[];
|
|
77
|
+
providerConfigs?: unknown[];
|
|
78
|
+
defaultProviderId?: string | null;
|
|
79
|
+
projectsFail?: boolean;
|
|
80
|
+
connectionsFail?: boolean;
|
|
81
|
+
providerConfigsFail?: boolean;
|
|
82
|
+
projectsPending?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function setupFetch(options: SetupFetchOptions = {}) {
|
|
86
|
+
const {
|
|
87
|
+
projects = [],
|
|
88
|
+
connections = [],
|
|
89
|
+
providerConfigs = [modelProviderConfig()],
|
|
90
|
+
defaultProviderId = 'anthropic',
|
|
91
|
+
projectsFail = false,
|
|
92
|
+
connectionsFail = false,
|
|
93
|
+
providerConfigsFail = false,
|
|
94
|
+
projectsPending = false,
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
return vi.fn((input: RequestInfo | URL) => {
|
|
98
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
99
|
+
if (url.includes('/projects?')) {
|
|
100
|
+
if (projectsPending) return new Promise<Response>(() => undefined);
|
|
101
|
+
if (projectsFail) return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
102
|
+
return Promise.resolve(jsonResponse({projects, next_cursor: null}));
|
|
103
|
+
}
|
|
104
|
+
if (url.includes('/integration-connections?')) {
|
|
105
|
+
if (connectionsFail)
|
|
106
|
+
return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
107
|
+
return Promise.resolve(jsonResponse({connections}));
|
|
108
|
+
}
|
|
109
|
+
if (url.endsWith('/agent/model-providers')) {
|
|
110
|
+
if (providerConfigsFail)
|
|
111
|
+
return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
112
|
+
return Promise.resolve(
|
|
113
|
+
jsonResponse({configs: providerConfigs, default_provider_id: defaultProviderId}),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return Promise.resolve(jsonResponse({}, {status: 404}));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderSetupRoute(
|
|
121
|
+
path: string,
|
|
122
|
+
fetchImpl: ReturnType<typeof setupFetch>,
|
|
123
|
+
options: {seedQueryClient?: (queryClient: QueryClient) => void} = {},
|
|
124
|
+
) {
|
|
125
|
+
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
126
|
+
options.seedQueryClient?.(queryClient);
|
|
127
|
+
|
|
128
|
+
const rootRoute = createRootRouteWithContext<{queryClient: QueryClient}>()({
|
|
129
|
+
component: Outlet,
|
|
130
|
+
});
|
|
131
|
+
const guardedRoute = (routePath: string, label: string) =>
|
|
132
|
+
createRoute({
|
|
133
|
+
getParentRoute: () => rootRoute,
|
|
134
|
+
path: routePath,
|
|
135
|
+
beforeLoad: ({context, location, params}) =>
|
|
136
|
+
loadWorkspaceSetupRoute({
|
|
137
|
+
queryClient: context.queryClient,
|
|
138
|
+
workspaceId: (params as {wid: string}).wid,
|
|
139
|
+
pathname: location.pathname,
|
|
140
|
+
}),
|
|
141
|
+
pendingComponent: FullPageLoader,
|
|
142
|
+
errorComponent: WorkspaceLayoutErrorRoute,
|
|
143
|
+
component: () => <GuardedRoute label={label} />,
|
|
144
|
+
});
|
|
145
|
+
const routeTree = rootRoute.addChildren([
|
|
146
|
+
guardedRoute('/workspaces/$wid', 'Workspace home'),
|
|
147
|
+
guardedRoute('/workspaces/$wid/model-provider', 'Model provider onboarding'),
|
|
148
|
+
guardedRoute('/workspaces/$wid/integrations', 'VCS onboarding'),
|
|
149
|
+
guardedRoute('/workspaces/$wid/integrations/gitea', 'Gitea install'),
|
|
150
|
+
guardedRoute('/workspaces/$wid/projects/new', 'Create project'),
|
|
151
|
+
guardedRoute('/workspaces/$wid/settings/agents', 'Settings agents'),
|
|
152
|
+
guardedRoute('/workspaces/$wid/settings/integrations', 'Settings integrations'),
|
|
153
|
+
]);
|
|
154
|
+
const router = createRouter({
|
|
155
|
+
defaultPendingMs: 0,
|
|
156
|
+
history: createMemoryHistory({initialEntries: [path]}),
|
|
157
|
+
routeTree,
|
|
158
|
+
context: {queryClient},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
queryClient,
|
|
165
|
+
router,
|
|
166
|
+
...render(<RouterProvider router={router} context={{queryClient}} />),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function GuardedRoute({label}: {label: string}) {
|
|
171
|
+
const setupState = useRouteContext({strict: false}) as WorkspaceSetupState;
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<>
|
|
175
|
+
<div data-testid="project-navigation">
|
|
176
|
+
{setupState.hideProjectNavigation ? 'hidden' : 'visible'}
|
|
177
|
+
</div>
|
|
178
|
+
<main>{label}</main>
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function projectStub() {
|
|
184
|
+
return {id: 'project-1', workspace_id: WORKSPACE_ID, name: 'Platform'};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function modelProviderConfig() {
|
|
188
|
+
return {
|
|
189
|
+
provider_id: 'anthropic',
|
|
190
|
+
default_model: null,
|
|
191
|
+
key_fingerprints: {'credential:api_key': '...abcd'},
|
|
192
|
+
created_at: new Date().toISOString(),
|
|
193
|
+
updated_at: new Date().toISOString(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function calledUrls(fetchImpl: ReturnType<typeof setupFetch>) {
|
|
198
|
+
return fetchImpl.mock.calls.map(([input]) =>
|
|
199
|
+
input instanceof Request ? input.url : String(input),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
describe('workspace setup route hook', () => {
|
|
204
|
+
afterEach(cleanup);
|
|
205
|
+
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
window.localStorage.clear();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('renders a loader while the project existence query is pending', async () => {
|
|
211
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, setupFetch({projectsPending: true}));
|
|
212
|
+
|
|
213
|
+
expect(await screen.findByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
214
|
+
expect(screen.queryByText('Workspace home')).not.toBeInTheDocument();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('renders a retryable setup-status error when the project query fails', async () => {
|
|
218
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, setupFetch({projectsFail: true}));
|
|
219
|
+
|
|
220
|
+
expect(await screen.findByText('Could not load workspace setup')).toBeInTheDocument();
|
|
221
|
+
expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
|
|
222
|
+
expect(screen.queryByText('Workspace home')).not.toBeInTheDocument();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('allows normal workspace content and skips source connections when a project exists', async () => {
|
|
226
|
+
const fetchImpl = setupFetch({projects: [projectStub()]});
|
|
227
|
+
|
|
228
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl);
|
|
229
|
+
|
|
230
|
+
expect(await screen.findByText('Workspace home')).toBeInTheDocument();
|
|
231
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('visible');
|
|
232
|
+
expect(calledUrls(fetchImpl).some((url) => url.includes('/integration-connections?'))).toBe(
|
|
233
|
+
false,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('keeps cached completed-workspace state when the project refetch fails', async () => {
|
|
238
|
+
const fetchImpl = setupFetch({projectsFail: true});
|
|
239
|
+
|
|
240
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl, {
|
|
241
|
+
seedQueryClient: (queryClient) => {
|
|
242
|
+
queryClient.setQueryData(projectExistenceQueryOptions(WORKSPACE_ID).queryKey, {
|
|
243
|
+
projects: [projectStub()] as never,
|
|
244
|
+
next_cursor: null,
|
|
245
|
+
});
|
|
246
|
+
// Existence has a freshness window, so explicit invalidation forces the
|
|
247
|
+
// refetch whose failure exercises the cached fallback.
|
|
248
|
+
void queryClient.invalidateQueries({
|
|
249
|
+
queryKey: projectExistenceQueryOptions(WORKSPACE_ID).queryKey,
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(await screen.findByText('Workspace home')).toBeInTheDocument();
|
|
255
|
+
await waitFor(() =>
|
|
256
|
+
expect(calledUrls(fetchImpl).some((url) => url.includes('/projects?'))).toBe(true),
|
|
257
|
+
);
|
|
258
|
+
expect(screen.queryByText('Could not load workspace setup')).not.toBeInTheDocument();
|
|
259
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('visible');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('sends a workspace with no active VCS to source-control onboarding', async () => {
|
|
263
|
+
const fetchImpl = setupFetch({
|
|
264
|
+
connections: [sourceConnection({lifecycle_status: 'disabled'})],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl);
|
|
268
|
+
|
|
269
|
+
expect(await screen.findByText('VCS onboarding')).toBeInTheDocument();
|
|
270
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('hidden');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('keeps integrations settings available before source-control onboarding', async () => {
|
|
274
|
+
renderSetupRoute(
|
|
275
|
+
`/workspaces/${WORKSPACE_ID}/settings/integrations`,
|
|
276
|
+
setupFetch({connections: []}),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(await screen.findByText('Settings integrations')).toBeInTheDocument();
|
|
280
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('hidden');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('sends a workspace with active VCS and no project to project creation', async () => {
|
|
284
|
+
renderSetupRoute(
|
|
285
|
+
`/workspaces/${WORKSPACE_ID}/integrations`,
|
|
286
|
+
setupFetch({connections: [sourceConnection()]}),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
expect(await screen.findByText('Create project')).toBeInTheDocument();
|
|
290
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('hidden');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('sends a source-connected workspace with no provider config to provider onboarding', async () => {
|
|
294
|
+
renderSetupRoute(
|
|
295
|
+
`/workspaces/${WORKSPACE_ID}/integrations`,
|
|
296
|
+
setupFetch({
|
|
297
|
+
connections: [sourceConnection()],
|
|
298
|
+
providerConfigs: [],
|
|
299
|
+
defaultProviderId: null,
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(await screen.findByText('Model provider onboarding')).toBeInTheDocument();
|
|
304
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('hidden');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('keeps the provider onboarding route available while provider setup is pending', async () => {
|
|
308
|
+
renderSetupRoute(
|
|
309
|
+
`/workspaces/${WORKSPACE_ID}/model-provider`,
|
|
310
|
+
setupFetch({
|
|
311
|
+
connections: [sourceConnection()],
|
|
312
|
+
providerConfigs: [],
|
|
313
|
+
defaultProviderId: null,
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(await screen.findByText('Model provider onboarding')).toBeInTheDocument();
|
|
318
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('hidden');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('keeps model provider settings available before first project creation', async () => {
|
|
322
|
+
renderSetupRoute(
|
|
323
|
+
`/workspaces/${WORKSPACE_ID}/settings/agents`,
|
|
324
|
+
setupFetch({
|
|
325
|
+
connections: [sourceConnection()],
|
|
326
|
+
providerConfigs: [],
|
|
327
|
+
defaultProviderId: null,
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(await screen.findByText('Settings agents')).toBeInTheDocument();
|
|
332
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('hidden');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('uses a dismissed provider step without fetching provider configs', async () => {
|
|
336
|
+
const fetchImpl = setupFetch({connections: [sourceConnection()]});
|
|
337
|
+
dismissModelProviderOnboarding(WORKSPACE_ID);
|
|
338
|
+
|
|
339
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl);
|
|
340
|
+
|
|
341
|
+
expect(await screen.findByText('Create project')).toBeInTheDocument();
|
|
342
|
+
expect(calledUrls(fetchImpl).some((url) => url.endsWith('/agent/model-providers'))).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('uses cached provider config state when the provider config refetch fails', async () => {
|
|
346
|
+
const fetchImpl = setupFetch({connections: [sourceConnection()], providerConfigsFail: true});
|
|
347
|
+
|
|
348
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl, {
|
|
349
|
+
seedQueryClient: (queryClient) => {
|
|
350
|
+
queryClient.setQueryData(modelProviderConfigsQueryOptions(WORKSPACE_ID).queryKey, {
|
|
351
|
+
configs: [modelProviderConfig()] as never,
|
|
352
|
+
default_provider_id: 'anthropic',
|
|
353
|
+
default_harness_id: null,
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(await screen.findByText('Create project')).toBeInTheDocument();
|
|
359
|
+
await waitFor(() =>
|
|
360
|
+
expect(calledUrls(fetchImpl).some((url) => url.endsWith('/agent/model-providers'))).toBe(
|
|
361
|
+
true,
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
expect(screen.queryByText('Could not load workspace setup')).not.toBeInTheDocument();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('fails open to project creation when provider configs cannot load without cache', async () => {
|
|
368
|
+
renderSetupRoute(
|
|
369
|
+
`/workspaces/${WORKSPACE_ID}`,
|
|
370
|
+
setupFetch({connections: [sourceConnection()], providerConfigsFail: true}),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
expect(await screen.findByText('Create project')).toBeInTheDocument();
|
|
374
|
+
expect(screen.queryByText('Could not load workspace setup')).not.toBeInTheDocument();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('keeps cached source-connection state when the source refetch fails', async () => {
|
|
378
|
+
const fetchImpl = setupFetch({connectionsFail: true});
|
|
379
|
+
|
|
380
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl, {
|
|
381
|
+
seedQueryClient: (queryClient) => {
|
|
382
|
+
queryClient.setQueryData(sourceConnectionsQueryOptions(WORKSPACE_ID).queryKey, [
|
|
383
|
+
cachedSourceConnection(),
|
|
384
|
+
]);
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(await screen.findByText('Create project')).toBeInTheDocument();
|
|
389
|
+
await waitFor(() =>
|
|
390
|
+
expect(calledUrls(fetchImpl).some((url) => url.includes('/integration-connections?'))).toBe(
|
|
391
|
+
true,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
expect(screen.queryByText('Could not load workspace setup')).not.toBeInTheDocument();
|
|
395
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('hidden');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('redirects the completed workspace integrations index to settings integrations', async () => {
|
|
399
|
+
renderSetupRoute(
|
|
400
|
+
`/workspaces/${WORKSPACE_ID}/integrations`,
|
|
401
|
+
setupFetch({projects: [projectStub()]}),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
expect(await screen.findByText('Settings integrations')).toBeInTheDocument();
|
|
405
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('visible');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('keeps completed workspace integration install routes available', async () => {
|
|
409
|
+
renderSetupRoute(
|
|
410
|
+
`/workspaces/${WORKSPACE_ID}/integrations/gitea`,
|
|
411
|
+
setupFetch({projects: [projectStub()]}),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(await screen.findByText('Gitea install')).toBeInTheDocument();
|
|
415
|
+
expect(screen.getByTestId('project-navigation')).toHaveTextContent('visible');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('renders a retryable setup-status error when the source connection query fails', async () => {
|
|
419
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, setupFetch({connectionsFail: true}));
|
|
420
|
+
|
|
421
|
+
expect(await screen.findByText('Could not load workspace setup')).toBeInTheDocument();
|
|
422
|
+
expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
|
|
423
|
+
expect(screen.queryByText('Workspace home')).not.toBeInTheDocument();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('recovers workspace content when Retry re-runs the route load', async () => {
|
|
427
|
+
let projectAttempts = 0;
|
|
428
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
429
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
430
|
+
if (url.includes('/projects?')) {
|
|
431
|
+
projectAttempts += 1;
|
|
432
|
+
if (projectAttempts === 1)
|
|
433
|
+
return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
434
|
+
return Promise.resolve(jsonResponse({projects: [projectStub()], next_cursor: null}));
|
|
435
|
+
}
|
|
436
|
+
return Promise.resolve(jsonResponse({}, {status: 404}));
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl);
|
|
440
|
+
|
|
441
|
+
fireEvent.click(await screen.findByRole('button', {name: 'Retry'}));
|
|
442
|
+
|
|
443
|
+
expect(await screen.findByText('Workspace home')).toBeInTheDocument();
|
|
444
|
+
expect(screen.queryByText('Could not load workspace setup')).not.toBeInTheDocument();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('re-evaluates the guard on navigation between children without refetching fresh existence', async () => {
|
|
448
|
+
const fetchImpl = setupFetch({projects: [projectStub()]});
|
|
449
|
+
const {router} = renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl);
|
|
450
|
+
|
|
451
|
+
expect(await screen.findByText('Workspace home')).toBeInTheDocument();
|
|
452
|
+
|
|
453
|
+
await act(async () => {
|
|
454
|
+
await router.navigate({
|
|
455
|
+
to: '/workspaces/$wid/integrations',
|
|
456
|
+
params: {wid: WORKSPACE_ID},
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(await screen.findByText('Settings integrations')).toBeInTheDocument();
|
|
461
|
+
expect(calledUrls(fetchImpl).filter((url) => url.includes('/projects?'))).toHaveLength(1);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('refetches stale project existence so external project creation can complete setup', async () => {
|
|
465
|
+
let projects = [] as unknown[];
|
|
466
|
+
const fetchImpl = setupFetch({connections: [sourceConnection()]});
|
|
467
|
+
fetchImpl.mockImplementation((input: RequestInfo | URL) => {
|
|
468
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
469
|
+
if (url.includes('/projects?')) {
|
|
470
|
+
return Promise.resolve(jsonResponse({projects, next_cursor: null}));
|
|
471
|
+
}
|
|
472
|
+
if (url.includes('/integration-connections?')) {
|
|
473
|
+
return Promise.resolve(jsonResponse({connections: [sourceConnection()]}));
|
|
474
|
+
}
|
|
475
|
+
return Promise.resolve(jsonResponse({}, {status: 404}));
|
|
476
|
+
});
|
|
477
|
+
const {queryClient, router} = renderSetupRoute(`/workspaces/${WORKSPACE_ID}`, fetchImpl);
|
|
478
|
+
|
|
479
|
+
expect(await screen.findByText('Create project')).toBeInTheDocument();
|
|
480
|
+
projects = [projectStub()];
|
|
481
|
+
queryClient.setQueryData(
|
|
482
|
+
projectExistenceQueryOptions(WORKSPACE_ID).queryKey,
|
|
483
|
+
{projects: [], next_cursor: null},
|
|
484
|
+
{updatedAt: Date.now() - 31_000},
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
await act(async () => {
|
|
488
|
+
await router.navigate({
|
|
489
|
+
to: '/workspaces/$wid',
|
|
490
|
+
params: {wid: WORKSPACE_ID},
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
expect(await screen.findByText('Workspace home')).toBeInTheDocument();
|
|
495
|
+
expect(calledUrls(fetchImpl).filter((url) => url.includes('/projects?'))).toHaveLength(2);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('uses a generic workspace error for descendant route failures', async () => {
|
|
499
|
+
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
500
|
+
const rootRoute = createRootRouteWithContext<{queryClient: QueryClient}>()({
|
|
501
|
+
component: Outlet,
|
|
502
|
+
});
|
|
503
|
+
const throwingRoute = createRoute({
|
|
504
|
+
getParentRoute: () => rootRoute,
|
|
505
|
+
path: '/workspaces/$wid',
|
|
506
|
+
beforeLoad: () => ({hideProjectNavigation: false}),
|
|
507
|
+
errorComponent: WorkspaceLayoutErrorRoute,
|
|
508
|
+
component: ThrowingWorkspaceRoute,
|
|
509
|
+
});
|
|
510
|
+
const router = createRouter({
|
|
511
|
+
defaultPendingMs: 0,
|
|
512
|
+
history: createMemoryHistory({initialEntries: [`/workspaces/${WORKSPACE_ID}`]}),
|
|
513
|
+
routeTree: rootRoute.addChildren([throwingRoute]),
|
|
514
|
+
context: {queryClient},
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
render(<RouterProvider router={router} context={{queryClient}} />);
|
|
518
|
+
|
|
519
|
+
expect(await screen.findByText('Could not load workspace')).toBeInTheDocument();
|
|
520
|
+
expect(screen.queryByText('Could not load workspace setup')).not.toBeInTheDocument();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('shows the pending loader while setup state is unresolved (auth-loading parity)', async () => {
|
|
524
|
+
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
525
|
+
const rootRoute = createRootRouteWithContext<{queryClient: QueryClient}>()({
|
|
526
|
+
component: Outlet,
|
|
527
|
+
});
|
|
528
|
+
const layoutRoute = createRoute({
|
|
529
|
+
getParentRoute: () => rootRoute,
|
|
530
|
+
path: '/workspaces/$wid',
|
|
531
|
+
beforeLoad: () => undefined,
|
|
532
|
+
component: WorkspaceLayoutParity,
|
|
533
|
+
});
|
|
534
|
+
const router = createRouter({
|
|
535
|
+
defaultPendingMs: 0,
|
|
536
|
+
history: createMemoryHistory({initialEntries: [`/workspaces/${WORKSPACE_ID}`]}),
|
|
537
|
+
routeTree: rootRoute.addChildren([layoutRoute]),
|
|
538
|
+
context: {queryClient},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
render(<RouterProvider router={router} context={{queryClient}} />);
|
|
542
|
+
|
|
543
|
+
expect(await screen.findByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
544
|
+
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// Mirrors the _layout route component: while auth is loading, beforeLoad returns
|
|
549
|
+
// undefined, so the route context carries no setup state and the layout shows a
|
|
550
|
+
// loader instead of protected content. Guards the TanStack contract (undefined
|
|
551
|
+
// beforeLoad leaves the key absent) that the production sentinel depends on.
|
|
552
|
+
function WorkspaceLayoutParity() {
|
|
553
|
+
const setupState = useRouteContext({strict: false}) as Partial<WorkspaceSetupState>;
|
|
554
|
+
if (setupState.hideProjectNavigation === undefined) return <WorkspaceSetupPending />;
|
|
555
|
+
|
|
556
|
+
return <main>Protected content</main>;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function ThrowingWorkspaceRoute(): never {
|
|
560
|
+
throw new Error('Descendant route failed');
|
|
561
|
+
}
|