@solidxai/core 0.1.13-beta.4 → 0.1.13-beta.6

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 (118) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/CHANGELOG.md +1053 -0
  3. package/CLAUDE.md +26 -0
  4. package/dist/commands/run-tests.command.d.ts +7 -1
  5. package/dist/commands/run-tests.command.d.ts.map +1 -1
  6. package/dist/commands/run-tests.command.js +49 -3
  7. package/dist/commands/run-tests.command.js.map +1 -1
  8. package/dist/constants/chatter-message.constants.d.ts +1 -0
  9. package/dist/constants/chatter-message.constants.d.ts.map +1 -1
  10. package/dist/constants/chatter-message.constants.js +3 -1
  11. package/dist/constants/chatter-message.constants.js.map +1 -1
  12. package/dist/constants/user.constants.d.ts +2 -0
  13. package/dist/constants/user.constants.d.ts.map +1 -0
  14. package/dist/constants/user.constants.js +5 -0
  15. package/dist/constants/user.constants.js.map +1 -0
  16. package/dist/controllers/chatter-message.controller.d.ts +5 -0
  17. package/dist/controllers/chatter-message.controller.d.ts.map +1 -1
  18. package/dist/controllers/chatter-message.controller.js +14 -0
  19. package/dist/controllers/chatter-message.controller.js.map +1 -1
  20. package/dist/entities/user.entity.d.ts.map +1 -1
  21. package/dist/entities/user.entity.js +36 -0
  22. package/dist/entities/user.entity.js.map +1 -1
  23. package/dist/seeders/seed-data/solid-core-metadata.json +12 -7
  24. package/dist/services/chatter-message.service.d.ts +10 -0
  25. package/dist/services/chatter-message.service.d.ts.map +1 -1
  26. package/dist/services/chatter-message.service.js +44 -3
  27. package/dist/services/chatter-message.service.js.map +1 -1
  28. package/dist/services/crud.service.d.ts +2 -0
  29. package/dist/services/crud.service.d.ts.map +1 -1
  30. package/dist/services/crud.service.js +13 -1
  31. package/dist/services/crud.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/controllers/chatter-message.controller.ts +8 -0
  62. package/src/entities/user.entity.ts +53 -36
  63. package/src/seeders/seed-data/solid-core-metadata.json +12 -7
  64. package/src/services/1.js +6 -0
  65. package/src/services/chatter-message.service.ts +86 -4
  66. package/src/services/crud.service.ts +31 -1
  67. package/src/services/settings/default-settings-provider.service.ts +22 -0
  68. package/src/testing/README.md +64 -2
  69. package/src/testing/adapters/ui/playwright-adapter.ts +25 -0
  70. package/src/testing/adapters/ui/ui.types.ts +2 -0
  71. package/src/testing/steps/ui/actions.step.ts +8 -4
  72. package/src/testing/steps/ui/assertions.step.ts +18 -5
  73. package/src/testing/steps/ui/form.step.ts +16 -6
  74. package/src/testing/steps/ui/manual.step.ts +1 -1
  75. package/src/testing/steps/ui/navigation.step.ts +4 -2
  76. package/dist-tests/api/authenticate.spec.js +0 -119
  77. package/dist-tests/api/authenticate.spec.js.map +0 -1
  78. package/dist-tests/api/crud-service.findOne.cityMaster.spec.js +0 -97
  79. package/dist-tests/api/crud-service.findOne.cityMaster.spec.js.map +0 -1
  80. package/dist-tests/api/ping.spec.js +0 -21
  81. package/dist-tests/api/ping.spec.js.map +0 -1
  82. package/dist-tests/helpers/auth.js +0 -41
  83. package/dist-tests/helpers/auth.js.map +0 -1
  84. package/dist-tests/helpers/env.js +0 -11
  85. package/dist-tests/helpers/env.js.map +0 -1
  86. package/docs/agent-builder/notebook-lm-prompt-for-agent-builder.md +0 -136
  87. package/docs/agent-builder/team-ready-note-agent-builder.md +0 -305
  88. package/docs/agent-hub-grooming.md +0 -301
  89. package/docs/dashboards/AGENTIC_DASHBOARD_IMPLEMENTATION_PLAN.md +0 -438
  90. package/docs/dashboards/dashboard-curl-smoke-tests.txt +0 -146
  91. package/docs/dashboards/delete-legacy-dashboard-metadata.sql +0 -172
  92. package/docs/datasource-introspection-ddl-analysis.md +0 -326
  93. package/docs/datasource-introspection-implementation-plan.md +0 -306
  94. package/docs/grouping-enhancements.md +0 -89
  95. package/docs/java-spring/README.md +0 -3
  96. package/docs/java-spring/solid-core-module-deep-dive-report.md +0 -1317
  97. package/docs/module-package-import-handoff.md +0 -691
  98. package/docs/seed-changes.md +0 -65
  99. package/docs/test-data-workflow.md +0 -200
  100. package/docs/type-declaration-import-issue.md +0 -24
  101. package/docs/workflow/business-automation-example-notes.md +0 -309
  102. package/docs/workflow/control-flow-node-addition-sop.md +0 -324
  103. package/docs/workflow/data-engineering-pipeline-example-notes.md +0 -330
  104. package/docs/workflow/foreach-example-notes.md +0 -187
  105. package/docs/workflow/hello-world-example-notes.md +0 -271
  106. package/docs/workflow/kestra-concepts-plugins-blueprints.md +0 -315
  107. package/docs/workflow/loop-until-example-notes.md +0 -198
  108. package/docs/workflow/microservices-and-apis-example-notes.md +0 -264
  109. package/docs/workflow/samples.md +0 -25
  110. package/docs/workflow/what-is-kestra.md +0 -79
  111. package/docs/workflow/workflow-core-module-handoff-summary.md +0 -191
  112. package/docs/workflow/workflow-module-brd.md +0 -185
  113. package/docs/workflow/workflow-module-domain-model.md +0 -252
  114. package/docs/workflow/workflow-module-metadata-dsl.md +0 -302
  115. package/docs/workflow/workflow-module-technical-spec.md +0 -293
  116. package/docs/workflow/workflow-node-type-contracts.md +0 -635
  117. package/docs/workflow/workflow-node-ui-metadata-contract.md +0 -1262
  118. package/docs/workflow/workflow-solidx-model-field-spec.md +0 -284
@@ -6141,7 +6141,7 @@
6141
6141
  "type": "int",
6142
6142
  "ormType": "integer",
6143
6143
  "isSystem": false,
6144
- "defaultValue": 1,
6144
+ "defaultValue": "1",
6145
6145
  "min": null,
6146
6146
  "max": null,
6147
6147
  "required": true,
@@ -6162,7 +6162,7 @@
6162
6162
  "type": "int",
6163
6163
  "ormType": "integer",
6164
6164
  "isSystem": false,
6165
- "defaultValue": 0,
6165
+ "defaultValue": "0",
6166
6166
  "min": null,
6167
6167
  "max": null,
6168
6168
  "required": false,
@@ -7367,7 +7367,7 @@
7367
7367
  "type": "boolean",
7368
7368
  "ormType": "boolean",
7369
7369
  "isSystem": false,
7370
- "defaultValue": false,
7370
+ "defaultValue": "false",
7371
7371
  "required": false,
7372
7372
  "unique": false,
7373
7373
  "index": true,
@@ -7868,6 +7868,7 @@
7868
7868
  "ChatterMessageController.findMany",
7869
7869
  "ChatterMessageController.markCompleted",
7870
7870
  "ChatterMessageController.updateCustomNoteMessage",
7871
+ "ChatterMessageController.getMentionableUsers",
7871
7872
  "ListOfValuesController.findMany",
7872
7873
  "ListOfValuesController.findOne",
7873
7874
  "SettingController.getAllSettings",
@@ -10433,7 +10434,8 @@
10433
10434
  "attrs": {
10434
10435
  "name": "user",
10435
10436
  "isSearchable": true,
10436
- "searchField": "user.fullName"
10437
+ "searchField": "user.fullName",
10438
+ "coModelFieldToDisplay": "fullName"
10437
10439
  }
10438
10440
  }
10439
10441
  ]
@@ -10517,7 +10519,8 @@
10517
10519
  {
10518
10520
  "type": "field",
10519
10521
  "attrs": {
10520
- "name": "user"
10522
+ "name": "user",
10523
+ "coModelFieldToDisplay": "fullName"
10521
10524
  }
10522
10525
  },
10523
10526
  {
@@ -14855,7 +14858,8 @@
14855
14858
  "name": "user",
14856
14859
  "label": "User",
14857
14860
  "isSearchable": true,
14858
- "searchField": "user.fullName"
14861
+ "searchField": "user.fullName",
14862
+ "coModelFieldToDisplay": "fullName"
14859
14863
  }
14860
14864
  },
14861
14865
  {
@@ -17077,7 +17081,8 @@
17077
17081
  "name": "user",
17078
17082
  "label": "User",
17079
17083
  "isSearchable": true,
17080
- "searchField": "user.fullName"
17084
+ "searchField": "user.fullName",
17085
+ "coModelFieldToDisplay": "fullName"
17081
17086
  }
17082
17087
  }
17083
17088
  ]
@@ -0,0 +1,6 @@
1
+ 1. Do i need to create a storeStreams method for aws service too?
2
+ - Handle later
3
+ 2. queues handling -> if queues is enabled by default, i.e triggerExport(exportTransactionEntity.id).
4
+ - startExport should either return the data or return the transaction id
5
+ 3. How to handle scenarios wherein, nested related exist.(do i need to only get the userkey)
6
+ - show the userKey
@@ -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,9 +18,11 @@ 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';
25
+ import { User } from '../entities/user.entity';
24
26
  import { getMediaStorageProvider } from './mediaStorageProviders';
25
27
  import { RequestContextService } from './request-context.service';
26
28
  import { Logger } from '@nestjs/common';
@@ -101,6 +103,44 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
101
103
  chatterMessage.updatedBy = resolvedUserId;
102
104
  }
103
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
+
104
144
  private isEditableCustomNoteMessage(message: ChatterMessage): boolean {
105
145
  if (message.messageType !== CHATTER_MESSAGE_TYPE.CUSTOM) {
106
146
  return false;
@@ -188,6 +228,31 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
188
228
  }
189
229
  }
190
230
 
231
+ // Deliberately bypasses UserRepository's security-rule filtering: the mention picker
232
+ // needs id/username/fullName for any active user regardless of the caller's row-level
233
+ // access to the User model, same as the getChatterMessages one-to-many lookup below.
234
+ async getMentionableUsers(search?: string, limit: number = 8): Promise<Array<{ id: number; username: string; fullName: string }>> {
235
+ const normalizedLimit = Number.isInteger(limit) && limit > 0 && limit <= 50 ? limit : 8;
236
+ const userRepository = this.entityManager.getRepository(User);
237
+
238
+ const qb = userRepository
239
+ .createQueryBuilder('user')
240
+ .select(['user.id', 'user.username', 'user.fullName'])
241
+ .where('user.active = :active', { active: true });
242
+
243
+ const trimmedSearch = (search ?? '').trim();
244
+ if (trimmedSearch) {
245
+ qb.andWhere('(LOWER(user.username) LIKE :search OR LOWER(user.fullName) LIKE :search)', {
246
+ search: `%${trimmedSearch.toLowerCase()}%`,
247
+ });
248
+ }
249
+
250
+ qb.orderBy('user.username', 'ASC').take(normalizedLimit);
251
+
252
+ const users = await qb.getMany();
253
+ return users.map(user => ({ id: user.id, username: user.username, fullName: user.fullName }));
254
+ }
255
+
191
256
  async markCompleted(id: number) {
192
257
  const activeUser = this.requestContextService.getActiveUser();
193
258
  if (!activeUser) {
@@ -306,7 +371,8 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
306
371
 
307
372
  await this.publishChatterMentionNotifications(savedMessage, model);
308
373
 
309
- return savedMessage;
374
+ // The `user` relation was loaded for the ownership check above; don't return all of it.
375
+ return this.trimMessageUser(savedMessage);
310
376
  }
311
377
 
312
378
  async postMessage(postDto: PostChatterMessageDto, files: Express.Multer.File[] = []) {
@@ -891,16 +957,25 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
891
957
  qb.where(orConditions.join(' OR '), parameters);
892
958
  }));
893
959
 
894
- const relations = ['chatterMessageDetails', 'user'];
960
+ const relations = ['chatterMessageDetails'];
895
961
  if (populate && populate.length > 0) {
896
962
  const normalizedPopulate = this.crudHelperService.normalize(populate);
897
- 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)));
898
967
  }
899
968
 
900
969
  relations.forEach(relation => {
901
970
  qb.leftJoinAndSelect(`entity.${relation}`, relation);
902
971
  });
903
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
+
904
979
  if (filters) {
905
980
  qb.andWhere(new Brackets(whereQb => {
906
981
  this.crudHelperService.applyFilters(whereQb, filters, 'entity', qb);
@@ -914,6 +989,13 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
914
989
  const [entities, count] = await qb.getManyAndCount();
915
990
  this.logHeapUsed('getChatterMessages-entitiesLoaded');
916
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
+
917
999
  // Convert date strings in message details to ISO format for consistent handling on the frontend
918
1000
  const DATE_FIELD_TYPES = ['date', 'datetime', 'time'];
919
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
  }
@@ -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(
@@ -4,9 +4,13 @@ import type { TestContext } from "../../contracts/runtime-context.types";
4
4
  import type { OpStep } from "../../contracts/testing-metadata.types";
5
5
  import { StepRegistry } from "../../core/step-registry";
6
6
 
7
- type FillInput = { selector: string; value: string };
8
- type SelectInput = { selector: string; value: string };
9
- type UploadFileInput = { selector: string; filePath: string | string[] };
7
+ type FillInput = { selector: string; value: string; timeoutMs?: number };
8
+ type SelectInput = { selector: string; value: string; timeoutMs?: number };
9
+ type UploadFileInput = {
10
+ selector: string;
11
+ filePath: string | string[];
12
+ timeoutMs?: number;
13
+ };
10
14
 
11
15
  function requirePage(ctx: TestContext, op: string) {
12
16
  if (!ctx.ui || !ctx.ui.page) {
@@ -25,7 +29,9 @@ export function registerFormSteps(registry: StepRegistry): void {
25
29
  if (input.value === undefined) {
26
30
  throw new Error('Missing "value" in step.with for op "ui.fill"');
27
31
  }
28
- await page.fill(input.selector, String(input.value));
32
+ await page.fill(input.selector, String(input.value), {
33
+ timeout: ctx.ui?.resolveTimeout(input.timeoutMs),
34
+ });
29
35
  });
30
36
 
31
37
  registry.register("ui.select", async (ctx: TestContext, step: OpStep) => {
@@ -37,7 +43,9 @@ export function registerFormSteps(registry: StepRegistry): void {
37
43
  if (input.value === undefined) {
38
44
  throw new Error('Missing "value" in step.with for op "ui.select"');
39
45
  }
40
- await page.selectOption(input.selector, String(input.value));
46
+ await page.selectOption(input.selector, String(input.value), {
47
+ timeout: ctx.ui?.resolveTimeout(input.timeoutMs),
48
+ });
41
49
  });
42
50
 
43
51
  registry.register("ui.uploadFile", async (ctx: TestContext, step: OpStep) => {
@@ -53,6 +61,8 @@ export function registerFormSteps(registry: StepRegistry): void {
53
61
  const resolved = Array.isArray(input.filePath)
54
62
  ? input.filePath.map((p) => path.resolve(cwd, String(p)))
55
63
  : path.resolve(cwd, String(input.filePath));
56
- await page.setInputFiles(input.selector, resolved);
64
+ await page.setInputFiles(input.selector, resolved, {
65
+ timeout: ctx.ui?.resolveTimeout(input.timeoutMs),
66
+ });
57
67
  });
58
68
  }
@@ -28,7 +28,7 @@ export function registerManualSteps(registry: StepRegistry): void {
28
28
  const inputConfig = (step.with ?? {}) as WaitForManualInput;
29
29
  const message = inputConfig.message?.trim() || "Manual interaction required.";
30
30
  const prompt = inputConfig.prompt?.trim() || "Press Enter to continue...";
31
- const timeoutMs = inputConfig.timeoutMs;
31
+ const timeoutMs = ctx.ui?.resolveTimeout(inputConfig.timeoutMs);
32
32
  const bringToFront = inputConfig.bringToFront ?? true;
33
33
 
34
34
  if (ctx.ui?.isHeadless()) {