@thinkwise/testwise 0.2.105 → 0.2.111

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 (100) hide show
  1. package/artifact-builder/CustomActionBarGenerator.ts +210 -0
  2. package/artifact-builder/InterfaceGenerator.ts +4 -1
  3. package/artifact-builder/ScreenInterfaceRefiner.ts +7 -1
  4. package/artifact-builder/SubjectComponentGenerator.ts +7 -1
  5. package/artifact-builder/SubjectRegistration.ts +4 -1
  6. package/artifact-builder/TaskDataBuilder.ts +71 -0
  7. package/artifact-builder/helpers/DataRetriever.ts +11 -0
  8. package/artifact-builder/helpers/PipelinePaths.ts +5 -0
  9. package/artifact-builder/index.ts +2 -0
  10. package/components/action-bar/ActionBarObjects.ts +3 -1
  11. package/components/filter/FilterPopup.ts +77 -14
  12. package/components/filter/FilterPopupObjects.ts +25 -4
  13. package/components/grid/Grid.ts +40 -2
  14. package/components/grid/GridObjects.ts +18 -3
  15. package/components/pop-up/AllRowsPopUp.ts +4 -1
  16. package/controls/DateControl.ts +26 -13
  17. package/controls/DateControlObjects.ts +1 -1
  18. package/controls/FormComboBoxObjects.ts +2 -2
  19. package/controls/GridColumnControl.ts +10 -0
  20. package/dist/artifact-builder/CustomActionBarGenerator.d.ts +82 -0
  21. package/dist/artifact-builder/CustomActionBarGenerator.js +175 -0
  22. package/dist/artifact-builder/CustomActionBarGenerator.js.map +1 -0
  23. package/dist/artifact-builder/InterfaceGenerator.js +4 -1
  24. package/dist/artifact-builder/InterfaceGenerator.js.map +1 -1
  25. package/dist/artifact-builder/ScreenInterfaceRefiner.js +7 -1
  26. package/dist/artifact-builder/ScreenInterfaceRefiner.js.map +1 -1
  27. package/dist/artifact-builder/SubjectComponentGenerator.js +5 -1
  28. package/dist/artifact-builder/SubjectComponentGenerator.js.map +1 -1
  29. package/dist/artifact-builder/SubjectRegistration.js +4 -1
  30. package/dist/artifact-builder/SubjectRegistration.js.map +1 -1
  31. package/dist/artifact-builder/TaskDataBuilder.d.ts +21 -0
  32. package/dist/artifact-builder/TaskDataBuilder.js +60 -0
  33. package/dist/artifact-builder/TaskDataBuilder.js.map +1 -0
  34. package/dist/artifact-builder/helpers/DataRetriever.d.ts +2 -0
  35. package/dist/artifact-builder/helpers/DataRetriever.js +7 -0
  36. package/dist/artifact-builder/helpers/DataRetriever.js.map +1 -1
  37. package/dist/artifact-builder/helpers/PipelinePaths.d.ts +2 -0
  38. package/dist/artifact-builder/helpers/PipelinePaths.js +4 -0
  39. package/dist/artifact-builder/helpers/PipelinePaths.js.map +1 -1
  40. package/dist/artifact-builder/index.d.ts +2 -0
  41. package/dist/artifact-builder/index.js +2 -0
  42. package/dist/artifact-builder/index.js.map +1 -1
  43. package/dist/components/action-bar/ActionBarObjects.js +2 -1
  44. package/dist/components/action-bar/ActionBarObjects.js.map +1 -1
  45. package/dist/components/filter/FilterPopup.d.ts +50 -2
  46. package/dist/components/filter/FilterPopup.js +69 -15
  47. package/dist/components/filter/FilterPopup.js.map +1 -1
  48. package/dist/components/filter/FilterPopupObjects.d.ts +4 -2
  49. package/dist/components/filter/FilterPopupObjects.js +18 -4
  50. package/dist/components/filter/FilterPopupObjects.js.map +1 -1
  51. package/dist/components/grid/Grid.d.ts +22 -0
  52. package/dist/components/grid/Grid.js +36 -2
  53. package/dist/components/grid/Grid.js.map +1 -1
  54. package/dist/components/grid/GridObjects.d.ts +2 -0
  55. package/dist/components/grid/GridObjects.js +12 -2
  56. package/dist/components/grid/GridObjects.js.map +1 -1
  57. package/dist/components/pop-up/AllRowsPopUp.js +4 -1
  58. package/dist/components/pop-up/AllRowsPopUp.js.map +1 -1
  59. package/dist/controls/DateControl.d.ts +18 -10
  60. package/dist/controls/DateControl.js +21 -8
  61. package/dist/controls/DateControl.js.map +1 -1
  62. package/dist/controls/DateControlObjects.js +1 -1
  63. package/dist/controls/DateControlObjects.js.map +1 -1
  64. package/dist/controls/FormComboBoxObjects.js +2 -2
  65. package/dist/controls/FormComboBoxObjects.js.map +1 -1
  66. package/dist/controls/GridColumnControl.d.ts +2 -0
  67. package/dist/controls/GridColumnControl.js +6 -0
  68. package/dist/controls/GridColumnControl.js.map +1 -1
  69. package/dist/helpers/DateFormat.d.ts +69 -0
  70. package/dist/helpers/DateFormat.js +100 -0
  71. package/dist/helpers/DateFormat.js.map +1 -0
  72. package/dist/helpers/DateLocaleHelper.d.ts +17 -0
  73. package/dist/helpers/DateLocaleHelper.js +52 -0
  74. package/dist/helpers/DateLocaleHelper.js.map +1 -0
  75. package/dist/helpers/LoginHelper.js +2 -1
  76. package/dist/helpers/LoginHelper.js.map +1 -1
  77. package/dist/helpers/Poll.d.ts +24 -2
  78. package/dist/helpers/Poll.js +87 -5
  79. package/dist/helpers/Poll.js.map +1 -1
  80. package/dist/helpers/UserSimulationHelper.js +2 -1
  81. package/dist/helpers/UserSimulationHelper.js.map +1 -1
  82. package/dist/interfaces/ITabTask.d.ts +21 -0
  83. package/dist/interfaces/ITabTask.js +2 -0
  84. package/dist/interfaces/ITabTask.js.map +1 -0
  85. package/dist/services/IndiciumApi.service.d.ts +7 -0
  86. package/dist/services/IndiciumApi.service.js +19 -0
  87. package/dist/services/IndiciumApi.service.js.map +1 -1
  88. package/dist/types/PollTypes.d.ts +5 -0
  89. package/helpers/DateFormat.ts +123 -0
  90. package/helpers/DateLocaleHelper.ts +57 -0
  91. package/helpers/LoginHelper.ts +2 -1
  92. package/helpers/Poll.ts +108 -5
  93. package/helpers/UserSimulationHelper.ts +2 -1
  94. package/interfaces/ITabTask.ts +21 -0
  95. package/package.json +3 -1
  96. package/promptCredentials.js +124 -124
  97. package/scripts/checkPackageContents.js +39 -0
  98. package/scripts/sync.js +14 -0
  99. package/services/IndiciumApi.service.ts +21 -0
  100. package/types/PollTypes.ts +5 -0
@@ -0,0 +1,210 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { ISubject } from '../interfaces/ISubject.js';
4
+ import type { ITabTask } from '../interfaces/ITabTask.js';
5
+ import { DataRetriever } from './helpers/DataRetriever.js';
6
+ import { NamingHandler } from './helpers/NamingHandler.js';
7
+ import { PipelinePaths } from './helpers/PipelinePaths.js';
8
+
9
+ /**
10
+ * **Pipeline Step 6.5** — For each subject+variant that has qualifying task
11
+ * buttons (written by `TaskDataBuilder`), generates a subject-specific
12
+ * `ActionBar` subclass with a typed getter per task, then patches the
13
+ * already-generated subject class file(s) to use that subclass instead of
14
+ * the base `ActionBar`.
15
+ *
16
+ * **Why a separate step (not baked into `SubjectGenerator`):**
17
+ * `SubjectGenerator` and `SubjectClassModelBuilder` run pipeline checks
18
+ * against the known component lists (`coreComponents`, `screenComponents`).
19
+ * A dynamically named type like `EmployeeEditable1ActionBar` is not in those
20
+ * lists and would break those checks. Running as a post-step lets the normal
21
+ * pipeline complete cleanly, then applies the swap after all validation has
22
+ * passed.
23
+ *
24
+ * **Generated file:** `test-artifacts/subjects/<SubjectFolder>/<ClassName>.ts`
25
+ * where `ClassName = <SubjectFolderPascal><VariantPascal>ActionBar`.
26
+ * Example: subject `employee`, variant `editable1` → `EmployeeEditable1ActionBar.ts`
27
+ *
28
+ * **Patching:** For each screen context (Main, Detail, Popup, Zoom) of a
29
+ * subject+variant with tasks, the generated `<ClassName>.ts` subject class is
30
+ * updated to:
31
+ * - Import the custom ActionBar instead of `ActionBar` from components.
32
+ * - Declare the `actionBar` property with the custom type.
33
+ * - Instantiate the custom class in the constructor.
34
+ *
35
+ * **Subject folder index.ts:** After generating each custom ActionBar file,
36
+ * the subject folder's `index.ts` barrel is updated to re-export it.
37
+ *
38
+ * **When it runs:** Step 6.5 of `testwise sync`, after `SubjectGenerator`
39
+ * (which writes and indexes subject class files) and before `SubjectRegistration`.
40
+ */
41
+ export class CustomActionBarGenerator {
42
+ private readonly _paths: PipelinePaths;
43
+ private readonly _namingHandler: NamingHandler;
44
+ private readonly _dataRetriever: DataRetriever;
45
+
46
+ constructor(paths?: PipelinePaths) {
47
+ this._paths = paths ?? new PipelinePaths();
48
+ this._namingHandler = new NamingHandler();
49
+ this._dataRetriever = new DataRetriever(this._paths);
50
+ }
51
+
52
+ public run(): void {
53
+ const tabTasks = this._dataRetriever.getTabTasks();
54
+
55
+ if (tabTasks.length === 0) return;
56
+
57
+ const subjects = this._dataRetriever.getSubjectsToBuild();
58
+
59
+ const taskMap = new Map<string, ITabTask>(tabTasks.map((t) => [`${t.subject}__${t.variant}`, t]));
60
+
61
+ // Track which subject+variant combos already have a generated ActionBar file
62
+ const generatedActionBars = new Set<string>();
63
+
64
+ for (const subject of subjects) {
65
+ const key = `${subject.subject}__${subject.variant}`;
66
+ const taskEntry = taskMap.get(key);
67
+
68
+ if (!taskEntry || taskEntry.tasks.length === 0) continue;
69
+
70
+ const subjectFolderName = this._namingHandler.toSubjectPascalCase(subject.subject);
71
+ const variantPascal = subject.variant ? this._namingHandler.toPascalCase(subject.variant) : '';
72
+ const customActionBarClassName = `${subjectFolderName}${variantPascal}ActionBar`;
73
+
74
+ if (!generatedActionBars.has(key)) {
75
+ this.generateCustomActionBarFile(subjectFolderName, customActionBarClassName, taskEntry.tasks);
76
+ this.appendToSubjectIndex(subjectFolderName, customActionBarClassName);
77
+ generatedActionBars.add(key);
78
+ }
79
+
80
+ this.patchSubjectClassFile(subject, subjectFolderName, customActionBarClassName);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Writes a new `<ClassName>.ts` file to the subject's `Components/` subfolder.
86
+ * The class extends `ActionBar` and adds one getter per qualifying task ID.
87
+ */
88
+ private generateCustomActionBarFile(subjectFolderName: string, className: string, taskIds: string[]): void {
89
+ const componentsFolderPath = this._paths.subjectComponentsFolder(subjectFolderName);
90
+
91
+ if (!fs.existsSync(this._paths.subjectFolder(subjectFolderName))) return;
92
+
93
+ if (!fs.existsSync(componentsFolderPath)) {
94
+ fs.mkdirSync(componentsFolderPath, { recursive: true });
95
+ }
96
+
97
+ const getters = taskIds
98
+ .map((taskId) => {
99
+ const getterName = this.taskIdToGetterName(taskId);
100
+ return ` get ${getterName}(): Locator {\n return this.getTaskButtonById('${this.taskIdToTestId(taskId)}');\n }`;
101
+ })
102
+ .join('\n\n');
103
+
104
+ const content = [
105
+ `import type { Locator } from '@playwright/test';`,
106
+ `import { ActionBar } from '../../../../components/index.js';`,
107
+ ``,
108
+ `export class ${className} extends ActionBar {`,
109
+ getters,
110
+ `}`,
111
+ ``
112
+ ].join('\n');
113
+
114
+ fs.writeFileSync(path.join(componentsFolderPath, `${className}.ts`), content, 'utf-8');
115
+ }
116
+
117
+ /**
118
+ * Appends the custom ActionBar export to the subject folder's `index.ts` barrel.
119
+ * `SubjectGenerator.indexGeneratedSubjects()` runs before this step, so the
120
+ * barrel already exists but does not yet include the custom ActionBar file.
121
+ */
122
+ private appendToSubjectIndex(subjectFolderName: string, className: string): void {
123
+ const indexPath = path.join(this._paths.subjectFolder(subjectFolderName), 'index.ts');
124
+
125
+ if (!fs.existsSync(indexPath)) return;
126
+
127
+ const exportLine = `export { ${className} } from './Components/${className}.js';`;
128
+ const existing = fs.readFileSync(indexPath, 'utf-8');
129
+
130
+ if (existing.includes(exportLine)) return;
131
+
132
+ fs.writeFileSync(indexPath, `${existing}\n${exportLine}`, 'utf-8');
133
+ }
134
+
135
+ /**
136
+ * Reads the generated subject class file for one screen context and applies
137
+ * three targeted text replacements to swap in the custom ActionBar type.
138
+ */
139
+ private patchSubjectClassFile(subject: ISubject, subjectFolderName: string, customActionBarClassName: string): void {
140
+ const className = this._namingHandler.generateSubjectClassName(subject);
141
+ const filePath = path.join(this._paths.subjectFolder(subjectFolderName), `${className}.ts`);
142
+
143
+ if (!fs.existsSync(filePath)) return;
144
+
145
+ let content = fs.readFileSync(filePath, 'utf-8');
146
+
147
+ content = this.swapActionBarImport(content, customActionBarClassName);
148
+ content = content.split(': ActionBar').join(`: ${customActionBarClassName}`);
149
+ content = content.split(': CustomActionBar').join(`: ${customActionBarClassName}`);
150
+ content = content.split('new ActionBar(').join(`new ${customActionBarClassName}(`);
151
+ content = content.split('new CustomActionBar(').join(`new ${customActionBarClassName}(`);
152
+
153
+ fs.writeFileSync(filePath, content, 'utf-8');
154
+ }
155
+
156
+ /**
157
+ * Removes `ActionBar` and `CustomActionBar` from the core components import
158
+ * and inserts a subject-local import for the generated subclass.
159
+ */
160
+ private swapActionBarImport(content: string, customActionBarClassName: string): string {
161
+ const customImport = `import { ${customActionBarClassName} } from './Components/${customActionBarClassName}.js';`;
162
+
163
+ // Remove ActionBar / CustomActionBar from the components barrel import
164
+ content = content.replace(
165
+ /^import \{ ([^}]+) \} from '\.\.\/\.\.\/\.\.\/components\/index\.js';$/m,
166
+ (_, imports: string) => {
167
+ const remaining = imports
168
+ .split(',')
169
+ .map((i) => i.trim())
170
+ .filter((i) => i !== 'ActionBar' && i !== 'CustomActionBar');
171
+
172
+ if (remaining.length === 0) return '';
173
+ return `import { ${remaining.join(', ')} } from '../../../components/index.js';`;
174
+ }
175
+ );
176
+
177
+ // Insert the subject-local import before the @playwright/test import line
178
+ content = content.replace(/^(import type \{ Page \} from '@playwright\/test';)/m, `${customImport}\n$1`);
179
+
180
+ return content;
181
+ }
182
+
183
+ /**
184
+ * Converts a Thinkwise task ID (kebab-case or snake_case) to a camelCase getter
185
+ * name with a `Button` suffix for consistent, discoverable naming.
186
+ *
187
+ * Examples:
188
+ * - `"supplier-item-add"` → `"supplierItemAddButton"`
189
+ * - `"pf_run_prefilter_mand_on"` → `"pfRunPrefilterMandOnButton"`
190
+ * - `"deactivate"` → `"deactivateButton"`
191
+ */
192
+ private taskIdToGetterName(taskId: string): string {
193
+ const camel = taskId.replace(/[-_]([a-z0-9])/g, (_, c: string) => c.toUpperCase());
194
+ return `${camel}Button`;
195
+ }
196
+
197
+ /**
198
+ * Converts a Thinkwise task ID to the id the action bar renders in its
199
+ * `actionbar__task__<id>` test id: lowercase, with underscores replaced by
200
+ * hyphens. This is the same model-to-DOM rule the pipeline applies to column
201
+ * and screen type ids (see `SelectorBuilder`).
202
+ *
203
+ * Examples:
204
+ * - `"pf_run_prefilter_mand_on"` → `"pf-run-prefilter-mand-on"`
205
+ * - `"supplier-item-add"` → `"supplier-item-add"` (unchanged)
206
+ */
207
+ private taskIdToTestId(taskId: string): string {
208
+ return taskId.toLowerCase().replace(/_/g, '-');
209
+ }
210
+ }
@@ -108,7 +108,10 @@ export class InterfaceGenerator {
108
108
 
109
109
  let updatedContent = result.lines.join('\n');
110
110
 
111
- updatedContent = updatedContent.replace(/interface\s+\w+/, `interface ${correctInterfaceName}`);
111
+ // Match the whole name quicktype gave the interface, up to whitespace, `{` or a type parameter.
112
+ // `\w+` stopped at the first non-ASCII letter, so a screen type such as "Algemeen hiërarchie"
113
+ // left the tail of the old name behind and the generated file did not compile.
114
+ updatedContent = updatedContent.replace(/interface\s+[^\s{<]+/, `interface ${correctInterfaceName}`);
112
115
 
113
116
  const formattedContent = await prettier.format(updatedContent, {
114
117
  parser: 'typescript',
@@ -151,7 +151,13 @@ export class ScreenInterfaceRefiner {
151
151
  }
152
152
 
153
153
  private findInterfaces(content: string): string[] {
154
- const interfaceRegex = /export interface (\w+) \{[\s\S]*?\}/g;
154
+ // `\w` is ASCII-only. A screen type such as "Algemeen hiërarchie" gives the screen interface a
155
+ // non-ASCII name, which `\w+` skipped, so the first component placeholder was taken for the screen.
156
+ // `pascalCase` builds that name from letters, digits and `_`, and changing the case of a few letters
157
+ // adds a separate mark (`\p{M}`): Turkish "İ" lower-cases to "i" plus U+0307. The rename in
158
+ // `InterfaceGenerator` matches any character up to whitespace, `{` or `<`; this pattern lists the
159
+ // characters instead, because `getPlaceholderInterfaces()` puts the names it finds into a RegExp.
160
+ const interfaceRegex = /export interface ([\p{L}\p{M}\p{N}_]+) \{[\s\S]*?\}/gu;
155
161
  const interfaces = [];
156
162
  let match = interfaceRegex.exec(content);
157
163
 
@@ -88,7 +88,13 @@ export class SubjectComponentGenerator {
88
88
 
89
89
  componentFile.content = refineComponentInterface(componentFile.content, componentName as SubjectComponents);
90
90
 
91
- const interfaceMatches: RegExpMatchArray | null = componentFile.content.match(/export interface (\w+) extends/);
91
+ // The interface is named after the subject, and a subject id can have any letter: "algemeen_hiërarchie" gives
92
+ // `AlgemeenHiërarchieForm`. `\w+` is ASCII-only, so such a component got no class, and the subject class that
93
+ // imports it did not compile. Same characters as `ScreenInterfaceRefiner.findInterfaces()`, including the mark
94
+ // (`\p{M}`) that changing case adds to a few letters. They are listed because the name goes into a RegExp below.
95
+ const interfaceMatches: RegExpMatchArray | null = componentFile.content.match(
96
+ /export interface ([\p{L}\p{M}\p{N}_]+) extends/u
97
+ );
92
98
 
93
99
  if (interfaceMatches) {
94
100
  const mainInterface = interfaceMatches[1];
@@ -138,7 +138,10 @@ export class SubjectRegistration {
138
138
 
139
139
  tsFiles.forEach((tsFile) => {
140
140
  const classContent = fs.readFileSync(path.join(folderPath, tsFile), 'utf-8');
141
- const classMatches = classContent.matchAll(/export\s+class\s+(\w+)/g);
141
+ // A subject or variant id can have any letter: "algemeen_hiërarchie" gives `AlgemeenHiërarchieMain`. `\w+`
142
+ // is ASCII-only and stopped at the "ë", so the class was left out of the registry. Same characters as
143
+ // `ScreenInterfaceRefiner.findInterfaces()`, including the mark (`\p{M}`) that changing case adds.
144
+ const classMatches = classContent.matchAll(/export\s+class\s+([\p{L}\p{M}\p{N}_]+)/gu);
142
145
 
143
146
  for (const match of classMatches) {
144
147
  const className = match[1];
@@ -0,0 +1,71 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /** biome-ignore-all lint/suspicious/noExplicitAny: Because I can */
3
+ import * as fs from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import type { ITabTask } from '../interfaces/ITabTask.js';
6
+ import { indiciumApi } from '../services/IndiciumApi.service.js';
7
+ import { DataRetriever } from './helpers/DataRetriever.js';
8
+ import { PipelinePaths } from './helpers/PipelinePaths.js';
9
+
10
+ /**
11
+ * **Pipeline Step 1.5** — Fetches task button data from the Indicium API
12
+ * (`i_ui_tab_task`) for the verified GUI application and writes qualifying
13
+ * entries to `seed-data/tab-tasks.json`.
14
+ *
15
+ * **Qualifying tasks:** A task qualifies when `custom_display_type` is absent
16
+ * or not equal to `4`. Tasks where `custom_display_type === 4` are special
17
+ * UI elements (not standard toolbar buttons) and are excluded from typed
18
+ * ActionBar generation.
19
+ *
20
+ * **Output format:** An array of `ITabTask` objects — one per subject+variant
21
+ * that has at least one qualifying task. If all tasks for a given subject+variant
22
+ * are `custom_display_type === 4`, no entry is written for that pair (it will
23
+ * receive a plain `ActionBar` during subject class generation).
24
+ *
25
+ * **When it runs:** Step 1.5 of `testwise sync`, after `buildSubjects` and
26
+ * `buildScreens` but before `ModelDataRefiner`. Fetching here (pre-refinement)
27
+ * means the full model is queried once and the data is available for all
28
+ * registered subjects during Phase 4.
29
+ */
30
+ export async function buildTaskData(): Promise<void> {
31
+ if (!(await indiciumApi.canConnectToProject())) {
32
+ console.log('Cannot connect to Indicium API. Aborting task data build.');
33
+ return;
34
+ }
35
+
36
+ const rawTasks: any[] = await indiciumApi.getTabTasks();
37
+
38
+ // Group by subject+variant, keeping only qualifying tasks
39
+ const grouped = new Map<string, { subject: string; variant: string; tasks: string[] }>();
40
+
41
+ for (const raw of rawTasks) {
42
+ const subject: string = raw.tab_id;
43
+ const variant: string = raw.tab_variant_id;
44
+ const taskId: string = raw.task_id;
45
+ const customDisplayType: number | undefined = raw.custom_display_type;
46
+
47
+ // Skip tasks rendered with a special custom display type (not a standard button)
48
+ if (customDisplayType === 4) continue;
49
+
50
+ const key = `${subject}__${variant}`;
51
+
52
+ if (!grouped.has(key)) {
53
+ grouped.set(key, { subject, variant, tasks: [] });
54
+ }
55
+
56
+ grouped.get(key)?.tasks.push(taskId);
57
+ }
58
+
59
+ const result: ITabTask[] = [...grouped.values()];
60
+
61
+ const paths = new PipelinePaths();
62
+ const tabTasksPath = paths.tabTasksJson;
63
+
64
+ await fs.mkdir(path.dirname(tabTasksPath), { recursive: true });
65
+ await fs.writeFile(tabTasksPath, JSON.stringify(result, null, 2), 'utf-8');
66
+ console.log(`Task data written to ${tabTasksPath}`);
67
+
68
+ // Verify using DataRetriever to confirm the file was written correctly
69
+ const written = new DataRetriever(paths).getTabTasks();
70
+ console.log(`Task data verified: ${written.length} subject+variant combination(s) with qualifying tasks.`);
71
+ }
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import type { IRegisteredSubjects } from '../../interfaces/IRegisteredSubjects.js';
3
3
  import type { ISubject } from '../../interfaces/ISubject.js';
4
+ import type { ITabTask } from '../../interfaces/ITabTask.js';
4
5
  import { PipelinePaths } from './PipelinePaths.js';
5
6
 
6
7
  /**
@@ -89,6 +90,16 @@ export class DataRetriever {
89
90
  return result;
90
91
  }
91
92
 
93
+ public getTabTasks(): ITabTask[] {
94
+ const tabTasksPath = this._paths.tabTasksJson;
95
+
96
+ if (!fs.existsSync(tabTasksPath)) {
97
+ return [];
98
+ }
99
+
100
+ return this.parseJsonFile<ITabTask[]>(tabTasksPath);
101
+ }
102
+
92
103
  public getSeedDataDirectory(): string {
93
104
  return this._paths.seedData;
94
105
  }
@@ -64,6 +64,11 @@ export class PipelinePaths {
64
64
  return path.join(this._consumerRoot, 'seed-data', 'registeredSubjects.json');
65
65
  }
66
66
 
67
+ /** `<package>/seed-data/tab-tasks.json` */
68
+ get tabTasksJson(): string {
69
+ return path.join(this.seedData, 'tab-tasks.json');
70
+ }
71
+
67
72
  // ── Package root – generated artifacts ─────────────────────────────────
68
73
 
69
74
  /** `<package>/test-artifacts/` */
@@ -1,5 +1,6 @@
1
1
  export * from './ArtifactManager.js';
2
2
  export * from './ComponentClassModelBuilder.js';
3
+ export * from './CustomActionBarGenerator.js';
3
4
  export * from './helpers/index.js';
4
5
  export * from './InterfaceGenerator.js';
5
6
  export * from './ModelDataRefiner.js';
@@ -9,5 +10,6 @@ export * from './SubjectClassModelBuilder.js';
9
10
  export * from './SubjectComponentGenerator.js';
10
11
  export * from './SubjectGenerator.js';
11
12
  export * from './SubjectRegistration.js';
13
+ export * from './TaskDataBuilder.js';
12
14
  export * from './TsFileModel.js';
13
15
  export * from './TsFileSerializer.js';
@@ -73,7 +73,9 @@ export class ActionBarOverflowableObjects extends BaseComponentObjects {
73
73
  // Tasks: data-testid like: "actionbar__task__<something>"
74
74
  public taskButtons = () => this.context.locator('[data-testid^="actionbar__task__"]');
75
75
 
76
- public taskButtonById = (id: string) => this.context.getByTestId(`actionbar__task__${id}`);
76
+ // The model's snake_case task id is rendered kebab-case, so both forms of the id resolve
77
+ public taskButtonById = (id: string) =>
78
+ this.context.getByTestId(`actionbar__task__${id.toLowerCase().replace(/_/g, '-')}`);
77
79
 
78
80
  public taskButtonByLabel = (label: string) =>
79
81
  this.context.locator('[data-testid^="actionbar__task__"]').filter({ hasText: label });
@@ -1,4 +1,6 @@
1
1
  import type { Locator, Page } from '@playwright/test';
2
+ import { isoToLocaleDigits } from '../../helpers/DateFormat.js';
3
+ import { getDateLocale } from '../../helpers/DateLocaleHelper.js';
2
4
  import { PopUpComponent } from '../pop-up/PopUpComponent.js';
3
5
  import { FilterPopupObjects } from './FilterPopupObjects.js';
4
6
 
@@ -81,9 +83,16 @@ export class FilterPopup {
81
83
  }
82
84
 
83
85
  public async saveAsPrefilterPopup(name: string): Promise<void> {
84
- await this.waitForOpen();
85
- await this._filterPopupObjects.prefilterNameInput().fill(name);
86
- await this._filterPopupObjects.saveButton().click();
86
+ const nameInput = this._filterPopupObjects.prefilterNameInput();
87
+ await nameInput.waitFor({ state: 'visible' });
88
+ await nameInput.fill(name);
89
+ await this._filterPopupObjects.prefilterSaveButton().click();
90
+
91
+ // "Save as prefilter" is a modal stacked on top of the filter popup, so saving unwinds
92
+ // TWO backdrops. Without waiting for both to detach this returns while a backdrop is
93
+ // still animating out, and the caller's next click gets intercepted.
94
+ await nameInput.waitFor({ state: 'hidden' });
95
+ await this._filterPopupObjects.applyFilterButton().waitFor({ state: 'hidden' });
87
96
  }
88
97
  public async setAdvanced(enabled: boolean): Promise<void> {
89
98
  await this.waitForOpen();
@@ -140,6 +149,31 @@ export class FilterPopup {
140
149
  await this._filterPopupObjects.valueInput(args.fieldId, args.part).press('Tab');
141
150
  }
142
151
 
152
+ /**
153
+ * Enter a date into a filter date input from a canonical ISO string
154
+ * (`YYYY-MM-DD` or `YYYY-MM-DD HH:mm:ss`). Reads the app's active date locale,
155
+ * reorders the digits to the picker's expected order, types them sequentially,
156
+ * and blurs to commit — so the same ISO value applies the same real date under
157
+ * any locale. Use this instead of {@link setValue} for date/date-time parts
158
+ * (e.g. interval `date` inputs).
159
+ *
160
+ * @param args.fieldId - The filter field/column id.
161
+ * @param args.value - Canonical ISO date or date-time string.
162
+ * @param args.part - The value part to target (defaults to `date`).
163
+ */
164
+ public async setDate(args: { fieldId: string; value: string; part?: string }): Promise<void> {
165
+ await this.waitForOpen();
166
+ const part = args.part ?? 'date';
167
+ const input = this._filterPopupObjects.valueInput(args.fieldId, part);
168
+ const locale = await getDateLocale(this.page);
169
+ const digits = isoToLocaleDigits(args.value, locale);
170
+
171
+ await input.click();
172
+ await input.press('ControlOrMeta+a');
173
+ await input.pressSequentially(digits);
174
+ await input.press('Tab');
175
+ }
176
+
143
177
  public async openControl(fieldId: string, part: string): Promise<void> {
144
178
  await this.waitForOpen();
145
179
  await this._filterPopupObjects.fieldControl(fieldId, part).click();
@@ -155,24 +189,53 @@ export class FilterPopup {
155
189
  await this._filterPopupObjects.timeOperatorControl().click();
156
190
  }
157
191
 
158
- public intervalSelector(colId: string): Locator {
159
- return this._filterPopupObjects.intervalSelector(colId);
192
+ /**
193
+ * One of a date column's interval selectors. Universal 2026.3.11 and later add the next interval condition row
194
+ * as soon as a row has a value, so a column can have several.
195
+ *
196
+ * @param colId - The column id.
197
+ * @param options.index - Which of the column's interval selectors, counting from 0 at the top. Defaults to 0.
198
+ */
199
+ public intervalSelector(colId: string, options?: { index?: number }): Locator {
200
+ return this._filterPopupObjects.intervalSelector(colId, options?.index);
160
201
  }
161
202
 
162
- public intervalSelectorInput(colId: string): Locator {
163
- return this._filterPopupObjects.intervalSelectorInput(colId);
203
+ /**
204
+ * The input of one of a date column's interval selectors, see {@link intervalSelector}.
205
+ *
206
+ * @param colId - The column id.
207
+ * @param options.index - Which of the column's interval selectors, counting from 0 at the top. Defaults to 0.
208
+ */
209
+ public intervalSelectorInput(colId: string, options?: { index?: number }): Locator {
210
+ return this._filterPopupObjects.intervalSelectorInput(colId, options?.index);
164
211
  }
165
212
 
166
- public async openInterval(colId: string, options?: { rowIndex?: number }): Promise<void> {
213
+ /**
214
+ * Opens one of a date column's interval selectors, see {@link intervalSelector}.
215
+ *
216
+ * @param colId - The column id.
217
+ * @param options.index - Which of the column's interval selectors, counting from 0 at the top. Defaults to 0.
218
+ * @param options.rowIndex - A row number of the advanced filter grid instead, as in its test ids. Wins over
219
+ * `index`. It also counts the other columns' rows, and it changes when the pop-up reopens with the active
220
+ * filters at the top, so `index` is easier to use.
221
+ */
222
+ public async openInterval(colId: string, options?: { index?: number; rowIndex?: number }): Promise<void> {
167
223
  await this.waitForOpen();
168
- if (options?.rowIndex !== undefined) {
169
- await this._filterPopupObjects.intervalSelectorInputByRowIndex(options.rowIndex, colId).click();
170
- } else {
171
- await this._filterPopupObjects.intervalSelectorInput(colId).click();
172
- }
224
+ const input =
225
+ options?.rowIndex !== undefined
226
+ ? this._filterPopupObjects.intervalSelectorInputByRowIndex(options.rowIndex, colId)
227
+ : this.intervalSelectorInput(colId, options);
228
+ await input.click();
173
229
  }
174
230
 
175
- public async setInterval(colId: string, kind: IntervalType, options?: { rowIndex?: number }): Promise<void> {
231
+ /**
232
+ * Picks an interval in one of a date column's interval selectors, see {@link openInterval} for `options`.
233
+ */
234
+ public async setInterval(
235
+ colId: string,
236
+ kind: IntervalType,
237
+ options?: { index?: number; rowIndex?: number }
238
+ ): Promise<void> {
176
239
  await this.openInterval(colId, options);
177
240
  await this.pickAutocomplete(kind);
178
241
  }
@@ -28,6 +28,14 @@ export class FilterPopupObjects extends BaseComponentObjects {
28
28
 
29
29
  public prefilterNameInput = () => this.context.getByTestId('prefilter__name__input');
30
30
 
31
+ public saveAsPopup = () =>
32
+ this._page
33
+ .locator('[role="dialog"]')
34
+ .filter({ has: this._page.getByTestId('prefilter__name__input') })
35
+ .last();
36
+
37
+ public prefilterSaveButton = () => this.saveAsPopup().getByRole('button', { name: 'Save' });
38
+
31
39
  public saveAsOption = () => this._page.getByRole('menuitem', { name: 'Save' });
32
40
 
33
41
  public saveButton = () => this._page.getByRole('button', { name: 'Save' });
@@ -46,9 +54,15 @@ export class FilterPopupObjects extends BaseComponentObjects {
46
54
  public timeOperatorControl = () =>
47
55
  this.context.getByTestId('form-field__event-time__operator__control').getByRole('button', { name: 'Open' });
48
56
 
49
- public intervalSelector = (colId: string) => this.context.locator(`[data-testid$="__${colId}__select-interval"]`);
57
+ // Universal 2026.3.11 and later add the next date interval condition row as soon as a row has a value
58
+ // (Universal PR 21830), so a column can show several interval selectors. `index` counts only the column's
59
+ // own selectors, from the top. A row number in the test id also counts the other columns' rows, and it
60
+ // changes when the pop-up reopens with the active filters at the top.
61
+ public intervalSelector = (colId: string, index = 0) =>
62
+ this.context.locator(`[data-testid$="__${colId}__select-interval"]`).nth(index);
50
63
 
51
- public intervalSelectorInput = (colId: string) => this.intervalSelector(colId).getByTestId('select-interval__input');
64
+ public intervalSelectorInput = (colId: string, index = 0) =>
65
+ this.intervalSelector(colId, index).getByTestId('select-interval__input');
52
66
 
53
67
  public intervalSelectorByRowIndex = (rowIndex: number, colId: string) =>
54
68
  this.context.getByTestId(`advanced-filter-grid__row${rowIndex}__${colId}__select-interval`);
@@ -60,9 +74,16 @@ export class FilterPopupObjects extends BaseComponentObjects {
60
74
 
61
75
  public emptySelectFieldRow = () => this.context.getByTestId('advanced-filter-grid__row-empty-select-field');
62
76
 
63
- public emptySelectFieldRowButton = () => this.emptySelectFieldRow().getByRole('button');
77
+ // The field picker is an MUI Select. Universal 2026.3.11 and later (MUI v6) render it with
78
+ // role="combobox", Universal 2026.3.10.1 and earlier (MUI 5.12.3) with role="button".
79
+ // Accept both so these selectors work on every supported Universal version.
80
+ public emptySelectFieldRowButton = () =>
81
+ this.emptySelectFieldRow().getByRole('combobox').or(this.emptySelectFieldRow().getByRole('button'));
64
82
 
65
- public selectFieldButton = () => this._page.getByRole('button', { name: 'Select field' });
83
+ public selectFieldButton = () =>
84
+ this._page
85
+ .getByRole('combobox', { name: 'Select field' })
86
+ .or(this._page.getByRole('button', { name: 'Select field' }));
66
87
 
67
88
  public addFieldButton = () => this._page.getByRole('button', { name: 'Add field' });
68
89
 
@@ -121,6 +121,29 @@ export class Grid {
121
121
  return Array.from(collected);
122
122
  }
123
123
 
124
+ /**
125
+ * Retrieves the cell in the grid's pinned bottom summary row for a specified column.
126
+ *
127
+ * Only present when the subject has grid summaries (count/sum) configured on the column.
128
+ */
129
+ public getSummaryCellByColId(colId: string): Locator {
130
+ return this._gridObjects.summaryCellsByColumnId(colId);
131
+ }
132
+
133
+ /**
134
+ * Gets the summary value for a specified column, or `null` when the subject has no
135
+ * grid summary configured for it.
136
+ */
137
+ public async getSummaryValueByColId(colId: string): Promise<string | null> {
138
+ const summaryCell = this._gridObjects.summaryCellsByColumnId(colId);
139
+ if ((await summaryCell.count()) === 0) {
140
+ return null;
141
+ }
142
+
143
+ const value = await summaryCell.first().textContent();
144
+ return value === null ? null : value.trim();
145
+ }
146
+
124
147
  /**
125
148
  * Retrieves all column header values from the grid.
126
149
  *
@@ -216,6 +239,20 @@ export class Grid {
216
239
  return this._gridObjects.rows();
217
240
  }
218
241
 
242
+ /**
243
+ * Makes a row the active record by clicking one of its cells.
244
+ *
245
+ * Pass a read-only column. Clicking a row's centre, as `rows().nth(i).click()` does,
246
+ * lands on whichever cell happens to sit mid-row; on an auto-edit grid that opens an
247
+ * editor and dirties the record instead of selecting it.
248
+ *
249
+ * @param rowIndex Zero-based index of the row within the grid body.
250
+ * @param colId Column to click, e.g. the primary key column.
251
+ */
252
+ public async selectRowByIndex(rowIndex: number, colId: string): Promise<void> {
253
+ await this._gridObjects.cellByRowIndexAndColumnId(rowIndex, colId).click();
254
+ }
255
+
219
256
  /**
220
257
  * Verifies the number of records in the grid and checks for "No result" overlay if zero.
221
258
  * @param expectedRecordCount The expected number of records.
@@ -230,7 +267,8 @@ export class Grid {
230
267
  }
231
268
 
232
269
  await pollUntil(() => this.rows().count(), {
233
- predicate: (n: number) => n === expectedRecordCount + 1 // +1 to account for header row
270
+ predicate: (n: number) => n === expectedRecordCount,
271
+ description: `the grid to show ${expectedRecordCount} records`
234
272
  });
235
273
  }
236
274
 
@@ -243,7 +281,7 @@ export class Grid {
243
281
 
244
282
  const hasCorrectWording = await pollUntil(
245
283
  async () => (await overlay.textContent())?.trim().includes('No result') ?? false,
246
- { predicate: Boolean }
284
+ { predicate: Boolean, description: 'the grid to show the "No result" overlay' }
247
285
  );
248
286
 
249
287
  return hasCorrectWording && (await overlay.isVisible());