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 +3 -2
- package/src/beats/beats.api.js +43 -0
- package/src/cli.js +21 -0
- package/src/commands/base.command.js +47 -0
- package/src/commands/manual-sync.command.js +311 -0
- package/src/commands/publish.command.js +6 -29
- package/src/helpers/constants.js +1 -0
- package/src/index.d.ts +7 -2
- package/src/manual/parsers/base.js +39 -0
- package/src/manual/parsers/gherkin.js +247 -0
- package/src/manual/sync.helper.js +193 -0
- package/src/targets/github-output.target.js +64 -0
- package/src/targets/index.js +3 -0
- package/src/types.d.ts +104 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testbeats",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
60
|
+
"test-results-parser": "0.2.9"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"c8": "^10.1.2",
|
package/src/beats/beats.api.js
CHANGED
|
@@ -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
|
-
|
|
19
|
+
super(opts);
|
|
23
20
|
this.errors = [];
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
async publish() {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
this
|
|
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');
|
package/src/helpers/constants.js
CHANGED
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
|
+
};
|
package/src/targets/index.js
CHANGED
|
@@ -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
|
+
}
|