@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.90
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/components.md +3 -1
- package/.agents/rules/shell.md +12 -2
- package/AGENTS.md +1 -1
- package/LICENSE.txt +129 -0
- package/client/components/back-button.tsx +37 -12
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +47 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +0 -4
- package/client/lib/navigation-transform.ts +29 -1
- package/client/locales/system/en-US.ts +23 -0
- package/client/locales/system/zh-CN.ts +23 -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/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +15 -7
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/plugins.ts +8 -1
- package/client/routes.ts +2 -1
- package/client/routing/client-route.tsx +199 -37
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-leave-boundary.tsx +13 -1
- package/client/styles.css +18 -1
- package/config.example.yml +17 -11
- package/index.html +8 -11
- package/package.json +27 -27
- 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/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-route-recovery.test.tsx +93 -1
- 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 +42 -29
- package/tests/client/home.test.tsx +26 -85
- package/tests/client/layout-i18n.test.tsx +83 -2
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +48 -12
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +4 -2
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +20 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +77 -22
- package/tests/playwright/dialog-footer.test.ts +227 -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
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { expect, test, type Locator, type Page } from '@playwright/test';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const applicationUrl = process.env.APP_URL;
|
|
6
|
+
test.skip(!applicationUrl, 'Set APP_URL to the running application entry.');
|
|
7
|
+
const fixture = `${applicationUrl?.replace(/\/$/, '')}/tests/fixtures/dialog-footer.html`;
|
|
8
|
+
|
|
9
|
+
async function geometry(page: Page) {
|
|
10
|
+
return page.getByTestId('cancel').evaluate((cancel) => {
|
|
11
|
+
const submit = document.querySelector('[data-testid=submit]')!;
|
|
12
|
+
const footer = cancel.parentElement!;
|
|
13
|
+
const panel = cancel.closest('[role=dialog], [role=alertdialog]')!;
|
|
14
|
+
const measure = (element: Element) => {
|
|
15
|
+
const rect = element.getBoundingClientRect();
|
|
16
|
+
const css = getComputedStyle(element);
|
|
17
|
+
return {
|
|
18
|
+
x: rect.x,
|
|
19
|
+
y: rect.y,
|
|
20
|
+
right: rect.right,
|
|
21
|
+
bottom: rect.bottom,
|
|
22
|
+
height: rect.height,
|
|
23
|
+
width: rect.width,
|
|
24
|
+
paddingLeft: css.paddingLeft,
|
|
25
|
+
paddingRight: css.paddingRight,
|
|
26
|
+
paddingTop: css.paddingTop,
|
|
27
|
+
paddingBottom: css.paddingBottom,
|
|
28
|
+
minHeight: parseFloat(css.minHeight),
|
|
29
|
+
overflow: element.scrollWidth > element.clientWidth + 1,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
cancel: measure(cancel),
|
|
34
|
+
submit: measure(submit),
|
|
35
|
+
footer: measure(footer),
|
|
36
|
+
panel: measure(panel),
|
|
37
|
+
horizontal:
|
|
38
|
+
getComputedStyle(footer).flexDirection === 'row' ||
|
|
39
|
+
getComputedStyle(footer).display === 'grid',
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function checkGeometry(page: Page) {
|
|
45
|
+
await expect(page.getByTestId('cancel')).toBeVisible();
|
|
46
|
+
let previous = '';
|
|
47
|
+
await expect
|
|
48
|
+
.poll(async () => {
|
|
49
|
+
const current = JSON.stringify(await geometry(page));
|
|
50
|
+
const stable = current === previous;
|
|
51
|
+
previous = current;
|
|
52
|
+
return stable;
|
|
53
|
+
})
|
|
54
|
+
.toBe(true);
|
|
55
|
+
const result = await geometry(page);
|
|
56
|
+
const { cancel, submit, footer, panel, horizontal } = result;
|
|
57
|
+
expect(cancel.height).toBeGreaterThanOrEqual(cancel.minHeight - 1);
|
|
58
|
+
expect(submit.height).toBeGreaterThanOrEqual(submit.minHeight - 1);
|
|
59
|
+
expect(cancel.paddingLeft).toBe(submit.paddingLeft);
|
|
60
|
+
expect(cancel.paddingRight).toBe(submit.paddingRight);
|
|
61
|
+
expect(cancel.paddingTop).toBe(submit.paddingTop);
|
|
62
|
+
expect(cancel.paddingBottom).toBe(submit.paddingBottom);
|
|
63
|
+
if (horizontal) {
|
|
64
|
+
expect(Math.abs(cancel.y - submit.y)).toBeLessThan(1);
|
|
65
|
+
expect(Math.abs(cancel.bottom - submit.bottom)).toBeLessThan(1);
|
|
66
|
+
expect(cancel.right).toBeLessThanOrEqual(submit.x + 1);
|
|
67
|
+
} else {
|
|
68
|
+
expect(submit.bottom).toBeLessThanOrEqual(cancel.y + 1);
|
|
69
|
+
}
|
|
70
|
+
for (const button of [cancel, submit]) {
|
|
71
|
+
expect(button.overflow).toBe(false);
|
|
72
|
+
expect(button.x).toBeGreaterThanOrEqual(panel.x);
|
|
73
|
+
expect(button.right).toBeLessThanOrEqual(panel.right + 1);
|
|
74
|
+
expect(button.y).toBeGreaterThanOrEqual(footer.y);
|
|
75
|
+
expect(button.bottom).toBeLessThanOrEqual(panel.bottom + 1);
|
|
76
|
+
expect(button.bottom).toBeLessThanOrEqual(page.viewportSize()!.height);
|
|
77
|
+
}
|
|
78
|
+
expect(panel.x).toBeGreaterThanOrEqual(0);
|
|
79
|
+
expect(panel.right).toBeLessThanOrEqual(page.viewportSize()!.width);
|
|
80
|
+
expect(panel.overflow).toBe(false);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function screenshot(page: Page, name: string) {
|
|
85
|
+
if (process.env.DIALOG_EVIDENCE_DIR) {
|
|
86
|
+
await page.screenshot({
|
|
87
|
+
path: path.join(process.env.DIALOG_EVIDENCE_DIR, `${name}.png`),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const kind of [
|
|
93
|
+
'dialog',
|
|
94
|
+
'dialog-close',
|
|
95
|
+
'alert',
|
|
96
|
+
'alert-small',
|
|
97
|
+
'route',
|
|
98
|
+
'drawer',
|
|
99
|
+
]) {
|
|
100
|
+
for (const preset of ['compact', 'default']) {
|
|
101
|
+
for (const width of [1440, 375]) {
|
|
102
|
+
test(`${kind}, ${preset}, ${width}: semantic actions align and keep their padding`, async ({
|
|
103
|
+
page,
|
|
104
|
+
}) => {
|
|
105
|
+
await page.setViewportSize({ width, height: 900 });
|
|
106
|
+
await page.goto(`${fixture}?kind=${kind}&preset=${preset}`);
|
|
107
|
+
const result = await checkGeometry(page);
|
|
108
|
+
expect(result.cancel.height).toBeCloseTo(result.submit.height, 0);
|
|
109
|
+
expect(result.cancel.minHeight).toBe(preset === 'compact' ? 36 : 40);
|
|
110
|
+
expect(result.cancel.paddingLeft).toBe(
|
|
111
|
+
preset === 'compact' ? '14.4px' : '16px',
|
|
112
|
+
);
|
|
113
|
+
if (width > 640 && kind !== 'alert-small')
|
|
114
|
+
expect(result.cancel.width).toBeLessThan(result.submit.width);
|
|
115
|
+
if (preset === 'default') await screenshot(page, `${kind}-${width}-en`);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const language of ['english', 'chinese']) {
|
|
121
|
+
test(`${kind}: long ${language} labels remain visible on a short phone viewport`, async ({
|
|
122
|
+
page,
|
|
123
|
+
}) => {
|
|
124
|
+
await page.setViewportSize({ width: 375, height: 667 });
|
|
125
|
+
await page.goto(
|
|
126
|
+
`${fixture}?kind=${kind}&long&${language === 'chinese' ? 'chinese&' : ''}dark`,
|
|
127
|
+
);
|
|
128
|
+
await checkGeometry(page);
|
|
129
|
+
await screenshot(page, `${kind}-375-long-${language}`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test(`${kind}: disabled and submitting actions retain geometry`, async ({
|
|
134
|
+
page,
|
|
135
|
+
}) => {
|
|
136
|
+
await page.setViewportSize({ width: 375, height: 900 });
|
|
137
|
+
await page.goto(`${fixture}?kind=${kind}&pending`);
|
|
138
|
+
await checkGeometry(page);
|
|
139
|
+
await expect(page.getByTestId('cancel')).toBeDisabled();
|
|
140
|
+
await expect(page.getByTestId('submit')).toBeDisabled();
|
|
141
|
+
await expect(page.getByTestId('submit')).toHaveAttribute(
|
|
142
|
+
'aria-busy',
|
|
143
|
+
'true',
|
|
144
|
+
);
|
|
145
|
+
await page.keyboard.press('Escape');
|
|
146
|
+
await expect(page.getByTestId('submit')).toBeVisible();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const kind of ['dialog', 'alert']) {
|
|
151
|
+
test(`${kind}: a phone row wraps long labels without overflowing`, async ({
|
|
152
|
+
page,
|
|
153
|
+
}) => {
|
|
154
|
+
await page.setViewportSize({ width: 375, height: 667 });
|
|
155
|
+
await page.goto(`${fixture}?kind=${kind}&horizontal&chinese`);
|
|
156
|
+
await checkGeometry(page);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function expectKeyboardFocus(action: Locator) {
|
|
161
|
+
await action.focus();
|
|
162
|
+
await expect(action).toBeFocused();
|
|
163
|
+
expect(
|
|
164
|
+
await action.evaluate(
|
|
165
|
+
(element) =>
|
|
166
|
+
getComputedStyle(element).outlineStyle !== 'none' ||
|
|
167
|
+
getComputedStyle(element).boxShadow !== 'none',
|
|
168
|
+
),
|
|
169
|
+
).toBe(true);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const kind of ['dialog', 'route', 'drawer']) {
|
|
173
|
+
test(`${kind}: link cancel and native form submit remain independent`, async ({
|
|
174
|
+
page,
|
|
175
|
+
}) => {
|
|
176
|
+
await page.goto(`${fixture}?kind=${kind}`);
|
|
177
|
+
await checkGeometry(page);
|
|
178
|
+
const cancel = page.getByTestId('cancel');
|
|
179
|
+
await expect(cancel).toHaveAttribute('href', '/instances?filter=recent');
|
|
180
|
+
expect(await cancel.evaluate((element) => element.tagName)).toBe('A');
|
|
181
|
+
await expectKeyboardFocus(cancel);
|
|
182
|
+
await page.keyboard.press('Enter');
|
|
183
|
+
await expect(page.getByTestId('location')).toHaveText(
|
|
184
|
+
'/instances?filter=recent',
|
|
185
|
+
);
|
|
186
|
+
await expect(page.getByTestId('submit')).toHaveCount(0);
|
|
187
|
+
await page.goto(`${fixture}?kind=${kind}`);
|
|
188
|
+
await checkGeometry(page);
|
|
189
|
+
await page.getByLabel('Instance name').fill('New customer portal');
|
|
190
|
+
await expectKeyboardFocus(page.getByTestId('submit'));
|
|
191
|
+
await page.keyboard.press('Enter');
|
|
192
|
+
await expect(page.getByTestId('location')).toHaveText(
|
|
193
|
+
'/instances?created=1',
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test('route footer stays visible when the body scrolls', async ({ page }) => {
|
|
199
|
+
await page.setViewportSize({ width: 375, height: 667 });
|
|
200
|
+
await page.goto(`${fixture}?kind=route&long`);
|
|
201
|
+
const before = await checkGeometry(page);
|
|
202
|
+
await page.locator('[data-slot=route-overlay-body]').evaluate((element) => {
|
|
203
|
+
element.scrollTop = element.scrollHeight;
|
|
204
|
+
});
|
|
205
|
+
await expect
|
|
206
|
+
.poll(() =>
|
|
207
|
+
page
|
|
208
|
+
.locator('[data-slot=route-overlay-body]')
|
|
209
|
+
.evaluate((element) => element.scrollTop),
|
|
210
|
+
)
|
|
211
|
+
.toBeGreaterThan(0);
|
|
212
|
+
const after = await checkGeometry(page);
|
|
213
|
+
expect(after.footer.y).toBe(before.footer.y);
|
|
214
|
+
await page.getByTestId('cancel').click();
|
|
215
|
+
await expect(page.getByTestId('location')).toHaveText(
|
|
216
|
+
'/instances?filter=recent',
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// DialogClose replaces Button's semantic slot just as AlertDialog's actions do.
|
|
221
|
+
test('DialogClose cancels without submitting the form', async ({ page }) => {
|
|
222
|
+
await page.goto(`${fixture}?kind=dialog-close`);
|
|
223
|
+
await checkGeometry(page);
|
|
224
|
+
await page.getByTestId('cancel').click();
|
|
225
|
+
await expect(page.getByTestId('submit')).toHaveCount(0);
|
|
226
|
+
await expect(page.getByTestId('location')).toHaveText('Instance list');
|
|
227
|
+
});
|
|
@@ -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
|
+
});
|