cabloy 5.1.193 → 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 +14 -0
- package/package.json +1 -1
- package/repo-agent-governance/adapters/codex/contract-loop-gate.mjs +1 -1
- package/repo-agent-governance/adapters/cursor/contract-loop-gate.mjs +1 -1
- package/repo-agent-governance/managed-assets.json +2 -2
- package/repo-agent-governance/tests/governance.test.mjs +32 -0
- package/repo-agent-governance/tools/contract-loop/hook-runtime.mjs +4 -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-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
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.194
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.1.194
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- Resolve lint errors in governance contract-loop hooks.
|
|
8
|
+
|
|
9
|
+
### Improvements
|
|
10
|
+
|
|
11
|
+
- Update controller-boundary tests.
|
|
12
|
+
|
|
13
|
+
### Breaking Changes
|
|
14
|
+
|
|
15
|
+
- Require explicit menu site bindings for SSR.
|
|
16
|
+
|
|
3
17
|
## 5.1.193
|
|
4
18
|
|
|
5
19
|
### Features
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ function runCodexHook() {
|
|
|
17
17
|
const evaluated = evaluateCodexEditedFiles(ROOT, extractCodexEditedFiles(payload), payload.cwd);
|
|
18
18
|
if (!evaluated.length) return 0;
|
|
19
19
|
const message = evaluated.map(item => item.message).join('\n\n');
|
|
20
|
-
// eslint-disable-next-line
|
|
20
|
+
// eslint-disable-next-line no-console
|
|
21
21
|
console.log(JSON.stringify(formatCodexHookOutput(message)));
|
|
22
22
|
return 0;
|
|
23
23
|
}
|
|
@@ -19,7 +19,7 @@ function runCursorHook() {
|
|
|
19
19
|
if (!evaluated) return 0;
|
|
20
20
|
const output = formatCursorHookOutput(evaluated.message, inferCursorHookEvent(payload));
|
|
21
21
|
if (output) {
|
|
22
|
-
// eslint-disable-next-line
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
23
|
console.log(JSON.stringify(output));
|
|
24
24
|
}
|
|
25
25
|
return 0;
|
|
@@ -592,7 +592,7 @@
|
|
|
592
592
|
{
|
|
593
593
|
"adapter": "codex",
|
|
594
594
|
"category": "runtime",
|
|
595
|
-
"sha256": "
|
|
595
|
+
"sha256": "7d7bc4472cd7fdfa2a34193117a771445b3b2ac6fddb553c64050f7ae9cf01cc",
|
|
596
596
|
"source": "adapters/codex/contract-loop-gate.mjs",
|
|
597
597
|
"target": ".codex/hooks/contract-loop-gate.mjs"
|
|
598
598
|
},
|
|
@@ -606,7 +606,7 @@
|
|
|
606
606
|
{
|
|
607
607
|
"adapter": "cursor",
|
|
608
608
|
"category": "runtime",
|
|
609
|
-
"sha256": "
|
|
609
|
+
"sha256": "14ec4e6aaf57cd8cdc21b42b8ff865810d79a82f0927a4445b756e41c0e2438a",
|
|
610
610
|
"source": "adapters/cursor/contract-loop-gate.mjs",
|
|
611
611
|
"target": ".cursor/hooks/contract-loop-gate.mjs"
|
|
612
612
|
},
|
|
@@ -367,6 +367,38 @@ test('contract-loop recognizes supported vendor and suite source paths', () => {
|
|
|
367
367
|
assert.equal(isCodeFile('/repo/repo-docs/ai/agent-governance.md'), false);
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
+
test('Codex patch headers preserve operations, moves, and paths with whitespace', () => {
|
|
371
|
+
const command = [
|
|
372
|
+
'*** Begin Patch',
|
|
373
|
+
'*** Add File:\t vona/src/module/demo/src/dto/new item.ts ',
|
|
374
|
+
'*** Update File: vona/src/module/demo/src/service/old.ts',
|
|
375
|
+
'*** Move to:\t vona/src/module/demo/src/controller/moved item.ts \t',
|
|
376
|
+
'@@',
|
|
377
|
+
'+*** Delete File: not-a-header.ts',
|
|
378
|
+
'*** Delete File: vona/src/module/demo/src/dto/deleted.ts',
|
|
379
|
+
'*** End Patch',
|
|
380
|
+
].join('\r\n');
|
|
381
|
+
assert.deepEqual(extractCodexEditedFiles({ tool_input: { command } }), [
|
|
382
|
+
{ operation: 'add', filePath: 'vona/src/module/demo/src/dto/new item.ts' },
|
|
383
|
+
{ operation: 'update', filePath: 'vona/src/module/demo/src/controller/moved item.ts' },
|
|
384
|
+
{ operation: 'delete', filePath: 'vona/src/module/demo/src/dto/deleted.ts' },
|
|
385
|
+
]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('Codex blank file headers do not consume subsequent patch lines', () => {
|
|
389
|
+
const command = [
|
|
390
|
+
'*** Add File:',
|
|
391
|
+
`*** Delete File:${' '.repeat(100_000)}`,
|
|
392
|
+
'*** Update File:\t ',
|
|
393
|
+
'*** Add File: vona/src/module/demo/src/dto/valid.ts',
|
|
394
|
+
].join('\n');
|
|
395
|
+
assert.deepEqual(extractCodexEditedFiles({ tool_input: { command } }), [
|
|
396
|
+
{ operation: 'add', filePath: 'vona/src/module/demo/src/dto/valid.ts' },
|
|
397
|
+
]);
|
|
398
|
+
assert.deepEqual(extractCodexEditedFiles({}), []);
|
|
399
|
+
assert.deepEqual(extractCodexEditedFiles({ tool_input: { command: null } }), []);
|
|
400
|
+
});
|
|
401
|
+
|
|
370
402
|
test('Codex contract-loop adapter evaluates operation-aware apply_patch paths', () => {
|
|
371
403
|
withFixture(root => {
|
|
372
404
|
const sourcePath = resolve(root, 'vona/src/module/demo/src/controller/demo.ts');
|
|
@@ -176,15 +176,16 @@ export function extractCodexEditedFiles(payload) {
|
|
|
176
176
|
if (typeof command !== 'string') return [];
|
|
177
177
|
const entries = [];
|
|
178
178
|
let pendingUpdate = null;
|
|
179
|
-
const headerPattern =
|
|
180
|
-
/^\*\*\* (Add|Update|Delete) File:\s*(.+?)\s*$|^\*\*\* Move to:\s*(.+?)\s*$/gm;
|
|
179
|
+
const headerPattern = /^\*\*\* (Add|Update|Delete) File:(.+)$|^\*\*\* Move to:(.+)$/;
|
|
181
180
|
|
|
182
181
|
const flushUpdate = () => {
|
|
183
182
|
if (pendingUpdate) entries.push(pendingUpdate);
|
|
184
183
|
pendingUpdate = null;
|
|
185
184
|
};
|
|
186
185
|
|
|
187
|
-
for (const
|
|
186
|
+
for (const line of command.split(/\r?\n/)) {
|
|
187
|
+
const match = headerPattern.exec(line);
|
|
188
|
+
if (!match) continue;
|
|
188
189
|
const [, verb, sourcePath, destinationPath] = match;
|
|
189
190
|
if (destinationPath !== undefined) {
|
|
190
191
|
if (pendingUpdate) pendingUpdate.filePath = destinationPath.trim();
|
|
@@ -57,9 +57,16 @@ In the current repo implementation, the out-of-the-box menu controller is public
|
|
|
57
57
|
|
|
58
58
|
## SSR Site targeting
|
|
59
59
|
|
|
60
|
-
`@SsrMenu(...)` and `@SsrMenuGroup(...)`
|
|
60
|
+
`@SsrMenu(...)` and `@SsrMenuGroup(...)` require an explicit `site` to select the SSR Site onion(s) that receive the declaration. It is a navigation-composition filter, not a site-admission, role-visibility, or backend-authorization decision.
|
|
61
61
|
|
|
62
|
-
Use
|
|
62
|
+
- Use a site string or an array of site strings to target known SSR Sites.
|
|
63
|
+
- Omission, `undefined`, `null`, and empty or whitespace-only strings are invalid, including blank entries in an array.
|
|
64
|
+
- `site: []` is valid and explicitly means no sites receive the declaration.
|
|
65
|
+
- Sharing requires an explicit array of intended sites; there is no wildcard or implicit all-sites scope.
|
|
66
|
+
- Menu and group declarations are independently scoped. A menu does not inherit its group's `site`; declare the intended sites on both.
|
|
67
|
+
- `locale` remains optional: omitting it leaves the declaration unrestricted by locale.
|
|
68
|
+
|
|
69
|
+
Use a single site string for navigation owned by one SSR Site:
|
|
63
70
|
|
|
64
71
|
```typescript
|
|
65
72
|
@SsrMenu({
|
|
@@ -69,11 +76,11 @@ Use `site` for navigation owned by one SSR Site or deliberately exclusive to a k
|
|
|
69
76
|
roles: ['systemAdmin'],
|
|
70
77
|
},
|
|
71
78
|
// This Basic Admin-owned entry intentionally targets one SSR Site.
|
|
72
|
-
site:
|
|
79
|
+
site: 'basic-siteadmin:admin',
|
|
73
80
|
})
|
|
74
81
|
```
|
|
75
82
|
|
|
76
|
-
For a shared module declaration
|
|
83
|
+
For a shared module declaration, list every intended compatible SSR Admin site explicitly:
|
|
77
84
|
|
|
78
85
|
```typescript
|
|
79
86
|
@SsrMenu({
|
|
@@ -82,11 +89,15 @@ For a shared module declaration intended for every compatible SSR Admin site, om
|
|
|
82
89
|
link: 'presetResource',
|
|
83
90
|
roles: ['systemAdmin'],
|
|
84
91
|
},
|
|
85
|
-
//
|
|
92
|
+
// Share only with these explicitly selected SSR Sites.
|
|
93
|
+
site: ['basic-siteadmin:admin', 'commerce-siteadmin:commerceAdmin'],
|
|
86
94
|
})
|
|
87
95
|
```
|
|
88
96
|
|
|
89
|
-
|
|
97
|
+
Choose the site list from the intended navigation contract, not merely the current/default Admin site. Isolate an independent site through its enabled module composition and site-owned menus/groups. When a shared capability needs genuinely different navigation contracts per site, use an explicit, approved site-specific composition or owner.
|
|
98
|
+
|
|
99
|
+
> [!WARNING]
|
|
100
|
+
> This is a breaking change for declarations that previously omitted `site` to reach all sites. Migrate each menu and group to an explicit site string or array, or use `site: []` when it should reach no sites. Add future shared sites to the array deliberately; omission is no longer a sharing mechanism.
|
|
90
101
|
|
|
91
102
|
`site` is distinct from a role's `siteIds`, menu `roles` or role-menu associations, and Passport/RBAC authorization:
|
|
92
103
|
|
|
@@ -140,6 +151,6 @@ When editing SSR menu behavior, ask:
|
|
|
140
151
|
2. is there a default fallback menu that should remain available?
|
|
141
152
|
3. does the menu contract belong in backend API design, frontend route design, or both?
|
|
142
153
|
4. does the active edition affect the menu structure or public path assumptions?
|
|
143
|
-
5.
|
|
154
|
+
5. does every menu and group declare its own explicit `site` string or array, with all intended shared sites listed and `[]` used only when no sites should receive it?
|
|
144
155
|
|
|
145
156
|
That helps AI keep menu behavior aligned with Cabloy’s shared SSR architecture.
|
|
@@ -2,13 +2,17 @@ import type { ChildProcess } from 'node:child_process';
|
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
|
|
5
|
-
import { E2E_ROOT_DIR } from './e2e.ts';
|
|
5
|
+
import { E2E_LOCAL_BASE_URL, E2E_ROOT_DIR } from './e2e.ts';
|
|
6
6
|
|
|
7
7
|
const child: ChildProcess = spawn('npm', ['run', 'dev:one'], {
|
|
8
8
|
cwd: E2E_ROOT_DIR,
|
|
9
9
|
detached: process.platform !== 'win32',
|
|
10
10
|
stdio: 'inherit',
|
|
11
|
-
env:
|
|
11
|
+
env: {
|
|
12
|
+
...process.env,
|
|
13
|
+
SERVER_SERVE_PROTOCOL: 'http',
|
|
14
|
+
SERVER_SERVE_HOST: E2E_LOCAL_BASE_URL.replace(/^https?:\/\//, ''),
|
|
15
|
+
},
|
|
12
16
|
});
|
|
13
17
|
|
|
14
18
|
const gracefulShutdownTimeout = 7000;
|
|
@@ -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);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { VonaApplication } from 'vona';
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert';
|
|
4
|
+
import { PassThrough } from 'node:stream';
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { onerror } from '../src/lib/onerror.ts';
|
|
8
|
+
|
|
9
|
+
function createFixture(aborted: boolean, writable: boolean) {
|
|
10
|
+
const logged: Error[] = [];
|
|
11
|
+
const responses: unknown[] = [];
|
|
12
|
+
const app = { context: {} } as VonaApplication;
|
|
13
|
+
const options = {
|
|
14
|
+
all(_err: Error, ctx: any) {
|
|
15
|
+
ctx.body = 'error response';
|
|
16
|
+
},
|
|
17
|
+
log(err: Error) {
|
|
18
|
+
logged.push(err);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
onerror(app, options);
|
|
22
|
+
const req = Object.assign(new PassThrough(), { aborted });
|
|
23
|
+
req.destroy();
|
|
24
|
+
const ctx = Object.assign(Object.create(app.context), {
|
|
25
|
+
req,
|
|
26
|
+
writable,
|
|
27
|
+
headerSent: false,
|
|
28
|
+
res: {
|
|
29
|
+
end(body: unknown) {
|
|
30
|
+
responses.push(body);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
set() {},
|
|
34
|
+
accepts() {
|
|
35
|
+
return 'text';
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return { ctx, logged, responses };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createBodyError(type: string, status: number) {
|
|
42
|
+
return Object.assign(new Error(type), { type, status });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('onerror.test.ts', () => {
|
|
46
|
+
for (const [type, status] of [
|
|
47
|
+
['stream.not.readable', 500],
|
|
48
|
+
['request.aborted', 400],
|
|
49
|
+
] as const) {
|
|
50
|
+
it(`ignores ${type} only for an aborted, unwritable request`, async () => {
|
|
51
|
+
const { ctx, logged, responses } = createFixture(true, false);
|
|
52
|
+
await ctx.onerror(createBodyError(type, status));
|
|
53
|
+
assert.deepEqual(logged, []);
|
|
54
|
+
assert.deepEqual(responses, []);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it(`keeps ${type} visible without confirmed request abortion`, async () => {
|
|
58
|
+
const { ctx, logged, responses } = createFixture(false, false);
|
|
59
|
+
const error = createBodyError(type, status);
|
|
60
|
+
await ctx.onerror(error);
|
|
61
|
+
assert.deepEqual(logged, [error]);
|
|
62
|
+
assert.equal(Reflect.get(error, 'headerSent'), true);
|
|
63
|
+
assert.deepEqual(responses, []);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it(`preserves ${type} handling while the response is writable`, async () => {
|
|
67
|
+
const { ctx, logged, responses } = createFixture(true, true);
|
|
68
|
+
const error = createBodyError(type, status);
|
|
69
|
+
await ctx.onerror(error);
|
|
70
|
+
assert.deepEqual(logged, [error]);
|
|
71
|
+
assert.equal(ctx.status, status);
|
|
72
|
+
assert.deepEqual(responses, ['error response']);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('does not hide unrelated application errors on aborted requests', async () => {
|
|
77
|
+
const { ctx, logged, responses } = createFixture(true, false);
|
|
78
|
+
const error = new Error('application failure');
|
|
79
|
+
await ctx.onerror(error);
|
|
80
|
+
assert.deepEqual(logged, [error]);
|
|
81
|
+
assert.deepEqual(responses, []);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('preserves body parsing errors on a writable request', async () => {
|
|
85
|
+
const { ctx, logged, responses } = createFixture(false, true);
|
|
86
|
+
const error = createBodyError('entity.parse.failed', 400);
|
|
87
|
+
await ctx.onerror(error);
|
|
88
|
+
assert.deepEqual(logged, [error]);
|
|
89
|
+
assert.equal(ctx.status, 400);
|
|
90
|
+
assert.deepEqual(responses, ['error response']);
|
|
91
|
+
});
|
|
92
|
+
});
|