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

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 (63) hide show
  1. package/.agents/rules/notification-templates.md +5 -0
  2. package/.agents/rules/shell.md +4 -2
  3. package/AGENTS.md +3 -1
  4. package/README.MD +11 -5
  5. package/client/components/body-portal.tsx +25 -0
  6. package/client/components/breadcrumbs.tsx +13 -0
  7. package/client/components/flow-diagram.tsx +2 -2
  8. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +1 -1
  9. package/client/layouts/app-layout.tsx +8 -4
  10. package/client/layouts/components/header-actions.tsx +0 -2
  11. package/client/layouts/components/sidebar-footer.tsx +18 -25
  12. package/client/layouts/components/user-menu.tsx +6 -13
  13. package/client/lib/navigation-transform.ts +156 -22
  14. package/client/locales/system/en-US.ts +8 -6
  15. package/client/locales/system/settings-en-US.ts +17 -0
  16. package/client/locales/system/settings-zh-CN.ts +15 -0
  17. package/client/locales/system/zh-CN.ts +8 -6
  18. package/client/pages/auth/register.tsx +39 -22
  19. package/client/pages/auth/shared.tsx +1 -3
  20. package/client/pages/settings/hooks.tsx +3 -68
  21. package/client/pages/settings/notification-routes.ts +109 -0
  22. package/client/pages/settings/notification-templates.tsx +26 -0
  23. package/client/pages/settings/permission-management-page.tsx +15 -90
  24. package/client/pages/settings/routes.ts +96 -98
  25. package/client/pages/settings/settings-home.tsx +109 -0
  26. package/client/pages/settings/settings-module-page.tsx +158 -0
  27. package/client/pages/settings/shell-routes.ts +53 -0
  28. package/client/routes.ts +17 -1
  29. package/client/routing/app-router.tsx +1 -14
  30. package/client/routing/client-route.tsx +16 -9
  31. package/client/routing/route-context.tsx +30 -3
  32. package/client/routing/route-leave-boundary.tsx +249 -0
  33. package/client/routing/route-navigation.ts +72 -8
  34. package/client/routing/route-tree.tsx +7 -12
  35. package/client/routing/standalone-page-layout.tsx +0 -5
  36. package/client/runtime.ts +2 -2
  37. package/client/theme/theme-settings.tsx +60 -162
  38. package/config.example.yml +51 -32
  39. package/package.json +28 -28
  40. package/server/config/auth.ts +2 -2
  41. package/server/config/index.ts +3 -0
  42. package/server/config/mailers.ts +11 -0
  43. package/server/providers/index.ts +2 -0
  44. package/server/providers/notification-management.ts +27 -0
  45. package/tests/client/account-permissions.test.tsx +1 -1
  46. package/tests/client/client-auth.test.tsx +10 -1
  47. package/tests/client/client-routes.test.ts +137 -4
  48. package/tests/client/client-shell.test.tsx +29 -10
  49. package/tests/client/client-theme.test.tsx +118 -39
  50. package/tests/client/foundation-integration.test.ts +38 -17
  51. package/tests/client/header-hover.test.tsx +33 -5
  52. package/tests/client/layout-i18n.test.tsx +9 -4
  53. package/tests/client/permission-management-tabs.test.tsx +421 -16
  54. package/tests/client/registration-feedback.test.tsx +143 -0
  55. package/tests/client/settings-home.test.tsx +110 -0
  56. package/tests/client/settings-navigation.test.ts +230 -0
  57. package/tests/client/standalone-page-layout.test.tsx +45 -0
  58. package/tests/client/user-menu-sign-out.test.tsx +38 -14
  59. package/tests/client/user-menu.test.tsx +65 -0
  60. package/tests/fixtures/theme-tokens.tsx +13 -1
  61. package/tests/server/auth-email-config.test.ts +51 -17
  62. package/tests/server/notification-templates.test.ts +144 -0
  63. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -0,0 +1,143 @@
1
+ import {
2
+ AuthenticationProvider,
3
+ GuestAuthentication,
4
+ authenticationClientToken,
5
+ } from '@nocobase/app-plugin-authentication/client';
6
+ import { authorizationClientToken } from '@nocobase/app-plugin-authorization/client';
7
+ import { reactProviders } from '@nocobase/app-plugin-authorization/client/react-providers';
8
+ import { renderWithApp } from '@nocobase/app-testing/client';
9
+ import { act, fireEvent, screen } from '@testing-library/react';
10
+ import { Route, Routes } from 'react-router';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import enUS from '../../client/locales/en-US.js';
13
+ import zhCN from '../../client/locales/zh-CN.js';
14
+ import RegisterPage from '../../client/pages/auth/register.js';
15
+
16
+ const AuthorizationProvider = reactProviders[0]!.component;
17
+
18
+ async function renderRegistration(signedIn = false, locale = 'en-US') {
19
+ let registered = false;
20
+ const refresh = Promise.withResolvers<void>();
21
+ const email = vi.fn(async () => {
22
+ registered = true;
23
+ return { token: null, user: { id: 'untrusted-id' } };
24
+ });
25
+ const getSession = vi.fn(async () => {
26
+ if (registered) await refresh.promise;
27
+ return {
28
+ data:
29
+ registered && signedIn
30
+ ? { user: { id: 'real-user' }, session: { id: 'real-session' } }
31
+ : null,
32
+ };
33
+ });
34
+ const view = await renderWithApp(
35
+ <AuthenticationProvider>
36
+ <AuthorizationProvider>
37
+ <Routes>
38
+ <Route
39
+ path='/register'
40
+ element={
41
+ <GuestAuthentication>
42
+ <RegisterPage />
43
+ </GuestAuthentication>
44
+ }
45
+ />
46
+ <Route path='/' element={<p>Signed-in home</p>} />
47
+ </Routes>
48
+ </AuthorizationProvider>
49
+ </AuthenticationProvider>,
50
+ {
51
+ route: '/register',
52
+ locale,
53
+ namespace: 'registration-test',
54
+ namespaces: { 'registration-test': locale === 'en-US' ? enUS : zhCN },
55
+ services: (app) => {
56
+ app.container.instance(authorizationClientToken, {
57
+ resetSession: vi.fn(),
58
+ });
59
+ app.container.instance(authenticationClientToken, {
60
+ getSession,
61
+ signUp: { email },
62
+ });
63
+ },
64
+ },
65
+ );
66
+ await screen.findByRole('heading', {
67
+ name: locale === 'en-US' ? 'Create an account' : '创建账户',
68
+ });
69
+ return { ...view, refresh, email };
70
+ }
71
+
72
+ function submitForm(container: HTMLElement, locale = 'en-US') {
73
+ const labels =
74
+ locale === 'en-US'
75
+ ? ['Name', 'Username', 'Email', 'Password', 'Confirm password']
76
+ : ['姓名', '用户名', '邮箱', '密码', '确认密码'];
77
+ const values = [
78
+ 'Person',
79
+ 'person',
80
+ 'person@example.test',
81
+ 'test-password',
82
+ 'test-password',
83
+ ];
84
+ labels.forEach((label, index) =>
85
+ fireEvent.change(screen.getByLabelText(label), {
86
+ target: { value: values[index] },
87
+ }),
88
+ );
89
+ fireEvent.submit(container.querySelector('form')!);
90
+ }
91
+
92
+ describe('registration feedback with the real session provider and guest guard', () => {
93
+ it.each(['en-US', 'zh-CN'])(
94
+ 'keeps accepted unsigned-in feedback after session refresh in %s',
95
+ async (locale) => {
96
+ const { container, refresh, email } = await renderRegistration(
97
+ false,
98
+ locale,
99
+ );
100
+ submitForm(container, locale);
101
+ try {
102
+ await vi.waitFor(() => expect(email).toHaveBeenCalledOnce());
103
+ expect(container.querySelector('form')).toBeInTheDocument();
104
+ } finally {
105
+ await act(async () => {
106
+ refresh.resolve();
107
+ });
108
+ }
109
+ const status = await screen.findByRole('status');
110
+ expect(status).toHaveTextContent(
111
+ locale === 'en-US'
112
+ ? 'the new details will not take effect'
113
+ : '新填写的信息不会生效',
114
+ );
115
+ expect(container.querySelector('form')).not.toBeInTheDocument();
116
+ expect(
117
+ screen.getByRole('link', {
118
+ name: locale === 'en-US' ? 'Sign in' : '登录',
119
+ }),
120
+ ).toHaveAttribute('href', '/login');
121
+ expect(
122
+ screen.getByRole('link', {
123
+ name: locale === 'en-US' ? 'Forgot password?' : '忘记密码?',
124
+ }),
125
+ ).toHaveAttribute('href', '/forgot-password');
126
+ expect(status).not.toHaveTextContent(
127
+ /account (?:created|has been created)|email (?:sent|has been sent)|账户已创建|邮件已发送/i,
128
+ );
129
+ },
130
+ );
131
+
132
+ it('uses the refreshed session to leave the guest page', async () => {
133
+ const { container, refresh } = await renderRegistration(true);
134
+ submitForm(container);
135
+ await act(async () => {
136
+ refresh.resolve();
137
+ });
138
+ expect(await screen.findByText('Signed-in home')).toBeVisible();
139
+ expect(
140
+ screen.queryByText('Registration request submitted'),
141
+ ).not.toBeInTheDocument();
142
+ });
143
+ });
@@ -0,0 +1,110 @@
1
+ import packageMetadata from '../../package.json' with { type: 'json' };
2
+ import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
3
+ import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
4
+ import { screen, waitFor, within } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import type { ReactNode } from 'react';
7
+ import { Route, Routes, useLocation } from 'react-router';
8
+ import { expect, it } from 'vitest';
9
+ import { Breadcrumbs } from '../../client/components/breadcrumbs.js';
10
+ import SettingsHomePage from '../../client/pages/settings/settings-home.js';
11
+ import settingsRoutes from '../../client/pages/settings/routes.js';
12
+ import shellRoutes from '../../client/pages/settings/shell-routes.js';
13
+ import { RouteTreeProvider } from '../../client/routing/route-context.js';
14
+ import { useRouteNavigation } from '../../client/routing/route-navigation.js';
15
+
16
+ const { routes } = resolveAppClientContributions([
17
+ {
18
+ packageName: packageMetadata.name,
19
+ source: 'application',
20
+ routes: [shellRoutes, settingsRoutes],
21
+ },
22
+ ]);
23
+
24
+ function Shell({ children }: { children: ReactNode }) {
25
+ const { denied, loading } = useRouteNavigation(routes);
26
+ return (
27
+ <RouteTreeProvider routes={routes} access={{ denied, loading }}>
28
+ <Breadcrumbs denied={denied} />
29
+ {children}
30
+ </RouteTreeProvider>
31
+ );
32
+ }
33
+
34
+ function Where() {
35
+ return <output aria-label='Route'>{useLocation().pathname}</output>;
36
+ }
37
+
38
+ function mount(
39
+ options: Parameters<typeof renderAuthenticatedClient>[1] = {},
40
+ route = '/settings',
41
+ ) {
42
+ return renderAuthenticatedClient(
43
+ <Shell>
44
+ <Routes>
45
+ <Route path='/settings' element={<SettingsHomePage />} />
46
+ <Route path='*' element={<Where />} />
47
+ </Routes>
48
+ </Shell>,
49
+ { route, ...options },
50
+ );
51
+ }
52
+
53
+ it('groups the modules an administrator may open into categories of cards that open by keyboard', async () => {
54
+ const user = userEvent.setup();
55
+ await mount();
56
+ const account = await screen.findByRole('region', {
57
+ name: 'Accounts and permissions',
58
+ });
59
+ const cards = within(account).getAllByRole('link');
60
+ expect(cards.map((card) => card.textContent)).toEqual([
61
+ 'UsersManage accounts, access and active sessions.',
62
+ 'Permission managementSet what each role can open and do, and the data it can see.',
63
+ ]);
64
+ expect(cards[0]).toHaveAttribute('href', '/main/settings/users');
65
+ expect(cards[1]).toHaveAttribute('href', '/main/settings/roles');
66
+ // Categories without a module this viewer may open are not shown.
67
+ expect(screen.getAllByRole('region')).toHaveLength(1);
68
+ cards[1].focus();
69
+ await user.keyboard('{Enter}');
70
+ await waitFor(() =>
71
+ expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
72
+ '/settings/roles',
73
+ ),
74
+ );
75
+ });
76
+
77
+ it('shows a restricted viewer only what they may open, opening a tabbed module on its readable view', async () => {
78
+ await mount({
79
+ member: true,
80
+ grants: [
81
+ {
82
+ resource: { type: 'settings', id: 'authorization.default-access' },
83
+ actions: [{ action: 'read' }],
84
+ },
85
+ ],
86
+ });
87
+ const account = await screen.findByRole('region', {
88
+ name: 'Accounts and permissions',
89
+ });
90
+ // Categories without a module this viewer may open are not shown.
91
+ expect(screen.getAllByRole('region')).toHaveLength(1);
92
+ const cards = within(account).getAllByRole('link');
93
+ expect(cards).toHaveLength(1);
94
+ expect(cards[0]).toHaveAttribute('href', '/main/settings/default-access');
95
+ });
96
+
97
+ it('explains when there is nothing to manage', async () => {
98
+ await mount({ member: true, locale: 'zh-CN' });
99
+ expect(await screen.findByText('暂时没有你可以管理的设置。')).toBeVisible();
100
+ expect(screen.getByRole('heading', { name: '系统设置' })).toBeVisible();
101
+ });
102
+
103
+ it('links the System settings breadcrumb back to the home from a module view', async () => {
104
+ await mount({}, '/settings/roles');
105
+ const trail = await screen.findByRole('navigation', { name: 'Breadcrumb' });
106
+ expect(
107
+ within(trail).getByRole('link', { name: 'System settings' }),
108
+ ).toHaveAttribute('href', '/main/settings');
109
+ expect(within(trail).getByText('Roles and permissions')).toBeVisible();
110
+ });
@@ -0,0 +1,230 @@
1
+ import {
2
+ defineAppRoutes,
3
+ resolveAppClientContributions,
4
+ type AppClientRegisteredRoute,
5
+ } from '@nocobase/app-client/plugins';
6
+ import { describe, expect, it } from 'vitest';
7
+ import foundation from '@nocobase/app-plugin-foundation/client/routes';
8
+ import settings from '../../client/pages/settings/routes.js';
9
+ import shell from '../../client/pages/settings/shell-routes.js';
10
+ import { createApplicationRoutes } from '../../client/routes.js';
11
+ import {
12
+ buildRouteNavigation,
13
+ buildSettingsCatalog,
14
+ filterRouteTree,
15
+ selectedNavigationId,
16
+ routeKey,
17
+ } from '../../client/routing/route-navigation.js';
18
+
19
+ const componentLoader = async () => ({ default: () => null });
20
+ const app = '@nocobase/app-template-default';
21
+
22
+ function resolve(...extra: ReturnType<typeof defineAppRoutes>[]) {
23
+ return resolveAppClientContributions([
24
+ {
25
+ packageName: app,
26
+ source: 'application',
27
+ routes: [shell, settings, ...extra],
28
+ },
29
+ ]).routes;
30
+ }
31
+
32
+ function find(
33
+ routes: readonly AppClientRegisteredRoute[],
34
+ name: string,
35
+ ): AppClientRegisteredRoute {
36
+ for (const route of routes) {
37
+ if (route.name === name) return route;
38
+ const nested = route.children && find(route.children, name);
39
+ if (nested) return nested;
40
+ }
41
+ return undefined as unknown as AppClientRegisteredRoute;
42
+ }
43
+
44
+ const catalog = (
45
+ routes: readonly AppClientRegisteredRoute[],
46
+ denied = new Set<string>(),
47
+ ) =>
48
+ buildSettingsCatalog(routes, denied).map((category) => [
49
+ category.route.name,
50
+ category.modules.map((module) => [module.route.name, module.target.path]),
51
+ ]);
52
+
53
+ describe('System settings navigation', () => {
54
+ it('keeps the existing URLs and authorization of the settings pages', () => {
55
+ const routes = resolve();
56
+ expect(
57
+ [
58
+ 'settingsHome',
59
+ 'settingsUsers',
60
+ 'settingsRoles',
61
+ 'settingsDefaultAccess',
62
+ ].map((name) => {
63
+ const route = find(routes, name);
64
+ return [name, route.path, route.authz];
65
+ }),
66
+ ).toEqual([
67
+ ['settingsHome', '/settings', 'skip'],
68
+ [
69
+ 'settingsUsers',
70
+ '/settings/users',
71
+ { resource: { type: 'user', id: '*' }, action: 'read' },
72
+ ],
73
+ [
74
+ 'settingsRoles',
75
+ '/settings/roles',
76
+ {
77
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
78
+ action: 'read',
79
+ },
80
+ ],
81
+ [
82
+ 'settingsDefaultAccess',
83
+ '/settings/default-access',
84
+ {
85
+ resource: { type: 'settings', id: 'authorization.default-access' },
86
+ action: 'read',
87
+ },
88
+ ],
89
+ ]);
90
+ expect(find(routes, 'settingsUsers').id).toBe(`${app}:settingsUsers`);
91
+ });
92
+
93
+ it('shows one sidebar entry that opens the home and stays selected on every settings page', () => {
94
+ const routes = resolve();
95
+ const menu = buildRouteNavigation(routes, new Set());
96
+ expect(menu.map(({ route }) => [route.name, route.path])).toEqual([
97
+ ['settingsHome', '/settings'],
98
+ ]);
99
+ expect(menu[0].children).toEqual([]);
100
+ expect(menu[0].route.navigation?.title).toBe('settings.title');
101
+ const entry = routeKey(menu[0].route);
102
+ for (const path of [
103
+ '/settings',
104
+ '/settings/users',
105
+ '/settings/users/create',
106
+ '/settings/roles/member/edit',
107
+ '/settings/default-access',
108
+ ])
109
+ expect(selectedNavigationId(routes, path)).toBe(entry);
110
+ });
111
+
112
+ it('groups modules into categories, opens a tabbed module on its first readable tab and hides empty categories', () => {
113
+ const routes = resolve();
114
+ expect(catalog(routes)).toEqual([
115
+ [
116
+ 'settingsAccount',
117
+ [
118
+ ['settingsUsers', '/settings/users'],
119
+ ['permissionManagement', '/settings/roles'],
120
+ ],
121
+ ],
122
+ ]);
123
+ const roles = find(routes, 'settingsRoles');
124
+ expect(catalog(routes, new Set([routeKey(roles)]))).toEqual([
125
+ [
126
+ 'settingsAccount',
127
+ [
128
+ ['settingsUsers', '/settings/users'],
129
+ ['permissionManagement', '/settings/default-access'],
130
+ ],
131
+ ],
132
+ ]);
133
+ const denied = new Set(
134
+ ['settingsUsers', 'settingsRoles', 'settingsDefaultAccess'].map((name) =>
135
+ routeKey(find(routes, name)),
136
+ ),
137
+ );
138
+ expect(catalog(routes, denied)).toEqual([]);
139
+ // Without a module to open, the sidebar entry disappears too.
140
+ expect(buildRouteNavigation(routes, denied)).toEqual([]);
141
+ });
142
+
143
+ it('adds a later module with several views to its category without another sidebar entry', () => {
144
+ const routes = resolve(
145
+ defineAppRoutes([
146
+ {
147
+ parent: 'settingsSystem',
148
+ name: 'notificationManagement',
149
+ navigation: {
150
+ title: 'Notifications',
151
+ description: 'Channels and delivery records.',
152
+ },
153
+ breadcrumb: { title: 'Notifications' },
154
+ children: [
155
+ {
156
+ name: 'notificationLogs',
157
+ path: '/notifications/logs',
158
+ authz: {
159
+ resource: { type: 'page', id: 'notification.logs' },
160
+ action: 'access',
161
+ },
162
+ navigation: { title: 'Delivery logs' },
163
+ componentLoader,
164
+ },
165
+ ],
166
+ },
167
+ ]),
168
+ );
169
+ expect(find(routes, 'notificationLogs').path).toBe(
170
+ '/settings/notifications/logs',
171
+ );
172
+ expect(catalog(routes).at(-1)).toEqual([
173
+ 'settingsSystem',
174
+ [['notificationManagement', '/settings/notifications/logs']],
175
+ ]);
176
+ expect(buildRouteNavigation(routes, new Set())).toHaveLength(1);
177
+ expect(selectedNavigationId(routes, '/settings/notifications/logs')).toBe(
178
+ routeKey(buildRouteNavigation(routes, new Set())[0].route),
179
+ );
180
+ });
181
+
182
+ it('places a plugin module registered outside the categories under System', () => {
183
+ const routes = resolveAppClientContributions([
184
+ { packageName: app, source: 'application', routes: [shell] },
185
+ {
186
+ packageName: '@nocobase/app-plugin-foundation',
187
+ source: 'plugin',
188
+ routes: foundation,
189
+ },
190
+ ]).routes;
191
+ // The bare shell: only App Foundation, which only an unrestricted administrator may open.
192
+ const menu = buildRouteNavigation(routes, new Set());
193
+ expect(menu.map(({ route }) => route.name)).toEqual(['settingsHome']);
194
+ expect(catalog(routes)).toEqual([
195
+ ['settingsSystem', [['foundation', '/foundation']]],
196
+ ]);
197
+ // The signed-in shell receives the tree filtered to its pages; empty categories keep their titles there.
198
+ const signedIn = filterRouteTree(
199
+ routes,
200
+ (route) => route.auth === 'required',
201
+ );
202
+ expect(catalog(signedIn)).toEqual([
203
+ ['settingsSystem', [['foundation', '/foundation']]],
204
+ ]);
205
+ const page = find(routes, 'foundation');
206
+ expect(page.navigation?.description).toBe('nav.foundationDescription');
207
+ expect(buildRouteNavigation(routes, new Set([routeKey(page)]))).toEqual([]);
208
+ });
209
+
210
+ it('shows the notification template view as a System card once an invitation mailer is configured', () => {
211
+ const routes = resolveAppClientContributions([
212
+ {
213
+ packageName: app,
214
+ source: 'application',
215
+ routes: createApplicationRoutes({
216
+ notificationTemplates: { invitationConfigured: true },
217
+ }),
218
+ },
219
+ ]).routes;
220
+ expect(catalog(routes)).toEqual([
221
+ [
222
+ 'settingsSystem',
223
+ [['notificationManagement', '/settings/notifications/templates']],
224
+ ],
225
+ ]);
226
+ expect(
227
+ buildRouteNavigation(routes, new Set()).map(({ route }) => route.name),
228
+ ).toEqual(['home', 'settingsHome']);
229
+ });
230
+ });
@@ -0,0 +1,45 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { MemoryRouter, Route, Routes } from 'react-router';
3
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
4
+
5
+ import { StandalonePageLayout } from '../../client/routing/standalone-page-layout.js';
6
+ import { AppThemeProvider } from '../../client/theme/index.js';
7
+
8
+ beforeEach(() => {
9
+ // jsdom does not implement the media queries used by the real theme provider.
10
+ vi.stubGlobal('matchMedia', () => ({
11
+ matches: false,
12
+ addListener() {},
13
+ removeListener() {},
14
+ }));
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals();
19
+ document.documentElement.removeAttribute('class');
20
+ document.documentElement.removeAttribute('style');
21
+ document.documentElement.removeAttribute('data-theme');
22
+ });
23
+
24
+ it.each(['/login', '/device'])(
25
+ 'renders standalone content at %s without an account menu',
26
+ (path) => {
27
+ render(
28
+ <AppThemeProvider>
29
+ <MemoryRouter initialEntries={[path]}>
30
+ <Routes>
31
+ <Route element={<StandalonePageLayout />}>
32
+ <Route path={path} element={<h1>Standalone page</h1>} />
33
+ </Route>
34
+ </Routes>
35
+ </MemoryRouter>
36
+ </AppThemeProvider>,
37
+ );
38
+ expect(
39
+ screen.getByRole('heading', { name: 'Standalone page' }),
40
+ ).toBeVisible();
41
+ expect(
42
+ screen.queryByRole('button', { name: 'Theme' }),
43
+ ).not.toBeInTheDocument();
44
+ },
45
+ );
@@ -1,9 +1,19 @@
1
+ import { AppThemeProvider } from '../../client/theme/index.js';
1
2
  import { act, fireEvent, screen, waitFor } from '@testing-library/react';
2
3
  import userEvent from '@testing-library/user-event';
3
- import { describe, expect, it } from 'vitest';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { UserMenu } from '../../client/layouts/components/user-menu.tsx';
5
6
  import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
6
7
 
8
+ // jsdom has no media queries; the real account menu includes the theme provider.
9
+ beforeEach(() => {
10
+ vi.stubGlobal('matchMedia', () => ({
11
+ matches: false,
12
+ addListener() {},
13
+ removeListener() {},
14
+ }));
15
+ });
16
+
7
17
  async function openAccountMenu(): Promise<void> {
8
18
  act(() => screen.getByRole('button', { name: 'Open account menu' }).focus());
9
19
  await userEvent.keyboard('{Enter}');
@@ -15,7 +25,11 @@ async function signOutFromMenu() {
15
25
 
16
26
  describe('account menu sign out', () => {
17
27
  it('ends the server session and refreshes the displayed identity', async () => {
18
- const view = await renderAuthenticatedClient(<UserMenu />);
28
+ const view = await renderAuthenticatedClient(
29
+ <AppThemeProvider>
30
+ <UserMenu />
31
+ </AppThemeProvider>,
32
+ );
19
33
  await signOutFromMenu();
20
34
  await waitFor(() => expect(view.identity().session).toBeNull());
21
35
  expect(
@@ -26,13 +40,18 @@ describe('account menu sign out', () => {
26
40
  expect(screen.queryByText(view.session.user.email)).not.toBeInTheDocument();
27
41
  });
28
42
  it('reports a rejected origin without pretending the session ended', async () => {
29
- const view = await renderAuthenticatedClient(<UserMenu />, {
30
- beforeRequest(request) {
31
- // Exercise the real origin guard and error resolver, not a fabricated authentication result.
32
- if (request.url.endsWith('/sign-out'))
33
- request.headers.set('origin', 'https://untrusted.example');
43
+ const view = await renderAuthenticatedClient(
44
+ <AppThemeProvider>
45
+ <UserMenu />
46
+ </AppThemeProvider>,
47
+ {
48
+ beforeRequest(request) {
49
+ // Exercise the real origin guard and error resolver, not a fabricated authentication result.
50
+ if (request.url.endsWith('/sign-out'))
51
+ request.headers.set('origin', 'https://untrusted.example');
52
+ },
34
53
  },
35
- });
54
+ );
36
55
  await signOutFromMenu();
37
56
  await waitFor(() => expect(view.toasts()).toHaveLength(1));
38
57
  expect(view.toasts()[0]).toMatchObject({
@@ -50,13 +69,18 @@ describe('account menu sign out', () => {
50
69
  });
51
70
  it('reports a network failure and successfully retries sign-out', async () => {
52
71
  let offline = true;
53
- const view = await renderAuthenticatedClient(<UserMenu />, {
54
- beforeRequest(request) {
55
- // Network loss cannot be produced by the in-process server.
56
- if (offline && request.url.endsWith('/sign-out'))
57
- throw new TypeError('Failed to fetch');
72
+ const view = await renderAuthenticatedClient(
73
+ <AppThemeProvider>
74
+ <UserMenu />
75
+ </AppThemeProvider>,
76
+ {
77
+ beforeRequest(request) {
78
+ // Network loss cannot be produced by the in-process server.
79
+ if (offline && request.url.endsWith('/sign-out'))
80
+ throw new TypeError('Failed to fetch');
81
+ },
58
82
  },
59
- });
83
+ );
60
84
  await signOutFromMenu();
61
85
  await waitFor(() => expect(view.toasts()).toHaveLength(1));
62
86
  expect(view.toasts()[0]).toMatchObject({
@@ -0,0 +1,65 @@
1
+ import { AppThemeProvider } from '../../client/theme/index.js';
2
+ import { act, screen, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { beforeEach, expect, it, vi } from 'vitest';
5
+
6
+ import { UserMenu } from '../../client/layouts/components/user-menu.js';
7
+ import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
8
+
9
+ // jsdom has no media queries; the real account menu includes the theme provider.
10
+ beforeEach(() => {
11
+ vi.stubGlobal('matchMedia', () => ({
12
+ matches: false,
13
+ addListener() {},
14
+ removeListener() {},
15
+ }));
16
+ });
17
+
18
+ it('uses CJK name endings in the avatar while retaining the full account name and picture', async () => {
19
+ const view = await renderAuthenticatedClient(
20
+ <AppThemeProvider>
21
+ <UserMenu />
22
+ </AppThemeProvider>,
23
+ );
24
+ const user = userEvent.setup();
25
+ const trigger = screen.getByRole('button', { name: 'Open account menu' });
26
+ await user.tab();
27
+ for (const [name, expected] of [
28
+ ['周建国', '建国'],
29
+ ['欧阳娜娜', '娜娜'],
30
+ ['李雷', '李雷'],
31
+ ['周 建国', '建国'],
32
+ ['Li Lei', 'LL'],
33
+ ['周', '周'],
34
+ ] as const) {
35
+ await act(async () => {
36
+ const response = await view.identity().client.updateUser({ name });
37
+ expect(response.error).toBeNull();
38
+ await view.identity().refresh();
39
+ });
40
+ expect(trigger).toHaveTextContent(expected);
41
+ await user.keyboard('{Enter}');
42
+ expect(
43
+ within(screen.getByRole('menu')).getByText(name, { exact: true }),
44
+ ).toBeVisible();
45
+ await user.keyboard('{Escape}');
46
+ }
47
+ await act(async () => {
48
+ const response = await view.identity().client.updateUser({ name: '' });
49
+ expect(response.error).toBeNull();
50
+ await view.identity().refresh();
51
+ });
52
+ expect(trigger).toHaveTextContent(view.session.user.email[0]!.toUpperCase());
53
+ await act(async () => {
54
+ const response = await view
55
+ .identity()
56
+ .client.updateUser({ image: 'https://example.test/avatar.png' });
57
+ expect(response.error).toBeNull();
58
+ await view.identity().refresh();
59
+ });
60
+ expect(trigger.querySelector('img')).toHaveAttribute(
61
+ 'src',
62
+ 'https://example.test/avatar.png',
63
+ );
64
+ expect(trigger).not.toHaveTextContent('周');
65
+ });