@solidxai/core 0.1.13-beta.11 → 0.1.13-beta.13

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 (103) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/CHANGELOG.md +1128 -0
  3. package/CLAUDE.md +26 -0
  4. package/dist/commands/run-tests.command.d.ts +2 -0
  5. package/dist/commands/run-tests.command.d.ts.map +1 -1
  6. package/dist/commands/run-tests.command.js +20 -3
  7. package/dist/commands/run-tests.command.js.map +1 -1
  8. package/dist/seeders/seed-data/solid-core-metadata.json +1 -1
  9. package/dist/services/chatter-message.service.d.ts +4 -1
  10. package/dist/services/chatter-message.service.d.ts.map +1 -1
  11. package/dist/services/chatter-message.service.js +4 -1
  12. package/dist/services/chatter-message.service.js.map +1 -1
  13. package/dist/services/crud-helper.service.d.ts +1 -2
  14. package/dist/services/crud-helper.service.d.ts.map +1 -1
  15. package/dist/services/crud-helper.service.js +2 -16
  16. package/dist/services/crud-helper.service.js.map +1 -1
  17. package/dist/services/crud.service.d.ts.map +1 -1
  18. package/dist/services/crud.service.js +1 -1
  19. package/dist/services/crud.service.js.map +1 -1
  20. package/dist/services/draft-publish-helper.service.d.ts +1 -0
  21. package/dist/services/draft-publish-helper.service.d.ts.map +1 -1
  22. package/dist/services/draft-publish-helper.service.js +38 -4
  23. package/dist/services/draft-publish-helper.service.js.map +1 -1
  24. package/dist/services/settings/default-settings-provider.service.d.ts +62 -6
  25. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  26. package/dist/services/settings/default-settings-provider.service.js +34 -3
  27. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  28. package/dist/testing/reporter/console-reporter.d.ts +3 -0
  29. package/dist/testing/reporter/console-reporter.d.ts.map +1 -1
  30. package/dist/testing/reporter/console-reporter.js +2 -0
  31. package/dist/testing/reporter/console-reporter.js.map +1 -1
  32. package/dist/testing/reporter/progress-reporter.d.ts +64 -0
  33. package/dist/testing/reporter/progress-reporter.d.ts.map +1 -0
  34. package/dist/testing/reporter/progress-reporter.js +224 -0
  35. package/dist/testing/reporter/progress-reporter.js.map +1 -0
  36. package/dist/testing/reporter/reporter.types.d.ts +3 -0
  37. package/dist/testing/reporter/reporter.types.d.ts.map +1 -1
  38. package/dist/testing/reporter/reporter.types.js.map +1 -1
  39. package/dist/testing/runner/run-from-metadata.d.ts.map +1 -1
  40. package/dist/testing/runner/run-from-metadata.js +1 -0
  41. package/dist/testing/runner/run-from-metadata.js.map +1 -1
  42. package/dist/theme/theme-registry.d.ts +14 -0
  43. package/dist/theme/theme-registry.d.ts.map +1 -0
  44. package/dist/theme/theme-registry.js +147 -0
  45. package/dist/theme/theme-registry.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/commands/run-tests.command.ts +17 -3
  48. package/src/seeders/seed-data/solid-core-metadata.json +1 -1
  49. package/src/services/1.js +6 -0
  50. package/src/services/chatter-message.service.ts +22 -1
  51. package/src/services/crud-helper.service.ts +19 -16
  52. package/src/services/crud.service.ts +2 -1
  53. package/src/services/draft-publish-helper.service.ts +56 -4
  54. package/src/services/settings/default-settings-provider.service.ts +34 -3
  55. package/src/testing/README.md +2 -1
  56. package/src/testing/reporter/console-reporter.ts +4 -0
  57. package/src/testing/reporter/progress-reporter.ts +251 -0
  58. package/src/testing/reporter/reporter.types.ts +3 -0
  59. package/src/testing/runner/run-from-metadata.ts +1 -0
  60. package/src/theme/theme-registry.ts +181 -0
  61. package/dist-tests/api/authenticate.spec.js +0 -119
  62. package/dist-tests/api/authenticate.spec.js.map +0 -1
  63. package/dist-tests/api/crud-service.findOne.cityMaster.spec.js +0 -97
  64. package/dist-tests/api/crud-service.findOne.cityMaster.spec.js.map +0 -1
  65. package/dist-tests/api/ping.spec.js +0 -21
  66. package/dist-tests/api/ping.spec.js.map +0 -1
  67. package/dist-tests/helpers/auth.js +0 -41
  68. package/dist-tests/helpers/auth.js.map +0 -1
  69. package/dist-tests/helpers/env.js +0 -11
  70. package/dist-tests/helpers/env.js.map +0 -1
  71. package/docs/agent-builder/notebook-lm-prompt-for-agent-builder.md +0 -136
  72. package/docs/agent-builder/team-ready-note-agent-builder.md +0 -305
  73. package/docs/agent-hub-grooming.md +0 -301
  74. package/docs/dashboards/AGENTIC_DASHBOARD_IMPLEMENTATION_PLAN.md +0 -438
  75. package/docs/dashboards/dashboard-curl-smoke-tests.txt +0 -146
  76. package/docs/dashboards/delete-legacy-dashboard-metadata.sql +0 -172
  77. package/docs/datasource-introspection-ddl-analysis.md +0 -326
  78. package/docs/datasource-introspection-implementation-plan.md +0 -306
  79. package/docs/grouping-enhancements.md +0 -89
  80. package/docs/java-spring/README.md +0 -3
  81. package/docs/java-spring/solid-core-module-deep-dive-report.md +0 -1317
  82. package/docs/module-package-import-handoff.md +0 -691
  83. package/docs/seed-changes.md +0 -65
  84. package/docs/test-data-workflow.md +0 -200
  85. package/docs/type-declaration-import-issue.md +0 -24
  86. package/docs/workflow/business-automation-example-notes.md +0 -309
  87. package/docs/workflow/control-flow-node-addition-sop.md +0 -324
  88. package/docs/workflow/data-engineering-pipeline-example-notes.md +0 -330
  89. package/docs/workflow/foreach-example-notes.md +0 -187
  90. package/docs/workflow/hello-world-example-notes.md +0 -271
  91. package/docs/workflow/kestra-concepts-plugins-blueprints.md +0 -315
  92. package/docs/workflow/loop-until-example-notes.md +0 -198
  93. package/docs/workflow/microservices-and-apis-example-notes.md +0 -264
  94. package/docs/workflow/samples.md +0 -25
  95. package/docs/workflow/what-is-kestra.md +0 -79
  96. package/docs/workflow/workflow-core-module-handoff-summary.md +0 -191
  97. package/docs/workflow/workflow-module-brd.md +0 -185
  98. package/docs/workflow/workflow-module-domain-model.md +0 -252
  99. package/docs/workflow/workflow-module-metadata-dsl.md +0 -302
  100. package/docs/workflow/workflow-module-technical-spec.md +0 -293
  101. package/docs/workflow/workflow-node-type-contracts.md +0 -635
  102. package/docs/workflow/workflow-node-ui-metadata-contract.md +0 -1262
  103. package/docs/workflow/workflow-solidx-model-field-spec.md +0 -284
@@ -3,6 +3,7 @@ import { SubCommand, CommandRunner, Option } from 'nest-commander';
3
3
  import * as path from 'path';
4
4
  import { ModuleMetadataHelperService } from 'src/helpers/module-metadata-helper.service';
5
5
  import { ConsoleReporter } from 'src/testing/reporter/console-reporter';
6
+ import { ProgressReporter } from 'src/testing/reporter/progress-reporter';
6
7
  import { WebhookReporter } from 'src/testing/reporter/webhook-reporter';
7
8
  import { runFromMetadata } from 'src/testing/runner/run-from-metadata';
8
9
  import type { TestingMetadata } from 'src/testing/contracts/testing-metadata.types';
@@ -27,6 +28,7 @@ interface TestRunCommandOptions {
27
28
  listSpecs?: boolean;
28
29
  printApiLogs?: boolean;
29
30
  runName?: string;
31
+ progress?: boolean;
30
32
  }
31
33
 
32
34
  @SubCommand({
@@ -106,9 +108,12 @@ export class TestRunCommand extends CommandRunner {
106
108
 
107
109
  const webhookUrl = process.env.SOLIDCTL_WEBHOOK_URL;
108
110
  const runName = options?.runName ?? `run-${Date.now()}`;
109
- const reporter = webhookUrl
111
+ const baseReporter = webhookUrl
110
112
  ? new WebhookReporter(webhookUrl, runName)
111
113
  : new ConsoleReporter();
114
+ const reporter = options?.progress === false
115
+ ? baseReporter
116
+ : new ProgressReporter(baseReporter);
112
117
 
113
118
  try {
114
119
  await runFromMetadata({
@@ -138,8 +143,8 @@ export class TestRunCommand extends CommandRunner {
138
143
  console.error('Run tests command failed');
139
144
  process.exitCode = 1;
140
145
  } finally {
141
- if (reporter instanceof WebhookReporter) {
142
- await reporter.flush(Number(process.exitCode ?? 0));
146
+ if (baseReporter instanceof WebhookReporter) {
147
+ await baseReporter.flush(Number(process.exitCode ?? 0));
143
148
  }
144
149
  }
145
150
  return;
@@ -289,6 +294,15 @@ export class TestRunCommand extends CommandRunner {
289
294
  parseRunName(val: string): string {
290
295
  return val;
291
296
  }
297
+
298
+ @Option({
299
+ flags: '--no-progress',
300
+ description: 'Disable the interactive test progress footer.',
301
+ required: false,
302
+ })
303
+ parseNoProgress(): boolean {
304
+ return false;
305
+ }
292
306
  }
293
307
 
294
308
  function splitCsv(value?: string): string[] | undefined {
@@ -12591,7 +12591,7 @@
12591
12591
  50
12592
12592
  ],
12593
12593
  "enableGlobalSearch": true,
12594
- "create": true,
12594
+ "create": false,
12595
12595
  "edit": true,
12596
12596
  "delete": true,
12597
12597
  "import": true,
@@ -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
@@ -37,6 +37,16 @@ interface ChatterMention {
37
37
  id?: string | number;
38
38
  }
39
39
 
40
+ /**
41
+ * Server-side-only overrides for `postMessage`. Not part of `PostChatterMessageDto`, so
42
+ * nothing here is settable over HTTP - only in-process callers such as migration or
43
+ * back-fill services can supply it.
44
+ */
45
+ export interface PostChatterMessageOptions {
46
+ /** Backdate the message instead of stamping it with the current time. */
47
+ createdAt?: Date;
48
+ }
49
+
40
50
  @Injectable()
41
51
  export class ChatterMessageService extends CRUDService<ChatterMessage> {
42
52
  private readonly _logger = new Logger(ChatterMessageService.name);
@@ -389,7 +399,14 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
389
399
  return this.trimMessageUser(savedMessage);
390
400
  }
391
401
 
392
- async postMessage(postDto: PostChatterMessageDto, files: Express.Multer.File[] = []) {
402
+ /**
403
+ * Post a chatter message.
404
+ *
405
+ * `options` is deliberately kept out of `PostChatterMessageDto` so it can only be supplied
406
+ * by server-side callers (migrations, back-fills). The controller binds the DTO alone, so
407
+ * an API client has no way to backdate a message.
408
+ */
409
+ async postMessage(postDto: PostChatterMessageDto, files: Express.Multer.File[] = [], options: PostChatterMessageOptions = {}) {
393
410
  const coModelName = lowerFirst(postDto.coModelName);
394
411
  await this.assertRecordAccess(coModelName, postDto.coModelEntityId);
395
412
 
@@ -404,6 +421,10 @@ export class ChatterMessageService extends CRUDService<ChatterMessage> {
404
421
  chatterMessage.coModelEntityId = postDto.coModelEntityId;
405
422
  chatterMessage.coModelName = coModelName;
406
423
  chatterMessage.modelUserKey = postDto.modelUserKey ?? null;
424
+ // Left unset otherwise, so the @CreateDateColumn default still applies.
425
+ if (options.createdAt) {
426
+ chatterMessage.createdAt = options.createdAt;
427
+ }
407
428
 
408
429
  const model = await this.modelMetadataRepo.findOne({
409
430
  where: { singularName: coModelName },
@@ -889,25 +889,25 @@ export class CrudHelperService {
889
889
  };
890
890
  }
891
891
 
892
- async countGroupedRecords(qb: SelectQueryBuilder<any>, basicFilterDto: BasicFilterDto, entityAlias: string) { //TODO : Check how to pass a type to SelectQueryBuilder instead of any
893
- const { limit, offset, ...rest } = basicFilterDto;
894
- const filteredDto = { ...rest, limit: undefined, offset: undefined };
892
+ // async countGroupedRecords(qb: SelectQueryBuilder<any>, basicFilterDto: BasicFilterDto, entityAlias: string, internationalisation?: boolean, draftPublishWorkflow?: boolean, moduleRef?: any) { //TODO : Check how to pass a type to SelectQueryBuilder instead of any
893
+ // const { limit, offset, ...rest } = basicFilterDto;
894
+ // const filteredDto = { ...rest, limit: undefined, offset: undefined };
895
895
 
896
- const filteredQB = this.buildFilterQuery(qb, filteredDto as BasicFilterDto, entityAlias, undefined, undefined, undefined, FilterCombinator.AND, false, false);
896
+ // const filteredQB = this.buildFilterQuery(qb, filteredDto as BasicFilterDto, entityAlias, internationalisation, draftPublishWorkflow, moduleRef, FilterCombinator.AND, false, false);
897
897
 
898
- const groupByFields = this.normalize(filteredDto.groupBy);
898
+ // const groupByFields = this.normalize(filteredDto.groupBy);
899
899
 
900
- if (!groupByFields || groupByFields.length === 0) {
901
- throw new Error(ERROR_MESSAGES.INVALID_GROUP_BY_COUNT);
902
- }
900
+ // if (!groupByFields || groupByFields.length === 0) {
901
+ // throw new Error(ERROR_MESSAGES.INVALID_GROUP_BY_COUNT);
902
+ // }
903
903
 
904
- this.applyGroupBySelections(filteredQB, groupByFields, entityAlias);
905
- this.applyAggregates(filteredQB, ['count'], entityAlias);
906
- filteredQB.limit(undefined).offset(undefined).take(undefined).skip(undefined);
904
+ // this.applyGroupBySelections(filteredQB, groupByFields, entityAlias);
905
+ // this.applyAggregates(filteredQB, ['count'], entityAlias);
906
+ // filteredQB.limit(undefined).offset(undefined).take(undefined).skip(undefined);
907
907
 
908
- const rawResults = await filteredQB.getRawMany();
909
- return rawResults.length;
910
- }
908
+ // const rawResults = await filteredQB.getRawMany();
909
+ // return rawResults.length;
910
+ // }
911
911
 
912
912
  hasReadPermissionOnModel = (activeUser: ActiveUserData, modelName: string) => {
913
913
  const permissionNames = [`${classify(modelName)}Controller.findOne`, `${classify(modelName)}Controller.findMany`];
@@ -980,7 +980,10 @@ export class CrudHelperService {
980
980
  basicFilterDto: BasicFilterDto,
981
981
  alias: string,
982
982
  createQbFn: () => Promise<SelectQueryBuilder<T>>,
983
- postProcessEntities?: (entities: T[]) => Promise<void>
983
+ postProcessEntities?: (entities: T[]) => Promise<void>,
984
+ internationalisation?: boolean,
985
+ draftPublishWorkflow?: boolean,
986
+ moduleRef?: any
984
987
  ): Promise<{ meta: { totalRecords: number }; groupMeta: any[]; groupRecords: any[] }> {
985
988
  const groupByFields = this.normalize(basicFilterDto.groupBy);
986
989
  if (!groupByFields.length) throw new BadRequestException(ERROR_MESSAGES.INVALID_GROUP_BY_COUNT);
@@ -1015,7 +1018,7 @@ export class CrudHelperService {
1015
1018
  aggregates: undefined,
1016
1019
  sort: basicFilterDto.groupFilter?.sort,
1017
1020
  };
1018
- groupQb = this.buildFilterQuery(groupQb, groupFilterDto, alias);
1021
+ groupQb = this.buildFilterQuery(groupQb, groupFilterDto, alias, internationalisation, draftPublishWorkflow, moduleRef);
1019
1022
  groupQb = this.buildGroupByRecordsQuery(groupQb, group, alias, groupAliasMap, aggregateAliasMap, groupExpressionMap);
1020
1023
  const [entities, count] = await groupQb.getManyAndCount();
1021
1024
  if (postProcessEntities) await postProcessEntities(entities);
@@ -583,7 +583,8 @@ private async prepareManyToManyAuditSnapshot(entity: T,id: number,modelSingularN
583
583
  async (entities) => {
584
584
  if (populateUserIdFields?.length) await this.handlePopulateUserIdFields(populateUserIdFields, entities);
585
585
  if (populateMedia?.length) await this.handlePopulateMedia(populateMedia, entities);
586
- }
586
+ },
587
+ internationalisation, draftPublishWorkflow, this.moduleRef
587
588
  );
588
589
  }
589
590
  else {
@@ -333,13 +333,65 @@ export class DraftPublishHelperService {
333
333
  throw new BadRequestException(`${modelName} with id ${id} is already unpublished`);
334
334
  }
335
335
 
336
- return repo.save({
337
- ...entity,
338
- isPublished: false,
339
- updatedBy: activeUser?.sub ?? entity.updatedBy,
336
+ const chainId = entity.initialEntityVersionId || entity.id;
337
+
338
+ return repo.manager.transaction(async (manager) => {
339
+ const transactionalRepo = manager.getRepository(repo.metadata.target);
340
+
341
+ // Release the "na" published tracker before anything else can claim it, mirroring
342
+ // publishRecord's archive-others-then-publish ordering in reverse. unpublishRecord
343
+ // only ever acts on the latest version (see loadLatestEntityForPublishAction), so
344
+ // this always becomes a Draft rather than an Archived version — clear publishedAt
345
+ // to match applyCreateDefaults' Draft convention instead of leaving a stale date.
346
+ const unpublishedEntity = await transactionalRepo.save({
347
+ ...entity,
348
+ isPublished: false,
349
+ publishedAt: null,
350
+ publishedTracker: this.createPublishedVersionTracker(entity.id),
351
+ updatedBy: activeUser?.sub ?? entity.updatedBy,
352
+ } as any) as unknown as T;
353
+
354
+ const chainVersions = await transactionalRepo.find({
355
+ where: [
356
+ { initialEntityVersionId: chainId },
357
+ { id: chainId },
358
+ ] as any,
359
+ }) as unknown as T[];
360
+
361
+ const versionToRestore = this.pickMostRecentlyPublishedVersion(
362
+ chainVersions.filter(version => version.id !== entity.id),
363
+ );
364
+
365
+ if (versionToRestore) {
366
+ await transactionalRepo.save({
367
+ ...versionToRestore,
368
+ isPublished: true,
369
+ publishedAt: new Date(),
370
+ publishedTracker: "na",
371
+ } as any);
372
+ }
373
+
374
+ return unpublishedEntity;
340
375
  });
341
376
  }
342
377
 
378
+ /**
379
+ * Among the other versions in a chain, find the one that was live most recently before
380
+ * being superseded (i.e. the "Archived" version unpublishRecord should restore). Ties on
381
+ * publishedAt fall back to the highest id, matching pickCurrentLocaleRecord's ordering.
382
+ */
383
+ private pickMostRecentlyPublishedVersion<T extends CommonEntity>(versions: T[]): T | null {
384
+ const previouslyPublished = versions.filter(version => Boolean(version.publishedAt));
385
+ if (previouslyPublished.length === 0) return null;
386
+
387
+ return previouslyPublished.sort((a, b) => {
388
+ const aPublishedAt = new Date(a.publishedAt as any).getTime();
389
+ const bPublishedAt = new Date(b.publishedAt as any).getTime();
390
+ if (aPublishedAt !== bPublishedAt) return bPublishedAt - aPublishedAt;
391
+ return Number(b.id) - Number(a.id);
392
+ })[0];
393
+ }
394
+
343
395
  /**
344
396
  * Load the latest version of a draft/publish-enabled record for a publish/unpublish
345
397
  * action, applying the two checks both actions share (workflow enabled, version is latest).
@@ -6,6 +6,7 @@ import {
6
6
  SettingDefinition,
7
7
  SettingLevel,
8
8
  } from "src/interfaces";
9
+ import { getDefaultThemeKey, getThemesByMode } from "src/theme/theme-registry";
9
10
 
10
11
  export const DEFAULT_MEDIA_UPLOAD_DIR = "media-uploads";
11
12
  export const DEFAULT_MEDIA_FILE_STORAGE_DIR = "media-files-storage";
@@ -224,14 +225,44 @@ const getSolidCoreSettings = (isProd: boolean) =>
224
225
  },
225
226
  {
226
227
  moduleName: "solid-core",
227
- key: "enableDarkMode",
228
+ key: "showThemeToggle",
228
229
  value: true,
229
230
  level: SettingLevel.SystemAdminEditable,
230
- label: "Enable Dark Mode",
231
+ label: "Show Theme Toggle",
231
232
  group: "app-settings",
232
233
  sortOrder: 120,
233
234
  controlType: "boolean",
234
- helpText: "Enables dark theme support for frontend surfaces that honor the global appearance setting.",
235
+ helpText: "Shows the theme toggle in the frontend so users can switch between light and dark themes.",
236
+ },
237
+ {
238
+ moduleName: "solid-core",
239
+ key: "lightTheme",
240
+ value: getDefaultThemeKey("light"),
241
+ level: SettingLevel.SystemAdminEditable,
242
+ label: "Light Theme",
243
+ group: "app-settings",
244
+ sortOrder: 125,
245
+ controlType: "selectionStatic",
246
+ options: getThemesByMode("light").map((theme) => ({
247
+ label: theme.label,
248
+ value: theme.key,
249
+ })),
250
+ helpText: "Selects the theme used whenever the application is in light mode.",
251
+ },
252
+ {
253
+ moduleName: "solid-core",
254
+ key: "darkTheme",
255
+ value: getDefaultThemeKey("dark"),
256
+ level: SettingLevel.SystemAdminEditable,
257
+ label: "Dark Theme",
258
+ group: "app-settings",
259
+ sortOrder: 126,
260
+ controlType: "selectionStatic",
261
+ options: getThemesByMode("dark").map((theme) => ({
262
+ label: theme.label,
263
+ value: theme.key,
264
+ })),
265
+ helpText: "Selects the theme used whenever the application is in dark mode.",
235
266
  },
236
267
  {
237
268
  moduleName: "solid-core",
@@ -479,10 +479,11 @@ Common `test run` flags (all pass through solidctl unchanged):
479
479
  - `--ui-navigation-timeout-ms <number>` — default wait for `ui.goto`
480
480
  - `--timeout-ms <number>` / `--retries <number>` — scenario-level guard and retry count
481
481
  - `--scenario-ids <ids>` / `--include-tags <tags>` / `--skip-scenario-ids <ids>`
482
+ - `--no-progress` — disable the interactive test progress footer
482
483
 
483
484
  ## Add A New Step (SOP)
484
485
  1. Create a new `*.step.ts` in the right domain folder.
485
486
  2. Implement a `registerXSteps(registry)` function and `registry.register("op.name", handler)`.
486
487
  3. Validate required `step.with` fields and throw clear errors.
487
488
  4. Use adapters via `ctx.api` / `ctx.ui` and update `ctx.last` when helpful.
488
- 5. Export and register it from the domain `index.ts`.
489
+ 5. Export and register it from the domain `index.ts`.
@@ -150,6 +150,10 @@ export class ConsoleReporter implements Reporter {
150
150
  private passedScenarios = 0;
151
151
  private failedScenarios = 0;
152
152
 
153
+ onRunStart(_args: { total: number }): void {
154
+ // Progress-aware reporters use this hook; the plain console reporter has no setup.
155
+ }
156
+
153
157
  onScenarioStart(scenario: { id: string; name?: string }): void {
154
158
  const label = scenario.name ? `${scenario.id} (${scenario.name})` : scenario.id;
155
159
  console.log(`\n▶ Scenario: ${label}`);
@@ -0,0 +1,251 @@
1
+ import * as readline from "readline";
2
+ import type { StepPhase } from "../contracts/runtime-context.types";
3
+ import type { OpStep, ScenarioSpec } from "../contracts/testing-metadata.types";
4
+ import type { SolidTestSpecResult } from "../contracts/test-spec.types";
5
+ import type { Reporter } from "./reporter.types";
6
+
7
+ const ANSI_BOLD = "\u001b[1m";
8
+ const ANSI_BOLD_OFF = "\u001b[22m";
9
+ const ANSI_DIM = "\u001b[2m";
10
+ const ANSI_GREEN = "\u001b[32m";
11
+ const ANSI_COLOR_OFF = "\u001b[39m";
12
+ const ANSI_RED = "\u001b[31m";
13
+ const ANSI_INVERSE = "\u001b[7m";
14
+ const ANSI_INVERSE_OFF = "\u001b[27m";
15
+
16
+ export class ProgressReporter implements Reporter {
17
+ private startedAt = Date.now();
18
+ private total = 0;
19
+ private completed = 0;
20
+ private passed = 0;
21
+ private failed = 0;
22
+ private timer?: NodeJS.Timeout;
23
+ private rendered = false;
24
+
25
+ constructor(private readonly delegate: Reporter) {}
26
+
27
+ onRunStart(args: { total: number }): void {
28
+ this.total = args.total;
29
+ this.startedAt = Date.now();
30
+ this.completed = 0;
31
+ this.passed = 0;
32
+ this.failed = 0;
33
+ this.delegate.onRunStart?.(args);
34
+ this.startTimer();
35
+ this.renderFooter();
36
+ }
37
+
38
+ onScenarioStart(scenario: ScenarioSpec): void {
39
+ this.withFooterPaused(() => this.delegate.onScenarioStart(scenario));
40
+ }
41
+
42
+ onScenarioEnd(
43
+ scenario: ScenarioSpec,
44
+ result: { ok: boolean; error?: unknown; durationMs: number },
45
+ ): void {
46
+ this.completed += 1;
47
+ if (result.ok) {
48
+ this.passed += 1;
49
+ } else {
50
+ this.failed += 1;
51
+ }
52
+ this.withFooterPaused(() => this.delegate.onScenarioEnd(scenario, result));
53
+ }
54
+
55
+ onStepStart(args: {
56
+ scenarioId: string;
57
+ phase: StepPhase;
58
+ step: OpStep;
59
+ }): void {
60
+ this.withFooterPaused(() => this.delegate.onStepStart(args));
61
+ }
62
+
63
+ onStepEnd(args: {
64
+ scenarioId: string;
65
+ phase: StepPhase;
66
+ step: OpStep;
67
+ ok: boolean;
68
+ error?: unknown;
69
+ durationMs: number;
70
+ }): void {
71
+ this.withFooterPaused(() => this.delegate.onStepEnd(args));
72
+ }
73
+
74
+ onSpecResult(args: {
75
+ scenarioId: string;
76
+ specId: string;
77
+ stepName?: string;
78
+ result: SolidTestSpecResult;
79
+ }): void {
80
+ this.withFooterPaused(() => this.delegate.onSpecResult?.(args));
81
+ }
82
+
83
+ attach(args: {
84
+ scenarioId: string;
85
+ name: string;
86
+ contentType: string;
87
+ data: Buffer | string;
88
+ }): void {
89
+ this.withFooterPaused(() => this.delegate.attach?.(args));
90
+ }
91
+
92
+ onRunEnd(args: {
93
+ ok: boolean;
94
+ total: number;
95
+ passed: number;
96
+ failed: number;
97
+ durationMs: number;
98
+ }): void {
99
+ this.completed = args.passed + args.failed;
100
+ this.passed = args.passed;
101
+ this.failed = args.failed;
102
+ this.total = args.total;
103
+ this.stopTimer();
104
+ this.withFooterPaused(() => this.delegate.onRunEnd?.(args), false);
105
+ }
106
+
107
+ private startTimer(): void {
108
+ this.stopTimer();
109
+ if (!this.isInteractive()) {
110
+ return;
111
+ }
112
+ this.timer = setInterval(() => this.renderFooter(), 1_000);
113
+ this.timer.unref?.();
114
+ }
115
+
116
+ private stopTimer(): void {
117
+ if (!this.timer) {
118
+ return;
119
+ }
120
+ clearInterval(this.timer);
121
+ this.timer = undefined;
122
+ }
123
+
124
+ private withFooterPaused(callback: () => void, renderAfter = true): void {
125
+ this.clearFooter();
126
+ callback();
127
+ if (renderAfter) {
128
+ this.renderFooter();
129
+ }
130
+ }
131
+
132
+ private renderFooter(): void {
133
+ if (!this.isInteractive()) {
134
+ return;
135
+ }
136
+
137
+ const terminalWidth = process.stdout.columns || 80;
138
+ const elapsed = formatElapsed(Date.now() - this.startedAt);
139
+ const progressText = `${this.completed}/${this.total}`;
140
+ const resultText = `${ANSI_GREEN}${this.passed} passed${ANSI_COLOR_OFF}${ANSI_DIM} | ${ANSI_BOLD_OFF}${this.failed ? ANSI_RED : ANSI_DIM}${this.failed} failed${this.failed ? ANSI_COLOR_OFF : ANSI_BOLD_OFF}`;
141
+ const footer = [
142
+ `${ANSI_BOLD}Tests${ANSI_BOLD_OFF}: ${this.renderProgressBar(terminalWidth)} ${progressText}`,
143
+ `${ANSI_BOLD}Elapsed${ANSI_BOLD_OFF}: ${elapsed}`,
144
+ resultText,
145
+ ].join(`${ANSI_DIM} | ${ANSI_BOLD_OFF}`);
146
+
147
+ readline.clearLine(process.stdout, 0);
148
+ readline.cursorTo(process.stdout, 0);
149
+ process.stdout.write(truncateAnsiText(`${ANSI_INVERSE} ${footer} ${ANSI_INVERSE_OFF}`, terminalWidth));
150
+ this.rendered = true;
151
+ }
152
+
153
+ private clearFooter(): void {
154
+ if (!this.rendered || !this.isInteractive()) {
155
+ return;
156
+ }
157
+ readline.clearLine(process.stdout, 0);
158
+ readline.cursorTo(process.stdout, 0);
159
+ this.rendered = false;
160
+ }
161
+
162
+ private renderProgressBar(terminalWidth: number): string {
163
+ const width = terminalWidth >= 120 ? 30 : terminalWidth >= 90 ? 22 : 14;
164
+ const ratio = this.total > 0 ? Math.min(1, this.completed / this.total) : 0;
165
+ const filled = Math.round(width * ratio);
166
+ const empty = Math.max(0, width - filled);
167
+ return `[${ANSI_GREEN}${"=".repeat(filled)}${ANSI_COLOR_OFF}${" ".repeat(empty)}]`;
168
+ }
169
+
170
+ private isInteractive(): boolean {
171
+ return Boolean(process.stdout.isTTY || process.env.SOLIDCTL_TEST_PROGRESS === "1");
172
+ }
173
+ }
174
+
175
+ function formatElapsed(durationMs: number): string {
176
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
177
+ const minutes = Math.floor(totalSeconds / 60);
178
+ const seconds = totalSeconds % 60;
179
+ if (minutes < 60) {
180
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
181
+ }
182
+ const hours = Math.floor(minutes / 60);
183
+ const remainingMinutes = minutes % 60;
184
+ return `${hours}:${remainingMinutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
185
+ }
186
+
187
+ function truncateAnsiText(text: string, maxWidth: number): string {
188
+ if (maxWidth <= 0) {
189
+ return "";
190
+ }
191
+
192
+ let visibleWidth = 0;
193
+ let index = 0;
194
+ let output = "";
195
+ let hasEscape = false;
196
+
197
+ while (index < text.length) {
198
+ if (text[index] === "\u001b") {
199
+ const match = /\u001b\[[0-9;]*m/.exec(text.slice(index));
200
+ if (!match) {
201
+ break;
202
+ }
203
+ output += match[0];
204
+ hasEscape = true;
205
+ index += match[0].length;
206
+ continue;
207
+ }
208
+
209
+ if (visibleWidth >= maxWidth) {
210
+ break;
211
+ }
212
+
213
+ output += text[index];
214
+ visibleWidth += 1;
215
+ index += 1;
216
+ }
217
+
218
+ if (index < text.length && maxWidth >= 1) {
219
+ output = trimAnsiText(output, maxWidth - 1) + "…";
220
+ }
221
+
222
+ if (hasEscape && !output.endsWith(ANSI_INVERSE_OFF)) {
223
+ output += ANSI_INVERSE_OFF;
224
+ }
225
+
226
+ return output;
227
+ }
228
+
229
+ function trimAnsiText(text: string, maxWidth: number): string {
230
+ let visibleWidth = 0;
231
+ let index = 0;
232
+ let output = "";
233
+
234
+ while (index < text.length && visibleWidth < maxWidth) {
235
+ if (text[index] === "\u001b") {
236
+ const match = /\u001b\[[0-9;]*m/.exec(text.slice(index));
237
+ if (!match) {
238
+ break;
239
+ }
240
+ output += match[0];
241
+ index += match[0].length;
242
+ continue;
243
+ }
244
+
245
+ output += text[index];
246
+ visibleWidth += 1;
247
+ index += 1;
248
+ }
249
+
250
+ return output;
251
+ }
@@ -3,6 +3,9 @@ import type { OpStep, ScenarioSpec } from "../contracts/testing-metadata.types";
3
3
  import type { SolidTestSpecResult } from "../contracts/test-spec.types";
4
4
 
5
5
  export interface Reporter {
6
+ onRunStart?(args: {
7
+ total: number;
8
+ }): void;
6
9
  onScenarioStart(scenario: ScenarioSpec): void;
7
10
  onScenarioEnd(
8
11
  scenario: ScenarioSpec,
@@ -67,6 +67,7 @@ export async function runFromMetadata(opts: RunnerOptions): Promise<void> {
67
67
 
68
68
  const resources = new SimpleResourceStore();
69
69
  const reporter = opts.reporter ?? new ConsoleReporter();
70
+ reporter.onRunStart?.({ total: scenarios.length });
70
71
  const api = new ApiAdapter(opts.api);
71
72
  const { PlaywrightAdapter } = await import("../adapters/ui/playwright-adapter");
72
73
  const ui = new PlaywrightAdapter(opts.ui);