@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.57

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 (250) hide show
  1. package/.cursor/mcp.json +8 -0
  2. package/.mcp.json +8 -0
  3. package/.prettierignore +20 -0
  4. package/.vscode/mcp.json +8 -0
  5. package/AGENTS.md +432 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +481 -0
  10. package/cli/plugins.ts +14 -0
  11. package/client/agent-annotations-host.ts +19 -0
  12. package/client/app.ts +38 -0
  13. package/client/components/back-button.tsx +49 -0
  14. package/client/components/breadcrumbs.tsx +167 -0
  15. package/client/components/inbox-badge.ts +49 -0
  16. package/client/components/inbox-button.tsx +132 -0
  17. package/client/components/inbox-header-button.tsx +25 -0
  18. package/client/components/loading.tsx +35 -0
  19. package/client/components/page-container.tsx +24 -0
  20. package/client/components/page-header.tsx +33 -0
  21. package/client/components/route-child-page.tsx +121 -0
  22. package/client/components/route-dialog.tsx +10 -0
  23. package/client/components/route-drawer.tsx +10 -0
  24. package/client/components/route-overlay.tsx +190 -0
  25. package/client/components/ui/alert.tsx +75 -0
  26. package/client/components/ui/badge.tsx +51 -0
  27. package/client/components/ui/breadcrumb.tsx +121 -0
  28. package/client/components/ui/button.tsx +58 -0
  29. package/client/components/ui/collapsible.tsx +19 -0
  30. package/client/components/ui/context-menu.tsx +271 -0
  31. package/client/components/ui/dialog.tsx +160 -0
  32. package/client/components/ui/dropdown-menu.tsx +270 -0
  33. package/client/components/ui/empty.tsx +103 -0
  34. package/client/components/ui/field.tsx +236 -0
  35. package/client/components/ui/input-group.tsx +158 -0
  36. package/client/components/ui/input.tsx +20 -0
  37. package/client/components/ui/kbd.tsx +26 -0
  38. package/client/components/ui/label.tsx +18 -0
  39. package/client/components/ui/popover.tsx +89 -0
  40. package/client/components/ui/select.tsx +200 -0
  41. package/client/components/ui/separator.tsx +22 -0
  42. package/client/components/ui/sheet.tsx +137 -0
  43. package/client/components/ui/sidebar.tsx +723 -0
  44. package/client/components/ui/skeleton.tsx +13 -0
  45. package/client/components/ui/spinner.tsx +20 -0
  46. package/client/components/ui/tabs.tsx +79 -0
  47. package/client/components/ui/textarea.tsx +17 -0
  48. package/client/components/ui/toast.tsx +231 -0
  49. package/client/components/ui/tooltip.tsx +63 -0
  50. package/client/components/use-route-overlay.ts +26 -0
  51. package/client/config/api.ts +13 -0
  52. package/client/config/app.ts +8 -0
  53. package/client/config/auth.ts +18 -0
  54. package/client/config/index.ts +17 -0
  55. package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
  56. package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
  57. package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
  58. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
  59. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
  60. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
  61. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
  62. package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
  63. package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
  64. package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
  65. package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
  66. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
  67. package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
  68. package/client/extensions/nocobase-file-component-ui/README.md +13 -0
  69. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
  70. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
  71. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
  72. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
  73. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
  74. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
  75. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
  76. package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
  77. package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
  78. package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
  79. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
  80. package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
  81. package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
  82. package/client/extensions/nocobase-inbox/frame.ts +31 -0
  83. package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
  84. package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
  85. package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
  86. package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
  87. package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
  88. package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
  89. package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
  90. package/client/extensions/nocobase-inbox/model.ts +428 -0
  91. package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
  92. package/client/extensions/nocobase-inbox/registry.ts +303 -0
  93. package/client/extensions/nocobase-inbox/source.ts +117 -0
  94. package/client/hooks/use-mobile.ts +21 -0
  95. package/client/index.tsx +60 -0
  96. package/client/layouts/app-layout.tsx +105 -0
  97. package/client/layouts/components/app-brand.tsx +49 -0
  98. package/client/layouts/components/app-sidebar.tsx +196 -0
  99. package/client/layouts/components/header-actions.tsx +50 -0
  100. package/client/layouts/components/language-switcher.tsx +95 -0
  101. package/client/layouts/components/layout-header.tsx +17 -0
  102. package/client/layouts/components/navigation-menu.tsx +411 -0
  103. package/client/layouts/components/sidebar-footer.tsx +107 -0
  104. package/client/layouts/components/surface-empty.tsx +35 -0
  105. package/client/layouts/components/user-menu.tsx +127 -0
  106. package/client/layouts/index.ts +2 -0
  107. package/client/layouts/settings-layout.tsx +172 -0
  108. package/client/layouts/use-sidebar-preference.ts +57 -0
  109. package/client/lib/constants.ts +5 -0
  110. package/client/lib/toaster.ts +56 -0
  111. package/client/lib/utils.ts +1 -0
  112. package/client/locales/en-US.ts +178 -0
  113. package/client/locales/index.ts +9 -0
  114. package/client/locales/zh-CN.ts +161 -0
  115. package/client/pages/auth/device.tsx +41 -0
  116. package/client/pages/auth/forgot-password.tsx +39 -0
  117. package/client/pages/auth/login.tsx +66 -0
  118. package/client/pages/auth/register.tsx +46 -0
  119. package/client/pages/auth/reset-password.tsx +38 -0
  120. package/client/pages/auth/shared.tsx +111 -0
  121. package/client/pages/home.tsx +16 -0
  122. package/client/pages/inbox.tsx +12 -0
  123. package/client/plugins.ts +41 -0
  124. package/client/react-providers.ts +27 -0
  125. package/client/route-overrides.ts +9 -0
  126. package/client/routes.ts +75 -0
  127. package/client/routing/app-router.tsx +127 -0
  128. package/client/routing/client-route.tsx +129 -0
  129. package/client/routing/route-context.tsx +85 -0
  130. package/client/routing/route-navigation.ts +153 -0
  131. package/client/routing/route-tree.tsx +42 -0
  132. package/client/routing/standalone-page-layout.tsx +15 -0
  133. package/client/runtime.ts +26 -0
  134. package/client/service-provider.ts +40 -0
  135. package/client/source-extensions.ts +19 -0
  136. package/client/startup.tsx +22 -0
  137. package/client/styles.css +122 -0
  138. package/client/theme/index.ts +4 -0
  139. package/client/theme/theme-context.ts +12 -0
  140. package/client/theme/theme-preferences.ts +84 -0
  141. package/client/theme/theme-presets.ts +7 -0
  142. package/client/theme/theme-provider.tsx +106 -0
  143. package/client/theme/theme-settings.tsx +176 -0
  144. package/client/theme/themes/compact.css +140 -0
  145. package/client/theme/themes/default.css +117 -0
  146. package/client/vite-env.d.ts +5 -0
  147. package/components.json +27 -0
  148. package/config.example.yml +172 -0
  149. package/database/main/migrations/.gitkeep +0 -0
  150. package/database/main/migrations/202610060001_create_device_code.ts +37 -0
  151. package/database/main/seeds/.gitkeep +0 -0
  152. package/database/tsconfig.json +4 -0
  153. package/ecosystem.config.js +35 -0
  154. package/eslint.config.js +24 -0
  155. package/index.html +100 -0
  156. package/package.json +200 -3
  157. package/playwright.config.ts +10 -0
  158. package/public/assets/favicon.ico +0 -0
  159. package/public/assets/logo-dark.png +0 -0
  160. package/public/assets/logo-mark-dark.png +0 -0
  161. package/public/assets/logo-mark.png +0 -0
  162. package/public/assets/logo.png +0 -0
  163. package/server/app.ts +50 -0
  164. package/server/config/ai.ts +27 -0
  165. package/server/config/api.ts +8 -0
  166. package/server/config/app.ts +40 -0
  167. package/server/config/auth.ts +36 -0
  168. package/server/config/authorization.ts +18 -0
  169. package/server/config/caching.ts +21 -0
  170. package/server/config/database.ts +22 -0
  171. package/server/config/drive.ts +30 -0
  172. package/server/config/i18n.ts +17 -0
  173. package/server/config/index.ts +73 -0
  174. package/server/config/jobs.ts +27 -0
  175. package/server/config/logging.ts +23 -0
  176. package/server/config/notification.ts +11 -0
  177. package/server/config/queue.ts +28 -0
  178. package/server/config/scheduler.ts +23 -0
  179. package/server/config/secrets.ts +10 -0
  180. package/server/config/server.ts +26 -0
  181. package/server/config/session.ts +60 -0
  182. package/server/config/snowflake.ts +21 -0
  183. package/server/config/spa.ts +24 -0
  184. package/server/config/users.ts +11 -0
  185. package/server/config/workflow.ts +24 -0
  186. package/server/config.ts +24 -0
  187. package/server/embedded.ts +23 -0
  188. package/server/locales/en-US.ts +10 -0
  189. package/server/locales/index.ts +10 -0
  190. package/server/locales/zh-CN.ts +5 -0
  191. package/server/plugins.ts +41 -0
  192. package/server/providers/index.ts +5 -0
  193. package/server/routes/index.ts +6 -0
  194. package/server/runtime.ts +24 -0
  195. package/server/standalone.ts +29 -0
  196. package/server/tsconfig.json +4 -0
  197. package/tailwind.config.mjs +59 -0
  198. package/tests/components/app-layout-routes.test.tsx +132 -0
  199. package/tests/components/app-title.test.tsx +57 -0
  200. package/tests/components/auth-i18n.test.tsx +87 -0
  201. package/tests/components/back-button.test.tsx +80 -0
  202. package/tests/components/file-preview.test.tsx +101 -0
  203. package/tests/components/header-hover.test.tsx +87 -0
  204. package/tests/components/inbox.test.tsx +139 -0
  205. package/tests/components/language-switcher.test.tsx +298 -0
  206. package/tests/components/layout-containers.test.tsx +15 -0
  207. package/tests/components/layout-i18n.test.tsx +187 -0
  208. package/tests/components/loading.test.tsx +25 -0
  209. package/tests/components/navigation-menu.test.tsx +355 -0
  210. package/tests/components/page-harness.test.tsx +318 -0
  211. package/tests/components/primitive-labels.test.tsx +80 -0
  212. package/tests/components/sidebar-permissions.test.tsx +110 -0
  213. package/tests/components/sidebar-preference.test.tsx +42 -0
  214. package/tests/fixtures/theme-tokens.html +12 -0
  215. package/tests/fixtures/theme-tokens.tsx +111 -0
  216. package/tests/logic/account-permissions.test.tsx +204 -0
  217. package/tests/logic/app-locale-coverage.test.ts +136 -0
  218. package/tests/logic/app-server.test.ts +1589 -0
  219. package/tests/logic/asset-url-build.test.ts +106 -0
  220. package/tests/logic/client-auth.test.tsx +122 -0
  221. package/tests/logic/client-plugin-registry.test.ts +63 -0
  222. package/tests/logic/client-route-recovery.test.tsx +80 -0
  223. package/tests/logic/client-routes.test.ts +126 -0
  224. package/tests/logic/client-runtime.test.ts +112 -0
  225. package/tests/logic/client-settings.test.tsx +841 -0
  226. package/tests/logic/client-shell.test.tsx +466 -0
  227. package/tests/logic/client-theme.test.tsx +441 -0
  228. package/tests/logic/config.test.ts +197 -0
  229. package/tests/logic/dev-client-watch.test.ts +108 -0
  230. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  231. package/tests/logic/device-code-migration.test.ts +70 -0
  232. package/tests/logic/lifecycle.test.ts +221 -0
  233. package/tests/logic/plugin-commands.test.ts +69 -0
  234. package/tests/logic/plugin-page-authz.test.ts +76 -0
  235. package/tests/logic/route-navigation.test.ts +66 -0
  236. package/tests/logic/route-overlay.test.tsx +353 -0
  237. package/tests/logic/server-imports.test.ts +88 -0
  238. package/tests/logic/tailwind-sources.test.ts +151 -0
  239. package/tests/logic/template-package-name.test.ts +88 -0
  240. package/tests/logic/theme-preferences.test.ts +86 -0
  241. package/tests/logic/theme-tokens.test.ts +248 -0
  242. package/tests/logic/toaster.test.tsx +108 -0
  243. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  244. package/tests/setup/client-config.ts +27 -0
  245. package/tsconfig.json +11 -0
  246. package/tsconfig.node.json +12 -0
  247. package/tsconfig.server.json +19 -0
  248. package/vite.config.ts +92 -0
  249. package/vitest.config.ts +37 -0
  250. package/README.md +0 -3
@@ -0,0 +1,441 @@
1
+ import { I18nRuntime } from '@nocobase/i18n';
2
+ import { I18nProvider } from '@nocobase/i18n/client';
3
+ import locales from '../../client/locales/index.js';
4
+ import { themePresets } from '../../client/theme/theme-presets';
5
+ import { initializeTheme } from '../../client/theme/theme-preferences';
6
+ import {
7
+ act,
8
+ fireEvent,
9
+ render,
10
+ screen,
11
+ waitFor,
12
+ } from '@testing-library/react';
13
+ import userEvent from '@testing-library/user-event';
14
+ import type { ReactElement } from 'react';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+
17
+ import {
18
+ AppThemeProvider,
19
+ ThemeSettings,
20
+ useTheme,
21
+ } from '../../client/theme/index.ts';
22
+
23
+ /**
24
+ * Renders the client configuration the server would, mounted at `/crm` so the theme's storage keys are
25
+ * `nocobase:crm:*`. It replaces any block already on the page, since the client reads the first one it finds.
26
+ */
27
+ function renderClientConfig(
28
+ config: { app?: Record<string, unknown> } = {},
29
+ ): void {
30
+ document.getElementById('nocobase-runtime-config')?.remove();
31
+ const element = document.createElement('script');
32
+ element.id = 'nocobase-runtime-config';
33
+ element.type = 'application/json';
34
+ element.textContent = JSON.stringify({
35
+ version: 1,
36
+ config: { ...config, app: { basePath: '/crm', ...config.app } },
37
+ });
38
+ document.head.append(element);
39
+ }
40
+
41
+ /**
42
+ * Opens the appearance popover from the keyboard. Its trigger opens on hover, and a pointer press landing more than
43
+ * Base UI's 500 ms patient-click threshold after that, as on a loaded runner, closes it again.
44
+ */
45
+ async function openAppearance(name = 'Appearance'): Promise<void> {
46
+ act(() => screen.getByRole('button', { name }).focus());
47
+ await userEvent.keyboard('{Enter}');
48
+ }
49
+
50
+ describe('app client theme', () => {
51
+ it.each([
52
+ ['en-US', 'Appearance', 'Spacious'],
53
+ ['zh-CN', '外观', '宽松'],
54
+ ])(
55
+ 'labels the default preset without implying default selection (%s)',
56
+ async (locale, appearance, label) => {
57
+ const runtime = new I18nRuntime({
58
+ defaultLocale: 'en-US',
59
+ locales: ['en-US', 'zh-CN'],
60
+ applicationNamespace: 'test-app',
61
+ });
62
+ runtime.registerApplicationNamespace('test-app', locales);
63
+ await runtime.init(locale);
64
+ render(
65
+ <I18nProvider runtime={runtime}>
66
+ <AppThemeProvider>
67
+ <ThemeSettings />
68
+ </AppThemeProvider>
69
+ </I18nProvider>,
70
+ );
71
+ await openAppearance(appearance);
72
+ const option = await screen.findByRole('radio', { name: label });
73
+ expect(option).toHaveAttribute('value', 'default');
74
+ await userEvent.click(option);
75
+ expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
76
+ },
77
+ );
78
+
79
+ it('falls back from the removed Ant Design preset without changing mode', async () => {
80
+ localStorage.setItem('nocobase:crm:theme:preset', 'ant-design');
81
+ localStorage.setItem('nocobase:crm:theme:color-scheme', 'light');
82
+ render(
83
+ <AppThemeProvider>
84
+ <ThemeSettings />
85
+ </AppThemeProvider>,
86
+ );
87
+ await openAppearance();
88
+ expect(
89
+ screen.queryByRole('radio', { name: 'Ant-design' }),
90
+ ).not.toBeInTheDocument();
91
+ expect(await screen.findByRole('radio', { name: 'Compact' })).toBeChecked();
92
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
93
+ expect(document.documentElement).toHaveClass('light');
94
+ });
95
+ it('omits Ocean and falls back from its saved ID to Compact', async () => {
96
+ localStorage.setItem('nocobase:crm:theme:preset', 'ocean');
97
+ render(
98
+ <AppThemeProvider>
99
+ <ThemeSettings />
100
+ </AppThemeProvider>,
101
+ );
102
+ await openAppearance();
103
+ expect(
104
+ screen.queryByRole('radio', { name: 'Ocean' }),
105
+ ).not.toBeInTheDocument();
106
+ expect(await screen.findByRole('radio', { name: 'Compact' })).toBeChecked();
107
+ expect(
108
+ await screen.findByRole('radio', { name: 'Default' }),
109
+ ).toBeInTheDocument();
110
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
111
+ });
112
+
113
+ beforeEach(() => {
114
+ localStorage.clear();
115
+ renderClientConfig();
116
+ document.documentElement.removeAttribute('class');
117
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
118
+ addEventListener: vi.fn(),
119
+ addListener: vi.fn(),
120
+ dispatchEvent: vi.fn(),
121
+ matches: query === '(prefers-color-scheme: dark)',
122
+ media: query,
123
+ onchange: null,
124
+ removeEventListener: vi.fn(),
125
+ removeListener: vi.fn(),
126
+ }));
127
+ });
128
+
129
+ afterEach(() => {
130
+ vi.unstubAllGlobals();
131
+ vi.restoreAllMocks();
132
+ localStorage.clear();
133
+ document.getElementById('nocobase-runtime-config')?.remove();
134
+ document.documentElement.removeAttribute('class');
135
+ document.documentElement.removeAttribute('style');
136
+ });
137
+
138
+ it.each([
139
+ [undefined, undefined, 'light', 'compact'],
140
+ ['dark', 'default', 'dark', 'default'],
141
+ ['system', 'removed', 'dark', 'compact'],
142
+ ['invalid', 'default', 'light', 'default'],
143
+ ])(
144
+ 'uses configured defaults with saved mode %s and preset %s',
145
+ async (savedMode, savedPreset, mode, preset) => {
146
+ renderClientConfig({
147
+ app: { defaultColorScheme: 'light', defaultTheme: 'compact' },
148
+ });
149
+ if (savedMode)
150
+ localStorage.setItem('nocobase:crm:theme:color-scheme', savedMode);
151
+ if (savedPreset)
152
+ localStorage.setItem('nocobase:crm:theme:preset', savedPreset);
153
+ initializeTheme(
154
+ '/crm/',
155
+ themePresets.map(({ id }) => id),
156
+ );
157
+ expect(document.documentElement).toHaveClass(mode);
158
+ expect(document.documentElement).toHaveAttribute('data-theme', preset);
159
+ render(
160
+ <AppThemeProvider>
161
+ <ThemeProbe />
162
+ </AppThemeProvider>,
163
+ );
164
+ await waitFor(() => expect(document.documentElement).toHaveClass(mode));
165
+ expect(document.documentElement).toHaveAttribute('data-theme', preset);
166
+ if (!savedMode)
167
+ expect(
168
+ localStorage.getItem('nocobase:crm:theme:color-scheme'),
169
+ ).toBeNull();
170
+ if (!savedPreset)
171
+ expect(localStorage.getItem('nocobase:crm:theme:preset')).toBeNull();
172
+ localStorage.clear();
173
+ fireEvent(window, new StorageEvent('storage', { key: null }));
174
+ await waitFor(() =>
175
+ expect(document.documentElement).toHaveClass('light'),
176
+ );
177
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
178
+ expect(
179
+ localStorage.getItem('nocobase:crm:theme:color-scheme'),
180
+ ).toBeNull();
181
+ },
182
+ );
183
+
184
+ it.each([
185
+ [undefined, undefined, 'compact'],
186
+ ['default', undefined, 'default'],
187
+ [undefined, 'default', 'default'],
188
+ ['default', 'compact', 'compact'],
189
+ ])(
190
+ 'keeps startup and Provider consistent (%s, %s)',
191
+ async (configured, saved, expected) => {
192
+ renderClientConfig({ app: { defaultTheme: configured } });
193
+ const key = 'nocobase:crm:theme:preset';
194
+ if (saved) localStorage.setItem(key, saved);
195
+ initializeTheme(
196
+ '/crm/',
197
+ themePresets.map(({ id }) => id),
198
+ );
199
+ expect(document.documentElement).toHaveAttribute('data-theme', expected);
200
+ render(
201
+ <AppThemeProvider>
202
+ <ThemeSettings />
203
+ </AppThemeProvider>,
204
+ );
205
+ await openAppearance();
206
+ expect(
207
+ await screen.findByRole('radio', {
208
+ name: expected === 'compact' ? 'Compact' : 'Default',
209
+ }),
210
+ ).toBeChecked();
211
+ expect(document.documentElement).toHaveAttribute('data-theme', expected);
212
+ expect(localStorage.getItem(key)).toBe(saved ?? null);
213
+ },
214
+ );
215
+
216
+ it.each([null, 'invalid'])(
217
+ 'syncs deleted or invalid modes without writing defaults (%s)',
218
+ async (newValue) => {
219
+ renderClientConfig({ app: { defaultColorScheme: 'light' } });
220
+ localStorage.setItem('nocobase:crm:theme:color-scheme', 'dark');
221
+ render(
222
+ <AppThemeProvider>
223
+ <ThemeProbe />
224
+ </AppThemeProvider>,
225
+ );
226
+ const write = vi.spyOn(Storage.prototype, 'setItem');
227
+ fireEvent(
228
+ window,
229
+ new StorageEvent('storage', {
230
+ key: 'nocobase:crm:theme:color-scheme',
231
+ newValue,
232
+ }),
233
+ );
234
+ await waitFor(() =>
235
+ expect(document.documentElement).toHaveClass('light'),
236
+ );
237
+ expect(write).not.toHaveBeenCalled();
238
+ },
239
+ );
240
+
241
+ it('uses configured defaults without storage and preserves explicit Provider mode', async () => {
242
+ renderClientConfig({
243
+ app: { defaultColorScheme: 'light', defaultTheme: 'compact' },
244
+ });
245
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
246
+ throw new Error('blocked');
247
+ });
248
+ initializeTheme(
249
+ '/crm/',
250
+ themePresets.map(({ id }) => id),
251
+ );
252
+ expect(document.documentElement).toHaveClass('light');
253
+ const { unmount } = render(
254
+ <AppThemeProvider>
255
+ <ThemeProbe />
256
+ </AppThemeProvider>,
257
+ );
258
+ await waitFor(() => expect(document.documentElement).toHaveClass('light'));
259
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
260
+ unmount();
261
+ render(
262
+ <AppThemeProvider defaultTheme='dark'>
263
+ <ThemeProbe />
264
+ </AppThemeProvider>,
265
+ );
266
+ await waitFor(() => expect(document.documentElement).toHaveClass('dark'));
267
+ });
268
+
269
+ it.each([
270
+ {},
271
+ { defaultColorScheme: 'unknown', defaultTheme: 'removed' },
272
+ { defaultColorScheme: 4, defaultTheme: {} },
273
+ ])('ignores invalid theme defaults %s', async (app) => {
274
+ renderClientConfig({ app });
275
+ initializeTheme(
276
+ '/crm/',
277
+ themePresets.map(({ id }) => id),
278
+ );
279
+ render(
280
+ <AppThemeProvider>
281
+ <ThemeProbe />
282
+ </AppThemeProvider>,
283
+ );
284
+ await waitFor(() => expect(document.documentElement).toHaveClass('dark'));
285
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
286
+ });
287
+
288
+ it('follows the system theme and persists explicit changes', async () => {
289
+ render(
290
+ <AppThemeProvider>
291
+ <ThemeProbe />
292
+ </AppThemeProvider>,
293
+ );
294
+
295
+ await waitFor(() => {
296
+ expect(document.documentElement).toHaveClass('dark');
297
+ expect(screen.getByTestId('theme')).toHaveTextContent('system');
298
+ expect(screen.getByTestId('resolved-theme')).toHaveTextContent('dark');
299
+ });
300
+
301
+ fireEvent.click(screen.getByRole('button', { name: 'Use light theme' }));
302
+
303
+ await waitFor(() => {
304
+ expect(document.documentElement).toHaveClass('light');
305
+ expect(document.documentElement).not.toHaveClass('dark');
306
+ expect(localStorage.getItem('nocobase:crm:theme:color-scheme')).toBe(
307
+ 'light',
308
+ );
309
+ expect(screen.getByTestId('resolved-theme')).toHaveTextContent('light');
310
+ });
311
+ });
312
+
313
+ it('selects presets independently, restores them and ignores another app', async () => {
314
+ localStorage.setItem('nocobase:crm:theme:preset', 'default');
315
+ render(
316
+ <AppThemeProvider>
317
+ <ThemeSettings />
318
+ </AppThemeProvider>,
319
+ );
320
+
321
+ await openAppearance();
322
+ await userEvent.click(
323
+ await screen.findByRole('radio', { name: 'Compact' }),
324
+ );
325
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
326
+ expect(document.documentElement).toHaveClass('dark');
327
+ expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('compact');
328
+ await userEvent.click(await screen.findByRole('radio', { name: 'Light' }));
329
+ expect(document.documentElement).toHaveClass('light');
330
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
331
+ fireEvent(
332
+ window,
333
+ new StorageEvent('storage', {
334
+ key: 'nocobase:erp:theme:preset',
335
+ newValue: 'default',
336
+ }),
337
+ );
338
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
339
+ fireEvent(
340
+ window,
341
+ new StorageEvent('storage', {
342
+ key: 'nocobase:crm:theme:preset',
343
+ newValue: 'default',
344
+ }),
345
+ );
346
+ expect(await screen.findByRole('radio', { name: 'Default' })).toBeChecked();
347
+ await userEvent.keyboard('{Escape}');
348
+ await waitFor(() =>
349
+ expect(screen.getByRole('button', { name: 'Appearance' })).toHaveFocus(),
350
+ );
351
+ });
352
+
353
+ it('restores a saved preset and resets both selections when storage is cleared', async () => {
354
+ localStorage.setItem('nocobase:crm:theme:preset', 'default');
355
+ localStorage.setItem('nocobase:crm:theme:color-scheme', 'light');
356
+ render(
357
+ <AppThemeProvider>
358
+ <ThemeProbe />
359
+ </AppThemeProvider>,
360
+ );
361
+ await waitFor(() =>
362
+ expect(document.documentElement).toHaveAttribute('data-theme', 'default'),
363
+ );
364
+ expect(document.documentElement).toHaveClass('light');
365
+ localStorage.clear();
366
+ fireEvent(window, new StorageEvent('storage', { key: null }));
367
+ await waitFor(() => expect(document.documentElement).toHaveClass('dark'));
368
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
369
+ });
370
+ it('keeps selections usable when browser storage is unavailable', async () => {
371
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
372
+ throw new Error('blocked');
373
+ });
374
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
375
+ throw new Error('blocked');
376
+ });
377
+ render(
378
+ <AppThemeProvider>
379
+ <ThemeSettings />
380
+ </AppThemeProvider>,
381
+ );
382
+ await openAppearance();
383
+ await userEvent.click(
384
+ await screen.findByRole('radio', { name: 'Default' }),
385
+ );
386
+ await userEvent.click(await screen.findByRole('radio', { name: 'Light' }));
387
+ expect(document.documentElement).toHaveAttribute('data-theme', 'default');
388
+ expect(document.documentElement).toHaveClass('light');
389
+ });
390
+
391
+ it('syncs valid modes, normalizes invalid modes and removed presets', async () => {
392
+ render(
393
+ <AppThemeProvider>
394
+ <ThemeSettings />
395
+ </AppThemeProvider>,
396
+ );
397
+ await openAppearance();
398
+ await userEvent.click(
399
+ await screen.findByRole('radio', { name: 'Default' }),
400
+ );
401
+ fireEvent(
402
+ window,
403
+ new StorageEvent('storage', {
404
+ key: 'nocobase:crm:theme:color-scheme',
405
+ newValue: 'light',
406
+ }),
407
+ );
408
+ await waitFor(() => expect(document.documentElement).toHaveClass('light'));
409
+ fireEvent(
410
+ window,
411
+ new StorageEvent('storage', {
412
+ key: 'nocobase:crm:theme:preset',
413
+ newValue: 'removed',
414
+ }),
415
+ );
416
+ expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
417
+ fireEvent(
418
+ window,
419
+ new StorageEvent('storage', {
420
+ key: 'nocobase:crm:theme:color-scheme',
421
+ newValue: 'invalid',
422
+ }),
423
+ );
424
+ await waitFor(() => expect(document.documentElement).toHaveClass('dark'));
425
+ expect(await screen.findByRole('radio', { name: 'System' })).toBeChecked();
426
+ });
427
+ });
428
+
429
+ function ThemeProbe(): ReactElement {
430
+ const { resolvedTheme, setTheme, theme } = useTheme();
431
+
432
+ return (
433
+ <>
434
+ <span data-testid='theme'>{theme}</span>
435
+ <span data-testid='resolved-theme'>{resolvedTheme}</span>
436
+ <button type='button' onClick={() => setTheme('light')}>
437
+ Use light theme
438
+ </button>
439
+ </>
440
+ );
441
+ }
@@ -0,0 +1,197 @@
1
+ // @vitest-environment node
2
+
3
+ import { fileURLToPath } from 'node:url';
4
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ import type { AuthorizationConfig } from '@nocobase/app-plugin-authorization/server';
9
+ import { type AppIdentityConfig } from '@nocobase/app-server/config';
10
+ import { type AppDatabaseConfig } from '@nocobase/app-server/database';
11
+ import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
12
+ import type { AppJobsConfig } from '@nocobase/app-server/jobs';
13
+ import {
14
+ type CachingConfig,
15
+ type AppDriveConfig,
16
+ type AppLoggingConfig,
17
+ type AppQueueConfig,
18
+ type AppSessionConfigInput,
19
+ } from '@nocobase/app-server';
20
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
21
+
22
+ import appRuntime from '../../server/runtime.ts';
23
+
24
+ const templateRootDir = fileURLToPath(new URL('../..', import.meta.url));
25
+
26
+ describe('application config', () => {
27
+ let configRoot: string;
28
+ let configPath: string;
29
+ beforeAll(async () => {
30
+ configRoot = await mkdtemp(path.join(os.tmpdir(), 'app-config-test-'));
31
+ configPath = path.join(configRoot, 'config.yml');
32
+ await writeFile(configPath, '{}');
33
+ });
34
+ afterAll(async () => {
35
+ await rm(configRoot, { recursive: true, force: true });
36
+ });
37
+
38
+ it('assembles module defaults in the runtime', async () => {
39
+ const runtime = await resolveStandaloneAppRuntime(appRuntime, {
40
+ rootDir: templateRootDir,
41
+ configPath,
42
+ env: { AUTH_SECRET: 'test-auth-secret-at-least-32-characters' },
43
+ });
44
+
45
+ expect(runtime.config.get<AppIdentityConfig>('app')!.name).toBe('main');
46
+ const authorization =
47
+ runtime.config.get<AuthorizationConfig>('authorization')!;
48
+ expect(authorization.permissionSets).toEqual({
49
+ rootSet: 'root',
50
+ defaultSet: 'member',
51
+ });
52
+ expect(authorization.plugins?.map((plugin) => plugin.id)).toEqual([
53
+ 'default-access',
54
+ 'sharing-rules',
55
+ 'restriction-rules',
56
+ ]);
57
+ expect(runtime.config.get<CachingConfig>('caching')!.default).toBe(
58
+ 'memory',
59
+ );
60
+ expect(runtime.config.get<AppDatabaseConfig>('database')!.default).toBe(
61
+ 'main',
62
+ );
63
+ const drive = runtime.config.get<AppDriveConfig>('drive')!;
64
+ expect(drive.default).toBe('local');
65
+ expect(drive.disks.local).toEqual({
66
+ driver: 'fs',
67
+ location: fileURLToPath(new URL('../../storage', import.meta.url)),
68
+ visibility: 'private',
69
+ });
70
+ expect(drive.disks.public).toBeUndefined();
71
+ expect(
72
+ runtime.config.get<AppLoggingConfig>('logging')!.default,
73
+ ).toBeUndefined();
74
+ expect(runtime.config.get('logging.file.name')).toBe('app');
75
+ // No default: queues run on the built-in memory configuration until one is named.
76
+ expect(runtime.config.get<AppQueueConfig>('queue')).toEqual({
77
+ memory: {
78
+ adapter: 'inMemory',
79
+ persistence: { path: runtime.paths.storage('queue') },
80
+ },
81
+ redis: {
82
+ adapter: 'redis',
83
+ connection: { host: '127.0.0.1', port: 6379, db: 0 },
84
+ removeOnComplete: { count: 1000 },
85
+ removeOnFail: { age: 604_800 },
86
+ },
87
+ });
88
+ expect(runtime.config.get<AppJobsConfig>('jobs')).toEqual({
89
+ memory: {
90
+ adapter: 'memory',
91
+ persistence: { path: runtime.paths.storage('jobs') },
92
+ },
93
+ redis: {
94
+ adapter: 'redis',
95
+ connection: { host: '127.0.0.1', port: 6379, db: 0 },
96
+ removeOnComplete: { count: 1000 },
97
+ removeOnFail: { age: 604_800 },
98
+ },
99
+ });
100
+ expect(runtime.config.get<AppSessionConfigInput>('session')!.default).toBe(
101
+ 'memory',
102
+ );
103
+ });
104
+
105
+ it('reads the secrets keys from SECRETS_KEYS and rejects a weak one', async () => {
106
+ const key = 'a'.repeat(64);
107
+ const runtime = await resolveStandaloneAppRuntime(appRuntime, {
108
+ rootDir: templateRootDir,
109
+ configPath,
110
+ env: { SECRETS_KEYS: `2:${key},1:short` },
111
+ });
112
+
113
+ expect(runtime.config.get('secrets.keys')).toEqual([
114
+ { version: 2, key },
115
+ { version: 1, key: 'short' },
116
+ ]);
117
+ expect(
118
+ (await runtime.config.validate()).filter(
119
+ (issue) => issue.level === 'error',
120
+ ),
121
+ ).toEqual([
122
+ expect.objectContaining({
123
+ path: 'secrets.keys.1',
124
+ message: expect.stringContaining('too short'),
125
+ }),
126
+ ]);
127
+ });
128
+
129
+ it('reloads a file-backed configuration explicitly', async () => {
130
+ const runtime = await resolveStandaloneAppRuntime(appRuntime, {
131
+ rootDir: templateRootDir,
132
+ configPath,
133
+ env: { AUTH_SECRET: 'test-auth-secret-at-least-32-characters' },
134
+ });
135
+
136
+ const result = await runtime.config.reload();
137
+
138
+ expect(result.changedNamespaces).toEqual([]);
139
+ });
140
+ it('lets the environment select the jobs configuration Scheduler runs on', async () => {
141
+ const defaults = await resolveStandaloneAppRuntime(appRuntime, {
142
+ rootDir: templateRootDir,
143
+ configPath,
144
+ });
145
+ expect(defaults.config.get('scheduler')).toEqual({});
146
+
147
+ const runtime = await resolveStandaloneAppRuntime(appRuntime, {
148
+ rootDir: templateRootDir,
149
+ configPath,
150
+ env: { SCHEDULER_JOBS: 'redis' },
151
+ });
152
+
153
+ expect(runtime.config.get('scheduler.jobs')).toBe('redis');
154
+ });
155
+
156
+ it('scopes the session cookie to the application base path', async () => {
157
+ const defaults = await resolveStandaloneAppRuntime(appRuntime, {
158
+ rootDir: templateRootDir,
159
+ configPath,
160
+ });
161
+ expect(defaults.config.get('session.cookie.path')).toBe('/main');
162
+
163
+ // Two applications on one origin keep separate session cookies.
164
+ const mounted = await resolveStandaloneAppRuntime(appRuntime, {
165
+ rootDir: templateRootDir,
166
+ configPath,
167
+ env: { APP_BASE_PATH: '/crm/' },
168
+ });
169
+ expect(mounted.config.get('session.cookie.path')).toBe('/crm');
170
+ expect(mounted.config.get('session.cookie.name')).toBe('nocobase_session');
171
+ });
172
+
173
+ it('loads only explicit env overrides and restores defaults on reload', async () => {
174
+ const runtime = await resolveStandaloneAppRuntime(appRuntime, {
175
+ rootDir: templateRootDir,
176
+ configPath,
177
+ env: {
178
+ APP_SERVER_PORT: '14001',
179
+ APP_SERVER_START_LOG: 'false',
180
+ REDIS_HOST: 'ignored',
181
+ NODE_ENV: 'production',
182
+ },
183
+ });
184
+ expect(runtime.config.get('server.port')).toBe(14001);
185
+ expect(runtime.config.get('server.startLog')).toBe(false);
186
+ expect(runtime.config.get('queue.redis.connection.host')).toBe('127.0.0.1');
187
+ expect(runtime.config.get('session.stores.redis.host')).toBe('127.0.0.1');
188
+ expect(runtime.config.get('logging.console.pretty')).toBe(false);
189
+ expect(runtime.config.get('session.cookie.secure')).toBe(true);
190
+ expect(runtime.config.get('workflow.production')).toBe(true);
191
+ delete runtime.env.APP_SERVER_PORT;
192
+ delete runtime.env.APP_SERVER_START_LOG;
193
+ await runtime.config.reload();
194
+ expect(runtime.config.get('server.port')).toBe(13000);
195
+ expect(runtime.config.get('server.startLog')).toBe(true);
196
+ });
197
+ });