testbeats 2.2.9 → 2.4.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.2.9",
3
+ "version": "2.4.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",
@@ -47,24 +47,24 @@
47
47
  "homepage": "https://testbeats.com",
48
48
  "dependencies": {
49
49
  "async-retry": "^1.3.3",
50
- "dotenv": "^16.4.5",
50
+ "dotenv": "^17.2.3",
51
51
  "form-data-lite": "^1.0.3",
52
- "influxdb-lite": "^1.0.0",
52
+ "influxdb-lite": "1.1.0",
53
53
  "object-hash": "^3.0.0",
54
54
  "performance-results-parser": "latest",
55
- "phin-retry": "^1.0.3",
55
+ "phin-retry": "2.0.0",
56
56
  "pretty-ms": "^7.0.1",
57
57
  "prompts": "^2.4.2",
58
58
  "rosters": "0.0.1",
59
59
  "sade": "^1.8.1",
60
- "test-results-parser": "0.2.9"
60
+ "test-results-parser": "0.4.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "c8": "^10.1.2",
64
64
  "mocha": "^10.7.3",
65
65
  "mocha-junit-reporter": "^2.2.1",
66
66
  "mocha-multi-reporters": "^1.5.1",
67
- "pactum": "^3.7.1",
67
+ "pactum": "^3.9.0",
68
68
  "pkg": "^5.8.1"
69
69
  },
70
70
  "engines": {
@@ -9,6 +9,15 @@ class BeatsApi {
9
9
  this.config = config;
10
10
  }
11
11
 
12
+ searchProjects(search_text) {
13
+ return request.get({
14
+ url: `${this.getBaseUrl()}/api/core/v1/projects?search_text=${search_text}`,
15
+ headers: {
16
+ 'x-api-key': this.config.api_key
17
+ }
18
+ });
19
+ }
20
+
12
21
  postTestRun(payload) {
13
22
  return request.post({
14
23
  url: `${this.getBaseUrl()}/api/core/v1/test-runs`,
@@ -75,6 +84,40 @@ class BeatsApi {
75
84
  }
76
85
  });
77
86
  }
87
+
88
+ /**
89
+ * @param {import('../types').IManualSyncComparePayload} payload
90
+ * @returns {Promise<import('../types').IManualSyncCompareResponse>}
91
+ */
92
+ compareManualTests(payload) {
93
+ return request.post({
94
+ url: `${this.getBaseUrl()}/api/core/v1/manual/sync/compare`,
95
+ headers: {
96
+ 'x-api-key': this.config.api_key
97
+ },
98
+ body: payload
99
+ });
100
+ }
101
+
102
+ syncManualFolders(payload) {
103
+ return request.post({
104
+ url: `${this.getBaseUrl()}/api/core/v1/manual/sync/folders`,
105
+ headers: {
106
+ 'x-api-key': this.config.api_key
107
+ },
108
+ body: payload
109
+ });
110
+ }
111
+
112
+ syncManualTestSuites(payload) {
113
+ return request.post({
114
+ url: `${this.getBaseUrl()}/api/core/v1/manual/sync/suites`,
115
+ headers: {
116
+ 'x-api-key': this.config.api_key
117
+ },
118
+ body: payload
119
+ });
120
+ }
78
121
  }
79
122
 
80
123
  module.exports = { BeatsApi }
package/src/cli.js CHANGED
@@ -68,6 +68,9 @@ prog.command('init')
68
68
  prog.command('manual sync')
69
69
  .describe('Sync manual test cases from gherkin files in a directory')
70
70
  .option('-p, --path', 'Path to directory to sync (default: current directory)')
71
+ .option('-c, --config', 'path to config file')
72
+ .option('--api-key', 'api key')
73
+ .option('--project', 'project name')
71
74
  .example('manual sync')
72
75
  .example('manual sync --path ./tests/features')
73
76
  .action(async (opts) => {
@@ -0,0 +1,47 @@
1
+ const pkg = require('../../package.json');
2
+ const { MIN_NODE_VERSION } = require('../helpers/constants');
3
+ const { ConfigBuilder } = require('../utils/config.builder');
4
+ const logger = require('../utils/logger');
5
+ const os = require('os');
6
+
7
+ class BaseCommand {
8
+ constructor(opts) {
9
+ this.opts = opts;
10
+ }
11
+
12
+ printBanner() {
13
+ const banner = `
14
+ _____ _ ___ _
15
+ (_ _) ( )_ ( _'\\ ( )_
16
+ | | __ ___ | ,_)| (_) ) __ _ _ | ,_) ___
17
+ | | /'__'\\/',__)| | | _ <' /'__'\\ /'_' )| | /',__)
18
+ | |( ___/\\__, \\| |_ | (_) )( ___/( (_| || |_ \\__, \\
19
+ (_)'\\____)(____/'\\__)(____/''\\____)'\\__,_)'\\__)(____/
20
+
21
+ v${pkg.version}
22
+ `;
23
+ console.log(banner);
24
+ }
25
+
26
+ validateEnvDetails() {
27
+ try {
28
+ const current_major_version = parseInt(process.version.split('.')[0].replace('v', ''));
29
+ if (current_major_version >= MIN_NODE_VERSION) {
30
+ logger.info(`💻 NodeJS: ${process.version}, OS: ${os.platform()}, Version: ${os.release()}, Arch: ${os.machine()}`);
31
+ return;
32
+ }
33
+ } catch (error) {
34
+ logger.warn(`⚠️ Unable to verify NodeJS version: ${error.message}`);
35
+ return;
36
+ }
37
+ throw new Error(`❌ Supported NodeJS version is >= v${MIN_NODE_VERSION}. Current version is ${process.version}`)
38
+ }
39
+
40
+ buildConfig() {
41
+ const config_builder = new ConfigBuilder(this.opts);
42
+ config_builder.build();
43
+ }
44
+
45
+ }
46
+
47
+ module.exports = { BaseCommand };
@@ -1,13 +1,14 @@
1
- const path = require('path');
2
1
  const { ManualSyncHelper } = require('../manual/sync.helper');
3
2
  const logger = require('../utils/logger');
3
+ const { BeatsApi } = require('../beats/beats.api');
4
+ const { BaseCommand } = require('./base.command');
4
5
 
5
- class ManualSyncCommand {
6
+ class ManualSyncCommand extends BaseCommand {
6
7
  /**
7
8
  * @param {Object} opts - Command options
8
9
  */
9
10
  constructor(opts) {
10
- this.opts = opts;
11
+ super(opts);
11
12
  this.syncHelper = new ManualSyncHelper();
12
13
  }
13
14
 
@@ -16,6 +17,10 @@ class ManualSyncCommand {
16
17
  */
17
18
  async execute() {
18
19
  try {
20
+ this.printBanner();
21
+ this.validateEnvDetails();
22
+ this.buildConfig();
23
+
19
24
  logger.info('🔄 Starting manual test case sync...');
20
25
 
21
26
  const targetPath = this.opts.path || '.';
@@ -23,17 +28,29 @@ class ManualSyncCommand {
23
28
 
24
29
  const result = await this.syncHelper.scanDirectory(targetPath);
25
30
 
31
+ const counts = this.getCounts(result);
32
+ logger.info(`📊 Found ${counts.totalTestCases} test cases, ${counts.totalTestSuites} test suites, and ${counts.totalFolders} folders`);
33
+
26
34
  // Format output as specified in requirements
27
35
  const output = {
28
- folders: [result]
36
+ folders: result.folders
29
37
  };
30
38
 
31
- // Display results
32
- this.displayResults(output);
33
39
 
34
- logger.info('✅ Manual sync completed successfully!');
35
40
 
36
- return output;
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);
37
54
 
38
55
  } catch (error) {
39
56
  logger.error(`❌ Manual sync failed: ${error.message}`);
@@ -41,39 +58,254 @@ class ManualSyncCommand {
41
58
  }
42
59
  }
43
60
 
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
+
44
199
  /**
45
- * Display sync results in a readable format
46
- * @param {Object} output - Sync results
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
47
203
  */
48
- displayResults(output) {
49
- logger.info('\n📊 Sync Results:');
50
- this.displayFolderRecursive(output.folders[0], 0);
204
+ #mergeTestSuite(localSuite, serverSuite) {
205
+ localSuite.id = serverSuite.id;
206
+ localSuite.type = serverSuite.type;
51
207
  }
52
208
 
53
209
  /**
54
- * Recursively display folder structure
55
- * @param {Object} folder - Folder object
56
- * @param {number} depth - Current depth for indentation
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
57
213
  */
58
- displayFolderRecursive(folder, depth) {
59
- const indent = ' '.repeat(depth);
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
+ }
60
225
 
61
- logger.info(`${indent}📁 ${folder.name} (${folder.path})`);
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
+ }
62
242
 
63
- if (folder.test_suites.length > 0) {
64
- logger.info(`${indent} 📋 Test Suites: ${folder.test_suites.length}`);
65
- folder.test_suites.forEach(suite => {
66
- logger.info(`${indent} • ${suite.name} (${suite.test_cases.length} test cases)`);
67
- });
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);
273
+ }
68
274
  }
275
+ }
69
276
 
70
- if (folder.folders.length > 0) {
71
- logger.info(`${indent} 📂 Subfolders: ${folder.folders.length}`);
72
- folder.folders.forEach(subFolder => {
73
- this.displayFolderRecursive(subFolder, depth + 1);
74
- });
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
+ }
75
306
  }
76
307
  }
308
+
77
309
  }
78
310
 
79
- module.exports = { ManualSyncCommand };
311
+ module.exports = { ManualSyncCommand, ManualSyncCompare };
@@ -1,33 +1,29 @@
1
1
  const path = require('path');
2
2
  const trp = require('test-results-parser');
3
3
  const prp = require('performance-results-parser');
4
- const os = require('os');
5
4
 
6
5
  const beats = require('../beats');
7
- const { ConfigBuilder } = require('../utils/config.builder');
8
6
  const target_manager = require('../targets');
9
7
  const logger = require('../utils/logger');
10
8
  const { processData } = require('../helpers/helper');
11
9
  const { ExtensionsSetup } = require('../setups/extensions.setup');
12
- const pkg = require('../../package.json');
13
- const { MIN_NODE_VERSION } = require('../helpers/constants');
14
10
  const { sortExtensionsByOrder } = require('../helpers/extension.helper');
11
+ const { BaseCommand } = require('./base.command');
15
12
 
16
- class PublishCommand {
13
+ class PublishCommand extends BaseCommand {
17
14
 
18
15
  /**
19
16
  * @param {import('../index').CommandLineOptions} opts
20
17
  */
21
18
  constructor(opts) {
22
- this.opts = opts;
19
+ super(opts);
23
20
  this.errors = [];
24
21
  }
25
22
 
26
23
  async publish() {
27
- logger.info(`🥁 TestBeats v${pkg.version}`);
28
-
29
- this.#validateEnvDetails();
30
- this.#buildConfig();
24
+ this.printBanner();
25
+ this.validateEnvDetails();
26
+ this.buildConfig();
31
27
  this.#validateOptions();
32
28
  this.#setConfigFromFile();
33
29
  this.#mergeConfigOptions();
@@ -39,25 +35,6 @@ class PublishCommand {
39
35
  await this.#publishErrors();
40
36
  }
41
37
 
42
- #validateEnvDetails() {
43
- try {
44
- const current_major_version = parseInt(process.version.split('.')[0].replace('v', ''));
45
- if (current_major_version >= MIN_NODE_VERSION) {
46
- logger.info(`💻 NodeJS: ${process.version}, OS: ${os.platform()}, Version: ${os.release()}, Arch: ${os.machine()}`);
47
- return;
48
- }
49
- } catch (error) {
50
- logger.warn(`⚠️ Unable to verify NodeJS version: ${error.message}`);
51
- return;
52
- }
53
- throw new Error(`❌ Supported NodeJS version is >= v${MIN_NODE_VERSION}. Current version is ${process.version}`)
54
- }
55
-
56
- #buildConfig() {
57
- const config_builder = new ConfigBuilder(this.opts);
58
- config_builder.build();
59
- }
60
-
61
38
  #validateOptions() {
62
39
  if (!this.opts) {
63
40
  throw new Error('Missing publish options');
@@ -0,0 +1,39 @@
1
+ const hash = require('object-hash');
2
+
3
+ class BaseParser {
4
+ hash(obj) {
5
+ return hash(obj, { algorithm: 'md5' });
6
+ }
7
+
8
+ /**
9
+ * Hash a test case
10
+ * @param {import('../../types').IManualTestCase} testCase
11
+ * @returns {string}
12
+ */
13
+ hashTestCase(testCase) {
14
+ return this.hash({
15
+ name: testCase.name,
16
+ type: testCase.type,
17
+ tags: testCase.tags,
18
+ steps: testCase.steps,
19
+ path: testCase.path
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Hash a test suite
25
+ * @param {import('../../types').IManualTestSuite} testSuite
26
+ * @returns {string}
27
+ */
28
+ hashTestSuite(testSuite) {
29
+ return this.hash({
30
+ name: testSuite.name,
31
+ type: testSuite.type,
32
+ tags: testSuite.tags,
33
+ before_each: testSuite.before_each,
34
+ test_cases: testSuite.test_cases.map(testCase => testCase.hash)
35
+ });
36
+ }
37
+ }
38
+
39
+ module.exports = { BaseParser };
@@ -1,11 +1,13 @@
1
1
  const fs = require('fs');
2
+ const { BaseParser } = require('./base');
2
3
 
3
4
  /**
4
5
  * Simple and extendable Gherkin parser for Cucumber feature files
5
6
  * Parses .feature files and returns structured test suite objects
6
7
  */
7
- class GherkinParser {
8
+ class GherkinParser extends BaseParser {
8
9
  constructor() {
10
+ super();
9
11
  /** @type {string[]} Supported step keywords */
10
12
  this.stepKeywords = ['Given', 'When', 'Then', 'And', 'But'];
11
13
  }
@@ -31,12 +33,15 @@ class GherkinParser {
31
33
  * @returns {Object}
32
34
  */
33
35
  parseLines(lines) {
36
+ /**
37
+ * @type {import('../../types').IManualTestSuite}
38
+ */
34
39
  const testSuite = {
35
40
  name: '',
36
41
  type: 'feature',
37
42
  tags: [],
38
- beforeEach: [],
39
- cases: []
43
+ before_each: [],
44
+ test_cases: []
40
45
  };
41
46
 
42
47
  let currentFeature = null;
@@ -71,12 +76,12 @@ class GherkinParser {
71
76
  } else if (line.startsWith('Background:')) {
72
77
  // Parse Background
73
78
  currentBackground = this.parseBackground();
74
- testSuite.beforeEach.push(currentBackground);
79
+ testSuite.before_each.push(currentBackground);
75
80
  } else if (line.startsWith('Scenario:')) {
76
81
  // Parse Scenario
77
82
  currentScenario = this.parseScenario(line);
78
83
  currentScenario.tags = pendingScenarioTags.map(tag => tag.name);
79
- testSuite.cases.push(currentScenario);
84
+ testSuite.test_cases.push(currentScenario);
80
85
  pendingScenarioTags = [];
81
86
  currentBackground = null; // Reset Background context when Scenario starts
82
87
  } else if (this.isStep(line)) {
@@ -94,6 +99,11 @@ class GherkinParser {
94
99
  }
95
100
  }
96
101
 
102
+ for (const testCase of testSuite.test_cases) {
103
+ testCase.hash = this.hashTestCase(testCase);
104
+ }
105
+ testSuite.hash = this.hashTestSuite(testSuite);
106
+
97
107
  return testSuite;
98
108
  }
99
109
 
@@ -169,7 +179,7 @@ class GherkinParser {
169
179
  /**
170
180
  * Parse Scenario line
171
181
  * @param {string} line
172
- * @returns {Object}
182
+ * @returns {import('../../types').IManualTestCase}
173
183
  */
174
184
  parseScenario(line) {
175
185
  const name = line.replace('Scenario:', '').trim();
@@ -229,8 +239,8 @@ class GherkinParser {
229
239
  document.name &&
230
240
  document.type === 'feature' &&
231
241
  Array.isArray(document.tags) &&
232
- Array.isArray(document.beforeEach) &&
233
- Array.isArray(document.cases);
242
+ Array.isArray(document.before_each) &&
243
+ Array.isArray(document.test_cases);
234
244
  }
235
245
  }
236
246
 
@@ -56,6 +56,35 @@ class ManualSyncHelper {
56
56
  };
57
57
  }
58
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
+
59
88
  /**
60
89
  * Scan directory recursively and build folder structure
61
90
  * @param {string} directoryPath - Path to scan
@@ -72,20 +101,22 @@ class ManualSyncHelper {
72
101
  throw new Error(`Path is not a directory: ${directoryPath}`);
73
102
  }
74
103
 
75
- const structure = this.buildFolderStructure(absolutePath, directoryPath);
76
- return this.addFolderHash(structure);
104
+ return this.buildFolderStructure(absolutePath, directoryPath);
77
105
  }
78
106
 
79
107
  /**
80
108
  * Build folder structure recursively
81
109
  * @param {string} absolutePath - Absolute path for file operations
82
110
  * @param {string} relativePath - Relative path for output
83
- * @returns {Object} Folder structure
111
+ * @returns {import('../types').IManualTestFolder} Folder structure
84
112
  */
85
113
  buildFolderStructure(absolutePath, relativePath) {
86
114
  const folderName = path.basename(absolutePath);
87
115
  const items = fs.readdirSync(absolutePath);
88
116
 
117
+ /**
118
+ * @type {import('../types').IManualTestFolder}
119
+ */
89
120
  const structure = {
90
121
  name: folderName,
91
122
  path: relativePath,
@@ -106,12 +137,26 @@ class ManualSyncHelper {
106
137
  // Parse gherkin files
107
138
  try {
108
139
  const testSuite = this.parseGherkinFile(itemPath, itemRelativePath);
140
+ testSuite.hash = this.hashTestSuite(testSuite);
109
141
  structure.test_suites.push(testSuite);
110
142
  } catch (error) {
111
143
  console.warn(`Warning: Failed to parse ${itemPath}: ${error.message}`);
112
144
  }
113
145
  }
114
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
+ }
115
160
 
116
161
  return structure;
117
162
  }
@@ -129,7 +174,7 @@ class ManualSyncHelper {
129
174
  * Parse a gherkin file and format output
130
175
  * @param {string} filePath - Path to gherkin file
131
176
  * @param {string} relativePath - Relative path for output
132
- * @returns {Object} Formatted test suite
177
+ * @returns {import('../types').IManualTestSuite} Formatted test suite
133
178
  */
134
179
  parseGherkinFile(filePath, relativePath) {
135
180
  const parsed = this.parser.parse(filePath);
@@ -138,9 +183,9 @@ class ManualSyncHelper {
138
183
  name: parsed.name,
139
184
  type: parsed.type,
140
185
  tags: parsed.tags,
141
- beforeEach: parsed.beforeEach,
186
+ before_each: parsed.before_each,
142
187
  path: relativePath,
143
- test_cases: parsed.cases || [] // Map 'cases' to 'test_cases' for consistency
188
+ test_cases: parsed.test_cases || [] // Map 'cases' to 'test_cases' for consistency
144
189
  };
145
190
  }
146
191
  }
package/src/types.d.ts ADDED
@@ -0,0 +1,104 @@
1
+ export type IManualTestCase = {
2
+ name: string;
3
+ type: string;
4
+ tags: string[];
5
+ steps: string[];
6
+ path: string;
7
+ hash: string;
8
+ }
9
+
10
+ export type IManualTestSuite = {
11
+ name: string;
12
+ type: string;
13
+ tags: string[];
14
+ before_each: string[];
15
+ test_cases: IManualTestCase[];
16
+ hash: string;
17
+ }
18
+
19
+ export type IManualTestFolder = {
20
+ name: string;
21
+ path: string;
22
+ test_suites: IManualTestSuite[];
23
+ folders: IManualTestFolder[];
24
+ hash: string;
25
+ }
26
+
27
+ export type IManualSyncComparePayload = {
28
+ project_id: string;
29
+ folders: IManualSyncCompareFolder[];
30
+ }
31
+
32
+ export type IManualSyncCompareFolder = {
33
+ name: string;
34
+ hash: string;
35
+ test_suites: IManualSyncCompareTestSuite[];
36
+ folders: IManualSyncCompareFolder[];
37
+ }
38
+
39
+ export type IManualSyncCompareTestSuite = {
40
+ name: string;
41
+ hash: string;
42
+ }
43
+
44
+ export type IManualSyncCompareTestCase = {
45
+ name: string;
46
+ hash: string;
47
+ }
48
+
49
+ export type IManualSyncCompareResponse = {
50
+ folders: IManualSyncCompareResponseFolder[];
51
+ }
52
+
53
+ export type SyncOperationType = 'create' | 'update' | 'delete' | 'no_change';
54
+
55
+ export type IManualSyncCompareResponseFolder = {
56
+ type: SyncOperationType;
57
+ id?: string;
58
+ name: string;
59
+ hash: string;
60
+ test_suites: IManualSyncCompareResponseTestSuite[];
61
+ folders: IManualSyncCompareResponseFolder[];
62
+ }
63
+
64
+ export type IManualSyncCompareResponseTestSuite = {
65
+ type: SyncOperationType;
66
+ id?: string;
67
+ name: string;
68
+ hash: string;
69
+ }
70
+
71
+ export type IManualSyncFoldersPayload = {
72
+ project_id: string;
73
+ folders: IManualSyncFoldersFolder[];
74
+ }
75
+
76
+ export type IManualSyncFoldersFolder = {
77
+ type: SyncOperationType;
78
+ name: string;
79
+ path: string;
80
+ hash: string;
81
+ id?: string;
82
+ }
83
+
84
+ export type ISyncManualSuitesPayload = {
85
+ project_id: string;
86
+ suites: ISyncManualSuitesSuite[];
87
+ }
88
+
89
+ export type ISyncManualSuitesSuite = {
90
+ type: SyncOperationType;
91
+ name: string;
92
+ folder_id: string;
93
+ hash: string;
94
+ tags?: string[];
95
+ before_each?: string[];
96
+ test_cases?: ISyncManualSuitesTestCase[];
97
+ }
98
+
99
+ export type ISyncManualSuitesTestCase = {
100
+ name: string;
101
+ hash: string;
102
+ tags?: string[];
103
+ steps: string[];
104
+ }