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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testbeats",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Publish test results to Microsoft Teams, Google Chat, Slack and InfluxDB",
5
5
  "main": "src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -20,14 +20,11 @@ class Beats {
20
20
  }
21
21
 
22
22
  async publish() {
23
- this.#setApiKey();
24
23
  if (!this.config.api_key) {
25
24
  logger.warn('😿 No API key provided, skipping publishing results to TestBeats Portal...');
26
25
  return;
27
26
  }
28
27
  this.#setCIInfo();
29
- this.#setProjectName();
30
- this.#setRunName();
31
28
  await this.#publishTestResults();
32
29
  await this.#uploadAttachments();
33
30
  this.#updateTitleLink();
@@ -38,19 +35,6 @@ class Beats {
38
35
  this.ci = getCIInformation();
39
36
  }
40
37
 
41
- #setProjectName() {
42
- this.config.project = this.config.project || process.env.TEST_BEATS_PROJECT || (this.ci && this.ci.repository_name) || 'demo-project';
43
- }
44
-
45
- #setApiKey() {
46
- this.config.api_key = this.config.api_key || process.env.TEST_BEATS_API_KEY;
47
- }
48
-
49
- #setRunName() {
50
- this.config.run = this.config.run || process.env.TEST_BEATS_RUN || (this.ci && this.ci.build_name) || 'demo-run';
51
- this.result.name = this.config.run;
52
- }
53
-
54
38
  async #publishTestResults() {
55
39
  logger.info("🚀 Publishing results to TestBeats Portal...");
56
40
  try {
@@ -3,9 +3,13 @@ const { MIN_NODE_VERSION } = require('../helpers/constants');
3
3
  const { ConfigBuilder } = require('../utils/config.builder');
4
4
  const logger = require('../utils/logger');
5
5
  const os = require('os');
6
+ const { getCIInformation } = require('../helpers/ci');
6
7
 
7
8
  class BaseCommand {
8
9
  constructor(opts) {
10
+ /**
11
+ * @type {import('../index').CommandLineOptions}
12
+ */
9
13
  this.opts = opts;
10
14
  }
11
15
 
@@ -38,7 +42,7 @@ class BaseCommand {
38
42
  }
39
43
 
40
44
  buildConfig() {
41
- const config_builder = new ConfigBuilder(this.opts);
45
+ const config_builder = new ConfigBuilder(this.opts, getCIInformation(), process.env);
42
46
  config_builder.build();
43
47
  }
44
48
 
@@ -1,6 +1,5 @@
1
- const { ManualSyncHelper } = require('../manual/sync.helper');
1
+ const { ManualSyncServiceFactory } = require('../manual');
2
2
  const logger = require('../utils/logger');
3
- const { BeatsApi } = require('../beats/beats.api');
4
3
  const { BaseCommand } = require('./base.command');
5
4
 
6
5
  class ManualSyncCommand extends BaseCommand {
@@ -9,7 +8,6 @@ class ManualSyncCommand extends BaseCommand {
9
8
  */
10
9
  constructor(opts) {
11
10
  super(opts);
12
- this.syncHelper = new ManualSyncHelper();
13
11
  }
14
12
 
15
13
  /**
@@ -24,288 +22,53 @@ class ManualSyncCommand extends BaseCommand {
24
22
  logger.info('🔄 Starting manual test case sync...');
25
23
 
26
24
  const targetPath = this.opts.path || '.';
27
- logger.info(`📁 Scanning directory: ${targetPath}`);
25
+ const projectName = this.opts.config.project;
28
26
 
29
- const result = await this.syncHelper.scanDirectory(targetPath);
27
+ // Create orchestrator with all dependencies wired
28
+ const orchestrator = ManualSyncServiceFactory.create(this.opts.config);
30
29
 
31
- const counts = this.getCounts(result);
32
- logger.info(`📊 Found ${counts.totalTestCases} test cases, ${counts.totalTestSuites} test suites, and ${counts.totalFolders} folders`);
30
+ // Execute the complete pipeline
31
+ const result = await orchestrator.execute(targetPath, projectName);
33
32
 
34
- // Format output as specified in requirements
35
- const output = {
36
- folders: result.folders
37
- };
38
-
39
-
40
-
41
- const beats = new BeatsApi(this.opts.config);
42
- const projectsResponse = await beats.searchProjects(this.opts.config.project);
43
- const project = projectsResponse.values.find(p => p.name === this.opts.config.project);
44
- if (!project) {
45
- throw new Error(`Project ${this.opts.config.project} not found`);
46
- }
47
- this.opts.config.project = project.id;
48
-
49
- const compare = new ManualSyncCompare(this.opts.config);
50
- const compareResult = await compare.compare(output);
51
-
52
- const resources = new ManualSyncResources(this.opts.config);
53
- await resources.sync(output, compareResult);
33
+ // Log final results
34
+ this.#logResults(result);
54
35
 
36
+ return result;
55
37
  } catch (error) {
56
38
  logger.error(`❌ Manual sync failed: ${error.message}`);
57
39
  throw error;
58
40
  }
59
41
  }
60
42
 
61
- getCounts(result, counts = { totalTestCases: 0, totalTestSuites: 0, totalFolders: 0 }) {
62
- // return the total number of test cases, test suites, and folders
63
- for (const folder of result.folders) {
64
- counts.totalTestCases += folder.test_suites.reduce((acc, suite) => acc + suite.test_cases.length, 0);
65
- counts.totalTestSuites += folder.test_suites.length;
66
- counts.totalFolders += folder.folders.length;
67
- this.getCounts(folder, counts);
68
- }
69
- return counts;
70
- }
71
- }
72
-
73
- class ManualSyncCompare {
74
-
75
- constructor(config) {
76
- this.config = config;
77
- this.beats = new BeatsApi(this.config);
78
- }
79
-
80
- /**
81
- * @param {import('../types').IManualTestFolder} structure
82
- */
83
- async compare(structure) {
84
- const payload = this.#buildPayload(structure);
85
- const response = await this.beats.compareManualTests(payload);
86
- return response;
87
- }
88
-
89
-
90
- /**
91
- * Build the payload for the compare manual tests API
92
- * @param {import('../types').IManualTestFolder} structure
93
- * @returns {import('../types').IManualSyncComparePayload}
94
- */
95
- #buildPayload(structure) {
96
- const payload = {
97
- project_id: this.config.project,
98
- folders: [],
99
- };
100
- for (const folder of structure.folders) {
101
- payload.folders.push(this.#buildPayloadFolder(folder));
102
- }
103
- return payload;
104
- }
105
-
106
- /**
107
- * Build the payload for a folder
108
- * @param {import('../types').IManualTestFolder} folder
109
- * @returns {import('../types').IManualSyncCompareFolder}
110
- */
111
- #buildPayloadFolder(folder) {
112
- return {
113
- name: folder.name,
114
- hash: folder.hash,
115
- test_suites: folder.test_suites.map(testSuite => this.#buildPayloadTestSuite(testSuite)),
116
- folders: folder.folders.map(subFolder => this.#buildPayloadFolder(subFolder))
117
- };
118
- }
119
-
120
- /**
121
- * Build the payload for a test suite
122
- * @param {import('../types').IManualTestSuite} testSuite
123
- * @returns {import('../types').IManualSyncCompareTestSuite}
124
- */
125
- #buildPayloadTestSuite(testSuite) {
126
- return {
127
- name: testSuite.name,
128
- hash: testSuite.hash,
129
- };
130
- }
131
- }
132
-
133
- class ManualSyncResources {
134
- constructor(config) {
135
- this.config = config;
136
- this.beats = new BeatsApi(this.config);
137
- }
138
-
139
- /**
140
- * Sync the manual resources
141
- * @param {import('../types').IManualTestFolder} structure
142
- * @param {import('../types').IManualSyncCompareResponse} compareResult
143
- */
144
- async sync(structure, compareResult) {
145
- this.#mergeStructure(structure, compareResult);
146
- await this.#syncFolders(structure.folders);
147
- }
148
-
149
- /**
150
- * Merge the structure with the compare result
151
- * @param {import('../types').IManualTestFolder} structure
152
- * @param {import('../types').IManualSyncCompareResponse} compareResult
153
- */
154
- #mergeStructure(structure, compareResult) {
155
- // Iterate through all folders from compare result and merge with local structure
156
- for (const serverFolder of compareResult.folders) {
157
- const localFolder = structure.folders.find(f => f.name === serverFolder.name);
158
- if (localFolder) {
159
- this.#mergeFolder(localFolder, serverFolder);
160
- } else {
161
- structure.folders.push(this.#convertServerFolderToLocal(serverFolder));
162
- }
163
- }
164
- }
165
-
166
- /**
167
- * Merge server folder metadata into local folder
168
- * @param {import('../types').IManualTestFolder} localFolder - Local folder with full data
169
- * @param {import('../types').IManualSyncCompareResponseFolder} serverFolder - Server folder with metadata
170
- */
171
- #mergeFolder(localFolder, serverFolder) {
172
- // Enrich local folder with server metadata
173
- localFolder.id = serverFolder.id;
174
- localFolder.type = serverFolder.type;
175
-
176
- // Ensure server folder has test suites and folders
177
- serverFolder.test_suites = serverFolder.test_suites || [];
178
- serverFolder.folders = serverFolder.folders || [];
179
-
180
- for (const serverSuite of serverFolder.test_suites) {
181
- const localSuite = localFolder.test_suites.find(s => s.name === serverSuite.name);
182
- if (localSuite) {
183
- this.#mergeTestSuite(localSuite, serverSuite);
184
- } else {
185
- localFolder.test_suites.push(this.#convertServerSuiteToLocal(serverSuite));
186
- }
187
- }
188
-
189
- for (const serverSubFolder of serverFolder.folders) {
190
- const localSubFolder = localFolder.folders.find(f => f.name === serverSubFolder.name);
191
- if (localSubFolder) {
192
- this.#mergeFolder(localSubFolder, serverSubFolder);
193
- } else {
194
- localFolder.folders.push(this.#convertServerFolderToLocal(serverSubFolder));
195
- }
196
- }
197
- }
198
-
199
43
  /**
200
- * Merge server test suite metadata into local test suite
201
- * @param {import('../types').IManualTestSuite} localSuite - Local test suite with full data
202
- * @param {import('../types').IManualSyncCompareResponseTestSuite} serverSuite - Server suite with metadata
44
+ * Log the final sync results
45
+ * @param {Object} result - Sync result from orchestrator
203
46
  */
204
- #mergeTestSuite(localSuite, serverSuite) {
205
- localSuite.id = serverSuite.id;
206
- localSuite.type = serverSuite.type;
207
- }
208
-
209
- /**
210
- * Convert server folder to local folder structure (for delete operations)
211
- * @param {import('../types').IManualSyncCompareResponseFolder} serverFolder - Server folder
212
- * @returns {import('../types').IManualTestFolder} Local folder structure
213
- */
214
- #convertServerFolderToLocal(serverFolder) {
215
- return {
216
- name: serverFolder.name,
217
- path: '',
218
- hash: serverFolder.hash,
219
- id: serverFolder.id,
220
- type: serverFolder.type,
221
- test_suites: serverFolder.test_suites?.map(s => this.#convertServerSuiteToLocal(s)) || [],
222
- folders: serverFolder.folders?.map(f => this.#convertServerFolderToLocal(f)) || []
223
- };
224
- }
225
-
226
- /**
227
- * Convert server test suite to local test suite structure (for delete operations)
228
- * @param {import('../types').IManualSyncCompareResponseTestSuite} serverSuite - Server suite
229
- * @returns {import('../types').IManualTestSuite} Local test suite structure
230
- */
231
- #convertServerSuiteToLocal(serverSuite) {
232
- return {
233
- name: serverSuite.name,
234
- tags: [],
235
- before_each: [],
236
- test_cases: [],
237
- hash: serverSuite.hash,
238
- id: serverSuite.id,
239
- type: serverSuite.type
240
- };
241
- }
242
-
243
- async #syncFolders(folders) {
244
- const payload = {
245
- project_id: this.config.project,
246
- folders: folders.map(folder => ({
247
- type: folder.type,
248
- id: folder.id,
249
- name: folder.name,
250
- hash: folder.hash,
251
- parent_folder_id: folder.parent_folder_id,
252
- })),
253
- };
254
- const response = await this.beats.syncManualFolders(payload);
255
- const results = response.results;
256
- for (const result of results) {
257
- if (result.success) {
258
- logger.info(`✅ Folder '${result.name}' synced successfully`);
259
- const folder = folders.find(f => f.name === result.name);
260
- if (!folder) {
261
- console.log(`❌ Folder ${result.name} not found in local structure`);
262
- continue;
263
- }
264
- folder.id = folder.id || result.id;
265
- for (const testSuite of folder.test_suites) {
266
- testSuite.folder_id = folder.id;
267
- }
268
- await this.#syncTestSuites(folder.test_suites);
269
- for (const subFolder of folder.folders) {
270
- subFolder.parent_folder_id = folder.id;
271
- }
272
- await this.#syncFolders(folder.folders);
47
+ #logResults(result) {
48
+ logger.info('');
49
+ logger.info('📊 Sync Summary:');
50
+ logger.info(` • Test Cases Found: ${result.statistics.totalTestCases}`);
51
+ logger.info(` • Test Suites Found: ${result.statistics.totalTestSuites}`);
52
+ logger.info(` • Folders Found: ${result.statistics.totalFolders}`);
53
+ logger.info(` Folders Synced: ${result.statistics.foldersProcessed}`);
54
+ logger.info(` Test Suites Synced: ${result.statistics.testSuitesProcessed}`);
55
+
56
+ if (result.errors.length > 0) {
57
+ logger.info('');
58
+ logger.warn(`⚠️ Encountered ${result.errors.length} error(s):`);
59
+ for (const error of result.errors) {
60
+ logger.warn(` • [${error.type}] ${error.name}: ${error.error}`);
273
61
  }
274
62
  }
275
- }
276
63
 
277
- async #syncTestSuites(testSuites) {
278
- const payload = {
279
- project_id: this.config.project,
280
- suites: testSuites.map(testSuite => ({
281
- type: testSuite.type,
282
- name: testSuite.name,
283
- folder_id: testSuite.folder_id,
284
- hash: testSuite.hash,
285
- tags: testSuite.tags,
286
- before_each: testSuite.before_each.map(step => step.name),
287
- test_cases: testSuite.test_cases.map(tc => {
288
- return {
289
- name: tc.name,
290
- type: tc.type,
291
- tags: tc.tags,
292
- steps: tc.steps.map(step => step.name),
293
- hash: tc.hash,
294
- };
295
- }),
296
- })),
297
- };
298
- const response = await this.beats.syncManualTestSuites(payload);
299
- const results = response.results;
300
- for (const result of results) {
301
- if (result.success) {
302
- logger.info(`✅ Test Suite '${result.name}' synced successfully`);
303
- } else {
304
- logger.error(`❌ Test Suite '${result.name}' failed to sync: ${result.error}`);
305
- }
64
+ if (result.success) {
65
+ logger.info('');
66
+ logger.info('✅ Manual sync completed successfully!');
67
+ } else {
68
+ logger.info('');
69
+ logger.warn('⚠️ Manual sync completed with errors');
306
70
  }
307
71
  }
308
-
309
72
  }
310
73
 
311
- module.exports = { ManualSyncCommand, ManualSyncCompare };
74
+ module.exports = { ManualSyncCommand };
@@ -1,4 +1,3 @@
1
- const path = require('path');
2
1
  const trp = require('test-results-parser');
3
2
  const prp = require('performance-results-parser');
4
3
 
@@ -25,8 +24,6 @@ class PublishCommand extends BaseCommand {
25
24
  this.validateEnvDetails();
26
25
  this.buildConfig();
27
26
  this.#validateOptions();
28
- this.#setConfigFromFile();
29
- this.#mergeConfigOptions();
30
27
  this.#processConfig();
31
28
  this.#validateConfig();
32
29
  this.#processResults();
@@ -44,27 +41,6 @@ class PublishCommand extends BaseCommand {
44
41
  }
45
42
  }
46
43
 
47
- #setConfigFromFile() {
48
- if (typeof this.opts.config === 'string') {
49
- const cwd = process.cwd();
50
- const file_path = path.join(cwd, this.opts.config);
51
- try {
52
- const config_json = require(path.join(cwd, this.opts.config));
53
- this.opts.config = config_json;
54
- } catch (error) {
55
- throw new Error(`Failed to read config file: '${file_path}' with error: '${error.message}'`);
56
- }
57
- }
58
- }
59
-
60
- #mergeConfigOptions() {
61
- if (this.opts.config && typeof this.opts.config === 'object') {
62
- this.opts.config.project = this.opts.project || this.opts.config.project;
63
- this.opts.config.run = this.opts.run || this.opts.config.run;
64
- this.opts.config.api_key = this.opts['api-key'] || this.opts.config.api_key;
65
- }
66
- }
67
-
68
44
  #processConfig() {
69
45
  const processed_config = processData(this.opts.config);
70
46
  /**@type {import('../index').PublishConfig[]} */
@@ -230,6 +206,9 @@ class PublishCommand extends BaseCommand {
230
206
  for (const config of this.configs) {
231
207
  for (let i = 0; i < this.results.length; i++) {
232
208
  const result = this.results[i];
209
+ if (config.api_key) {
210
+ result.name = config.run || result.name || 'demo-run';
211
+ }
233
212
  config.extensions = config.extensions || [];
234
213
  await beats.run(config, result);
235
214
  if (config.targets) {
package/src/index.d.ts CHANGED
@@ -345,6 +345,8 @@ export interface CommandLineOptions {
345
345
  nunit?: string;
346
346
  xunit?: string;
347
347
  mstest?: string;
348
+ // Manual sync command options
349
+ path?: string;
348
350
  }
349
351
 
350
352
  export type IExtensionDefaultOptions = {
@@ -0,0 +1,71 @@
1
+ class ManualTestComparator {
2
+ /**
3
+ * @param {import('../beats/beats.api').BeatsApi} beatsApi
4
+ */
5
+ constructor(beatsApi) {
6
+ this.beatsApi = beatsApi;
7
+ }
8
+
9
+ /**
10
+ * Compare local structure with server
11
+ * @param {import('../types').IManualTestFolder} structure - Local folder structure
12
+ * @param {string} projectId - Project ID
13
+ * @returns {Promise<import('../types').IManualSyncCompareResponse>} Comparison result
14
+ * @throws {Error} If comparison fails
15
+ */
16
+ async compare(structure, projectId) {
17
+ try {
18
+ const payload = this.#buildPayload(structure, projectId);
19
+ const response = await this.beatsApi.compareManualTests(payload);
20
+ return response;
21
+ } catch (error) {
22
+ throw new Error(error.message);
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Build the payload for the compare manual tests API
28
+ * @param {import('../types').IManualTestFolder} structure
29
+ * @param {string} projectId
30
+ * @returns {import('../types').IManualSyncComparePayload}
31
+ */
32
+ #buildPayload(structure, projectId) {
33
+ const payload = {
34
+ project_id: projectId,
35
+ folders: [],
36
+ };
37
+ for (const folder of structure.folders) {
38
+ payload.folders.push(this.#buildPayloadFolder(folder));
39
+ }
40
+ return payload;
41
+ }
42
+
43
+ /**
44
+ * Build the payload for a folder
45
+ * @param {import('../types').IManualTestFolder} folder
46
+ * @returns {import('../types').IManualSyncCompareFolder}
47
+ */
48
+ #buildPayloadFolder(folder) {
49
+ return {
50
+ name: folder.name,
51
+ hash: folder.hash,
52
+ test_suites: folder.test_suites.map(testSuite => this.#buildPayloadTestSuite(testSuite)),
53
+ folders: folder.folders.map(subFolder => this.#buildPayloadFolder(subFolder))
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Build the payload for a test suite
59
+ * @param {import('../types').IManualTestSuite} testSuite
60
+ * @returns {import('../types').IManualSyncCompareTestSuite}
61
+ */
62
+ #buildPayloadTestSuite(testSuite) {
63
+ return {
64
+ name: testSuite.name,
65
+ hash: testSuite.hash,
66
+ };
67
+ }
68
+ }
69
+
70
+ module.exports = { ManualTestComparator };
71
+
@@ -0,0 +1,60 @@
1
+ const fs = require('fs');
2
+ const { BeatsApi } = require('../beats/beats.api');
3
+ const { ManualTestScanner } = require('./scanner');
4
+ const { ManualTestComparator } = require('./comparator');
5
+ const { ManualTestSynchronizer } = require('./synchronizer');
6
+ const { ProjectResolver } = require('./project-resolver');
7
+ const { ManualSyncOrchestrator } = require('./orchestrator');
8
+ const { ManualTestHasher } = require('./hasher');
9
+ const { GherkinParser } = require('./parsers/gherkin');
10
+
11
+ /**
12
+ * Factory for creating properly wired manual sync services
13
+ * Implements dependency injection and ensures single BeatsApi instance
14
+ */
15
+ class ManualSyncServiceFactory {
16
+ /**
17
+ * Create a fully configured orchestrator with all dependencies
18
+ * @param {Object} config - Configuration object for BeatsApi
19
+ * @returns {ManualSyncOrchestrator} Configured orchestrator
20
+ */
21
+ static create(config) {
22
+ const beatsApi = new BeatsApi(config);
23
+ const hasher = new ManualTestHasher();
24
+ const parser = new GherkinParser(fs);
25
+ const scanner = new ManualTestScanner(fs, parser);
26
+ const comparator = new ManualTestComparator(beatsApi);
27
+ const synchronizer = new ManualTestSynchronizer(beatsApi);
28
+ const projectResolver = new ProjectResolver(beatsApi);
29
+
30
+ return new ManualSyncOrchestrator(
31
+ scanner,
32
+ comparator,
33
+ synchronizer,
34
+ projectResolver,
35
+ hasher
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Create individual services for testing or custom workflows
41
+ * @param {Object} config - Configuration object for BeatsApi
42
+ * @returns {Object} Object containing all services
43
+ */
44
+ static createServices(config) {
45
+ const beatsApi = new BeatsApi(config);
46
+ const hasher = new ManualTestHasher();
47
+
48
+ return {
49
+ beatsApi,
50
+ hasher,
51
+ scanner: new ManualTestScanner(),
52
+ comparator: new ManualTestComparator(beatsApi),
53
+ synchronizer: new ManualTestSynchronizer(beatsApi),
54
+ projectResolver: new ProjectResolver(beatsApi)
55
+ };
56
+ }
57
+ }
58
+
59
+ module.exports = { ManualSyncServiceFactory };
60
+
@@ -0,0 +1,93 @@
1
+ const hash = require('object-hash');
2
+
3
+ /**
4
+ * Handles all hashing operations for manual test entities
5
+ * Centralizes hashing logic with configurable algorithm
6
+ */
7
+ class ManualTestHasher {
8
+ /**
9
+ * @param {string} algorithm - Hashing algorithm (default: 'md5')
10
+ */
11
+ constructor(algorithm = 'md5') {
12
+ this.algorithm = algorithm;
13
+ }
14
+
15
+ /**
16
+ * Generate hash for any object
17
+ * @param {Object} obj - Object to hash
18
+ * @returns {string} Hash string
19
+ */
20
+ hash(obj) {
21
+ return hash(obj, { algorithm: this.algorithm });
22
+ }
23
+
24
+ /**
25
+ * Hash a test case
26
+ * @param {import('../types').IManualTestCase} testCase
27
+ * @returns {string} Hash string
28
+ */
29
+ hashTestCase(testCase) {
30
+ return this.hash({
31
+ name: testCase.name,
32
+ type: testCase.type,
33
+ tags: testCase.tags,
34
+ steps: testCase.steps,
35
+ path: testCase.path
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Hash a test suite
41
+ * @param {import('../types').IManualTestSuite} testSuite
42
+ * @returns {string} Hash string
43
+ */
44
+ hashTestSuite(testSuite) {
45
+ return this.hash({
46
+ name: testSuite.name,
47
+ type: testSuite.type,
48
+ tags: testSuite.tags,
49
+ before_each: testSuite.before_each,
50
+ test_cases: testSuite.test_cases.map(tc => tc.hash)
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Hash a folder
56
+ * @param {import('../types').IManualTestFolder} folder
57
+ * @returns {string} Hash string
58
+ */
59
+ hashFolder(folder) {
60
+ return this.hash({
61
+ name: folder.name,
62
+ path: folder.path,
63
+ test_suites: folder.test_suites.map(ts => ts.hash),
64
+ folders: folder.folders.map(f => f.hash)
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Recursively hash an entire folder structure
70
+ * Adds hash properties to all test cases, test suites, and folders
71
+ * @param {import('../types').IManualTestFolder} structure - Folder structure to hash
72
+ * @returns {import('../types').IManualTestFolder} Same structure with hashes added (mutates in place)
73
+ */
74
+ hashStructure(structure) {
75
+ for (const testSuite of structure.test_suites) {
76
+ for (const testCase of testSuite.test_cases) {
77
+ testCase.hash = this.hashTestCase(testCase);
78
+ }
79
+ testSuite.hash = this.hashTestSuite(testSuite);
80
+ }
81
+
82
+ for (const subFolder of structure.folders) {
83
+ this.hashStructure(subFolder);
84
+ }
85
+
86
+ structure.hash = this.hashFolder(structure);
87
+
88
+ return structure;
89
+ }
90
+ }
91
+
92
+ module.exports = { ManualTestHasher };
93
+