@solidxai/core 0.1.13-beta.5 → 0.1.13-beta.7
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/CHANGELOG.md +1581 -0
- package/dist/commands/run-tests.command.d.ts +7 -1
- package/dist/commands/run-tests.command.d.ts.map +1 -1
- package/dist/commands/run-tests.command.js +49 -3
- package/dist/commands/run-tests.command.js.map +1 -1
- package/dist/constants/chatter-message.constants.d.ts +1 -0
- package/dist/constants/chatter-message.constants.d.ts.map +1 -1
- package/dist/constants/chatter-message.constants.js +3 -1
- package/dist/constants/chatter-message.constants.js.map +1 -1
- package/dist/constants/user.constants.d.ts +2 -0
- package/dist/constants/user.constants.d.ts.map +1 -0
- package/dist/constants/user.constants.js +5 -0
- package/dist/constants/user.constants.js.map +1 -0
- package/dist/controllers/media.controller.d.ts +1 -1
- package/dist/controllers/media.controller.js +1 -1
- package/dist/entities/user.entity.d.ts.map +1 -1
- package/dist/entities/user.entity.js +36 -0
- package/dist/entities/user.entity.js.map +1 -1
- package/dist/seeders/seed-data/solid-core-metadata.json +36 -6
- package/dist/services/chatter-message.service.d.ts +5 -0
- package/dist/services/chatter-message.service.d.ts.map +1 -1
- package/dist/services/chatter-message.service.js +26 -3
- package/dist/services/chatter-message.service.js.map +1 -1
- package/dist/services/crud.service.d.ts +2 -0
- package/dist/services/crud.service.d.ts.map +1 -1
- package/dist/services/crud.service.js +13 -1
- package/dist/services/crud.service.js.map +1 -1
- package/dist/services/media.service.d.ts +1 -1
- package/dist/services/media.service.d.ts.map +1 -1
- package/dist/services/media.service.js +16 -27
- package/dist/services/media.service.js.map +1 -1
- package/dist/services/settings/default-settings-provider.service.d.ts +40 -0
- package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
- package/dist/services/settings/default-settings-provider.service.js +22 -0
- package/dist/services/settings/default-settings-provider.service.js.map +1 -1
- package/dist/testing/adapters/ui/playwright-adapter.d.ts +5 -0
- package/dist/testing/adapters/ui/playwright-adapter.d.ts.map +1 -1
- package/dist/testing/adapters/ui/playwright-adapter.js +17 -1
- package/dist/testing/adapters/ui/playwright-adapter.js.map +1 -1
- package/dist/testing/adapters/ui/ui.types.d.ts +2 -0
- package/dist/testing/adapters/ui/ui.types.d.ts.map +1 -1
- package/dist/testing/adapters/ui/ui.types.js.map +1 -1
- package/dist/testing/steps/ui/actions.step.d.ts.map +1 -1
- package/dist/testing/steps/ui/actions.step.js +6 -2
- package/dist/testing/steps/ui/actions.step.js.map +1 -1
- package/dist/testing/steps/ui/assertions.step.d.ts.map +1 -1
- package/dist/testing/steps/ui/assertions.step.js +11 -3
- package/dist/testing/steps/ui/assertions.step.js.map +1 -1
- package/dist/testing/steps/ui/form.step.d.ts.map +1 -1
- package/dist/testing/steps/ui/form.step.js +9 -3
- package/dist/testing/steps/ui/form.step.js.map +1 -1
- package/dist/testing/steps/ui/manual.step.js +1 -1
- package/dist/testing/steps/ui/manual.step.js.map +1 -1
- package/dist/testing/steps/ui/navigation.step.d.ts.map +1 -1
- package/dist/testing/steps/ui/navigation.step.js +3 -1
- package/dist/testing/steps/ui/navigation.step.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/run-tests.command.ts +47 -1
- package/src/constants/chatter-message.constants.ts +12 -0
- package/src/constants/user.constants.ts +13 -0
- package/src/entities/user.entity.ts +53 -36
- package/src/seeders/seed-data/solid-core-metadata.json +36 -6
- package/src/services/chatter-message.service.ts +60 -4
- package/src/services/crud.service.ts +31 -1
- package/src/services/media.service.ts +19 -33
- package/src/services/settings/default-settings-provider.service.ts +22 -0
- package/src/testing/README.md +64 -2
- package/src/testing/adapters/ui/playwright-adapter.ts +25 -0
- package/src/testing/adapters/ui/ui.types.ts +2 -0
- package/src/testing/steps/ui/actions.step.ts +8 -4
- package/src/testing/steps/ui/assertions.step.ts +18 -5
- package/src/testing/steps/ui/form.step.ts +16 -6
- package/src/testing/steps/ui/manual.step.ts +1 -1
- package/src/testing/steps/ui/navigation.step.ts +4 -2
|
@@ -7850,6 +7850,8 @@
|
|
|
7850
7850
|
"FieldMetadataController.getSelectionDynamicValues",
|
|
7851
7851
|
"FieldMetadataController.getSelectionDynamicValue",
|
|
7852
7852
|
"FieldMetadataController.findFieldDefaultMetaData",
|
|
7853
|
+
"MediaController.create",
|
|
7854
|
+
"MediaController.upload",
|
|
7853
7855
|
"MediaController.findMany",
|
|
7854
7856
|
"MediaController.findOne",
|
|
7855
7857
|
"MediaController.download",
|
|
@@ -8090,6 +8092,19 @@
|
|
|
8090
8092
|
"moduleUserKey": "solid-core",
|
|
8091
8093
|
"modelUserKey": "media"
|
|
8092
8094
|
},
|
|
8095
|
+
{
|
|
8096
|
+
"displayName": "Media Upload Action",
|
|
8097
|
+
"name": "media-upload-action",
|
|
8098
|
+
"type": "custom",
|
|
8099
|
+
"domain": "",
|
|
8100
|
+
"context": "",
|
|
8101
|
+
"customComponent": "/admin/core/solid-core/media/upload",
|
|
8102
|
+
"customIsModal": false,
|
|
8103
|
+
"serverEndpoint": "",
|
|
8104
|
+
"viewUserKey": "media-list-view",
|
|
8105
|
+
"moduleUserKey": "solid-core",
|
|
8106
|
+
"modelUserKey": "media"
|
|
8107
|
+
},
|
|
8093
8108
|
{
|
|
8094
8109
|
"displayName": "Solid Media Storage Provider Model List Action",
|
|
8095
8110
|
"name": "mediaStorageProviderMetadata-list-action",
|
|
@@ -10434,7 +10449,8 @@
|
|
|
10434
10449
|
"attrs": {
|
|
10435
10450
|
"name": "user",
|
|
10436
10451
|
"isSearchable": true,
|
|
10437
|
-
"searchField": "user.fullName"
|
|
10452
|
+
"searchField": "user.fullName",
|
|
10453
|
+
"coModelFieldToDisplay": "fullName"
|
|
10438
10454
|
}
|
|
10439
10455
|
}
|
|
10440
10456
|
]
|
|
@@ -10518,7 +10534,8 @@
|
|
|
10518
10534
|
{
|
|
10519
10535
|
"type": "field",
|
|
10520
10536
|
"attrs": {
|
|
10521
|
-
"name": "user"
|
|
10537
|
+
"name": "user",
|
|
10538
|
+
"coModelFieldToDisplay": "fullName"
|
|
10522
10539
|
}
|
|
10523
10540
|
},
|
|
10524
10541
|
{
|
|
@@ -11563,7 +11580,8 @@
|
|
|
11563
11580
|
50
|
|
11564
11581
|
],
|
|
11565
11582
|
"enableGlobalSearch": true,
|
|
11566
|
-
"create":
|
|
11583
|
+
"create": true,
|
|
11584
|
+
"createAction": "media-upload-action",
|
|
11567
11585
|
"edit": false,
|
|
11568
11586
|
"delete": true,
|
|
11569
11587
|
"allowedViews": [
|
|
@@ -11601,6 +11619,15 @@
|
|
|
11601
11619
|
"sortable": false
|
|
11602
11620
|
}
|
|
11603
11621
|
},
|
|
11622
|
+
{
|
|
11623
|
+
"type": "field",
|
|
11624
|
+
"attrs": {
|
|
11625
|
+
"name": "relativeUri",
|
|
11626
|
+
"viewWidget": "MediaRelativeUriListWidget",
|
|
11627
|
+
"isSearchable": true,
|
|
11628
|
+
"sortable": false
|
|
11629
|
+
}
|
|
11630
|
+
},
|
|
11604
11631
|
{
|
|
11605
11632
|
"type": "field",
|
|
11606
11633
|
"attrs": {
|
|
@@ -11757,7 +11784,8 @@
|
|
|
11757
11784
|
{
|
|
11758
11785
|
"type": "field",
|
|
11759
11786
|
"attrs": {
|
|
11760
|
-
"name": "relativeUri"
|
|
11787
|
+
"name": "relativeUri",
|
|
11788
|
+
"viewWidget": "MediaRelativeUriFormViewWidget"
|
|
11761
11789
|
}
|
|
11762
11790
|
},
|
|
11763
11791
|
{
|
|
@@ -14856,7 +14884,8 @@
|
|
|
14856
14884
|
"name": "user",
|
|
14857
14885
|
"label": "User",
|
|
14858
14886
|
"isSearchable": true,
|
|
14859
|
-
"searchField": "user.fullName"
|
|
14887
|
+
"searchField": "user.fullName",
|
|
14888
|
+
"coModelFieldToDisplay": "fullName"
|
|
14860
14889
|
}
|
|
14861
14890
|
},
|
|
14862
14891
|
{
|
|
@@ -17078,7 +17107,8 @@
|
|
|
17078
17107
|
"name": "user",
|
|
17079
17108
|
"label": "User",
|
|
17080
17109
|
"isSearchable": true,
|
|
17081
|
-
"searchField": "user.fullName"
|
|
17110
|
+
"searchField": "user.fullName",
|
|
17111
|
+
"coModelFieldToDisplay": "fullName"
|
|
17082
17112
|
}
|
|
17083
17113
|
}
|
|
17084
17114
|
]
|
|
@@ -5,7 +5,7 @@ import { InjectEntityManager } from '@nestjs/typeorm';
|
|
|
5
5
|
import { Brackets, EntityManager, EntityMetadata, In } from 'typeorm';
|
|
6
6
|
|
|
7
7
|
import { classify } from '@angular-devkit/core/src/utils/strings';
|
|
8
|
-
import { CHATTER_MESSAGE_STATUS, CHATTER_MESSAGE_SUBTYPE, CHATTER_MESSAGE_TYPE } from 'src/constants/chatter-message.constants';
|
|
8
|
+
import { CHATTER_MESSAGE_STATUS, CHATTER_MESSAGE_SUBTYPE, CHATTER_MESSAGE_TYPE, CHATTER_MESSAGE_USER_FIELDS } from 'src/constants/chatter-message.constants';
|
|
9
9
|
import { ERROR_MESSAGES } from 'src/constants/error-messages';
|
|
10
10
|
import { PostChatterMessageDto } from 'src/dtos/post-chatter-message.dto';
|
|
11
11
|
import { UpdateChatterNoteMessageDto } from 'src/dtos/update-chatter-note-message.dto';
|
|
@@ -18,6 +18,7 @@ import { FieldMetadataRepository } from 'src/repository/field-metadata.repositor
|
|
|
18
18
|
import { MediaRepository } from 'src/repository/media.repository';
|
|
19
19
|
import { ModelMetadataRepository } from 'src/repository/model-metadata.repository';
|
|
20
20
|
import { CRUDService } from 'src/services/crud.service';
|
|
21
|
+
import { BasicFilterDto } from '../dtos/basic-filters.dto';
|
|
21
22
|
import { MediaStorageProviderType } from '../dtos/create-media-storage-provider-metadata.dto';
|
|
22
23
|
import { ChatterMessageDetails } from '../entities/chatter-message-details.entity';
|
|
23
24
|
import { ChatterMessage } from '../entities/chatter-message.entity';
|
|
@@ -102,6 +103,44 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
|
|
|
102
103
|
chatterMessage.updatedBy = resolvedUserId;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Reduce a message's hydrated `user` relation to CHATTER_MESSAGE_USER_FIELDS.
|
|
108
|
+
*
|
|
109
|
+
* `getChatterMessages` restricts the columns in the query itself, which is preferable.
|
|
110
|
+
* The generic CRUD paths below build their query from find-options and hand `populate`
|
|
111
|
+
* straight to TypeORM, so they are trimmed after the fact instead. (`createdBy` /
|
|
112
|
+
* `updatedBy` need nothing here - CRUDService.handlePopulateUserIdFields already selects
|
|
113
|
+
* only USER_SUMMARY_FIELDS when a caller populates them.)
|
|
114
|
+
*/
|
|
115
|
+
private trimMessageUser<M extends ChatterMessage>(message: M): M {
|
|
116
|
+
if (message?.user) {
|
|
117
|
+
message.user = this.toUserSummary(message.user) as User;
|
|
118
|
+
}
|
|
119
|
+
return message;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private trimMessageUsers(messages: ChatterMessage[] | undefined) {
|
|
123
|
+
messages?.forEach(message => this.trimMessageUser(message));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `GET /chatter-message?populate[]=user` would otherwise be a way around the column
|
|
128
|
+
* allowlist applied by getChatterMessages.
|
|
129
|
+
*/
|
|
130
|
+
async find(basicFilterDto: BasicFilterDto, solidRequestContext: any = {}): Promise<any> {
|
|
131
|
+
const result = await super.find(basicFilterDto, solidRequestContext);
|
|
132
|
+
this.trimMessageUsers(result?.records);
|
|
133
|
+
// A grouped find (populateGroup) nests its entities one level deeper.
|
|
134
|
+
for (const groupRecord of result?.groupRecords ?? []) {
|
|
135
|
+
this.trimMessageUsers(groupRecord?.groupData?.records);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async findOne(id: number, query: any = {}, solidRequestContext: any = {}) {
|
|
141
|
+
return this.trimMessageUser(await super.findOne(id, query, solidRequestContext));
|
|
142
|
+
}
|
|
143
|
+
|
|
105
144
|
private isEditableCustomNoteMessage(message: ChatterMessage): boolean {
|
|
106
145
|
if (message.messageType !== CHATTER_MESSAGE_TYPE.CUSTOM) {
|
|
107
146
|
return false;
|
|
@@ -332,7 +371,8 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
|
|
|
332
371
|
|
|
333
372
|
await this.publishChatterMentionNotifications(savedMessage, model);
|
|
334
373
|
|
|
335
|
-
return
|
|
374
|
+
// The `user` relation was loaded for the ownership check above; don't return all of it.
|
|
375
|
+
return this.trimMessageUser(savedMessage);
|
|
336
376
|
}
|
|
337
377
|
|
|
338
378
|
async postMessage(postDto: PostChatterMessageDto, files: Express.Multer.File[] = []) {
|
|
@@ -917,16 +957,25 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
|
|
|
917
957
|
qb.where(orConditions.join(' OR '), parameters);
|
|
918
958
|
}));
|
|
919
959
|
|
|
920
|
-
const relations = ['chatterMessageDetails'
|
|
960
|
+
const relations = ['chatterMessageDetails'];
|
|
921
961
|
if (populate && populate.length > 0) {
|
|
922
962
|
const normalizedPopulate = this.crudHelperService.normalize(populate);
|
|
923
|
-
|
|
963
|
+
// SECURITY: 'user' is joined below with an explicit column allowlist. A client-supplied
|
|
964
|
+
// populate[]=user must not be able to turn it back into a full-entity join (the chatter
|
|
965
|
+
// panel does send exactly that).
|
|
966
|
+
relations.push(...normalizedPopulate.filter(rel => rel !== 'user' && !relations.includes(rel)));
|
|
924
967
|
}
|
|
925
968
|
|
|
926
969
|
relations.forEach(relation => {
|
|
927
970
|
qb.leftJoinAndSelect(`entity.${relation}`, relation);
|
|
928
971
|
});
|
|
929
972
|
|
|
973
|
+
// Only the author's id and display name leave this endpoint - see CHATTER_MESSAGE_USER_FIELDS.
|
|
974
|
+
// This join must stay before applyFilters below: it lets filters[user][fullName] reuse this
|
|
975
|
+
// alias instead of adding a second join of its own.
|
|
976
|
+
qb.leftJoin('entity.user', 'user');
|
|
977
|
+
qb.addSelect(CHATTER_MESSAGE_USER_FIELDS.map(field => `user.${field}`));
|
|
978
|
+
|
|
930
979
|
if (filters) {
|
|
931
980
|
qb.andWhere(new Brackets(whereQb => {
|
|
932
981
|
this.crudHelperService.applyFilters(whereQb, filters, 'entity', qb);
|
|
@@ -940,6 +989,13 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
|
|
|
940
989
|
const [entities, count] = await qb.getManyAndCount();
|
|
941
990
|
this.logHeapUsed('getChatterMessages-entitiesLoaded');
|
|
942
991
|
|
|
992
|
+
// The join above only selects the allowlisted columns, but TypeORM still hydrates the
|
|
993
|
+
// author with `new User()`, which leaves every initialiser-backed property (active,
|
|
994
|
+
// forcePasswordChange, ...) sitting on the instance holding its default rather than a
|
|
995
|
+
// value read from the database. Replace it with a plain object of exactly the
|
|
996
|
+
// allowlisted keys so the response cannot report fabricated defaults as data.
|
|
997
|
+
this.trimMessageUsers(entities);
|
|
998
|
+
|
|
943
999
|
// Convert date strings in message details to ISO format for consistent handling on the frontend
|
|
944
1000
|
const DATE_FIELD_TYPES = ['date', 'datetime', 'time'];
|
|
945
1001
|
for (const entity of entities) {
|
|
@@ -7,6 +7,7 @@ import { SolidBaseRepository } from "../repository/solid-base.repository";
|
|
|
7
7
|
import { SettingService } from "./setting.service";
|
|
8
8
|
import { ERROR_MESSAGES } from "src/constants/error-messages";
|
|
9
9
|
import { SUCCESS_MESSAGES } from "src/constants/success-messages";
|
|
10
|
+
import { USER_SUMMARY_FIELDS } from "src/constants/user.constants";
|
|
10
11
|
import { EntityManager, FindOptionsWhere, In, IsNull, Not, QueryFailedError, SelectQueryBuilder } from "typeorm";
|
|
11
12
|
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
12
13
|
import { BasicFilterDto } from "../dtos/basic-filters.dto";
|
|
@@ -573,6 +574,29 @@ private async prepareManyToManyAuditSnapshot(entity: T,id: number,modelSingularN
|
|
|
573
574
|
return this.crudHelperService.pagedResponse(offset, limit, count, entities);
|
|
574
575
|
}
|
|
575
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Reduce a user to USER_SUMMARY_FIELDS as a plain object.
|
|
579
|
+
*
|
|
580
|
+
* Returning the hydrated entity is not good enough. A partially selected `User` is still
|
|
581
|
+
* built with `new User()`, so every property that has a TypeScript field initializer
|
|
582
|
+
* (`active`, `forcePasswordChange`, `lastLoginProvider`, `failedLoginAttempts`,
|
|
583
|
+
* `isAllowedToGenerateApiKeys`) is present on the instance holding its *default* rather
|
|
584
|
+
* than the value in the database - `active: true` for a user who may well be inactive.
|
|
585
|
+
* Serialization does not help: those defaults are real own properties. Copying the
|
|
586
|
+
* allowlist into a fresh object is what guarantees the response contains only columns
|
|
587
|
+
* actually read from the database.
|
|
588
|
+
*/
|
|
589
|
+
protected toUserSummary(user: unknown): Partial<User> | null {
|
|
590
|
+
if (!user) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const summary: Partial<User> = {};
|
|
594
|
+
for (const field of USER_SUMMARY_FIELDS) {
|
|
595
|
+
summary[field] = user[field] as never;
|
|
596
|
+
}
|
|
597
|
+
return summary;
|
|
598
|
+
}
|
|
599
|
+
|
|
576
600
|
// entities is an array of T
|
|
577
601
|
// T can contain createdBy and updatedBy fields
|
|
578
602
|
// We need to populate the createdBy and updatedBy fields with the User entity
|
|
@@ -584,9 +608,15 @@ private async prepareManyToManyAuditSnapshot(entity: T,id: number,modelSingularN
|
|
|
584
608
|
if (userId) {
|
|
585
609
|
const user = await userRepository.findOne({
|
|
586
610
|
where: { id: userId },
|
|
611
|
+
// SECURITY: a populated createdBy/updatedBy is only ever displayed as
|
|
612
|
+
// "who did this", so it must not ship the author's email, mobile, role
|
|
613
|
+
// assignments or API keys - let alone the credential columns, which
|
|
614
|
+
// survive here whenever User is subclassed (class-transformer resolves
|
|
615
|
+
// @Exclude() per class and does not inherit it).
|
|
616
|
+
select: USER_SUMMARY_FIELDS as unknown as (keyof User)[],
|
|
587
617
|
});
|
|
588
618
|
// @ts-ignore
|
|
589
|
-
entity[userFieldPath] = user;
|
|
619
|
+
entity[userFieldPath] = this.toUserSummary(user);
|
|
590
620
|
}
|
|
591
621
|
}
|
|
592
622
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
1
|
+
import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
2
2
|
import { ModuleRef } from "@nestjs/core";
|
|
3
3
|
import { InjectEntityManager } from '@nestjs/typeorm';
|
|
4
4
|
import { EntityManager, In } from 'typeorm';
|
|
@@ -16,11 +16,12 @@ import { BasicFilterDto } from 'src/dtos/basic-filters.dto';
|
|
|
16
16
|
import { MediaStorageProviderType } from 'src/dtos/create-media-storage-provider-metadata.dto';
|
|
17
17
|
import { Media } from 'src/entities/media.entity';
|
|
18
18
|
import { MediaStorageProviderMetadata } from 'src/entities/media-storage-provider-metadata.entity';
|
|
19
|
+
import { MediaFieldCrudManager, SolidMediaType } from 'src/helpers/field-crud-managers/MediaFieldCrudManager';
|
|
19
20
|
import { FieldMetadataRepository } from 'src/repository/field-metadata.repository';
|
|
20
21
|
import { MediaStorageProviderMetadataRepository } from 'src/repository/media-storage-provider-metadata.repository';
|
|
21
22
|
import { MediaRepository } from 'src/repository/media.repository';
|
|
22
23
|
import { ModelMetadataRepository } from 'src/repository/model-metadata.repository';
|
|
23
|
-
import { buildDiskMediaPath,
|
|
24
|
+
import { buildDiskMediaPath, getEffectiveS3Region, resolveMediaIsPublic, } from 'src/services/media-storage.utils';
|
|
24
25
|
import { getMediaStorageProvider } from "./mediaStorageProviders";
|
|
25
26
|
|
|
26
27
|
@Injectable()
|
|
@@ -103,40 +104,25 @@ export class MediaService extends CRUDService<Media> {
|
|
|
103
104
|
throw new NotFoundException('Media storage provider metadata not found');
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
|
|
107
|
-
for (let i = 0; i < files.length; i++) {
|
|
108
|
-
const file = files[i];
|
|
109
|
-
const storageProvider = createDto.mediaStorageProviderMetadata as MediaStorageProviderMetadata;
|
|
110
|
-
const fileName = buildStoredMediaFileName(file);
|
|
107
|
+
createDto['fieldMetadata'].mediaStorageProvider = createDto['mediaStorageProviderMetadata'];
|
|
111
108
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
await this.s3FileService.write(`${bucketName}:${fileName}`, fileData, { contentType: file.mimetype, region });
|
|
109
|
+
const validator = new MediaFieldCrudManager({
|
|
110
|
+
type: createDto['fieldMetadata']?.type as SolidMediaType,
|
|
111
|
+
required: true,
|
|
112
|
+
fieldName: 'files',
|
|
113
|
+
mediaMaxSizeKb: createDto['fieldMetadata']?.mediaMaxSizeKb,
|
|
114
|
+
mediaTypes: createDto['fieldMetadata']?.mediaTypes || [],
|
|
115
|
+
isUpdate: false,
|
|
116
|
+
});
|
|
117
|
+
const validationErrors = validator.validate(createDto, files);
|
|
118
|
+
if (validationErrors.length > 0) {
|
|
119
|
+
throw new BadRequestException(validationErrors.map(error => error.error).join(', '));
|
|
120
|
+
}
|
|
125
121
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
default:
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
131
|
-
// Delete temp file from disk
|
|
132
|
-
await this.diskFileService.delete(file.path);
|
|
122
|
+
const storageProviderType = createDto['mediaStorageProviderMetadata'].type as MediaStorageProviderType;
|
|
123
|
+
const storageProvider = await getMediaStorageProvider(this.moduleRef, storageProviderType);
|
|
133
124
|
|
|
134
|
-
|
|
135
|
-
const media = this.repo.create(createDto as Partial<Media>) as Media;
|
|
136
|
-
const savedMedia = await this.repo.save(media);
|
|
137
|
-
savedMedias.push(savedMedia)
|
|
138
|
-
}
|
|
139
|
-
return savedMedias
|
|
125
|
+
return storageProvider.store(files, { id: Number(createDto['entityId']) }, createDto['fieldMetadata']);
|
|
140
126
|
}
|
|
141
127
|
|
|
142
128
|
async remove(id: number) {
|
|
@@ -1474,6 +1474,28 @@ const getSolidCoreSettings = (isProd: boolean) =>
|
|
|
1474
1474
|
value: process.env.COMMON_META_WHATSAPP_APP_SECRET,
|
|
1475
1475
|
level: SettingLevel.SystemEnv,
|
|
1476
1476
|
},
|
|
1477
|
+
{
|
|
1478
|
+
moduleName: "solid-core",
|
|
1479
|
+
key: "uiTestDefaultTimeoutMs",
|
|
1480
|
+
value: 30000,
|
|
1481
|
+
level: SettingLevel.SystemAdminEditable,
|
|
1482
|
+
label: "UI Test Default Timeout (ms)",
|
|
1483
|
+
group: "testing-settings",
|
|
1484
|
+
sortOrder: 10,
|
|
1485
|
+
controlType: "numeric",
|
|
1486
|
+
helpText: "How long UI test steps wait for an element before failing. Raise this if tests time out on slow screens. Overridden by the --ui-timeout-ms flag and by a per-step timeoutMs.",
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
moduleName: "solid-core",
|
|
1490
|
+
key: "uiTestNavigationTimeoutMs",
|
|
1491
|
+
value: 30000,
|
|
1492
|
+
level: SettingLevel.SystemAdminEditable,
|
|
1493
|
+
label: "UI Test Navigation Timeout (ms)",
|
|
1494
|
+
group: "testing-settings",
|
|
1495
|
+
sortOrder: 20,
|
|
1496
|
+
controlType: "numeric",
|
|
1497
|
+
helpText: "How long the ui.goto step waits for a page to load before failing. Falls back to the UI test default timeout.",
|
|
1498
|
+
},
|
|
1477
1499
|
] as const satisfies SettingDefinition[];
|
|
1478
1500
|
|
|
1479
1501
|
// 2.
|
package/src/testing/README.md
CHANGED
|
@@ -75,6 +75,48 @@ General step fields:
|
|
|
75
75
|
- `timeoutMs` (optional): per-step timeout override
|
|
76
76
|
- `spec` (optional): used by `test.spec` to identify the spec
|
|
77
77
|
|
|
78
|
+
## Timeouts
|
|
79
|
+
There are two independent timeout layers. They are easy to confuse, so note which one you want:
|
|
80
|
+
|
|
81
|
+
**1. Engine timeouts** — `scenario.timeoutMs` and the top-level `step.timeoutMs`.
|
|
82
|
+
These wrap execution in a `Promise.race` (`core/timeout.ts`). They are a hard upper bound
|
|
83
|
+
on wall-clock time and do **not** cancel the underlying browser call, so the failure message
|
|
84
|
+
only tells you the scenario or op that overran.
|
|
85
|
+
|
|
86
|
+
**2. UI (Playwright) timeouts** — the run-wide default plus per-step `with.timeoutMs`.
|
|
87
|
+
This is what governs how long `ui.expectVisible`, `ui.click`, `ui.fill` etc. wait for a
|
|
88
|
+
selector. Playwright cancels the operation itself, so the error names the selector and the
|
|
89
|
+
state it was waiting for. **If your UI steps are timing out, this is the knob to turn.**
|
|
90
|
+
|
|
91
|
+
Resolution order for UI steps: `with.timeoutMs` → `--ui-timeout-ms` → `UI_TIMEOUT_MS` →
|
|
92
|
+
`uiTestDefaultTimeoutMs` setting → `30000`.
|
|
93
|
+
`ui.goto` uses the navigation timeout instead: `with.timeoutMs` → `--ui-navigation-timeout-ms`
|
|
94
|
+
→ `UI_NAVIGATION_TIMEOUT_MS` → `uiTestNavigationTimeoutMs` setting → the resolved UI timeout.
|
|
95
|
+
|
|
96
|
+
Raise the default for a whole run:
|
|
97
|
+
```bash
|
|
98
|
+
solid test run --module <module-name> --ui-timeout-ms 60000
|
|
99
|
+
# or
|
|
100
|
+
UI_TIMEOUT_MS=60000 solid test run --module <module-name>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or set the installation-wide default under **Settings → Testing Settings** in the admin UI
|
|
104
|
+
(`uiTestDefaultTimeoutMs` / `uiTestNavigationTimeoutMs`). Those rows are created by `solid seed`;
|
|
105
|
+
until then the built-in `30000` applies. Flags and env vars still win, so CI keeps per-run control.
|
|
106
|
+
|
|
107
|
+
Or bump only the one slow step, leaving the rest of the suite strict:
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"then": {
|
|
111
|
+
"op": "ui.expectVisible",
|
|
112
|
+
"with": { "selector": "#report-grid", "timeoutMs": 90000 }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Caution: a scenario-level `timeoutMs` lower than the UI timeout will fire first and mask the
|
|
118
|
+
more useful Playwright error. Keep `defaults.timeoutMs` above your UI timeout.
|
|
119
|
+
|
|
78
120
|
## Interpolation
|
|
79
121
|
Available tokens:
|
|
80
122
|
- `${env:NAME}` (environment variables)
|
|
@@ -198,6 +240,7 @@ Description: Navigates the browser to a URL.
|
|
|
198
240
|
|
|
199
241
|
Options in `with`:
|
|
200
242
|
- `url` (required)
|
|
243
|
+
- `timeoutMs` (optional, overrides the run-wide UI navigation timeout)
|
|
201
244
|
|
|
202
245
|
### **Op: `ui.expectUrl`**
|
|
203
246
|
Description: Asserts the current page URL.
|
|
@@ -212,6 +255,7 @@ Description: Fills an input or editable element.
|
|
|
212
255
|
Options in `with`:
|
|
213
256
|
- `selector` (required)
|
|
214
257
|
- `value` (required)
|
|
258
|
+
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
215
259
|
|
|
216
260
|
### **Op: `ui.select`**
|
|
217
261
|
Description: Selects an option in a select element.
|
|
@@ -219,12 +263,14 @@ Description: Selects an option in a select element.
|
|
|
219
263
|
Options in `with`:
|
|
220
264
|
- `selector` (required)
|
|
221
265
|
- `value` (required)
|
|
266
|
+
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
222
267
|
|
|
223
268
|
### **Op: `ui.click`**
|
|
224
269
|
Description: Clicks an element located by selector.
|
|
225
270
|
|
|
226
271
|
Options in `with`:
|
|
227
272
|
- `selector` (required)
|
|
273
|
+
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
228
274
|
|
|
229
275
|
### **Op: `ui.press`**
|
|
230
276
|
Description: Presses a keyboard key on a focused element.
|
|
@@ -232,6 +278,7 @@ Description: Presses a keyboard key on a focused element.
|
|
|
232
278
|
Options in `with`:
|
|
233
279
|
- `selector` (required)
|
|
234
280
|
- `key` (required)
|
|
281
|
+
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
235
282
|
|
|
236
283
|
### **Op: `ui.waitForManual`**
|
|
237
284
|
Description: Pauses a headed Playwright run so a human can interact with the live browser, then resumes when Enter is pressed in the terminal.
|
|
@@ -242,7 +289,7 @@ Options in `with`:
|
|
|
242
289
|
- `waitForSelector` (optional, waits for a visible selector after resume)
|
|
243
290
|
- `waitForUrlEquals` (optional, waits until the current URL exactly matches)
|
|
244
291
|
- `waitForUrlContains` (optional, waits until the current URL contains a substring)
|
|
245
|
-
- `timeoutMs` (optional, timeout for post-resume waits)
|
|
292
|
+
- `timeoutMs` (optional, timeout for post-resume waits; defaults to the run-wide UI timeout)
|
|
246
293
|
- `bringToFront` (optional, defaults to `true`)
|
|
247
294
|
|
|
248
295
|
Notes:
|
|
@@ -269,6 +316,7 @@ Description: Waits for an element to be visible.
|
|
|
269
316
|
|
|
270
317
|
Options in `with`:
|
|
271
318
|
- `selector` (required)
|
|
319
|
+
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
272
320
|
|
|
273
321
|
### **Op: `ui.expectText`**
|
|
274
322
|
Description: Asserts the text content of an element.
|
|
@@ -277,6 +325,7 @@ Options in `with`:
|
|
|
277
325
|
- `selector` (required)
|
|
278
326
|
- `equals` (optional)
|
|
279
327
|
- `contains` (optional)
|
|
328
|
+
- `timeoutMs` (optional, overrides the run-wide UI timeout)
|
|
280
329
|
|
|
281
330
|
### **Op: `assert.equals`**
|
|
282
331
|
Description: Asserts strict equality between two values.
|
|
@@ -388,7 +437,12 @@ await runFromMetadata({
|
|
|
388
437
|
includeTags: ["smoke"],
|
|
389
438
|
defaults: { timeoutMs: 30_000, retries: 1 },
|
|
390
439
|
api: { baseUrl: "https://api.example.com" },
|
|
391
|
-
ui: {
|
|
440
|
+
ui: {
|
|
441
|
+
baseUrl: "https://app.example.com",
|
|
442
|
+
headless: true,
|
|
443
|
+
defaultTimeoutMs: 30_000,
|
|
444
|
+
navigationTimeoutMs: 45_000
|
|
445
|
+
}
|
|
392
446
|
});
|
|
393
447
|
```
|
|
394
448
|
|
|
@@ -409,6 +463,14 @@ Lightweight existing-database workflow:
|
|
|
409
463
|
|
|
410
464
|
For human-assisted OTP or third-party verification flows, use `ui.waitForManual` in a headed run so the browser remains interactive while the scenario is paused.
|
|
411
465
|
|
|
466
|
+
Common `test run` flags (all pass through solidctl unchanged):
|
|
467
|
+
- `--ui-base-url <url>` / `--api-base-url <url>`
|
|
468
|
+
- `--headless [true|false]`
|
|
469
|
+
- `--ui-timeout-ms <number>` — default wait for every UI step (see [Timeouts](#timeouts))
|
|
470
|
+
- `--ui-navigation-timeout-ms <number>` — default wait for `ui.goto`
|
|
471
|
+
- `--timeout-ms <number>` / `--retries <number>` — scenario-level guard and retry count
|
|
472
|
+
- `--scenario-ids <ids>` / `--include-tags <tags>` / `--skip-scenario-ids <ids>`
|
|
473
|
+
|
|
412
474
|
## Add A New Step (SOP)
|
|
413
475
|
1. Create a new `*.step.ts` in the right domain folder.
|
|
414
476
|
2. Implement a `registerXSteps(registry)` function and `registry.register("op.name", handler)`.
|
|
@@ -6,9 +6,13 @@ function isAbsoluteUrl(url: string): boolean {
|
|
|
6
6
|
return /^https?:\/\//i.test(url);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export const DEFAULT_UI_TIMEOUT_MS = 30_000;
|
|
10
|
+
|
|
9
11
|
export class PlaywrightAdapter {
|
|
10
12
|
private readonly baseUrl?: string;
|
|
11
13
|
private readonly headless: boolean;
|
|
14
|
+
private readonly defaultTimeoutMs: number;
|
|
15
|
+
private readonly navigationTimeoutMs: number;
|
|
12
16
|
private browser?: Browser;
|
|
13
17
|
private context?: BrowserContext;
|
|
14
18
|
public page?: Page;
|
|
@@ -16,16 +20,37 @@ export class PlaywrightAdapter {
|
|
|
16
20
|
constructor(opts?: PlaywrightAdapterOptions) {
|
|
17
21
|
this.baseUrl = opts?.baseUrl;
|
|
18
22
|
this.headless = opts?.headless ?? true;
|
|
23
|
+
this.defaultTimeoutMs = opts?.defaultTimeoutMs ?? DEFAULT_UI_TIMEOUT_MS;
|
|
24
|
+
this.navigationTimeoutMs =
|
|
25
|
+
opts?.navigationTimeoutMs ?? this.defaultTimeoutMs;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
isHeadless(): boolean {
|
|
22
29
|
return this.headless;
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the timeout for a single Playwright call: an explicit per-step
|
|
34
|
+
* value wins, otherwise the run-wide default applies.
|
|
35
|
+
*/
|
|
36
|
+
resolveTimeout(stepTimeoutMs?: number): number {
|
|
37
|
+
return typeof stepTimeoutMs === 'number' && Number.isFinite(stepTimeoutMs)
|
|
38
|
+
? stepTimeoutMs
|
|
39
|
+
: this.defaultTimeoutMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
resolveNavigationTimeout(stepTimeoutMs?: number): number {
|
|
43
|
+
return typeof stepTimeoutMs === 'number' && Number.isFinite(stepTimeoutMs)
|
|
44
|
+
? stepTimeoutMs
|
|
45
|
+
: this.navigationTimeoutMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
async start(): Promise<void> {
|
|
26
49
|
const { chromium } = await import('playwright');
|
|
27
50
|
this.browser = await chromium.launch({ headless: this.headless });
|
|
28
51
|
this.context = await this.browser.newContext();
|
|
52
|
+
this.context.setDefaultTimeout(this.defaultTimeoutMs);
|
|
53
|
+
this.context.setDefaultNavigationTimeout(this.navigationTimeoutMs);
|
|
29
54
|
this.page = await this.context.newPage();
|
|
30
55
|
}
|
|
31
56
|
|
|
@@ -2,8 +2,8 @@ import type { TestContext } from "../../contracts/runtime-context.types";
|
|
|
2
2
|
import type { OpStep } from "../../contracts/testing-metadata.types";
|
|
3
3
|
import { StepRegistry } from "../../core/step-registry";
|
|
4
4
|
|
|
5
|
-
type ClickInput = { selector: string };
|
|
6
|
-
type PressInput = { selector: string; key: string };
|
|
5
|
+
type ClickInput = { selector: string; timeoutMs?: number };
|
|
6
|
+
type PressInput = { selector: string; key: string; timeoutMs?: number };
|
|
7
7
|
|
|
8
8
|
function requirePage(ctx: TestContext, op: string) {
|
|
9
9
|
if (!ctx.ui || !ctx.ui.page) {
|
|
@@ -19,7 +19,9 @@ export function registerActionSteps(registry: StepRegistry): void {
|
|
|
19
19
|
if (!input.selector) {
|
|
20
20
|
throw new Error('Missing "selector" in step.with for op "ui.click"');
|
|
21
21
|
}
|
|
22
|
-
await page.click(input.selector
|
|
22
|
+
await page.click(input.selector, {
|
|
23
|
+
timeout: ctx.ui?.resolveTimeout(input.timeoutMs),
|
|
24
|
+
});
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
registry.register("ui.press", async (ctx: TestContext, step: OpStep) => {
|
|
@@ -31,6 +33,8 @@ export function registerActionSteps(registry: StepRegistry): void {
|
|
|
31
33
|
if (!input.key) {
|
|
32
34
|
throw new Error('Missing "key" in step.with for op "ui.press"');
|
|
33
35
|
}
|
|
34
|
-
await page.press(input.selector, input.key
|
|
36
|
+
await page.press(input.selector, input.key, {
|
|
37
|
+
timeout: ctx.ui?.resolveTimeout(input.timeoutMs),
|
|
38
|
+
});
|
|
35
39
|
});
|
|
36
40
|
}
|
|
@@ -2,8 +2,13 @@ import type { TestContext } from "../../contracts/runtime-context.types";
|
|
|
2
2
|
import type { OpStep } from "../../contracts/testing-metadata.types";
|
|
3
3
|
import { StepRegistry } from "../../core/step-registry";
|
|
4
4
|
|
|
5
|
-
type VisibleInput = { selector: string };
|
|
6
|
-
type ExpectTextInput = {
|
|
5
|
+
type VisibleInput = { selector: string; timeoutMs?: number };
|
|
6
|
+
type ExpectTextInput = {
|
|
7
|
+
selector: string;
|
|
8
|
+
equals?: string;
|
|
9
|
+
contains?: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
};
|
|
7
12
|
|
|
8
13
|
function requirePage(ctx: TestContext, op: string) {
|
|
9
14
|
if (!ctx.ui || !ctx.ui.page) {
|
|
@@ -19,7 +24,10 @@ export function registerAssertionSteps(registry: StepRegistry): void {
|
|
|
19
24
|
if (!input.selector) {
|
|
20
25
|
throw new Error('Missing "selector" in step.with for op "ui.expectVisible"');
|
|
21
26
|
}
|
|
22
|
-
await page.waitForSelector(input.selector, {
|
|
27
|
+
await page.waitForSelector(input.selector, {
|
|
28
|
+
state: "visible",
|
|
29
|
+
timeout: ctx.ui?.resolveTimeout(input.timeoutMs),
|
|
30
|
+
});
|
|
23
31
|
});
|
|
24
32
|
|
|
25
33
|
registry.register("ui.expectHidden", async (ctx: TestContext, step: OpStep) => {
|
|
@@ -28,7 +36,10 @@ export function registerAssertionSteps(registry: StepRegistry): void {
|
|
|
28
36
|
if (!input.selector) {
|
|
29
37
|
throw new Error('Missing "selector" in step.with for op "ui.expectHidden"');
|
|
30
38
|
}
|
|
31
|
-
await page.waitForSelector(input.selector, {
|
|
39
|
+
await page.waitForSelector(input.selector, {
|
|
40
|
+
state: "hidden",
|
|
41
|
+
timeout: ctx.ui?.resolveTimeout(input.timeoutMs),
|
|
42
|
+
});
|
|
32
43
|
});
|
|
33
44
|
|
|
34
45
|
registry.register("ui.expectText", async (ctx: TestContext, step: OpStep) => {
|
|
@@ -38,7 +49,9 @@ export function registerAssertionSteps(registry: StepRegistry): void {
|
|
|
38
49
|
throw new Error('Missing "selector" in step.with for op "ui.expectText"');
|
|
39
50
|
}
|
|
40
51
|
|
|
41
|
-
const text = await page
|
|
52
|
+
const text = await page
|
|
53
|
+
.locator(input.selector)
|
|
54
|
+
.innerText({ timeout: ctx.ui?.resolveTimeout(input.timeoutMs) });
|
|
42
55
|
if (input.equals !== undefined) {
|
|
43
56
|
if (text !== input.equals) {
|
|
44
57
|
throw new Error(
|