testbeats 2.2.8 → 2.2.9

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.2.9",
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",
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,21 @@ 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
+ .example('manual sync')
72
+ .example('manual sync --path ./tests/features')
73
+ .action(async (opts) => {
74
+ try {
75
+ logger.setLevel(opts.logLevel);
76
+ const manual_sync_command = new ManualSyncCommand(opts);
77
+ await manual_sync_command.execute();
78
+ } catch (error) {
79
+ logger.error(`Manual sync failed: ${error.message}`);
80
+ process.exit(1);
81
+ }
82
+ });
83
+
66
84
  prog.parse(process.argv);
@@ -0,0 +1,79 @@
1
+ const path = require('path');
2
+ const { ManualSyncHelper } = require('../manual/sync.helper');
3
+ const logger = require('../utils/logger');
4
+
5
+ class ManualSyncCommand {
6
+ /**
7
+ * @param {Object} opts - Command options
8
+ */
9
+ constructor(opts) {
10
+ this.opts = opts;
11
+ this.syncHelper = new ManualSyncHelper();
12
+ }
13
+
14
+ /**
15
+ * Execute the manual sync command
16
+ */
17
+ async execute() {
18
+ try {
19
+ logger.info('šŸ”„ Starting manual test case sync...');
20
+
21
+ const targetPath = this.opts.path || '.';
22
+ logger.info(`šŸ“ Scanning directory: ${targetPath}`);
23
+
24
+ const result = await this.syncHelper.scanDirectory(targetPath);
25
+
26
+ // Format output as specified in requirements
27
+ const output = {
28
+ folders: [result]
29
+ };
30
+
31
+ // Display results
32
+ this.displayResults(output);
33
+
34
+ logger.info('āœ… Manual sync completed successfully!');
35
+
36
+ return output;
37
+
38
+ } catch (error) {
39
+ logger.error(`āŒ Manual sync failed: ${error.message}`);
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Display sync results in a readable format
46
+ * @param {Object} output - Sync results
47
+ */
48
+ displayResults(output) {
49
+ logger.info('\nšŸ“Š Sync Results:');
50
+ this.displayFolderRecursive(output.folders[0], 0);
51
+ }
52
+
53
+ /**
54
+ * Recursively display folder structure
55
+ * @param {Object} folder - Folder object
56
+ * @param {number} depth - Current depth for indentation
57
+ */
58
+ displayFolderRecursive(folder, depth) {
59
+ const indent = ' '.repeat(depth);
60
+
61
+ logger.info(`${indent}šŸ“ ${folder.name} (${folder.path})`);
62
+
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
+ });
68
+ }
69
+
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
+ });
75
+ }
76
+ }
77
+ }
78
+
79
+ module.exports = { ManualSyncCommand };
@@ -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,237 @@
1
+ const fs = require('fs');
2
+
3
+ /**
4
+ * Simple and extendable Gherkin parser for Cucumber feature files
5
+ * Parses .feature files and returns structured test suite objects
6
+ */
7
+ class GherkinParser {
8
+ constructor() {
9
+ /** @type {string[]} Supported step keywords */
10
+ this.stepKeywords = ['Given', 'When', 'Then', 'And', 'But'];
11
+ }
12
+
13
+ /**
14
+ * @param {string} file_path
15
+ * @returns {Object} Parsed test suite structure
16
+ */
17
+ parse(file_path) {
18
+ try {
19
+ const content = fs.readFileSync(file_path, 'utf8');
20
+ const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0);
21
+
22
+ return this.parseLines(lines);
23
+ } catch (error) {
24
+ throw new Error(`Failed to parse Gherkin file: ${error.message}`);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Parse lines and build the test suite structure
30
+ * @param {string[]} lines
31
+ * @returns {Object}
32
+ */
33
+ parseLines(lines) {
34
+ const testSuite = {
35
+ name: '',
36
+ type: 'feature',
37
+ tags: [],
38
+ beforeEach: [],
39
+ cases: []
40
+ };
41
+
42
+ let currentFeature = null;
43
+ let currentBackground = null;
44
+ let currentScenario = null;
45
+ let pendingFeatureTags = [];
46
+ let pendingScenarioTags = [];
47
+
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const line = lines[i];
50
+
51
+ if (line.startsWith('@')) {
52
+ // Handle tags
53
+ const tags = this.parseTags(line);
54
+
55
+ // Look ahead to see if tags belong to Feature or Scenario
56
+ if (i + 1 < lines.length && lines[i + 1].startsWith('Feature:')) {
57
+ // Tags belong to Feature
58
+ pendingFeatureTags = tags;
59
+ } else if (i + 1 < lines.length && lines[i + 1].startsWith('Scenario:')) {
60
+ // Tags belong to Scenario
61
+ pendingScenarioTags = tags;
62
+ }
63
+ } else if (line.startsWith('Feature:')) {
64
+ // Parse Feature
65
+ const description = this.parseMultiLineDescription(lines, i + 1);
66
+ currentFeature = this.parseFeature(line, description);
67
+ testSuite.name = currentFeature.name;
68
+ testSuite.tags = pendingFeatureTags.map(tag => tag.name);
69
+ pendingFeatureTags = [];
70
+ i += description.split('\n').length; // Skip all description lines
71
+ } else if (line.startsWith('Background:')) {
72
+ // Parse Background
73
+ currentBackground = this.parseBackground();
74
+ testSuite.beforeEach.push(currentBackground);
75
+ } else if (line.startsWith('Scenario:')) {
76
+ // Parse Scenario
77
+ currentScenario = this.parseScenario(line);
78
+ currentScenario.tags = pendingScenarioTags.map(tag => tag.name);
79
+ testSuite.cases.push(currentScenario);
80
+ pendingScenarioTags = [];
81
+ currentBackground = null; // Reset Background context when Scenario starts
82
+ } else if (this.isStep(line)) {
83
+ // Parse Step
84
+ const step = this.parseStep(line);
85
+
86
+ // Determine where to add the step based on current context
87
+ if (currentBackground && currentBackground.steps) {
88
+ // Add step to Background
89
+ currentBackground.steps.push(step);
90
+ } else if (currentScenario && currentScenario.steps) {
91
+ // Add step to Scenario
92
+ currentScenario.steps.push(step);
93
+ }
94
+ }
95
+ }
96
+
97
+ return testSuite;
98
+ }
99
+
100
+ /**
101
+ * Parse tags from a line
102
+ * @param {string} line
103
+ * @returns {Array}
104
+ */
105
+ parseTags(line) {
106
+ const tagMatches = line.match(/@\w+/g);
107
+ return tagMatches ? tagMatches.map(tag => ({ name: tag })) : [];
108
+ }
109
+
110
+ /**
111
+ * Parse multi-line description starting from a given line index
112
+ * @param {string[]} lines
113
+ * @param {number} startIndex
114
+ * @returns {string}
115
+ */
116
+ parseMultiLineDescription(lines, startIndex) {
117
+ const descriptionLines = [];
118
+
119
+ for (let i = startIndex; i < lines.length; i++) {
120
+ const line = lines[i];
121
+
122
+ // Stop if we hit a keyword or step
123
+ if (line.startsWith('Background:') ||
124
+ line.startsWith('Scenario:') ||
125
+ line.startsWith('@') ||
126
+ this.isStep(line)) {
127
+ break;
128
+ }
129
+
130
+ // Add non-empty lines to description
131
+ if (line.trim().length > 0) {
132
+ descriptionLines.push(line.trim());
133
+ }
134
+ }
135
+
136
+ return descriptionLines.join('\n');
137
+ }
138
+
139
+ /**
140
+ * Parse Feature line
141
+ * @param {string} line
142
+ * @param {string} description
143
+ * @returns {Object}
144
+ */
145
+ parseFeature(line, description) {
146
+ const name = line.replace('Feature:', '').trim();
147
+ return {
148
+ type: 'Feature',
149
+ tags: [],
150
+ keyword: 'Feature',
151
+ name: name,
152
+ description: description.trim(),
153
+ children: []
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Parse Background line
159
+ * @returns {Object}
160
+ */
161
+ parseBackground() {
162
+ return {
163
+ name: '',
164
+ type: 'background',
165
+ steps: []
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Parse Scenario line
171
+ * @param {string} line
172
+ * @returns {Object}
173
+ */
174
+ parseScenario(line) {
175
+ const name = line.replace('Scenario:', '').trim();
176
+ return {
177
+ name: name,
178
+ type: 'scenario',
179
+ tags: [],
180
+ steps: []
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Check if a line is a step
186
+ * @param {string} line
187
+ * @returns {boolean}
188
+ */
189
+ isStep(line) {
190
+ return this.stepKeywords.some(keyword =>
191
+ line.startsWith(keyword + ' ') ||
192
+ line.startsWith('And ') ||
193
+ line.startsWith('But ')
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Parse a step line
199
+ * @param {string} line
200
+ * @returns {Object}
201
+ */
202
+ parseStep(line) {
203
+ return {
204
+ name: line.trim()
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Parse from string content instead of file
210
+ * @param {string} content
211
+ * @returns {Object} Parsed test suite structure
212
+ */
213
+ parseString(content) {
214
+ try {
215
+ const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0);
216
+ return this.parseLines(lines);
217
+ } catch (error) {
218
+ throw new Error(`Failed to parse Gherkin content: ${error.message}`);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Validate if the parsed document has required structure
224
+ * @param {Object} document
225
+ * @returns {boolean}
226
+ */
227
+ validate(document) {
228
+ return document &&
229
+ document.name &&
230
+ document.type === 'feature' &&
231
+ Array.isArray(document.tags) &&
232
+ Array.isArray(document.beforeEach) &&
233
+ Array.isArray(document.cases);
234
+ }
235
+ }
236
+
237
+ module.exports = { GherkinParser };
@@ -0,0 +1,148 @@
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
+ * Scan directory recursively and build folder structure
61
+ * @param {string} directoryPath - Path to scan
62
+ * @returns {Object} Folder structure with test suites and hashes
63
+ */
64
+ async scanDirectory(directoryPath) {
65
+ const absolutePath = path.resolve(directoryPath);
66
+
67
+ if (!fs.existsSync(absolutePath)) {
68
+ throw new Error(`Directory does not exist: ${directoryPath}`);
69
+ }
70
+
71
+ if (!fs.statSync(absolutePath).isDirectory()) {
72
+ throw new Error(`Path is not a directory: ${directoryPath}`);
73
+ }
74
+
75
+ const structure = this.buildFolderStructure(absolutePath, directoryPath);
76
+ return this.addFolderHash(structure);
77
+ }
78
+
79
+ /**
80
+ * Build folder structure recursively
81
+ * @param {string} absolutePath - Absolute path for file operations
82
+ * @param {string} relativePath - Relative path for output
83
+ * @returns {Object} Folder structure
84
+ */
85
+ buildFolderStructure(absolutePath, relativePath) {
86
+ const folderName = path.basename(absolutePath);
87
+ const items = fs.readdirSync(absolutePath);
88
+
89
+ const structure = {
90
+ name: folderName,
91
+ path: relativePath,
92
+ test_suites: [],
93
+ folders: []
94
+ };
95
+
96
+ for (const item of items) {
97
+ const itemPath = path.join(absolutePath, item);
98
+ const itemRelativePath = path.join(relativePath, item);
99
+ const stats = fs.statSync(itemPath);
100
+
101
+ if (stats.isDirectory()) {
102
+ // Recursively process subdirectories
103
+ const subFolder = this.buildFolderStructure(itemPath, itemRelativePath);
104
+ structure.folders.push(subFolder);
105
+ } else if (this.isGherkinFile(item)) {
106
+ // Parse gherkin files
107
+ try {
108
+ const testSuite = this.parseGherkinFile(itemPath, itemRelativePath);
109
+ structure.test_suites.push(testSuite);
110
+ } catch (error) {
111
+ console.warn(`Warning: Failed to parse ${itemPath}: ${error.message}`);
112
+ }
113
+ }
114
+ }
115
+
116
+ return structure;
117
+ }
118
+
119
+ /**
120
+ * Check if file is a gherkin file
121
+ * @param {string} filename - Filename to check
122
+ * @returns {boolean} True if gherkin file
123
+ */
124
+ isGherkinFile(filename) {
125
+ return filename.endsWith('.feature');
126
+ }
127
+
128
+ /**
129
+ * Parse a gherkin file and format output
130
+ * @param {string} filePath - Path to gherkin file
131
+ * @param {string} relativePath - Relative path for output
132
+ * @returns {Object} Formatted test suite
133
+ */
134
+ parseGherkinFile(filePath, relativePath) {
135
+ const parsed = this.parser.parse(filePath);
136
+
137
+ return {
138
+ name: parsed.name,
139
+ type: parsed.type,
140
+ tags: parsed.tags,
141
+ beforeEach: parsed.beforeEach,
142
+ path: relativePath,
143
+ test_cases: parsed.cases || [] // Map 'cases' to 'test_cases' for consistency
144
+ };
145
+ }
146
+ }
147
+
148
+ 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: