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

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 (249) 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 +434 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +485 -0
  10. package/cli/plugins.ts +8 -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 +37 -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 +156 -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 +198 -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/api.ts +8 -0
  165. package/server/config/app.ts +40 -0
  166. package/server/config/auth.ts +36 -0
  167. package/server/config/authorization.ts +18 -0
  168. package/server/config/caching.ts +21 -0
  169. package/server/config/database.ts +22 -0
  170. package/server/config/drive.ts +30 -0
  171. package/server/config/i18n.ts +17 -0
  172. package/server/config/index.ts +67 -0
  173. package/server/config/jobs.ts +27 -0
  174. package/server/config/logging.ts +23 -0
  175. package/server/config/notification.ts +11 -0
  176. package/server/config/queue.ts +28 -0
  177. package/server/config/scheduler.ts +23 -0
  178. package/server/config/secrets.ts +10 -0
  179. package/server/config/server.ts +26 -0
  180. package/server/config/session.ts +60 -0
  181. package/server/config/snowflake.ts +21 -0
  182. package/server/config/spa.ts +24 -0
  183. package/server/config/users.ts +11 -0
  184. package/server/config/workflow.ts +24 -0
  185. package/server/config.ts +24 -0
  186. package/server/embedded.ts +23 -0
  187. package/server/locales/en-US.ts +10 -0
  188. package/server/locales/index.ts +10 -0
  189. package/server/locales/zh-CN.ts +5 -0
  190. package/server/plugins.ts +37 -0
  191. package/server/providers/index.ts +5 -0
  192. package/server/routes/index.ts +6 -0
  193. package/server/runtime.ts +24 -0
  194. package/server/standalone.ts +29 -0
  195. package/server/tsconfig.json +4 -0
  196. package/tailwind.config.mjs +59 -0
  197. package/tests/components/app-layout-routes.test.tsx +132 -0
  198. package/tests/components/app-title.test.tsx +57 -0
  199. package/tests/components/auth-i18n.test.tsx +87 -0
  200. package/tests/components/back-button.test.tsx +80 -0
  201. package/tests/components/file-preview.test.tsx +101 -0
  202. package/tests/components/header-hover.test.tsx +87 -0
  203. package/tests/components/inbox.test.tsx +139 -0
  204. package/tests/components/language-switcher.test.tsx +298 -0
  205. package/tests/components/layout-containers.test.tsx +15 -0
  206. package/tests/components/layout-i18n.test.tsx +187 -0
  207. package/tests/components/loading.test.tsx +25 -0
  208. package/tests/components/navigation-menu.test.tsx +355 -0
  209. package/tests/components/page-harness.test.tsx +318 -0
  210. package/tests/components/primitive-labels.test.tsx +80 -0
  211. package/tests/components/sidebar-permissions.test.tsx +110 -0
  212. package/tests/components/sidebar-preference.test.tsx +42 -0
  213. package/tests/fixtures/theme-tokens.html +12 -0
  214. package/tests/fixtures/theme-tokens.tsx +111 -0
  215. package/tests/logic/account-permissions.test.tsx +204 -0
  216. package/tests/logic/app-locale-coverage.test.ts +136 -0
  217. package/tests/logic/app-server.test.ts +1589 -0
  218. package/tests/logic/asset-url-build.test.ts +106 -0
  219. package/tests/logic/client-auth.test.tsx +122 -0
  220. package/tests/logic/client-plugin-registry.test.ts +63 -0
  221. package/tests/logic/client-route-recovery.test.tsx +80 -0
  222. package/tests/logic/client-routes.test.ts +126 -0
  223. package/tests/logic/client-runtime.test.ts +112 -0
  224. package/tests/logic/client-settings.test.tsx +841 -0
  225. package/tests/logic/client-shell.test.tsx +466 -0
  226. package/tests/logic/client-theme.test.tsx +441 -0
  227. package/tests/logic/config.test.ts +197 -0
  228. package/tests/logic/dev-client-watch.test.ts +108 -0
  229. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  230. package/tests/logic/device-code-migration.test.ts +70 -0
  231. package/tests/logic/lifecycle.test.ts +221 -0
  232. package/tests/logic/plugin-commands.test.ts +69 -0
  233. package/tests/logic/plugin-page-authz.test.ts +53 -0
  234. package/tests/logic/route-navigation.test.ts +66 -0
  235. package/tests/logic/route-overlay.test.tsx +353 -0
  236. package/tests/logic/server-imports.test.ts +88 -0
  237. package/tests/logic/tailwind-sources.test.ts +151 -0
  238. package/tests/logic/template-package-name.test.ts +88 -0
  239. package/tests/logic/theme-preferences.test.ts +86 -0
  240. package/tests/logic/theme-tokens.test.ts +248 -0
  241. package/tests/logic/toaster.test.tsx +108 -0
  242. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  243. package/tests/setup/client-config.ts +27 -0
  244. package/tsconfig.json +11 -0
  245. package/tsconfig.node.json +12 -0
  246. package/tsconfig.server.json +19 -0
  247. package/vite.config.ts +92 -0
  248. package/vitest.config.ts +44 -0
  249. package/README.md +0 -3
@@ -0,0 +1,1589 @@
1
+ import { createApp } from '../../server/app.js';
2
+ import authConfig from '../../server/config/auth.js';
3
+ // @vitest-environment node
4
+
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import {
7
+ createTestAppConfig,
8
+ type TestAppConfig,
9
+ } from '@nocobase/app-testing/server';
10
+ import {
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ rmSync,
14
+ symlinkSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { createServer as createHttpServer, type Server } from 'node:http';
18
+ import { createRequire } from 'node:module';
19
+ import type { AddressInfo } from 'node:net';
20
+ import { tmpdir } from 'node:os';
21
+ import path from 'node:path';
22
+ import { Hono } from 'hono';
23
+ import type { Knex } from 'knex';
24
+ import { createDefaultCachingConfig } from '@nocobase/caching';
25
+ import { CachingProvider } from '@nocobase/app-server/caching';
26
+ import { DriveProvider } from '@nocobase/app-server/drive';
27
+ import { IdGeneratorProvider } from '@nocobase/app-server/id-generator';
28
+ import {
29
+ LoggingProvider,
30
+ requestLoggingMiddleware,
31
+ } from '@nocobase/app-server/logging';
32
+ import {
33
+ QueueServiceProvider,
34
+ type AppQueueConfig,
35
+ } from '@nocobase/app-server/queue';
36
+ import {
37
+ SessionProvider,
38
+ sessionHttpMiddleware,
39
+ } from '@nocobase/app-server/session';
40
+ import { createAppPaths } from '@nocobase/app-server/config';
41
+ import {
42
+ type AppIdentityConfig,
43
+ type AppConfigAccessor,
44
+ } from '@nocobase/app-server/config';
45
+ import { startNodeAppServer } from '@nocobase/app-server/node';
46
+ import {
47
+ createRealtimeService,
48
+ realtimeServiceToken,
49
+ type RealtimeServerMessage,
50
+ } from '@nocobase/app-server/realtime';
51
+ import {
52
+ defineApiRoutes,
53
+ healthCheckApiRoutes,
54
+ } from '@nocobase/app-server/router';
55
+ import {
56
+ createServiceToken,
57
+ ServiceContainer,
58
+ ServiceProvider,
59
+ ServiceProviderRegistry,
60
+ } from '@nocobase/service-provider';
61
+ import type {
62
+ AppWebSocket,
63
+ AppWebSocketReadyState,
64
+ } from '@nocobase/app-websocket';
65
+ import {
66
+ databaseManagerToken,
67
+ type DatabaseManager,
68
+ type QueryAdapter,
69
+ } from '@nocobase/db';
70
+ import { createSilentLoggingConfig } from '@nocobase/logging';
71
+ import { spaRootRoutes } from '@nocobase/app-server/spa';
72
+ import { createNullSessionConfig } from '@nocobase/session';
73
+ import {
74
+ joinBasePath,
75
+ normalizeBasePath,
76
+ resolveAppNameFromBasePath,
77
+ } from '@nocobase/app-server/support';
78
+ import {
79
+ createAppDisposerRegistry,
80
+ createPublicBasePathAdapter,
81
+ resolveAppRuntime,
82
+ type AppDisposer,
83
+ type AppScope,
84
+ } from '@nocobase/app-server/runtime';
85
+ import { Application } from '@nocobase/app-server/application';
86
+ import {
87
+ defineServerPlugin,
88
+ defineServerPlugins,
89
+ type AppServerPlugin,
90
+ type ResolvedAppServerPlugins,
91
+ } from '@nocobase/app-server/plugins';
92
+ import authenticationServerPlugin from '@nocobase/app-plugin-authentication/server';
93
+ import authorizationServerPlugin from '@nocobase/app-plugin-authorization/server';
94
+
95
+ import { createServer as createEmbeddedServer } from '../../server/embedded.ts';
96
+ import { createStandaloneRuntimeScope } from '@nocobase/app-server/node';
97
+ import appRuntime from '../../server/runtime.ts';
98
+ import serverPlugins from '../../server/plugins.ts';
99
+ import {
100
+ createStandaloneServer,
101
+ type StandaloneServer,
102
+ type StandaloneServerOptions,
103
+ } from '../../server/standalone.ts';
104
+ type AppConfig = object;
105
+
106
+ process.env.AUTH_SECRET ??= 'test-auth-secret-at-least-32-characters';
107
+
108
+ interface CloseableResource {
109
+ close(): Promise<void>;
110
+ }
111
+
112
+ interface FetchableResource {
113
+ readonly fetch: (request: Request) => Response | Promise<Response>;
114
+ }
115
+
116
+ type TestApp = Application<AppConfig> & CloseableResource;
117
+
118
+ type TestStandaloneServerOptions = StandaloneServerOptions & {
119
+ readonly clientDir?: string;
120
+ };
121
+
122
+ interface RegisteredTestDisposer {
123
+ name: string;
124
+ dispose: AppDisposer;
125
+ }
126
+
127
+ const apps: CloseableResource[] = [];
128
+ const servers: Server[] = [];
129
+ const tempDirs: string[] = [];
130
+ /** Test databases the applications above ran on, dropped once those applications have closed. */
131
+ const testConfigs: TestAppConfig[] = [];
132
+ const TEST_REALTIME_TOPIC = 'test:realtime';
133
+ const require = createRequire(import.meta.url);
134
+
135
+ function requestApp(
136
+ app: FetchableResource,
137
+ input: Request | string | URL,
138
+ requestInit?: RequestInit,
139
+ ): Response | Promise<Response> {
140
+ const request =
141
+ input instanceof Request ? input : new Request(input, requestInit);
142
+ return app.fetch(request);
143
+ }
144
+
145
+ afterEach(async () => {
146
+ vi.unstubAllEnvs();
147
+ await Promise.all(apps.splice(0).map((app) => app.close()));
148
+ await Promise.all(testConfigs.splice(0).map((config) => config.dispose()));
149
+
150
+ for (const dir of tempDirs.splice(0)) {
151
+ rmSync(dir, { recursive: true, force: true });
152
+ }
153
+
154
+ await Promise.all(
155
+ servers.splice(0).map(
156
+ (server) =>
157
+ new Promise<void>((resolve, reject) => {
158
+ server.close((error) => {
159
+ if (error) {
160
+ reject(error);
161
+ return;
162
+ }
163
+
164
+ resolve();
165
+ });
166
+ }),
167
+ ),
168
+ );
169
+ });
170
+
171
+ describe('app server', () => {
172
+ it('encrypts stored OAuth tokens', () => {
173
+ expect(authConfig({} as never).account?.encryptOAuthTokens).toBe(true);
174
+ });
175
+
176
+ it('represents the NocoBase application separately from its Hono router', () => {
177
+ const app = createTestApp({
178
+ publicBasePath: '/app-template-default',
179
+ });
180
+
181
+ expect(app).toBeInstanceOf(Application);
182
+ expect(app.router).not.toBe(app);
183
+ expect(app.container).toBeDefined();
184
+ expect(app.appName).toBe('app-template-default');
185
+ expect(app.publicBasePath).toBe('/app-template-default');
186
+ expect(app.config.get<AppIdentityConfig>('app')!.publicBasePath).toBe(
187
+ '/app-template-default',
188
+ );
189
+ });
190
+
191
+ it('passes the application to plugin providers', () => {
192
+ let providerApplication: unknown;
193
+ class TestPluginProvider extends ServiceProvider {
194
+ public readonly name: string = '@nocobase/app-plugin-test';
195
+
196
+ public override register(): void {
197
+ providerApplication = this.app;
198
+ }
199
+ }
200
+
201
+ const app = createTestApp({
202
+ plugins: [
203
+ defineServerPlugin<AppConfig>({
204
+ baseDir: import.meta.dirname,
205
+ packageName: '@nocobase/app-plugin-test',
206
+ serviceProviders: [TestPluginProvider],
207
+ }),
208
+ ],
209
+ });
210
+
211
+ expect(providerApplication).toBe(app);
212
+ expect(app.container).toBeInstanceOf(ServiceContainer);
213
+ expect(app.container.resolve(realtimeServiceToken)).toBeDefined();
214
+ });
215
+
216
+ it('registers plugin API routes through the dedicated API router', async () => {
217
+ const app = createTestApp({
218
+ plugins: [
219
+ defineServerPlugin<AppConfig>({
220
+ baseDir: import.meta.dirname,
221
+ packageName: '@nocobase/app-plugin-test',
222
+ routes: [
223
+ defineApiRoutes((application) => {
224
+ const router = new Hono();
225
+ router.get('/plugin-test', (context) =>
226
+ context.json({ appName: application.appName }),
227
+ );
228
+ return router;
229
+ }),
230
+ ],
231
+ }),
232
+ ],
233
+ });
234
+
235
+ await app.start();
236
+
237
+ const response = await requestApp(app, 'http://localhost/api/plugin-test');
238
+ expect(response.status).toBe(200);
239
+ expect(await response.json()).toEqual({ appName: app.appName });
240
+ });
241
+
242
+ it('starts service providers only through the asynchronous runtime path', async () => {
243
+ const boot = vi.spyOn(ServiceProviderRegistry.prototype, 'bootAll');
244
+ const start = vi.spyOn(ServiceProviderRegistry.prototype, 'startAll');
245
+ const ready = vi.spyOn(ServiceProviderRegistry.prototype, 'readyAll');
246
+
247
+ try {
248
+ createTestApp({
249
+ publicBasePath: '/low-level-app',
250
+ });
251
+
252
+ expect(boot).not.toHaveBeenCalled();
253
+ expect(start).not.toHaveBeenCalled();
254
+ expect(ready).not.toHaveBeenCalled();
255
+
256
+ await createEmbeddedServer(
257
+ await createEmbeddedTestScope({
258
+ id: 'provider-lifecycle-app',
259
+ basePath: '/provider-lifecycle-app',
260
+ }),
261
+ );
262
+
263
+ expect(boot).toHaveBeenCalledOnce();
264
+ expect(start).toHaveBeenCalledOnce();
265
+ expect(ready).toHaveBeenCalledOnce();
266
+ expect(boot.mock.invocationCallOrder[0]).toBeLessThan(
267
+ start.mock.invocationCallOrder[0],
268
+ );
269
+ expect(start.mock.invocationCallOrder[0]).toBeLessThan(
270
+ ready.mock.invocationCallOrder[0],
271
+ );
272
+ } finally {
273
+ boot.mockRestore();
274
+ start.mockRestore();
275
+ ready.mockRestore();
276
+ }
277
+ });
278
+
279
+ it('starts without optional plugins or workflow routes', async () => {
280
+ const app = createTestApp();
281
+
282
+ expect(
283
+ app.router.routes.some((route) => route.path.includes('/workflows')),
284
+ ).toBe(false);
285
+ });
286
+
287
+ it('creates embedded apps from a scope', async () => {
288
+ const app = await createEmbeddedServer(
289
+ await createEmbeddedTestScope({
290
+ id: 'app-template-default',
291
+ basePath: '/embedded-app-template-default',
292
+ }),
293
+ );
294
+
295
+ const response = await requestApp(app, 'http://localhost/api/healthz');
296
+ const websocketEvents = await app.websocket?.(
297
+ new Request('http://localhost/ws'),
298
+ );
299
+
300
+ await expect(response.json()).resolves.toEqual({
301
+ ok: true,
302
+ app: {
303
+ name: app.appName,
304
+ basePath: '/embedded-app-template-default',
305
+ },
306
+ basePath: '/embedded-app-template-default',
307
+ });
308
+ expect(websocketEvents).toMatchObject({
309
+ onMessage: expect.any(Function),
310
+ });
311
+ });
312
+
313
+ it('registers application-owned providers and routes from the runtime', async () => {
314
+ const pluginServiceToken = createServiceToken<string>(
315
+ '@nocobase/app-template-default/tests/plugin-service',
316
+ );
317
+ const providerCalls: string[] = [];
318
+ class TestRuntimePluginProvider extends ServiceProvider<
319
+ Application<AppConfig>
320
+ > {
321
+ public readonly name: string = '@nocobase/app-plugin-runtime-test';
322
+
323
+ public override register(): void {
324
+ providerCalls.push('plugin');
325
+ this.app.container.instance(pluginServiceToken, 'plugin service');
326
+ }
327
+ }
328
+ class TestRuntimeApplicationProvider extends ServiceProvider<
329
+ Application<AppConfig>
330
+ > {
331
+ public readonly name: string =
332
+ '@nocobase/app-template-default/runtime-test';
333
+
334
+ public override register(): void {
335
+ providerCalls.push(this.app.container.resolve(pluginServiceToken));
336
+ }
337
+ }
338
+ const scope = await createEmbeddedTestScope({
339
+ id: 'app-template-default',
340
+ basePath: '/embedded-app-template-default',
341
+ });
342
+ const resolvedRuntime = await resolveAppRuntime(
343
+ {
344
+ ...appRuntime,
345
+ plugins: defineServerPlugins<AppConfig>([]),
346
+ routes: [
347
+ defineApiRoutes(() =>
348
+ new Hono().get('/test-runtime', (c) => c.json({ ok: true })),
349
+ ),
350
+ ],
351
+ serviceProviders: [
352
+ ...appRuntime.serviceProviders,
353
+ TestRuntimeApplicationProvider,
354
+ ],
355
+ },
356
+ scope,
357
+ );
358
+ const application = createApp({
359
+ ...resolvedRuntime,
360
+ plugins: createResolvedTestServerPlugins([
361
+ defineServerPlugin<AppConfig>({
362
+ baseDir: import.meta.dirname,
363
+ packageName: '@nocobase/app-plugin-runtime-test',
364
+ serviceProviders: [TestRuntimePluginProvider],
365
+ }),
366
+ ]),
367
+ });
368
+ const app = trackCloseable(
369
+ Object.assign(application, {
370
+ close(): Promise<void> {
371
+ return application.shutdown();
372
+ },
373
+ }),
374
+ );
375
+ await app.start();
376
+
377
+ expect(providerCalls).toEqual(['plugin', 'plugin service']);
378
+ const response = await requestApp(app, 'http://localhost/api/test-runtime');
379
+ expect(response.status).toBe(200);
380
+ await expect(response.json()).resolves.toEqual({ ok: true });
381
+ });
382
+
383
+ it('routes authentication requests through the configured public auth URL', async () => {
384
+ const app = createTestApp({
385
+ publicOrigin: 'http://localhost',
386
+ publicBasePath: '/main',
387
+ });
388
+ const mounted = createPublicBasePathAdapter(app, '/main');
389
+
390
+ const response = await requestApp(
391
+ mounted,
392
+ 'http://localhost/main/api/auth/sign-in/username',
393
+ {
394
+ method: 'POST',
395
+ headers: { 'content-type': 'application/json' },
396
+ body: JSON.stringify({
397
+ username: 'missing-user',
398
+ password: 'not-the-password',
399
+ }),
400
+ },
401
+ );
402
+
403
+ expect(response.status).toBe(422);
404
+ await expect(response.json()).resolves.toEqual({
405
+ code: 'INVALID_USERNAME',
406
+ message: 'Username is invalid',
407
+ });
408
+ });
409
+
410
+ it('exposes an app-local WebSocket handler outside the API namespace', async () => {
411
+ const app = createTestApp({
412
+ publicBasePath: '/app-template-default',
413
+ });
414
+
415
+ const response = await requestApp(app, 'http://localhost/ws');
416
+ const websocketEvents = await app.websocket?.(
417
+ new Request('http://localhost/ws'),
418
+ );
419
+ const missingEvents = await app.websocket?.(
420
+ new Request('http://localhost/missing-ws'),
421
+ );
422
+
423
+ expect(response.status).toBe(426);
424
+ expect(response.headers.get('upgrade')).toBe('websocket');
425
+ await expect(response.json()).resolves.toEqual({
426
+ error: 'WebSocket upgrade required',
427
+ });
428
+ expect(websocketEvents).toMatchObject({
429
+ onMessage: expect.any(Function),
430
+ });
431
+ expect(missingEvents).toBeNull();
432
+ });
433
+
434
+ it('subscribes, publishes, and unsubscribes realtime messages', () => {
435
+ const realtime = createRealtimeService();
436
+ const websocket = createTestWebSocket();
437
+ const connection = realtime.connect(websocket);
438
+
439
+ realtime.handleClientMessage(
440
+ connection,
441
+ JSON.stringify({
442
+ type: 'subscribe',
443
+ id: 'subscribe-test-topic',
444
+ topic: TEST_REALTIME_TOPIC,
445
+ }),
446
+ );
447
+ const subscribed = websocket.messages[0];
448
+
449
+ expect(subscribed).toMatchObject({
450
+ type: 'subscribed',
451
+ id: 'subscribe-test-topic',
452
+ topic: TEST_REALTIME_TOPIC,
453
+ subscriptionId: expect.any(String),
454
+ });
455
+
456
+ realtime.publish(TEST_REALTIME_TOPIC, 'tick');
457
+
458
+ expect(websocket.messages[1]).toMatchObject({
459
+ type: 'event',
460
+ topic: TEST_REALTIME_TOPIC,
461
+ payload: 'tick',
462
+ publishedAt: expect.any(String),
463
+ });
464
+
465
+ realtime.handleClientMessage(
466
+ connection,
467
+ JSON.stringify({
468
+ type: 'unsubscribe',
469
+ id: 'unsubscribe-test-topic',
470
+ subscriptionId: (subscribed as { subscriptionId: string })
471
+ .subscriptionId,
472
+ }),
473
+ );
474
+ realtime.publish(TEST_REALTIME_TOPIC, 'after unsubscribe');
475
+
476
+ expect(websocket.messages[2]).toMatchObject({
477
+ type: 'unsubscribed',
478
+ id: 'unsubscribe-test-topic',
479
+ subscriptionId: (subscribed as { subscriptionId: string }).subscriptionId,
480
+ topic: TEST_REALTIME_TOPIC,
481
+ });
482
+ expect(websocket.messages).toHaveLength(3);
483
+
484
+ realtime.close();
485
+ });
486
+
487
+ it('registers embedded app resources with the scope', async () => {
488
+ const registeredDisposers: RegisteredTestDisposer[] = [];
489
+ const app = await createEmbeddedServer(
490
+ await createEmbeddedTestScope(
491
+ {
492
+ id: 'app-template-default',
493
+ basePath: '/embedded-app-template-default',
494
+ },
495
+ registeredDisposers,
496
+ ),
497
+ );
498
+
499
+ expect(typeof (app as { close?: unknown }).close).toBe('undefined');
500
+ expect(registeredDisposers.map((disposer) => disposer.name)).toEqual([
501
+ 'application',
502
+ ]);
503
+
504
+ for (const disposer of [...registeredDisposers].reverse()) {
505
+ await expect(disposer.dispose()).resolves.toBeUndefined();
506
+ await expect(disposer.dispose()).resolves.toBeUndefined();
507
+ }
508
+ });
509
+
510
+ it('serves embedded production SPA routes from the stripped app-host path', async () => {
511
+ const root = mkdtempSync(
512
+ path.join(tmpdir(), 'nocobase-app-template-default-embedded-client-'),
513
+ );
514
+ tempDirs.push(root);
515
+ writeFileSync(
516
+ path.join(root, 'index.html'),
517
+ '<div id="root"></div><script type="module" src="/app-template-default/assets/index.js"></script>',
518
+ );
519
+
520
+ const app = await createEmbeddedServer(
521
+ await createEmbeddedTestScope({
522
+ id: 'app-template-default',
523
+ basePath: '/app-template-default',
524
+ clientDir: root,
525
+ }),
526
+ );
527
+
528
+ const response = await requestApp(app, 'http://localhost/');
529
+ const html = await response.text();
530
+
531
+ expect(response.status).toBe(200);
532
+ expect(readRuntimeConfig(html).config).toMatchObject({
533
+ app: { basePath: '/app-template-default' },
534
+ api: { baseURL: '/app-template-default/api' },
535
+ });
536
+ // The configuration block is the client's only source of runtime values; nothing is put on `window`.
537
+ expect(html).not.toContain('window.');
538
+ });
539
+
540
+ it('publishes whether sign-up is open, and keeps the rest of auth off the page', async () => {
541
+ const appRoot = mkdtempSync(
542
+ path.join(tmpdir(), 'nocobase-app-template-default-embedded-root-'),
543
+ );
544
+ tempDirs.push(appRoot);
545
+ const clientDir = path.join(appRoot, 'dist', 'client');
546
+ mkdirSync(clientDir, { recursive: true });
547
+ createEmbeddedPluginFixture(appRoot);
548
+ writeFileSync(
549
+ path.join(appRoot, 'config.yml'),
550
+ [
551
+ 'auth:',
552
+ ' secret: test-auth-secret-at-least-32-characters',
553
+ ' emailAndPassword:',
554
+ ' disableSignUp: true',
555
+ ].join('\n'),
556
+ );
557
+ writeFileSync(
558
+ path.join(clientDir, 'index.html'),
559
+ '<script type="module" src="/app-template-default/assets/index.js"></script>',
560
+ );
561
+
562
+ const app = await createEmbeddedServer(
563
+ await createEmbeddedTestScope({
564
+ id: 'app-template-default',
565
+ basePath: '/app-template-default',
566
+ rootDir: appRoot,
567
+ clientDir,
568
+ }),
569
+ );
570
+
571
+ const html = await (await requestApp(app, 'http://localhost/')).text();
572
+ const payload = JSON.parse(
573
+ /<script id="nocobase-runtime-config" type="application\/json">(.*?)<\/script>/u.exec(
574
+ html,
575
+ )![1],
576
+ ) as { config: Record<string, unknown>; public: Record<string, unknown> };
577
+ expect(payload.public.auth).toEqual({
578
+ emailAndPassword: { enabled: true, disableSignUp: true },
579
+ });
580
+ expect(payload.config.auth).toBeUndefined();
581
+ expect(html).not.toContain('test-auth-secret-at-least-32-characters');
582
+ });
583
+
584
+ it('reads embedded runtime config from the application root without using process.env', async () => {
585
+ const appRoot = mkdtempSync(
586
+ path.join(tmpdir(), 'nocobase-app-template-default-embedded-root-'),
587
+ );
588
+ tempDirs.push(appRoot);
589
+ const clientDir = path.join(appRoot, 'dist', 'client');
590
+ mkdirSync(clientDir, { recursive: true });
591
+ createEmbeddedPluginFixture(appRoot);
592
+ writeFileSync(
593
+ path.join(appRoot, 'config.yml'),
594
+ [
595
+ 'auth:',
596
+ ' secret: test-auth-secret-at-least-32-characters',
597
+ 'i18n:',
598
+ ' defaultLocale: zh-CN',
599
+ ].join('\n'),
600
+ );
601
+ writeFileSync(
602
+ path.join(clientDir, 'index.html'),
603
+ '<script type="module" src="/app-template-default/assets/index.js"></script>',
604
+ );
605
+
606
+ const app = await createEmbeddedServer(
607
+ await createEmbeddedTestScope({
608
+ id: 'app-template-default',
609
+ basePath: '/app-template-default',
610
+ rootDir: appRoot,
611
+ clientDir,
612
+ }),
613
+ );
614
+
615
+ const page = await requestApp(app, 'http://localhost/');
616
+ const html = await page.text();
617
+ // Read from the application root's config.yml, not from process.env.
618
+ expect(readRuntimeConfig(html).public).toMatchObject({
619
+ i18n: { defaultLocale: 'zh-CN' },
620
+ });
621
+ });
622
+
623
+ it('requires a database for authentication', async () => {
624
+ const app = createTestApp({ database: false });
625
+ await expect(
626
+ requestApp(app, 'http://localhost/api/auth/session'),
627
+ ).rejects.toThrow('Authentication requires a database connection.');
628
+ });
629
+
630
+ it('keeps app-local API routes on the standalone server when Vite dev proxy is enabled', async () => {
631
+ let viteRequestCount = 0;
632
+ const viteDevUrl = await startHttpStub(() => {
633
+ viteRequestCount += 1;
634
+ });
635
+ const app = trackCloseable(
636
+ await createIsolatedStandaloneServer({ viteDevUrl }),
637
+ );
638
+ const publicBasePath = app.application.publicBasePath;
639
+
640
+ const response = await requestApp(
641
+ app,
642
+ `http://localhost${publicBasePath}/api/healthz`,
643
+ );
644
+
645
+ await expect(response.json()).resolves.toEqual({
646
+ ok: true,
647
+ app: {
648
+ name: app.application.appName,
649
+ basePath: app.application.publicBasePath,
650
+ },
651
+ basePath: app.application.publicBasePath,
652
+ });
653
+ expect(viteRequestCount).toBe(0);
654
+ });
655
+
656
+ it('starts a fresh Default without example tables or APIs', async () => {
657
+ const clientDir = mkdtempSync(
658
+ path.join(tmpdir(), 'nocobase-default-fresh-client-'),
659
+ );
660
+ tempDirs.push(clientDir);
661
+ const spaContent = '<main>Fresh Default test application</main>';
662
+ // The fallback must not depend on a previous local client build.
663
+ writeFileSync(path.join(clientDir, 'index.html'), spaContent);
664
+ const app = trackCloseable(
665
+ await createInstalledStandaloneServer({
666
+ viteDevUrl: false,
667
+ env: { APP_CONFIG_FILE: 'config.example.yml' },
668
+ clientDir,
669
+ }),
670
+ );
671
+ const database = app.application.container.resolve(databaseManagerToken);
672
+ const client = await database.connection('main').client<Knex>();
673
+ expect(await client.schema.hasTable('articles')).toBe(false);
674
+ const tables: string[] = [];
675
+ let cursor: string | undefined;
676
+ do {
677
+ const page = await database
678
+ .connection('main')
679
+ .schemaInspector.listPhysicalCollections(cursor ? { cursor } : {});
680
+ tables.push(...page.items.map((item) => item.tableName));
681
+ cursor = page.nextCursor;
682
+ } while (cursor);
683
+ expect(tables.some((name) => name.includes('example'))).toBe(false);
684
+ const baseUrl = `http://localhost${app.application.publicBasePath}`;
685
+ for (const endpoint of ['articles', 'example', 'routesExample']) {
686
+ const response = await requestApp(app, `${baseUrl}/api/${endpoint}`);
687
+ // An unregistered API path is a JSON 404, never the application page.
688
+ expect(response.status).toBe(404);
689
+ await expect(response.json()).resolves.toMatchObject({
690
+ error: { status: 'NOT_FOUND', reason: 'ROUTE_NOT_FOUND' },
691
+ });
692
+ }
693
+ });
694
+
695
+ it('serves Users and API Keys with the application authentication and permissions', async () => {
696
+ const app = trackCloseable(
697
+ await createInstalledStandaloneServer({ viteDevUrl: false }),
698
+ );
699
+ const baseUrl = `http://localhost${app.application.publicBasePath}`;
700
+ const anonymous = await requestApp(app, `${baseUrl}/api/users`);
701
+ expect(anonymous.status).toBe(401);
702
+
703
+ const signIn = await requestApp(
704
+ app,
705
+ `${baseUrl}/api/auth/sign-in/username`,
706
+ {
707
+ method: 'POST',
708
+ headers: { 'content-type': 'application/json' },
709
+ body: JSON.stringify({ username: 'nocobase', password: 'admin123' }),
710
+ },
711
+ );
712
+ expect(signIn.status).toBe(200);
713
+ const cookie = signIn.headers
714
+ .getSetCookie()
715
+ .map((header) => header.split(';')[0])
716
+ .join('; ');
717
+ const users = await requestApp(app, `${baseUrl}/api/users`, {
718
+ headers: { cookie },
719
+ });
720
+ expect(users.status).toBe(200);
721
+ const created = await requestApp(
722
+ app,
723
+ `${baseUrl}/api/auth/api-key/create`,
724
+ {
725
+ method: 'POST',
726
+ headers: { cookie, 'content-type': 'application/json' },
727
+ body: JSON.stringify({ name: 'template-integration' }),
728
+ },
729
+ );
730
+ expect(created.status).toBe(200);
731
+ const key = (await created.json()) as { id: string; key: string };
732
+ const authenticated = await requestApp(app, `${baseUrl}/api/users`, {
733
+ headers: { 'x-api-key': key.key },
734
+ });
735
+ expect(authenticated.status).toBe(200);
736
+ const revoked = await requestApp(
737
+ app,
738
+ `${baseUrl}/api/auth/api-key/delete`,
739
+ {
740
+ method: 'POST',
741
+ headers: { cookie, 'content-type': 'application/json' },
742
+ body: JSON.stringify({ keyId: key.id }),
743
+ },
744
+ );
745
+ expect(revoked.status).toBe(200);
746
+ const rejected = await requestApp(app, `${baseUrl}/api/users`, {
747
+ headers: { 'x-api-key': key.key },
748
+ });
749
+ expect(rejected.status).toBe(401);
750
+ });
751
+
752
+ it('mounts standalone app-local routes behind the public base path', async () => {
753
+ const app = trackCloseable(
754
+ await createIsolatedStandaloneServer({ viteDevUrl: false }),
755
+ );
756
+ const publicBasePath = app.application.publicBasePath;
757
+ const expectedHealth = {
758
+ ok: true,
759
+ app: {
760
+ name: app.application.appName,
761
+ basePath: publicBasePath,
762
+ },
763
+ basePath: publicBasePath,
764
+ };
765
+
766
+ const rootHealth = await requestApp(app, 'http://localhost/healthz');
767
+ const appHealth = await requestApp(
768
+ app,
769
+ `http://localhost${publicBasePath}/api/healthz`,
770
+ );
771
+ const bareLocalApi = await requestApp(app, 'http://localhost/api/healthz');
772
+
773
+ await expect(appHealth.json()).resolves.toEqual(expectedHealth);
774
+ expect(rootHealth.status).toBe(404);
775
+ expect(bareLocalApi.status).toBe(404);
776
+ await app.close();
777
+ });
778
+
779
+ it('mounts standalone WebSocket handlers behind the public base path', async () => {
780
+ const app = trackCloseable(
781
+ await createIsolatedStandaloneServer({ viteDevUrl: false }),
782
+ );
783
+ const publicBasePath = app.application.publicBasePath;
784
+
785
+ const bareResult = await app.websocket?.(
786
+ new Request('http://localhost/ws'),
787
+ );
788
+ const mountedResult = await app.websocket?.(
789
+ new Request(`http://localhost${publicBasePath}/ws`),
790
+ );
791
+
792
+ expect(bareResult).toBeNull();
793
+ expect(mountedResult).toMatchObject({
794
+ onMessage: expect.any(Function),
795
+ });
796
+ });
797
+
798
+ it('accepts standalone WebSocket upgrades through the public base path', async () => {
799
+ const app = trackCloseable(
800
+ await createIsolatedStandaloneServer({ viteDevUrl: false }),
801
+ );
802
+ const serverUrl = await startStandaloneTestServer(app);
803
+ const websocket = new WebSocket(
804
+ `${serverUrl}${app.application.publicBasePath}/ws`,
805
+ );
806
+
807
+ await waitForWebSocketOpen(websocket);
808
+ const subscribed = waitForWebSocketJsonMessage(
809
+ websocket,
810
+ (message) => message.type === 'subscribed',
811
+ );
812
+ websocket.send(
813
+ JSON.stringify({
814
+ type: 'subscribe',
815
+ id: 'test-topic',
816
+ topic: TEST_REALTIME_TOPIC,
817
+ }),
818
+ );
819
+
820
+ await expect(subscribed).resolves.toMatchObject({
821
+ type: 'subscribed',
822
+ id: 'test-topic',
823
+ topic: TEST_REALTIME_TOPIC,
824
+ subscriptionId: expect.any(String),
825
+ });
826
+
827
+ const close = waitForWebSocketClose(websocket);
828
+ websocket.close();
829
+ await close;
830
+ });
831
+
832
+ it('closes standalone WebSocket connections when the app closes', async () => {
833
+ const app = trackCloseable(
834
+ await createIsolatedStandaloneServer({ viteDevUrl: false }),
835
+ );
836
+ const serverUrl = await startStandaloneTestServer(app);
837
+ const websocket = new WebSocket(
838
+ `${serverUrl}${app.application.publicBasePath}/ws`,
839
+ );
840
+
841
+ await waitForWebSocketOpen(websocket);
842
+ const close = waitForWebSocketClose(websocket);
843
+
844
+ await app.close();
845
+
846
+ await expect(close).resolves.toMatchObject({
847
+ code: 1001,
848
+ reason: 'app runtime closed',
849
+ });
850
+ });
851
+
852
+ it('returns a closable standalone app', async () => {
853
+ const app = trackCloseable(
854
+ await createIsolatedStandaloneServer({ viteDevUrl: false }),
855
+ );
856
+
857
+ expect(typeof app.close).toBe('function');
858
+ await expect(app.close()).resolves.toBeUndefined();
859
+ await expect(app.close()).resolves.toBeUndefined();
860
+ });
861
+
862
+ it('owns standalone cancellation and disposal through its scope', async () => {
863
+ const scope = createStandaloneRuntimeScope({
864
+ rootDir: path.resolve(import.meta.dirname, '../..'),
865
+ env: { APP_VITE_DEV_URL: 'false' },
866
+ });
867
+ const events: string[] = [];
868
+ scope.onBeforeDestroy(() => {
869
+ events.push('before-destroy');
870
+ });
871
+ scope.registerDisposer('test-resource', () => {
872
+ events.push('dispose');
873
+ });
874
+
875
+ expect(scope.mode).toBe('standalone');
876
+ expect(scope.signal.aborted).toBe(false);
877
+
878
+ await scope.destroy();
879
+ await scope.destroy();
880
+
881
+ expect(scope.signal.aborted).toBe(true);
882
+ expect(events).toEqual(['before-destroy', 'dispose']);
883
+ });
884
+
885
+ it('proxies standalone SPA routes to Vite dev server with the public base path restored', async () => {
886
+ const viteDevUrl = await startHttpStub((_request, response) => {
887
+ response.setHeader('content-type', 'application/json; charset=utf-8');
888
+ response.end(
889
+ JSON.stringify({
890
+ method: _request.method,
891
+ url: _request.url,
892
+ origin: _request.headers.origin,
893
+ referer: _request.headers.referer,
894
+ }),
895
+ );
896
+ });
897
+ const app = trackCloseable(
898
+ await createIsolatedStandaloneServer({ viteDevUrl }),
899
+ );
900
+ const publicBasePath = app.application.publicBasePath;
901
+ const requestPath = `${publicBasePath}/settings?tab=apps`;
902
+
903
+ const response = await requestApp(app, `http://localhost${requestPath}`, {
904
+ headers: {
905
+ origin: 'http://localhost',
906
+ referer: `http://localhost${publicBasePath}/`,
907
+ },
908
+ });
909
+
910
+ expect(response.status).toBe(200);
911
+ const viteOrigin = new URL(viteDevUrl).origin;
912
+ await expect(response.json()).resolves.toEqual({
913
+ method: 'GET',
914
+ url: requestPath,
915
+ origin: viteOrigin,
916
+ referer: `${viteOrigin}${publicBasePath}/`,
917
+ });
918
+ await app.close();
919
+ });
920
+
921
+ it('injects browser runtime config when serving the production SPA index', async () => {
922
+ const root = mkdtempSync(
923
+ path.join(tmpdir(), 'nocobase-app-template-default-client-'),
924
+ );
925
+ tempDirs.push(root);
926
+ const indexPath = path.join(root, 'index.html');
927
+ writeFileSync(
928
+ indexPath,
929
+ [
930
+ '<!doctype html>',
931
+ '<html>',
932
+ '<body>',
933
+ '<div id="root"></div>',
934
+ '<script type="module" src="/app-template-default/assets/index.js"></script>',
935
+ '</body>',
936
+ '</html>',
937
+ ].join(''),
938
+ );
939
+
940
+ const app = createTestApp({
941
+ publicBasePath: '/app-template-default',
942
+ spa: {
943
+ indexPath,
944
+ },
945
+ });
946
+
947
+ const response = await requestApp(app, 'http://localhost/settings');
948
+ const html = await response.text();
949
+
950
+ expect(response.status).toBe(200);
951
+ expect(readRuntimeConfig(html).config).toMatchObject({
952
+ app: { basePath: '/app-template-default' },
953
+ api: { baseURL: '/app-template-default/api' },
954
+ });
955
+ // The configuration block is the client's only source of runtime values; nothing is put on `window`.
956
+ expect(html).not.toContain('window.');
957
+ expect(html.indexOf('nocobase-runtime-config')).toBeLessThan(
958
+ html.indexOf('<script type="module"'),
959
+ );
960
+ });
961
+
962
+ it('serves production SPA assets before the SPA fallback', async () => {
963
+ const root = mkdtempSync(
964
+ path.join(tmpdir(), 'nocobase-app-template-default-client-'),
965
+ );
966
+ tempDirs.push(root);
967
+ mkdirSync(path.join(root, 'assets'));
968
+ writeFileSync(
969
+ path.join(root, 'index.html'),
970
+ '<script type="module" src="/app-template-default/assets/index.js"></script>',
971
+ );
972
+ writeFileSync(
973
+ path.join(root, 'assets/index.js'),
974
+ 'console.log("app-template-default asset");',
975
+ );
976
+
977
+ const app = createTestApp({
978
+ publicBasePath: '/app-template-default',
979
+ spa: {
980
+ indexPath: path.join(root, 'index.html'),
981
+ },
982
+ });
983
+
984
+ const response = await requestApp(app, 'http://localhost/assets/index.js');
985
+
986
+ expect(response.status).toBe(200);
987
+ expect(response.headers.get('content-type')).toBe(
988
+ 'text/javascript; charset=utf-8',
989
+ );
990
+ expect(response.headers.get('cache-control')).toBe(
991
+ 'public, max-age=31536000, immutable',
992
+ );
993
+ await expect(response.text()).resolves.toBe(
994
+ 'console.log("app-template-default asset");',
995
+ );
996
+ });
997
+
998
+ it('does not return the SPA index for missing production SPA assets', async () => {
999
+ const root = mkdtempSync(
1000
+ path.join(tmpdir(), 'nocobase-app-template-default-client-'),
1001
+ );
1002
+ tempDirs.push(root);
1003
+ mkdirSync(path.join(root, 'assets'));
1004
+ writeFileSync(
1005
+ path.join(root, 'index.html'),
1006
+ '<main>app-template-default app</main>',
1007
+ );
1008
+
1009
+ const app = createTestApp({
1010
+ publicBasePath: '/app-template-default',
1011
+ spa: {
1012
+ indexPath: path.join(root, 'index.html'),
1013
+ },
1014
+ });
1015
+
1016
+ const response = await requestApp(
1017
+ app,
1018
+ 'http://localhost/assets/missing.js',
1019
+ );
1020
+
1021
+ expect(response.status).toBe(404);
1022
+ expect(response.headers.get('content-type')).toContain('application/json');
1023
+ await expect(response.json()).resolves.toEqual({
1024
+ error: 'Not found',
1025
+ });
1026
+ });
1027
+ });
1028
+
1029
+ async function startStandaloneTestServer(
1030
+ app: StandaloneServer,
1031
+ ): Promise<string> {
1032
+ let listenInfo: AddressInfo | undefined;
1033
+ const server = (await startNodeAppServer(app, {
1034
+ hostname: '127.0.0.1',
1035
+ port: 0,
1036
+ registerProcessSignals: false,
1037
+ onListen: (info) => {
1038
+ listenInfo = info;
1039
+ },
1040
+ })) as Server;
1041
+ servers.push(server);
1042
+
1043
+ if (!listenInfo) {
1044
+ throw new Error('Node app server started without listen information.');
1045
+ }
1046
+
1047
+ return `ws://${normalizeListenAddress(listenInfo)}:${listenInfo.port}`;
1048
+ }
1049
+
1050
+ function normalizeListenAddress(info: AddressInfo): string {
1051
+ return info.address === '::' ? '127.0.0.1' : info.address;
1052
+ }
1053
+
1054
+ function waitForWebSocketOpen(websocket: WebSocket): Promise<void> {
1055
+ if (websocket.readyState === WebSocket.OPEN) {
1056
+ return Promise.resolve();
1057
+ }
1058
+
1059
+ return new Promise((resolve, reject) => {
1060
+ const cleanup = (): void => {
1061
+ websocket.removeEventListener('open', handleOpen);
1062
+ websocket.removeEventListener('error', handleError);
1063
+ };
1064
+ const handleOpen = (): void => {
1065
+ cleanup();
1066
+ resolve();
1067
+ };
1068
+ const handleError = (): void => {
1069
+ cleanup();
1070
+ reject(new Error('WebSocket failed to open.'));
1071
+ };
1072
+
1073
+ websocket.addEventListener('open', handleOpen);
1074
+ websocket.addEventListener('error', handleError);
1075
+ });
1076
+ }
1077
+
1078
+ function waitForWebSocketJsonMessage(
1079
+ websocket: WebSocket,
1080
+ predicate: (message: RealtimeServerMessage) => boolean,
1081
+ ): Promise<RealtimeServerMessage> {
1082
+ return new Promise((resolve, reject) => {
1083
+ const cleanup = (): void => {
1084
+ websocket.removeEventListener('message', handleMessage);
1085
+ websocket.removeEventListener('error', handleError);
1086
+ };
1087
+ const handleMessage = (event: MessageEvent): void => {
1088
+ try {
1089
+ const message = JSON.parse(String(event.data)) as RealtimeServerMessage;
1090
+ if (!predicate(message)) {
1091
+ return;
1092
+ }
1093
+
1094
+ cleanup();
1095
+ resolve(message);
1096
+ } catch (error) {
1097
+ cleanup();
1098
+ reject(error);
1099
+ }
1100
+ };
1101
+ const handleError = (): void => {
1102
+ cleanup();
1103
+ reject(new Error('WebSocket message failed.'));
1104
+ };
1105
+
1106
+ websocket.addEventListener('message', handleMessage);
1107
+ websocket.addEventListener('error', handleError);
1108
+ });
1109
+ }
1110
+
1111
+ function waitForWebSocketClose(websocket: WebSocket): Promise<CloseEvent> {
1112
+ return new Promise((resolve) => {
1113
+ websocket.addEventListener('close', (event) => resolve(event), {
1114
+ once: true,
1115
+ });
1116
+ });
1117
+ }
1118
+
1119
+ interface TestWebSocket extends AppWebSocket {
1120
+ readonly messages: RealtimeServerMessage[];
1121
+ }
1122
+
1123
+ function createTestWebSocket(): TestWebSocket {
1124
+ let readyState: AppWebSocketReadyState = 1;
1125
+ const messages: RealtimeServerMessage[] = [];
1126
+
1127
+ return {
1128
+ url: new URL('ws://localhost/ws'),
1129
+ protocol: null,
1130
+ messages,
1131
+ get readyState() {
1132
+ return readyState;
1133
+ },
1134
+ send(data) {
1135
+ messages.push(JSON.parse(String(data)) as RealtimeServerMessage);
1136
+ },
1137
+ close() {
1138
+ readyState = 3;
1139
+ },
1140
+ };
1141
+ }
1142
+
1143
+ function startHttpStub(
1144
+ handler?: Parameters<typeof createHttpServer>[0],
1145
+ ): Promise<string> {
1146
+ const server = createHttpServer(handler);
1147
+ servers.push(server);
1148
+
1149
+ return new Promise((resolve, reject) => {
1150
+ server.once('error', reject);
1151
+ server.listen(0, '127.0.0.1', () => {
1152
+ const address = server.address();
1153
+ if (!address || typeof address === 'string') {
1154
+ reject(new Error('Failed to resolve Vite stub address.'));
1155
+ return;
1156
+ }
1157
+
1158
+ resolve(`http://127.0.0.1:${address.port}`);
1159
+ });
1160
+ });
1161
+ }
1162
+
1163
+ interface CreateTestAppOptions {
1164
+ publicOrigin?: string;
1165
+ publicBasePath?: string;
1166
+ database?: DatabaseManager | false;
1167
+ queue?: AppQueueConfig;
1168
+ plugins?: readonly AppServerPlugin<AppConfig>[];
1169
+ spa?: {
1170
+ indexPath?: string;
1171
+ };
1172
+ }
1173
+
1174
+ /** Queue state files of a test application stay in a temporary directory. */
1175
+ function createTestQueueConfig(): AppQueueConfig {
1176
+ return {
1177
+ default: 'memory',
1178
+ memory: {
1179
+ adapter: 'inMemory',
1180
+ persistence: {
1181
+ path: mkdtempSync(
1182
+ path.join(tmpdir(), 'nocobase-app-template-default-queue-'),
1183
+ ),
1184
+ },
1185
+ },
1186
+ };
1187
+ }
1188
+
1189
+ function createTestApp(options: CreateTestAppOptions = {}): TestApp {
1190
+ const publicBasePath = normalizeBasePath(
1191
+ options.publicBasePath ?? '/app-template-default',
1192
+ );
1193
+ const configValues = {
1194
+ app: {
1195
+ name: resolveAppNameFromBasePath(publicBasePath, 'app-template-default'),
1196
+ publicOrigin: options.publicOrigin,
1197
+ publicBasePath,
1198
+ internalBasePath: '',
1199
+ publicApiUrl: joinBasePath(publicBasePath, '/api'),
1200
+ },
1201
+ auth: {
1202
+ secret: 'test-auth-secret-at-least-32-characters',
1203
+ emailAndPassword: {
1204
+ enabled: true,
1205
+ },
1206
+ },
1207
+ caching: createDefaultCachingConfig(),
1208
+ database: {
1209
+ default: 'none',
1210
+ connections: {},
1211
+ migrations: {
1212
+ directory: '',
1213
+ autoRun: false,
1214
+ },
1215
+ seeds: {
1216
+ directory: '',
1217
+ autoRun: false,
1218
+ },
1219
+ },
1220
+ drive: {
1221
+ default: 'local',
1222
+ disks: {
1223
+ local: {
1224
+ driver: 'fs' as const,
1225
+ location: path.join(tmpdir(), 'nocobase-app-server-test-drive'),
1226
+ visibility: 'private' as const,
1227
+ },
1228
+ },
1229
+ },
1230
+ logging: createSilentLoggingConfig(),
1231
+ queue: options.queue ?? createTestQueueConfig(),
1232
+ session: createNullSessionConfig(),
1233
+ workflow: {
1234
+ sourceRoot: path.resolve(process.cwd(), 'workflows'),
1235
+ distRoot: path.resolve(process.cwd(), 'dist/workflows'),
1236
+ artifactDisk: 'local',
1237
+ production: false,
1238
+ },
1239
+ snowflake: {
1240
+ workerId: 0,
1241
+ },
1242
+ server: {
1243
+ host: '127.0.0.1',
1244
+ port: 0,
1245
+ startLog: false,
1246
+ viteDevUrl: undefined,
1247
+ },
1248
+ spa: {
1249
+ indexPath:
1250
+ options.spa?.indexPath ?? path.resolve(process.cwd(), 'index.html'),
1251
+ },
1252
+ };
1253
+ const config = createTestConfig(configValues);
1254
+ const paths = createAppPaths({ rootDir: '/test/app-template-default' });
1255
+ const database =
1256
+ options.database === false
1257
+ ? undefined
1258
+ : (options.database ?? createMockDatabase([]));
1259
+ class TestDatabaseProvider extends ServiceProvider<Application<AppConfig>> {
1260
+ public readonly name: string = 'test-database';
1261
+
1262
+ public override register(): void {
1263
+ if (database) {
1264
+ this.app.container.instance(databaseManagerToken, database);
1265
+ }
1266
+ }
1267
+
1268
+ public override async shutdown(): Promise<void> {
1269
+ await database?.destroy();
1270
+ }
1271
+ }
1272
+ const app = new Application({
1273
+ config,
1274
+ appName: configValues.app.name,
1275
+ publicBasePath: configValues.app.publicBasePath,
1276
+ paths,
1277
+ });
1278
+ configValues.auth = { ...authConfig({} as never), ...configValues.auth };
1279
+ if (database) {
1280
+ app.addServiceProvider(TestDatabaseProvider);
1281
+ }
1282
+ app.addServiceProvider(LoggingProvider);
1283
+ app.addServiceProvider(CachingProvider);
1284
+ app.addServiceProvider(IdGeneratorProvider);
1285
+ app.addServiceProvider(SessionProvider);
1286
+ app.addServiceProvider(DriveProvider);
1287
+ app.addServiceProvider(QueueServiceProvider);
1288
+ app.addHttpMiddleware(requestLoggingMiddleware);
1289
+ app.addHttpMiddleware(sessionHttpMiddleware);
1290
+ app.addRoutes(healthCheckApiRoutes);
1291
+ app.addServerPlugins(
1292
+ createResolvedTestServerPlugins([
1293
+ authenticationServerPlugin,
1294
+ authorizationServerPlugin,
1295
+ ...(options.plugins ?? []),
1296
+ ]),
1297
+ );
1298
+ app.addRoutes(spaRootRoutes);
1299
+ app.registerProviders();
1300
+
1301
+ return trackCloseable(
1302
+ Object.assign(app, {
1303
+ close: (): Promise<void> => app.shutdown(),
1304
+ }),
1305
+ );
1306
+ }
1307
+
1308
+ function createResolvedTestServerPlugins(
1309
+ plugins: readonly AppServerPlugin<AppConfig>[],
1310
+ ): ResolvedAppServerPlugins<AppConfig> {
1311
+ const definitions = defineServerPlugins<AppConfig>(plugins);
1312
+
1313
+ return {
1314
+ appPackageName: '@nocobase/app-template-default',
1315
+ plugins: definitions.plugins.map((definition) => ({
1316
+ definition,
1317
+ metadata: {
1318
+ packageName: definition.packageName,
1319
+ version: 'test',
1320
+ rootDir: `/test/plugins/${definition.packageName}`,
1321
+ baseDir: definition.baseDir,
1322
+ jobLocations: [],
1323
+ },
1324
+ })),
1325
+ };
1326
+ }
1327
+
1328
+ function createTestConfig(
1329
+ values: Readonly<Record<string, unknown>>,
1330
+ ): AppConfigAccessor {
1331
+ return {
1332
+ get: <TValue>(definition: string): TValue => values[definition] as TValue,
1333
+ raw: () => values,
1334
+ reload: () => Promise.resolve({ changedNamespaces: [] }),
1335
+ subscribe: () => () => undefined,
1336
+ };
1337
+ }
1338
+
1339
+ async function createEmbeddedTestScope(
1340
+ options: Omit<AppScope, 'registerDisposer'>,
1341
+ registeredDisposers: RegisteredTestDisposer[] = [],
1342
+ ): Promise<AppScope> {
1343
+ const lifecycle = createAppDisposerRegistry();
1344
+ const sourceRoot = path.resolve(import.meta.dirname, '../..');
1345
+ const databaseDir = mkdtempSync(
1346
+ path.join(tmpdir(), 'nocobase-app-template-default-database-'),
1347
+ );
1348
+ tempDirs.push(databaseDir);
1349
+ apps.push({
1350
+ close: () => lifecycle.disposeAll(),
1351
+ });
1352
+
1353
+ return {
1354
+ ...options,
1355
+ env: {
1356
+ DB_MIGRATIONS_AUTO_RUN: 'true',
1357
+ ...options.env,
1358
+ APP_CONFIG_FILE: options.rootDir
1359
+ ? undefined
1360
+ : await writeRuntimeTestConfig(databaseDir, options.env),
1361
+ },
1362
+ paths:
1363
+ options.paths ??
1364
+ (options.rootDir
1365
+ ? undefined
1366
+ : {
1367
+ rootDir: sourceRoot,
1368
+ serverDir: path.join(sourceRoot, 'server'),
1369
+ databaseDir: path.join(sourceRoot, 'database'),
1370
+ clientDir:
1371
+ options.clientDir ?? path.join(sourceRoot, 'dist/client'),
1372
+ storageDir: options.dataDir ?? path.join(sourceRoot, 'storage'),
1373
+ }),
1374
+ registerDisposer(name, dispose) {
1375
+ registeredDisposers.push({ name, dispose });
1376
+ lifecycle.registerDisposer(name, dispose);
1377
+ },
1378
+ };
1379
+ }
1380
+
1381
+ async function createIsolatedStandaloneServer(
1382
+ options: TestStandaloneServerOptions = {},
1383
+ ): Promise<StandaloneServer> {
1384
+ const sourceRoot = path.resolve(import.meta.dirname, '../..');
1385
+ const databaseDir = mkdtempSync(
1386
+ path.join(tmpdir(), 'nocobase-app-template-default-standalone-database-'),
1387
+ );
1388
+ tempDirs.push(databaseDir);
1389
+
1390
+ return createStandaloneServer({
1391
+ ...options,
1392
+ env: {
1393
+ DB_MIGRATIONS_AUTO_RUN: 'true',
1394
+ ...options.env,
1395
+ APP_CONFIG_FILE: await writeRuntimeTestConfig(databaseDir, options.env),
1396
+ },
1397
+ paths: {
1398
+ rootDir: sourceRoot,
1399
+ serverDir: path.join(sourceRoot, 'server'),
1400
+ databaseDir: path.join(sourceRoot, 'database'),
1401
+ clientDir: options.clientDir ?? path.join(sourceRoot, 'dist/client'),
1402
+ storageDir: path.join(sourceRoot, 'storage'),
1403
+ },
1404
+ });
1405
+ }
1406
+
1407
+ function createInstalledStandaloneServer(
1408
+ options: TestStandaloneServerOptions = {},
1409
+ ): Promise<StandaloneServer> {
1410
+ return createIsolatedStandaloneServer({
1411
+ ...options,
1412
+ env: {
1413
+ ...options.env,
1414
+ DB_MIGRATIONS_AUTO_RUN: 'true',
1415
+ DB_SEEDS_AUTO_RUN: 'true',
1416
+ },
1417
+ });
1418
+ }
1419
+
1420
+ function createEmbeddedPluginFixture(rootDir: string): void {
1421
+ // Derived from the composition root rather than written out again. The embedded server resolves every plugin
1422
+ // `server/plugins.ts` declares, and a temporary root resolves nothing it is not given, so a separate list has to
1423
+ // be updated whenever a plugin is registered — and a copy of a list is a copy that goes stale.
1424
+ //
1425
+ // It went stale here without failing, which is the part worth knowing. `require.resolve` falls back to NODE_PATH,
1426
+ // and vitest sets NODE_PATH to pnpm's hidden hoisted directory, so a plugin the fixture never symlinked was still
1427
+ // found — through a path that has nothing to do with the application root this test claims to resolve from.
1428
+ // Whether that fallback happens to hold a given package depends on install history, so the same commit passed in
1429
+ // CI and failed locally once the list fell behind. Deriving the list removes the guess: every declared plugin is
1430
+ // symlinked, and resolution comes from the root under test.
1431
+ const pluginPackages = serverPlugins.plugins.map(
1432
+ (plugin) => plugin.packageName,
1433
+ );
1434
+ writeFileSync(
1435
+ path.join(rootDir, 'package.json'),
1436
+ JSON.stringify({
1437
+ name: '@nocobase/app-template-test',
1438
+ nocobase: {
1439
+ plugins: Object.fromEntries(
1440
+ pluginPackages.map((packageName) => [packageName, { enabled: true }]),
1441
+ ),
1442
+ },
1443
+ }),
1444
+ );
1445
+
1446
+ for (const packageName of pluginPackages) {
1447
+ const packageRoot = path.dirname(
1448
+ require.resolve(`${packageName}/package.json`),
1449
+ );
1450
+ const target = path.join(
1451
+ rootDir,
1452
+ 'node_modules',
1453
+ ...packageName.split('/'),
1454
+ );
1455
+ mkdirSync(path.dirname(target), { recursive: true });
1456
+ symlinkSync(packageRoot, target, 'junction');
1457
+ }
1458
+ }
1459
+
1460
+ function trackCloseable<T extends CloseableResource>(resource: T): T {
1461
+ apps.push(resource);
1462
+ return resource;
1463
+ }
1464
+
1465
+ function createMockDatabase(
1466
+ rows: unknown[],
1467
+ insertedRows: unknown[] = [],
1468
+ ): DatabaseManager {
1469
+ const query = createMockQuery(rows, insertedRows);
1470
+ return {
1471
+ connection: (() => ({ query })) as DatabaseManager['connection'],
1472
+ builder: (() => {
1473
+ throw new Error('Not implemented.');
1474
+ }) as DatabaseManager['builder'],
1475
+ query: (() => query) as DatabaseManager['query'],
1476
+ createMigrator: (() => {
1477
+ throw new Error('Not implemented.');
1478
+ }) as DatabaseManager['createMigrator'],
1479
+ createSeeder: (() => {
1480
+ throw new Error('Not implemented.');
1481
+ }) as DatabaseManager['createSeeder'],
1482
+ connect: (() =>
1483
+ Promise.reject(
1484
+ new Error('Not implemented.'),
1485
+ )) as DatabaseManager['connect'],
1486
+ transaction: (() =>
1487
+ Promise.reject(
1488
+ new Error('Not implemented.'),
1489
+ )) as DatabaseManager['transaction'],
1490
+ disconnect: (() => Promise.resolve()) as DatabaseManager['disconnect'],
1491
+ reconnect: (() =>
1492
+ Promise.reject(
1493
+ new Error('Not implemented.'),
1494
+ )) as DatabaseManager['reconnect'],
1495
+ destroy: (() => Promise.resolve()) as DatabaseManager['destroy'],
1496
+ };
1497
+ }
1498
+
1499
+ function createMockQuery(
1500
+ rows: unknown[],
1501
+ insertedRows: unknown[],
1502
+ ): QueryAdapter {
1503
+ const selectQuery = {
1504
+ select: () => selectQuery,
1505
+ orderBy: () => selectQuery,
1506
+ execute: () => Promise.resolve(rows),
1507
+ };
1508
+ const insertQuery = {
1509
+ values: (data: unknown | readonly unknown[]) => {
1510
+ if (Array.isArray(data)) {
1511
+ insertedRows.push(...data);
1512
+ } else {
1513
+ insertedRows.push(data);
1514
+ }
1515
+
1516
+ return insertQuery;
1517
+ },
1518
+ execute: () => Promise.resolve({ insertedCount: insertedRows.length }),
1519
+ compile: () => ({ sql: '', parameters: [] }),
1520
+ };
1521
+
1522
+ return {
1523
+ selectFrom: () => selectQuery,
1524
+ insertInto: () => insertQuery,
1525
+ updateTable: () => {
1526
+ throw new Error('Not implemented.');
1527
+ },
1528
+ deleteFrom: () => {
1529
+ throw new Error('Not implemented.');
1530
+ },
1531
+ } as unknown as QueryAdapter;
1532
+ }
1533
+
1534
+ /**
1535
+ * The configuration the application under test loads instead of config.yml: test databases of its own, on the dialect
1536
+ * NOCOBASE_TEST_DB_DIALECT selects, dropped once the test's applications have closed.
1537
+ */
1538
+ async function writeRuntimeTestConfig(
1539
+ directory: string,
1540
+ env: Readonly<Record<string, string | undefined>> = {},
1541
+ ): Promise<string> {
1542
+ const config = await createTestAppConfig({
1543
+ connections: ['main'],
1544
+ install: env.DB_MIGRATIONS_AUTO_RUN !== 'false',
1545
+ config: {
1546
+ auth: { secret: 'test-auth-secret-at-least-32-characters' },
1547
+ // Scheduled jobs keep their state beside the test database, not in the template's storage/, which
1548
+ // another suite may be using at the same time.
1549
+ jobs: {
1550
+ default: 'memory',
1551
+ memory: {
1552
+ adapter: 'memory',
1553
+ persistence: { path: path.join(directory, 'jobs') },
1554
+ },
1555
+ },
1556
+ // Queue state files likewise stay beside the test database.
1557
+ queue: {
1558
+ default: 'memory',
1559
+ memory: {
1560
+ adapter: 'inMemory',
1561
+ persistence: { path: path.join(directory, 'queue') },
1562
+ },
1563
+ },
1564
+ database: {
1565
+ connections: {
1566
+ main: { seeds: { autoRun: env.DB_SEEDS_AUTO_RUN === 'true' } },
1567
+ },
1568
+ },
1569
+ hub: { host: { enabled: false } },
1570
+ },
1571
+ });
1572
+ testConfigs.push(config);
1573
+ return config.path;
1574
+ }
1575
+
1576
+ function readRuntimeConfig(html: string): {
1577
+ config: Record<string, unknown>;
1578
+ public?: Record<string, unknown>;
1579
+ } {
1580
+ const source =
1581
+ /<script id="nocobase-runtime-config" type="application\/json">(.*?)<\/script>/u.exec(
1582
+ html,
1583
+ )?.[1];
1584
+ if (!source) throw new Error('The page carries no client configuration.');
1585
+ return JSON.parse(source) as {
1586
+ config: Record<string, unknown>;
1587
+ public?: Record<string, unknown>;
1588
+ };
1589
+ }