@nocobase/app-template-default 1.0.0-beta.89 → 1.0.0-beta.91
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/.agents/rules/shell.md +4 -0
- package/LICENSE.txt +129 -0
- package/client/lib/navigation-transform.ts +29 -1
- package/client/locales/system/en-US.ts +4 -0
- package/client/locales/system/zh-CN.ts +4 -0
- package/client/pages/home/index.tsx +66 -51
- package/client/pages/settings/database-explorer/en-US.ts +33 -0
- package/client/pages/settings/database-explorer/index.tsx +358 -0
- package/client/pages/settings/database-explorer/routes.ts +14 -0
- package/client/pages/settings/database-explorer/zh-CN.ts +30 -0
- package/client/plugins.ts +8 -1
- package/client/routes.ts +2 -1
- package/config.example.yml +17 -11
- package/index.html +8 -11
- package/package.json +26 -26
- package/server/config/auth.ts +1 -1
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +16 -8
- package/server/config/queue.ts +14 -13
- package/server/config/realtime.ts +4 -0
- package/tests/client/client-routes.test.ts +5 -3
- package/tests/client/database-explorer-integration.test.tsx +30 -0
- package/tests/client/database-explorer.test.tsx +152 -0
- package/tests/client/foundation-integration.test.ts +71 -29
- package/tests/client/home.test.tsx +26 -85
- package/tests/client/layout-i18n.test.tsx +83 -2
- package/tests/client/settings-navigation.test.ts +20 -0
- package/tests/helpers/authenticated-client.tsx +7 -0
- package/tests/project/bootstrap-loading.test.ts +60 -0
- package/tests/server/app-server.test.ts +18 -12
- package/tests/server/auth-email-config.test.ts +21 -0
- package/tests/server/backends-config.test.ts +118 -0
- package/tests/server/concurrency.test.ts +1 -1
- package/tests/server/config.test.ts +22 -14
- package/tests/server/lifecycle.test.ts +2 -2
- package/tests/server/realtime-config.test.ts +114 -0
- package/client/pages/home/capabilities.ts +0 -62
- package/client/pages/home/prompt-dialog.tsx +0 -122
|
@@ -228,3 +228,23 @@ describe('System settings navigation', () => {
|
|
|
228
228
|
).toEqual(['home', 'settingsHome']);
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
it('keeps database browsing accessible by route without adding a System settings card', () => {
|
|
233
|
+
const routes = resolveAppClientContributions([
|
|
234
|
+
{
|
|
235
|
+
packageName: app,
|
|
236
|
+
source: 'application',
|
|
237
|
+
routes: createApplicationRoutes({}),
|
|
238
|
+
},
|
|
239
|
+
]).routes;
|
|
240
|
+
const page = find(routes, 'databaseExplorer');
|
|
241
|
+
expect(page.path).toBe('/settings/database-explorer');
|
|
242
|
+
expect(page.auth).toBe('required');
|
|
243
|
+
expect(page.authz).toEqual({
|
|
244
|
+
resource: { type: 'page', id: 'database-explorer' },
|
|
245
|
+
action: 'access',
|
|
246
|
+
});
|
|
247
|
+
expect(page.navigation).toBeUndefined();
|
|
248
|
+
expect(catalog(routes)).toEqual([]);
|
|
249
|
+
expect(catalog(routes, new Set([routeKey(page)]))).toEqual([]);
|
|
250
|
+
});
|
|
@@ -89,6 +89,13 @@ export async function renderAuthenticatedClient(
|
|
|
89
89
|
function createClientTestServer() {
|
|
90
90
|
return createTestApp({
|
|
91
91
|
createServer: createStandaloneServer,
|
|
92
|
+
// Local preview origins must not change test cookie security or trusted origins.
|
|
93
|
+
server: {
|
|
94
|
+
env: {
|
|
95
|
+
APP_PUBLIC_ORIGIN: 'http://localhost',
|
|
96
|
+
BETTER_AUTH_TRUSTED_ORIGINS: 'http://localhost',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
92
99
|
connections: ['main'],
|
|
93
100
|
config: {
|
|
94
101
|
logging: { enabled: false },
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const html = readFileSync(
|
|
6
|
+
fileURLToPath(new URL('../../index.html', import.meta.url)),
|
|
7
|
+
'utf8',
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
/** The markup the browser paints before the client script has started and the visitor's language is known. */
|
|
11
|
+
function bootstrapRoot(): string {
|
|
12
|
+
const match = /<div id="root">([\s\S]*?)<\/div>\s*<script/u.exec(html);
|
|
13
|
+
if (!match) throw new Error('index.html has no #root placeholder.');
|
|
14
|
+
return match[1].replace(/<!--[\s\S]*?-->/gu, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The opening tag of the placeholder element, which carries its accessible semantics. */
|
|
18
|
+
function placeholderTag(): string {
|
|
19
|
+
const tag = /<div\s+class="app-bootstrap-loading"[^>]*>/u.exec(
|
|
20
|
+
bootstrapRoot(),
|
|
21
|
+
);
|
|
22
|
+
if (!tag) throw new Error('index.html has no bootstrap loading placeholder.');
|
|
23
|
+
return tag[0].replace(/\s+/gu, ' ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('bootstrap loading in index.html', () => {
|
|
27
|
+
it('shows no text in any language before the client starts', () => {
|
|
28
|
+
const text = bootstrapRoot()
|
|
29
|
+
.replace(/<[^>]*>/gu, '')
|
|
30
|
+
.trim();
|
|
31
|
+
|
|
32
|
+
expect(text).toBe('');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('is perceivable as loading without words in a language', () => {
|
|
36
|
+
const tag = placeholderTag();
|
|
37
|
+
const title = /<title>([^<]*)<\/title>/u.exec(html)?.[1];
|
|
38
|
+
|
|
39
|
+
// An indeterminate progress bar: the screen reader announces the role in its own language.
|
|
40
|
+
expect(tag).toContain('role="progressbar"');
|
|
41
|
+
expect(tag).not.toMatch(/aria-valuenow|aria-valuetext/u);
|
|
42
|
+
// Its name is the brand, which reads the same in every language and matches the document title.
|
|
43
|
+
expect(title).toBeTruthy();
|
|
44
|
+
expect(tag).toContain(`aria-label="${title}"`);
|
|
45
|
+
expect(bootstrapRoot()).toMatch(/<svg[^>]*aria-hidden="true"/u);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('leaves announcing the loading to the localized status of the client', () => {
|
|
49
|
+
// A live region here would be announced, then announced again by the client's localized status.
|
|
50
|
+
expect(bootstrapRoot()).not.toMatch(
|
|
51
|
+
/role="status"|role="alert"|aria-live/u,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps the brand mark still for reduced motion', () => {
|
|
56
|
+
expect(html).toMatch(
|
|
57
|
+
/prefers-reduced-motion: reduce\)\s*\{\s*\.app-bootstrap-loading \.nocobase-loading-orbit\s*\{\s*animation: none;/u,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -1170,12 +1170,14 @@ interface CreateTestAppOptions {
|
|
|
1170
1170
|
function createTestQueueConfig(): AppQueueConfig {
|
|
1171
1171
|
return {
|
|
1172
1172
|
default: 'memory',
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
path
|
|
1178
|
-
|
|
1173
|
+
backends: {
|
|
1174
|
+
memory: {
|
|
1175
|
+
adapter: 'inMemory',
|
|
1176
|
+
persistence: {
|
|
1177
|
+
path: mkdtempSync(
|
|
1178
|
+
path.join(tmpdir(), 'nocobase-app-template-default-queue-'),
|
|
1179
|
+
),
|
|
1180
|
+
},
|
|
1179
1181
|
},
|
|
1180
1182
|
},
|
|
1181
1183
|
};
|
|
@@ -1537,17 +1539,21 @@ async function writeRuntimeTestConfig(
|
|
|
1537
1539
|
// another suite may be using at the same time.
|
|
1538
1540
|
jobs: {
|
|
1539
1541
|
default: 'memory',
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1542
|
+
backends: {
|
|
1543
|
+
memory: {
|
|
1544
|
+
adapter: 'memory',
|
|
1545
|
+
persistence: { path: path.join(directory, 'jobs') },
|
|
1546
|
+
},
|
|
1543
1547
|
},
|
|
1544
1548
|
},
|
|
1545
1549
|
// Queue state files likewise stay beside the test database.
|
|
1546
1550
|
queue: {
|
|
1547
1551
|
default: 'memory',
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1552
|
+
backends: {
|
|
1553
|
+
memory: {
|
|
1554
|
+
adapter: 'inMemory',
|
|
1555
|
+
persistence: { path: path.join(directory, 'queue') },
|
|
1556
|
+
},
|
|
1551
1557
|
},
|
|
1552
1558
|
},
|
|
1553
1559
|
database: {
|
|
@@ -111,3 +111,24 @@ it('accepts an entirely environment-configured shared sender without enabling re
|
|
|
111
111
|
},
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
it('loads the direct password-reset reference from the inherited environment mapping', async () => {
|
|
116
|
+
const config = new AppConfig();
|
|
117
|
+
await config.loadAll();
|
|
118
|
+
const defaults = defaultAppConfigs({ auth, mailers });
|
|
119
|
+
config.mergeDefaults(defaults({} as never));
|
|
120
|
+
config.defineSections(defaults.sections!);
|
|
121
|
+
await config.loadSectionEnvironment({
|
|
122
|
+
AUTH_PASSWORD_RESET_MAILER: 'system',
|
|
123
|
+
MAILERS_SYSTEM_PROVIDER: 'smtp',
|
|
124
|
+
MAILERS_SYSTEM_HOST: 'localhost',
|
|
125
|
+
MAILERS_SYSTEM_PORT: '2525',
|
|
126
|
+
});
|
|
127
|
+
expect(await config.validate()).toEqual([]);
|
|
128
|
+
expect(config.get('auth.passwordReset.mailer')).toBe('system');
|
|
129
|
+
expect(config.get('auth.passwordReset.enabled')).toBeUndefined();
|
|
130
|
+
expect(config.environmentVariableMappings()).toMatchObject({
|
|
131
|
+
AUTH_PASSWORD_RESET_MAILER: { path: 'auth.passwordReset.mailer' },
|
|
132
|
+
});
|
|
133
|
+
expect(config.publicValues()).not.toHaveProperty('auth.passwordReset');
|
|
134
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
|
|
7
|
+
import { createJobExecutorService } from '@nocobase/jobs';
|
|
8
|
+
import { createQueueService } from '@nocobase/queue';
|
|
9
|
+
import appRuntime from '../../server/runtime.ts';
|
|
10
|
+
|
|
11
|
+
const directories: string[] = [];
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
for (const directory of directories.splice(0))
|
|
14
|
+
await rm(directory, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
async function runtime(yaml: string) {
|
|
17
|
+
const root = await mkdtemp(path.join(tmpdir(), 'backends-template-'));
|
|
18
|
+
directories.push(root);
|
|
19
|
+
const configPath = path.join(root, 'config.yml');
|
|
20
|
+
await writeFile(configPath, yaml);
|
|
21
|
+
return resolveStandaloneAppRuntime(appRuntime, {
|
|
22
|
+
rootDir: fileURLToPath(new URL('../..', import.meta.url)),
|
|
23
|
+
configPath,
|
|
24
|
+
env: { AUTH_SECRET: 'test-auth-secret-at-least-32-characters' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
describe('merged backend configuration in the real template', () => {
|
|
28
|
+
it('keeps local defaults without additional user configuration', async () => {
|
|
29
|
+
const resolved = await runtime('{}');
|
|
30
|
+
expect(resolved.config.get('concurrency.default')).toBe('memory');
|
|
31
|
+
expect(resolved.config.get('concurrency.backends.memory')).toEqual({
|
|
32
|
+
adapter: 'mutex',
|
|
33
|
+
});
|
|
34
|
+
expect(resolved.config.get('jobs.default')).toBeUndefined();
|
|
35
|
+
expect(resolved.config.get('queue.default')).toBeUndefined();
|
|
36
|
+
expect(resolved.config.get('jobs.backends.memory')).toEqual({
|
|
37
|
+
adapter: 'memory',
|
|
38
|
+
persistence: { path: resolved.paths.storage('jobs') },
|
|
39
|
+
});
|
|
40
|
+
expect(resolved.config.get('queue.backends.memory')).toEqual({
|
|
41
|
+
adapter: 'inMemory',
|
|
42
|
+
persistence: { path: resolved.paths.storage('queue') },
|
|
43
|
+
});
|
|
44
|
+
const issues = await resolved.config.validate();
|
|
45
|
+
expect(
|
|
46
|
+
issues.filter((issue) =>
|
|
47
|
+
/^(concurrency|jobs|queue)(\.|$)/u.test(issue.path),
|
|
48
|
+
),
|
|
49
|
+
).toEqual([]);
|
|
50
|
+
const jobs = createJobExecutorService(resolved.config.get('jobs'), {
|
|
51
|
+
appName: 'app',
|
|
52
|
+
storagePath: resolved.paths.storage('jobs'),
|
|
53
|
+
});
|
|
54
|
+
const queue = createQueueService(resolved.config.get('queue'), {
|
|
55
|
+
appName: 'app',
|
|
56
|
+
storagePath: resolved.paths.storage('queue'),
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
expect(jobs.getJobExecutor('scope')).toBeDefined();
|
|
60
|
+
await queue.setup();
|
|
61
|
+
} finally {
|
|
62
|
+
await jobs.shutdown();
|
|
63
|
+
await queue.shutdown();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
it.each(['concurrency', 'jobs', 'queue'])(
|
|
67
|
+
'rejects old user YAML merged over new %s defaults, selected or not',
|
|
68
|
+
async (section) => {
|
|
69
|
+
const adapter =
|
|
70
|
+
section === 'concurrency'
|
|
71
|
+
? 'mutex'
|
|
72
|
+
: section === 'queue'
|
|
73
|
+
? 'inMemory'
|
|
74
|
+
: 'memory';
|
|
75
|
+
for (const selector of [
|
|
76
|
+
'',
|
|
77
|
+
' default: memory\n',
|
|
78
|
+
' default: legacy\n',
|
|
79
|
+
]) {
|
|
80
|
+
const resolved = await runtime(
|
|
81
|
+
`${section}:\n${selector} legacy:\n adapter: ${adapter}\n`,
|
|
82
|
+
);
|
|
83
|
+
const issues = await resolved.config.validate();
|
|
84
|
+
expect(issues).toEqual(
|
|
85
|
+
expect.arrayContaining([
|
|
86
|
+
expect.objectContaining({
|
|
87
|
+
level: 'error',
|
|
88
|
+
message: expect.stringContaining('old configuration format'),
|
|
89
|
+
}),
|
|
90
|
+
]),
|
|
91
|
+
);
|
|
92
|
+
expect(JSON.stringify(issues)).toContain(`${section}.backends.legacy`);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
it.each(['concurrency', 'jobs', 'queue'])(
|
|
97
|
+
'rejects same-name legacy overrides for %s',
|
|
98
|
+
async (section) => {
|
|
99
|
+
const adapter =
|
|
100
|
+
section === 'concurrency'
|
|
101
|
+
? 'mutex'
|
|
102
|
+
: section === 'queue'
|
|
103
|
+
? 'inMemory'
|
|
104
|
+
: 'memory';
|
|
105
|
+
const resolved = await runtime(
|
|
106
|
+
`${section}:\n default: memory\n memory:\n adapter: ${adapter}\n`,
|
|
107
|
+
);
|
|
108
|
+
expect(await resolved.config.validate()).toEqual(
|
|
109
|
+
expect.arrayContaining([
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
level: 'error',
|
|
112
|
+
message: expect.stringContaining(`${section}.backends.memory`),
|
|
113
|
+
}),
|
|
114
|
+
]),
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
});
|
|
@@ -81,7 +81,7 @@ test('the real default composition supplies one memory lock domain to two busine
|
|
|
81
81
|
const second = new BusinessConsumer(service);
|
|
82
82
|
expect(app.config.get('concurrency')).toEqual({
|
|
83
83
|
default: 'memory',
|
|
84
|
-
memory: { adapter: 'mutex' },
|
|
84
|
+
backends: { memory: { adapter: 'mutex' } },
|
|
85
85
|
});
|
|
86
86
|
const [left, right] = await Promise.all([first.manager(), second.manager()]);
|
|
87
87
|
expect(left).toBe(right);
|
|
@@ -52,14 +52,16 @@ describe('application config', () => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
expect(runtime.config.get<AppJobsConfig>('jobs')).toEqual({
|
|
55
|
-
memory: {
|
|
56
|
-
adapter: 'memory',
|
|
57
|
-
persistence: { path: runtime.paths.storage('jobs') },
|
|
58
|
-
},
|
|
59
55
|
default: 'redis',
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
backends: {
|
|
57
|
+
memory: {
|
|
58
|
+
adapter: 'memory',
|
|
59
|
+
persistence: { path: runtime.paths.storage('jobs') },
|
|
60
|
+
},
|
|
61
|
+
redis: {
|
|
62
|
+
adapter: 'redis',
|
|
63
|
+
connection: { host: '127.0.0.1', port: 6379 },
|
|
64
|
+
},
|
|
63
65
|
},
|
|
64
66
|
});
|
|
65
67
|
});
|
|
@@ -103,15 +105,19 @@ describe('application config', () => {
|
|
|
103
105
|
expect(runtime.config.get('logging.file.name')).toBe('app');
|
|
104
106
|
// No default: queues run on the built-in memory configuration until one is named.
|
|
105
107
|
expect(runtime.config.get<AppQueueConfig>('queue')).toEqual({
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
backends: {
|
|
109
|
+
memory: {
|
|
110
|
+
adapter: 'inMemory',
|
|
111
|
+
persistence: { path: runtime.paths.storage('queue') },
|
|
112
|
+
},
|
|
109
113
|
},
|
|
110
114
|
});
|
|
111
115
|
expect(runtime.config.get<AppJobsConfig>('jobs')).toEqual({
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
backends: {
|
|
117
|
+
memory: {
|
|
118
|
+
adapter: 'memory',
|
|
119
|
+
persistence: { path: runtime.paths.storage('jobs') },
|
|
120
|
+
},
|
|
115
121
|
},
|
|
116
122
|
});
|
|
117
123
|
expect(runtime.config.get<AppSessionConfigInput>('session')!.default).toBe(
|
|
@@ -203,7 +209,9 @@ describe('application config', () => {
|
|
|
203
209
|
expect(runtime.config.get('server.port')).toBe(14001);
|
|
204
210
|
expect(runtime.config.get('server.startLog')).toBe(false);
|
|
205
211
|
expect(runtime.config.get('queue.redis')).toBeUndefined();
|
|
206
|
-
expect(runtime.config.get('queue.memory.adapter')).toBe(
|
|
212
|
+
expect(runtime.config.get('queue.backends.memory.adapter')).toBe(
|
|
213
|
+
'inMemory',
|
|
214
|
+
);
|
|
207
215
|
expect(runtime.config.get('session.stores.redis.host')).toBe('127.0.0.1');
|
|
208
216
|
expect(runtime.config.get('logging.console.pretty')).toBe(false);
|
|
209
217
|
expect(runtime.config.get('session.cookie.secure')).toBe(true);
|
|
@@ -51,7 +51,7 @@ describe('app service providers', () => {
|
|
|
51
51
|
},
|
|
52
52
|
queue: {
|
|
53
53
|
default: 'memory',
|
|
54
|
-
memory: { adapter: 'inMemory' },
|
|
54
|
+
backends: { memory: { adapter: 'inMemory' } },
|
|
55
55
|
},
|
|
56
56
|
session: createNullSessionConfig(),
|
|
57
57
|
snowflake: {
|
|
@@ -142,7 +142,7 @@ describe('app service providers', () => {
|
|
|
142
142
|
} as unknown as DatabaseManager;
|
|
143
143
|
const queueConfig = {
|
|
144
144
|
default: 'test',
|
|
145
|
-
test: { adapter: 'inMemory' as const },
|
|
145
|
+
backends: { test: { adapter: 'inMemory' as const } },
|
|
146
146
|
};
|
|
147
147
|
const app = createProviderApplication(
|
|
148
148
|
{
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterAll, beforeAll, expect, it } from 'vitest';
|
|
5
|
+
import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
|
|
6
|
+
import {
|
|
7
|
+
createRealtimeBrokerRegistry,
|
|
8
|
+
realtimeServiceOptions,
|
|
9
|
+
type AppRealtimeConfig,
|
|
10
|
+
} from '@nocobase/app-server/realtime';
|
|
11
|
+
import { createInMemoryBroker } from '@nocobase/realtime/server';
|
|
12
|
+
import appRuntime from '../../server/runtime.js';
|
|
13
|
+
|
|
14
|
+
let root: string;
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
root = await mkdtemp(path.join(os.tmpdir(), 'realtime-config-'));
|
|
17
|
+
});
|
|
18
|
+
afterAll(async () => {
|
|
19
|
+
await rm(root, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
async function load(yaml: string) {
|
|
23
|
+
const configPath = path.join(root, `${crypto.randomUUID()}.yml`);
|
|
24
|
+
await writeFile(configPath, yaml);
|
|
25
|
+
const runtime = await resolveStandaloneAppRuntime(appRuntime, {
|
|
26
|
+
rootDir: path.resolve(import.meta.dirname, '../..'),
|
|
27
|
+
configPath,
|
|
28
|
+
env: { AUTH_SECRET: 'test-auth-secret-at-least-32-characters' },
|
|
29
|
+
});
|
|
30
|
+
const issues = (await runtime.config.validate()).filter(
|
|
31
|
+
(issue) => issue.path === 'realtime' || issue.path.startsWith('realtime.'),
|
|
32
|
+
);
|
|
33
|
+
if (issues.length)
|
|
34
|
+
throw new Error(
|
|
35
|
+
issues.map((issue) => `${issue.path} ${issue.message}`).join(' '),
|
|
36
|
+
);
|
|
37
|
+
return runtime;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it('loads the actual commented Redis example through the application runtime without exposing credentials', async () => {
|
|
41
|
+
const source = await readFile(
|
|
42
|
+
new URL('../../config.example.yml', import.meta.url),
|
|
43
|
+
'utf8',
|
|
44
|
+
);
|
|
45
|
+
const example = source.match(/^# realtime:\n(?:#(?: {2,}.*)?\n)*/m)?.[0];
|
|
46
|
+
expect(example).toBeDefined();
|
|
47
|
+
const runtime = await load(
|
|
48
|
+
source.replace(example!, example!.replace(/^# ?/gm, '')),
|
|
49
|
+
);
|
|
50
|
+
const config = runtime.config.get<AppRealtimeConfig>('realtime');
|
|
51
|
+
expect(config).toMatchObject({
|
|
52
|
+
namespace: 'my-app',
|
|
53
|
+
authorizationTimeoutMs: 5000,
|
|
54
|
+
sessionCheckIntervalMs: 30000,
|
|
55
|
+
broker: {
|
|
56
|
+
adapter: 'redis',
|
|
57
|
+
setupTimeoutMs: 5000,
|
|
58
|
+
connection: { host: '127.0.0.1', port: 6379, commandTimeout: 5000 },
|
|
59
|
+
},
|
|
60
|
+
limits: { maxConnections: 10000 },
|
|
61
|
+
});
|
|
62
|
+
expect(
|
|
63
|
+
(await runtime.config.validate()).filter((issue) =>
|
|
64
|
+
issue.path.startsWith('realtime'),
|
|
65
|
+
),
|
|
66
|
+
).toEqual([]);
|
|
67
|
+
expect(
|
|
68
|
+
runtime.config.publicPaths().some((p) => p.startsWith('realtime')),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
// Configuration loads without commercial dependencies, but Redis selection still requires registration.
|
|
71
|
+
expect(() => realtimeServiceOptions(config)).toThrow(/not registered/u);
|
|
72
|
+
const registry = createRealtimeBrokerRegistry();
|
|
73
|
+
registry.register('redis', (context) => {
|
|
74
|
+
expect(context.config).toBe(config);
|
|
75
|
+
expect(context.namespace).toBe('my-app');
|
|
76
|
+
return createInMemoryBroker();
|
|
77
|
+
});
|
|
78
|
+
await realtimeServiceOptions(
|
|
79
|
+
config,
|
|
80
|
+
'application',
|
|
81
|
+
registry,
|
|
82
|
+
).broker?.shutdown();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it.each([
|
|
86
|
+
'{}',
|
|
87
|
+
'realtime:\n limits:\n maxConnections: 10',
|
|
88
|
+
'realtime:\n broker:\n adapter: inMemory',
|
|
89
|
+
])('keeps local operation for %s', async (yaml) => {
|
|
90
|
+
const runtime = await load(yaml);
|
|
91
|
+
const config = runtime.config.get<AppRealtimeConfig>('realtime');
|
|
92
|
+
expect(config?.broker?.adapter).not.toBe('redis');
|
|
93
|
+
const options = realtimeServiceOptions(config);
|
|
94
|
+
await options.broker?.shutdown();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.each(['{}', 'null', '[]', 'redis', '{ adapter: "" }', '{ adapter: 12 }'])(
|
|
98
|
+
'does not fill in a missing adapter for explicit broker %s',
|
|
99
|
+
async (broker) => {
|
|
100
|
+
await expect(
|
|
101
|
+
load(`realtime:\n broker: ${broker}`).then(() => undefined),
|
|
102
|
+
).rejects.toThrow(/realtime.broker/u);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
it.each([
|
|
107
|
+
'adapter: inMemory',
|
|
108
|
+
'redis: {}',
|
|
109
|
+
'adapter: redis\n broker: { adapter: redis }',
|
|
110
|
+
])('rejects legacy fields in runtime configuration: %s', async (legacy) => {
|
|
111
|
+
await expect(
|
|
112
|
+
load(`realtime:\n ${legacy}`).then(() => undefined),
|
|
113
|
+
).rejects.toThrow(/old configuration format/u);
|
|
114
|
+
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Bell,
|
|
3
|
-
CalendarClock,
|
|
4
|
-
Database,
|
|
5
|
-
KeyRound,
|
|
6
|
-
Languages,
|
|
7
|
-
Mail,
|
|
8
|
-
Palette,
|
|
9
|
-
Paperclip,
|
|
10
|
-
ShieldCheck,
|
|
11
|
-
Workflow,
|
|
12
|
-
type LucideIcon,
|
|
13
|
-
} from 'lucide-react';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* A built-in capability the homepage introduces. Its title, description and prompts live in the locale files under
|
|
17
|
-
* `home.capabilities.<id>`, one `prompts.<promptId>.label` and `prompts.<promptId>.text` per prompt, so the wording is
|
|
18
|
-
* translated and reworded there rather than here.
|
|
19
|
-
*/
|
|
20
|
-
export interface Capability {
|
|
21
|
-
readonly id: string;
|
|
22
|
-
readonly icon: LucideIcon;
|
|
23
|
-
readonly prompts: readonly string[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// The default prompts follow the examples in the documentation's Capabilities chapter, in the order of its sidebar.
|
|
27
|
-
export const CAPABILITIES: readonly Capability[] = [
|
|
28
|
-
{
|
|
29
|
-
id: 'auth',
|
|
30
|
-
icon: KeyRound,
|
|
31
|
-
prompts: ['disableSignUp', 'companySso', 'apiKeys'],
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
id: 'dataSources',
|
|
35
|
-
icon: Database,
|
|
36
|
-
prompts: ['postgres', 'externalDatabase'],
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
id: 'authorization',
|
|
40
|
-
icon: ShieldCheck,
|
|
41
|
-
prompts: ['jobs', 'dataScopes', 'restrictions'],
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
id: 'workflow',
|
|
45
|
-
icon: Workflow,
|
|
46
|
-
prompts: ['stages', 'backgroundExport', 'history'],
|
|
47
|
-
},
|
|
48
|
-
{ id: 'file', icon: Paperclip, prompts: ['attachments', 'avatar'] },
|
|
49
|
-
{ id: 'i18n', icon: Languages, prompts: ['addLanguage', 'defaultLanguage'] },
|
|
50
|
-
{ id: 'theme', icon: Palette, prompts: ['changeTheme', 'newTheme'] },
|
|
51
|
-
{ id: 'notification', icon: Bell, prompts: ['inApp', 'email'] },
|
|
52
|
-
{
|
|
53
|
-
id: 'scheduler',
|
|
54
|
-
icon: CalendarClock,
|
|
55
|
-
prompts: ['reminder', 'weeklyReport'],
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
id: 'mail',
|
|
59
|
-
icon: Mail,
|
|
60
|
-
prompts: ['providers', 'mailCenter', 'correspondence'],
|
|
61
|
-
},
|
|
62
|
-
];
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
-
import { CheckIcon, CopyIcon, RotateCcwIcon } from 'lucide-react';
|
|
3
|
-
import { type ReactElement, useState } from 'react';
|
|
4
|
-
|
|
5
|
-
import { Button } from '#components/ui/button';
|
|
6
|
-
import {
|
|
7
|
-
Dialog,
|
|
8
|
-
DialogClose,
|
|
9
|
-
DialogContent,
|
|
10
|
-
DialogDescription,
|
|
11
|
-
DialogFooter,
|
|
12
|
-
DialogHeader,
|
|
13
|
-
DialogTitle,
|
|
14
|
-
} from '#components/ui/dialog';
|
|
15
|
-
import { Tabs, TabsList, TabsTrigger } from '#components/ui/tabs';
|
|
16
|
-
import { Textarea } from '#components/ui/textarea';
|
|
17
|
-
|
|
18
|
-
import type { Capability } from './capabilities.js';
|
|
19
|
-
import { useCopy } from './use-copy.js';
|
|
20
|
-
|
|
21
|
-
export interface PromptDialogProps {
|
|
22
|
-
readonly capability: Capability;
|
|
23
|
-
readonly open: boolean;
|
|
24
|
-
readonly onOpenChange: (open: boolean) => void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The prompts of one capability, ready to hand to a coding agent. Each prompt can be edited before copying. Edits are
|
|
29
|
-
* not saved: the page mounts a new dialog each time one is opened, so it starts from the default wording again.
|
|
30
|
-
*/
|
|
31
|
-
export function PromptDialog({
|
|
32
|
-
capability,
|
|
33
|
-
open,
|
|
34
|
-
onOpenChange,
|
|
35
|
-
}: PromptDialogProps): ReactElement {
|
|
36
|
-
const { t } = useTranslation();
|
|
37
|
-
const { copied, copy } = useCopy();
|
|
38
|
-
const [current, setCurrent] = useState(capability.prompts[0]);
|
|
39
|
-
// Edited text per prompt id; a prompt without an entry shows its default wording.
|
|
40
|
-
const [edits, setEdits] = useState<Readonly<Record<string, string>>>({});
|
|
41
|
-
|
|
42
|
-
const prefix = `home.capabilities.${capability.id}`;
|
|
43
|
-
const defaultText = t(`${prefix}.prompts.${current}.text`);
|
|
44
|
-
const text = edits[current] ?? defaultText;
|
|
45
|
-
const Icon = capability.icon;
|
|
46
|
-
|
|
47
|
-
return (
|
|
48
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
49
|
-
<DialogContent className='gap-5 sm:max-w-2xl'>
|
|
50
|
-
<DialogHeader className='flex-row items-center gap-3 pr-8'>
|
|
51
|
-
<span className='flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/5 text-primary'>
|
|
52
|
-
<Icon className='size-5' aria-hidden />
|
|
53
|
-
</span>
|
|
54
|
-
<div className='flex min-w-0 flex-col gap-1.5'>
|
|
55
|
-
<DialogTitle>{t(`${prefix}.title`)}</DialogTitle>
|
|
56
|
-
<DialogDescription>{t(`${prefix}.description`)}</DialogDescription>
|
|
57
|
-
</div>
|
|
58
|
-
</DialogHeader>
|
|
59
|
-
<div className='flex flex-col gap-3'>
|
|
60
|
-
{capability.prompts.length > 1 ? (
|
|
61
|
-
<Tabs
|
|
62
|
-
value={current}
|
|
63
|
-
onValueChange={(value) => {
|
|
64
|
-
if (typeof value === 'string') setCurrent(value);
|
|
65
|
-
}}
|
|
66
|
-
>
|
|
67
|
-
<TabsList className='w-full'>
|
|
68
|
-
{capability.prompts.map((id) => (
|
|
69
|
-
<TabsTrigger key={id} value={id}>
|
|
70
|
-
{t(`${prefix}.prompts.${id}.label`)}
|
|
71
|
-
</TabsTrigger>
|
|
72
|
-
))}
|
|
73
|
-
</TabsList>
|
|
74
|
-
</Tabs>
|
|
75
|
-
) : null}
|
|
76
|
-
<Textarea
|
|
77
|
-
aria-label={t('home.prompt.label')}
|
|
78
|
-
className='field-sizing-fixed h-60 max-h-[50dvh] resize-none leading-6'
|
|
79
|
-
value={text}
|
|
80
|
-
onChange={(event) =>
|
|
81
|
-
setEdits((previous) => ({
|
|
82
|
-
...previous,
|
|
83
|
-
[current]: event.target.value,
|
|
84
|
-
}))
|
|
85
|
-
}
|
|
86
|
-
/>
|
|
87
|
-
<p className='text-xs text-muted-foreground'>
|
|
88
|
-
{t('home.prompt.hint')}
|
|
89
|
-
</p>
|
|
90
|
-
</div>
|
|
91
|
-
<DialogFooter className='sm:items-center'>
|
|
92
|
-
<Button
|
|
93
|
-
variant='ghost'
|
|
94
|
-
className='sm:mr-auto'
|
|
95
|
-
disabled={text === defaultText}
|
|
96
|
-
onClick={() =>
|
|
97
|
-
setEdits((previous) => {
|
|
98
|
-
const rest = { ...previous };
|
|
99
|
-
delete rest[current];
|
|
100
|
-
return rest;
|
|
101
|
-
})
|
|
102
|
-
}
|
|
103
|
-
>
|
|
104
|
-
<RotateCcwIcon data-icon='inline-start' />
|
|
105
|
-
{t('home.prompt.reset')}
|
|
106
|
-
</Button>
|
|
107
|
-
<DialogClose render={<Button variant='outline' />}>
|
|
108
|
-
{t('actions.close')}
|
|
109
|
-
</DialogClose>
|
|
110
|
-
<Button disabled={text.trim() === ''} onClick={() => void copy(text)}>
|
|
111
|
-
{copied ? (
|
|
112
|
-
<CheckIcon data-icon='inline-start' />
|
|
113
|
-
) : (
|
|
114
|
-
<CopyIcon data-icon='inline-start' />
|
|
115
|
-
)}
|
|
116
|
-
{copied ? t('home.prompt.copiedShort') : t('home.prompt.copy')}
|
|
117
|
-
</Button>
|
|
118
|
-
</DialogFooter>
|
|
119
|
-
</DialogContent>
|
|
120
|
-
</Dialog>
|
|
121
|
-
);
|
|
122
|
-
}
|