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.
Files changed (47) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/package.json +1 -1
  4. package/repo-agent-governance/adapters/claude/contract-loop-gate.ts +15 -187
  5. package/repo-agent-governance/adapters/codex/contract-loop-gate.mjs +25 -0
  6. package/repo-agent-governance/adapters/codex/hooks.json +19 -0
  7. package/repo-agent-governance/adapters/cursor/contract-loop-gate.mjs +28 -0
  8. package/repo-agent-governance/adapters/cursor/hooks.json +19 -0
  9. package/repo-agent-governance/managed-assets.json +38 -10
  10. package/repo-agent-governance/manifest.json +24 -0
  11. package/repo-agent-governance/policies/repository.md +1 -1
  12. package/repo-agent-governance/scripts/governance.mjs +101 -2
  13. package/repo-agent-governance/scripts/pack-check.mjs +5 -0
  14. package/repo-agent-governance/skills/cabloy-contract-loop/SKILL.md +9 -2
  15. package/repo-agent-governance/skills/cabloy-contract-loop/references/verification-checklist.md +5 -3
  16. package/repo-agent-governance/tests/governance.test.mjs +306 -0
  17. package/repo-agent-governance/tools/contract-loop/core.mjs +30 -14
  18. package/repo-agent-governance/tools/contract-loop/hook-runtime.mjs +368 -0
  19. package/repo-docs/ai/agent-governance.md +8 -6
  20. package/repo-docs/ai/docs-skills-rules-mapping.md +4 -1
  21. package/repo-docs/ai/edition-consistency-checklist.md +1 -1
  22. package/repo-docs/ai/introduction.md +1 -1
  23. package/repo-docs/ai/repo-guidance.md +1 -1
  24. package/repo-docs/ai/rules-and-config.md +5 -3
  25. package/repo-docs/backend/menu-authorization.md +1 -0
  26. package/repo-docs/backend/menu-guide.md +18 -7
  27. package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +1 -0
  28. package/repo-docs/reference/package-map.md +1 -1
  29. package/repo-e2e/config/playwright.config.ts +1 -1
  30. package/repo-e2e/scripts/startE2eVona.ts +6 -2
  31. package/repo-e2e/specs/controller-boundary.spec.ts +76 -28
  32. package/vona/packages-vona/vona/package.json +1 -1
  33. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
  34. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/beanSsrSiteBase.ts +2 -0
  35. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenu.ts +9 -3
  36. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuEligibility.ts +14 -0
  37. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuGroup.ts +9 -3
  38. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuSite.ts +10 -0
  39. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/types/ssrMenu.ts +1 -1
  40. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/types/ssrMenuGroup.ts +1 -1
  41. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/test/ssrMenuEligibility.test.ts +73 -13
  42. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/test/ssrMenuSite.test.ts +90 -0
  43. package/vona/src/suite-vendor/a-cabloy/package.json +1 -1
  44. package/vona/src/suite-vendor/a-vona/modules/a-error/package.json +1 -1
  45. package/vona/src/suite-vendor/a-vona/modules/a-error/src/lib/onerror.ts +9 -0
  46. package/vona/src/suite-vendor/a-vona/modules/a-error/test/onerror.test.ts +92 -0
  47. 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
- await page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click();
54
- await page.waitForTimeout(400);
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 page.getByRole('button', { name: 'Mount fast failing probe', exact: true }).click();
60
- await page.waitForTimeout(400);
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
- const delayedSuccessLoading = loading.waitFor({ state: 'visible' });
66
- await page.getByRole('button', { name: 'Mount delayed success probe', exact: true }).click();
67
- await delayedSuccessLoading;
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
- const failingProbeLoading = loading.waitFor({ state: 'visible' });
73
- await page.getByRole('button', { name: 'Mount failing probe', exact: true }).click();
74
- await failingProbeLoading;
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
- const failedRetryLoading = loading.waitFor({ state: 'visible' });
83
- await retry.click();
84
- await failedRetryLoading;
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
- const successfulRetryLoading = loading.waitFor({ state: 'visible' });
90
- await retry.evaluate(button => {
91
- button.click();
92
- button.click();
93
- });
94
- await successfulRetryLoading;
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 disposedLoading = loading.waitFor({ state: 'visible' });
120
- await page.getByRole('button', { name: 'Mount failing probe', exact: true }).click();
121
- await disposedLoading;
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.waitForTimeout(1_000);
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
- const staleLoading = loading.waitFor({ state: 'visible' });
129
- await page.getByRole('button', { name: 'Mount failing probe', exact: true }).click();
130
- await staleLoading;
131
- await page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click();
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.waitForTimeout(1_000);
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.101",
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": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-ssr",
3
- "version": "5.1.33",
3
+ "version": "5.1.34",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
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
- export function SsrMenu<T extends IDecoratorSsrMenuOptions<any>>(options?: T): ClassDecorator {
6
- return createBeanDecorator('ssrMenu', options);
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?: T,
8
+ options: T,
7
9
  ): ClassDecorator {
8
- return createBeanDecorator('ssrMenuGroup', options);
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?: keyof ISsrSiteRecord | (keyof ISsrSiteRecord)[];
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?: keyof ISsrSiteRecord | (keyof ISsrSiteRecord)[];
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:omitted',
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:malformed',
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('matches unset, scalar, or array bindings', () => {
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:omitted',
68
- onionName: 'test:omitted',
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:omitted',
86
- onionName: 'test:omitted',
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:omitted', menus), {
109
+ assert.deepEqual(resolveSsrMenuEligibility('test:admin', 'test:shared', menus), {
107
110
  ssrSiteName: 'test:admin',
108
- ssrMenuName: 'test:omitted',
111
+ ssrMenuName: 'test:shared',
109
112
  rolesDefined: true,
110
113
  });
111
- assert.deepEqual(resolveSsrMenuEligibility('test:web', 'test:omitted', menus), {
114
+ assert.deepEqual(resolveSsrMenuEligibility('test:web', 'test:shared', menus), {
112
115
  ssrSiteName: 'test:web',
113
- ssrMenuName: 'test:omitted',
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:malformed', menus), undefined);
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
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-cabloy",
3
- "version": "5.1.57",
3
+ "version": "5.1.58",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-error",
3
- "version": "5.1.13",
3
+ "version": "5.1.14",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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);