@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
@@ -1,36 +1,19 @@
1
- import {
2
- TestI18nProvider,
3
- createTestI18nRuntime,
4
- } from '@nocobase/i18n/testing';
5
- import { render, screen, waitFor, within } from '@testing-library/react';
1
+ import { renderWithApp } from '@nocobase/app-testing/client';
2
+ import { screen, waitFor, within } from '@testing-library/react';
6
3
  import userEvent from '@testing-library/user-event';
7
- import type { ReactElement, ReactNode } from 'react';
8
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
9
5
 
6
+ import packageMetadata from '../../package.json' with { type: 'json' };
10
7
  import enUS from '../../client/locales/en-US.js';
11
8
  import zhCN from '../../client/locales/zh-CN.js';
12
9
  import HomePage from '../../client/pages/home/index.js';
13
10
  import { CAPABILITIES } from '../../client/pages/home/capabilities.js';
14
11
 
15
- const { toaster } = vi.hoisted(() => ({
16
- toaster: { show: vi.fn(), close: vi.fn() },
17
- }));
18
-
19
- vi.mock('@nocobase/app-client', async (original) => ({
20
- ...(await original<typeof import('@nocobase/app-client')>()),
21
- useToaster: () => toaster,
22
- }));
23
-
24
- // Strict: a key the locale lacks fails the test instead of rendering as text.
25
- const runtime = await createTestI18nRuntime({
26
- application: {
27
- namespace: '@nocobase/app-template-default',
28
- resources: enUS,
29
- },
30
- });
31
-
32
- function I18n({ children }: { readonly children: ReactNode }): ReactElement {
33
- return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
12
+ function renderHome() {
13
+ return renderWithApp(<HomePage />, {
14
+ namespace: packageMetadata.name,
15
+ namespaces: { [packageMetadata.name]: enUS },
16
+ });
34
17
  }
35
18
 
36
19
  const writeText = vi.fn<(text: string) => Promise<void>>();
@@ -53,12 +36,11 @@ function setup(): ReturnType<typeof userEvent.setup> {
53
36
 
54
37
  describe('home page', () => {
55
38
  beforeEach(() => {
56
- toaster.show.mockReset();
57
39
  clipboard.fail = false;
58
40
  });
59
41
 
60
- it('tells people to build by asking their coding agent', () => {
61
- render(<HomePage />, { wrapper: I18n });
42
+ it('tells people to build by asking their coding agent', async () => {
43
+ await renderHome();
62
44
 
63
45
  expect(
64
46
  screen.getByRole('heading', { level: 1, name: enUS.home.title }),
@@ -66,8 +48,8 @@ describe('home page', () => {
66
48
  expect(screen.getByText(enUS.home.steps.describe.title)).toBeVisible();
67
49
  });
68
50
 
69
- it('shows one card per built-in capability', () => {
70
- render(<HomePage />, { wrapper: I18n });
51
+ it('shows one card per built-in capability', async () => {
52
+ await renderHome();
71
53
 
72
54
  for (const { id } of CAPABILITIES) {
73
55
  const title =
@@ -80,7 +62,7 @@ describe('home page', () => {
80
62
 
81
63
  it('opens a capability, switches prompts, and copies the edited text', async () => {
82
64
  const user = setup();
83
- render(<HomePage />, { wrapper: I18n });
65
+ const view = await renderHome();
84
66
  const scheduler = enUS.home.capabilities.scheduler;
85
67
 
86
68
  await user.click(
@@ -106,10 +88,12 @@ describe('home page', () => {
106
88
  );
107
89
 
108
90
  expect(writeText).toHaveBeenCalledWith('Every Friday');
109
- expect(toaster.show).toHaveBeenCalledWith({
110
- type: 'success',
111
- title: enUS.home.prompt.copied,
112
- });
91
+ expect(view.toasts()).toEqual([
92
+ expect.objectContaining({
93
+ type: 'success',
94
+ title: enUS.home.prompt.copied,
95
+ }),
96
+ ]);
113
97
 
114
98
  await user.click(
115
99
  within(dialog).getByRole('button', { name: enUS.home.prompt.reset }),
@@ -119,7 +103,7 @@ describe('home page', () => {
119
103
 
120
104
  it('starts from the default prompt each time the dialog opens', async () => {
121
105
  const user = setup();
122
- render(<HomePage />, { wrapper: I18n });
106
+ await renderHome();
123
107
  const theme = enUS.home.capabilities.theme;
124
108
  const card = screen.getByRole('button', { name: new RegExp(theme.title) });
125
109
 
@@ -142,16 +126,18 @@ describe('home page', () => {
142
126
  it('reports a copy the browser refuses', async () => {
143
127
  clipboard.fail = true;
144
128
  const user = setup();
145
- render(<HomePage />, { wrapper: I18n });
129
+ const view = await renderHome();
146
130
 
147
131
  await user.click(
148
132
  screen.getAllByRole('button', { name: enUS.home.prompt.copy })[0],
149
133
  );
150
134
 
151
- expect(toaster.show).toHaveBeenCalledWith({
152
- type: 'error',
153
- title: enUS.home.prompt.copyFailed,
154
- });
135
+ expect(view.toasts()).toEqual([
136
+ expect.objectContaining({
137
+ type: 'error',
138
+ title: enUS.home.prompt.copyFailed,
139
+ }),
140
+ ]);
155
141
  });
156
142
  });
157
143
 
@@ -0,0 +1,94 @@
1
+ import { realtimeClientToken } from '@nocobase/app-client';
2
+ import {
3
+ AuthenticationProvider,
4
+ authenticationClientToken,
5
+ } from '@nocobase/app-plugin-authentication/client';
6
+ import {
7
+ answerApi,
8
+ renderWithApp,
9
+ type ApiCall,
10
+ } from '@nocobase/app-testing/client';
11
+ import { fireEvent, screen, waitFor } from '@testing-library/react';
12
+ import { expect, it, vi } from 'vitest';
13
+
14
+ import packageMetadata from '../../package.json' with { type: 'json' };
15
+ import locales from '../../client/locales/index.js';
16
+ import { TooltipProvider } from '../../client/components/ui/tooltip';
17
+ import InboxPage from '../../client/pages/inbox.tsx';
18
+
19
+ const messages = [
20
+ {
21
+ id: 'm1',
22
+ deliveryId: 'd1',
23
+ notificationId: 'n1',
24
+ title: 'Weekly report is ready',
25
+ body: 'The sales report for week 40 is ready.',
26
+ createdAt: '2026-10-01T08:00:00.000Z',
27
+ },
28
+ {
29
+ id: 'm2',
30
+ deliveryId: 'd2',
31
+ notificationId: 'n2',
32
+ title: 'Backup finished',
33
+ body: 'All databases were backed up.',
34
+ readAt: '2026-10-01T09:00:00.000Z',
35
+ createdAt: '2026-10-01T07:00:00.000Z',
36
+ },
37
+ ];
38
+
39
+ const api = vi.fn(({ path }: ApiCall) => {
40
+ if (path === 'notificationInApp/unreadCount') return { data: { count: 2 } };
41
+ if (path === 'notificationInApp/messages')
42
+ return { data: messages, meta: {} };
43
+ if (path === 'notificationInApp/messages/m1/markRead') {
44
+ return { data: { ...messages[0], readAt: '2026-10-01T10:00:00.000Z' } };
45
+ }
46
+ return new Response(null, { status: 404 });
47
+ });
48
+
49
+ function renderInbox() {
50
+ return renderWithApp(
51
+ <AuthenticationProvider>
52
+ <TooltipProvider>
53
+ <InboxPage />
54
+ </TooltipProvider>
55
+ </AuthenticationProvider>,
56
+ {
57
+ route: '/inbox',
58
+ namespace: packageMetadata.name,
59
+ namespaces: { [packageMetadata.name]: locales },
60
+ fetch: answerApi(api),
61
+ services: (app) => {
62
+ app.container.instance(authenticationClientToken, {
63
+ getSession: async () => ({
64
+ data: { user: { id: 'user-1' }, session: { id: 'session-1' } },
65
+ }),
66
+ });
67
+ // Keep realtime hooks real while preventing a WebSocket connection in this page test.
68
+ const realtime = app.container.resolve(realtimeClientToken);
69
+ vi.spyOn(realtime, 'watch').mockReturnValue({ close: vi.fn() });
70
+ vi.spyOn(realtime, 'subscribe').mockReturnValue(vi.fn());
71
+ vi.spyOn(realtime, 'onOpen').mockReturnValue(vi.fn());
72
+ },
73
+ },
74
+ );
75
+ }
76
+
77
+ it("lists the plugin's messages as notifications and marks one read when it is opened", async () => {
78
+ await renderInbox();
79
+ expect(
80
+ await screen.findByRole('heading', { name: /Notifications/u }),
81
+ ).toBeInTheDocument();
82
+ expect(screen.getByText('Backup finished')).toBeInTheDocument();
83
+ // The first message is shown, and opening it marks it read.
84
+ expect(
85
+ screen.getByRole('heading', { name: 'Weekly report is ready', level: 2 }),
86
+ ).toBeInTheDocument();
87
+ fireEvent.click(screen.getAllByText('Weekly report is ready')[0]!);
88
+ await waitFor(() =>
89
+ expect(api).toHaveBeenCalledWith({
90
+ path: 'notificationInApp/messages/m1/markRead',
91
+ method: 'POST',
92
+ }),
93
+ );
94
+ });
@@ -1,34 +1,24 @@
1
- import { renderWithApp } from '@nocobase/app-testing/client';
1
+ import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
2
2
  import { screen, waitFor } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { Route, Routes, useLocation } from 'react-router';
5
- import { beforeEach, expect, it, vi } from 'vitest';
5
+ import { expect, it } from 'vitest';
6
6
  import { PermissionManagementPage } from '../../client/pages/settings/permission-management-page.js';
7
- import enUS from '../../client/locales/en-US.js';
8
- import zhCN from '../../client/locales/zh-CN.js';
9
- const access = vi.hoisted(() => ({ roles: true, defaults: true }));
10
- vi.mock('@nocobase/app-plugin-authorization/client', async (original) => ({
11
- ...(await original<
12
- typeof import('@nocobase/app-plugin-authorization/client')
13
- >()),
14
- useCan: ({ resource }: { resource: { id: string } }) => ({
15
- can:
16
- resource.id === 'authorization.permission-sets'
17
- ? access.roles
18
- : access.defaults,
19
- }),
20
- }));
21
- const namespace = '@nocobase/permission-tabs-test';
22
- const namespaces = {
23
- [namespace]: {
24
- 'en-US': async () => ({ default: enUS }),
25
- 'zh-CN': async () => ({ default: zhCN }),
26
- },
27
- };
28
- beforeEach(() => {
29
- access.roles = true;
30
- access.defaults = true;
31
- });
7
+
8
+ function mount(prefix: string, tab = 'roles', roles = true, locale = 'en-US') {
9
+ return renderAuthenticatedClient(tree(prefix), {
10
+ route: `${prefix}/${tab}`,
11
+ locale,
12
+ member: true,
13
+ grants: [
14
+ 'authorization.default-access',
15
+ ...(roles ? ['authorization.permission-sets'] : []),
16
+ ].map((id) => ({
17
+ resource: { type: 'settings', id },
18
+ actions: [{ action: 'read' }],
19
+ })),
20
+ });
21
+ }
32
22
  function Content({ tab }: { tab: 'roles' | 'default-access' }) {
33
23
  const location = useLocation();
34
24
  return (
@@ -55,11 +45,8 @@ it.each(['', '/settings'])(
55
45
  'uses two URL-backed tabs under one heading, preserves the existing %s route prefix and keyboard focus',
56
46
  async (prefix) => {
57
47
  const user = userEvent.setup();
58
- await renderWithApp(tree(prefix), {
59
- route: `${prefix}/roles`,
60
- namespace,
61
- namespaces,
62
- });
48
+ await mount(prefix);
49
+ await screen.findByRole('tab', { name: 'Roles and permissions' });
63
50
  expect(
64
51
  screen.getByRole('heading', { name: 'Permission management' }),
65
52
  ).toBeVisible();
@@ -99,12 +86,8 @@ it.each(['', '/settings'])(
99
86
  },
100
87
  );
101
88
  it('hides an unreadable tab while keeping direct navigation on the readable default scope page', async () => {
102
- access.roles = false;
103
- await renderWithApp(tree(''), {
104
- route: '/default-access',
105
- namespace,
106
- namespaces,
107
- });
89
+ await mount('', 'default-access', false);
90
+ await screen.findByRole('tab', { name: 'Default data scopes' });
108
91
  expect(
109
92
  screen.queryByRole('tab', { name: 'Roles and permissions' }),
110
93
  ).not.toBeInTheDocument();
@@ -116,12 +99,8 @@ it('hides an unreadable tab while keeping direct navigation on the readable defa
116
99
  );
117
100
  });
118
101
  it('uses the agreed Chinese menu and tab names', async () => {
119
- await renderWithApp(tree(''), {
120
- route: '/roles',
121
- namespace,
122
- namespaces,
123
- locale: 'zh-CN',
124
- });
102
+ await mount('', 'roles', true, 'zh-CN');
103
+ await screen.findByRole('tab', { name: '角色与权限' });
125
104
  expect(screen.getByRole('heading', { name: '权限管理' })).toBeVisible();
126
105
  expect(screen.getByRole('tab', { name: '角色与权限' })).toBeVisible();
127
106
  expect(screen.getByRole('tab', { name: '默认数据范围' })).toBeVisible();
@@ -0,0 +1,157 @@
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, screen, waitFor } from '@testing-library/react';
7
+ import { apiClientToken } from '@nocobase/app-client';
8
+ import {
9
+ AuthorizationClient,
10
+ authorizationClientToken,
11
+ type AuthorizationSnapshot,
12
+ } from '@nocobase/app-plugin-authorization/client';
13
+ import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
14
+ import packageMetadata from '../../package.json' with { type: 'json' };
15
+ import enUS from '../../client/locales/en-US.js';
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
+ import {
18
+ useRouteNavigation,
19
+ routeKey,
20
+ } from '../../client/routing/route-navigation';
21
+ import { NavigationMenu } from '../../client/layouts/components/navigation-menu';
22
+ import { AppSidebarProvider } from '../../client/layouts/components/app-sidebar';
23
+
24
+ const sidebarPreference = createAppSidebarPreferenceStore('/main');
25
+
26
+ beforeEach(() => {
27
+ localStorage.clear();
28
+ vi.stubGlobal('matchMedia', () => ({
29
+ matches: false,
30
+ addEventListener: vi.fn(),
31
+ removeEventListener: vi.fn(),
32
+ }));
33
+ });
34
+ afterEach(() => vi.unstubAllGlobals());
35
+ function page(name: string): AppClientRegisteredRoute {
36
+ return {
37
+ id: name,
38
+ name,
39
+ path: `/${name}`,
40
+ packageName: 'test',
41
+ source: 'application',
42
+ auth: 'required',
43
+ authz: { resource: { type: 'page', id: name }, action: 'access' },
44
+ navigation: { title: name },
45
+ componentLoader: async () => ({ default: () => null }),
46
+ };
47
+ }
48
+ const visible = page('Allowed');
49
+ const hidden = page('Denied');
50
+ const routes: AppClientRegisteredRoute[] = [
51
+ { ...page('Group'), componentLoader: undefined, children: [visible, hidden] },
52
+ { ...page('Empty'), componentLoader: undefined, children: [hidden] },
53
+ ];
54
+ function Menu() {
55
+ const { items, loading } = useRouteNavigation(routes);
56
+ return (
57
+ <section aria-label='Permission status' aria-busy={loading}>
58
+ <NavigationMenu
59
+ items={items}
60
+ label='Navigation'
61
+ selectedKey={routeKey(visible)}
62
+ />
63
+ </section>
64
+ );
65
+ }
66
+ describe.each(['expanded', 'collapsed', 'mobile'])(
67
+ 'permissions in %s navigation',
68
+ (mode) => {
69
+ it('hides pending and denied entries, removes empty groups, and refreshes open menus', async () => {
70
+ if (mode === 'mobile') vi.stubGlobal('innerWidth', 390);
71
+ if (mode === 'collapsed')
72
+ localStorage.setItem('nocobase:sidebar:collapsed:app:/main', 'true');
73
+ const response = Promise.withResolvers<{ data: AuthorizationSnapshot }>();
74
+ let permissions: AuthorizationSnapshot = {
75
+ unrestricted: false,
76
+ permissions: [],
77
+ };
78
+ let pending = true;
79
+ const { app } = await renderWithApp(
80
+ <SidebarPreferenceProvider value={sidebarPreference}>
81
+ <AppSidebarProvider>
82
+ <Menu />
83
+ </AppSidebarProvider>
84
+ </SidebarPreferenceProvider>,
85
+ {
86
+ namespace: packageMetadata.name,
87
+ namespaces: {
88
+ [packageMetadata.name]: enUS,
89
+ test: {
90
+ Allowed: 'Allowed',
91
+ Denied: 'Denied',
92
+ Group: 'Group',
93
+ Empty: 'Empty',
94
+ },
95
+ },
96
+ fetch: answerApi(({ path }) =>
97
+ path === 'authorization/permissions'
98
+ ? pending
99
+ ? response.promise
100
+ : { data: permissions }
101
+ : new Response(null, { status: 404 }),
102
+ ),
103
+ services: (app) => {
104
+ app.container.singleton(
105
+ authorizationClientToken,
106
+ (resolver) =>
107
+ new AuthorizationClient(resolver.resolve(apiClientToken)),
108
+ );
109
+ },
110
+ },
111
+ );
112
+ expect(screen.queryByText('Group')).not.toBeInTheDocument();
113
+ await act(async () => {
114
+ permissions = {
115
+ unrestricted: false,
116
+ permissions: [
117
+ { resource: { type: 'page', id: 'Allowed' }, actions: ['access'] },
118
+ ],
119
+ };
120
+ pending = false;
121
+ response.resolve({ data: permissions });
122
+ });
123
+ // Group is a section: its entries show in every mode, as icons in the collapsed sidebar.
124
+ expect(
125
+ await screen.findByRole('link', { name: 'Allowed' }),
126
+ ).toHaveAttribute('href', '/Allowed');
127
+ expect(screen.queryByText('Denied')).not.toBeInTheDocument();
128
+ expect(screen.queryByText('Empty')).not.toBeInTheDocument();
129
+ const allowedPermissions = permissions;
130
+ await act(async () => {
131
+ permissions = { unrestricted: false, permissions: [] };
132
+ const authorization = app.container.resolve(authorizationClientToken);
133
+ authorization.invalidate();
134
+ await authorization.snapshot();
135
+ });
136
+ await waitFor(() =>
137
+ expect(
138
+ screen.getByRole('region', { name: 'Permission status' }),
139
+ ).toHaveAttribute('aria-busy', 'false'),
140
+ );
141
+ expect(screen.queryByText('Group')).not.toBeInTheDocument();
142
+ expect(
143
+ screen.queryByRole('link', { name: 'Allowed' }),
144
+ ).not.toBeInTheDocument();
145
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
146
+ await act(async () => {
147
+ permissions = allowedPermissions;
148
+ const authorization = app.container.resolve(authorizationClientToken);
149
+ authorization.invalidate();
150
+ await authorization.snapshot();
151
+ });
152
+ expect(
153
+ await screen.findByRole('link', { name: 'Allowed' }),
154
+ ).toBeVisible();
155
+ });
156
+ },
157
+ );
@@ -1,14 +1,12 @@
1
- // @vitest-environment node
2
-
1
+ import path from 'node:path';
3
2
  import { readFileSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
5
3
  import tailwind from '@tailwindcss/postcss';
6
4
  import postcss, { type Root } from 'postcss';
7
5
  import { beforeAll, describe, expect, it } from 'vitest';
8
6
 
9
7
  import { themePresets } from '../../client/theme/theme-presets';
10
8
 
11
- const appRoot = fileURLToPath(new URL('../..', import.meta.url));
9
+ const appRoot = path.resolve(import.meta.dirname, '../..');
12
10
  const sizes = [
13
11
  'xs',
14
12
  'sm',
@@ -92,7 +90,7 @@ describe('theme token contract', () => {
92
90
  const readPreset = (id: string) =>
93
91
  postcss.parse(
94
92
  readFileSync(
95
- new URL(`../../client/theme/themes/${id}.css`, import.meta.url),
93
+ path.join(appRoot, `client/theme/themes/${id}.css`),
96
94
  'utf8',
97
95
  ),
98
96
  );
@@ -129,7 +127,7 @@ describe('theme token contract', () => {
129
127
  it(`${id} defines complete tokens for the page and isolated previews`, () => {
130
128
  const root = postcss.parse(
131
129
  readFileSync(
132
- new URL(`../../client/theme/themes/${id}.css`, import.meta.url),
130
+ path.join(appRoot, `client/theme/themes/${id}.css`),
133
131
  'utf8',
134
132
  ),
135
133
  );
@@ -170,13 +168,13 @@ describe('compiled theme utilities', () => {
170
168
  'stroke-chart-5',
171
169
  ];
172
170
  const source = readFileSync(
173
- new URL('../../client/styles.css', import.meta.url),
171
+ path.join(appRoot, 'client/styles.css'),
174
172
  'utf8',
175
173
  );
176
174
  const result = await postcss([
177
175
  tailwind({ base: appRoot, optimize: false }),
178
176
  ]).process(source + `\n@source inline("${candidates.join(' ')}");`, {
179
- from: fileURLToPath(new URL('../../client/styles.css', import.meta.url)),
177
+ from: path.join(appRoot, 'client/styles.css'),
180
178
  });
181
179
  compiled = result.root;
182
180
  }, 30_000);
@@ -0,0 +1,78 @@
1
+ import { act, fireEvent, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { UserMenu } from '../../client/layouts/components/user-menu.tsx';
5
+ import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
6
+
7
+ async function openAccountMenu(): Promise<void> {
8
+ act(() => screen.getByRole('button', { name: 'Open account menu' }).focus());
9
+ await userEvent.keyboard('{Enter}');
10
+ }
11
+ async function signOutFromMenu() {
12
+ await openAccountMenu();
13
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Sign out' }));
14
+ }
15
+
16
+ describe('account menu sign out', () => {
17
+ it('ends the server session and refreshes the displayed identity', async () => {
18
+ const view = await renderAuthenticatedClient(<UserMenu />);
19
+ await signOutFromMenu();
20
+ await waitFor(() => expect(view.identity().session).toBeNull());
21
+ expect(
22
+ await (await view.session.fetch('/auth/get-session')).json(),
23
+ ).toBeNull();
24
+ expect(view.toasts()).toHaveLength(0);
25
+ await openAccountMenu();
26
+ expect(screen.queryByText(view.session.user.email)).not.toBeInTheDocument();
27
+ });
28
+ 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');
34
+ },
35
+ });
36
+ await signOutFromMenu();
37
+ await waitFor(() => expect(view.toasts()).toHaveLength(1));
38
+ expect(view.toasts()[0]).toMatchObject({
39
+ type: 'error',
40
+ title: expect.stringContaining('This origin is not trusted.'),
41
+ });
42
+ expect(view.identity().session?.user.id).toBe(view.session.user.id);
43
+ expect(
44
+ await (await view.session.fetch('/auth/get-session')).json(),
45
+ ).toMatchObject({ user: { id: view.session.user.id } });
46
+ await openAccountMenu();
47
+ expect(
48
+ await screen.findByRole('menuitem', { name: 'Sign out' }),
49
+ ).not.toHaveAttribute('aria-disabled', 'true');
50
+ });
51
+ it('reports a network failure and successfully retries sign-out', async () => {
52
+ 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');
58
+ },
59
+ });
60
+ await signOutFromMenu();
61
+ await waitFor(() => expect(view.toasts()).toHaveLength(1));
62
+ expect(view.toasts()[0]).toMatchObject({
63
+ type: 'error',
64
+ title: 'Could not reach the server. Check your connection and try again.',
65
+ });
66
+ expect(view.identity().session?.user.id).toBe(view.session.user.id);
67
+ expect(
68
+ await (await view.session.fetch('/auth/get-session')).json(),
69
+ ).toMatchObject({ user: { id: view.session.user.id } });
70
+ offline = false;
71
+ await signOutFromMenu();
72
+ await waitFor(() => expect(view.identity().session).toBeNull());
73
+ expect(
74
+ await (await view.session.fetch('/auth/get-session')).json(),
75
+ ).toBeNull();
76
+ expect(view.toasts()).toHaveLength(1);
77
+ });
78
+ });
@@ -1,4 +1,3 @@
1
- // @vitest-environment node
2
1
  import path from 'node:path';
3
2
  import type { MigrationSource } from '@nocobase/db';
4
3
  import { describeMigration } from '@nocobase/app-testing/server';