@nocobase/app-template-default 1.0.0-beta.79 → 1.0.0-beta.80

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 (84) hide show
  1. package/AGENTS.md +4 -4
  2. package/README.MD +4 -2
  3. package/package.json +8 -7
  4. package/tests/client/account-permissions.test.tsx +174 -0
  5. package/tests/{components → client}/app-layout-routes.test.tsx +25 -42
  6. package/tests/{logic → client}/app-locale-coverage.test.ts +1 -4
  7. package/tests/{logic → client}/client-plugin-registry.test.ts +1 -4
  8. package/tests/{logic → client}/client-route-recovery.test.tsx +27 -7
  9. package/tests/{components → client}/header-hover.test.tsx +4 -14
  10. package/tests/{components → client}/home.test.tsx +27 -41
  11. package/tests/client/inbox.test.tsx +94 -0
  12. package/tests/{components → client}/permission-management-tabs.test.tsx +23 -44
  13. package/tests/client/sidebar-permissions.test.tsx +157 -0
  14. package/tests/{logic → client}/theme-tokens.test.ts +6 -8
  15. package/tests/client/user-menu-sign-out.test.tsx +78 -0
  16. package/tests/{logic → database}/device-code-migration.test.ts +0 -1
  17. package/tests/helpers/authenticated-client.tsx +148 -0
  18. package/tests/{logic → project}/agents-md.test.ts +0 -2
  19. package/tests/{logic → project}/asset-url-build.test.ts +0 -1
  20. package/tests/{logic → project}/config-example.test.ts +0 -2
  21. package/tests/{logic → project}/dev-client-watch.test.ts +0 -2
  22. package/tests/{logic → project}/plugin-commands.test.ts +0 -2
  23. package/tests/{logic → project}/server-imports.test.ts +0 -1
  24. package/tests/{logic → project}/tailwind-sources.test.ts +0 -1
  25. package/tests/{logic → project}/template-package-name.test.ts +0 -2
  26. package/tests/{logic → server}/app-server.test.ts +0 -1
  27. package/tests/{logic → server}/authorization-assignment-safety.test.ts +0 -2
  28. package/tests/{logic → server}/config.test.ts +0 -2
  29. package/tests/{logic → server}/session-isolation.test.ts +0 -2
  30. package/tests/setup/application-environment.ts +8 -0
  31. package/tests/setup/client-config.ts +0 -2
  32. package/vitest.config.ts +38 -23
  33. package/tests/components/inbox.test.tsx +0 -124
  34. package/tests/components/sidebar-permissions.test.tsx +0 -108
  35. package/tests/logic/account-permissions.test.tsx +0 -204
  36. package/tests/logic/user-menu-sign-out.test.tsx +0 -107
  37. /package/tests/{components → client}/app-title.test.tsx +0 -0
  38. /package/tests/{components → client}/auth-i18n.test.tsx +0 -0
  39. /package/tests/{components → client}/back-button.test.tsx +0 -0
  40. /package/tests/{logic → client}/client-auth.test.tsx +0 -0
  41. /package/tests/{logic → client}/client-routes.test.ts +0 -0
  42. /package/tests/{logic → client}/client-runtime.test.ts +0 -0
  43. /package/tests/{logic → client}/client-shell.test.tsx +0 -0
  44. /package/tests/{logic → client}/client-theme.test.tsx +0 -0
  45. /package/tests/{components → client}/data-table.test.tsx +0 -0
  46. /package/tests/{components → client}/default-access-editor.test.tsx +0 -0
  47. /package/tests/{logic → client}/default-access-model.test.ts +0 -0
  48. /package/tests/{components → client}/default-access.test.tsx +0 -0
  49. /package/tests/{components → client}/file-preview.test.tsx +0 -0
  50. /package/tests/{logic → client}/foundation-integration.test.ts +0 -0
  51. /package/tests/{components → client}/language-switcher.test.tsx +0 -0
  52. /package/tests/{components → client}/layout-containers.test.tsx +0 -0
  53. /package/tests/{components → client}/layout-i18n.test.tsx +0 -0
  54. /package/tests/{components → client}/loading.test.tsx +0 -0
  55. /package/tests/{components → client}/navigation-menu.test.tsx +0 -0
  56. /package/tests/{components → client}/page-harness.test.tsx +0 -0
  57. /package/tests/{logic → client}/page-permission-navigation.test.ts +0 -0
  58. /package/tests/{logic → client}/page-permission-titles.test.ts +0 -0
  59. /package/tests/{components → client}/permission-editor.test.tsx +0 -0
  60. /package/tests/{components → client}/permission-filter-binding.test.tsx +0 -0
  61. /package/tests/{components → client}/permission-filter.test.tsx +0 -0
  62. /package/tests/{logic → client}/permission-management-routes.test.ts +0 -0
  63. /package/tests/{logic → client}/permission-workspace.test.ts +0 -0
  64. /package/tests/{components → client}/primitive-labels.test.tsx +0 -0
  65. /package/tests/{logic → client}/return-to.test.ts +0 -0
  66. /package/tests/{components → client}/role-list.test.tsx +0 -0
  67. /package/tests/{components → client}/roles.test.tsx +0 -0
  68. /package/tests/{logic → client}/route-navigation.test.ts +0 -0
  69. /package/tests/{logic → client}/route-overlay.test.tsx +0 -0
  70. /package/tests/{logic → client}/scrollbar.test.ts +0 -0
  71. /package/tests/{logic → client}/session-preparation.test.tsx +0 -0
  72. /package/tests/{components → client}/settings-continuity.test.tsx +0 -0
  73. /package/tests/{components → client}/settings-feedback.test.tsx +0 -0
  74. /package/tests/{logic → client}/settings-permissions.test.ts +0 -0
  75. /package/tests/{components → client}/settings.test.tsx +0 -0
  76. /package/tests/{components → client}/setup-readiness.test.tsx +0 -0
  77. /package/tests/{components → client}/sidebar-preference.test.tsx +0 -0
  78. /package/tests/{components → client}/status-badge.test.tsx +0 -0
  79. /package/tests/{components → client}/table-url-state.test.tsx +0 -0
  80. /package/tests/{logic → client}/theme-preferences.test.ts +0 -0
  81. /package/tests/{logic → client}/toaster.test.tsx +0 -0
  82. /package/tests/{components → client}/user-drawer.test.tsx +0 -0
  83. /package/tests/{components → client}/user-permissions.test.tsx +0 -0
  84. /package/tests/{logic → server}/lifecycle.test.ts +0 -0
@@ -0,0 +1,148 @@
1
+ import type { ReactElement } from 'react';
2
+ import { CookieJar } from 'jsdom';
3
+ import { onTestFinished, vi } from 'vitest';
4
+ import { act, waitFor } from '@testing-library/react';
5
+ import { createTestApp, type TestApp } from '@nocobase/app-testing/server';
6
+ import { renderWithApp } from '@nocobase/app-testing/client';
7
+ import authorization from '@nocobase/app-plugin-authorization/client/plugin';
8
+ import { authorizationToken } from '@nocobase/app-plugin-authorization/server';
9
+ import type { PermissionGrant } from '@nocobase/authorization/core';
10
+ import authentication from '@nocobase/app-plugin-authentication/client/plugin';
11
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
12
+ import {
13
+ signIn,
14
+ DEFAULT_ADMIN_CREDENTIALS,
15
+ type TestSession,
16
+ type SignInCredentials,
17
+ } from '@nocobase/app-plugin-authentication/testing';
18
+ import { createStandaloneServer } from '../../server/standalone.js';
19
+ import packageMetadata from '../../package.json' with { type: 'json' };
20
+ import enUS from '../../client/locales/en-US.js';
21
+ import zhCN from '../../client/locales/zh-CN.js';
22
+
23
+ /** Real application, authentication and cookies; only deliberate transport failures are injected by the caller. */
24
+ export async function renderAuthenticatedClient(
25
+ ui: ReactElement,
26
+ options: {
27
+ member?: boolean;
28
+ grants?: readonly PermissionGrant[];
29
+ route?: string;
30
+ locale?: string;
31
+ prepare?: (server: TestApp, session: TestSession) => Promise<void>;
32
+ namespaces?: NonNullable<Parameters<typeof renderWithApp>[1]>['namespaces'];
33
+ beforeRequest?: (request: Request) => void | Promise<void>;
34
+ afterResponse?: (
35
+ request: Request,
36
+ response: Response,
37
+ ) => void | Promise<void>;
38
+ } = {},
39
+ ) {
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());
54
+ const member = {
55
+ name: 'Test member',
56
+ email: 'member@example.test',
57
+ password: 'test-member-password',
58
+ };
59
+ if (options.member) {
60
+ const response = await server.request('/auth/sign-up/email', {
61
+ method: 'POST',
62
+ headers: {
63
+ 'content-type': 'application/json',
64
+ origin: 'http://localhost',
65
+ },
66
+ body: JSON.stringify(member),
67
+ });
68
+ if (!response.ok)
69
+ throw new Error(`Member registration failed: ${await response.text()}`);
70
+ }
71
+ const session = await signIn(
72
+ server,
73
+ options.member ? member : DEFAULT_ADMIN_CREDENTIALS,
74
+ );
75
+ if (options.grants) {
76
+ const authz = server.application.container.resolve(authorizationToken);
77
+ await authz.permissionSets.create({
78
+ key: 'test-access',
79
+ grants: options.grants,
80
+ });
81
+ await authz.permissionSets.assign({
82
+ id: 'test-assignment',
83
+ permissionSet: 'test-access',
84
+ subject: { type: 'user', id: session.user.id },
85
+ });
86
+ }
87
+ await options.prepare?.(server, session);
88
+ const jar = new CookieJar();
89
+ const root = `http://localhost${server.publicBasePath.replace(/\/$/u, '')}`;
90
+ for (const cookie of session.cookie.split('; ')) {
91
+ await jar.setCookie(`${cookie}; Path=/`, root);
92
+ }
93
+ const fetch: typeof globalThis.fetch = async (input, init) => {
94
+ const request = new Request(input, init);
95
+ request.headers.set('cookie', await jar.getCookieString(request.url));
96
+ request.headers.set('origin', 'http://localhost');
97
+ await options.beforeRequest?.(request);
98
+ const response = await server.fetch(request);
99
+ for (const cookie of response.headers.getSetCookie()) {
100
+ await jar.setCookie(cookie, request.url);
101
+ }
102
+ await options.afterResponse?.(request, response);
103
+ return response;
104
+ };
105
+ // Better Auth uses fetch directly; route it to the real in-process server with browser cookie handling.
106
+ vi.stubGlobal('fetch', fetch);
107
+ onTestFinished(() => {
108
+ vi.unstubAllGlobals();
109
+ });
110
+ let identity: ReturnType<typeof useAuthentication> | undefined;
111
+ function SessionObserver() {
112
+ identity = useAuthentication();
113
+ return ui;
114
+ }
115
+ const view = await renderWithApp(<SessionObserver />, {
116
+ plugins: [authentication(), authorization()],
117
+ namespace: packageMetadata.name,
118
+ namespaces: {
119
+ ...options.namespaces,
120
+ [packageMetadata.name]: {
121
+ 'en-US': async () => ({ default: enUS }),
122
+ 'zh-CN': async () => ({ default: zhCN }),
123
+ },
124
+ },
125
+ ...(options.route ? { route: options.route } : {}),
126
+ ...(options.locale ? { locale: options.locale } : {}),
127
+ server: { publicBasePath: server.publicBasePath, fetch },
128
+ });
129
+ await waitFor(() => {
130
+ if (identity?.isPending || identity?.session?.user.id !== session.user.id) {
131
+ throw new Error('The signed-in session has not reached the client.');
132
+ }
133
+ });
134
+ return {
135
+ ...view,
136
+ server,
137
+ session,
138
+ identity: () => identity!,
139
+ async signInAs(credentials: SignInCredentials) {
140
+ const next = await signIn(server, credentials);
141
+ await jar.removeAllCookies();
142
+ for (const cookie of next.cookie.split('; '))
143
+ await jar.setCookie(`${cookie}; Path=/`, root);
144
+ await act(() => identity!.refresh());
145
+ return next;
146
+ },
147
+ };
148
+ }
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
2
  import path from 'node:path';
5
3
  import { fileURLToPath } from 'node:url';
@@ -1,4 +1,3 @@
1
- // @vitest-environment node
2
1
  import {
3
2
  copyFile,
4
3
  mkdtemp,
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
2
 
5
3
  import os from 'node:os';
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import {
4
2
  copyFile,
5
3
  mkdir,
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  // Users develop, build and start their app through these scripts. Nothing at runtime depends on them, so dropping or
4
2
  // renaming one — a bad merge resolution did exactly that once — breaks the documented development steps silently: the app still
5
3
  // builds, starts, and passes every other test. These assertions are the alarm.
@@ -1,4 +1,3 @@
1
- // @vitest-environment node
2
1
  import { execFile } from 'node:child_process';
3
2
  import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
3
  import os from 'node:os';
@@ -1,5 +1,4 @@
1
1
  import clientPlugins from '../../client/plugins.js';
2
- // @vitest-environment node
3
2
 
4
3
  import {
5
4
  existsSync,
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { readFileSync, readdirSync, statSync } from 'node:fs';
4
2
  import path from 'node:path';
5
3
  import { fileURLToPath } from 'node:url';
@@ -1,6 +1,5 @@
1
1
  import { createApp } from '../../server/app.js';
2
2
  import authConfig from '../../server/config/auth.js';
3
- // @vitest-environment node
4
3
 
5
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
6
5
  import {
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { expect, it } from 'vitest';
4
2
  import { createAppTest } from '@nocobase/app-testing/server';
5
3
  import { createStandaloneServer } from '../../server/standalone.js';
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { fileURLToPath } from 'node:url';
4
2
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
5
3
  import os from 'node:os';
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
2
  import { tmpdir } from 'node:os';
5
3
  import path from 'node:path';
@@ -0,0 +1,8 @@
1
+ import { builtinEnvironments, type Environment } from 'vitest/runtime';
2
+
3
+ // Render in jsdom while loading the in-process application's Node workers as server modules.
4
+ export default {
5
+ ...builtinEnvironments.jsdom,
6
+ name: 'application',
7
+ viteEnvironment: 'ssr',
8
+ } satisfies Environment;
@@ -8,8 +8,6 @@ const RUNTIME_CONFIG_ELEMENT_ID = 'nocobase-runtime-config';
8
8
  * at `/main`. A test that needs other values removes it and renders its own.
9
9
  */
10
10
  function renderClientConfig(): void {
11
- // Server-side tests run under `@vitest-environment node`, where there is no page to render into.
12
- if (typeof document === 'undefined') return;
13
11
  if (document.getElementById(RUNTIME_CONFIG_ELEMENT_ID)) return;
14
12
  const element = document.createElement('script');
15
13
  element.id = RUNTIME_CONFIG_ELEMENT_ID;
package/vitest.config.ts CHANGED
@@ -1,34 +1,49 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
 
3
+ import { createNodeVitestConfig } from '@nocobase/dev-config/vitest/node';
3
4
  import { createReactVitestConfig } from '@nocobase/dev-config/vitest/react';
5
+ import { defineConfig } from 'vitest/config';
4
6
 
5
7
  const root = fileURLToPath(new URL('.', import.meta.url));
6
8
 
7
- export default createReactVitestConfig({
9
+ export default defineConfig({
8
10
  test: {
9
11
  root,
10
- server: {
11
- deps: {
12
- // Transform the public Client surfaces so vi.mock applies, without duplicating the Server packages' identity tokens.
13
- inline: [
14
- // The published fixture mounts MemoryRouter; transform it with the page to share the router context.
15
- /@nocobase\/app-testing\/(?:dist\/)?src\/client\//u,
16
- // AppClientProviders includes the navigation guard and must use that same router context.
17
- /@nocobase\/app-client\//u,
18
- // Client plugins in every scope, including @acme/app-plugin-*, must share the transformed runtime.
19
- // Keep server/shared modules external so their service identities remain unchanged.
20
- /@[^/]+\/app-plugin-[^/]+\/(?:dist\/)?client\//u,
21
- ],
22
- },
23
- },
24
- // A glob rather than a list of filenames. The list had to be edited by hand for every test added or removed and
25
- // silently drifted: it named a file that no longer existed while several real test files were absent from it, so
26
- // those tests were never run at all.
27
- include: ['tests/**/*.test.{ts,tsx}'],
28
- // Playwright runs tests/playwright/ against a built application; its files are named *.test.ts too.
29
- exclude: ['tests/playwright/**'],
30
- setupFiles: ['./tests/setup/client-config.ts'],
31
- // Keep the command usable if an application intentionally removes all scaffold tests before adding its own.
12
+ projects: [
13
+ createNodeVitestConfig({
14
+ test: {
15
+ root,
16
+ name: 'node',
17
+ include: ['tests/{server,database,cli,project}/**/*.test.{ts,tsx}'],
18
+ // Database fixtures and command output capture run one file at a time.
19
+ fileParallelism: false,
20
+ },
21
+ }),
22
+ createReactVitestConfig({
23
+ test: {
24
+ root,
25
+ name: 'client',
26
+ environment: './tests/setup/application-environment.ts',
27
+ // Client integration tests also start isolated application databases.
28
+ fileParallelism: false,
29
+ include: ['tests/client/**/*.test.{ts,tsx}'],
30
+ setupFiles: ['./tests/setup/client-config.ts'],
31
+ server: {
32
+ deps: {
33
+ // Keep the fixture, application and plugin clients in the same React/router context.
34
+ // Server and shared modules stay external to preserve their service identities.
35
+ inline: [
36
+ /@nocobase\/app-testing\/(?:dist\/)?src\/client\//u,
37
+ /@nocobase\/app-client\//u,
38
+ /@[^/]+\/app-plugin-[^/]+\/(?:dist\/)?client\//u,
39
+ ],
40
+ },
41
+ },
42
+ },
43
+ }),
44
+ ],
45
+ // Only the directories above contain Vitest suites. Playwright has its own runner.
46
+ // Keep the command usable if an application intentionally removes all scaffold tests.
32
47
  passWithNoTests: true,
33
48
  coverage: {
34
49
  provider: 'v8',
@@ -1,124 +0,0 @@
1
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
- import { I18nRuntime } from '@nocobase/i18n';
3
- import { I18nProvider } from '@nocobase/i18n/client';
4
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
- import type { ReactElement } from 'react';
6
- import { MemoryRouter } from 'react-router';
7
- import { beforeEach, expect, it, vi } from 'vitest';
8
-
9
- import locales from '../../client/locales/index.js';
10
- import { TooltipProvider } from '../../client/components/ui/tooltip';
11
-
12
- const mocks = vi.hoisted(() => ({
13
- session: { user: { id: 'user-1' } } as { user: { id: string } } | null,
14
- unread: 2,
15
- request: vi.fn(),
16
- }));
17
- vi.mock('@nocobase/app-plugin-authentication/client', () => ({
18
- useAuthentication: () => ({ session: mocks.session, isPending: false }),
19
- }));
20
- vi.mock('@nocobase/app-client', async (importOriginal) => {
21
- const actual = await importOriginal<typeof import('@nocobase/app-client')>();
22
- const client = { request: mocks.request };
23
- const realtime = {
24
- watch: () => ({ close: vi.fn() }),
25
- subscribe: () => vi.fn(),
26
- onOpen: () => vi.fn(),
27
- };
28
- return {
29
- ...actual,
30
- useApiClient: () => client,
31
- useService: (token: unknown) =>
32
- token === actual.realtimeClientToken ? realtime : client,
33
- };
34
- });
35
-
36
- import InboxPage from '../../client/pages/inbox.tsx';
37
-
38
- const messages = [
39
- {
40
- id: 'm1',
41
- deliveryId: 'd1',
42
- notificationId: 'n1',
43
- title: 'Weekly report is ready',
44
- body: 'The sales report for week 40 is ready.',
45
- createdAt: '2026-10-01T08:00:00.000Z',
46
- },
47
- {
48
- id: 'm2',
49
- deliveryId: 'd2',
50
- notificationId: 'n2',
51
- title: 'Backup finished',
52
- body: 'All databases were backed up.',
53
- readAt: '2026-10-01T09:00:00.000Z',
54
- createdAt: '2026-10-01T07:00:00.000Z',
55
- },
56
- ];
57
-
58
- let runtime: I18nRuntime;
59
- beforeEach(async () => {
60
- mocks.session = { user: { id: 'user-1' } };
61
- mocks.unread = 2;
62
- mocks.request.mockReset();
63
- mocks.request.mockImplementation(
64
- async ({ path }: { readonly path: string }) => {
65
- if (path.endsWith('/unreadCount'))
66
- return { data: { count: mocks.unread } };
67
- if (path.startsWith('notificationInApp/messages?'))
68
- return { data: messages, meta: {} };
69
- return { data: { ...messages[0], readAt: '2026-10-01T10:00:00.000Z' } };
70
- },
71
- );
72
- runtime = new I18nRuntime({
73
- defaultLocale: 'en-US',
74
- locales: ['en-US', 'zh-CN'],
75
- applicationNamespace: 'test-app',
76
- });
77
- runtime.registerApplicationNamespace('test-app', locales);
78
- await runtime.init('en-US');
79
- });
80
-
81
- function Frame({
82
- children,
83
- path,
84
- }: {
85
- readonly children: ReactElement;
86
- readonly path: string;
87
- }): ReactElement {
88
- return (
89
- <QueryClientProvider
90
- client={
91
- new QueryClient({ defaultOptions: { queries: { retry: false } } })
92
- }
93
- >
94
- <MemoryRouter initialEntries={[path]}>
95
- <I18nProvider runtime={runtime}>
96
- <TooltipProvider>{children}</TooltipProvider>
97
- </I18nProvider>
98
- </MemoryRouter>
99
- </QueryClientProvider>
100
- );
101
- }
102
-
103
- it("lists the plugin's messages as notifications and marks one read when it is opened", async () => {
104
- render(
105
- <Frame path='/inbox'>
106
- <InboxPage />
107
- </Frame>,
108
- );
109
- expect(
110
- await screen.findByRole('heading', { name: /Notifications/u }),
111
- ).toBeInTheDocument();
112
- expect(screen.getByText('Backup finished')).toBeInTheDocument();
113
- // The first message is shown, and opening it marks it read.
114
- expect(
115
- screen.getByRole('heading', { name: 'Weekly report is ready', level: 2 }),
116
- ).toBeInTheDocument();
117
- fireEvent.click(screen.getAllByText('Weekly report is ready')[0]!);
118
- await waitFor(() =>
119
- expect(mocks.request).toHaveBeenCalledWith({
120
- path: 'notificationInApp/messages/m1/markRead',
121
- method: 'POST',
122
- }),
123
- );
124
- });
@@ -1,108 +0,0 @@
1
- import {
2
- createAppSidebarPreferenceStore,
3
- SidebarPreferenceProvider,
4
- } from '../../client/layouts/use-sidebar-preference.js';
5
- import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
6
- import { act, render, screen, waitFor } from '@testing-library/react';
7
- import { MemoryRouter } from 'react-router';
8
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
- import {
10
- useRouteNavigation,
11
- routeKey,
12
- } from '../../client/routing/route-navigation';
13
- import { NavigationMenu } from '../../client/layouts/components/navigation-menu';
14
- import { AppSidebarProvider } from '../../client/layouts/components/app-sidebar';
15
-
16
- const sidebarPreference = createAppSidebarPreferenceStore('/main');
17
-
18
- const auth = vi.hoisted(() => ({ revision: 0, client: { can: vi.fn() } }));
19
- vi.mock('@nocobase/app-plugin-authorization/client', () => ({
20
- useAuthorizationClient: () => auth.client,
21
- useAuthorizationRevision: () => auth.revision,
22
- }));
23
- vi.mock('@nocobase/i18n/client', () => ({
24
- useTranslation: () => ({ t: (key: string) => key }),
25
- }));
26
- beforeEach(() => {
27
- auth.revision = 0;
28
- localStorage.clear();
29
- vi.stubGlobal('matchMedia', () => ({
30
- matches: false,
31
- addEventListener: vi.fn(),
32
- removeEventListener: vi.fn(),
33
- }));
34
- });
35
- afterEach(() => vi.unstubAllGlobals());
36
- function page(name: string): AppClientRegisteredRoute {
37
- return {
38
- id: name,
39
- name,
40
- path: `/${name}`,
41
- packageName: 'test',
42
- source: 'application',
43
- auth: 'required',
44
- authz: { resource: { type: 'page', id: name }, action: 'access' },
45
- navigation: { title: name },
46
- componentLoader: async () => ({ default: () => null }),
47
- };
48
- }
49
- const visible = page('Allowed');
50
- const hidden = page('Denied');
51
- const routes: AppClientRegisteredRoute[] = [
52
- { ...page('Group'), componentLoader: undefined, children: [visible, hidden] },
53
- { ...page('Empty'), componentLoader: undefined, children: [hidden] },
54
- ];
55
- function Menu() {
56
- const { items } = useRouteNavigation(routes);
57
- return (
58
- <NavigationMenu
59
- items={items}
60
- label='Navigation'
61
- selectedKey={routeKey(visible)}
62
- />
63
- );
64
- }
65
- describe.each(['expanded', 'collapsed', 'mobile'])(
66
- 'permissions in %s navigation',
67
- (mode) => {
68
- it('hides pending and denied entries, removes empty groups, and refreshes open menus', async () => {
69
- if (mode === 'mobile') vi.stubGlobal('innerWidth', 390);
70
- if (mode === 'collapsed')
71
- localStorage.setItem('nocobase:sidebar:collapsed:app:/main', 'true');
72
- const resolve: ((allowed: boolean) => void)[] = [];
73
- auth.client.can.mockImplementation(
74
- () => new Promise<boolean>((done) => resolve.push(done)),
75
- );
76
- const tree = () => (
77
- <MemoryRouter>
78
- <SidebarPreferenceProvider value={sidebarPreference}>
79
- <AppSidebarProvider>
80
- <Menu />
81
- </AppSidebarProvider>
82
- </SidebarPreferenceProvider>
83
- </MemoryRouter>
84
- );
85
- const { rerender } = render(tree());
86
- expect(screen.queryByText('Group')).not.toBeInTheDocument();
87
- await act(async () => {
88
- resolve.forEach((done, i) => done(i === 0));
89
- });
90
- // Group is a section: its entries show in every mode, as icons in the collapsed sidebar.
91
- expect(
92
- await screen.findByRole('link', { name: 'Allowed' }),
93
- ).toHaveAttribute('href', '/Allowed');
94
- expect(screen.queryByText('Denied')).not.toBeInTheDocument();
95
- expect(screen.queryByText('Empty')).not.toBeInTheDocument();
96
- auth.client.can.mockResolvedValue(false);
97
- auth.revision++;
98
- rerender(tree());
99
- await waitFor(() =>
100
- expect(screen.queryByText('Group')).not.toBeInTheDocument(),
101
- );
102
- expect(
103
- screen.queryByRole('link', { name: 'Allowed' }),
104
- ).not.toBeInTheDocument();
105
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
106
- });
107
- },
108
- );