@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.
- package/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/.prettierignore +20 -0
- package/.vscode/mcp.json +8 -0
- package/AGENTS.md +434 -0
- package/CLAUDE.md +9 -0
- package/Dockerfile +132 -0
- package/Dockerfile.dockerignore +45 -0
- package/README.MD +485 -0
- package/cli/plugins.ts +8 -0
- package/client/agent-annotations-host.ts +19 -0
- package/client/app.ts +38 -0
- package/client/components/back-button.tsx +49 -0
- package/client/components/breadcrumbs.tsx +167 -0
- package/client/components/inbox-badge.ts +49 -0
- package/client/components/inbox-button.tsx +132 -0
- package/client/components/inbox-header-button.tsx +25 -0
- package/client/components/loading.tsx +35 -0
- package/client/components/page-container.tsx +24 -0
- package/client/components/page-header.tsx +33 -0
- package/client/components/route-child-page.tsx +121 -0
- package/client/components/route-dialog.tsx +10 -0
- package/client/components/route-drawer.tsx +10 -0
- package/client/components/route-overlay.tsx +190 -0
- package/client/components/ui/alert.tsx +75 -0
- package/client/components/ui/badge.tsx +51 -0
- package/client/components/ui/breadcrumb.tsx +121 -0
- package/client/components/ui/button.tsx +58 -0
- package/client/components/ui/collapsible.tsx +19 -0
- package/client/components/ui/context-menu.tsx +271 -0
- package/client/components/ui/dialog.tsx +160 -0
- package/client/components/ui/dropdown-menu.tsx +270 -0
- package/client/components/ui/empty.tsx +103 -0
- package/client/components/ui/field.tsx +236 -0
- package/client/components/ui/input-group.tsx +158 -0
- package/client/components/ui/input.tsx +20 -0
- package/client/components/ui/kbd.tsx +26 -0
- package/client/components/ui/label.tsx +18 -0
- package/client/components/ui/popover.tsx +89 -0
- package/client/components/ui/select.tsx +200 -0
- package/client/components/ui/separator.tsx +22 -0
- package/client/components/ui/sheet.tsx +137 -0
- package/client/components/ui/sidebar.tsx +723 -0
- package/client/components/ui/skeleton.tsx +13 -0
- package/client/components/ui/spinner.tsx +20 -0
- package/client/components/ui/tabs.tsx +79 -0
- package/client/components/ui/textarea.tsx +17 -0
- package/client/components/ui/toast.tsx +231 -0
- package/client/components/ui/tooltip.tsx +63 -0
- package/client/components/use-route-overlay.ts +26 -0
- package/client/config/api.ts +13 -0
- package/client/config/app.ts +8 -0
- package/client/config/auth.ts +18 -0
- package/client/config/index.ts +17 -0
- package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
- package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
- package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
- package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
- package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
- package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
- package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
- package/client/extensions/nocobase-file-component-ui/README.md +13 -0
- package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
- package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
- package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
- package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
- package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
- package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
- package/client/extensions/nocobase-inbox/frame.ts +31 -0
- package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
- package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
- package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
- package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
- package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
- package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
- package/client/extensions/nocobase-inbox/model.ts +428 -0
- package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
- package/client/extensions/nocobase-inbox/registry.ts +303 -0
- package/client/extensions/nocobase-inbox/source.ts +117 -0
- package/client/hooks/use-mobile.ts +21 -0
- package/client/index.tsx +60 -0
- package/client/layouts/app-layout.tsx +105 -0
- package/client/layouts/components/app-brand.tsx +49 -0
- package/client/layouts/components/app-sidebar.tsx +196 -0
- package/client/layouts/components/header-actions.tsx +50 -0
- package/client/layouts/components/language-switcher.tsx +95 -0
- package/client/layouts/components/layout-header.tsx +17 -0
- package/client/layouts/components/navigation-menu.tsx +411 -0
- package/client/layouts/components/sidebar-footer.tsx +107 -0
- package/client/layouts/components/surface-empty.tsx +35 -0
- package/client/layouts/components/user-menu.tsx +127 -0
- package/client/layouts/index.ts +2 -0
- package/client/layouts/settings-layout.tsx +172 -0
- package/client/layouts/use-sidebar-preference.ts +57 -0
- package/client/lib/constants.ts +5 -0
- package/client/lib/toaster.ts +56 -0
- package/client/lib/utils.ts +1 -0
- package/client/locales/en-US.ts +178 -0
- package/client/locales/index.ts +9 -0
- package/client/locales/zh-CN.ts +161 -0
- package/client/pages/auth/device.tsx +41 -0
- package/client/pages/auth/forgot-password.tsx +39 -0
- package/client/pages/auth/login.tsx +66 -0
- package/client/pages/auth/register.tsx +46 -0
- package/client/pages/auth/reset-password.tsx +38 -0
- package/client/pages/auth/shared.tsx +111 -0
- package/client/pages/home.tsx +16 -0
- package/client/pages/inbox.tsx +12 -0
- package/client/plugins.ts +37 -0
- package/client/react-providers.ts +27 -0
- package/client/route-overrides.ts +9 -0
- package/client/routes.ts +75 -0
- package/client/routing/app-router.tsx +127 -0
- package/client/routing/client-route.tsx +129 -0
- package/client/routing/route-context.tsx +85 -0
- package/client/routing/route-navigation.ts +153 -0
- package/client/routing/route-tree.tsx +42 -0
- package/client/routing/standalone-page-layout.tsx +15 -0
- package/client/runtime.ts +26 -0
- package/client/service-provider.ts +40 -0
- package/client/source-extensions.ts +19 -0
- package/client/startup.tsx +22 -0
- package/client/styles.css +122 -0
- package/client/theme/index.ts +4 -0
- package/client/theme/theme-context.ts +12 -0
- package/client/theme/theme-preferences.ts +84 -0
- package/client/theme/theme-presets.ts +7 -0
- package/client/theme/theme-provider.tsx +106 -0
- package/client/theme/theme-settings.tsx +176 -0
- package/client/theme/themes/compact.css +140 -0
- package/client/theme/themes/default.css +117 -0
- package/client/vite-env.d.ts +5 -0
- package/components.json +27 -0
- package/config.example.yml +156 -0
- package/database/main/migrations/.gitkeep +0 -0
- package/database/main/migrations/202610060001_create_device_code.ts +37 -0
- package/database/main/seeds/.gitkeep +0 -0
- package/database/tsconfig.json +4 -0
- package/ecosystem.config.js +35 -0
- package/eslint.config.js +24 -0
- package/index.html +100 -0
- package/package.json +198 -3
- package/playwright.config.ts +10 -0
- package/public/assets/favicon.ico +0 -0
- package/public/assets/logo-dark.png +0 -0
- package/public/assets/logo-mark-dark.png +0 -0
- package/public/assets/logo-mark.png +0 -0
- package/public/assets/logo.png +0 -0
- package/server/app.ts +50 -0
- package/server/config/api.ts +8 -0
- package/server/config/app.ts +40 -0
- package/server/config/auth.ts +36 -0
- package/server/config/authorization.ts +18 -0
- package/server/config/caching.ts +21 -0
- package/server/config/database.ts +22 -0
- package/server/config/drive.ts +30 -0
- package/server/config/i18n.ts +17 -0
- package/server/config/index.ts +67 -0
- package/server/config/jobs.ts +27 -0
- package/server/config/logging.ts +23 -0
- package/server/config/notification.ts +11 -0
- package/server/config/queue.ts +28 -0
- package/server/config/scheduler.ts +23 -0
- package/server/config/secrets.ts +10 -0
- package/server/config/server.ts +26 -0
- package/server/config/session.ts +60 -0
- package/server/config/snowflake.ts +21 -0
- package/server/config/spa.ts +24 -0
- package/server/config/users.ts +11 -0
- package/server/config/workflow.ts +24 -0
- package/server/config.ts +24 -0
- package/server/embedded.ts +23 -0
- package/server/locales/en-US.ts +10 -0
- package/server/locales/index.ts +10 -0
- package/server/locales/zh-CN.ts +5 -0
- package/server/plugins.ts +37 -0
- package/server/providers/index.ts +5 -0
- package/server/routes/index.ts +6 -0
- package/server/runtime.ts +24 -0
- package/server/standalone.ts +29 -0
- package/server/tsconfig.json +4 -0
- package/tailwind.config.mjs +59 -0
- package/tests/components/app-layout-routes.test.tsx +132 -0
- package/tests/components/app-title.test.tsx +57 -0
- package/tests/components/auth-i18n.test.tsx +87 -0
- package/tests/components/back-button.test.tsx +80 -0
- package/tests/components/file-preview.test.tsx +101 -0
- package/tests/components/header-hover.test.tsx +87 -0
- package/tests/components/inbox.test.tsx +139 -0
- package/tests/components/language-switcher.test.tsx +298 -0
- package/tests/components/layout-containers.test.tsx +15 -0
- package/tests/components/layout-i18n.test.tsx +187 -0
- package/tests/components/loading.test.tsx +25 -0
- package/tests/components/navigation-menu.test.tsx +355 -0
- package/tests/components/page-harness.test.tsx +318 -0
- package/tests/components/primitive-labels.test.tsx +80 -0
- package/tests/components/sidebar-permissions.test.tsx +110 -0
- package/tests/components/sidebar-preference.test.tsx +42 -0
- package/tests/fixtures/theme-tokens.html +12 -0
- package/tests/fixtures/theme-tokens.tsx +111 -0
- package/tests/logic/account-permissions.test.tsx +204 -0
- package/tests/logic/app-locale-coverage.test.ts +136 -0
- package/tests/logic/app-server.test.ts +1589 -0
- package/tests/logic/asset-url-build.test.ts +106 -0
- package/tests/logic/client-auth.test.tsx +122 -0
- package/tests/logic/client-plugin-registry.test.ts +63 -0
- package/tests/logic/client-route-recovery.test.tsx +80 -0
- package/tests/logic/client-routes.test.ts +126 -0
- package/tests/logic/client-runtime.test.ts +112 -0
- package/tests/logic/client-settings.test.tsx +841 -0
- package/tests/logic/client-shell.test.tsx +466 -0
- package/tests/logic/client-theme.test.tsx +441 -0
- package/tests/logic/config.test.ts +197 -0
- package/tests/logic/dev-client-watch.test.ts +108 -0
- package/tests/logic/dev-routes-production-build.test.ts +168 -0
- package/tests/logic/device-code-migration.test.ts +70 -0
- package/tests/logic/lifecycle.test.ts +221 -0
- package/tests/logic/plugin-commands.test.ts +69 -0
- package/tests/logic/plugin-page-authz.test.ts +53 -0
- package/tests/logic/route-navigation.test.ts +66 -0
- package/tests/logic/route-overlay.test.tsx +353 -0
- package/tests/logic/server-imports.test.ts +88 -0
- package/tests/logic/tailwind-sources.test.ts +151 -0
- package/tests/logic/template-package-name.test.ts +88 -0
- package/tests/logic/theme-preferences.test.ts +86 -0
- package/tests/logic/theme-tokens.test.ts +248 -0
- package/tests/logic/toaster.test.tsx +108 -0
- package/tests/logic/user-menu-sign-out.test.tsx +98 -0
- package/tests/setup/client-config.ts +27 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +12 -0
- package/tsconfig.server.json +19 -0
- package/vite.config.ts +92 -0
- package/vitest.config.ts +44 -0
- package/README.md +0 -3
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
copyFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
realpath,
|
|
8
|
+
rm,
|
|
9
|
+
symlink,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from 'node:fs/promises';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { createServer, type ViteDevServer } from 'vite';
|
|
15
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
const appRoot = path.resolve(import.meta.dirname, '../..');
|
|
18
|
+
let workspace: string | undefined;
|
|
19
|
+
let server: ViteDevServer | undefined;
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await server?.close();
|
|
23
|
+
server = undefined;
|
|
24
|
+
if (workspace) await rm(workspace, { recursive: true, force: true });
|
|
25
|
+
workspace = undefined;
|
|
26
|
+
vi.unstubAllEnvs();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('development client file watching', () => {
|
|
30
|
+
it('skips the complete app build while watching client and linked dependency edits', async () => {
|
|
31
|
+
workspace = await realpath(
|
|
32
|
+
await mkdtemp(path.join(tmpdir(), 'nocobase-client-watch-')),
|
|
33
|
+
);
|
|
34
|
+
// Brackets also guard against accidentally interpreting the app path as a glob.
|
|
35
|
+
const root = path.join(workspace, 'app [fixture]');
|
|
36
|
+
const clientFile = path.join(root, 'client/main.js');
|
|
37
|
+
const dependencyDirectory = path.join(
|
|
38
|
+
workspace,
|
|
39
|
+
'linked-plugin/dist/client',
|
|
40
|
+
);
|
|
41
|
+
const dependencyFile = path.join(dependencyDirectory, 'index.js');
|
|
42
|
+
const files = [
|
|
43
|
+
clientFile,
|
|
44
|
+
dependencyFile,
|
|
45
|
+
path.join(root, 'dist/client/index.js'),
|
|
46
|
+
path.join(root, 'dist/server/index.js'),
|
|
47
|
+
path.join(root, 'dist/vendor/plugin/dist/client/index.js'),
|
|
48
|
+
path.join(root, 'dist-sources/index.js'),
|
|
49
|
+
];
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
52
|
+
await writeFile(file, 'export const value = 1;\n');
|
|
53
|
+
}
|
|
54
|
+
for (const file of ['vite.config.ts', 'package.json']) {
|
|
55
|
+
await copyFile(path.join(appRoot, file), path.join(root, file));
|
|
56
|
+
}
|
|
57
|
+
await symlink(
|
|
58
|
+
path.join(appRoot, 'node_modules'),
|
|
59
|
+
path.join(root, 'node_modules'),
|
|
60
|
+
'junction',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
vi.stubEnv('AGENT_ANNOTATIONS_ENABLED', 'false');
|
|
64
|
+
vi.stubEnv('PROXY_TARGET_URL', '');
|
|
65
|
+
// `pnpm dev` always passes the mount path; the Vite preset refuses to serve without one.
|
|
66
|
+
vi.stubEnv('APP_BASE_PATH', '/main');
|
|
67
|
+
server = await createServer({
|
|
68
|
+
root,
|
|
69
|
+
// node_modules is shared with the running app; keep optimizer writes local.
|
|
70
|
+
cacheDir: path.join(workspace, 'vite-cache'),
|
|
71
|
+
configFile: path.join(root, 'vite.config.ts'),
|
|
72
|
+
logLevel: 'silent',
|
|
73
|
+
optimizeDeps: { noDiscovery: true, include: [] },
|
|
74
|
+
server: {
|
|
75
|
+
middlewareMode: true,
|
|
76
|
+
// Poll this small fixture so change events are reliable across CI hosts.
|
|
77
|
+
watch: { usePolling: true, interval: 20 },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const watcher = server.watcher;
|
|
81
|
+
const changes: string[] = [];
|
|
82
|
+
watcher.on('change', (file) => changes.push(file));
|
|
83
|
+
// Vite adds imported modules outside the app root to this same watcher.
|
|
84
|
+
watcher.add(dependencyDirectory);
|
|
85
|
+
await vi.waitFor(() => {
|
|
86
|
+
const watched = watcher.getWatched();
|
|
87
|
+
expect(watched[path.dirname(clientFile)]).toContain('main.js');
|
|
88
|
+
expect(watched[dependencyDirectory]).toContain('index.js');
|
|
89
|
+
expect(watched[path.join(root, 'dist-sources')]).toContain('index.js');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await writeFile(clientFile, 'export const value = 2;\n');
|
|
93
|
+
await writeFile(dependencyFile, 'export const value = 2;\n');
|
|
94
|
+
await vi.waitFor(() => {
|
|
95
|
+
expect(changes).toContain(clientFile);
|
|
96
|
+
expect(changes).toContain(dependencyFile);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const watchedDirectories = Object.keys(watcher.getWatched());
|
|
100
|
+
expect(
|
|
101
|
+
watchedDirectories.filter(
|
|
102
|
+
(directory) =>
|
|
103
|
+
directory === path.join(root, 'dist') ||
|
|
104
|
+
directory.startsWith(path.join(root, 'dist') + path.sep),
|
|
105
|
+
),
|
|
106
|
+
).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
// Vite's esbuild dependency asserts that `new TextEncoder().encode('')` yields a real Uint8Array, which jsdom's
|
|
3
|
+
// globals break. This file drives an actual production build, so it runs under Node rather than the shared jsdom
|
|
4
|
+
// environment the React tests use.
|
|
5
|
+
import {
|
|
6
|
+
mkdtemp,
|
|
7
|
+
mkdir,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
rm,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from 'node:fs/promises';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { findPackageJSON } from 'node:module';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { build } from 'vite';
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The guarantee under test is a build-time one: nothing a plugin declares through `defineDevRoutes()` may reach a
|
|
22
|
+
* production bundle. A unit test cannot show that — only a real production build can, because the guarantee comes
|
|
23
|
+
* from Vite replacing `import.meta.env.PROD` with `true` and then dropping the branch that becomes unreachable.
|
|
24
|
+
*
|
|
25
|
+
* The fixture builds against the real `@nocobase/app-client` helpers rather than a stand-in, so the test fails if the
|
|
26
|
+
* guard inside `defineDevRoutes()` is ever weakened.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const appClientPackagePath = findPackageJSON(
|
|
30
|
+
'@nocobase/app-client',
|
|
31
|
+
import.meta.url,
|
|
32
|
+
);
|
|
33
|
+
if (!appClientPackagePath) {
|
|
34
|
+
throw new Error('Cannot resolve @nocobase/app-client/package.json');
|
|
35
|
+
}
|
|
36
|
+
const appClientPackage = JSON.parse(
|
|
37
|
+
readFileSync(appClientPackagePath, 'utf8'),
|
|
38
|
+
) as { exports?: { './plugins'?: { import?: string } } };
|
|
39
|
+
const appClientPluginsExport = appClientPackage.exports?.['./plugins']?.import;
|
|
40
|
+
if (!appClientPluginsExport) {
|
|
41
|
+
throw new Error('@nocobase/app-client does not export ./plugins');
|
|
42
|
+
}
|
|
43
|
+
const appClientPlugins = path.resolve(
|
|
44
|
+
path.dirname(appClientPackagePath),
|
|
45
|
+
appClientPluginsExport,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const DEV_PAGE_MARKER = 'dev_page_marker_a7f3c1';
|
|
49
|
+
const DEV_ONLY_DEPENDENCY_MARKER = 'dev_only_dependency_marker_a7f3c1';
|
|
50
|
+
const SETTINGS_PAGE_MARKER = 'settings_page_marker_a7f3c1';
|
|
51
|
+
|
|
52
|
+
let workspace: string;
|
|
53
|
+
let bundle: string;
|
|
54
|
+
|
|
55
|
+
async function readBundle(outputDirectory: string): Promise<string> {
|
|
56
|
+
const entries = await readdir(outputDirectory, {
|
|
57
|
+
recursive: true,
|
|
58
|
+
withFileTypes: true,
|
|
59
|
+
});
|
|
60
|
+
const files = entries.filter((entry) => entry.isFile());
|
|
61
|
+
const contents = await Promise.all(
|
|
62
|
+
files.map((entry) =>
|
|
63
|
+
readFile(path.join(entry.parentPath, entry.name), 'utf8'),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
return contents.join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
workspace = await mkdtemp(path.join(tmpdir(), 'nocobase-dev-routes-'));
|
|
71
|
+
const source = path.join(workspace, 'src');
|
|
72
|
+
await mkdir(source, { recursive: true });
|
|
73
|
+
|
|
74
|
+
await writeFile(
|
|
75
|
+
path.join(source, 'dev-only-dependency.ts'),
|
|
76
|
+
`export const devOnlyDependency = '${DEV_ONLY_DEPENDENCY_MARKER}';\n`,
|
|
77
|
+
);
|
|
78
|
+
await writeFile(
|
|
79
|
+
path.join(source, 'dev-page.tsx'),
|
|
80
|
+
`import { devOnlyDependency } from './dev-only-dependency.js';\n\n` +
|
|
81
|
+
`export default function DevPage() {\n` +
|
|
82
|
+
` return '${DEV_PAGE_MARKER}' + devOnlyDependency;\n` +
|
|
83
|
+
`}\n`,
|
|
84
|
+
);
|
|
85
|
+
await writeFile(
|
|
86
|
+
path.join(source, 'settings-page.tsx'),
|
|
87
|
+
`export default function SettingsPage() {\n` +
|
|
88
|
+
` return '${SETTINGS_PAGE_MARKER}';\n` +
|
|
89
|
+
`}\n`,
|
|
90
|
+
);
|
|
91
|
+
// The entry mirrors how a plugin declares both surfaces side by side, so the settings page acts as the control: it
|
|
92
|
+
// must survive exactly the build that drops the dev page.
|
|
93
|
+
await writeFile(
|
|
94
|
+
path.join(source, 'main.ts'),
|
|
95
|
+
`import { defineDevRoutes, defineSettingsRoutes } from '@nocobase/app-client/plugins';\n\n` +
|
|
96
|
+
`const routes = [\n` +
|
|
97
|
+
` defineSettingsRoutes([\n` +
|
|
98
|
+
` {\n` +
|
|
99
|
+
` name: 'settings-page',\n` +
|
|
100
|
+
` path: '/settings-page',\n` +
|
|
101
|
+
` navigation: { title: 'Settings page' },\n` +
|
|
102
|
+
` componentLoader: () => import('./settings-page.js'),\n` +
|
|
103
|
+
` },\n` +
|
|
104
|
+
` ]),\n` +
|
|
105
|
+
` defineDevRoutes([\n` +
|
|
106
|
+
` {\n` +
|
|
107
|
+
` name: 'dev-page',\n` +
|
|
108
|
+
` path: '/dev-page',\n` +
|
|
109
|
+
` navigation: { title: 'Dev page' },\n` +
|
|
110
|
+
` componentLoader: () => import('./dev-page.js'),\n` +
|
|
111
|
+
` },\n` +
|
|
112
|
+
` ]),\n` +
|
|
113
|
+
`];\n\n` +
|
|
114
|
+
`globalThis.__routes = routes;\n`,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Vite decides `import.meta.env.PROD` from NODE_ENV, not from the build mode. Vitest sets NODE_ENV=test, under
|
|
118
|
+
// which Vite treats even `mode: 'production'` as a non-production build and leaves the dev branch in place — so the
|
|
119
|
+
// variable has to be set for the duration of the build, or this test would assert against a development bundle.
|
|
120
|
+
const previousNodeEnv = process.env.NODE_ENV;
|
|
121
|
+
process.env.NODE_ENV = 'production';
|
|
122
|
+
try {
|
|
123
|
+
await build({
|
|
124
|
+
root: workspace,
|
|
125
|
+
logLevel: 'silent',
|
|
126
|
+
resolve: {
|
|
127
|
+
alias: [
|
|
128
|
+
{
|
|
129
|
+
find: '@nocobase/app-client/plugins',
|
|
130
|
+
replacement: appClientPlugins,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
build: {
|
|
135
|
+
outDir: 'dist',
|
|
136
|
+
rollupOptions: { input: path.join(source, 'main.ts') },
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
} finally {
|
|
140
|
+
if (previousNodeEnv === undefined) {
|
|
141
|
+
delete process.env.NODE_ENV;
|
|
142
|
+
} else {
|
|
143
|
+
process.env.NODE_ENV = previousNodeEnv;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
bundle = await readBundle(path.join(workspace, 'dist'));
|
|
148
|
+
}, 120_000);
|
|
149
|
+
|
|
150
|
+
afterAll(async () => {
|
|
151
|
+
if (workspace) {
|
|
152
|
+
await rm(workspace, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('dev routes in a production build', () => {
|
|
157
|
+
it('builds the settings page that acts as the control', () => {
|
|
158
|
+
expect(bundle).toContain(SETTINGS_PAGE_MARKER);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('drops the dev page component', () => {
|
|
162
|
+
expect(bundle).not.toContain(DEV_PAGE_MARKER);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('drops a module only a dev page imports', () => {
|
|
166
|
+
expect(bundle).not.toContain(DEV_ONLY_DEPENDENCY_MARKER);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { MigrationSource } from '@nocobase/db';
|
|
4
|
+
import { describeMigration } from '@nocobase/app-testing/server';
|
|
5
|
+
import { expect } from 'vitest';
|
|
6
|
+
|
|
7
|
+
const sources: readonly MigrationSource[] = [
|
|
8
|
+
{
|
|
9
|
+
packageName: 'device-code-test',
|
|
10
|
+
directory: path.resolve(
|
|
11
|
+
import.meta.dirname,
|
|
12
|
+
'../../database/main/migrations',
|
|
13
|
+
),
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// The table Better Auth's `deviceAuthorization()` reads and writes (`server/config/auth.ts`).
|
|
18
|
+
describeMigration('202610060001_create_device_code', {
|
|
19
|
+
sources,
|
|
20
|
+
up: async ({ connection, expectCollection }) => {
|
|
21
|
+
const deviceCode = await expectCollection('deviceCode').toExist();
|
|
22
|
+
expect(Object.keys(deviceCode.fields)).toEqual([
|
|
23
|
+
'id',
|
|
24
|
+
'deviceCode',
|
|
25
|
+
'userCode',
|
|
26
|
+
'userId',
|
|
27
|
+
'expiresAt',
|
|
28
|
+
'status',
|
|
29
|
+
'lastPolledAt',
|
|
30
|
+
'pollingInterval',
|
|
31
|
+
'clientId',
|
|
32
|
+
'scope',
|
|
33
|
+
]);
|
|
34
|
+
expect(deviceCode.primaryKey).toEqual(['id']);
|
|
35
|
+
await expectCollection('deviceCode').toHaveField('userId', {
|
|
36
|
+
nullable: true,
|
|
37
|
+
});
|
|
38
|
+
await expectCollection('deviceCode').toHaveField('expiresAt', {
|
|
39
|
+
nullable: false,
|
|
40
|
+
});
|
|
41
|
+
const row = {
|
|
42
|
+
deviceCode: 'device-1',
|
|
43
|
+
userCode: 'WXYZ2345',
|
|
44
|
+
expiresAt: new Date(),
|
|
45
|
+
status: 'pending',
|
|
46
|
+
pollingInterval: 5000,
|
|
47
|
+
clientId: 'cli',
|
|
48
|
+
};
|
|
49
|
+
await connection.query
|
|
50
|
+
.insertInto('deviceCode')
|
|
51
|
+
.values({ ...row, id: 'a' })
|
|
52
|
+
.execute();
|
|
53
|
+
// Both codes are unique.
|
|
54
|
+
await expect(
|
|
55
|
+
connection.query
|
|
56
|
+
.insertInto('deviceCode')
|
|
57
|
+
.values({ ...row, id: 'b', userCode: 'OTHER234' })
|
|
58
|
+
.execute(),
|
|
59
|
+
).rejects.toThrow();
|
|
60
|
+
await expect(
|
|
61
|
+
connection.query
|
|
62
|
+
.insertInto('deviceCode')
|
|
63
|
+
.values({ ...row, id: 'c', deviceCode: 'device-2' })
|
|
64
|
+
.execute(),
|
|
65
|
+
).rejects.toThrow();
|
|
66
|
+
},
|
|
67
|
+
down: async ({ expectCollection }) => {
|
|
68
|
+
await expectCollection('deviceCode').not.toExist();
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
RealtimeProvider,
|
|
5
|
+
realtimeServiceToken,
|
|
6
|
+
} from '@nocobase/app-server/realtime';
|
|
7
|
+
import {
|
|
8
|
+
ServiceContainer,
|
|
9
|
+
ServiceProviderRegistry,
|
|
10
|
+
} from '@nocobase/service-provider';
|
|
11
|
+
import { createDefaultCachingConfig } from '@nocobase/caching';
|
|
12
|
+
import { CachingProvider, cachingToken } from '@nocobase/app-server/caching';
|
|
13
|
+
import type { AppConfigAccessor } from '@nocobase/app-server/config';
|
|
14
|
+
import { DriveProvider, driveManagerToken } from '@nocobase/app-server/drive';
|
|
15
|
+
import {
|
|
16
|
+
IdGeneratorProvider,
|
|
17
|
+
idGeneratorToken,
|
|
18
|
+
} from '@nocobase/app-server/id-generator';
|
|
19
|
+
import { LoggingProvider, loggingToken } from '@nocobase/app-server/logging';
|
|
20
|
+
import {
|
|
21
|
+
QueueServiceProvider,
|
|
22
|
+
queueServiceToken,
|
|
23
|
+
} from '@nocobase/app-server/queue';
|
|
24
|
+
import {
|
|
25
|
+
SessionProvider,
|
|
26
|
+
sessionManagerToken,
|
|
27
|
+
} from '@nocobase/app-server/session';
|
|
28
|
+
import { createNullSessionConfig } from '@nocobase/session';
|
|
29
|
+
import { databaseManagerToken, type DatabaseManager } from '@nocobase/db';
|
|
30
|
+
|
|
31
|
+
describe('app service providers', () => {
|
|
32
|
+
it('registers core services and shuts them down in reverse order', async () => {
|
|
33
|
+
const services = new ServiceContainer();
|
|
34
|
+
const registry = new ServiceProviderRegistry();
|
|
35
|
+
const app = createProviderApplication(
|
|
36
|
+
{
|
|
37
|
+
caching: createDefaultCachingConfig(),
|
|
38
|
+
drive: {
|
|
39
|
+
default: 'local',
|
|
40
|
+
disks: {
|
|
41
|
+
local: {
|
|
42
|
+
driver: 'fs',
|
|
43
|
+
location: process.cwd(),
|
|
44
|
+
visibility: 'private',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
logging: {
|
|
49
|
+
enabled: false,
|
|
50
|
+
level: 'silent',
|
|
51
|
+
},
|
|
52
|
+
queue: {
|
|
53
|
+
default: 'memory',
|
|
54
|
+
memory: { adapter: 'inMemory' },
|
|
55
|
+
},
|
|
56
|
+
session: createNullSessionConfig(),
|
|
57
|
+
snowflake: {
|
|
58
|
+
workerId: 0,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
services,
|
|
62
|
+
);
|
|
63
|
+
registry.add(new LoggingProvider(app));
|
|
64
|
+
registry.add(new CachingProvider(app));
|
|
65
|
+
registry.add(new IdGeneratorProvider(app));
|
|
66
|
+
registry.add(new SessionProvider(app));
|
|
67
|
+
registry.add(new DriveProvider(app));
|
|
68
|
+
registry.add(new QueueServiceProvider(app));
|
|
69
|
+
registry.add(new RealtimeProvider(app));
|
|
70
|
+
|
|
71
|
+
registry.registerAll();
|
|
72
|
+
const logging = services.resolve(loggingToken);
|
|
73
|
+
const caching = services.resolve(cachingToken);
|
|
74
|
+
const idGenerator = services.resolve(idGeneratorToken);
|
|
75
|
+
const sessionManager = services.resolve(sessionManagerToken);
|
|
76
|
+
const queue = services.resolve(queueServiceToken);
|
|
77
|
+
const realtime = services.resolve(realtimeServiceToken);
|
|
78
|
+
const closeLogging = vi.spyOn(logging, 'close');
|
|
79
|
+
const dispose = vi.spyOn(caching, 'dispose');
|
|
80
|
+
const disposeSession = vi.spyOn(sessionManager, 'dispose');
|
|
81
|
+
const closeQueue = vi.spyOn(queue, 'shutdown');
|
|
82
|
+
const closeRealtime = vi.spyOn(realtime, 'close');
|
|
83
|
+
|
|
84
|
+
await registry.shutdown();
|
|
85
|
+
|
|
86
|
+
expect(services.resolve(idGeneratorToken)).toBe(idGenerator);
|
|
87
|
+
expect(services.has(driveManagerToken)).toBe(true);
|
|
88
|
+
expect(closeRealtime).toHaveBeenCalledOnce();
|
|
89
|
+
expect(closeQueue).toHaveBeenCalledOnce();
|
|
90
|
+
expect(disposeSession).toHaveBeenCalledOnce();
|
|
91
|
+
expect(dispose).toHaveBeenCalledOnce();
|
|
92
|
+
expect(closeLogging).toHaveBeenCalledOnce();
|
|
93
|
+
expect(disposeSession.mock.invocationCallOrder[0]).toBeLessThan(
|
|
94
|
+
dispose.mock.invocationCallOrder[0],
|
|
95
|
+
);
|
|
96
|
+
expect(closeQueue.mock.invocationCallOrder[0]).toBeLessThan(
|
|
97
|
+
disposeSession.mock.invocationCallOrder[0],
|
|
98
|
+
);
|
|
99
|
+
expect(closeRealtime.mock.invocationCallOrder[0]).toBeLessThan(
|
|
100
|
+
closeQueue.mock.invocationCallOrder[0],
|
|
101
|
+
);
|
|
102
|
+
expect(dispose.mock.invocationCallOrder[0]).toBeLessThan(
|
|
103
|
+
closeLogging.mock.invocationCallOrder[0],
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('registers and prepares drive only when configuration is available', async () => {
|
|
108
|
+
const services = new ServiceContainer();
|
|
109
|
+
const registry = new ServiceProviderRegistry();
|
|
110
|
+
registry.add(
|
|
111
|
+
new DriveProvider({
|
|
112
|
+
config: createTestConfig({
|
|
113
|
+
drive: {
|
|
114
|
+
default: 'public',
|
|
115
|
+
disks: {
|
|
116
|
+
public: {
|
|
117
|
+
driver: 'fs',
|
|
118
|
+
location: process.cwd(),
|
|
119
|
+
visibility: 'public',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
container: services,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
registry.registerAll();
|
|
129
|
+
await registry.bootAll();
|
|
130
|
+
|
|
131
|
+
expect(services.has(driveManagerToken)).toBe(true);
|
|
132
|
+
expect(services.resolve(driveManagerToken)).toBeDefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('resolves authentication, authorization, and queue provider dependencies', async () => {
|
|
136
|
+
const services = new ServiceContainer();
|
|
137
|
+
const registry = new ServiceProviderRegistry();
|
|
138
|
+
const connection = { kind: 'connection' };
|
|
139
|
+
const database = {
|
|
140
|
+
connection: vi.fn(() => connection),
|
|
141
|
+
destroy: vi.fn(() => Promise.resolve()),
|
|
142
|
+
} as unknown as DatabaseManager;
|
|
143
|
+
const queueConfig = {
|
|
144
|
+
default: 'test',
|
|
145
|
+
test: { adapter: 'inMemory' as const },
|
|
146
|
+
};
|
|
147
|
+
const app = createProviderApplication(
|
|
148
|
+
{
|
|
149
|
+
database: createDatabaseConfig(),
|
|
150
|
+
app: {
|
|
151
|
+
name: 'provider-test',
|
|
152
|
+
publicOrigin: 'https://example.com',
|
|
153
|
+
publicBasePath: '/provider-test',
|
|
154
|
+
},
|
|
155
|
+
auth: {
|
|
156
|
+
secret: 'test-auth-secret-at-least-32-characters',
|
|
157
|
+
},
|
|
158
|
+
caching: createDefaultCachingConfig(),
|
|
159
|
+
logging: {
|
|
160
|
+
enabled: false,
|
|
161
|
+
level: 'silent',
|
|
162
|
+
},
|
|
163
|
+
queue: queueConfig,
|
|
164
|
+
snowflake: {
|
|
165
|
+
workerId: 0,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
services,
|
|
169
|
+
);
|
|
170
|
+
services.instance(databaseManagerToken, database);
|
|
171
|
+
registry.add(new LoggingProvider(app));
|
|
172
|
+
registry.add(new CachingProvider(app));
|
|
173
|
+
registry.add(new IdGeneratorProvider(app));
|
|
174
|
+
registry.add(new QueueServiceProvider(app));
|
|
175
|
+
|
|
176
|
+
registry.registerAll();
|
|
177
|
+
services.resolve(queueServiceToken);
|
|
178
|
+
|
|
179
|
+
expect(services.resolve(databaseManagerToken)).toBe(database);
|
|
180
|
+
await registry.shutdown();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function createProviderApplication(
|
|
185
|
+
values: Readonly<Record<string, unknown>>,
|
|
186
|
+
container: ServiceContainer,
|
|
187
|
+
): {
|
|
188
|
+
appName: string;
|
|
189
|
+
paths: { storage: (...segments: string[]) => string };
|
|
190
|
+
config: AppConfigAccessor;
|
|
191
|
+
container: ServiceContainer;
|
|
192
|
+
} {
|
|
193
|
+
return {
|
|
194
|
+
appName: 'provider-test',
|
|
195
|
+
paths: { storage: (...segments: string[]) => segments.join('/') },
|
|
196
|
+
config: createTestConfig(values),
|
|
197
|
+
container,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function createTestConfig(
|
|
202
|
+
values: Readonly<Record<string, unknown>>,
|
|
203
|
+
): AppConfigAccessor {
|
|
204
|
+
return {
|
|
205
|
+
get: <TValue>(definition: string): TValue => values[definition] as TValue,
|
|
206
|
+
raw: () => values,
|
|
207
|
+
reload: () => Promise.resolve({ changedNamespaces: [] }),
|
|
208
|
+
subscribe: () => () => undefined,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function createDatabaseConfig(): object {
|
|
213
|
+
return {
|
|
214
|
+
default: 'main',
|
|
215
|
+
connections: {},
|
|
216
|
+
migrations: {
|
|
217
|
+
directory: '',
|
|
218
|
+
autoRun: false,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
// Users develop, build and start their app through these scripts. Nothing at runtime depends on them, so dropping or
|
|
4
|
+
// renaming one — a bad merge resolution did exactly that once — breaks the documented workflow silently: the app still
|
|
5
|
+
// builds, starts, and passes every other test. These assertions are the alarm.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
|
|
12
|
+
const appRoot = fileURLToPath(new URL('../..', import.meta.url));
|
|
13
|
+
|
|
14
|
+
interface AppPackageJson {
|
|
15
|
+
readonly files?: readonly string[];
|
|
16
|
+
readonly publishConfig?: unknown;
|
|
17
|
+
readonly scripts?: Record<string, string>;
|
|
18
|
+
readonly dependencies?: Record<string, string>;
|
|
19
|
+
readonly devDependencies?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const appPackage = JSON.parse(
|
|
23
|
+
readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
|
|
24
|
+
) as AppPackageJson;
|
|
25
|
+
|
|
26
|
+
const scripts = appPackage.scripts ?? {};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The scripts an application keeps. Every other command is `pnpm nocobase <topic> <command>`, which pnpm resolves to
|
|
30
|
+
* the `nocobase` bin without a script, so a script alias would only be a second name to keep in step.
|
|
31
|
+
*/
|
|
32
|
+
const LIFECYCLE_SCRIPTS: Readonly<Record<string, string>> = {
|
|
33
|
+
postinstall: 'nocobase skills sync',
|
|
34
|
+
dev: 'nocobase dev',
|
|
35
|
+
build: 'nocobase build',
|
|
36
|
+
start: 'nocobase start',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('application scripts', () => {
|
|
40
|
+
it.each(Object.entries(LIFECYCLE_SCRIPTS))('exposes %s', (name, command) => {
|
|
41
|
+
expect(scripts[name]).toBe(command);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps no alias for a CLI command', () => {
|
|
45
|
+
const aliases = Object.entries(scripts).filter(
|
|
46
|
+
([name, command]) =>
|
|
47
|
+
!(name in LIFECYCLE_SCRIPTS) && /(^|&&\s*)nocobase\s/.test(command),
|
|
48
|
+
);
|
|
49
|
+
expect(aliases).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('declares the CLI the scripts invoke', () => {
|
|
53
|
+
// A runtime dependency, not tooling: a deployment runs `dist/cli/index.js`, which imports it, and installs from
|
|
54
|
+
// `dependencies` alone.
|
|
55
|
+
expect(appPackage.dependencies?.['@nocobase/app-cli']).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps synchronized Agent state out of source control and publication', () => {
|
|
59
|
+
expect(readFileSync(path.join(appRoot, '.gitignore'), 'utf8')).toContain(
|
|
60
|
+
'/.agents/',
|
|
61
|
+
);
|
|
62
|
+
const npmIgnorePath = path.join(appRoot, '.npmignore');
|
|
63
|
+
if (appPackage.publishConfig) {
|
|
64
|
+
expect(existsSync(npmIgnorePath)).toBe(true);
|
|
65
|
+
expect(readFileSync(npmIgnorePath, 'utf8')).toContain('.agents/');
|
|
66
|
+
}
|
|
67
|
+
expect(appPackage.files).not.toContain('.agents');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveAppClientContributions,
|
|
5
|
+
type AppClientRegisteredRoute,
|
|
6
|
+
} from '@nocobase/app-client/plugins';
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import clientPlugins from '../../client/plugins.js';
|
|
10
|
+
|
|
11
|
+
// The plugins whose pages the permission workspace offers as page grants.
|
|
12
|
+
const PAGE_GRANT_PLUGINS = [
|
|
13
|
+
'@nocobase/app-plugin-users',
|
|
14
|
+
'@nocobase/app-plugin-notification',
|
|
15
|
+
'@nocobase/app-plugin-api-keys',
|
|
16
|
+
'@nocobase/app-plugin-database-explorer',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
describe('plugin page authorization', () => {
|
|
20
|
+
it('offers a page grant for each listed plugin', () => {
|
|
21
|
+
const resolved = resolveAppClientContributions(
|
|
22
|
+
clientPlugins.plugins.map((plugin) => ({
|
|
23
|
+
packageName: plugin.packageName,
|
|
24
|
+
source: 'plugin' as const,
|
|
25
|
+
routes: plugin.routes,
|
|
26
|
+
})),
|
|
27
|
+
);
|
|
28
|
+
const pages = entryPages([
|
|
29
|
+
...resolved.routes,
|
|
30
|
+
...resolved.settingsRouteTree,
|
|
31
|
+
]).filter((route) => PAGE_GRANT_PLUGINS.includes(route.packageName));
|
|
32
|
+
|
|
33
|
+
// A page may check another resource type; each plugin still offers a page grant.
|
|
34
|
+
const granted = pages.filter(
|
|
35
|
+
(route) =>
|
|
36
|
+
typeof route.authz === 'object' &&
|
|
37
|
+
route.authz.resource.type === 'page' &&
|
|
38
|
+
route.authz.action === 'access',
|
|
39
|
+
);
|
|
40
|
+
expect(new Set(granted.map((route) => route.packageName))).toEqual(
|
|
41
|
+
new Set(PAGE_GRANT_PLUGINS),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Pages without a page above them: nested pages share their parent's check. */
|
|
47
|
+
function entryPages(
|
|
48
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
49
|
+
): AppClientRegisteredRoute[] {
|
|
50
|
+
return routes.flatMap((route) =>
|
|
51
|
+
route.componentLoader ? [route] : entryPages(route.children ?? []),
|
|
52
|
+
);
|
|
53
|
+
}
|