cabloy 5.1.192 → 5.1.194
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/.cabloy-version +1 -1
- package/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/repo-agent-governance/adapters/claude/contract-loop-gate.ts +15 -187
- package/repo-agent-governance/adapters/codex/contract-loop-gate.mjs +25 -0
- package/repo-agent-governance/adapters/codex/hooks.json +19 -0
- package/repo-agent-governance/adapters/cursor/contract-loop-gate.mjs +28 -0
- package/repo-agent-governance/adapters/cursor/hooks.json +19 -0
- package/repo-agent-governance/managed-assets.json +38 -10
- package/repo-agent-governance/manifest.json +24 -0
- package/repo-agent-governance/policies/repository.md +1 -1
- package/repo-agent-governance/scripts/governance.mjs +101 -2
- package/repo-agent-governance/scripts/pack-check.mjs +5 -0
- package/repo-agent-governance/skills/cabloy-contract-loop/SKILL.md +9 -2
- package/repo-agent-governance/skills/cabloy-contract-loop/references/verification-checklist.md +5 -3
- package/repo-agent-governance/tests/governance.test.mjs +306 -0
- package/repo-agent-governance/tools/contract-loop/core.mjs +30 -14
- package/repo-agent-governance/tools/contract-loop/hook-runtime.mjs +368 -0
- package/repo-docs/ai/agent-governance.md +8 -6
- package/repo-docs/ai/docs-skills-rules-mapping.md +4 -1
- package/repo-docs/ai/edition-consistency-checklist.md +1 -1
- package/repo-docs/ai/introduction.md +1 -1
- package/repo-docs/ai/repo-guidance.md +1 -1
- package/repo-docs/ai/rules-and-config.md +5 -3
- package/repo-docs/backend/menu-authorization.md +1 -0
- package/repo-docs/backend/menu-guide.md +18 -7
- package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +1 -0
- package/repo-docs/reference/package-map.md +1 -1
- package/repo-e2e/config/playwright.config.ts +1 -1
- package/repo-e2e/scripts/startE2eVona.ts +6 -2
- package/repo-e2e/specs/controller-boundary.spec.ts +76 -28
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/beanSsrSiteBase.ts +2 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenu.ts +9 -3
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuEligibility.ts +14 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuGroup.ts +9 -3
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuSite.ts +10 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/types/ssrMenu.ts +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/types/ssrMenuGroup.ts +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/test/ssrMenuEligibility.test.ts +73 -13
- package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/test/ssrMenuSite.test.ts +90 -0
- package/vona/src/suite-vendor/a-cabloy/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-error/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-error/src/lib/onerror.ts +9 -0
- package/vona/src/suite-vendor/a-vona/modules/a-error/test/onerror.test.ts +92 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
|
@@ -4,6 +4,29 @@ import { expect, test } from '@playwright/test';
|
|
|
4
4
|
|
|
5
5
|
const probeErrorMessage = 'Controller boundary probe initialization failed';
|
|
6
6
|
|
|
7
|
+
async function pauseProbeClock(page: Page) {
|
|
8
|
+
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 60_000));
|
|
9
|
+
// Wait for __init__ to schedule its timer, not merely for the boundary timer to start.
|
|
10
|
+
const timers = await page.evaluateHandle(() => {
|
|
11
|
+
const scheduled: Record<number, number> = {};
|
|
12
|
+
window.setTimeout = new Proxy(window.setTimeout, {
|
|
13
|
+
apply(target, receiver, args) {
|
|
14
|
+
const delay = args[1];
|
|
15
|
+
scheduled[delay] = (scheduled[delay] ?? 0) + 1;
|
|
16
|
+
return Reflect.apply(target, receiver, args);
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
return scheduled;
|
|
20
|
+
});
|
|
21
|
+
return async (delay: number, action: () => Promise<unknown>) => {
|
|
22
|
+
const before = await timers.evaluate((scheduled, delay) => scheduled[delay] ?? 0, delay);
|
|
23
|
+
await action();
|
|
24
|
+
await expect
|
|
25
|
+
.poll(() => timers.evaluate((scheduled, delay) => scheduled[delay] ?? 0, delay))
|
|
26
|
+
.toBe(before + 1);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
function collectPageErrors(page: Page) {
|
|
8
31
|
const errors: Error[] = [];
|
|
9
32
|
page.on('pageerror', error => {
|
|
@@ -26,6 +49,7 @@ test(
|
|
|
26
49
|
'ATP-BASIC-CONTROLLER-BOUNDARY-01: global boundaries render loading, error, and recovery',
|
|
27
50
|
{ tag: ['@web', '@flow'] },
|
|
28
51
|
async ({ page, request }) => {
|
|
52
|
+
await page.clock.install();
|
|
29
53
|
const serverResponse = await request.get('/demo/basic/controllerBoundary');
|
|
30
54
|
expect(serverResponse.ok()).toBeTruthy();
|
|
31
55
|
const serverHtml = await serverResponse.text();
|
|
@@ -50,48 +74,63 @@ test(
|
|
|
50
74
|
await expect(error).toHaveCount(0);
|
|
51
75
|
await expect(ready).toHaveCount(0);
|
|
52
76
|
|
|
53
|
-
|
|
54
|
-
await
|
|
77
|
+
const startProbe = await pauseProbeClock(page);
|
|
78
|
+
await startProbe(300, () =>
|
|
79
|
+
page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click(),
|
|
80
|
+
);
|
|
81
|
+
await page.clock.runFor(400);
|
|
55
82
|
await expect(loading).toHaveCount(0);
|
|
56
83
|
await expect(ready).toBeVisible();
|
|
57
84
|
await expect(error).toHaveCount(0);
|
|
58
85
|
|
|
59
|
-
await
|
|
60
|
-
|
|
86
|
+
await startProbe(300, () =>
|
|
87
|
+
page.getByRole('button', { name: 'Mount fast failing probe', exact: true }).click(),
|
|
88
|
+
);
|
|
89
|
+
await page.clock.runFor(400);
|
|
61
90
|
await expect(error).toHaveText(probeErrorMessage);
|
|
62
91
|
await expect(loading).toHaveCount(0);
|
|
63
92
|
await expect(ready).toHaveCount(0);
|
|
64
93
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
await startProbe(800, () =>
|
|
95
|
+
page.getByRole('button', { name: 'Mount delayed success probe', exact: true }).click(),
|
|
96
|
+
);
|
|
97
|
+
await page.clock.runFor(600);
|
|
98
|
+
await expect(loading).toBeVisible();
|
|
99
|
+
await page.clock.runFor(400);
|
|
68
100
|
await expect(ready).toBeVisible();
|
|
69
101
|
await expect(loading).toHaveCount(0);
|
|
70
102
|
await expect(error).toHaveCount(0);
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
await startProbe(800, () =>
|
|
105
|
+
page.getByRole('button', { name: 'Mount failing probe', exact: true }).click(),
|
|
106
|
+
);
|
|
107
|
+
await page.clock.runFor(600);
|
|
108
|
+
await expect(loading).toBeVisible();
|
|
75
109
|
await expect.poll(() => loading.evaluate(element => element.tagName)).toBe('DIV');
|
|
110
|
+
await page.clock.runFor(400);
|
|
76
111
|
await expect(error).toHaveText(probeErrorMessage);
|
|
77
112
|
await expect.poll(() => error.evaluate(element => element.tagName)).toBe('DIV');
|
|
78
113
|
await expect(ready).toHaveCount(0);
|
|
79
114
|
const retry = page.getByRole('button', { name: 'Retry', exact: true });
|
|
80
115
|
await expect(retry).toBeVisible();
|
|
81
116
|
|
|
82
|
-
|
|
83
|
-
await
|
|
84
|
-
await
|
|
117
|
+
await startProbe(800, () => retry.click());
|
|
118
|
+
await page.clock.runFor(600);
|
|
119
|
+
await expect(loading).toBeVisible();
|
|
120
|
+
await page.clock.runFor(400);
|
|
85
121
|
await expect(error).toHaveText(probeErrorMessage);
|
|
86
122
|
await expect(retry).toBeVisible();
|
|
87
123
|
|
|
88
124
|
await page.getByRole('button', { name: 'Prepare probe retry success', exact: true }).click();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
await startProbe(800, () =>
|
|
126
|
+
retry.evaluate(button => {
|
|
127
|
+
button.click();
|
|
128
|
+
button.click();
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
await page.clock.runFor(600);
|
|
132
|
+
await expect(loading).toBeVisible();
|
|
133
|
+
await page.clock.runFor(400);
|
|
95
134
|
await expect(ready).toBeVisible();
|
|
96
135
|
await expect(loading).toHaveCount(0);
|
|
97
136
|
await expect(error).toHaveCount(0);
|
|
@@ -105,6 +144,7 @@ test(
|
|
|
105
144
|
'ATP-BASIC-CONTROLLER-BOUNDARY-06: disposed and stale loads do not surface errors',
|
|
106
145
|
{ tag: ['@web', '@flow'] },
|
|
107
146
|
async ({ page }) => {
|
|
147
|
+
await page.clock.install();
|
|
108
148
|
const pageErrors = collectPageErrors(page);
|
|
109
149
|
const consoleErrors = collectConsoleErrors(page);
|
|
110
150
|
const response = await page.goto('/demo/basic/controllerBoundary', { waitUntil: 'load' });
|
|
@@ -116,21 +156,29 @@ test(
|
|
|
116
156
|
const error = page.locator('.alert.alert-error');
|
|
117
157
|
const ready = page.getByText('Controller boundary probe ready', { exact: true });
|
|
118
158
|
|
|
119
|
-
const
|
|
120
|
-
await
|
|
121
|
-
|
|
159
|
+
const startProbe = await pauseProbeClock(page);
|
|
160
|
+
await startProbe(800, () =>
|
|
161
|
+
page.getByRole('button', { name: 'Mount failing probe', exact: true }).click(),
|
|
162
|
+
);
|
|
163
|
+
await page.clock.runFor(600);
|
|
164
|
+
await expect(loading).toBeVisible();
|
|
122
165
|
await page.getByRole('button', { name: 'Clear probe', exact: true }).click();
|
|
123
|
-
await page.
|
|
166
|
+
await page.clock.runFor(1_000);
|
|
124
167
|
await expect(loading).toHaveCount(0);
|
|
125
168
|
await expect(error).toHaveCount(0);
|
|
126
169
|
await expect(ready).toHaveCount(0);
|
|
127
170
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
await page.
|
|
171
|
+
await startProbe(800, () =>
|
|
172
|
+
page.getByRole('button', { name: 'Mount failing probe', exact: true }).click(),
|
|
173
|
+
);
|
|
174
|
+
await page.clock.runFor(600);
|
|
175
|
+
await expect(loading).toBeVisible();
|
|
176
|
+
await startProbe(300, () =>
|
|
177
|
+
page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click(),
|
|
178
|
+
);
|
|
179
|
+
await page.clock.runFor(1_000);
|
|
132
180
|
await expect(ready).toBeVisible();
|
|
133
|
-
await page.
|
|
181
|
+
await page.clock.runFor(1_000);
|
|
134
182
|
await expect(error).toHaveCount(0);
|
|
135
183
|
await expect(ready).toBeVisible();
|
|
136
184
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.102",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
|
|
6
6
|
"keywords": [
|
|
@@ -36,6 +36,7 @@ import { ServiceDevProxy } from '../service/devProxy.ts';
|
|
|
36
36
|
import { ServiceSsrHandler } from '../service/ssrHandler.ts';
|
|
37
37
|
import { SymbolCacheMenus } from './const.ts';
|
|
38
38
|
import { checkSsrBinding } from './ssrMenuEligibility.ts';
|
|
39
|
+
import { validateSsrMenuSite } from './ssrMenuSite.ts';
|
|
39
40
|
import {
|
|
40
41
|
projectPublicSsrMenuGroups,
|
|
41
42
|
projectPublicSsrMenus,
|
|
@@ -360,6 +361,7 @@ export class BeanSsrSiteBase<
|
|
|
360
361
|
for (const ssrMenu of ssrMenus) {
|
|
361
362
|
const siteMenuOptions = ssrMenu.beanOptions
|
|
362
363
|
.options as IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>;
|
|
364
|
+
validateSsrMenuSite(siteMenuOptions?.site, ssrMenu.beanOptions.beanFullName || ssrMenu.name);
|
|
363
365
|
if (!checkSsrBinding(siteOnionName, siteMenuOptions.site)) continue;
|
|
364
366
|
if (!checkSsrBinding(locale, siteMenuOptions.locale)) continue;
|
|
365
367
|
const menusFrom =
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { createBeanDecorator } from 'vona';
|
|
1
|
+
import { appResource, createBeanDecorator } from 'vona';
|
|
2
2
|
|
|
3
3
|
import type { IDecoratorSsrMenuOptions } from '../types/ssrMenu.ts';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { validateSsrMenuSite } from './ssrMenuSite.ts';
|
|
6
|
+
|
|
7
|
+
export function SsrMenu<T extends IDecoratorSsrMenuOptions<any>>(options: T): ClassDecorator {
|
|
8
|
+
validateSsrMenuSite(options?.site, 'SsrMenu');
|
|
9
|
+
return createBeanDecorator('ssrMenu', options, false, target => {
|
|
10
|
+
const beanOptions = appResource.getBean(target)!;
|
|
11
|
+
validateSsrMenuSite((beanOptions.options as T).site, beanOptions.beanFullName);
|
|
12
|
+
});
|
|
7
13
|
}
|
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
import type { IDecoratorSsrMenuGroupOptions, ISsrMenuGroupRecord } from '../types/ssrMenuGroup.ts';
|
|
10
10
|
import type { IDecoratorSsrSiteOptions } from '../types/ssrSite.ts';
|
|
11
11
|
|
|
12
|
+
import { validateSsrMenuSite } from './ssrMenuSite.ts';
|
|
13
|
+
|
|
12
14
|
export function checkSsrBinding<T>(expect: T, target: T | T[] | null | ''): boolean {
|
|
13
15
|
if (target === undefined || target === null || target === '') return true;
|
|
14
16
|
if (Array.isArray(target)) return target.some(item => item === expect);
|
|
@@ -20,6 +22,12 @@ export function resolveSsrMenuCatalog(
|
|
|
20
22
|
ssrMenus: IOnionSlice<ISsrMenuRecord, keyof ISsrMenuRecord, unknown>[],
|
|
21
23
|
ssrMenuGroups: IOnionSlice<ISsrMenuGroupRecord, keyof ISsrMenuGroupRecord, unknown>[] = [],
|
|
22
24
|
): ISsrMenuCatalog {
|
|
25
|
+
for (const ssrMenu of [...ssrMenus, ...ssrMenuGroups]) {
|
|
26
|
+
const options = ssrMenu.beanOptions.options as
|
|
27
|
+
| IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>
|
|
28
|
+
| undefined;
|
|
29
|
+
validateSsrMenuSite(options?.site, ssrMenu.beanOptions.beanFullName || ssrMenu.name);
|
|
30
|
+
}
|
|
23
31
|
const menus: ISsrMenuCatalog['menus'] = [];
|
|
24
32
|
const groups: ISsrMenuCatalog['groups'] = [];
|
|
25
33
|
for (const { ssrSiteName } of sites) {
|
|
@@ -87,6 +95,12 @@ export function resolveSsrMenuEligibility(
|
|
|
87
95
|
ssrMenuName: string,
|
|
88
96
|
ssrMenus: IOnionSlice<ISsrMenuRecord, keyof ISsrMenuRecord, unknown>[],
|
|
89
97
|
): ISsrMenuEligibility | undefined {
|
|
98
|
+
for (const ssrMenu of ssrMenus) {
|
|
99
|
+
const options = ssrMenu.beanOptions.options as
|
|
100
|
+
| IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>
|
|
101
|
+
| undefined;
|
|
102
|
+
validateSsrMenuSite(options?.site, ssrMenu.beanOptions.beanFullName || ssrMenu.name);
|
|
103
|
+
}
|
|
90
104
|
for (const ssrMenu of ssrMenus) {
|
|
91
105
|
const options = ssrMenu.beanOptions.options as
|
|
92
106
|
| IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { createBeanDecorator } from 'vona';
|
|
1
|
+
import { appResource, createBeanDecorator } from 'vona';
|
|
2
2
|
|
|
3
3
|
import type { IDecoratorSsrMenuGroupOptions } from '../types/ssrMenuGroup.ts';
|
|
4
4
|
|
|
5
|
+
import { validateSsrMenuSite } from './ssrMenuSite.ts';
|
|
6
|
+
|
|
5
7
|
export function SsrMenuGroup<T extends IDecoratorSsrMenuGroupOptions<any>>(
|
|
6
|
-
options
|
|
8
|
+
options: T,
|
|
7
9
|
): ClassDecorator {
|
|
8
|
-
|
|
10
|
+
validateSsrMenuSite(options?.site, 'SsrMenuGroup');
|
|
11
|
+
return createBeanDecorator('ssrMenuGroup', options, false, target => {
|
|
12
|
+
const beanOptions = appResource.getBean(target)!;
|
|
13
|
+
validateSsrMenuSite((beanOptions.options as T).site, beanOptions.beanFullName);
|
|
14
|
+
});
|
|
9
15
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function validateSsrMenuSite(
|
|
2
|
+
site: unknown,
|
|
3
|
+
name: string,
|
|
4
|
+
): asserts site is string | string[] {
|
|
5
|
+
const isSiteName = (value: unknown) => typeof value === 'string' && !!value.trim();
|
|
6
|
+
if (Array.isArray(site) ? [...site].every(isSiteName) : isSiteName(site)) return;
|
|
7
|
+
throw new TypeError(
|
|
8
|
+
`Should explicitly specify site for ${name}: use an SSR site name or an array of non-empty site names ([] means no sites)`,
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -96,7 +96,7 @@ export interface IDecoratorSsrMenuOptions<
|
|
|
96
96
|
> extends IOnionOptionsEnable {
|
|
97
97
|
item?: ISsrMenuItem<SsrSiteOptions['pages'], SsrSiteOptions['icons']>;
|
|
98
98
|
items?: Record<string, ISsrMenuItem<SsrSiteOptions['pages'], SsrSiteOptions['icons']>>;
|
|
99
|
-
site
|
|
99
|
+
site: keyof ISsrSiteRecord | (keyof ISsrSiteRecord)[];
|
|
100
100
|
locale?: keyof ILocaleRecord | (keyof ILocaleRecord)[];
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -17,7 +17,7 @@ export interface IDecoratorSsrMenuGroupOptions<
|
|
|
17
17
|
SsrSiteOptions extends IDecoratorSsrSiteOptions,
|
|
18
18
|
> extends IOnionOptionsEnable {
|
|
19
19
|
item?: ISsrMenuGroup<SsrSiteOptions['icons']>;
|
|
20
|
-
site
|
|
20
|
+
site: keyof ISsrSiteRecord | (keyof ISsrSiteRecord)[];
|
|
21
21
|
locale?: keyof ILocaleRecord | (keyof ILocaleRecord)[];
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -10,9 +10,10 @@ import {
|
|
|
10
10
|
function createMenus() {
|
|
11
11
|
return [
|
|
12
12
|
{
|
|
13
|
-
name: 'test:
|
|
13
|
+
name: 'test:shared',
|
|
14
14
|
beanOptions: {
|
|
15
15
|
options: {
|
|
16
|
+
site: ['test:admin', 'test:web'],
|
|
16
17
|
item: { roles: [] },
|
|
17
18
|
locale: ['en-us'],
|
|
18
19
|
},
|
|
@@ -31,14 +32,16 @@ function createMenus() {
|
|
|
31
32
|
},
|
|
32
33
|
},
|
|
33
34
|
{
|
|
34
|
-
name: 'test:
|
|
35
|
-
beanOptions: {
|
|
35
|
+
name: 'test:unbound',
|
|
36
|
+
beanOptions: {
|
|
37
|
+
options: { site: [], item: { roles: [] } },
|
|
38
|
+
},
|
|
36
39
|
},
|
|
37
40
|
] as any;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
describe('ssrMenuEligibility.test.ts', () => {
|
|
41
|
-
it('
|
|
44
|
+
it('preserves optional locale matching and scalar or array bindings', () => {
|
|
42
45
|
assert.equal(checkSsrBinding('test:admin', undefined), true);
|
|
43
46
|
assert.equal(checkSsrBinding('test:admin', null), true);
|
|
44
47
|
assert.equal(checkSsrBinding('test:admin', ''), true);
|
|
@@ -64,8 +67,8 @@ describe('ssrMenuEligibility.test.ts', () => {
|
|
|
64
67
|
assert.deepEqual(catalog.menus, [
|
|
65
68
|
{
|
|
66
69
|
ssrSiteName: 'test:admin',
|
|
67
|
-
ssrMenuName: 'test:
|
|
68
|
-
onionName: 'test:
|
|
70
|
+
ssrMenuName: 'test:shared',
|
|
71
|
+
onionName: 'test:shared',
|
|
69
72
|
roles: [],
|
|
70
73
|
},
|
|
71
74
|
{
|
|
@@ -82,8 +85,8 @@ describe('ssrMenuEligibility.test.ts', () => {
|
|
|
82
85
|
},
|
|
83
86
|
{
|
|
84
87
|
ssrSiteName: 'test:web',
|
|
85
|
-
ssrMenuName: 'test:
|
|
86
|
-
onionName: 'test:
|
|
88
|
+
ssrMenuName: 'test:shared',
|
|
89
|
+
onionName: 'test:shared',
|
|
87
90
|
roles: [],
|
|
88
91
|
},
|
|
89
92
|
{
|
|
@@ -103,18 +106,75 @@ describe('ssrMenuEligibility.test.ts', () => {
|
|
|
103
106
|
|
|
104
107
|
it('resolves final leaves independently of locale', () => {
|
|
105
108
|
const menus = createMenus();
|
|
106
|
-
assert.deepEqual(resolveSsrMenuEligibility('test:admin', 'test:
|
|
109
|
+
assert.deepEqual(resolveSsrMenuEligibility('test:admin', 'test:shared', menus), {
|
|
107
110
|
ssrSiteName: 'test:admin',
|
|
108
|
-
ssrMenuName: 'test:
|
|
111
|
+
ssrMenuName: 'test:shared',
|
|
109
112
|
rolesDefined: true,
|
|
110
113
|
});
|
|
111
|
-
assert.deepEqual(resolveSsrMenuEligibility('test:web', 'test:
|
|
114
|
+
assert.deepEqual(resolveSsrMenuEligibility('test:web', 'test:shared', menus), {
|
|
112
115
|
ssrSiteName: 'test:web',
|
|
113
|
-
ssrMenuName: 'test:
|
|
116
|
+
ssrMenuName: 'test:shared',
|
|
114
117
|
rolesDefined: true,
|
|
115
118
|
});
|
|
116
119
|
});
|
|
117
120
|
|
|
121
|
+
it('rejects invalid menu and group sites before catalog partitioning or eligibility lookup', () => {
|
|
122
|
+
const sites = [{ ssrSiteName: 'test:admin', title: 'Admin' }];
|
|
123
|
+
for (const site of [undefined, null, '', ' ', [''], ['test:admin', null]]) {
|
|
124
|
+
const invalid = [{ name: 'test:invalid', beanOptions: { options: { site } } }] as any;
|
|
125
|
+
assert.throws(() => resolveSsrMenuCatalog(sites, invalid), /site for test:invalid/);
|
|
126
|
+
assert.throws(() => resolveSsrMenuCatalog([], [], invalid), /site for test:invalid/);
|
|
127
|
+
assert.throws(
|
|
128
|
+
() =>
|
|
129
|
+
resolveSsrMenuEligibility('test:admin', 'test:shared', [...createMenus(), ...invalid]),
|
|
130
|
+
/site for test:invalid/,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const missing = [{ name: 'test:missingOptions', beanOptions: {} }] as any;
|
|
134
|
+
assert.throws(() => resolveSsrMenuCatalog(sites, missing), /site for test:missingOptions/);
|
|
135
|
+
assert.throws(
|
|
136
|
+
() => resolveSsrMenuEligibility('test:admin', 'test:missing', missing),
|
|
137
|
+
/site for test:missingOptions/,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('partitions scalar and shared groups and accepts empty menu or group site arrays', () => {
|
|
142
|
+
const groups = [
|
|
143
|
+
{ name: 'test:adminGroup', beanOptions: { options: { site: 'test:admin', item: {} } } },
|
|
144
|
+
{
|
|
145
|
+
name: 'test:sharedGroup',
|
|
146
|
+
beanOptions: { options: { site: ['test:admin', 'test:web'], item: {} } },
|
|
147
|
+
},
|
|
148
|
+
{ name: 'test:unboundGroup', beanOptions: { options: { site: [], item: {} } } },
|
|
149
|
+
] as any;
|
|
150
|
+
const catalog = resolveSsrMenuCatalog(
|
|
151
|
+
[
|
|
152
|
+
{ ssrSiteName: 'test:admin', title: 'Admin' },
|
|
153
|
+
{ ssrSiteName: 'test:web', title: 'Web' },
|
|
154
|
+
{ ssrSiteName: 'test:future', title: 'Future' },
|
|
155
|
+
],
|
|
156
|
+
createMenus(),
|
|
157
|
+
groups,
|
|
158
|
+
);
|
|
159
|
+
assert.deepEqual(
|
|
160
|
+
catalog.groups.map(group => [group.ssrSiteName, group.ssrMenuGroupName]),
|
|
161
|
+
[
|
|
162
|
+
['test:admin', 'test:adminGroup'],
|
|
163
|
+
['test:admin', 'test:sharedGroup'],
|
|
164
|
+
['test:web', 'test:sharedGroup'],
|
|
165
|
+
],
|
|
166
|
+
);
|
|
167
|
+
assert.equal(
|
|
168
|
+
catalog.menus.some(menu => menu.ssrMenuName === 'test:unbound'),
|
|
169
|
+
false,
|
|
170
|
+
);
|
|
171
|
+
assert.equal(
|
|
172
|
+
catalog.menus.some(menu => menu.ssrSiteName === 'test:future'),
|
|
173
|
+
false,
|
|
174
|
+
);
|
|
175
|
+
assert.equal(resolveSsrMenuEligibility('test:web', 'test:unbound', createMenus()), undefined);
|
|
176
|
+
});
|
|
177
|
+
|
|
118
178
|
it('distinguishes dynamic/static leaves from public leaves and exact keyed names', () => {
|
|
119
179
|
const menus = createMenus();
|
|
120
180
|
assert.deepEqual(resolveSsrMenuEligibility('test:admin', 'test:keyed#static', menus), {
|
|
@@ -130,6 +190,6 @@ describe('ssrMenuEligibility.test.ts', () => {
|
|
|
130
190
|
assert.equal(resolveSsrMenuEligibility('test:admin', 'test:keyed', menus), undefined);
|
|
131
191
|
assert.equal(resolveSsrMenuEligibility('test:other', 'test:keyed#static', menus), undefined);
|
|
132
192
|
assert.equal(resolveSsrMenuEligibility('test:admin', 'test:missing', menus), undefined);
|
|
133
|
-
assert.equal(resolveSsrMenuEligibility('test:admin', 'test:
|
|
193
|
+
assert.equal(resolveSsrMenuEligibility('test:admin', 'test:unbound', menus), undefined);
|
|
134
194
|
});
|
|
135
195
|
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import type { IDecoratorSsrMenuOptions } from '../src/types/ssrMenu.ts';
|
|
5
|
+
import type { IDecoratorSsrMenuGroupOptions } from '../src/types/ssrMenuGroup.ts';
|
|
6
|
+
import type { IDecoratorSsrSiteOptions } from '../src/types/ssrSite.ts';
|
|
7
|
+
|
|
8
|
+
import { BeanSsrSiteBase } from '../src/lib/beanSsrSiteBase.ts';
|
|
9
|
+
import { SsrMenu } from '../src/lib/ssrMenu.ts';
|
|
10
|
+
import { SsrMenuGroup } from '../src/lib/ssrMenuGroup.ts';
|
|
11
|
+
import { validateSsrMenuSite } from '../src/lib/ssrMenuSite.ts';
|
|
12
|
+
|
|
13
|
+
function checkSiteTypes() {
|
|
14
|
+
// @ts-expect-error SSR menus must explicitly bind a site
|
|
15
|
+
const menu: IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions> = { item: {} };
|
|
16
|
+
// @ts-expect-error SSR menu groups must explicitly bind a site
|
|
17
|
+
const group: IDecoratorSsrMenuGroupOptions<IDecoratorSsrSiteOptions> = { item: {} };
|
|
18
|
+
// @ts-expect-error SSR menu decorator options are required
|
|
19
|
+
SsrMenu();
|
|
20
|
+
// @ts-expect-error SSR menu group decorator options are required
|
|
21
|
+
SsrMenuGroup();
|
|
22
|
+
return { menu, group };
|
|
23
|
+
}
|
|
24
|
+
void checkSiteTypes;
|
|
25
|
+
|
|
26
|
+
function prepareMenus(options: unknown, locale = 'en-us') {
|
|
27
|
+
const owner = {
|
|
28
|
+
$onionName: 'test:admin',
|
|
29
|
+
$scope: { ssr: { service: { ssr: { prepareMenuLink: (link: unknown) => link } } } },
|
|
30
|
+
};
|
|
31
|
+
return (BeanSsrSiteBase.prototype as any)._prepareMenusOrGroups.call(owner, locale, [
|
|
32
|
+
{ name: 'test:menu', beanOptions: { options } },
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('ssrMenuSite.test.ts', () => {
|
|
37
|
+
it('requires explicit nonblank sites in both decorators, including array entries', () => {
|
|
38
|
+
for (const site of [
|
|
39
|
+
undefined,
|
|
40
|
+
null,
|
|
41
|
+
'',
|
|
42
|
+
' \t ',
|
|
43
|
+
1,
|
|
44
|
+
false,
|
|
45
|
+
{},
|
|
46
|
+
[''],
|
|
47
|
+
['test:admin', undefined],
|
|
48
|
+
Array.from({ length: 1 }),
|
|
49
|
+
]) {
|
|
50
|
+
assert.throws(() => validateSsrMenuSite(site, 'test:menu'), /site for test:menu/);
|
|
51
|
+
assert.throws(() => SsrMenu({ site } as any), /site for SsrMenu/);
|
|
52
|
+
assert.throws(() => SsrMenuGroup({ site } as any), /site for SsrMenuGroup/);
|
|
53
|
+
}
|
|
54
|
+
assert.throws(() => SsrMenu(undefined as any), /explicitly specify site/);
|
|
55
|
+
assert.throws(() => SsrMenu({} as any), /explicitly specify site/);
|
|
56
|
+
assert.throws(() => SsrMenuGroup(undefined as any), /explicitly specify site/);
|
|
57
|
+
assert.throws(() => SsrMenuGroup({} as any), /explicitly specify site/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('accepts scalar sites, explicit sharing, and empty arrays without changing their values', () => {
|
|
61
|
+
for (const site of ['test:admin', ['test:admin', 'test:web'], []]) {
|
|
62
|
+
assert.doesNotThrow(() => validateSsrMenuSite(site, 'test:menu'));
|
|
63
|
+
assert.equal(typeof SsrMenu({ site } as any), 'function');
|
|
64
|
+
assert.equal(typeof SsrMenuGroup({ site } as any), 'function');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('validates effective options before rendered menu site or locale filtering', () => {
|
|
69
|
+
for (const site of [undefined, null, '', ' ', ['']]) {
|
|
70
|
+
assert.throws(
|
|
71
|
+
() => prepareMenus({ site, locale: ['zh-cn'], item: {} }),
|
|
72
|
+
/site for test:menu/,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
assert.throws(() => prepareMenus(undefined), /site for test:menu/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('preserves optional locale behavior and renders only explicitly bound menus or groups', () => {
|
|
79
|
+
const expected = [
|
|
80
|
+
{ name: 'test:menu', title: undefined, description: undefined, link: undefined },
|
|
81
|
+
];
|
|
82
|
+
assert.deepEqual(prepareMenus({ site: 'test:admin', item: {} }), expected);
|
|
83
|
+
assert.deepEqual(prepareMenus({ site: ['test:admin', 'test:web'], item: {} }), expected);
|
|
84
|
+
assert.deepEqual(prepareMenus({ site: [], item: {} }), []);
|
|
85
|
+
assert.deepEqual(prepareMenus({ site: 'test:web', item: {} }), []);
|
|
86
|
+
assert.deepEqual(prepareMenus({ site: 'test:admin', locale: ['zh-cn'], item: {} }), []);
|
|
87
|
+
assert.deepEqual(prepareMenus({ site: 'test:admin', locale: ['en-us'], item: {} }), expected);
|
|
88
|
+
assert.deepEqual(prepareMenus({ site: 'test:admin', locale: [], item: {} }), []);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -50,6 +50,15 @@ export function onerror(app: VonaApplication, options?: OnerrorOptions) {
|
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// A disconnected request can become unreadable before the body parser observes its abort.
|
|
54
|
+
if (
|
|
55
|
+
this.req?.aborted &&
|
|
56
|
+
!this.writable &&
|
|
57
|
+
['request.aborted', 'stream.not.readable'].includes(err.type)
|
|
58
|
+
) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
// ignore all padding request stream
|
|
54
63
|
if (this.req) {
|
|
55
64
|
sendToWormhole(this.req);
|