@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.89

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 (70) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/shell.md +8 -2
  3. package/AGENTS.md +1 -1
  4. package/client/components/back-button.tsx +37 -12
  5. package/client/components/brand-loading-mark.tsx +5 -2
  6. package/client/components/brand-mark-geometry.ts +3 -3
  7. package/client/components/breadcrumbs.tsx +47 -3
  8. package/client/components/data-table/data-table.tsx +90 -19
  9. package/client/components/data-table/pagination.tsx +17 -2
  10. package/client/components/list-feedback.tsx +31 -22
  11. package/client/components/loading.tsx +127 -16
  12. package/client/components/pending-button.tsx +111 -0
  13. package/client/components/role-list.tsx +7 -0
  14. package/client/components/route-child-page.tsx +72 -1
  15. package/client/components/route-overlay.tsx +149 -15
  16. package/client/components/task-progress.tsx +155 -0
  17. package/client/components/use-route-overlay.ts +8 -11
  18. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  19. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  20. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  29. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  30. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  31. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  32. package/client/layouts/app-layout.tsx +0 -4
  33. package/client/locales/system/en-US.ts +19 -0
  34. package/client/locales/system/zh-CN.ts +19 -0
  35. package/client/pages/settings/default-access.tsx +1 -1
  36. package/client/pages/settings/roles.tsx +27 -27
  37. package/client/pages/settings/routes.ts +15 -7
  38. package/client/pages/settings/user-detail.tsx +3 -17
  39. package/client/pages/settings/user-form.tsx +9 -15
  40. package/client/pages/settings/users.tsx +11 -27
  41. package/client/routing/client-route.tsx +199 -37
  42. package/client/routing/navigation-destination.ts +42 -0
  43. package/client/routing/route-leave-boundary.tsx +13 -1
  44. package/client/styles.css +18 -1
  45. package/package.json +8 -8
  46. package/tests/client/auth-i18n.test.tsx +1 -1
  47. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  48. package/tests/client/back-button.test.tsx +34 -0
  49. package/tests/client/client-route-recovery.test.tsx +93 -1
  50. package/tests/client/list-feedback.test.tsx +5 -5
  51. package/tests/client/list-loading.test.tsx +397 -0
  52. package/tests/client/loading.test.tsx +20 -11
  53. package/tests/client/manual-list-retry.test.tsx +2 -2
  54. package/tests/client/navigation-parent.test.tsx +172 -0
  55. package/tests/client/pending-button.test.tsx +106 -0
  56. package/tests/client/permission-management-tabs.test.tsx +48 -12
  57. package/tests/client/primitive-labels.test.tsx +2 -2
  58. package/tests/client/role-return.test.tsx +21 -3
  59. package/tests/client/roles.test.tsx +84 -3
  60. package/tests/client/route-leave-recheck.test.tsx +74 -0
  61. package/tests/client/route-presentation.test.tsx +557 -0
  62. package/tests/client/session-preparation.test.tsx +20 -17
  63. package/tests/client/settings-continuity.test.tsx +35 -11
  64. package/tests/client/settings-home.test.tsx +4 -2
  65. package/tests/client/settings-idle-list.test.tsx +124 -0
  66. package/tests/client/user-drawer.test.tsx +3 -4
  67. package/tests/fixtures/dialog-footer.html +12 -0
  68. package/tests/fixtures/dialog-footer.tsx +215 -0
  69. package/tests/helpers/authenticated-client.tsx +70 -22
  70. package/tests/playwright/dialog-footer.test.ts +227 -0
@@ -1,4 +1,7 @@
1
- import { renderSettingsClient as renderWithApp } from '../helpers/settings-client.js';
1
+ import type { ReactElement } from 'react';
2
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
3
+ import { RouteTreeProvider } from '../../client/routing/route-context.js';
4
+ import { renderSettingsClient } from '../helpers/settings-client.js';
2
5
  import { answerApi } from '@nocobase/app-testing/client';
3
6
  import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
4
7
  import {
@@ -23,6 +26,28 @@ import { BackButton } from '../../client/components/back-button.js';
23
26
  import enUS from '../../client/locales/en-US.js';
24
27
  import zhCN from '../../client/locales/zh-CN.js';
25
28
  const namespace = '@nocobase/continuity-test';
29
+ const routes: AppClientRegisteredRoute[] = [
30
+ '/settings/roles',
31
+ '/settings/processes/loan',
32
+ ].map((path) => ({
33
+ id: path,
34
+ name: path,
35
+ path,
36
+ packageName: namespace,
37
+ source: 'application',
38
+ auth: 'required',
39
+ authz: 'skip',
40
+ componentLoader: async () => ({ default: () => null }),
41
+ }));
42
+ // Return targets are registered fixture pages, as they are in the running shell.
43
+ const renderWithApp = (
44
+ ui: ReactElement,
45
+ options: Parameters<typeof renderSettingsClient>[1] = {},
46
+ ) =>
47
+ renderSettingsClient(
48
+ <RouteTreeProvider routes={routes}>{ui}</RouteTreeProvider>,
49
+ options,
50
+ );
26
51
  const namespaces = {
27
52
  [namespace]: {
28
53
  'en-US': async () => ({ default: enUS }),
@@ -47,6 +72,7 @@ const options = {
47
72
  ],
48
73
  },
49
74
  };
75
+ const refreshing = enUS['status.refreshing'];
50
76
  const row = {
51
77
  id: 'alice',
52
78
  name: 'Alice',
@@ -77,9 +103,8 @@ it.each(['fast', 'slow'] as const)(
77
103
  await renderWithApp(
78
104
  <Routes>
79
105
  <Route path='/settings/users' element={<UsersPage />}>
80
- <Route path=':userId' element={<UserDetail />}>
81
- <Route path='edit' element={<UserForm />} />
82
- </Route>
106
+ <Route path=':userId' element={<UserDetail />} />
107
+ <Route path=':userId/edit' element={<UserForm />} />
83
108
  </Route>
84
109
  </Routes>,
85
110
  {
@@ -110,17 +135,17 @@ it.each(['fast', 'slow'] as const)(
110
135
  vi.useFakeTimers();
111
136
  try {
112
137
  await act(() => vi.advanceTimersByTimeAsync(1000));
113
- expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
138
+ expect(screen.queryByRole('status', { name: refreshing })).toBeNull();
114
139
  await act(async () => {
115
140
  fireEvent.click(
116
141
  screen.getByRole('button', { name: 'Cancel', exact: true }),
117
142
  );
118
143
  });
119
144
  expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
120
- expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
145
+ expect(screen.queryByRole('status', { name: refreshing })).toBeNull();
121
146
  if (speed === 'slow') {
122
147
  await act(() => vi.advanceTimersByTimeAsync(1000));
123
- expect(screen.getByText(enUS.settings.loading)).toBeVisible();
148
+ expect(screen.getByRole('status', { name: refreshing })).toBeVisible();
124
149
  expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
125
150
  }
126
151
  await act(async () => {
@@ -131,7 +156,7 @@ it.each(['fast', 'slow'] as const)(
131
156
  expect(
132
157
  screen.getByRole('link', { name: 'Alice refreshed' }),
133
158
  ).toBeVisible();
134
- expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
159
+ expect(screen.queryByRole('status', { name: refreshing })).toBeNull();
135
160
  } finally {
136
161
  vi.useRealTimers();
137
162
  }
@@ -320,9 +345,8 @@ it.each(['/settings/users', '/users'])(
320
345
  <>
321
346
  <Routes>
322
347
  <Route path={base} element={<UsersPage />}>
323
- <Route path=':userId' element={<UserDetail />}>
324
- <Route path='edit' element={<UserForm />} />
325
- </Route>
348
+ <Route path=':userId' element={<UserDetail />} />
349
+ <Route path=':userId/edit' element={<UserForm />} />
326
350
  </Route>
327
351
  </Routes>
328
352
  <Location />
@@ -1,5 +1,5 @@
1
1
  import packageMetadata from '../../package.json' with { type: 'json' };
2
- import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
2
+ import { createAuthenticatedClientRenderer } from '../helpers/authenticated-client.js';
3
3
  import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
4
4
  import { screen, waitFor, within } from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
@@ -13,6 +13,8 @@ import shellRoutes from '../../client/pages/settings/shell-routes.js';
13
13
  import { RouteTreeProvider } from '../../client/routing/route-context.js';
14
14
  import { useRouteNavigation } from '../../client/routing/route-navigation.js';
15
15
 
16
+ const renderAuthenticatedClient = createAuthenticatedClientRenderer();
17
+
16
18
  const { routes } = resolveAppClientContributions([
17
19
  {
18
20
  packageName: packageMetadata.name,
@@ -104,7 +106,7 @@ it('links the System settings breadcrumb back to the home from a module view', a
104
106
  await mount({}, '/settings/roles');
105
107
  const trail = await screen.findByRole('navigation', { name: 'Breadcrumb' });
106
108
  expect(
107
- within(trail).getByRole('link', { name: 'System settings' }),
109
+ await within(trail).findByRole('link', { name: 'System settings' }),
108
110
  ).toHaveAttribute('href', '/main/settings');
109
111
  expect(within(trail).getByText('Roles and permissions')).toBeVisible();
110
112
  });
@@ -0,0 +1,124 @@
1
+ import { answerApi } from '@nocobase/app-testing/client';
2
+ import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
+ import {
4
+ AuthorizationClient,
5
+ authorizationClientToken,
6
+ } from '@nocobase/app-plugin-authorization/client';
7
+ import { act, fireEvent, screen, waitFor } from '@testing-library/react';
8
+ import { Route, Routes, useNavigate } from 'react-router';
9
+ import { expect, it, vi } from 'vitest';
10
+ import { renderSettingsClient } from '../helpers/settings-client.js';
11
+ import UsersPage from '../../client/pages/settings/users.js';
12
+ import UserForm from '../../client/pages/settings/user-form.js';
13
+ import RolesPage from '../../client/pages/settings/roles.js';
14
+ import enUS from '../../client/locales/en-US.js';
15
+
16
+ const namespace = '@nocobase/idle-list-test';
17
+ const services = (app: ClientApplication) =>
18
+ app.container.instance(
19
+ authorizationClientToken,
20
+ new AuthorizationClient(app.container.resolve(apiClientToken)),
21
+ );
22
+ function ReturnToRoles() {
23
+ const navigate = useNavigate();
24
+ return (
25
+ <button onClick={() => void navigate('/settings/roles')}>
26
+ Return to roles
27
+ </button>
28
+ );
29
+ }
30
+
31
+ it.each(['users', 'roles'] as const)(
32
+ 'keeps a cold %s child route silent and starts real loading on return',
33
+ async (kind) => {
34
+ const listPath =
35
+ kind === 'users' ? 'users' : 'authorization/permissionSets';
36
+ let finish!: (value: unknown) => void;
37
+ const response = new Promise((resolve) => {
38
+ finish = resolve;
39
+ });
40
+ const calls = vi.fn(({ path }: { path: string }) => {
41
+ if (path === 'authorization/permissions')
42
+ return { data: { unrestricted: true, permissions: [] } };
43
+ if (path === 'users/options') return { data: { roleScopes: [] } };
44
+ if (path === listPath) return response;
45
+ return new Response(null, { status: 404 });
46
+ });
47
+ await renderSettingsClient(
48
+ <Routes>
49
+ <Route path='/settings/users' element={<UsersPage />}>
50
+ <Route path='create' element={<UserForm />} />
51
+ </Route>
52
+ <Route path='/settings/roles' element={<RolesPage />}>
53
+ <Route path='create' element={<ReturnToRoles />} />
54
+ </Route>
55
+ </Routes>,
56
+ {
57
+ route: `/settings/${kind}/create`,
58
+ namespace,
59
+ namespaces: {
60
+ [namespace]: { 'en-US': async () => ({ default: enUS }) },
61
+ },
62
+ services,
63
+ fetch: answerApi(calls),
64
+ },
65
+ );
66
+ // The real Users form is interactive; Roles uses the real list with a nested route.
67
+ const close = await screen.findByRole('button', {
68
+ name: kind === 'users' ? 'Cancel' : 'Return to roles',
69
+ exact: true,
70
+ });
71
+ vi.useFakeTimers();
72
+ try {
73
+ await act(() => vi.advanceTimersByTimeAsync(10_000));
74
+ expect(
75
+ calls.mock.calls.filter(([call]) => call.path === listPath),
76
+ ).toHaveLength(0);
77
+ expect(document.querySelector('[data-slot="loading"]')).toBeNull();
78
+ expect(screen.queryByText(enUS.pendingFeedback.longWait)).toBeNull();
79
+ expect(screen.queryByText(enUS.listFeedback.empty)).toBeNull();
80
+ await act(async () => {
81
+ fireEvent.click(close);
82
+ });
83
+ expect(
84
+ calls.mock.calls.filter(([call]) => call.path === listPath),
85
+ ).toHaveLength(1);
86
+ await act(() => vi.advanceTimersByTimeAsync(299));
87
+ expect(screen.queryByRole('status')).toBeNull();
88
+ await act(() => vi.advanceTimersByTimeAsync(1));
89
+ expect(screen.getAllByRole('status')).toHaveLength(1);
90
+ expect(screen.getByRole('status')).toHaveTextContent(
91
+ enUS.listFeedback.loading,
92
+ );
93
+ await act(async () => {
94
+ finish(
95
+ kind === 'users'
96
+ ? {
97
+ data: [
98
+ {
99
+ id: 'alice',
100
+ name: 'Alice',
101
+ username: 'alice',
102
+ email: 'alice@example.test',
103
+ roleScopes: {},
104
+ disabledAt: null,
105
+ },
106
+ ],
107
+ meta: { total: 1 },
108
+ }
109
+ : { data: [{ key: 'team', title: 'Team', grants: [] }] },
110
+ );
111
+ await response;
112
+ });
113
+ } finally {
114
+ finish({ data: [], meta: { total: 0 } });
115
+ vi.useRealTimers();
116
+ }
117
+ await waitFor(() =>
118
+ expect(
119
+ screen.getByText(kind === 'users' ? 'Alice' : 'Team', { exact: true }),
120
+ ).toBeVisible(),
121
+ );
122
+ expect(screen.queryByRole('status')).toBeNull();
123
+ },
124
+ );
@@ -51,9 +51,8 @@ function Pages({ base }: { readonly base: string }) {
51
51
  <Routes>
52
52
  <Route path={base} element={<UsersPage />}>
53
53
  <Route path='create' element={<UserForm />} />
54
- <Route path=':userId' element={<UserDetail />}>
55
- <Route path='edit' element={<UserForm />} />
56
- </Route>
54
+ <Route path=':userId' element={<UserDetail />} />
55
+ <Route path=':userId/edit' element={<UserForm />} />
57
56
  </Route>
58
57
  <Route path='*' element={<h1>Fallback home</h1>} />
59
58
  </Routes>
@@ -451,7 +450,7 @@ it('blocks closing and duplicate submission until the unified save completes', a
451
450
  await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
452
451
  await waitFor(() => expect(name).toBeDisabled());
453
452
  expect(
454
- screen.getByRole('button', { name: enUS.settings.saving }),
453
+ await screen.findByRole('button', { name: enUS.settings.saving }),
455
454
  ).toBeDisabled();
456
455
  expect(
457
456
  screen.getByRole('button', { name: 'Cancel', exact: true }),
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Dialog footer regression</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/tests/fixtures/dialog-footer.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,215 @@
1
+ import { useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { createMemoryRouter, Link, Outlet, RouterProvider } from 'react-router';
4
+ import {
5
+ createTestI18nRuntime,
6
+ TestI18nProvider,
7
+ } from '@nocobase/i18n/testing';
8
+ import { Button } from '../../client/components/ui/button';
9
+ import { Input } from '../../client/components/ui/input';
10
+ import { Spinner } from '../../client/components/ui/spinner';
11
+ import {
12
+ Dialog,
13
+ DialogClose,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from '../../client/components/ui/dialog';
20
+ import {
21
+ AlertDialog,
22
+ AlertDialogAction,
23
+ AlertDialogCancel,
24
+ AlertDialogContent,
25
+ AlertDialogDescription,
26
+ AlertDialogFooter,
27
+ AlertDialogHeader,
28
+ AlertDialogTitle,
29
+ } from '../../client/components/ui/alert-dialog';
30
+ import { RouteDialog } from '../../client/components/route-dialog';
31
+ import { RouteDrawer } from '../../client/components/route-drawer';
32
+ import enUS from '../../client/locales/en-US';
33
+ import '../../client/styles.css';
34
+
35
+ // Exercise installed primitives, portals, links and route footers with the real
36
+ // application stylesheet. No backend or authentication is needed for this fixture.
37
+ const params = new URLSearchParams(location.search);
38
+ const kind = params.get('kind') ?? 'dialog';
39
+ const chinese = params.has('chinese');
40
+ const long = params.has('long');
41
+ const pending = params.has('pending');
42
+ const horizontal = params.has('horizontal');
43
+ document.documentElement.dataset.theme = params.get('preset') ?? 'default';
44
+ document.documentElement.classList.toggle('dark', params.has('dark'));
45
+ const runtime = await createTestI18nRuntime({
46
+ application: { namespace: '@nocobase/app-template-default', resources: enUS },
47
+ });
48
+ const cancel = chinese ? '取消并返回实例列表' : 'Cancel';
49
+ const submit = chinese
50
+ ? '确认创建这个具有较长中文名称的应用实例并继续配置'
51
+ : long
52
+ ? 'Create this application instance and continue configuring its workspace'
53
+ : 'Create instance';
54
+ const title = chinese ? '创建应用实例' : 'Create application instance';
55
+ const description = chinese
56
+ ? '输入实例名称,然后创建实例。'
57
+ : 'Enter a name to create an application instance.';
58
+
59
+ function Editor() {
60
+ return (
61
+ <form
62
+ id='instance-form'
63
+ onSubmit={(event) => {
64
+ event.preventDefault();
65
+ void router.navigate('/instances?created=1');
66
+ }}
67
+ >
68
+ <label htmlFor='instance-name'>
69
+ {chinese ? '实例名称' : 'Instance name'}
70
+ </label>
71
+ <Input id='instance-name' required defaultValue='Customer portal' />
72
+ {long && !kind.startsWith('alert') && (
73
+ <div className='h-[900px]' aria-hidden='true' />
74
+ )}
75
+ </form>
76
+ );
77
+ }
78
+
79
+ function Actions() {
80
+ return (
81
+ <>
82
+ {kind === 'dialog-close' ? (
83
+ <DialogClose
84
+ disabled={pending}
85
+ data-testid='cancel'
86
+ render={<Button variant='outline' />}
87
+ >
88
+ {cancel}
89
+ </DialogClose>
90
+ ) : (
91
+ <Button
92
+ variant='outline'
93
+ nativeButton={false}
94
+ disabled={pending}
95
+ data-testid='cancel'
96
+ render={<Link to='/instances?filter=recent' />}
97
+ >
98
+ {cancel}
99
+ </Button>
100
+ )}
101
+ <Button
102
+ type='submit'
103
+ form='instance-form'
104
+ disabled={pending}
105
+ aria-busy={pending}
106
+ data-testid='submit'
107
+ >
108
+ {pending && <Spinner data-icon='inline-start' />}
109
+ {pending ? 'Creating instance…' : submit}
110
+ </Button>
111
+ </>
112
+ );
113
+ }
114
+
115
+ export default function Overlay() {
116
+ const [open, setOpen] = useState(true);
117
+ if (kind === 'route' || kind === 'drawer') {
118
+ const Component = kind === 'drawer' ? RouteDrawer : RouteDialog;
119
+ return (
120
+ <Component
121
+ title={title}
122
+ description={description}
123
+ footer={<Actions />}
124
+ beforeClose={() => !pending}
125
+ >
126
+ <Editor />
127
+ </Component>
128
+ );
129
+ }
130
+ if (kind === 'alert' || kind === 'alert-small') {
131
+ return (
132
+ <AlertDialog
133
+ open={open}
134
+ onOpenChange={(value) => {
135
+ if (!pending) setOpen(value);
136
+ }}
137
+ >
138
+ <AlertDialogContent size={kind === 'alert-small' ? 'sm' : 'default'}>
139
+ <AlertDialogHeader>
140
+ <AlertDialogTitle>{title}</AlertDialogTitle>
141
+ <AlertDialogDescription>{description}</AlertDialogDescription>
142
+ </AlertDialogHeader>
143
+ <Editor />
144
+ <AlertDialogFooter
145
+ className={horizontal ? 'flex-row items-center' : undefined}
146
+ >
147
+ <AlertDialogCancel disabled={pending} data-testid='cancel'>
148
+ {cancel}
149
+ </AlertDialogCancel>
150
+ <AlertDialogAction
151
+ type='submit'
152
+ form='instance-form'
153
+ disabled={pending}
154
+ aria-busy={pending}
155
+ data-testid='submit'
156
+ >
157
+ {pending && <Spinner data-icon='inline-start' />}
158
+ {pending ? 'Creating instance…' : submit}
159
+ </AlertDialogAction>
160
+ </AlertDialogFooter>
161
+ </AlertDialogContent>
162
+ </AlertDialog>
163
+ );
164
+ }
165
+ return (
166
+ <Dialog
167
+ open={open}
168
+ onOpenChange={(value) => {
169
+ if (!pending) setOpen(value);
170
+ }}
171
+ >
172
+ <DialogContent className='flex flex-col overflow-hidden'>
173
+ <DialogHeader>
174
+ <DialogTitle>{title}</DialogTitle>
175
+ <DialogDescription>{description}</DialogDescription>
176
+ </DialogHeader>
177
+ <div className='min-h-0 overflow-y-auto'>
178
+ <Editor />
179
+ </div>
180
+ <DialogFooter
181
+ className={horizontal ? 'flex-row items-center' : undefined}
182
+ >
183
+ <Actions />
184
+ </DialogFooter>
185
+ </DialogContent>
186
+ </Dialog>
187
+ );
188
+ }
189
+
190
+ const router = createMemoryRouter(
191
+ [
192
+ {
193
+ path: '/instances',
194
+ element: (
195
+ <main className='p-6'>
196
+ <h1>Application instances</h1>
197
+ <p data-testid='location'>Instance list</p>
198
+ <Outlet />
199
+ </main>
200
+ ),
201
+ children: [{ path: 'new', element: <Overlay /> }],
202
+ },
203
+ ],
204
+ { initialEntries: ['/instances/new?filter=recent'] },
205
+ );
206
+ router.subscribe((state) => {
207
+ const output = document.querySelector('[data-testid=location]');
208
+ if (output)
209
+ output.textContent = state.location.pathname + state.location.search;
210
+ });
211
+ createRoot(document.getElementById('root')!).render(
212
+ <TestI18nProvider runtime={runtime}>
213
+ <RouterProvider router={router} />
214
+ </TestI18nProvider>,
215
+ );
@@ -1,6 +1,7 @@
1
1
  import type { ReactElement } from 'react';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { CookieJar } from 'jsdom';
3
- import { onTestFinished, vi } from 'vitest';
4
+ import { afterAll, beforeAll, onTestFinished, vi } from 'vitest';
4
5
  import { act, waitFor } from '@testing-library/react';
5
6
  import { createTestApp, type TestApp } from '@nocobase/app-testing/server';
6
7
  import { renderWithApp } from '@nocobase/app-testing/client';
@@ -36,27 +37,18 @@ export async function renderAuthenticatedClient(
36
37
  response: Response,
37
38
  ) => void | Promise<void>;
38
39
  } = {},
40
+ sharedServer?: TestApp,
39
41
  ) {
40
- const server = await createTestApp({
41
- createServer: createStandaloneServer,
42
- connections: ['main'],
43
- config: {
44
- logging: { enabled: false },
45
- app: { publicOrigin: 'http://localhost' },
46
- auth: {
47
- secret: 'test-auth-secret-at-least-32-characters',
48
- emailAndPassword: { disableSignUp: false },
49
- advanced: { disableOriginCheck: false, disableCSRFCheck: false },
50
- },
51
- },
52
- });
53
- onTestFinished(() => server.close());
42
+ const server = sharedServer ?? (await createClientTestServer());
43
+ if (!sharedServer) onTestFinished(() => server.close());
44
+ const identity = sharedServer ? randomUUID() : '';
45
+ const permissionSetKey = identity ? `test-access-${identity}` : 'test-access';
54
46
  const member = {
55
47
  name: 'Test member',
56
- email: 'member@example.test',
48
+ email: identity ? `member-${identity}@example.test` : 'member@example.test',
57
49
  password: 'test-member-password',
58
50
  };
59
- if (options.member) {
51
+ if (options.member || sharedServer) {
60
52
  const response = await server.request('/auth/sign-up/email', {
61
53
  method: 'POST',
62
54
  headers: {
@@ -68,22 +60,77 @@ export async function renderAuthenticatedClient(
68
60
  if (!response.ok)
69
61
  throw new Error(`Member registration failed: ${await response.text()}`);
70
62
  }
63
+ const authz = server.application.container.resolve(authorizationToken);
71
64
  const session = await signIn(
72
65
  server,
73
- options.member ? member : DEFAULT_ADMIN_CREDENTIALS,
66
+ options.member || sharedServer ? member : DEFAULT_ADMIN_CREDENTIALS,
74
67
  );
68
+ if (sharedServer && !options.member) {
69
+ await authz.permissionSets.assign({
70
+ id: randomUUID(),
71
+ permissionSet: 'root',
72
+ subject: { type: 'user', id: session.user.id },
73
+ });
74
+ }
75
75
  if (options.grants) {
76
- const authz = server.application.container.resolve(authorizationToken);
77
76
  await authz.permissionSets.create({
78
- key: 'test-access',
77
+ key: permissionSetKey,
79
78
  grants: options.grants,
80
79
  });
81
80
  await authz.permissionSets.assign({
82
- id: 'test-assignment',
83
- permissionSet: 'test-access',
81
+ id: identity ? `test-assignment-${identity}` : 'test-assignment',
82
+ permissionSet: permissionSetKey,
84
83
  subject: { type: 'user', id: session.user.id },
85
84
  });
86
85
  }
86
+ return renderClient(server, session, permissionSetKey, ui, options);
87
+ }
88
+
89
+ function createClientTestServer() {
90
+ return createTestApp({
91
+ createServer: createStandaloneServer,
92
+ connections: ['main'],
93
+ config: {
94
+ logging: { enabled: false },
95
+ app: { publicOrigin: 'http://localhost' },
96
+ auth: {
97
+ secret: 'test-auth-secret-at-least-32-characters',
98
+ emailAndPassword: { disableSignUp: false },
99
+ advanced: { disableOriginCheck: false, disableCSRFCheck: false },
100
+ },
101
+ },
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Reuse installation within a file whose tests only change their own identity's state.
107
+ * Each render gets a new user, session, grants, cookies and client. Tests that mutate
108
+ * shared application configuration or business data use renderAuthenticatedClient instead.
109
+ */
110
+ export function createAuthenticatedClientRenderer() {
111
+ let server: TestApp | undefined;
112
+ beforeAll(async () => {
113
+ server = await createClientTestServer();
114
+ });
115
+ afterAll(async () => {
116
+ await server?.close();
117
+ });
118
+ return (
119
+ ui: ReactElement,
120
+ options: Parameters<typeof renderAuthenticatedClient>[1] = {},
121
+ ) => {
122
+ if (!server) throw new Error('The test application has not started.');
123
+ return renderAuthenticatedClient(ui, options, server);
124
+ };
125
+ }
126
+
127
+ async function renderClient(
128
+ server: TestApp,
129
+ session: TestSession,
130
+ permissionSetKey: string,
131
+ ui: ReactElement,
132
+ options: NonNullable<Parameters<typeof renderAuthenticatedClient>[1]>,
133
+ ) {
87
134
  await options.prepare?.(server, session);
88
135
  const jar = new CookieJar();
89
136
  const root = `http://localhost${server.publicBasePath.replace(/\/$/u, '')}`;
@@ -135,6 +182,7 @@ export async function renderAuthenticatedClient(
135
182
  ...view,
136
183
  server,
137
184
  session,
185
+ permissionSetKey,
138
186
  identity: () => identity!,
139
187
  async signInAs(credentials: SignInCredentials) {
140
188
  const next = await signIn(server, credentials);