@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,66 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
buildRouteNavigation,
|
|
5
|
+
selectedNavigationId,
|
|
6
|
+
} from '../../client/routing/route-navigation.js';
|
|
7
|
+
|
|
8
|
+
const page = (name: string, path: string): AppClientRegisteredRoute => ({
|
|
9
|
+
name,
|
|
10
|
+
id: name,
|
|
11
|
+
path,
|
|
12
|
+
auth: 'required',
|
|
13
|
+
packageName: 'test',
|
|
14
|
+
source: 'application',
|
|
15
|
+
navigation: { title: name },
|
|
16
|
+
componentLoader: async () => ({ default: () => null }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('route navigation', () => {
|
|
20
|
+
it('promotes visible descendants of hidden pages and removes denied subtrees and empty groups', () => {
|
|
21
|
+
const routes = [
|
|
22
|
+
{
|
|
23
|
+
...page('hidden', '/hidden'),
|
|
24
|
+
navigation: undefined,
|
|
25
|
+
children: [page('child', '/hidden/child')],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
...page('denied', '/denied'),
|
|
29
|
+
children: [page('secret', '/denied/secret')],
|
|
30
|
+
},
|
|
31
|
+
{ ...page('empty', '/empty'), componentLoader: undefined, children: [] },
|
|
32
|
+
];
|
|
33
|
+
expect(
|
|
34
|
+
buildRouteNavigation(routes, new Set(['test:/denied:denied'])).map(
|
|
35
|
+
({ route }) => route.id,
|
|
36
|
+
),
|
|
37
|
+
).toEqual(['child']);
|
|
38
|
+
});
|
|
39
|
+
it('does not select an unrelated menu for a matched hidden root page', () => {
|
|
40
|
+
const routes = [
|
|
41
|
+
page('home', '/'),
|
|
42
|
+
{ ...page('hidden', '/hidden'), navigation: undefined },
|
|
43
|
+
];
|
|
44
|
+
expect(selectedNavigationId(routes, '/hidden')).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('selects the deepest menu page through hidden descendants and multiple groups', () => {
|
|
48
|
+
const routes = [
|
|
49
|
+
{
|
|
50
|
+
...page('group', '/'),
|
|
51
|
+
componentLoader: undefined,
|
|
52
|
+
children: [
|
|
53
|
+
{
|
|
54
|
+
...page('orders', '/orders'),
|
|
55
|
+
children: [
|
|
56
|
+
{ ...page('detail', '/orders/:orderId'), navigation: undefined },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
expect(selectedNavigationId(routes, '/orders/42')).toBe(
|
|
63
|
+
'test:/orders:orders',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import {
|
|
2
|
+
act,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
} from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import {
|
|
10
|
+
TestI18nProvider,
|
|
11
|
+
createTestI18nRuntime,
|
|
12
|
+
} from '@nocobase/i18n/testing';
|
|
13
|
+
import { useEffect, type ReactNode } from 'react';
|
|
14
|
+
import { createMemoryRouter, Link, Outlet, RouterProvider } from 'react-router';
|
|
15
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
16
|
+
import { RouteDialog } from '../../client/components/route-dialog';
|
|
17
|
+
import { RouteDrawer } from '../../client/components/route-drawer';
|
|
18
|
+
import { useRouteOverlay } from '../../client/components/use-route-overlay';
|
|
19
|
+
import enUS from '../../client/locales/en-US.js';
|
|
20
|
+
|
|
21
|
+
const runtime = await createTestI18nRuntime({
|
|
22
|
+
application: { namespace: '@nocobase/app-template-default', resources: enUS },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function I18n({ children }: { readonly children: ReactNode }) {
|
|
26
|
+
return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Actions() {
|
|
30
|
+
const { close, isClosing } = useRouteOverlay();
|
|
31
|
+
return (
|
|
32
|
+
<button disabled={isClosing} onClick={() => void close()}>
|
|
33
|
+
Cancel
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
function setup(
|
|
38
|
+
beforeClose?: () => boolean | Promise<boolean>,
|
|
39
|
+
closeTo?: string,
|
|
40
|
+
nested = false,
|
|
41
|
+
) {
|
|
42
|
+
const router = createMemoryRouter(
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
path: '/orders',
|
|
46
|
+
element: (
|
|
47
|
+
<>
|
|
48
|
+
<span>Orders</span>
|
|
49
|
+
<Outlet />
|
|
50
|
+
</>
|
|
51
|
+
),
|
|
52
|
+
children: [
|
|
53
|
+
{
|
|
54
|
+
path: 'edit/:id',
|
|
55
|
+
element: (
|
|
56
|
+
<RouteDialog
|
|
57
|
+
title='Edit'
|
|
58
|
+
beforeClose={beforeClose}
|
|
59
|
+
closeTo={closeTo}
|
|
60
|
+
footer={<Actions />}
|
|
61
|
+
>
|
|
62
|
+
<input aria-label='Name' defaultValue='Original' />
|
|
63
|
+
<Link to='details'>Open details</Link>
|
|
64
|
+
{/* A page places the outlet wherever its next child belongs. */}
|
|
65
|
+
<Outlet />
|
|
66
|
+
</RouteDialog>
|
|
67
|
+
),
|
|
68
|
+
children: [
|
|
69
|
+
{
|
|
70
|
+
path: 'details',
|
|
71
|
+
element: (
|
|
72
|
+
<RouteDrawer title='Details' footer={<Actions />}>
|
|
73
|
+
Detail
|
|
74
|
+
</RouteDrawer>
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{ path: '/other', element: <span>Other</span> },
|
|
82
|
+
],
|
|
83
|
+
{
|
|
84
|
+
basename: '/main',
|
|
85
|
+
initialEntries: [
|
|
86
|
+
`/main/orders/edit/42${nested ? '/details' : ''}?filter=recent#field`,
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
render(<RouterProvider router={router} />, { wrapper: I18n });
|
|
91
|
+
return router;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('route overlays', () => {
|
|
95
|
+
it('closes a direct link to its route parent, preserving query and removing hash', async () => {
|
|
96
|
+
const router = setup();
|
|
97
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
98
|
+
await waitFor(() =>
|
|
99
|
+
expect(router.state.location.pathname).toBe('/main/orders'),
|
|
100
|
+
);
|
|
101
|
+
expect(router.state.location.search).toBe('?filter=recent');
|
|
102
|
+
expect(router.state.location.hash).toBe('');
|
|
103
|
+
expect(router.state.historyAction).toBe('REPLACE');
|
|
104
|
+
});
|
|
105
|
+
it('uses the explicit target without merging query', async () => {
|
|
106
|
+
const router = setup(undefined, '/other');
|
|
107
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Close' }));
|
|
108
|
+
await waitFor(() =>
|
|
109
|
+
expect(router.state.location.pathname).toBe('/main/other'),
|
|
110
|
+
);
|
|
111
|
+
expect(router.state.location.search).toBe('');
|
|
112
|
+
});
|
|
113
|
+
it('keeps the dialog open while checking and when rejected', async () => {
|
|
114
|
+
let resolve!: (value: boolean) => void;
|
|
115
|
+
const check = vi.fn(
|
|
116
|
+
() =>
|
|
117
|
+
new Promise<boolean>((done) => {
|
|
118
|
+
resolve = done;
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
const router = setup(check);
|
|
122
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
123
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
124
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
|
125
|
+
await waitFor(() => expect(check).toHaveBeenCalledTimes(1));
|
|
126
|
+
await act(async () => {
|
|
127
|
+
resolve(false);
|
|
128
|
+
});
|
|
129
|
+
expect(check).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42');
|
|
131
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
|
|
132
|
+
});
|
|
133
|
+
it('ignores a pending result after navigation away', async () => {
|
|
134
|
+
let resolve!: (value: boolean) => void;
|
|
135
|
+
const router = setup(
|
|
136
|
+
() =>
|
|
137
|
+
new Promise<boolean>((done) => {
|
|
138
|
+
resolve = done;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
142
|
+
await act(() => router.navigate('/other'));
|
|
143
|
+
await act(async () => {
|
|
144
|
+
resolve(true);
|
|
145
|
+
});
|
|
146
|
+
expect(router.state.location.pathname).toBe('/main/other');
|
|
147
|
+
});
|
|
148
|
+
it('closes only the top drawer and retains its parent dialog', async () => {
|
|
149
|
+
const router = setup(undefined, undefined, true);
|
|
150
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
151
|
+
await waitFor(() =>
|
|
152
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42'),
|
|
153
|
+
);
|
|
154
|
+
expect(await screen.findByRole('dialog', { name: 'Edit' })).toBeVisible();
|
|
155
|
+
expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue(
|
|
156
|
+
'Original',
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('route overlay interactions', () => {
|
|
162
|
+
it('renders an owning backdrop for the nested layer and dismisses only that layer', async () => {
|
|
163
|
+
const user = userEvent.setup();
|
|
164
|
+
const parentCheck = vi.fn(() => true);
|
|
165
|
+
const router = setup(parentCheck, undefined, true);
|
|
166
|
+
const child = await screen.findByRole('dialog', { name: 'Details' });
|
|
167
|
+
await waitFor(() =>
|
|
168
|
+
expect(child.contains(document.activeElement)).toBe(true),
|
|
169
|
+
);
|
|
170
|
+
// The hit target must belong to the child portal. A parent's z-indexed
|
|
171
|
+
// backdrop otherwise obscures the child's unstyled internal backdrop.
|
|
172
|
+
const portal = child.closest('[data-base-ui-portal]');
|
|
173
|
+
const backdrop = portal?.querySelector(
|
|
174
|
+
':scope > [data-slot="dialog-overlay"]',
|
|
175
|
+
);
|
|
176
|
+
expect(backdrop).toBeInTheDocument();
|
|
177
|
+
await user.click(backdrop!);
|
|
178
|
+
await waitFor(() =>
|
|
179
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42'),
|
|
180
|
+
);
|
|
181
|
+
expect(parentCheck).not.toHaveBeenCalled();
|
|
182
|
+
await waitFor(() =>
|
|
183
|
+
expect(screen.getByRole('dialog', { name: 'Edit' })).toBeVisible(),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('dismisses only the top layer with Escape', async () => {
|
|
188
|
+
const user = userEvent.setup();
|
|
189
|
+
const parentCheck = vi.fn(() => true);
|
|
190
|
+
const router = setup(parentCheck, undefined, true);
|
|
191
|
+
const child = await screen.findByRole('dialog', { name: 'Details' });
|
|
192
|
+
await waitFor(() =>
|
|
193
|
+
expect(child.contains(document.activeElement)).toBe(true),
|
|
194
|
+
);
|
|
195
|
+
await user.keyboard('{Escape}');
|
|
196
|
+
await waitFor(() =>
|
|
197
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42'),
|
|
198
|
+
);
|
|
199
|
+
expect(parentCheck).not.toHaveBeenCalled();
|
|
200
|
+
expect(await screen.findByRole('dialog', { name: 'Edit' })).toBeVisible();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('retains edited parent state and restores focus to the link that opened its child', async () => {
|
|
204
|
+
const user = userEvent.setup();
|
|
205
|
+
const router = setup();
|
|
206
|
+
const name = await screen.findByRole('textbox', { name: 'Name' });
|
|
207
|
+
await user.clear(name);
|
|
208
|
+
await user.type(name, 'Changed');
|
|
209
|
+
const link = screen.getByRole('link', { name: 'Open details' });
|
|
210
|
+
await user.click(link);
|
|
211
|
+
const child = await screen.findByRole('dialog', { name: 'Details' });
|
|
212
|
+
await waitFor(() =>
|
|
213
|
+
expect(child.contains(document.activeElement)).toBe(true),
|
|
214
|
+
);
|
|
215
|
+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
216
|
+
await waitFor(() =>
|
|
217
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42'),
|
|
218
|
+
);
|
|
219
|
+
await waitFor(() =>
|
|
220
|
+
expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue(
|
|
221
|
+
'Changed',
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
await waitFor(() => expect(link).toHaveFocus());
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('restores focus into the parent when closing a directly loaded nested route', async () => {
|
|
228
|
+
const user = userEvent.setup();
|
|
229
|
+
setup(undefined, undefined, true);
|
|
230
|
+
const child = await screen.findByRole('dialog', { name: 'Details' });
|
|
231
|
+
await waitFor(() =>
|
|
232
|
+
expect(child.contains(document.activeElement)).toBe(true),
|
|
233
|
+
);
|
|
234
|
+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
235
|
+
const parent = await screen.findByRole('dialog', { name: 'Edit' });
|
|
236
|
+
await waitFor(() =>
|
|
237
|
+
expect(parent.contains(document.activeElement)).toBe(true),
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('allows history back and forward without running the close guard', async () => {
|
|
242
|
+
const guard = vi.fn(() => false);
|
|
243
|
+
const router = createMemoryRouter(
|
|
244
|
+
[
|
|
245
|
+
{
|
|
246
|
+
path: '/orders',
|
|
247
|
+
element: (
|
|
248
|
+
<>
|
|
249
|
+
<span>Orders</span>
|
|
250
|
+
<Outlet />
|
|
251
|
+
</>
|
|
252
|
+
),
|
|
253
|
+
children: [
|
|
254
|
+
{
|
|
255
|
+
path: 'edit',
|
|
256
|
+
element: (
|
|
257
|
+
<RouteDialog title='Edit' beforeClose={guard}>
|
|
258
|
+
Form
|
|
259
|
+
</RouteDialog>
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
{ initialEntries: ['/orders', '/orders/edit'] },
|
|
266
|
+
);
|
|
267
|
+
render(<RouterProvider router={router} />, { wrapper: I18n });
|
|
268
|
+
expect(await screen.findByRole('dialog', { name: 'Edit' })).toBeVisible();
|
|
269
|
+
await act(() => router.navigate(-1));
|
|
270
|
+
expect(router.state.location.pathname).toBe('/orders');
|
|
271
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
272
|
+
await act(() => router.navigate(1));
|
|
273
|
+
expect(await screen.findByRole('dialog', { name: 'Edit' })).toBeVisible();
|
|
274
|
+
expect(guard).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('logs a rejected internal close request and leaves the dialog open', async () => {
|
|
278
|
+
const failure = new Error('Confirmation unavailable');
|
|
279
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
280
|
+
try {
|
|
281
|
+
const router = setup(() => Promise.reject(failure));
|
|
282
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Close' }));
|
|
283
|
+
await waitFor(() =>
|
|
284
|
+
expect(log).toHaveBeenCalledWith(
|
|
285
|
+
'Failed to close route overlay',
|
|
286
|
+
failure,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42');
|
|
290
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
|
|
291
|
+
} finally {
|
|
292
|
+
log.mockRestore();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('propagates a failed close check to the hook caller and permits a retry', async () => {
|
|
297
|
+
const failure = new Error('Confirmation unavailable');
|
|
298
|
+
const guard = vi
|
|
299
|
+
.fn()
|
|
300
|
+
.mockRejectedValueOnce(failure)
|
|
301
|
+
.mockResolvedValueOnce(true);
|
|
302
|
+
let overlay: ReturnType<typeof useRouteOverlay> | undefined;
|
|
303
|
+
function Capture() {
|
|
304
|
+
const value = useRouteOverlay();
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
overlay = value;
|
|
307
|
+
}, [value]);
|
|
308
|
+
return <span>Form</span>;
|
|
309
|
+
}
|
|
310
|
+
const router = createMemoryRouter(
|
|
311
|
+
[
|
|
312
|
+
{
|
|
313
|
+
path: '/orders',
|
|
314
|
+
element: <Outlet />,
|
|
315
|
+
children: [
|
|
316
|
+
{
|
|
317
|
+
path: 'edit',
|
|
318
|
+
element: (
|
|
319
|
+
<RouteDialog title='Edit' beforeClose={guard}>
|
|
320
|
+
<Capture />
|
|
321
|
+
</RouteDialog>
|
|
322
|
+
),
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
{ initialEntries: ['/orders/edit'] },
|
|
328
|
+
);
|
|
329
|
+
render(<RouterProvider router={router} />, { wrapper: I18n });
|
|
330
|
+
await screen.findByRole('dialog', { name: 'Edit' });
|
|
331
|
+
await act(async () => {
|
|
332
|
+
await expect(overlay!.close()).rejects.toBe(failure);
|
|
333
|
+
});
|
|
334
|
+
expect(router.state.location.pathname).toBe('/orders/edit');
|
|
335
|
+
expect(overlay!.isClosing).toBe(false);
|
|
336
|
+
await act(async () => {
|
|
337
|
+
await overlay!.close();
|
|
338
|
+
});
|
|
339
|
+
expect(router.state.location.pathname).toBe('/orders');
|
|
340
|
+
expect(guard).toHaveBeenCalledTimes(2);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('reports hook use outside a route overlay', () => {
|
|
344
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
345
|
+
try {
|
|
346
|
+
expect(() => render(<Actions />)).toThrow(
|
|
347
|
+
'useRouteOverlay must be used inside RouteDialog or RouteDrawer',
|
|
348
|
+
);
|
|
349
|
+
} finally {
|
|
350
|
+
log.mockRestore();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
import { expect, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
async function compile(config: string): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
await exec('pnpm', ['exec', 'tsc', '-p', config], { cwd: appRoot });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
throw new Error((error as { stdout: string }).stdout, { cause: error });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const appRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
20
|
+
|
|
21
|
+
it('runs compiled extensionless server imports without source files or a loader', async () => {
|
|
22
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), 'app-esm-imports-'));
|
|
23
|
+
try {
|
|
24
|
+
const source = path.join(directory, 'source');
|
|
25
|
+
await mkdir(path.join(source, 'nested'), { recursive: true });
|
|
26
|
+
await writeFile(path.join(directory, 'package.json'), '{"type":"module"}');
|
|
27
|
+
await writeFile(
|
|
28
|
+
path.join(source, 'value.ts'),
|
|
29
|
+
'export const value: number = 42;',
|
|
30
|
+
);
|
|
31
|
+
await writeFile(
|
|
32
|
+
path.join(source, 'nested/index.ts'),
|
|
33
|
+
"export { value } from '../value';",
|
|
34
|
+
);
|
|
35
|
+
await writeFile(
|
|
36
|
+
path.join(source, 'main.ts'),
|
|
37
|
+
`
|
|
38
|
+
import { value } from './nested';
|
|
39
|
+
import { value as explicit } from './value.js';
|
|
40
|
+
const dynamic = await import('./value');
|
|
41
|
+
export const result: number[] = [value, explicit, dynamic.value];
|
|
42
|
+
`,
|
|
43
|
+
);
|
|
44
|
+
const config = path.join(directory, 'tsconfig.json');
|
|
45
|
+
await writeFile(
|
|
46
|
+
config,
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
extends: path.join(appRoot, 'tsconfig.server.json'),
|
|
49
|
+
compilerOptions: {
|
|
50
|
+
rootDir: source,
|
|
51
|
+
outDir: path.join(directory, 'dist'),
|
|
52
|
+
types: [],
|
|
53
|
+
},
|
|
54
|
+
include: [path.join(source, '**/*.ts')],
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
// Use the same two stages as the shared application build.
|
|
58
|
+
await compile(config);
|
|
59
|
+
await exec(
|
|
60
|
+
'pnpm',
|
|
61
|
+
[
|
|
62
|
+
'exec',
|
|
63
|
+
'tsc-alias',
|
|
64
|
+
'-p',
|
|
65
|
+
'tsconfig.server.json',
|
|
66
|
+
'--outDir',
|
|
67
|
+
path.join(directory, 'dist'),
|
|
68
|
+
],
|
|
69
|
+
{ cwd: appRoot },
|
|
70
|
+
);
|
|
71
|
+
await rm(source, { recursive: true });
|
|
72
|
+
const runner = path.join(directory, 'runner.mjs');
|
|
73
|
+
await writeFile(
|
|
74
|
+
runner,
|
|
75
|
+
`
|
|
76
|
+
import { result } from './dist/main.js';
|
|
77
|
+
import { writeFileSync } from 'node:fs';
|
|
78
|
+
writeFileSync(new URL('./result.json', import.meta.url), JSON.stringify(result));
|
|
79
|
+
`,
|
|
80
|
+
);
|
|
81
|
+
await exec(process.execPath, [runner], { env: { PATH: process.env.PATH } });
|
|
82
|
+
expect(
|
|
83
|
+
JSON.parse(await readFile(path.join(directory, 'result.json'), 'utf8')),
|
|
84
|
+
).toEqual([42, 42, 42]);
|
|
85
|
+
} finally {
|
|
86
|
+
await rm(directory, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}, 30_000);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import clientPlugins from '../../client/plugins.js';
|
|
2
|
+
// @vitest-environment node
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import tailwindConfig, { contentFilesIn } from '../../tailwind.config.mjs';
|
|
20
|
+
|
|
21
|
+
const appRoot = path.resolve(
|
|
22
|
+
fileURLToPath(new URL('.', import.meta.url)),
|
|
23
|
+
'../..',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
describe('tailwind content sources', () => {
|
|
27
|
+
const content = tailwindConfig.content as string[];
|
|
28
|
+
|
|
29
|
+
it('scans the client files of every enabled plugin', () => {
|
|
30
|
+
const plugins = clientPlugins.plugins.map((plugin) =>
|
|
31
|
+
plugin.packageName.slice('@nocobase/'.length),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(plugins.length).toBeGreaterThan(0);
|
|
35
|
+
|
|
36
|
+
// A plugin with no client surface contributes no files, so only those that ship one are required to appear.
|
|
37
|
+
const withClient = plugins.filter((plugin) =>
|
|
38
|
+
['client', 'dist/client'].some((directory) =>
|
|
39
|
+
existsSync(
|
|
40
|
+
path.join(appRoot, 'node_modules/@nocobase', plugin, directory),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
expect(withClient.length).toBeGreaterThan(0);
|
|
45
|
+
|
|
46
|
+
for (const plugin of withClient) {
|
|
47
|
+
expect(
|
|
48
|
+
content.some((file) => file.includes(plugin)),
|
|
49
|
+
`${plugin} contributes no scanned files; its utility classes would be missing from the stylesheet`,
|
|
50
|
+
).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('resolves past the symlinks pnpm installs dependencies as', () => {
|
|
55
|
+
// The bug this guards: a wildcard `@source` matches nothing through a pnpm symlink, so every path here must be a
|
|
56
|
+
// real one that exists. An empty list would mean the whole mechanism silently stopped working.
|
|
57
|
+
expect(content.length).toBeGreaterThan(0);
|
|
58
|
+
for (const file of content.slice(0, 20)) {
|
|
59
|
+
expect(path.isAbsolute(file)).toBe(true);
|
|
60
|
+
expect(existsSync(file)).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Every file must be its canonical path rather than a path that only reaches the file through a package symlink.
|
|
64
|
+
// pnpm's canonical store path legitimately contains a nested `node_modules/@nocobase` segment.
|
|
65
|
+
for (const file of content) {
|
|
66
|
+
expect(file).toBe(realpathSync(file));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('scans build output the same way, which is the layout an installed plugin has', () => {
|
|
71
|
+
// The original failure was invisible in this repository, where plugins expose TypeScript sources, and only showed
|
|
72
|
+
// up in a generated application, where they ship `dist/client`. `dist` is a build artefact that a clean checkout
|
|
73
|
+
// does not have, so rather than requiring one, this drives the configuration's own resolution over a fixture
|
|
74
|
+
// laid out the way pnpm lays out an installed package: a symlink into a store directory.
|
|
75
|
+
const store = mkdtempSync(path.join(tmpdir(), 'nb3-tailwind-'));
|
|
76
|
+
try {
|
|
77
|
+
const installed = path.join(
|
|
78
|
+
store,
|
|
79
|
+
'store/app-plugin-fixture/dist/client',
|
|
80
|
+
);
|
|
81
|
+
mkdirSync(installed, { recursive: true });
|
|
82
|
+
writeFileSync(
|
|
83
|
+
path.join(installed, 'page.js'),
|
|
84
|
+
'export const cls = "mt-[77px]";',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const scope = path.join(store, 'node_modules/@nocobase');
|
|
88
|
+
mkdirSync(scope, { recursive: true });
|
|
89
|
+
symlinkSync(
|
|
90
|
+
path.join(store, 'store/app-plugin-fixture'),
|
|
91
|
+
path.join(scope, 'app-plugin-fixture'),
|
|
92
|
+
'junction',
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const files = contentFilesIn(store);
|
|
96
|
+
|
|
97
|
+
expect(files).toHaveLength(1);
|
|
98
|
+
expect(files[0]).toBe(path.join(realpathSync(installed), 'page.js'));
|
|
99
|
+
expect(files[0]).toBe(realpathSync(files[0]));
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(store, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('scans canonical Registry source exposed by a plugin', () => {
|
|
106
|
+
const store = mkdtempSync(path.join(tmpdir(), 'nb3-tailwind-registry-'));
|
|
107
|
+
try {
|
|
108
|
+
const registry = path.join(
|
|
109
|
+
store,
|
|
110
|
+
'store/app-plugin-fixture/registry/example',
|
|
111
|
+
);
|
|
112
|
+
mkdirSync(registry, { recursive: true });
|
|
113
|
+
writeFileSync(
|
|
114
|
+
path.join(registry, 'demo.tsx'),
|
|
115
|
+
'export const cls = "xl:grid-cols-5";',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const scope = path.join(store, 'node_modules/@nocobase');
|
|
119
|
+
mkdirSync(scope, { recursive: true });
|
|
120
|
+
symlinkSync(
|
|
121
|
+
path.join(store, 'store/app-plugin-fixture'),
|
|
122
|
+
path.join(scope, 'app-plugin-fixture'),
|
|
123
|
+
'junction',
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const files = contentFilesIn(store);
|
|
127
|
+
|
|
128
|
+
expect(files).toContain(path.join(realpathSync(registry), 'demo.tsx'));
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(store, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('is wired into the stylesheet, without the wildcard sources it replaced', () => {
|
|
135
|
+
const styles = readFileSync(
|
|
136
|
+
path.join(appRoot, 'client/styles.css'),
|
|
137
|
+
'utf8',
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(styles).toContain('@config "../tailwind.config.mjs"');
|
|
141
|
+
expect(styles).not.toContain('app-plugin-*');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('is published, so a generated application has the file @config points at', () => {
|
|
145
|
+
const packageJson = JSON.parse(
|
|
146
|
+
readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
|
|
147
|
+
) as { files?: string[] };
|
|
148
|
+
|
|
149
|
+
expect(packageJson.files).toContain('tailwind.config.mjs');
|
|
150
|
+
});
|
|
151
|
+
});
|