cabloy 5.1.154 → 5.1.156
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/.claude/scheduled_tasks.lock +1 -0
- package/.claude/skills/cabloy-contract-loop/SKILL.md +1 -1
- package/.claude/skills/cabloy-spec-generation/SKILL.md +1 -1
- package/.claude/skills/cabloy-workflow/SKILL.md +3 -3
- package/CHANGELOG.md +19 -0
- package/CLAUDE.md +1 -1
- package/README.md +18 -18
- package/package.json +1 -1
- package/repo-docs/.vitepress/public/CNAME +1 -1
- package/repo-docs/ai/edition-consistency-checklist.md +7 -7
- package/repo-docs/backend/quickstart.md +1 -1
- package/repo-docs/blogs/ai-react-nextjs-enterprise-architecture-cabloy/index.md +8 -8
- package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +13 -13
- package/repo-docs/blogs/vue-object-oriented-zova-beginner-mental-model/index.md +6 -6
- package/repo-docs/editions/cabloy-start.md +10 -12
- package/repo-docs/editions/choosing-between-basic-and-start.md +6 -7
- package/repo-docs/editions/detection.md +1 -1
- package/repo-docs/editions/overview.md +26 -14
- package/repo-docs/frontend/foundation.md +1 -1
- package/repo-docs/frontend/introduction.md +1 -1
- package/repo-docs/frontend/scripts.md +2 -2
- package/repo-docs/fullstack/deploy-cloudflare-docker.md +1 -1
- package/repo-docs/fullstack/edition-collaboration-differences.md +2 -2
- package/repo-docs/fullstack/introduction.md +7 -3
- package/repo-docs/fullstack/openapi-to-sdk.md +1 -1
- package/repo-docs/fullstack/quickstart.md +4 -4
- package/repo-docs/fullstack/vona-zova-integration.md +1 -1
- package/repo-docs/index.md +1 -1
- package/repo-docs/reference/glossary.md +8 -5
- package/repo-docs/reference/introduction.md +1 -1
- package/repo-docs/reference/repo-scripts.md +12 -4
- package/test-results/.last-run.json +2 -4
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-shared/package.json +4 -4
- package/vona/pnpm-lock.yaml +8 -3
- package/vona/src/suite/a-home/modules/home-user/src/bean/meta.redlock.ts +2 -1
- package/vona/src/suite/a-home/modules/home-user/src/service/roleAdapter.ts +115 -6
- package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +163 -2
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/.metadata/index.ts +26 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/eventListener.roleMembershipChanged.ts +25 -0
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-queue/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-queue/src/lib/beanQueueBase.ts +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-queue/src/service/queue.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/.metadata/index.ts +15 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/bean.role.ts +44 -3
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/event.roleMembershipChanged.ts +16 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/role.ts +12 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/pnpm-lock.yaml +1 -1
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/lib/resourceRouteMeta.ts +9 -0
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/routes.ts +4 -10
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/test/lib/routes.test.ts +37 -0
- package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
- package/test-results/cabloy-basic-ATP-BASIC-SUM-58d8a-ry-states-and-Markdown-HTML/error-context.md +0 -195
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-suite-a-cabloy",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.49",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "",
|
|
6
6
|
"author": "",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"vona-module-a-cabloy": "workspace:^",
|
|
11
11
|
"vona-module-a-markdown": "^5.0.3",
|
|
12
|
-
"vona-module-a-rbac": "^5.0.
|
|
12
|
+
"vona-module-a-rbac": "^5.0.3",
|
|
13
13
|
"vona-module-a-socket": "workspace:^",
|
|
14
14
|
"vona-module-a-ssr": "workspace:^",
|
|
15
15
|
"vona-module-a-ssrhmr": "workspace:^"
|
|
@@ -11,7 +11,7 @@ export class BeanQueueBase<DATA = unknown, RESULT = unknown> extends BeanBase {
|
|
|
11
11
|
);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
push(data: DATA, options?: IQueuePushOptions) {
|
|
14
|
+
push(data: DATA, options?: IQueuePushOptions): Promise<void> {
|
|
15
15
|
return this.$scope.queue.service.queue.push(
|
|
16
16
|
this.$scope.queue.service.queue.prepareJobInfo(this.$onionName as any, data, options),
|
|
17
17
|
);
|
|
@@ -24,9 +24,9 @@ export class ServiceQueue extends BeanBase {
|
|
|
24
24
|
_queues: IQueueQueues = {};
|
|
25
25
|
_queueCallbacks: IQueueCallbacks = {};
|
|
26
26
|
|
|
27
|
-
push<DATA>(info: IQueueJobContext<DATA>) {
|
|
27
|
+
push<DATA>(info: IQueueJobContext<DATA>): Promise<void> {
|
|
28
28
|
if (!info.options?.dbInfo) throw new Error('should specify the options.dbInfo');
|
|
29
|
-
this._queuePush(info, false);
|
|
29
|
+
return this._queuePush(info, false);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// { locale, instanceName, module, queueName,queueNameSub,data }
|
|
@@ -174,6 +174,7 @@ export interface IModuleCacheRedis {
|
|
|
174
174
|
export * from '../bean/event.activate.ts';
|
|
175
175
|
export * from '../bean/event.createAnonymous.ts';
|
|
176
176
|
export * from '../bean/event.register.ts';
|
|
177
|
+
export * from '../bean/event.roleMembershipChanged.ts';
|
|
177
178
|
export * from '../bean/event.signin.ts';
|
|
178
179
|
export * from '../bean/event.signout.ts';
|
|
179
180
|
|
|
@@ -214,6 +215,16 @@ declare module 'vona-module-a-user' {
|
|
|
214
215
|
get $onionName(): 'a-user:register';
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
export interface EventRoleMembershipChanged {
|
|
219
|
+
/** @internal */
|
|
220
|
+
get scope(): ScopeModuleAUser;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface EventRoleMembershipChanged {
|
|
224
|
+
get $beanFullName(): 'a-user.event.roleMembershipChanged';
|
|
225
|
+
get $onionName(): 'a-user:roleMembershipChanged';
|
|
226
|
+
}
|
|
227
|
+
|
|
217
228
|
export interface EventSignin {
|
|
218
229
|
/** @internal */
|
|
219
230
|
get scope(): ScopeModuleAUser;
|
|
@@ -239,12 +250,14 @@ declare module 'vona-module-a-user' {
|
|
|
239
250
|
import type { EventActivate } from '../bean/event.activate.ts';
|
|
240
251
|
import type { EventCreateAnonymous } from '../bean/event.createAnonymous.ts';
|
|
241
252
|
import type { EventRegister } from '../bean/event.register.ts';
|
|
253
|
+
import type { EventRoleMembershipChanged } from '../bean/event.roleMembershipChanged.ts';
|
|
242
254
|
import type { EventSignin } from '../bean/event.signin.ts';
|
|
243
255
|
import type { EventSignout } from '../bean/event.signout.ts';
|
|
244
256
|
export interface IModuleEvent {
|
|
245
257
|
'activate': EventActivate;
|
|
246
258
|
'createAnonymous': EventCreateAnonymous;
|
|
247
259
|
'register': EventRegister;
|
|
260
|
+
'roleMembershipChanged': EventRoleMembershipChanged;
|
|
248
261
|
'signin': EventSignin;
|
|
249
262
|
'signout': EventSignout;
|
|
250
263
|
}
|
|
@@ -253,6 +266,7 @@ export interface IModuleEvent {
|
|
|
253
266
|
import type { TypeEventActivateData, TypeEventActivateResult } from '../bean/event.activate.ts';
|
|
254
267
|
import type { TypeEventCreateAnonymousData, TypeEventCreateAnonymousResult } from '../bean/event.createAnonymous.ts';
|
|
255
268
|
import type { TypeEventRegisterData, TypeEventRegisterResult } from '../bean/event.register.ts';
|
|
269
|
+
import type { TypeEventRoleMembershipChangedData, TypeEventRoleMembershipChangedResult } from '../bean/event.roleMembershipChanged.ts';
|
|
256
270
|
import type { TypeEventSigninData, TypeEventSigninResult } from '../bean/event.signin.ts';
|
|
257
271
|
import type { TypeEventSignoutData, TypeEventSignoutResult } from '../bean/event.signout.ts';
|
|
258
272
|
import type { EventOn } from 'vona-module-a-event';
|
|
@@ -261,6 +275,7 @@ declare module 'vona-module-a-event' {
|
|
|
261
275
|
'a-user:activate': EventOn<TypeEventActivateData, TypeEventActivateResult>;
|
|
262
276
|
'a-user:createAnonymous': EventOn<TypeEventCreateAnonymousData, TypeEventCreateAnonymousResult>;
|
|
263
277
|
'a-user:register': EventOn<TypeEventRegisterData, TypeEventRegisterResult>;
|
|
278
|
+
'a-user:roleMembershipChanged': EventOn<TypeEventRoleMembershipChangedData, TypeEventRoleMembershipChangedResult>;
|
|
264
279
|
'a-user:signin': EventOn<TypeEventSigninData, TypeEventSigninResult>;
|
|
265
280
|
'a-user:signout': EventOn<TypeEventSignoutData, TypeEventSignoutResult>;
|
|
266
281
|
}
|
|
@@ -2,8 +2,9 @@ import type { TableIdentity } from 'table-identity';
|
|
|
2
2
|
|
|
3
3
|
import { BeanBase, beanFullNameFromOnionName } from 'vona';
|
|
4
4
|
import { Bean } from 'vona-module-a-bean';
|
|
5
|
+
import { Core } from 'vona-module-a-core';
|
|
5
6
|
|
|
6
|
-
import type { IRole, IRoleAdapter } from '../types/role.ts';
|
|
7
|
+
import type { IRole, IRoleAdapter, IRoleMembershipReplaceResult } from '../types/role.ts';
|
|
7
8
|
|
|
8
9
|
@Bean()
|
|
9
10
|
export class BeanRole extends BeanBase {
|
|
@@ -33,7 +34,47 @@ export class BeanRole extends BeanBase {
|
|
|
33
34
|
return this.roleAdapter.findAllByUserId(userId);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
@Core.transaction()
|
|
38
|
+
async addUserId(id: TableIdentity, userId: TableIdentity): Promise<boolean> {
|
|
39
|
+
const changed = await this.roleAdapter.addUserId(id, userId);
|
|
40
|
+
if (changed) await this.emitMembershipChanged(userId, [id]);
|
|
41
|
+
return changed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Core.transaction()
|
|
45
|
+
async removeUserId(id: TableIdentity, userId: TableIdentity): Promise<boolean> {
|
|
46
|
+
const changed = await this.roleAdapter.removeUserId(id, userId);
|
|
47
|
+
if (changed) await this.emitMembershipChanged(userId, [id]);
|
|
48
|
+
return changed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Core.transaction()
|
|
52
|
+
async replaceUserRoleIds(
|
|
53
|
+
userId: TableIdentity,
|
|
54
|
+
roleIds: TableIdentity[],
|
|
55
|
+
options?: { preserveRoleIds?: TableIdentity[] },
|
|
56
|
+
): Promise<IRoleMembershipReplaceResult> {
|
|
57
|
+
const result = await this.roleAdapter.replaceUserRoleIds(userId, roleIds, options);
|
|
58
|
+
const changedRoleIds = [...result.addedRoleIds, ...result.removedRoleIds];
|
|
59
|
+
if (changedRoleIds.length) await this.emitMembershipChanged(userId, changedRoleIds);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async emitMembershipChanged(
|
|
64
|
+
userId: TableIdentity,
|
|
65
|
+
roleIds: TableIdentity[],
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
await this.scope.event.roleMembershipChanged.emit({
|
|
68
|
+
userIds: this.uniqueIds([userId]),
|
|
69
|
+
roleIds: this.uniqueIds(roleIds),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private uniqueIds(ids: TableIdentity[]): TableIdentity[] {
|
|
74
|
+
const values = new Map<string, TableIdentity>();
|
|
75
|
+
for (const id of ids) values.set(String(id), id);
|
|
76
|
+
return [...values.entries()]
|
|
77
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
78
|
+
.map(([, id]) => id);
|
|
38
79
|
}
|
|
39
80
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TableIdentity } from 'table-identity';
|
|
2
|
+
|
|
3
|
+
import { BeanEventBase, Event } from 'vona-module-a-event';
|
|
4
|
+
|
|
5
|
+
export interface TypeEventRoleMembershipChangedData {
|
|
6
|
+
userIds: TableIdentity[];
|
|
7
|
+
roleIds: TableIdentity[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type TypeEventRoleMembershipChangedResult = void;
|
|
11
|
+
|
|
12
|
+
@Event()
|
|
13
|
+
export class EventRoleMembershipChanged extends BeanEventBase<
|
|
14
|
+
TypeEventRoleMembershipChangedData,
|
|
15
|
+
TypeEventRoleMembershipChangedResult
|
|
16
|
+
> {}
|
|
@@ -18,9 +18,20 @@ export interface IRole {
|
|
|
18
18
|
builtin?: boolean;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface IRoleMembershipReplaceResult {
|
|
22
|
+
addedRoleIds: TableIdentity[];
|
|
23
|
+
removedRoleIds: TableIdentity[];
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
export interface IRoleAdapter {
|
|
22
27
|
findOneByName(name: string): Promise<IRole | undefined>;
|
|
23
28
|
findOne(role: Partial<IRole>): Promise<IRole | undefined>;
|
|
24
29
|
findAllByUserId(userId: TableIdentity): Promise<IRole[] | undefined>;
|
|
25
|
-
addUserId(id: TableIdentity, userId: TableIdentity): Promise<
|
|
30
|
+
addUserId(id: TableIdentity, userId: TableIdentity): Promise<boolean>;
|
|
31
|
+
removeUserId(id: TableIdentity, userId: TableIdentity): Promise<boolean>;
|
|
32
|
+
replaceUserRoleIds(
|
|
33
|
+
userId: TableIdentity,
|
|
34
|
+
roleIds: TableIdentity[],
|
|
35
|
+
options?: { preserveRoleIds?: TableIdentity[] },
|
|
36
|
+
): Promise<IRoleMembershipReplaceResult>;
|
|
26
37
|
}
|
package/zova/pnpm-lock.yaml
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const resourceRouteMeta = {
|
|
2
|
+
tabKey: resourceTabKey,
|
|
3
|
+
componentKey: resourceTabKey,
|
|
4
|
+
ssrProfile: 'session' as const,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function resourceTabKey(route: { params: Record<string, string | string[]> }) {
|
|
8
|
+
return `/rest/resource/${encodeURIComponent(route.params.resource as string)}`;
|
|
9
|
+
}
|
|
@@ -3,23 +3,21 @@ import type { IModuleRoute } from 'zova-module-a-router';
|
|
|
3
3
|
import { ZPageEntry } from './.metadata/page/entry.js';
|
|
4
4
|
import { ZPageEntryCreate } from './.metadata/page/entryCreate.js';
|
|
5
5
|
import { ZPageResource } from './.metadata/page/resource.js';
|
|
6
|
+
import { resourceRouteMeta, resourceTabKey } from './lib/resourceRouteMeta.js';
|
|
6
7
|
|
|
7
8
|
export const routes: IModuleRoute[] = [
|
|
8
9
|
{
|
|
9
10
|
name: 'resource',
|
|
10
11
|
path: ':resource',
|
|
11
12
|
component: ZPageResource,
|
|
12
|
-
meta:
|
|
13
|
-
tabKey,
|
|
14
|
-
ssrProfile: 'session',
|
|
15
|
-
},
|
|
13
|
+
meta: resourceRouteMeta,
|
|
16
14
|
},
|
|
17
15
|
{
|
|
18
16
|
name: 'entryCreate',
|
|
19
17
|
path: ':resource/create',
|
|
20
18
|
component: ZPageEntryCreate,
|
|
21
19
|
meta: {
|
|
22
|
-
tabKey,
|
|
20
|
+
tabKey: resourceTabKey,
|
|
23
21
|
ssrProfile: 'session',
|
|
24
22
|
},
|
|
25
23
|
},
|
|
@@ -28,12 +26,8 @@ export const routes: IModuleRoute[] = [
|
|
|
28
26
|
path: ':resource/:id/:formScene?',
|
|
29
27
|
component: ZPageEntry,
|
|
30
28
|
meta: {
|
|
31
|
-
tabKey,
|
|
29
|
+
tabKey: resourceTabKey,
|
|
32
30
|
ssrProfile: 'session',
|
|
33
31
|
},
|
|
34
32
|
},
|
|
35
33
|
];
|
|
36
|
-
|
|
37
|
-
function tabKey(route) {
|
|
38
|
-
return `/rest/resource/${encodeURIComponent(route.params.resource)}`;
|
|
39
|
-
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { resourceRouteMeta } from '../../src/lib/resourceRouteMeta.ts';
|
|
5
|
+
|
|
6
|
+
function getResourceKey(url: string) {
|
|
7
|
+
const resource = decodeURIComponent(url.substring('/rest/resource/'.length));
|
|
8
|
+
const route = { params: { resource } };
|
|
9
|
+
return {
|
|
10
|
+
resource,
|
|
11
|
+
componentKey: resourceRouteMeta.componentKey(route),
|
|
12
|
+
tabKey: resourceRouteMeta.tabKey(route),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('resource root canonicalizes colon URL spellings to one component key', () => {
|
|
17
|
+
const expectedKey = '/rest/resource/admin-user%3Auser';
|
|
18
|
+
|
|
19
|
+
for (const url of [
|
|
20
|
+
'/rest/resource/admin-user:user',
|
|
21
|
+
'/rest/resource/admin-user%3Auser',
|
|
22
|
+
'/rest/resource/admin-user%3auser',
|
|
23
|
+
]) {
|
|
24
|
+
const actual = getResourceKey(url);
|
|
25
|
+
assert.equal(actual.resource, 'admin-user:user');
|
|
26
|
+
assert.equal(actual.componentKey, expectedKey);
|
|
27
|
+
assert.equal(actual.tabKey, expectedKey);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('resource root keeps distinct resource workspaces separate', () => {
|
|
32
|
+
const user = getResourceKey('/rest/resource/admin-user:user');
|
|
33
|
+
const role = getResourceKey('/rest/resource/admin-user:role');
|
|
34
|
+
|
|
35
|
+
assert.notEqual(user.componentKey, role.componentKey);
|
|
36
|
+
assert.notEqual(user.tabKey, role.tabKey);
|
|
37
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zova-suite-a-cabloy",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.50",
|
|
4
4
|
"gitHead": "4dece229b6d3f4735930e833afcb4b95ec51bf86",
|
|
5
5
|
"description": "",
|
|
6
6
|
"author": "",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "module",
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"zova-module-a-rbac": "^5.0.1",
|
|
11
|
-
"zova-module-rest-resource": "^5.1.
|
|
11
|
+
"zova-module-rest-resource": "^5.1.49"
|
|
12
12
|
},
|
|
13
13
|
"title": "a-cabloy"
|
|
14
14
|
}
|
package/test-results/cabloy-basic-ATP-BASIC-SUM-58d8a-ry-states-and-Markdown-HTML/error-context.md
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
# Instructions
|
|
2
|
-
|
|
3
|
-
- Following Playwright test failed.
|
|
4
|
-
- Explain why, be concise, respect Playwright best practices.
|
|
5
|
-
- Provide a snippet of code with the fix, if possible.
|
|
6
|
-
|
|
7
|
-
# Test info
|
|
8
|
-
|
|
9
|
-
- Name: cabloy-basic.spec.ts >> ATP-BASIC-SUMMARY-01: Training Student summary dialog renders query states and Markdown HTML
|
|
10
|
-
- Location: repo-e2e/specs/cabloy-basic.spec.ts:416:1
|
|
11
|
-
|
|
12
|
-
# Error details
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
Error: expect(page).not.toHaveURL(expected) failed
|
|
16
|
-
|
|
17
|
-
Expected pattern: not /\/admin\/login(?:\?|$)/
|
|
18
|
-
Received string: "http://127.0.0.1:7102/admin/login"
|
|
19
|
-
Timeout: 5000ms
|
|
20
|
-
|
|
21
|
-
Call log:
|
|
22
|
-
- Expect "not toHaveURL" with timeout 5000ms
|
|
23
|
-
14 × locator resolved to <html lang="en-us" data-zova-hydrated="admin">…</html>
|
|
24
|
-
- unexpected value "http://127.0.0.1:7102/admin/login"
|
|
25
|
-
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
```yaml
|
|
29
|
-
- heading "Zova" [level=1]
|
|
30
|
-
- heading "Less is more, while more is less" [level=5]
|
|
31
|
-
- heading "Login" [level=2]
|
|
32
|
-
- textbox:
|
|
33
|
-
- /placeholder: Your Username
|
|
34
|
-
- text: admin
|
|
35
|
-
- textbox:
|
|
36
|
-
- /placeholder: Your Password
|
|
37
|
-
- text: "123456"
|
|
38
|
-
- textbox:
|
|
39
|
-
- /placeholder: Please input captcha
|
|
40
|
-
- text: TrAs
|
|
41
|
-
- img
|
|
42
|
-
- button "Login"
|
|
43
|
-
- button "Create account"
|
|
44
|
-
- button "Forgot password?"
|
|
45
|
-
- text: OR
|
|
46
|
-
- button "Sign in with GitHub"
|
|
47
|
-
- dialog:
|
|
48
|
-
- heading "Cabloy Basic" [level=3]
|
|
49
|
-
- paragraph: "select `aAuthProvider`.`id` from `aAuthProvider` where `providerName` = 'auth-simple:simple' and `clientName` = 'default' and `aAuthProvider`.`iid` = 1 - no such table: aAuthProvider"
|
|
50
|
-
- button "Close"
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
# Test source
|
|
54
|
-
|
|
55
|
-
```ts
|
|
56
|
-
1 | import type { Locator, Page, Route } from '@playwright/test';
|
|
57
|
-
2 |
|
|
58
|
-
3 | import { expect, test } from '@playwright/test';
|
|
59
|
-
4 |
|
|
60
|
-
5 | const studentResourceUrl =
|
|
61
|
-
6 | /\/admin\/rest\/resource\/training-student(?:%3A|:|%253A)student(?:[/?#]|$)/;
|
|
62
|
-
7 |
|
|
63
|
-
8 | interface IFieldGeometry {
|
|
64
|
-
9 | container: {
|
|
65
|
-
10 | display: string;
|
|
66
|
-
11 | flexWrap: string;
|
|
67
|
-
12 | left: number;
|
|
68
|
-
13 | right: number;
|
|
69
|
-
14 | };
|
|
70
|
-
15 | field: {
|
|
71
|
-
16 | top: number;
|
|
72
|
-
17 | left: number;
|
|
73
|
-
18 | right: number;
|
|
74
|
-
19 | bottom: number;
|
|
75
|
-
20 | };
|
|
76
|
-
21 | }
|
|
77
|
-
22 |
|
|
78
|
-
23 | function collectPageErrors(page: Page) {
|
|
79
|
-
24 | const errors: Error[] = [];
|
|
80
|
-
25 | page.on('pageerror', error => {
|
|
81
|
-
26 | errors.push(error);
|
|
82
|
-
27 | });
|
|
83
|
-
28 | return errors;
|
|
84
|
-
29 | }
|
|
85
|
-
30 |
|
|
86
|
-
31 | async function loginAsAdmin(page: Page) {
|
|
87
|
-
32 | await page.goto('/admin/', { waitUntil: 'load' });
|
|
88
|
-
33 | if (page.url().includes('/admin/login')) {
|
|
89
|
-
34 | await page.getByPlaceholder('Your Username').fill('admin');
|
|
90
|
-
35 | await page.getByPlaceholder('Your Password').fill('123456');
|
|
91
|
-
36 | await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue('');
|
|
92
|
-
37 | await page.getByRole('button', { name: 'Login', exact: true }).click();
|
|
93
|
-
> 38 | await expect(page).not.toHaveURL(/\/admin\/login(?:\?|$)/);
|
|
94
|
-
| ^ Error: expect(page).not.toHaveURL(expected) failed
|
|
95
|
-
39 | }
|
|
96
|
-
40 |
|
|
97
|
-
41 | await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
|
|
98
|
-
42 | const dashboardResponse = await page.goto('/admin/', { waitUntil: 'load' });
|
|
99
|
-
43 | await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
|
|
100
|
-
44 | return dashboardResponse;
|
|
101
|
-
45 | }
|
|
102
|
-
46 |
|
|
103
|
-
47 | function waitForStudentSelect(page: Page) {
|
|
104
|
-
48 | return page.waitForResponse(response => {
|
|
105
|
-
49 | const url = new URL(response.url());
|
|
106
|
-
50 | return (
|
|
107
|
-
51 | response.request().method() === 'GET' &&
|
|
108
|
-
52 | response.ok() &&
|
|
109
|
-
53 | url.pathname === '/api/training/student' &&
|
|
110
|
-
54 | !response.request().headers()['x-vona-openapi-schema']
|
|
111
|
-
55 | );
|
|
112
|
-
56 | });
|
|
113
|
-
57 | }
|
|
114
|
-
58 |
|
|
115
|
-
59 | function installStudentCreateResponseCapture(page: Page) {
|
|
116
|
-
60 | let studentId: string | number | undefined;
|
|
117
|
-
61 | const handler = async (route: Route) => {
|
|
118
|
-
62 | const request = route.request();
|
|
119
|
-
63 | const url = new URL(request.url());
|
|
120
|
-
64 | if (
|
|
121
|
-
65 | request.method() !== 'POST' ||
|
|
122
|
-
66 | url.pathname !== '/api/training/student' ||
|
|
123
|
-
67 | request.headers()['x-vona-openapi-schema']
|
|
124
|
-
68 | ) {
|
|
125
|
-
69 | await route.continue();
|
|
126
|
-
70 | return;
|
|
127
|
-
71 | }
|
|
128
|
-
72 | const response = await route.fetch();
|
|
129
|
-
73 | const payload = await response.json();
|
|
130
|
-
74 | if (response.ok() && ['string', 'number'].includes(typeof payload.data)) {
|
|
131
|
-
75 | studentId = payload.data;
|
|
132
|
-
76 | }
|
|
133
|
-
77 | await route.fulfill({ response });
|
|
134
|
-
78 | };
|
|
135
|
-
79 | return {
|
|
136
|
-
80 | async install() {
|
|
137
|
-
81 | await page.route('**/api/training/student', handler);
|
|
138
|
-
82 | },
|
|
139
|
-
83 | async uninstall() {
|
|
140
|
-
84 | await page.unroute('**/api/training/student', handler);
|
|
141
|
-
85 | },
|
|
142
|
-
86 | get studentId() {
|
|
143
|
-
87 | return studentId;
|
|
144
|
-
88 | },
|
|
145
|
-
89 | };
|
|
146
|
-
90 | }
|
|
147
|
-
91 |
|
|
148
|
-
92 | async function getFieldGeometry(locator: Locator): Promise<IFieldGeometry> {
|
|
149
|
-
93 | return locator.evaluate(element => {
|
|
150
|
-
94 | const field = element.closest('label')?.parentElement;
|
|
151
|
-
95 | const container = field?.parentElement;
|
|
152
|
-
96 | if (!field || !container) throw new Error('Could not resolve the filter field layout');
|
|
153
|
-
97 |
|
|
154
|
-
98 | const fieldRect = field.getBoundingClientRect();
|
|
155
|
-
99 | const containerRect = container.getBoundingClientRect();
|
|
156
|
-
100 | const containerStyle = getComputedStyle(container);
|
|
157
|
-
101 | return {
|
|
158
|
-
102 | container: {
|
|
159
|
-
103 | display: containerStyle.display,
|
|
160
|
-
104 | flexWrap: containerStyle.flexWrap,
|
|
161
|
-
105 | left: containerRect.left,
|
|
162
|
-
106 | right: containerRect.right,
|
|
163
|
-
107 | },
|
|
164
|
-
108 | field: {
|
|
165
|
-
109 | top: fieldRect.top,
|
|
166
|
-
110 | left: fieldRect.left,
|
|
167
|
-
111 | right: fieldRect.right,
|
|
168
|
-
112 | bottom: fieldRect.bottom,
|
|
169
|
-
113 | },
|
|
170
|
-
114 | };
|
|
171
|
-
115 | });
|
|
172
|
-
116 | }
|
|
173
|
-
117 |
|
|
174
|
-
118 | async function getFlowGeometry(name: Locator, level: Locator, createdAtStart: Locator) {
|
|
175
|
-
119 | return Promise.all([
|
|
176
|
-
120 | getFieldGeometry(name),
|
|
177
|
-
121 | getFieldGeometry(level),
|
|
178
|
-
122 | getFieldGeometry(createdAtStart),
|
|
179
|
-
123 | ]);
|
|
180
|
-
124 | }
|
|
181
|
-
125 |
|
|
182
|
-
126 | function assertVisualOrder(geometry: IFieldGeometry[]) {
|
|
183
|
-
127 | for (let index = 1; index < geometry.length; index++) {
|
|
184
|
-
128 | const previous = geometry[index - 1].field;
|
|
185
|
-
129 | const current = geometry[index].field;
|
|
186
|
-
130 | expect(
|
|
187
|
-
131 | current.top > previous.top + 2 ||
|
|
188
|
-
132 | (Math.abs(current.top - previous.top) <= 2 && current.left > previous.left),
|
|
189
|
-
133 | ).toBeTruthy();
|
|
190
|
-
134 | }
|
|
191
|
-
135 | }
|
|
192
|
-
136 |
|
|
193
|
-
137 | async function getDocumentHorizontalOverflow(page: Page) {
|
|
194
|
-
138 | return page.evaluate(() => {
|
|
195
|
-
```
|