@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +1581 -0
  2. package/dist/commands/run-tests.command.d.ts +7 -1
  3. package/dist/commands/run-tests.command.d.ts.map +1 -1
  4. package/dist/commands/run-tests.command.js +49 -3
  5. package/dist/commands/run-tests.command.js.map +1 -1
  6. package/dist/constants/chatter-message.constants.d.ts +1 -0
  7. package/dist/constants/chatter-message.constants.d.ts.map +1 -1
  8. package/dist/constants/chatter-message.constants.js +3 -1
  9. package/dist/constants/chatter-message.constants.js.map +1 -1
  10. package/dist/constants/user.constants.d.ts +2 -0
  11. package/dist/constants/user.constants.d.ts.map +1 -0
  12. package/dist/constants/user.constants.js +5 -0
  13. package/dist/constants/user.constants.js.map +1 -0
  14. package/dist/controllers/media.controller.d.ts +1 -1
  15. package/dist/controllers/media.controller.js +1 -1
  16. package/dist/entities/user.entity.d.ts.map +1 -1
  17. package/dist/entities/user.entity.js +36 -0
  18. package/dist/entities/user.entity.js.map +1 -1
  19. package/dist/seeders/seed-data/solid-core-metadata.json +36 -6
  20. package/dist/services/chatter-message.service.d.ts +5 -0
  21. package/dist/services/chatter-message.service.d.ts.map +1 -1
  22. package/dist/services/chatter-message.service.js +26 -3
  23. package/dist/services/chatter-message.service.js.map +1 -1
  24. package/dist/services/crud.service.d.ts +2 -0
  25. package/dist/services/crud.service.d.ts.map +1 -1
  26. package/dist/services/crud.service.js +13 -1
  27. package/dist/services/crud.service.js.map +1 -1
  28. package/dist/services/media.service.d.ts +1 -1
  29. package/dist/services/media.service.d.ts.map +1 -1
  30. package/dist/services/media.service.js +16 -27
  31. package/dist/services/media.service.js.map +1 -1
  32. package/dist/services/settings/default-settings-provider.service.d.ts +40 -0
  33. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  34. package/dist/services/settings/default-settings-provider.service.js +22 -0
  35. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  36. package/dist/testing/adapters/ui/playwright-adapter.d.ts +5 -0
  37. package/dist/testing/adapters/ui/playwright-adapter.d.ts.map +1 -1
  38. package/dist/testing/adapters/ui/playwright-adapter.js +17 -1
  39. package/dist/testing/adapters/ui/playwright-adapter.js.map +1 -1
  40. package/dist/testing/adapters/ui/ui.types.d.ts +2 -0
  41. package/dist/testing/adapters/ui/ui.types.d.ts.map +1 -1
  42. package/dist/testing/adapters/ui/ui.types.js.map +1 -1
  43. package/dist/testing/steps/ui/actions.step.d.ts.map +1 -1
  44. package/dist/testing/steps/ui/actions.step.js +6 -2
  45. package/dist/testing/steps/ui/actions.step.js.map +1 -1
  46. package/dist/testing/steps/ui/assertions.step.d.ts.map +1 -1
  47. package/dist/testing/steps/ui/assertions.step.js +11 -3
  48. package/dist/testing/steps/ui/assertions.step.js.map +1 -1
  49. package/dist/testing/steps/ui/form.step.d.ts.map +1 -1
  50. package/dist/testing/steps/ui/form.step.js +9 -3
  51. package/dist/testing/steps/ui/form.step.js.map +1 -1
  52. package/dist/testing/steps/ui/manual.step.js +1 -1
  53. package/dist/testing/steps/ui/manual.step.js.map +1 -1
  54. package/dist/testing/steps/ui/navigation.step.d.ts.map +1 -1
  55. package/dist/testing/steps/ui/navigation.step.js +3 -1
  56. package/dist/testing/steps/ui/navigation.step.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/commands/run-tests.command.ts +47 -1
  59. package/src/constants/chatter-message.constants.ts +12 -0
  60. package/src/constants/user.constants.ts +13 -0
  61. package/src/entities/user.entity.ts +53 -36
  62. package/src/seeders/seed-data/solid-core-metadata.json +36 -6
  63. package/src/services/chatter-message.service.ts +60 -4
  64. package/src/services/crud.service.ts +31 -1
  65. package/src/services/media.service.ts +19 -33
  66. package/src/services/settings/default-settings-provider.service.ts +22 -0
  67. package/src/testing/README.md +64 -2
  68. package/src/testing/adapters/ui/playwright-adapter.ts +25 -0
  69. package/src/testing/adapters/ui/ui.types.ts +2 -0
  70. package/src/testing/steps/ui/actions.step.ts +8 -4
  71. package/src/testing/steps/ui/assertions.step.ts +18 -5
  72. package/src/testing/steps/ui/form.step.ts +16 -6
  73. package/src/testing/steps/ui/manual.step.ts +1 -1
  74. 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": false,
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 savedMessage;
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', 'user'];
960
+ const relations = ['chatterMessageDetails'];
921
961
  if (populate && populate.length > 0) {
922
962
  const normalizedPopulate = this.crudHelperService.normalize(populate);
923
- relations.push(...normalizedPopulate.filter(rel => !relations.includes(rel)));
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, buildStoredMediaFileName, getEffectiveS3Region, resolveMediaIsPublic, } from 'src/services/media-storage.utils';
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
- const savedMedias = [];
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
- switch (storageProvider.type) {
113
- case MediaStorageProviderType.Filesystem:
114
- const fileStoragePath = buildDiskMediaPath(fileName, this.settingService, storageProvider);
115
- await this.diskFileService.copy(file.path, fileStoragePath);
116
- createDto['relativeUri'] = fileName;
117
- break;
118
- case MediaStorageProviderType.AwsS3:
119
- const bucketName = storageProvider.bucketName;
120
- const region = getEffectiveS3Region(this.configService, storageProvider.region);
121
-
122
- // Read file from disk and upload to S3
123
- const fileData = await this.diskFileService.read(file.path);
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
- createDto['relativeUri'] = fileName;
127
- break;
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
- delete createDto['isPublic'];
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.
@@ -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: { baseUrl: "https://app.example.com", headless: true }
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
 
@@ -1,4 +1,6 @@
1
1
  export interface PlaywrightAdapterOptions {
2
2
  headless?: boolean;
3
3
  baseUrl?: string;
4
+ defaultTimeoutMs?: number;
5
+ navigationTimeoutMs?: number;
4
6
  }
@@ -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 = { selector: string; equals?: string; contains?: string };
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, { state: "visible" });
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, { state: "hidden" });
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.locator(input.selector).innerText();
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(