testbeats 2.2.8 → 2.3.1

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.8",
3
+ "version": "2.3.1",
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",
@@ -50,13 +50,14 @@
50
50
  "dotenv": "^16.4.5",
51
51
  "form-data-lite": "^1.0.3",
52
52
  "influxdb-lite": "^1.0.0",
53
+ "object-hash": "^3.0.0",
53
54
  "performance-results-parser": "latest",
54
55
  "phin-retry": "^1.0.3",
55
56
  "pretty-ms": "^7.0.1",
56
57
  "prompts": "^2.4.2",
57
58
  "rosters": "0.0.1",
58
59
  "sade": "^1.8.1",
59
- "test-results-parser": "0.2.8"
60
+ "test-results-parser": "0.2.9"
60
61
  },
61
62
  "devDependencies": {
62
63
  "c8": "^10.1.2",
@@ -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
@@ -6,6 +6,7 @@ const sade = require('sade');
6
6
  const prog = sade('testbeats');
7
7
  const { PublishCommand } = require('./commands/publish.command');
8
8
  const { GenerateConfigCommand } = require('./commands/generate-config.command');
9
+ const { ManualSyncCommand } = require('./commands/manual-sync.command');
9
10
  const logger = require('./utils/logger');
10
11
  const pkg = require('../package.json');
11
12
 
@@ -63,4 +64,24 @@ prog.command('init')
63
64
  }
64
65
  });
65
66
 
67
+ // Command to sync manual test cases from gherkin files
68
+ prog.command('manual sync')
69
+ .describe('Sync manual test cases from gherkin files in a directory')
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')
74
+ .example('manual sync')
75
+ .example('manual sync --path ./tests/features')
76
+ .action(async (opts) => {
77
+ try {
78
+ logger.setLevel(opts.logLevel);
79
+ const manual_sync_command = new ManualSyncCommand(opts);
80
+ await manual_sync_command.execute();
81
+ } catch (error) {
82
+ logger.error(`Manual sync failed: ${error.message}`);
83
+ process.exit(1);
84
+ }
85
+ });
86
+
66
87
  prog.parse(process.argv);
@@ -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 };
@@ -0,0 +1,311 @@
1
+ const { ManualSyncHelper } = require('../manual/sync.helper');
2
+ const logger = require('../utils/logger');
3
+ const { BeatsApi } = require('../beats/beats.api');
4
+ const { BaseCommand } = require('./base.command');
5
+
6
+ class ManualSyncCommand extends BaseCommand {
7
+ /**
8
+ * @param {Object} opts - Command options
9
+ */
10
+ constructor(opts) {
11
+ super(opts);
12
+ this.syncHelper = new ManualSyncHelper();
13
+ }
14
+
15
+ /**
16
+ * Execute the manual sync command
17
+ */
18
+ async execute() {
19
+ try {
20
+ this.printBanner();
21
+ this.validateEnvDetails();
22
+ this.buildConfig();
23
+
24
+ logger.info('🔄 Starting manual test case sync...');
25
+
26
+ const targetPath = this.opts.path || '.';
27
+ logger.info(`📁 Scanning directory: ${targetPath}`);
28
+
29
+ const result = await this.syncHelper.scanDirectory(targetPath);
30
+
31
+ const counts = this.getCounts(result);
32
+ logger.info(`📊 Found ${counts.totalTestCases} test cases, ${counts.totalTestSuites} test suites, and ${counts.totalFolders} folders`);
33
+
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);
54
+
55
+ } catch (error) {
56
+ logger.error(`❌ Manual sync failed: ${error.message}`);
57
+ throw error;
58
+ }
59
+ }
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
+
199
+ /**
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
203
+ */
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);
273
+ }
274
+ }
275
+ }
276
+
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
+ }
306
+ }
307
+ }
308
+
309
+ }
310
+
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');
@@ -15,6 +15,7 @@ const TARGET = Object.freeze({
15
15
  TEAMS: 'teams',
16
16
  CHAT: 'chat',
17
17
  GITHUB: 'github',
18
+ GITHUB_OUTPUT: 'github-output',
18
19
  CUSTOM: 'custom',
19
20
  DELAY: 'delay',
20
21
  INFLUX: 'influx',
package/src/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export interface ITarget {
10
10
  name: TargetName;
11
11
  enable?: string | boolean;
12
12
  condition?: Condition;
13
- inputs?: SlackInputs | TeamsInputs | ChatInputs | IGitHubInputs | ICustomTargetInputs | InfluxDBTargetInputs;
13
+ inputs?: SlackInputs | TeamsInputs | ChatInputs | IGitHubInputs | IGitHubOutputInputs | ICustomTargetInputs | InfluxDBTargetInputs;
14
14
  extensions?: IExtension[];
15
15
  }
16
16
 
@@ -25,7 +25,7 @@ export interface IExtension {
25
25
 
26
26
  export type ExtensionName = 'report-portal-analysis' | 'hyperlinks' | 'mentions' | 'report-portal-history' | 'quick-chart-test-summary' | 'metadata' | 'ci-info' | 'custom' | 'ai-failure-summary';
27
27
  export type Hook = 'start' | 'end' | 'after-summary';
28
- export type TargetName = 'slack' | 'teams' | 'chat' | 'github' | 'custom' | 'delay';
28
+ export type TargetName = 'slack' | 'teams' | 'chat' | 'github' | 'github-output' | 'custom' | 'delay';
29
29
  export type PublishReportType = 'test-summary' | 'test-summary-slim' | 'failure-details';
30
30
 
31
31
  export interface ConditionFunctionContext {
@@ -251,6 +251,11 @@ export interface IGitHubInputs extends TargetInputs {
251
251
  pull_number?: string;
252
252
  }
253
253
 
254
+ export interface IGitHubOutputInputs {
255
+ output_file?: string;
256
+ key?: string;
257
+ }
258
+
254
259
  export interface InfluxDBTargetInputs {
255
260
  url: string;
256
261
  version?: string;
@@ -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 };
@@ -0,0 +1,247 @@
1
+ const fs = require('fs');
2
+ const { BaseParser } = require('./base');
3
+
4
+ /**
5
+ * Simple and extendable Gherkin parser for Cucumber feature files
6
+ * Parses .feature files and returns structured test suite objects
7
+ */
8
+ class GherkinParser extends BaseParser {
9
+ constructor() {
10
+ super();
11
+ /** @type {string[]} Supported step keywords */
12
+ this.stepKeywords = ['Given', 'When', 'Then', 'And', 'But'];
13
+ }
14
+
15
+ /**
16
+ * @param {string} file_path
17
+ * @returns {Object} Parsed test suite structure
18
+ */
19
+ parse(file_path) {
20
+ try {
21
+ const content = fs.readFileSync(file_path, 'utf8');
22
+ const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0);
23
+
24
+ return this.parseLines(lines);
25
+ } catch (error) {
26
+ throw new Error(`Failed to parse Gherkin file: ${error.message}`);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Parse lines and build the test suite structure
32
+ * @param {string[]} lines
33
+ * @returns {Object}
34
+ */
35
+ parseLines(lines) {
36
+ /**
37
+ * @type {import('../../types').IManualTestSuite}
38
+ */
39
+ const testSuite = {
40
+ name: '',
41
+ type: 'feature',
42
+ tags: [],
43
+ before_each: [],
44
+ test_cases: []
45
+ };
46
+
47
+ let currentFeature = null;
48
+ let currentBackground = null;
49
+ let currentScenario = null;
50
+ let pendingFeatureTags = [];
51
+ let pendingScenarioTags = [];
52
+
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const line = lines[i];
55
+
56
+ if (line.startsWith('@')) {
57
+ // Handle tags
58
+ const tags = this.parseTags(line);
59
+
60
+ // Look ahead to see if tags belong to Feature or Scenario
61
+ if (i + 1 < lines.length && lines[i + 1].startsWith('Feature:')) {
62
+ // Tags belong to Feature
63
+ pendingFeatureTags = tags;
64
+ } else if (i + 1 < lines.length && lines[i + 1].startsWith('Scenario:')) {
65
+ // Tags belong to Scenario
66
+ pendingScenarioTags = tags;
67
+ }
68
+ } else if (line.startsWith('Feature:')) {
69
+ // Parse Feature
70
+ const description = this.parseMultiLineDescription(lines, i + 1);
71
+ currentFeature = this.parseFeature(line, description);
72
+ testSuite.name = currentFeature.name;
73
+ testSuite.tags = pendingFeatureTags.map(tag => tag.name);
74
+ pendingFeatureTags = [];
75
+ i += description.split('\n').length; // Skip all description lines
76
+ } else if (line.startsWith('Background:')) {
77
+ // Parse Background
78
+ currentBackground = this.parseBackground();
79
+ testSuite.before_each.push(currentBackground);
80
+ } else if (line.startsWith('Scenario:')) {
81
+ // Parse Scenario
82
+ currentScenario = this.parseScenario(line);
83
+ currentScenario.tags = pendingScenarioTags.map(tag => tag.name);
84
+ testSuite.test_cases.push(currentScenario);
85
+ pendingScenarioTags = [];
86
+ currentBackground = null; // Reset Background context when Scenario starts
87
+ } else if (this.isStep(line)) {
88
+ // Parse Step
89
+ const step = this.parseStep(line);
90
+
91
+ // Determine where to add the step based on current context
92
+ if (currentBackground && currentBackground.steps) {
93
+ // Add step to Background
94
+ currentBackground.steps.push(step);
95
+ } else if (currentScenario && currentScenario.steps) {
96
+ // Add step to Scenario
97
+ currentScenario.steps.push(step);
98
+ }
99
+ }
100
+ }
101
+
102
+ for (const testCase of testSuite.test_cases) {
103
+ testCase.hash = this.hashTestCase(testCase);
104
+ }
105
+ testSuite.hash = this.hashTestSuite(testSuite);
106
+
107
+ return testSuite;
108
+ }
109
+
110
+ /**
111
+ * Parse tags from a line
112
+ * @param {string} line
113
+ * @returns {Array}
114
+ */
115
+ parseTags(line) {
116
+ const tagMatches = line.match(/@\w+/g);
117
+ return tagMatches ? tagMatches.map(tag => ({ name: tag })) : [];
118
+ }
119
+
120
+ /**
121
+ * Parse multi-line description starting from a given line index
122
+ * @param {string[]} lines
123
+ * @param {number} startIndex
124
+ * @returns {string}
125
+ */
126
+ parseMultiLineDescription(lines, startIndex) {
127
+ const descriptionLines = [];
128
+
129
+ for (let i = startIndex; i < lines.length; i++) {
130
+ const line = lines[i];
131
+
132
+ // Stop if we hit a keyword or step
133
+ if (line.startsWith('Background:') ||
134
+ line.startsWith('Scenario:') ||
135
+ line.startsWith('@') ||
136
+ this.isStep(line)) {
137
+ break;
138
+ }
139
+
140
+ // Add non-empty lines to description
141
+ if (line.trim().length > 0) {
142
+ descriptionLines.push(line.trim());
143
+ }
144
+ }
145
+
146
+ return descriptionLines.join('\n');
147
+ }
148
+
149
+ /**
150
+ * Parse Feature line
151
+ * @param {string} line
152
+ * @param {string} description
153
+ * @returns {Object}
154
+ */
155
+ parseFeature(line, description) {
156
+ const name = line.replace('Feature:', '').trim();
157
+ return {
158
+ type: 'Feature',
159
+ tags: [],
160
+ keyword: 'Feature',
161
+ name: name,
162
+ description: description.trim(),
163
+ children: []
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Parse Background line
169
+ * @returns {Object}
170
+ */
171
+ parseBackground() {
172
+ return {
173
+ name: '',
174
+ type: 'background',
175
+ steps: []
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Parse Scenario line
181
+ * @param {string} line
182
+ * @returns {import('../../types').IManualTestCase}
183
+ */
184
+ parseScenario(line) {
185
+ const name = line.replace('Scenario:', '').trim();
186
+ return {
187
+ name: name,
188
+ type: 'scenario',
189
+ tags: [],
190
+ steps: []
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Check if a line is a step
196
+ * @param {string} line
197
+ * @returns {boolean}
198
+ */
199
+ isStep(line) {
200
+ return this.stepKeywords.some(keyword =>
201
+ line.startsWith(keyword + ' ') ||
202
+ line.startsWith('And ') ||
203
+ line.startsWith('But ')
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Parse a step line
209
+ * @param {string} line
210
+ * @returns {Object}
211
+ */
212
+ parseStep(line) {
213
+ return {
214
+ name: line.trim()
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Parse from string content instead of file
220
+ * @param {string} content
221
+ * @returns {Object} Parsed test suite structure
222
+ */
223
+ parseString(content) {
224
+ try {
225
+ const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0);
226
+ return this.parseLines(lines);
227
+ } catch (error) {
228
+ throw new Error(`Failed to parse Gherkin content: ${error.message}`);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Validate if the parsed document has required structure
234
+ * @param {Object} document
235
+ * @returns {boolean}
236
+ */
237
+ validate(document) {
238
+ return document &&
239
+ document.name &&
240
+ document.type === 'feature' &&
241
+ Array.isArray(document.tags) &&
242
+ Array.isArray(document.before_each) &&
243
+ Array.isArray(document.test_cases);
244
+ }
245
+ }
246
+
247
+ module.exports = { GherkinParser };
@@ -0,0 +1,193 @@
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 };
@@ -0,0 +1,64 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const logger = require('../utils/logger');
4
+ const { BaseTarget } = require('./base.target');
5
+ const context = require('../utils/context.utils');
6
+
7
+ const DEFAULT_INPUTS = {
8
+ output_file: process.env.GITHUB_OUTPUT, // Path to output file, defaults to GITHUB_OUTPUT env var
9
+ key: 'testbeats' // Key name for the output
10
+ };
11
+
12
+ const default_options = {
13
+ condition: 'passOrFail'
14
+ };
15
+
16
+ class GitHubOutputTarget extends BaseTarget {
17
+ constructor({ target }) {
18
+ super({ target });
19
+ }
20
+
21
+ async run({ result, target }) {
22
+ this.result = result;
23
+ this.setTargetInputs(target);
24
+
25
+ logger.info(`🔔 Writing results to GitHub Actions outputs...`);
26
+ return await this.writeToGitHubOutput({ target, result });
27
+ }
28
+
29
+ setTargetInputs(target) {
30
+ target.inputs = Object.assign({}, DEFAULT_INPUTS, target.inputs);
31
+ }
32
+
33
+ async writeToGitHubOutput({ target, result }) {
34
+ const outputFile = target.inputs.output_file || process.env.GITHUB_OUTPUT;
35
+
36
+ if (!outputFile) {
37
+ throw new Error('GitHub output file path is required. Set GITHUB_OUTPUT environment variable or provide output_file in target inputs.');
38
+ }
39
+
40
+ // Ensure the directory exists
41
+ const outputDir = path.dirname(outputFile);
42
+ if (!fs.existsSync(outputDir)) {
43
+ fs.mkdirSync(outputDir, { recursive: true });
44
+ }
45
+
46
+ const lines = []
47
+ lines.push(`${target.inputs.key}_results=${JSON.stringify(result)}`)
48
+ lines.push(`${target.inputs.key}_stores=${JSON.stringify(context.stores)}`)
49
+ const outputContent = lines.join('\n');
50
+
51
+ fs.appendFileSync(outputFile, outputContent);
52
+
53
+ logger.info(`✅ Successfully wrote results to ${outputFile}`);
54
+ return { success: true, key: target.inputs.key };
55
+ }
56
+
57
+ async handleErrors({ target, errors }) {
58
+ logger.error('GitHub Output target errors:', errors);
59
+ }
60
+ }
61
+
62
+ module.exports = {
63
+ GitHubOutputTarget
64
+ };
@@ -2,6 +2,7 @@ const teams = require('./teams');
2
2
  const slack = require('./slack');
3
3
  const chat = require('./chat');
4
4
  const { GitHubTarget} = require('./github.target');
5
+ const { GitHubOutputTarget } = require('./github-output.target');
5
6
  const { CustomTarget } = require('./custom.target');
6
7
  const { DelayTarget } = require('./delay.target');
7
8
  const { HttpTarget } = require('./http.target');
@@ -19,6 +20,8 @@ function getTargetRunner(target) {
19
20
  return chat;
20
21
  case TARGET.GITHUB:
21
22
  return new GitHubTarget({ target });
23
+ case TARGET.GITHUB_OUTPUT:
24
+ return new GitHubOutputTarget({ target });
22
25
  case TARGET.CUSTOM:
23
26
  return new CustomTarget({ target });
24
27
  case TARGET.DELAY:
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
+ }