testbeats 2.4.2 → 2.5.0

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.
@@ -0,0 +1,329 @@
1
+ const logger = require('../utils/logger');
2
+
3
+ /**
4
+ * Result of a synchronization operation
5
+ * @typedef {Object} SyncResult
6
+ * @property {boolean} success - Overall success status
7
+ * @property {number} foldersProcessed - Number of folders processed
8
+ * @property {number} testSuitesProcessed - Number of test suites processed
9
+ * @property {Array<{type: string, name: string, error: string}>} errors - List of errors encountered
10
+ */
11
+
12
+ /**
13
+ * Synchronizes manual test resources with the server
14
+ */
15
+ class ManualTestSynchronizer {
16
+ /**
17
+ * @param {import('../beats/beats.api').BeatsApi} beatsApi
18
+ */
19
+ constructor(beatsApi) {
20
+ this.beatsApi = beatsApi;
21
+ }
22
+
23
+ /**
24
+ * Sync the manual resources
25
+ * @param {import('../types').IManualTestFolder} structure - Local folder structure
26
+ * @param {import('../types').IManualSyncCompareResponse} compareResult - Comparison result from server
27
+ * @param {string} projectId - Project ID
28
+ * @returns {Promise<SyncResult>} Synchronization result
29
+ */
30
+ async sync(structure, compareResult, projectId) {
31
+ const errors = [];
32
+ let foldersProcessed = 0;
33
+ let testSuitesProcessed = 0;
34
+
35
+ const enrichedStructure = this.#mergeStructure(structure, compareResult);
36
+ const syncResult = await this.#syncFolders(enrichedStructure.folders, projectId, errors);
37
+ foldersProcessed = syncResult.foldersProcessed;
38
+ testSuitesProcessed = syncResult.testSuitesProcessed;
39
+
40
+ return {
41
+ success: errors.length === 0,
42
+ foldersProcessed,
43
+ testSuitesProcessed,
44
+ errors
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Merge the structure with the compare result (returns new structure)
50
+ * @param {import('../types').IManualTestFolder} structure
51
+ * @param {import('../types').IManualSyncCompareResponse} compareResult
52
+ * @returns {import('../types').IManualTestFolder} Enriched structure
53
+ */
54
+ #mergeStructure(structure, compareResult) {
55
+ const enrichedFolders = [];
56
+
57
+ for (const localFolder of structure.folders) {
58
+ const serverFolder = compareResult.folders.find(f => f.name === localFolder.name);
59
+ if (serverFolder) {
60
+ enrichedFolders.push(this.#mergeFolder(localFolder, serverFolder));
61
+ } else {
62
+ enrichedFolders.push(localFolder);
63
+ }
64
+ }
65
+
66
+ for (const serverFolder of compareResult.folders) {
67
+ const localFolder = structure.folders.find(f => f.name === serverFolder.name);
68
+ if (!localFolder) {
69
+ enrichedFolders.push(this.#convertServerFolderToLocal(serverFolder));
70
+ }
71
+ }
72
+
73
+ return {
74
+ ...structure,
75
+ folders: enrichedFolders
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Merge server folder metadata into local folder (returns new folder)
81
+ * @param {import('../types').IManualTestFolder} localFolder - Local folder with full data
82
+ * @param {import('../types').IManualSyncCompareResponseFolder} serverFolder - Server folder with metadata
83
+ * @returns {import('../types').IManualTestFolder} Merged folder
84
+ */
85
+ #mergeFolder(localFolder, serverFolder) {
86
+ const enrichedTestSuites = [];
87
+ const enrichedSubFolders = [];
88
+ const serverTestSuites = serverFolder.test_suites || [];
89
+ const serverFolders = serverFolder.folders || [];
90
+
91
+ for (const localSuite of localFolder.test_suites) {
92
+ const serverSuite = serverTestSuites.find(s => s.name === localSuite.name);
93
+ if (serverSuite) {
94
+ enrichedTestSuites.push(this.#mergeTestSuite(localSuite, serverSuite));
95
+ } else {
96
+ enrichedTestSuites.push(localSuite);
97
+ }
98
+ }
99
+
100
+ for (const serverSuite of serverTestSuites) {
101
+ const localSuite = localFolder.test_suites.find(s => s.name === serverSuite.name);
102
+ if (!localSuite) {
103
+ enrichedTestSuites.push(this.#convertServerSuiteToLocal(serverSuite));
104
+ }
105
+ }
106
+
107
+ for (const localSubFolder of localFolder.folders) {
108
+ const serverSubFolder = serverFolders.find(f => f.name === localSubFolder.name);
109
+ if (serverSubFolder) {
110
+ enrichedSubFolders.push(this.#mergeFolder(localSubFolder, serverSubFolder));
111
+ } else {
112
+ enrichedSubFolders.push(localSubFolder);
113
+ }
114
+ }
115
+
116
+ for (const serverSubFolder of serverFolders) {
117
+ const localSubFolder = localFolder.folders.find(f => f.name === serverSubFolder.name);
118
+ if (!localSubFolder) {
119
+ enrichedSubFolders.push(this.#convertServerFolderToLocal(serverSubFolder));
120
+ }
121
+ }
122
+
123
+ return {
124
+ ...localFolder,
125
+ id: serverFolder.id,
126
+ type: serverFolder.type,
127
+ test_suites: enrichedTestSuites,
128
+ folders: enrichedSubFolders
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Merge server test suite metadata into local test suite (returns new suite)
134
+ * @param {import('../types').IManualTestSuite} localSuite - Local test suite with full data
135
+ * @param {import('../types').IManualSyncCompareResponseTestSuite} serverSuite - Server suite with metadata
136
+ * @returns {import('../types').IManualTestSuite} Merged test suite
137
+ */
138
+ #mergeTestSuite(localSuite, serverSuite) {
139
+ return {
140
+ ...localSuite,
141
+ id: serverSuite.id,
142
+ type: serverSuite.type
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Convert server folder to local folder structure (for delete operations)
148
+ * @param {import('../types').IManualSyncCompareResponseFolder} serverFolder - Server folder
149
+ * @returns {import('../types').IManualTestFolder} Local folder structure
150
+ */
151
+ #convertServerFolderToLocal(serverFolder) {
152
+ return {
153
+ name: serverFolder.name,
154
+ path: '',
155
+ hash: serverFolder.hash,
156
+ id: serverFolder.id,
157
+ type: serverFolder.type,
158
+ test_suites: serverFolder.test_suites?.map(s => this.#convertServerSuiteToLocal(s)) || [],
159
+ folders: serverFolder.folders?.map(f => this.#convertServerFolderToLocal(f)) || []
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Convert server test suite to local test suite structure (for delete operations)
165
+ * @param {import('../types').IManualSyncCompareResponseTestSuite} serverSuite - Server suite
166
+ * @returns {import('../types').IManualTestSuite} Local test suite structure
167
+ */
168
+ #convertServerSuiteToLocal(serverSuite) {
169
+ return {
170
+ name: serverSuite.name,
171
+ tags: [],
172
+ before_each: [],
173
+ test_cases: [],
174
+ hash: serverSuite.hash,
175
+ id: serverSuite.id,
176
+ type: serverSuite.type
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Sync folders recursively
182
+ * @param {Array<import('../types').IManualTestFolder>} folders
183
+ * @param {string} projectId
184
+ * @param {Array} errors - Array to collect errors
185
+ * @returns {Promise<{foldersProcessed: number, testSuitesProcessed: number}>}
186
+ */
187
+ async #syncFolders(folders, projectId, errors) {
188
+ let foldersProcessed = 0;
189
+ let testSuitesProcessed = 0;
190
+
191
+ if (folders.length === 0) {
192
+ return { foldersProcessed, testSuitesProcessed };
193
+ }
194
+
195
+ const payload = {
196
+ project_id: projectId,
197
+ folders: folders.map(folder => ({
198
+ type: folder.type,
199
+ id: folder.id,
200
+ name: folder.name,
201
+ hash: folder.hash,
202
+ parent_folder_id: folder.parent_folder_id,
203
+ })),
204
+ };
205
+
206
+ try {
207
+ const response = await this.beatsApi.syncManualFolders(payload);
208
+ const results = response.results;
209
+
210
+ for (let i = 0; i < results.length; i++) {
211
+ const result = results[i];
212
+ const folder = folders[i];
213
+
214
+ if (result.success) {
215
+ logger.info(`✅ Folder '${result.name}' synced successfully`);
216
+ foldersProcessed++;
217
+
218
+ folder.id = folder.id || result.id;
219
+
220
+ if (folder.test_suites && folder.test_suites.length > 0) {
221
+ const testSuitesWithFolderId = folder.test_suites.map(ts => ({
222
+ ...ts,
223
+ folder_id: folder.id
224
+ }));
225
+
226
+ const suiteResult = await this.#syncTestSuites(testSuitesWithFolderId, projectId, errors);
227
+ testSuitesProcessed += suiteResult.testSuitesProcessed;
228
+ }
229
+
230
+ if (folder.folders && folder.folders.length > 0) {
231
+ const subfoldersWithParent = folder.folders.map(sf => ({
232
+ ...sf,
233
+ parent_folder_id: folder.id
234
+ }));
235
+
236
+ const subResult = await this.#syncFolders(subfoldersWithParent, projectId, errors);
237
+ foldersProcessed += subResult.foldersProcessed;
238
+ testSuitesProcessed += subResult.testSuitesProcessed;
239
+ }
240
+ } else {
241
+ const error = {
242
+ type: 'folder',
243
+ name: result.name,
244
+ error: result.error || 'Unknown error'
245
+ };
246
+ errors.push(error);
247
+ logger.error(`❌ Folder '${result.name}' failed to sync: ${error.error}`);
248
+ }
249
+ }
250
+ } catch (error) {
251
+ const syncError = {
252
+ type: 'folder_batch',
253
+ name: 'batch',
254
+ error: error.message
255
+ };
256
+ errors.push(syncError);
257
+ logger.error(`❌ Failed to sync folder batch: ${error.message}`);
258
+ }
259
+
260
+ return { foldersProcessed, testSuitesProcessed };
261
+ }
262
+
263
+ /**
264
+ * Sync test suites
265
+ * @param {Array<import('../types').IManualTestSuite>} testSuites
266
+ * @param {string} projectId
267
+ * @param {Array} errors - Array to collect errors
268
+ * @returns {Promise<{testSuitesProcessed: number}>}
269
+ */
270
+ async #syncTestSuites(testSuites, projectId, errors) {
271
+ let testSuitesProcessed = 0;
272
+
273
+ if (testSuites.length === 0) {
274
+ return { testSuitesProcessed };
275
+ }
276
+
277
+ const payload = {
278
+ project_id: projectId,
279
+ suites: testSuites.map(testSuite => ({
280
+ type: testSuite.type,
281
+ name: testSuite.name,
282
+ folder_id: testSuite.folder_id,
283
+ hash: testSuite.hash,
284
+ tags: testSuite.tags,
285
+ before_each: testSuite.before_each.map(step => step.name),
286
+ test_cases: testSuite.test_cases.map(tc => ({
287
+ name: tc.name,
288
+ type: tc.type,
289
+ tags: tc.tags,
290
+ steps: tc.steps.map(step => step.name),
291
+ hash: tc.hash,
292
+ })),
293
+ })),
294
+ };
295
+
296
+ try {
297
+ const response = await this.beatsApi.syncManualTestSuites(payload);
298
+ const results = response.results;
299
+
300
+ for (const result of results) {
301
+ if (result.success) {
302
+ logger.info(`✅ Test Suite '${result.name}' synced successfully`);
303
+ testSuitesProcessed++;
304
+ } else {
305
+ const error = {
306
+ type: 'test_suite',
307
+ name: result.name,
308
+ error: result.error || 'Unknown error'
309
+ };
310
+ errors.push(error);
311
+ logger.error(`❌ Test Suite '${result.name}' failed to sync: ${error.error}`);
312
+ }
313
+ }
314
+ } catch (error) {
315
+ const syncError = {
316
+ type: 'test_suite_batch',
317
+ name: 'batch',
318
+ error: error.message
319
+ };
320
+ errors.push(syncError);
321
+ logger.error(`❌ Failed to sync test suite batch: ${error.message}`);
322
+ }
323
+
324
+ return { testSuitesProcessed };
325
+ }
326
+ }
327
+
328
+ module.exports = { ManualTestSynchronizer };
329
+
@@ -1,47 +1,122 @@
1
1
  const path = require('path');
2
2
  const logger = require('./logger');
3
3
 
4
+ const DEFAULT_PROJECT = 'demo-project';
5
+
4
6
  class ConfigBuilder {
5
7
 
6
8
  /**
7
9
  * @param {import('../index').CommandLineOptions} opts
10
+ * @param {import('../helpers/ci').ICIInfo} ci
11
+ * @param {Record<string, string | undefined>} env
8
12
  */
9
- constructor(opts) {
13
+ constructor(opts, ci, env = process.env) {
10
14
  this.opts = opts;
15
+ this.ci = ci;
16
+ this.env = env;
17
+ /** @type {import('../index').PublishConfig} */
18
+ this.config = {};
11
19
  }
12
20
 
13
21
  build() {
14
22
  if (!this.opts) {
15
23
  return;
16
24
  }
17
- if (typeof this.opts.config === 'object') {
18
- return
25
+ logger.info('🏗 Building config...')
26
+ switch (typeof this.opts.config) {
27
+ case 'object':
28
+ this.#buildFromConfigFile();
29
+ break;
30
+ case 'string':
31
+ this.#buildFromConfigFilePath();
32
+ break;
33
+ default:
34
+ this.#buildFromCommandLineOptions();
35
+ break;
19
36
  }
20
- if (this.opts.config && typeof this.opts.config === 'string') {
21
- return;
37
+ }
38
+
39
+ #buildFromConfigFile() {
40
+ this.config = this.opts.config;
41
+ this.#buildBeats();
42
+ }
43
+
44
+ #buildFromConfigFilePath() {
45
+ const cwd = process.cwd();
46
+ const file_path = path.join(cwd, this.opts.config);
47
+ try {
48
+ const config_json = require(file_path);
49
+ this.opts.config = config_json;
50
+ this.#buildFromConfigFile();
51
+ } catch (error) {
52
+ throw new Error(`Failed to read config file: '${file_path}' with error: '${error.message}'`);
22
53
  }
54
+ }
23
55
 
24
- logger.info('🏗 Building config...')
25
- this.#buildConfig();
56
+
57
+
58
+ #buildFromCommandLineOptions() {
59
+ this.opts.config = {};
60
+ this.config = this.opts.config;
26
61
  this.#buildBeats();
27
62
  this.#buildResults();
28
63
  this.#buildTargets();
29
64
  this.#buildExtensions();
65
+ }
30
66
 
31
- logger.debug(`🛠️ Generated Config: \n${JSON.stringify(this.config, null, 2)}`);
67
+ #buildBeats() {
68
+ this.#setProject();
69
+ this.#setRun();
70
+ this.#setApiKey();
71
+ }
32
72
 
33
- this.opts.config = this.config;
73
+ #setProject() {
74
+ if (this.opts.project) {
75
+ this.config.project = this.opts.project;
76
+ return;
77
+ }
78
+ if (this.env.TEST_BEATS_PROJECT || this.env.TESTBEATS_PROJECT) {
79
+ this.config.project = this.env.TEST_BEATS_PROJECT || this.env.TESTBEATS_PROJECT;
80
+ return;
81
+ }
82
+ if (this.config.project) {
83
+ return;
84
+ }
85
+ if (this.ci && this.ci.repository_name) {
86
+ this.config.project = this.ci.repository_name;
87
+ return;
88
+ }
89
+
90
+ this.config.project = DEFAULT_PROJECT;
34
91
  }
35
92
 
36
- #buildConfig() {
37
- /** @type {import('../index').PublishConfig} */
38
- this.config = {};
93
+ #setRun() {
94
+ if (this.opts.run) {
95
+ this.config.run = this.opts.run;
96
+ return;
97
+ }
98
+ if (this.env.TEST_BEATS_RUN || this.env.TESTBEATS_RUN) {
99
+ this.config.run = this.env.TEST_BEATS_RUN || this.env.TESTBEATS_RUN;
100
+ return;
101
+ }
102
+ if (this.config.run) {
103
+ return;
104
+ }
105
+ if (this.ci && this.ci.build_name) {
106
+ this.config.run = this.ci.build_name;
107
+ return;
108
+ }
39
109
  }
40
110
 
41
- #buildBeats() {
42
- this.config.project = this.opts.project || this.config.project;
43
- this.config.run = this.opts.run || this.config.run;
44
- this.config.api_key = this.opts['api-key'] || this.config.api_key;
111
+ #setApiKey() {
112
+ if (this.opts['api-key']) {
113
+ this.config.api_key = this.opts['api-key'];
114
+ return;
115
+ }
116
+ if (this.env.TEST_BEATS_API_KEY || this.env.TESTBEATS_API_KEY) {
117
+ this.config.api_key = this.env.TEST_BEATS_API_KEY || this.env.TESTBEATS_API_KEY;
118
+ return;
119
+ }
45
120
  }
46
121
 
47
122
  #buildResults() {
@@ -1,193 +0,0 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const hash = require('object-hash');
4
- const { GherkinParser } = require('./parsers/gherkin');
5
-
6
- class ManualSyncHelper {
7
- constructor() {
8
- this.parser = new GherkinParser();
9
- }
10
-
11
- /**
12
- * Generate hash for an object
13
- * @param {Object} obj - Object to hash
14
- * @returns {string} Hash string
15
- */
16
- generateHash(obj) {
17
- return hash(obj, { algorithm: 'md5' });
18
- }
19
-
20
- /**
21
- * Add hash to test case
22
- * @param {Object} testCase - Test case object
23
- * @returns {Object} Test case with hash
24
- */
25
- addTestCaseHash(testCase) {
26
- return {
27
- ...testCase,
28
- hash: this.generateHash(testCase)
29
- };
30
- }
31
-
32
- /**
33
- * Add hash to test suite
34
- * @param {Object} testSuite - Test suite object
35
- * @returns {Object} Test suite with hash
36
- */
37
- addTestSuiteHash(testSuite) {
38
- return {
39
- ...testSuite,
40
- hash: this.generateHash(testSuite),
41
- test_cases: testSuite.test_cases.map(testCase => this.addTestCaseHash(testCase))
42
- };
43
- }
44
-
45
- /**
46
- * Add hash to folder
47
- * @param {Object} folder - Folder object
48
- * @returns {Object} Folder with hash
49
- */
50
- addFolderHash(folder) {
51
- return {
52
- ...folder,
53
- hash: this.generateHash(folder),
54
- test_suites: folder.test_suites.map(testSuite => this.addTestSuiteHash(testSuite)),
55
- folders: folder.folders.map(subFolder => this.addFolderHash(subFolder))
56
- };
57
- }
58
-
59
- /**
60
- * Hash a folder
61
- * @param {import('../types').IManualTestFolder} folder
62
- * @returns {string} Hash string
63
- */
64
- hashFolder(folder) {
65
- return this.generateHash({
66
- name: folder.name,
67
- path: folder.path,
68
- test_suites: folder.test_suites.map(testSuite => testSuite.hash),
69
- folders: folder.folders.map(subFolder => subFolder.hash)
70
- });
71
- }
72
-
73
- /**
74
- * Hash a test suite
75
- * @param {import('../types').IManualTestSuite} testSuite
76
- * @returns {string} Hash string
77
- */
78
- hashTestSuite(testSuite) {
79
- return this.generateHash({
80
- name: testSuite.name,
81
- tags: testSuite.tags,
82
- before_each: testSuite.before_each,
83
- test_cases: testSuite.test_cases.map(testCase => testCase.hash)
84
- });
85
- }
86
-
87
-
88
- /**
89
- * Scan directory recursively and build folder structure
90
- * @param {string} directoryPath - Path to scan
91
- * @returns {Object} Folder structure with test suites and hashes
92
- */
93
- async scanDirectory(directoryPath) {
94
- const absolutePath = path.resolve(directoryPath);
95
-
96
- if (!fs.existsSync(absolutePath)) {
97
- throw new Error(`Directory does not exist: ${directoryPath}`);
98
- }
99
-
100
- if (!fs.statSync(absolutePath).isDirectory()) {
101
- throw new Error(`Path is not a directory: ${directoryPath}`);
102
- }
103
-
104
- return this.buildFolderStructure(absolutePath, directoryPath);
105
- }
106
-
107
- /**
108
- * Build folder structure recursively
109
- * @param {string} absolutePath - Absolute path for file operations
110
- * @param {string} relativePath - Relative path for output
111
- * @returns {import('../types').IManualTestFolder} Folder structure
112
- */
113
- buildFolderStructure(absolutePath, relativePath) {
114
- const folderName = path.basename(absolutePath);
115
- const items = fs.readdirSync(absolutePath);
116
-
117
- /**
118
- * @type {import('../types').IManualTestFolder}
119
- */
120
- const structure = {
121
- name: folderName,
122
- path: relativePath,
123
- test_suites: [],
124
- folders: []
125
- };
126
-
127
- for (const item of items) {
128
- const itemPath = path.join(absolutePath, item);
129
- const itemRelativePath = path.join(relativePath, item);
130
- const stats = fs.statSync(itemPath);
131
-
132
- if (stats.isDirectory()) {
133
- // Recursively process subdirectories
134
- const subFolder = this.buildFolderStructure(itemPath, itemRelativePath);
135
- structure.folders.push(subFolder);
136
- } else if (this.isGherkinFile(item)) {
137
- // Parse gherkin files
138
- try {
139
- const testSuite = this.parseGherkinFile(itemPath, itemRelativePath);
140
- testSuite.hash = this.hashTestSuite(testSuite);
141
- structure.test_suites.push(testSuite);
142
- } catch (error) {
143
- console.warn(`Warning: Failed to parse ${itemPath}: ${error.message}`);
144
- }
145
- }
146
- }
147
- structure.hash = this.hashFolder(structure);
148
- if (structure.test_suites.length > 0) {
149
- const defaultFolder = {
150
- name: 'default',
151
- path: 'default',
152
- hash: '',
153
- test_suites: structure.test_suites,
154
- folders: []
155
- };
156
- defaultFolder.hash = this.hashFolder(defaultFolder);
157
- structure.folders.push(defaultFolder);
158
- structure.test_suites = [];
159
- }
160
-
161
- return structure;
162
- }
163
-
164
- /**
165
- * Check if file is a gherkin file
166
- * @param {string} filename - Filename to check
167
- * @returns {boolean} True if gherkin file
168
- */
169
- isGherkinFile(filename) {
170
- return filename.endsWith('.feature');
171
- }
172
-
173
- /**
174
- * Parse a gherkin file and format output
175
- * @param {string} filePath - Path to gherkin file
176
- * @param {string} relativePath - Relative path for output
177
- * @returns {import('../types').IManualTestSuite} Formatted test suite
178
- */
179
- parseGherkinFile(filePath, relativePath) {
180
- const parsed = this.parser.parse(filePath);
181
-
182
- return {
183
- name: parsed.name,
184
- type: parsed.type,
185
- tags: parsed.tags,
186
- before_each: parsed.before_each,
187
- path: relativePath,
188
- test_cases: parsed.test_cases || [] // Map 'cases' to 'test_cases' for consistency
189
- };
190
- }
191
- }
192
-
193
- module.exports = { ManualSyncHelper };