@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.
- package/artifact-builder/CustomActionBarGenerator.ts +210 -0
- package/artifact-builder/InterfaceGenerator.ts +4 -1
- package/artifact-builder/ScreenInterfaceRefiner.ts +7 -1
- package/artifact-builder/SubjectComponentGenerator.ts +7 -1
- package/artifact-builder/SubjectRegistration.ts +4 -1
- package/artifact-builder/TaskDataBuilder.ts +71 -0
- package/artifact-builder/helpers/DataRetriever.ts +11 -0
- package/artifact-builder/helpers/PipelinePaths.ts +5 -0
- package/artifact-builder/index.ts +2 -0
- package/components/action-bar/ActionBarObjects.ts +3 -1
- package/components/filter/FilterPopup.ts +77 -14
- package/components/filter/FilterPopupObjects.ts +25 -4
- package/components/grid/Grid.ts +40 -2
- package/components/grid/GridObjects.ts +18 -3
- package/components/pop-up/AllRowsPopUp.ts +4 -1
- package/controls/DateControl.ts +26 -13
- package/controls/DateControlObjects.ts +1 -1
- package/controls/FormComboBoxObjects.ts +2 -2
- package/controls/GridColumnControl.ts +10 -0
- package/dist/artifact-builder/CustomActionBarGenerator.d.ts +82 -0
- package/dist/artifact-builder/CustomActionBarGenerator.js +175 -0
- package/dist/artifact-builder/CustomActionBarGenerator.js.map +1 -0
- package/dist/artifact-builder/InterfaceGenerator.js +4 -1
- package/dist/artifact-builder/InterfaceGenerator.js.map +1 -1
- package/dist/artifact-builder/ScreenInterfaceRefiner.js +7 -1
- package/dist/artifact-builder/ScreenInterfaceRefiner.js.map +1 -1
- package/dist/artifact-builder/SubjectComponentGenerator.js +5 -1
- package/dist/artifact-builder/SubjectComponentGenerator.js.map +1 -1
- package/dist/artifact-builder/SubjectRegistration.js +4 -1
- package/dist/artifact-builder/SubjectRegistration.js.map +1 -1
- package/dist/artifact-builder/TaskDataBuilder.d.ts +21 -0
- package/dist/artifact-builder/TaskDataBuilder.js +60 -0
- package/dist/artifact-builder/TaskDataBuilder.js.map +1 -0
- package/dist/artifact-builder/helpers/DataRetriever.d.ts +2 -0
- package/dist/artifact-builder/helpers/DataRetriever.js +7 -0
- package/dist/artifact-builder/helpers/DataRetriever.js.map +1 -1
- package/dist/artifact-builder/helpers/PipelinePaths.d.ts +2 -0
- package/dist/artifact-builder/helpers/PipelinePaths.js +4 -0
- package/dist/artifact-builder/helpers/PipelinePaths.js.map +1 -1
- package/dist/artifact-builder/index.d.ts +2 -0
- package/dist/artifact-builder/index.js +2 -0
- package/dist/artifact-builder/index.js.map +1 -1
- package/dist/components/action-bar/ActionBarObjects.js +2 -1
- package/dist/components/action-bar/ActionBarObjects.js.map +1 -1
- package/dist/components/filter/FilterPopup.d.ts +50 -2
- package/dist/components/filter/FilterPopup.js +69 -15
- package/dist/components/filter/FilterPopup.js.map +1 -1
- package/dist/components/filter/FilterPopupObjects.d.ts +4 -2
- package/dist/components/filter/FilterPopupObjects.js +18 -4
- package/dist/components/filter/FilterPopupObjects.js.map +1 -1
- package/dist/components/grid/Grid.d.ts +22 -0
- package/dist/components/grid/Grid.js +36 -2
- package/dist/components/grid/Grid.js.map +1 -1
- package/dist/components/grid/GridObjects.d.ts +2 -0
- package/dist/components/grid/GridObjects.js +12 -2
- package/dist/components/grid/GridObjects.js.map +1 -1
- package/dist/components/pop-up/AllRowsPopUp.js +4 -1
- package/dist/components/pop-up/AllRowsPopUp.js.map +1 -1
- package/dist/controls/DateControl.d.ts +18 -10
- package/dist/controls/DateControl.js +21 -8
- package/dist/controls/DateControl.js.map +1 -1
- package/dist/controls/DateControlObjects.js +1 -1
- package/dist/controls/DateControlObjects.js.map +1 -1
- package/dist/controls/FormComboBoxObjects.js +2 -2
- package/dist/controls/FormComboBoxObjects.js.map +1 -1
- package/dist/controls/GridColumnControl.d.ts +2 -0
- package/dist/controls/GridColumnControl.js +6 -0
- package/dist/controls/GridColumnControl.js.map +1 -1
- package/dist/helpers/DateFormat.d.ts +69 -0
- package/dist/helpers/DateFormat.js +100 -0
- package/dist/helpers/DateFormat.js.map +1 -0
- package/dist/helpers/DateLocaleHelper.d.ts +17 -0
- package/dist/helpers/DateLocaleHelper.js +52 -0
- package/dist/helpers/DateLocaleHelper.js.map +1 -0
- package/dist/helpers/LoginHelper.js +2 -1
- package/dist/helpers/LoginHelper.js.map +1 -1
- package/dist/helpers/Poll.d.ts +24 -2
- package/dist/helpers/Poll.js +87 -5
- package/dist/helpers/Poll.js.map +1 -1
- package/dist/helpers/UserSimulationHelper.js +2 -1
- package/dist/helpers/UserSimulationHelper.js.map +1 -1
- package/dist/interfaces/ITabTask.d.ts +21 -0
- package/dist/interfaces/ITabTask.js +2 -0
- package/dist/interfaces/ITabTask.js.map +1 -0
- package/dist/services/IndiciumApi.service.d.ts +7 -0
- package/dist/services/IndiciumApi.service.js +19 -0
- package/dist/services/IndiciumApi.service.js.map +1 -1
- package/dist/types/PollTypes.d.ts +5 -0
- package/helpers/DateFormat.ts +123 -0
- package/helpers/DateLocaleHelper.ts +57 -0
- package/helpers/LoginHelper.ts +2 -1
- package/helpers/Poll.ts +108 -5
- package/helpers/UserSimulationHelper.ts +2 -1
- package/interfaces/ITabTask.ts +21 -0
- package/package.json +3 -1
- package/promptCredentials.js +124 -124
- package/scripts/checkPackageContents.js +39 -0
- package/scripts/sync.js +14 -0
- package/services/IndiciumApi.service.ts +21 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
await
|
|
86
|
-
await
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
|
package/components/grid/Grid.ts
CHANGED
|
@@ -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
|
|
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());
|