diff --git a/.gitignore b/.gitignore index ac5efc0..e1deca8 100644 --- a/.gitignore +++ b/.gitignore @@ -115,4 +115,5 @@ results # ignore test/override config files override-config*.json -.testbeats \ No newline at end of file +.testbeats +.testbeats.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7a61937..483f837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "performance-results-parser": "latest", "phin-retry": "^1.0.3", "pretty-ms": "^7.0.1", + "prompts": "^2.4.2", "rosters": "0.0.1", "sade": "^1.8.1", "test-results-parser": "0.2.5" @@ -1426,6 +1427,14 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "engines": { + "node": ">=6" + } + }, "node_modules/klona": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", @@ -2084,6 +2093,18 @@ "node": ">=0.4.0" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/pump": { "version": "3.0.0", "dev": true, @@ -2370,6 +2391,11 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, "node_modules/slash": { "version": "3.0.0", "dev": true, @@ -3700,6 +3726,11 @@ "universalify": "^2.0.0" } }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" + }, "klona": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", @@ -4139,6 +4170,15 @@ "version": "2.0.3", "dev": true }, + "prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, "pump": { "version": "3.0.0", "dev": true, @@ -4301,6 +4341,11 @@ "simple-concat": "^1.0.0" } }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, "slash": { "version": "3.0.0", "dev": true diff --git a/package.json b/package.json index e67e758..c5e282e 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "performance-results-parser": "latest", "phin-retry": "^1.0.3", "pretty-ms": "^7.0.1", + "prompts": "^2.4.2", "rosters": "0.0.1", "sade": "^1.8.1", "test-results-parser": "0.2.5" diff --git a/src/cli.js b/src/cli.js index e43c37b..e4e19f2 100755 --- a/src/cli.js +++ b/src/cli.js @@ -5,13 +5,18 @@ const sade = require('sade'); const prog = sade('testbeats'); const { PublishCommand } = require('./commands/publish.command'); +const { GenerateConfigCommand } = require('./commands/generate-config.command'); const logger = require('./utils/logger'); const pkg = require('../package.json'); prog .version(pkg.version) - .option('-c, --config', 'path to config file') .option('-l, --logLevel', 'Log Level', "INFO") + + +// Command to publish test results +prog.command('publish') + .option('-c, --config', 'path to config file') .option('--api-key', 'api key') .option('--project', 'project name') .option('--run', 'run name') @@ -27,9 +32,7 @@ prog .option('--xunit', 'xunit xml path') .option('--mstest', 'mstest xml path') .option('-ci-info', 'ci info extension') - .option('-chart-test-summary', 'chart test summary extension'); - -prog.command('publish') + .option('-chart-test-summary', 'chart test summary extension') .action(async (opts) => { try { logger.setLevel(opts.logLevel); @@ -41,4 +44,22 @@ prog.command('publish') } }); +// Command to initialize and generate TestBeats Configuration file +prog.command('init') + .describe('Generate a TestBeats configuration file') + .example('init') + .action(async (opts) => { + try { + const generate_command = new GenerateConfigCommand(opts); + await generate_command.execute(); + } catch (error) { + if (error.name === 'ExitPromptError') { + logger.info('😿 Configuration generation was canceled by the user.'); + } else { + throw new Error(`❌ Error in generating configuration file: ${error.message}`) + } + process.exit(1); + } + }); + prog.parse(process.argv); diff --git a/src/commands/generate-config.command.js b/src/commands/generate-config.command.js new file mode 100644 index 0000000..62314bf --- /dev/null +++ b/src/commands/generate-config.command.js @@ -0,0 +1,446 @@ +const prompts = require('prompts'); +const fs = require('fs/promises'); +const logger = require('../utils/logger'); +const pkg = require('../../package.json'); + + +class GenerateConfigCommand { + /** + * TODO: [BETA / Experimental Mode] + * Generates initial TestBests configuration file + */ + constructor(opts) { + this.opts = opts; + this.configPath = '.testbeats.json'; + this.config = {}; + } + + async execute() { + logger.setLevel(this.opts.logLevel); + this.#printBanner(); + logger.info(`🚧 Config generation is still in BETA mode, please report any issues at ${pkg.bugs.url}\n`); + + await this.#buildConfigFilePath(); + await this.#buildTestResultsConfig(); + await this.#buildTargetsConfig(); + await this.#buildGobalExtensionConfig(); + await this.#buildTestBeatsPortalConfig(); + await this.#saveConfigFile(); + } + + #printBanner() { + const banner = ` + _____ _ ___ _ + (_ _) ( )_ ( _'\\ ( )_ + | | __ ___ | ,_)| (_) ) __ _ _ | ,_) ___ + | | /'__'\\/',__)| | | _ <' /'__'\\ /'_' )| | /',__) + | |( ___/\\__, \\| |_ | (_) )( ___/( (_| || |_ \\__, \\ + (_)'\\____)(____/'\\__)(____/''\\____)'\\__,_)'\\__)(____/ + + v${pkg.version} + Config Generation [BETA] + `; + console.log(banner); + } + + async #buildConfigFilePath() { + const { configPath } = await prompts({ + type: 'text', + name: 'configPath', + message: 'Enter path for configuration file :', + initial: '.testbeats.json' + }); + this.configPath = configPath; + } + + async #buildTestResultsConfig() { + const runnerChoices = [ + { title: 'Mocha', value: 'mocha', selected: true }, + { title: 'JUnit', value: 'junit' }, + { title: 'TestNG', value: 'testng' }, + { title: 'Cucumber', value: 'cucumber' }, + { title: 'NUnit', value: 'nunit' }, + { title: 'xUnit', value: 'xunit' }, + { title: 'MSTest', value: 'mstest' } + ] + // Get test results details + const { includeResults } = await prompts({ + type: 'toggle', + name: 'includeResults', + message: 'Do you want to configure test results?', + initial: true, + active: 'Yes', + inactive: 'No' + }); + + if (!includeResults) { return }; + + const { testResults } = await prompts({ + type: 'multiselect', + name: 'testResults', + message: 'Select test result types to include:', + choices: runnerChoices, + min: 1 + }); + + // Handle result paths + this.config.results = [] + for (const resultType of testResults) { + const { path } = await prompts({ + type: 'text', + name: 'path', + message: `Enter file path for ${resultType} results (.json, .xml etc):`, + initial: "" + }); + this.config.results.push({ + files: path, + type: resultType + }); + } + } + + async #buildTargetsConfig() { + const targetChoices = [ + { title: 'Slack', value: 'slack' }, + { title: 'Microsoft Teams', value: 'teams' }, + { title: 'Google Chat', value: 'chat' } + ]; + + const { includeTargets } = await prompts({ + type: 'toggle', + name: 'includeTargets', + message: 'Do you want to configure notification targets (slack, teams, chat etc)?', + initial: true, + active: 'Yes', + inactive: 'No' + }); + + if (!includeTargets) { return } + + this.config.targets = [] + const { targets } = await prompts({ + type: 'multiselect', + name: 'targets', + message: 'Select notification targets:', + choices: targetChoices, + min: 1 + }); + + const { titleInput } = await prompts({ + type: 'text', + name: 'titleInput', + message: 'Enter notification title (optional):', + initial: 'TestBeats Report' + }); + + // For each target, ask about target-specific extensions + for (const target of targets) { + const { webhookEnvVar } = await prompts({ + type: 'text', + name: 'webhookEnvVar', + message: `Enter environment variable name for ${target} webhook URL:`, + initial: `${target.toUpperCase()}_WEBHOOK_URL` + }); + + const { useExtensions } = await prompts({ + type: 'toggle', + name: 'useExtensions', + message: `Do you want to configure extensions for ${target}?`, + initial: true, + active: 'Yes', + inactive: 'No' + }); + + const targetConfig = { + name: target, + inputs: { + title: titleInput, + url: `{${webhookEnvVar}}`, + publish: 'test-summary' + } + }; + + if (useExtensions) { + targetConfig.extensions = []; + const { selectedExtensions } = await prompts({ + type: 'multiselect', + name: 'selectedExtensions', + message: `Select extensions for ${target}:`, + choices: this.#getExtensionsList(), + min: 1 + }); + + // Configure extension-specific inputs + for (const ext of selectedExtensions) { + const extConfig = await this.#buildExtensionConfig(ext, target); + targetConfig.extensions.push(extConfig); + } + } + this.config.targets.push(targetConfig); + } + } + + async #buildGobalExtensionConfig() { + const { includeGlobalExtensions } = await prompts({ + type: 'toggle', + name: 'includeGlobalExtensions', + message: 'Do you want to configure global extensions?', + initial: false, + active: 'Yes', + inactive: 'No' + }); + + if (!includeGlobalExtensions) { return }; + + this.config.extensions = []; + const { globalExtensionsSelected } = await prompts({ + type: 'multiselect', + name: 'globalExtensionsSelected', + message: 'Select global extensions to enable:', + choices: this.#getExtensionsList() + }); + + // Configure extension-specific inputs + for (const ext of globalExtensionsSelected) { + const extDetails = await this.#buildExtensionConfig(ext, null); + this.config.extensions.push(extDetails); + } + } + + async #buildExtHyperlinks() { + const links = []; + const { addLink } = await prompts({ + type: 'toggle', + name: 'addLink', + message: 'Do you want to add a hyperlink?', + initial: true, + active: 'Yes', + inactive: 'No' + }); + + while (addLink) { + const { text, url } = await prompts([ + { + type: 'text', + name: 'text', + message: 'Enter link text:' + }, + { + type: 'text', + name: 'url', + message: 'Enter link URL:' + } + ]); + + links.push({ text, url }); + + const { addAnother } = await prompts({ + type: 'toggle', + name: 'addAnother', + message: 'Add another link?', + initial: false, + active: 'Yes', + inactive: 'No' + }); + if (!addAnother) break; + } + return { links }; + } + + async #buildExtMentions(targetName) { + const users = []; + const { addUser } = await prompts({ + type: 'toggle', + name: 'addUser', + message: 'Do you want to add user mentions?', + initial: true, + active: 'Yes', + inactive: 'No' + }); + + while (addUser) { + const user = {}; + const { name } = await prompts({ + type: 'text', + name: 'name', + message: 'Enter user name:' + }); + user.name = name; + + if (targetName === 'teams') { + const { teams_upn } = await prompts({ + type: 'text', + name: 'teams_upn', + message: 'Enter Teams UPN (user principal name):' + }); + user.teams_upn = teams_upn; + } else if (targetName === 'slack') { + const { slack_uid } = await prompts({ + type: 'text', + name: 'slack_uid', + message: 'Enter Slack user ID:' + }); + user.slack_uid = slack_uid; + } else if (targetName === 'chat') { + const { chat_uid } = await prompts({ + type: 'text', + name: 'chat_uid', + message: 'Enter Google Chat user ID:' + }); + user.chat_uid = chat_uid; + } + + users.push(user); + + const { addAnother } = await prompts({ + type: 'toggle', + name: 'addAnother', + message: 'Add another user?', + initial: false, + active: 'Yes', + inactive: 'No' + }); + if (!addAnother) break; + } + return { users }; + } + + async #buildExtMetadata() { + const data = []; + const { addMetadata } = await prompts({ + type: 'toggle', + name: 'addMetadata', + message: 'Do you want to add metadata?', + initial: true, + active: 'Yes', + inactive: 'No' + }); + + while (addMetadata) { + const { key, value } = await prompts([ + { + type: 'text', + name: 'key', + message: 'Enter metadata key:' + }, + { + type: 'text', + name: 'value', + message: 'Enter metadata value:' + } + ]); + + data.push({ key, value }); + + const { addAnother } = await prompts({ + type: 'toggle', + name: 'addAnother', + message: 'Add another metadata item?', + initial: false, + active: 'Yes', + inactive: 'No' + }); + if (!addAnother) break; + } + return { data }; + } + + async #buildExtensionConfig(extension, target) { + const extConfig = { + name: extension + }; + + switch (extension) { + case 'hyperlinks': + extConfig.inputs = await this.#buildExtHyperlinks() + break; + + case 'mentions': + extConfig.inputs = await this.#buildExtMentions(target); + break; + + case 'metadata': + extConfig.inputs = await this.#buildExtMetadata(); + break; + + default: + // Add default configuration for other extensions + extConfig.inputs = { + title: '', + separator: target === 'slack' ? false : true + }; + } + return extConfig; + } + + async #buildTestBeatsPortalConfig() { + // TestBeats configuration + const { includeTestBeats } = await prompts({ + type: 'toggle', + name: 'includeTestBeats', + message: 'Do you want to configure TestBeats API key (optional)?', + initial: false, + active: 'Yes', + inactive: 'No' + }); + + if (includeTestBeats) { + const { apiKey } = await prompts({ + type: 'text', + name: 'apiKey', + message: 'Enter environment variable name for API key (optional):', + initial: '{TEST_RESULTS_API_KEY}' + }); + + const { project } = await prompts({ + type: 'text', + name: 'project', + message: 'Enter project name (optional):' + }); + + this.config.api_key = apiKey; + // Add optional fields only if they have values + if (project?.trim()) { + this.config.project = project.trim(); + } + } + } + + #sortConfig() { + // Sort keys alphabetically + this.config = Object.keys(this.config).sort() + .reduce((sortedConfig, key) => { + sortedConfig[key] = this.config[key]; + return sortedConfig; + }, {}); + } + + async #saveConfigFile() { + // Write config to file + try { + await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2)); + logger.info(`✅ Configuration file successfully generated: ${this.configPath}`); + } catch (error) { + throw new Error(`Error: ${error.message}`) + } + } + + #getExtensionsList() { + // List of Extensions supported + return [ + { title: 'Quick Chart Test Summary', value: 'quick-chart-test-summary' }, + { title: 'CI Information', value: 'ci-info' }, + { title: 'Hyperlinks', value: 'hyperlinks' }, + { title: 'Mentions', value: 'mentions' }, + { title: 'Report Portal Analysis', value: 'report-portal-analysis' }, + { title: 'Report Portal History', value: 'report-portal-history' }, + { title: 'Percy Analysis', value: 'percy-analysis' }, + { title: 'Metadata', value: 'metadata' }, + { title: 'AI Failure Summary', value: 'ai-failure-summary' }, + { title: 'Smart Analysis', value: 'smart-analysis' }, + { title: 'Error Clusters', value: 'error-clusters' } + ]; + } +} + +module.exports = { GenerateConfigCommand }; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 17967c3..5bbb818 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,22 @@ const { PublishCommand } = require('./commands/publish.command'); +const { GenerateConfigCommand } = require('./commands/generate-config.command'); function publish(options) { const publish_command = new PublishCommand(options); return publish_command.publish(); } +function generateConfig() { + const generate_command = new GenerateConfigCommand(); + return generate_command.execute(); +} + function defineConfig(config) { - return config + return config; } module.exports = { publish, + generateConfig, defineConfig } \ No newline at end of file