@nocobase/app-template-default 1.0.0-beta.66 → 1.0.0-beta.68

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 (41) hide show
  1. package/.agents/rules/build-and-deploy.md +45 -0
  2. package/.agents/rules/components.md +34 -0
  3. package/.agents/rules/shell.md +31 -0
  4. package/.prettierignore +3 -1
  5. package/AGENTS.md +42 -366
  6. package/CLAUDE.md +0 -8
  7. package/client/components/page-header.tsx +1 -1
  8. package/client/components/status-badge.tsx +7 -1
  9. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  10. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  11. package/client/index.tsx +4 -0
  12. package/client/layouts/components/app-sidebar.tsx +5 -5
  13. package/client/layouts/components/layout-header.tsx +1 -1
  14. package/client/layouts/components/navigation-menu.tsx +223 -12
  15. package/client/layouts/components/sidebar-footer.tsx +1 -1
  16. package/client/layouts/components/user-menu.tsx +1 -1
  17. package/client/lib/navigation-transform.ts +8 -0
  18. package/client/lib/scrollbar.ts +88 -0
  19. package/client/locales/system/en-US.ts +4 -0
  20. package/client/locales/system/zh-CN.ts +15 -12
  21. package/client/pages/auth/auth-page.css +37 -0
  22. package/client/pages/auth/reset-password.tsx +57 -14
  23. package/client/pages/auth/shared.tsx +24 -24
  24. package/client/pages/home/index.tsx +2 -2
  25. package/client/routing/route-navigation.ts +10 -1
  26. package/client/styles.css +253 -17
  27. package/client/theme/theme-settings.tsx +12 -7
  28. package/client/theme/themes/compact.css +9 -14
  29. package/client/theme/themes/default.css +7 -12
  30. package/package.json +10 -9
  31. package/server/app.ts +2 -0
  32. package/server/config/notification.ts +4 -7
  33. package/tests/components/auth-i18n.test.tsx +57 -5
  34. package/tests/components/layout-i18n.test.tsx +2 -2
  35. package/tests/components/navigation-menu.test.tsx +133 -4
  36. package/tests/components/sidebar-permissions.test.tsx +5 -15
  37. package/tests/components/status-badge.test.tsx +22 -0
  38. package/tests/logic/agents-md.test.ts +108 -0
  39. package/tests/logic/scrollbar.test.ts +88 -0
  40. package/tests/logic/theme-tokens.test.ts +1 -0
  41. package/vitest.config.ts +1 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nocobase/app-template-default",
3
3
  "displayName": "Default Template",
4
- "version": "1.0.0-beta.66",
4
+ "version": "1.0.0-beta.68",
5
5
  "type": "module",
6
6
  "prettier": "@nocobase/dev-config/prettier",
7
7
  "engines": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "nocobase": {
11
11
  "templateKind": "app",
12
- "defaultTemplateVersion": "1.0.0-beta.66"
12
+ "defaultTemplateVersion": "1.0.0-beta.68"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@ai-sdk/react": "2.0.0",
@@ -77,7 +77,7 @@
77
77
  "tar": "^7.5.22",
78
78
  "@nocobase/app-client": "^3.0.0-beta.1",
79
79
  "@nocobase/app-plugin-template-print": "0.0.2-beta.2",
80
- "@nocobase/app-skills": "0.1.0-beta.33",
80
+ "@nocobase/app-skills": "0.1.0-beta.35",
81
81
  "@nocobase/app-testing": "0.1.0-beta.3",
82
82
  "@nocobase/app-websocket": "^0.0.2-beta.0",
83
83
  "@nocobase/dev-config": "0.1.0-beta.22",
@@ -113,6 +113,7 @@
113
113
  "config.example.yml",
114
114
  "README.MD",
115
115
  "AGENTS.md",
116
+ ".agents/rules",
116
117
  "CLAUDE.md",
117
118
  "playwright.config.ts"
118
119
  ],
@@ -142,18 +143,18 @@
142
143
  "better-auth": "1.7.5",
143
144
  "hono": "^4.13.2",
144
145
  "@nocobase/app-plugin-api-keys": "^1.0.0-beta.16",
145
- "@nocobase/app-plugin-authentication": "^2.0.0-beta.7",
146
+ "@nocobase/app-plugin-authentication": "^2.0.0-beta.8",
146
147
  "@nocobase/app-plugin-authorization": "^1.0.0-beta.27",
147
148
  "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.14",
148
149
  "@nocobase/app-plugin-file": "^1.0.0-beta.23",
149
150
  "@nocobase/app-plugin-i18n": "^1.0.0-beta.16",
150
- "@nocobase/app-plugin-mail": "^1.0.0-beta.12",
151
+ "@nocobase/app-plugin-mail": "^1.0.0-beta.13",
151
152
  "@nocobase/app-plugin-notification": "^1.0.0-beta.27",
152
153
  "@nocobase/app-plugin-notification-in-app": "^1.0.0-beta.24",
153
- "@nocobase/app-plugin-notification-providers": "^0.2.0-beta.11",
154
+ "@nocobase/app-plugin-notification-providers": "^0.2.0-beta.12",
154
155
  "@nocobase/app-plugin-scheduler": "^1.0.0-beta.17",
155
156
  "@nocobase/app-plugin-users": "^2.0.0-beta.4",
156
- "@nocobase/app-server": "^2.0.0-beta.5",
157
+ "@nocobase/app-server": "^2.0.0-beta.6",
157
158
  "@nocobase/config": "^0.1.0-beta.3",
158
159
  "@nocobase/db": "^1.0.0-beta.19",
159
160
  "@nocobase/db-sqlite": "^0.1.0-beta.5",
@@ -165,11 +166,11 @@
165
166
  "@nocobase/authorization": "^1.0.0-beta.13",
166
167
  "@nocobase/queue": "^0.1.0-beta.9",
167
168
  "@nocobase/jobs": "^0.1.0-beta.3",
168
- "@nocobase/lifecycle": "^0.1.0-beta.1",
169
+ "@nocobase/lifecycle": "^0.1.0-beta.2",
169
170
  "@nocobase/i18n": "^1.0.0-beta.7",
170
171
  "@nocobase/caching": "^0.1.0-beta.3",
171
172
  "@nocobase/repository-input": "^0.1.0-beta.3",
172
- "@nocobase/app-cli": "^1.0.0-beta.20"
173
+ "@nocobase/app-cli": "^1.0.0-beta.21"
173
174
  },
174
175
  "description": "NocoBase 3 application template with the standard product capabilities.",
175
176
  "imports": {
package/server/app.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { MailerProvider } from '@nocobase/app-server/mailer';
1
2
  import type { Application } from '@nocobase/app-server/application';
2
3
  import { DatabaseProvider } from '@nocobase/app-server/database';
3
4
  import { I18nProvider, i18nHttpMiddleware } from '@nocobase/app-server/i18n';
@@ -33,6 +34,7 @@ export function createApp(runtime: AppRuntimeContext): Application {
33
34
  app.addServiceProvider(SecretsProvider);
34
35
  app.addServiceProvider(SessionProvider);
35
36
  app.addServiceProvider(DriveProvider);
37
+ app.addServiceProvider(MailerProvider);
36
38
  app.addServiceProvider(QueueServiceProvider, {
37
39
  nodeEnv: runtime.env.NODE_ENV,
38
40
  });
@@ -1,11 +1,8 @@
1
- import {
2
- defineAppConfig,
3
- type AppConfigFactory,
4
- } from '@nocobase/app-server/config';
1
+ import { defineMailChannelsConfig } from '@nocobase/app-server/mailer';
2
+ import { type AppConfigFactory } from '@nocobase/app-server/config';
5
3
  import type { NotificationConfig } from '@nocobase/app-plugin-notification/server';
6
4
 
7
- const notification: AppConfigFactory<NotificationConfig> = defineAppConfig(
8
- (_runtime) => ({ channels: {} }),
9
- );
5
+ const notification: AppConfigFactory<NotificationConfig> =
6
+ defineMailChannelsConfig({ defaults: { channels: {} } });
10
7
 
11
8
  export default notification;
@@ -11,20 +11,32 @@ import RegisterPage from '../../client/pages/auth/register.js';
11
11
  import ResetPasswordPage from '../../client/pages/auth/reset-password.js';
12
12
  import { Loading } from '../../client/components/loading.js';
13
13
 
14
+ const resetState = vi.hoisted(() => ({
15
+ success: false,
16
+ available: true,
17
+ pending: false,
18
+ failed: false,
19
+ retry: vi.fn(),
20
+ }));
21
+
14
22
  vi.mock('@nocobase/app-plugin-authentication/client', () => ({
15
23
  useSignUpAvailable: () => true,
16
24
  usePasswordResetCapability: () => ({
17
- data: { passwordResetAvailable: true },
18
- isPending: false,
19
- isError: false,
20
- refetch: vi.fn(),
25
+ data: { passwordResetAvailable: resetState.available },
26
+ isPending: resetState.pending,
27
+ isError: resetState.failed,
28
+ refetch: resetState.retry,
21
29
  }),
22
30
  }));
23
31
 
24
32
  vi.mock('@nocobase/app-plugin-authentication/client/actions', () => ({
25
33
  usePasswordLogin: () => ({ isPending: false, submit: vi.fn() }),
26
34
  usePasswordRegistration: () => ({ isPending: false, submit: vi.fn() }),
27
- usePasswordReset: () => ({ isPending: false, submit: vi.fn() }),
35
+ usePasswordReset: () => ({
36
+ isPending: false,
37
+ isSuccess: resetState.success,
38
+ submit: vi.fn(),
39
+ }),
28
40
  usePasswordResetRequest: () => ({
29
41
  isPending: false,
30
42
  isSuccess: true,
@@ -49,6 +61,46 @@ function renderPage(value: I18nRuntime, page: ReactElement) {
49
61
  );
50
62
  }
51
63
  describe('authentication translations', () => {
64
+ it('shows translated success and stops offering a second reset', async () => {
65
+ resetState.success = true;
66
+ try {
67
+ const value = await runtime();
68
+ renderPage(value, <ResetPasswordPage />);
69
+ expect(screen.getByRole('status')).toHaveTextContent(
70
+ 'Your password has been reset.',
71
+ );
72
+ expect(screen.queryByLabelText('New password')).not.toBeInTheDocument();
73
+ await act(() => value.changeLanguage('zh-CN'));
74
+ expect(screen.getByRole('status')).toHaveTextContent(
75
+ '密码已重置,请用新密码登录。',
76
+ );
77
+ } finally {
78
+ resetState.success = false;
79
+ }
80
+ });
81
+ it('hides the form when capability is disabled and allows retry after a failed lookup', async () => {
82
+ resetState.available = false;
83
+ try {
84
+ const value = await runtime();
85
+ const view = renderPage(value, <ResetPasswordPage />);
86
+ expect(screen.queryByLabelText('New password')).not.toBeInTheDocument();
87
+ resetState.failed = true;
88
+ view.rerender(
89
+ <I18nProvider runtime={value}>
90
+ <MemoryRouter>
91
+ <ResetPasswordPage />
92
+ </MemoryRouter>
93
+ </I18nProvider>,
94
+ );
95
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
96
+ expect(resetState.retry).toHaveBeenCalledOnce();
97
+ } finally {
98
+ resetState.available = true;
99
+ resetState.failed = false;
100
+ resetState.retry.mockReset();
101
+ }
102
+ });
103
+
52
104
  it('updates the login page, brand panel and visibility control on language change', async () => {
53
105
  const value = await runtime();
54
106
  renderPage(value, <LoginPage />);
@@ -81,10 +81,10 @@ describe('shell translations', () => {
81
81
  expect(screen.getByText('AI builds freely.')).toBeVisible();
82
82
  await act(() => runtime.changeLanguage('zh-CN'));
83
83
  expect(screen.getByRole('navigation', { name: '面包屑' })).toBeVisible();
84
- expect(screen.getByText('AI 自由构建。')).toBeVisible();
84
+ expect(screen.getByText('AI 自由构建')).toBeVisible();
85
85
  expect(
86
86
  screen.getByRole('link', { name: 'NocoBase' }).parentElement,
87
- ).toHaveTextContent('NocoBase 保障可靠。');
87
+ ).toHaveTextContent('NocoBase 保障可靠');
88
88
  expect(
89
89
  screen.getByRole('button', { name: '展开或收起导航' }),
90
90
  ).toHaveAttribute('title', '展开或收起导航');
@@ -25,7 +25,10 @@ import {
25
25
  AppSidebarProvider,
26
26
  AppSidebarToggle,
27
27
  } from '../../client/layouts/components/app-sidebar.js';
28
- import { NavigationMenu } from '../../client/layouts/components/navigation-menu.js';
28
+ import {
29
+ NavigationMenu,
30
+ type NavigationBadges,
31
+ } from '../../client/layouts/components/navigation-menu.js';
29
32
 
30
33
  // Navigation titles are route data, not keys any namespace owns: the menu translates each through its package's
31
34
  // namespace with the title itself as `defaultValue`, so the runtime is not strict.
@@ -58,7 +61,12 @@ function page(name: string): AppClientRegisteredRoute {
58
61
  const child = page('child');
59
62
  const sibling = page('sibling');
60
63
 
61
- function shell(items: readonly RouteNavigationItem[], selectedKey?: string) {
64
+ function shell(
65
+ items: readonly RouteNavigationItem[],
66
+ selectedKey?: string,
67
+ sections = true,
68
+ badges?: NavigationBadges,
69
+ ) {
62
70
  return (
63
71
  <MemoryRouter>
64
72
  <AppSidebarProvider>
@@ -67,6 +75,8 @@ function shell(items: readonly RouteNavigationItem[], selectedKey?: string) {
67
75
  items={items}
68
76
  label='Menu'
69
77
  selectedKey={selectedKey}
78
+ sections={sections}
79
+ badges={badges}
70
80
  />
71
81
  </AppSidebar>
72
82
  <AppSidebarToggle />
@@ -99,7 +109,10 @@ describe.each([
99
109
  { route: sibling, children: [] },
100
110
  ],
101
111
  };
102
- const tree = (selectedKey: string | undefined) => shell([item], selectedKey);
112
+ // The collapsible form: a clickable parent always, and a group without a page when sections are off or it is
113
+ // nested. A top-level group without a page is a section, covered below.
114
+ const tree = (selectedKey: string | undefined) =>
115
+ shell([item], selectedKey, false);
103
116
  function toggle() {
104
117
  return screen.getByRole('button', { name: 'Group' });
105
118
  }
@@ -140,6 +153,61 @@ describe.each([
140
153
  });
141
154
  });
142
155
 
156
+ describe('sections', () => {
157
+ const group: RouteNavigationItem = {
158
+ route: { ...page('Group'), componentLoader: undefined },
159
+ children: [
160
+ { route: child, children: [] },
161
+ { route: sibling, children: [] },
162
+ ],
163
+ };
164
+
165
+ it('draws a top-level group without a page as a heading over entries always shown', () => {
166
+ render(shell([{ route: page('Home'), children: [] }, group], 'elsewhere'), {
167
+ wrapper: I18n,
168
+ });
169
+ expect(screen.getByText('Group')).toHaveAttribute(
170
+ 'data-sidebar',
171
+ 'group-label',
172
+ );
173
+ expect(
174
+ screen.queryByRole('button', { name: 'Group' }),
175
+ ).not.toBeInTheDocument();
176
+ expect(screen.getByRole('link', { name: 'child' })).toBeVisible();
177
+ expect(screen.getByRole('link', { name: 'sibling' })).toBeVisible();
178
+ expect(screen.getByRole('link', { name: 'Home' })).toBeVisible();
179
+ });
180
+
181
+ it('keeps a group divided into sections as one collapsible entry', () => {
182
+ const divided: RouteNavigationItem = {
183
+ route: { ...page('Flows'), componentLoader: undefined },
184
+ children: [
185
+ {
186
+ section: true,
187
+ route: { ...page('Business'), componentLoader: undefined },
188
+ children: [{ route: child, children: [] }],
189
+ },
190
+ ],
191
+ };
192
+ render(shell([divided], routeKey(child)), { wrapper: I18n });
193
+ expect(screen.getByRole('button', { name: 'Flows' })).toHaveAttribute(
194
+ 'aria-expanded',
195
+ 'true',
196
+ );
197
+ expect(screen.getByRole('group', { name: 'Business' })).toBeVisible();
198
+ });
199
+
200
+ it('shows a section entries as icons, not a popup, in the collapsed sidebar', () => {
201
+ setCollapsed(true);
202
+ render(shell([group], routeKey(child)), { wrapper: I18n });
203
+ const link = screen.getByRole('link', { name: 'child' });
204
+ expect(link).toHaveAttribute('aria-current', 'page');
205
+ expect(
206
+ screen.queryByRole('button', { name: 'Group' }),
207
+ ).not.toBeInTheDocument();
208
+ });
209
+ });
210
+
143
211
  it('marks the selected page with the selected navigation colors', () => {
144
212
  render(
145
213
  shell([{ route: page('Home'), children: [] }], routeKey(page('Home'))),
@@ -156,11 +224,72 @@ it('marks the selected page with the selected navigation colors', () => {
156
224
  );
157
225
  });
158
226
 
227
+ describe('entry counts', () => {
228
+ const entries: RouteNavigationItem[] = [
229
+ { route: page('inbox'), children: [] },
230
+ { route: page('approvals'), children: [] },
231
+ { route: page('runs'), children: [] },
232
+ { route: page('archive'), children: [] },
233
+ ];
234
+ const badges: NavigationBadges = {
235
+ inbox: 5,
236
+ approvals: 120,
237
+ runs: { count: 2, live: true, hint: '2 running' },
238
+ archive: 0,
239
+ };
240
+
241
+ it('shows a count beside its entry, in the entry name, and nothing at zero', () => {
242
+ render(shell(entries, undefined, true, badges), { wrapper: I18n });
243
+ const inbox = screen.getByRole('link', { name: 'inbox · 5' });
244
+ const badge = inbox.parentElement?.querySelector(
245
+ '[data-slot="sidebar-menu-badge"]',
246
+ );
247
+ expect(badge).toHaveTextContent('5');
248
+ expect(badge).toHaveAttribute('aria-hidden', 'true');
249
+ expect(screen.getByRole('link', { name: 'approvals · 120' })).toBeVisible();
250
+ expect(
251
+ screen
252
+ .getByRole('link', { name: 'approvals · 120' })
253
+ .parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
254
+ ).toHaveTextContent('99+');
255
+ const archive = screen.getByRole('link', { name: 'archive' });
256
+ expect(
257
+ archive.parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
258
+ ).toBeNull();
259
+ });
260
+
261
+ it('names work under way by its hint', () => {
262
+ render(shell(entries, undefined, true, badges), { wrapper: I18n });
263
+ const runs = screen.getByRole('link', { name: 'runs · 2 running' });
264
+ expect(
265
+ runs.parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
266
+ ).toHaveAttribute('title', '2 running');
267
+ });
268
+
269
+ it('marks a counted entry with a dot and its count in the tooltip when collapsed', async () => {
270
+ setCollapsed(true);
271
+ const user = userEvent.setup();
272
+ render(shell(entries, undefined, true, badges), { wrapper: I18n });
273
+ const inbox = screen.getByRole('link', { name: 'inbox · 5' });
274
+ expect(
275
+ inbox.parentElement?.querySelector('[data-slot="navigation-badge-dot"]'),
276
+ ).not.toBeNull();
277
+ expect(
278
+ screen
279
+ .getByRole('link', { name: 'archive' })
280
+ .parentElement?.querySelector('[data-slot="navigation-badge-dot"]'),
281
+ ).toBeNull();
282
+ await user.hover(inbox);
283
+ expect(screen.getByRole('tooltip')).toHaveTextContent('inbox · 5');
284
+ });
285
+ });
286
+
159
287
  describe('collapsed navigation', () => {
160
288
  beforeEach(() => setCollapsed(true));
161
289
 
290
+ // A group's popup belongs to the collapsible form, so these render without sections.
162
291
  function show(item: RouteNavigationItem, selectedKey = routeKey(child)) {
163
- return render(shell([item], selectedKey), { wrapper: I18n });
292
+ return render(shell([item], selectedKey, false), { wrapper: I18n });
164
293
  }
165
294
 
166
295
  it('shows a leaf label immediately on hover and dismisses it on leave', async () => {
@@ -1,6 +1,5 @@
1
1
  import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
- import { act, render, screen, waitFor, within } from '@testing-library/react';
3
- import userEvent from '@testing-library/user-event';
2
+ import { act, render, screen, waitFor } from '@testing-library/react';
4
3
  import { MemoryRouter } from 'react-router';
5
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
5
  import {
@@ -80,19 +79,10 @@ describe.each(['expanded', 'collapsed', 'mobile'])(
80
79
  await act(async () => {
81
80
  resolve.forEach((done, i) => done(i === 0));
82
81
  });
83
- const user = userEvent.setup();
84
- if (mode === 'collapsed') {
85
- await user.hover(await screen.findByRole('button', { name: 'Group' }));
86
- expect(
87
- within(await screen.findByRole('dialog')).getByRole('link', {
88
- name: 'Allowed',
89
- }),
90
- ).toHaveAttribute('href', '/Allowed');
91
- } else {
92
- expect(
93
- await screen.findByRole('link', { name: 'Allowed' }),
94
- ).toBeVisible();
95
- }
82
+ // Group is a section: its entries show in every mode, as icons in the collapsed sidebar.
83
+ expect(
84
+ await screen.findByRole('link', { name: 'Allowed' }),
85
+ ).toHaveAttribute('href', '/Allowed');
96
86
  expect(screen.queryByText('Denied')).not.toBeInTheDocument();
97
87
  expect(screen.queryByText('Empty')).not.toBeInTheDocument();
98
88
  auth.client.can.mockResolvedValue(false);
@@ -0,0 +1,22 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { StatusBadge } from '#components/status-badge';
5
+
6
+ // The two badge sizes are drawn by client/styles.css from `data-size`, which jsdom does not apply, so this checks the
7
+ // attribute that selects them.
8
+ describe('StatusBadge', () => {
9
+ it('is the default badge unless asked for the small one', () => {
10
+ render(
11
+ <>
12
+ <StatusBadge tone='success'>Done</StatusBadge>
13
+ <StatusBadge tone='warning' size='sm'>
14
+ Waiting
15
+ </StatusBadge>
16
+ </>,
17
+ );
18
+ expect(screen.getByText('Done')).not.toHaveAttribute('data-size');
19
+ expect(screen.getByText('Waiting')).toHaveAttribute('data-size', 'sm');
20
+ expect(screen.getByText('Waiting')).toHaveAttribute('data-slot', 'badge');
21
+ });
22
+ });
@@ -0,0 +1,108 @@
1
+ // @vitest-environment node
2
+
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ const appRoot = path.resolve(
9
+ fileURLToPath(new URL('.', import.meta.url)),
10
+ '../..',
11
+ );
12
+
13
+ /**
14
+ * Every agent loads `AGENTS.md` on every start, so its size is a cost paid on every task. It used to grow with each
15
+ * change that documented itself there; the detail now lives in `.agents/rules/` and is read on demand through links.
16
+ */
17
+ const AGENTS_MD_LIMIT_BYTES = 32 * 1024;
18
+
19
+ const rulesDirectory = path.join(appRoot, '.agents', 'rules');
20
+ const skillsDirectory = path.join(appRoot, '.agents', 'skills');
21
+
22
+ function listMarkdownFiles(directory: string): string[] {
23
+ if (!existsSync(directory)) {
24
+ return [];
25
+ }
26
+ return readdirSync(directory).flatMap((entry) => {
27
+ const entryPath = path.join(directory, entry);
28
+ if (statSync(entryPath).isDirectory()) {
29
+ return listMarkdownFiles(entryPath);
30
+ }
31
+ return entry.endsWith('.md') ? [entryPath] : [];
32
+ });
33
+ }
34
+
35
+ /** Relative link targets of a Markdown file, leaving out fenced code blocks and inline code spans. */
36
+ function readRelativeLinks(file: string): string[] {
37
+ const prose = readFileSync(file, 'utf8')
38
+ .replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gmu, '')
39
+ .replace(/`[^`\n]*`/gu, '');
40
+ const targets: string[] = [];
41
+ for (const match of prose.matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/gu)) {
42
+ const target = match[1] ?? '';
43
+ if (
44
+ target === '' ||
45
+ target.startsWith('#') ||
46
+ /^[a-z][a-z0-9+.-]*:/iu.test(target)
47
+ ) {
48
+ continue;
49
+ }
50
+ targets.push(target.replace(/[#?].*$/u, ''));
51
+ }
52
+ return targets;
53
+ }
54
+
55
+ function isInside(parent: string, child: string): boolean {
56
+ const relative = path.relative(parent, child);
57
+ return (
58
+ relative === '' ||
59
+ (!relative.startsWith('..') && !path.isAbsolute(relative))
60
+ );
61
+ }
62
+
63
+ const agentsMd = path.join(appRoot, 'AGENTS.md');
64
+ const documents = [agentsMd, ...listMarkdownFiles(rulesDirectory)];
65
+
66
+ describe('AGENTS.md', () => {
67
+ it('stays within the size every agent can afford to load on start', () => {
68
+ const size = statSync(agentsMd).size;
69
+ expect(
70
+ size,
71
+ `AGENTS.md is ${size} bytes, over the ${AGENTS_MD_LIMIT_BYTES}-byte limit. Move the detail into .agents/rules/<topic>.md and link it from the index in AGENTS.md; keep only the rules that apply everywhere in AGENTS.md itself.`,
72
+ ).toBeLessThanOrEqual(AGENTS_MD_LIMIT_BYTES);
73
+ });
74
+
75
+ it('links every file in .agents/rules/ from its index', () => {
76
+ const linked = new Set(
77
+ readRelativeLinks(agentsMd).map((target) =>
78
+ path.resolve(appRoot, target),
79
+ ),
80
+ );
81
+ const unlinked = listMarkdownFiles(rulesDirectory)
82
+ .filter((file) => !linked.has(file))
83
+ .map((file) => path.relative(appRoot, file));
84
+ expect(
85
+ unlinked,
86
+ 'Add each rules file to the index in AGENTS.md, saying when to read it, or an agent will never find it.',
87
+ ).toEqual([]);
88
+ });
89
+
90
+ it('has no broken links in AGENTS.md or .agents/rules/', () => {
91
+ // `.agents/skills/` is written by `pnpm nocobase skills sync` during install, so its links can only be checked once
92
+ // it exists; every other target is a committed file of this application.
93
+ const checkSkills = existsSync(skillsDirectory);
94
+ const broken = documents.flatMap((file) =>
95
+ readRelativeLinks(file)
96
+ .map((target) => path.resolve(path.dirname(file), target))
97
+ .filter(
98
+ (resolved) => checkSkills || !isInside(skillsDirectory, resolved),
99
+ )
100
+ .filter((resolved) => !existsSync(resolved))
101
+ .map(
102
+ (resolved) =>
103
+ `${path.relative(appRoot, file)} -> ${path.relative(appRoot, resolved)}`,
104
+ ),
105
+ );
106
+ expect(broken).toEqual([]);
107
+ });
108
+ });
@@ -0,0 +1,88 @@
1
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { revealScrollbarsWhileScrolling } from '../../client/lib/scrollbar';
4
+
5
+ describe('revealScrollbarsWhileScrolling', () => {
6
+ beforeAll(() => revealScrollbarsWhileScrolling());
7
+ afterEach(() => vi.useRealTimers());
8
+
9
+ function scroll(target: EventTarget) {
10
+ target.dispatchEvent(new Event('scroll'));
11
+ }
12
+
13
+ it('marks a scrolling element until it has been still for a moment', () => {
14
+ vi.useFakeTimers();
15
+ const list = document.createElement('div');
16
+ document.body.append(list);
17
+ scroll(list);
18
+ expect(list).toHaveAttribute('data-scrolling');
19
+ vi.advanceTimersByTime(799);
20
+ expect(list).toHaveAttribute('data-scrolling');
21
+ vi.advanceTimersByTime(1);
22
+ expect(list).not.toHaveAttribute('data-scrolling');
23
+ list.remove();
24
+ });
25
+
26
+ it('keeps the mark while scrolling goes on', () => {
27
+ vi.useFakeTimers();
28
+ const list = document.createElement('div');
29
+ document.body.append(list);
30
+ scroll(list);
31
+ vi.advanceTimersByTime(600);
32
+ scroll(list);
33
+ vi.advanceTimersByTime(600);
34
+ expect(list).toHaveAttribute('data-scrolling');
35
+ vi.advanceTimersByTime(200);
36
+ expect(list).not.toHaveAttribute('data-scrolling');
37
+ list.remove();
38
+ });
39
+
40
+ it('marks the page root when the page itself scrolls', () => {
41
+ vi.useFakeTimers();
42
+ scroll(document);
43
+ expect(
44
+ document.scrollingElement ?? document.documentElement,
45
+ ).toHaveAttribute('data-scrolling');
46
+ vi.advanceTimersByTime(800);
47
+ expect(
48
+ document.scrollingElement ?? document.documentElement,
49
+ ).not.toHaveAttribute('data-scrolling');
50
+ });
51
+ });
52
+
53
+ describe('revealScrollbarsWhileScrolling near an edge', () => {
54
+ function scroller(): HTMLDivElement {
55
+ const element = document.createElement('div');
56
+ element.style.overflowY = 'auto';
57
+ Object.defineProperty(element, 'scrollHeight', { value: 500 });
58
+ Object.defineProperty(element, 'clientHeight', { value: 100 });
59
+ element.getBoundingClientRect = () =>
60
+ ({ left: 0, top: 0, right: 200, bottom: 100 }) as DOMRect;
61
+ document.body.append(element);
62
+ return element;
63
+ }
64
+
65
+ function move(target: Element, clientX: number) {
66
+ const event = new MouseEvent('pointermove', {
67
+ bubbles: true,
68
+ clientX,
69
+ clientY: 50,
70
+ });
71
+ Object.defineProperty(event, 'pointerType', { value: 'mouse' });
72
+ target.dispatchEvent(event);
73
+ }
74
+
75
+ it('shows the bar while the mouse is near the edge it runs along', () => {
76
+ vi.useFakeTimers();
77
+ const list = scroller();
78
+ move(list, 100);
79
+ vi.advanceTimersByTime(20);
80
+ expect(list).not.toHaveAttribute('data-scrolling');
81
+ move(list, 195);
82
+ vi.advanceTimersByTime(20);
83
+ expect(list).toHaveAttribute('data-scrolling');
84
+ vi.advanceTimersByTime(800);
85
+ expect(list).not.toHaveAttribute('data-scrolling');
86
+ list.remove();
87
+ });
88
+ });
@@ -40,6 +40,7 @@ const colors = [
40
40
  'muted-foreground',
41
41
  'accent',
42
42
  'accent-foreground',
43
+ 'hover',
43
44
  'destructive',
44
45
  'border',
45
46
  'input',
package/vitest.config.ts CHANGED
@@ -15,6 +15,7 @@ export default createReactVitestConfig({
15
15
  // together with every client that resolves the authentication tokens and provider.
16
16
  /@nocobase\/app-plugin-(?:authentication|api-keys|users)\/(?:dist\/)?client\//u,
17
17
  /@nocobase\/app-plugin-authorization\/(?:dist\/)?client\//u,
18
+ /@nocobase\/app-plugin-(?:lifecycle-example|office-flows-example)\/(?:dist\/)?client\//u,
18
19
  /@nocobase\/app-plugin-notification-in-app\/(?:dist\/)?client\//u,
19
20
  ],
20
21
  },