testbeats 2.4.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ const { ManualTestScanner } = require('./scanner');
2
+ const { ManualTestComparator } = require('./comparator');
3
+ const { ManualTestSynchronizer } = require('./synchronizer');
4
+ const { ManualSyncOrchestrator } = require('./orchestrator');
5
+ const { ManualSyncServiceFactory } = require('./factory');
6
+ const { ProjectResolver } = require('./project-resolver');
7
+ const { ManualTestHasher } = require('./hasher');
8
+
9
+ module.exports = {
10
+ ManualTestScanner,
11
+ ManualTestComparator,
12
+ ManualTestSynchronizer,
13
+ ManualSyncOrchestrator,
14
+ ManualSyncServiceFactory,
15
+ ProjectResolver,
16
+ ManualTestHasher,
17
+ };
18
+
@@ -0,0 +1,124 @@
1
+ const logger = require('../utils/logger');
2
+
3
+ /**
4
+ * Orchestrates the manual sync pipeline
5
+ * Implements the Template Method pattern to coordinate the sync workflow
6
+ */
7
+ class ManualSyncOrchestrator {
8
+ /**
9
+ * @param {import('./scanner').ManualTestScanner} scanner
10
+ * @param {import('./comparator').ManualTestComparator} comparator
11
+ * @param {import('./synchronizer').ManualTestSynchronizer} synchronizer
12
+ * @param {import('./project-resolver').ProjectResolver} projectResolver
13
+ * @param {import('./hasher').ManualTestHasher} hasher
14
+ */
15
+ constructor(scanner, comparator, synchronizer, projectResolver, hasher) {
16
+ this.scanner = scanner;
17
+ this.comparator = comparator;
18
+ this.synchronizer = synchronizer;
19
+ this.projectResolver = projectResolver;
20
+ this.hasher = hasher;
21
+ }
22
+
23
+ /**
24
+ * Execute the complete manual sync pipeline
25
+ * @param {string} directoryPath - Path to scan for manual tests
26
+ * @param {string} projectName - Project name to sync to
27
+ * @returns {Promise<Object>} Sync result with statistics and errors
28
+ */
29
+ async execute(directoryPath, projectName) {
30
+ const result = {
31
+ success: false,
32
+ projectId: null,
33
+ statistics: {
34
+ totalTestCases: 0,
35
+ totalTestSuites: 0,
36
+ totalFolders: 0,
37
+ foldersProcessed: 0,
38
+ testSuitesProcessed: 0
39
+ },
40
+ errors: []
41
+ };
42
+
43
+ try {
44
+ logger.info(`📋 Resolving project: ${projectName}`);
45
+ result.projectId = await this.projectResolver.resolveProject(projectName);
46
+ logger.info(`✅ Project resolved: ${result.projectId}`);
47
+
48
+ logger.info(`📁 Scanning directory: ${directoryPath}`);
49
+ const scannedStructure = await this.scanner.scanDirectory(directoryPath);
50
+
51
+ logger.debug('🔐 Adding hashes to structure...');
52
+ this.hasher.hashStructure(scannedStructure);
53
+
54
+ const stats = this.#calculateStatistics(scannedStructure);
55
+ result.statistics.totalTestCases = stats.totalTestCases;
56
+ result.statistics.totalTestSuites = stats.totalTestSuites;
57
+ result.statistics.totalFolders = stats.totalFolders;
58
+
59
+ logger.info(`📊 Found ${stats.totalTestCases} test cases, ${stats.totalTestSuites} test suites, and ${stats.totalFolders} folders`);
60
+
61
+ logger.info('🔍 Comparing with server...');
62
+ const comparisonResult = await this.comparator.compare(scannedStructure, result.projectId);
63
+ logger.info('✅ Comparison completed');
64
+
65
+ logger.info('🔄 Synchronizing resources...');
66
+ const syncResult = await this.synchronizer.sync(scannedStructure, comparisonResult, result.projectId);
67
+
68
+ result.statistics.foldersProcessed = syncResult.foldersProcessed;
69
+ result.statistics.testSuitesProcessed = syncResult.testSuitesProcessed;
70
+ result.errors = syncResult.errors;
71
+ result.success = syncResult.success;
72
+
73
+ if (result.success) {
74
+ logger.info('✅ Synchronization completed successfully');
75
+ } else {
76
+ logger.warn(`⚠️ Synchronization completed with ${result.errors.length} error(s)`);
77
+ }
78
+
79
+ return result;
80
+ } catch (error) {
81
+ logger.error(`❌ Manual sync failed: ${error.message}`);
82
+ result.errors.push({
83
+ type: 'pipeline',
84
+ name: 'execution',
85
+ error: error.message
86
+ });
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Calculate statistics from scanned structure
93
+ * @param {import('../types').IManualTestFolder} structure
94
+ * @returns {{totalTestCases: number, totalTestSuites: number, totalFolders: number}}
95
+ */
96
+ #calculateStatistics(structure) {
97
+ const stats = {
98
+ totalTestCases: 0,
99
+ totalTestSuites: 0,
100
+ totalFolders: 0
101
+ };
102
+
103
+ const processFolder = (folder) => {
104
+ for (const suite of folder.test_suites) {
105
+ stats.totalTestCases += suite.test_cases.length;
106
+ stats.totalTestSuites++;
107
+ }
108
+
109
+ stats.totalFolders += folder.folders.length;
110
+ for (const subFolder of folder.folders) {
111
+ processFolder(subFolder);
112
+ }
113
+ };
114
+
115
+ for (const folder of structure.folders) {
116
+ processFolder(folder);
117
+ }
118
+
119
+ return stats;
120
+ }
121
+ }
122
+
123
+ module.exports = { ManualSyncOrchestrator };
124
+
@@ -1,38 +1,16 @@
1
- const hash = require('object-hash');
2
-
1
+ /**
2
+ * Base class for manual test parsers
3
+ * Provides common functionality for parsing test files
4
+ */
3
5
  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
6
  /**
24
- * Hash a test suite
25
- * @param {import('../../types').IManualTestSuite} testSuite
26
- * @returns {string}
7
+ * @param {import('fs')} fs
27
8
  */
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
- });
9
+ constructor(fs) {
10
+ /**
11
+ * @type {import('fs')}
12
+ */
13
+ this.fs = fs;
36
14
  }
37
15
  }
38
16
 
@@ -1,4 +1,3 @@
1
- const fs = require('fs');
2
1
  const { BaseParser } = require('./base');
3
2
 
4
3
  /**
@@ -6,8 +5,8 @@ const { BaseParser } = require('./base');
6
5
  * Parses .feature files and returns structured test suite objects
7
6
  */
8
7
  class GherkinParser extends BaseParser {
9
- constructor() {
10
- super();
8
+ constructor(fs) {
9
+ super(fs);
11
10
  /** @type {string[]} Supported step keywords */
12
11
  this.stepKeywords = ['Given', 'When', 'Then', 'And', 'But'];
13
12
  }
@@ -18,7 +17,7 @@ class GherkinParser extends BaseParser {
18
17
  */
19
18
  parse(file_path) {
20
19
  try {
21
- const content = fs.readFileSync(file_path, 'utf8');
20
+ const content = this.fs.readFileSync(file_path, 'utf8');
22
21
  const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0);
23
22
 
24
23
  return this.parseLines(lines);
@@ -54,25 +53,37 @@ class GherkinParser extends BaseParser {
54
53
  const line = lines[i];
55
54
 
56
55
  if (line.startsWith('@')) {
57
- // Handle tags
56
+ // Handle tags - accumulate from consecutive @ lines
58
57
  const tags = this.parseTags(line);
59
58
 
60
59
  // Look ahead to see if tags belong to Feature or Scenario
61
60
  if (i + 1 < lines.length && lines[i + 1].startsWith('Feature:')) {
62
- // Tags belong to Feature
63
- pendingFeatureTags = tags;
61
+ // Tags belong to Feature - accumulate instead of replace
62
+ pendingFeatureTags.push(...tags);
64
63
  } else if (i + 1 < lines.length && lines[i + 1].startsWith('Scenario:')) {
65
- // Tags belong to Scenario
66
- pendingScenarioTags = tags;
64
+ // Tags belong to Scenario - accumulate instead of replace
65
+ pendingScenarioTags.push(...tags);
66
+ } else if (i + 1 < lines.length && lines[i + 1].startsWith('@')) {
67
+ // Next line is also a tag line - determine which pending array to use
68
+ // Look further ahead to find Feature or Scenario
69
+ let j = i + 1;
70
+ while (j < lines.length && lines[j].startsWith('@')) {
71
+ j++;
72
+ }
73
+ if (j < lines.length && lines[j].startsWith('Feature:')) {
74
+ pendingFeatureTags.push(...tags);
75
+ } else if (j < lines.length && lines[j].startsWith('Scenario:')) {
76
+ pendingScenarioTags.push(...tags);
77
+ }
67
78
  }
68
79
  } else if (line.startsWith('Feature:')) {
69
80
  // Parse Feature
70
- const description = this.parseMultiLineDescription(lines, i + 1);
71
- currentFeature = this.parseFeature(line, description);
81
+ const descriptionResult = this.parseMultiLineDescription(lines, i + 1);
82
+ currentFeature = this.parseFeature(line, descriptionResult.description);
72
83
  testSuite.name = currentFeature.name;
73
84
  testSuite.tags = pendingFeatureTags.map(tag => tag.name);
74
85
  pendingFeatureTags = [];
75
- i += description.split('\n').length; // Skip all description lines
86
+ i += descriptionResult.linesConsumed; // Skip only actual description lines
76
87
  } else if (line.startsWith('Background:')) {
77
88
  // Parse Background
78
89
  currentBackground = this.parseBackground();
@@ -99,11 +110,6 @@ class GherkinParser extends BaseParser {
99
110
  }
100
111
  }
101
112
 
102
- for (const testCase of testSuite.test_cases) {
103
- testCase.hash = this.hashTestCase(testCase);
104
- }
105
- testSuite.hash = this.hashTestSuite(testSuite);
106
-
107
113
  return testSuite;
108
114
  }
109
115
 
@@ -121,10 +127,11 @@ class GherkinParser extends BaseParser {
121
127
  * Parse multi-line description starting from a given line index
122
128
  * @param {string[]} lines
123
129
  * @param {number} startIndex
124
- * @returns {string}
130
+ * @returns {{description: string, linesConsumed: number}}
125
131
  */
126
132
  parseMultiLineDescription(lines, startIndex) {
127
133
  const descriptionLines = [];
134
+ let linesConsumed = 0;
128
135
 
129
136
  for (let i = startIndex; i < lines.length; i++) {
130
137
  const line = lines[i];
@@ -137,13 +144,18 @@ class GherkinParser extends BaseParser {
137
144
  break;
138
145
  }
139
146
 
147
+ linesConsumed++;
148
+
140
149
  // Add non-empty lines to description
141
150
  if (line.trim().length > 0) {
142
151
  descriptionLines.push(line.trim());
143
152
  }
144
153
  }
145
154
 
146
- return descriptionLines.join('\n');
155
+ return {
156
+ description: descriptionLines.join('\n'),
157
+ linesConsumed: linesConsumed
158
+ };
147
159
  }
148
160
 
149
161
  /**
@@ -0,0 +1,33 @@
1
+ class ProjectResolver {
2
+ /**
3
+ * @param {import('../beats/beats.api').BeatsApi} beatsApi
4
+ */
5
+ constructor(beatsApi) {
6
+ this.beatsApi = beatsApi;
7
+ }
8
+
9
+ /**
10
+ * Resolve project name to project ID
11
+ * @param {string} projectName - Project name to resolve
12
+ * @returns {Promise<string>} Project ID
13
+ * @throws {Error} If project is not found
14
+ */
15
+ async resolveProject(projectName) {
16
+ const projectsResponse = await this.beatsApi.searchProjects(projectName);
17
+
18
+ if (!projectsResponse || !projectsResponse.values) {
19
+ throw new Error(`Project ${projectName} not found`);
20
+ }
21
+
22
+ const project = projectsResponse.values.find(p => p.name === projectName);
23
+
24
+ if (!project) {
25
+ throw new Error(`Project ${projectName} not found`);
26
+ }
27
+
28
+ return project.id;
29
+ }
30
+ }
31
+
32
+ module.exports = { ProjectResolver };
33
+
@@ -0,0 +1,127 @@
1
+ const path = require('path');
2
+ const logger = require('../utils/logger');
3
+
4
+ const DEFAULT_FOLDER_NAME = 'default';
5
+
6
+ /**
7
+ * Scans directories for manual test cases and builds folder structures
8
+ */
9
+ class ManualTestScanner {
10
+ constructor(fs, parser) {
11
+ /**
12
+ * @type {import('fs')}
13
+ */
14
+ this.fs = fs;
15
+ /**
16
+ * @type {import('./parsers/gherkin').GherkinParser}
17
+ */
18
+ this.parser = parser;
19
+ }
20
+
21
+ /**
22
+ * Scan directory recursively and build folder structure
23
+ * @param {string} directoryPath - Path to scan
24
+ * @returns {Promise<import('../types').IManualTestFolder>} Folder structure with test suites and hashes
25
+ * @throws {Error} If directory doesn't exist or is not a directory
26
+ */
27
+ async scanDirectory(directoryPath) {
28
+ const absolutePath = path.resolve(directoryPath);
29
+
30
+ if (!this.fs.existsSync(absolutePath)) {
31
+ throw new Error(`Directory ${directoryPath} does not exist`);
32
+ }
33
+
34
+ if (!this.fs.statSync(absolutePath).isDirectory()) {
35
+ throw new Error(`Path ${directoryPath} is not a directory`);
36
+ }
37
+
38
+ const structure = this.buildFolderStructure(absolutePath, directoryPath);
39
+ this.moveTestSuitesToDefaultFolder(structure);
40
+ return structure;
41
+ }
42
+
43
+ /**
44
+ * Build folder structure recursively
45
+ * @param {string} absolutePath - Absolute path for file operations
46
+ * @param {string} relativePath - Relative path for output
47
+ * @returns {import('../types').IManualTestFolder} Folder structure
48
+ */
49
+ buildFolderStructure(absolutePath, relativePath) {
50
+ const folderName = path.basename(absolutePath);
51
+ const items = this.fs.readdirSync(absolutePath);
52
+
53
+ /**
54
+ * @type {import('../types').IManualTestFolder}
55
+ */
56
+ const structure = {
57
+ name: folderName,
58
+ path: relativePath,
59
+ test_suites: [],
60
+ folders: []
61
+ };
62
+
63
+ for (const item of items) {
64
+ const itemPath = path.join(absolutePath, item);
65
+ const itemRelativePath = path.join(relativePath, item);
66
+ const stats = this.fs.statSync(itemPath);
67
+
68
+ if (stats.isDirectory()) {
69
+ const subFolder = this.buildFolderStructure(itemPath, itemRelativePath);
70
+ structure.folders.push(subFolder);
71
+ } else if (this.isGherkinFile(item)) {
72
+ try {
73
+ const testSuite = this.parseGherkinFile(itemPath, itemRelativePath);
74
+ structure.test_suites.push(testSuite);
75
+ } catch (error) {
76
+ logger.warn(`Warning: Failed to parse ${itemPath}: ${error.message}`);
77
+ }
78
+ }
79
+ }
80
+
81
+ return structure;
82
+ }
83
+
84
+ moveTestSuitesToDefaultFolder(structure) {
85
+ if (structure.test_suites.length > 0) {
86
+ const defaultFolder = {
87
+ name: DEFAULT_FOLDER_NAME,
88
+ path: DEFAULT_FOLDER_NAME,
89
+ test_suites: structure.test_suites,
90
+ folders: []
91
+ };
92
+ structure.folders.push(defaultFolder);
93
+ structure.test_suites = [];
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Check if file is a gherkin file
99
+ * @param {string} filename - Filename to check
100
+ * @returns {boolean} True if gherkin file
101
+ */
102
+ isGherkinFile(filename) {
103
+ return filename.endsWith('.feature');
104
+ }
105
+
106
+ /**
107
+ * Parse a gherkin file and format output
108
+ * @param {string} filePath - Path to gherkin file
109
+ * @param {string} relativePath - Relative path for output
110
+ * @returns {import('../types').IManualTestSuite} Formatted test suite
111
+ */
112
+ parseGherkinFile(filePath, relativePath) {
113
+ const parsed = this.parser.parse(filePath);
114
+
115
+ return {
116
+ name: parsed.name,
117
+ type: parsed.type,
118
+ tags: parsed.tags,
119
+ before_each: parsed.before_each,
120
+ path: relativePath,
121
+ test_cases: parsed.test_cases || []
122
+ };
123
+ }
124
+ }
125
+
126
+ module.exports = { ManualTestScanner };
127
+