@solidxai/core 0.1.13-beta.5 → 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 (113) 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/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 +8 -4
  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/settings/default-settings-provider.service.d.ts +40 -0
  29. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  30. package/dist/services/settings/default-settings-provider.service.js +22 -0
  31. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  32. package/dist/testing/adapters/ui/playwright-adapter.d.ts +5 -0
  33. package/dist/testing/adapters/ui/playwright-adapter.d.ts.map +1 -1
  34. package/dist/testing/adapters/ui/playwright-adapter.js +17 -1
  35. package/dist/testing/adapters/ui/playwright-adapter.js.map +1 -1
  36. package/dist/testing/adapters/ui/ui.types.d.ts +2 -0
  37. package/dist/testing/adapters/ui/ui.types.d.ts.map +1 -1
  38. package/dist/testing/adapters/ui/ui.types.js.map +1 -1
  39. package/dist/testing/steps/ui/actions.step.d.ts.map +1 -1
  40. package/dist/testing/steps/ui/actions.step.js +6 -2
  41. package/dist/testing/steps/ui/actions.step.js.map +1 -1
  42. package/dist/testing/steps/ui/assertions.step.d.ts.map +1 -1
  43. package/dist/testing/steps/ui/assertions.step.js +11 -3
  44. package/dist/testing/steps/ui/assertions.step.js.map +1 -1
  45. package/dist/testing/steps/ui/form.step.d.ts.map +1 -1
  46. package/dist/testing/steps/ui/form.step.js +9 -3
  47. package/dist/testing/steps/ui/form.step.js.map +1 -1
  48. package/dist/testing/steps/ui/manual.step.js +1 -1
  49. package/dist/testing/steps/ui/manual.step.js.map +1 -1
  50. package/dist/testing/steps/ui/navigation.step.d.ts.map +1 -1
  51. package/dist/testing/steps/ui/navigation.step.js +3 -1
  52. package/dist/testing/steps/ui/navigation.step.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/commands/run-tests.command.ts +47 -1
  55. package/src/constants/chatter-message.constants.ts +12 -0
  56. package/src/constants/user.constants.ts +13 -0
  57. package/src/entities/user.entity.ts +53 -36
  58. package/src/seeders/seed-data/solid-core-metadata.json +8 -4
  59. package/src/services/1.js +6 -0
  60. package/src/services/chatter-message.service.ts +60 -4
  61. package/src/services/crud.service.ts +31 -1
  62. package/src/services/settings/default-settings-provider.service.ts +22 -0
  63. package/src/testing/README.md +64 -2
  64. package/src/testing/adapters/ui/playwright-adapter.ts +25 -0
  65. package/src/testing/adapters/ui/ui.types.ts +2 -0
  66. package/src/testing/steps/ui/actions.step.ts +8 -4
  67. package/src/testing/steps/ui/assertions.step.ts +18 -5
  68. package/src/testing/steps/ui/form.step.ts +16 -6
  69. package/src/testing/steps/ui/manual.step.ts +1 -1
  70. package/src/testing/steps/ui/navigation.step.ts +4 -2
  71. package/dist-tests/api/authenticate.spec.js +0 -119
  72. package/dist-tests/api/authenticate.spec.js.map +0 -1
  73. package/dist-tests/api/crud-service.findOne.cityMaster.spec.js +0 -97
  74. package/dist-tests/api/crud-service.findOne.cityMaster.spec.js.map +0 -1
  75. package/dist-tests/api/ping.spec.js +0 -21
  76. package/dist-tests/api/ping.spec.js.map +0 -1
  77. package/dist-tests/helpers/auth.js +0 -41
  78. package/dist-tests/helpers/auth.js.map +0 -1
  79. package/dist-tests/helpers/env.js +0 -11
  80. package/dist-tests/helpers/env.js.map +0 -1
  81. package/docs/agent-builder/notebook-lm-prompt-for-agent-builder.md +0 -136
  82. package/docs/agent-builder/team-ready-note-agent-builder.md +0 -305
  83. package/docs/agent-hub-grooming.md +0 -301
  84. package/docs/dashboards/AGENTIC_DASHBOARD_IMPLEMENTATION_PLAN.md +0 -438
  85. package/docs/dashboards/dashboard-curl-smoke-tests.txt +0 -146
  86. package/docs/dashboards/delete-legacy-dashboard-metadata.sql +0 -172
  87. package/docs/datasource-introspection-ddl-analysis.md +0 -326
  88. package/docs/datasource-introspection-implementation-plan.md +0 -306
  89. package/docs/grouping-enhancements.md +0 -89
  90. package/docs/java-spring/README.md +0 -3
  91. package/docs/java-spring/solid-core-module-deep-dive-report.md +0 -1317
  92. package/docs/module-package-import-handoff.md +0 -691
  93. package/docs/seed-changes.md +0 -65
  94. package/docs/test-data-workflow.md +0 -200
  95. package/docs/type-declaration-import-issue.md +0 -24
  96. package/docs/workflow/business-automation-example-notes.md +0 -309
  97. package/docs/workflow/control-flow-node-addition-sop.md +0 -324
  98. package/docs/workflow/data-engineering-pipeline-example-notes.md +0 -330
  99. package/docs/workflow/foreach-example-notes.md +0 -187
  100. package/docs/workflow/hello-world-example-notes.md +0 -271
  101. package/docs/workflow/kestra-concepts-plugins-blueprints.md +0 -315
  102. package/docs/workflow/loop-until-example-notes.md +0 -198
  103. package/docs/workflow/microservices-and-apis-example-notes.md +0 -264
  104. package/docs/workflow/samples.md +0 -25
  105. package/docs/workflow/what-is-kestra.md +0 -79
  106. package/docs/workflow/workflow-core-module-handoff-summary.md +0 -191
  107. package/docs/workflow/workflow-module-brd.md +0 -185
  108. package/docs/workflow/workflow-module-domain-model.md +0 -252
  109. package/docs/workflow/workflow-module-metadata-dsl.md +0 -302
  110. package/docs/workflow/workflow-module-technical-spec.md +0 -293
  111. package/docs/workflow/workflow-node-type-contracts.md +0 -635
  112. package/docs/workflow/workflow-node-ui-metadata-contract.md +0 -1262
  113. package/docs/workflow/workflow-solidx-model-field-spec.md +0 -284
@@ -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
  }
@@ -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()) {
@@ -2,7 +2,7 @@ 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 GotoInput = { url: string };
5
+ type GotoInput = { url: string; timeoutMs?: number };
6
6
  type ExpectUrlInput = { equals?: string; contains?: string };
7
7
 
8
8
  function requirePage(ctx: TestContext, op: string) {
@@ -20,7 +20,9 @@ export function registerNavigationSteps(registry: StepRegistry): void {
20
20
  throw new Error('Missing "url" in step.with for op "ui.goto"');
21
21
  }
22
22
  const url = ctx.ui?.resolveUrl(input.url) ?? input.url;
23
- await page.goto(url);
23
+ await page.goto(url, {
24
+ timeout: ctx.ui?.resolveNavigationTimeout(input.timeoutMs),
25
+ });
24
26
  });
25
27
 
26
28
  registry.register("ui.expectUrl", async (ctx: TestContext, step: OpStep) => {