@toptal/davinci-workflow 1.9.0 → 1.10.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @toptal/davinci-workflow
2
2
 
3
+ ## 1.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#1341](https://github.com/toptal/davinci/pull/1341) [`2d5479da`](https://github.com/toptal/davinci/commit/2d5479dab2cedf0133da1a47e91b2bde5b6b019d) Thanks [@OndrejTuma](https://github.com/OndrejTuma)! - ### new-workflow integration-tests
8
+
9
+ - collects additional information during generation and updates the workflow
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [[`2d5479da`](https://github.com/toptal/davinci/commit/2d5479dab2cedf0133da1a47e91b2bde5b6b019d), [`5410a3b7`](https://github.com/toptal/davinci/commit/5410a3b73e3dbbc96ad8dbeca0bdde03d8d2d676)]:
14
+ - @toptal/davinci-monorepo@6.6.0
15
+ - @toptal/davinci-skeleton@7.1.1
16
+
3
17
  ## 1.9.0
4
18
 
5
19
  ### Minor Changes
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
-
2
+ const inquirer = require('inquirer')
3
3
  const cliEngine = require('@toptal/davinci-cli-shared')
4
4
 
5
5
  const newWorkflowCommandCreator = require('../src/commands/new-workflow')
6
6
 
7
+ // register 'table' prompt type https://npmjs.com/package/inquirer-table-prompt
8
+ inquirer.registerPrompt('table', require('inquirer-table-prompt'))
9
+
7
10
  cliEngine.loadCommands([newWorkflowCommandCreator])
8
11
 
9
12
  cliEngine.bootstrap()
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toptal/davinci-workflow",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "GH Workflow generator package for frontend applications",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,10 +31,13 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@toptal/davinci-cli-shared": "1.5.2",
34
- "@toptal/davinci-monorepo": "^6.5.0",
35
- "@toptal/davinci-skeleton": "7.1.0",
34
+ "@toptal/davinci-monorepo": "^6.6.0",
35
+ "@toptal/davinci-skeleton": "7.1.1",
36
36
  "chalk": "^4.1.2",
37
+ "cosmiconfig": "^7.0.1",
37
38
  "fs-extra": "^9.0.1",
39
+ "inquirer": "^8.2.4",
40
+ "inquirer-table-prompt": "^0.2.1",
38
41
  "js-yaml": "^4.1.0",
39
42
  "lodash.kebabcase": "^4.1.1",
40
43
  "ora": "^5.4.1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toptal/davinci-workflow",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "GH Workflow generator package for frontend applications",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,10 +31,13 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@toptal/davinci-cli-shared": "1.5.2",
34
- "@toptal/davinci-monorepo": "^6.5.0",
35
- "@toptal/davinci-skeleton": "7.1.0",
34
+ "@toptal/davinci-monorepo": "^6.6.0",
35
+ "@toptal/davinci-skeleton": "7.1.1",
36
36
  "chalk": "^4.1.2",
37
+ "cosmiconfig": "^7.0.1",
37
38
  "fs-extra": "^9.0.1",
39
+ "inquirer": "^8.2.4",
40
+ "inquirer-table-prompt": "^0.2.1",
38
41
  "js-yaml": "^4.1.0",
39
42
  "lodash.kebabcase": "^4.1.1",
40
43
  "ora": "^5.4.1"
@@ -1,4 +1,7 @@
1
1
  const { print } = require('@toptal/davinci-cli-shared')
2
+ const {
3
+ utils: { checkIfMonorepo },
4
+ } = require('@toptal/davinci-monorepo')
2
5
  const path = require('path')
3
6
  const fs = require('fs-extra')
4
7
  const ora = require('ora')
@@ -74,10 +77,10 @@ const getWorkflowFilename = workflowName => {
74
77
  }
75
78
 
76
79
  const getWorkflowPostBuildFilename = workflowName => {
77
- return kebabCase(workflowName) + '.js'
80
+ return path.join(kebabCase(workflowName), 'index.js')
78
81
  }
79
82
 
80
- const performPostBuildProcedure = async (filename, destDir) => {
83
+ const performPostBuildProcedure = async (filename, destDir, workflowName) => {
81
84
  const postBuildFilePathname = path.join(
82
85
  require.resolve('@toptal/davinci-workflow'),
83
86
  '../',
@@ -96,9 +99,13 @@ const performPostBuildProcedure = async (filename, destDir) => {
96
99
  throw new Error('post build file must export a function')
97
100
  }
98
101
 
102
+ const isMonorepo = checkIfMonorepo()
103
+
99
104
  await executePostBuild({
100
105
  print,
101
106
  destDir,
107
+ workflowName,
108
+ isMonorepo,
102
109
  })
103
110
  }
104
111
 
@@ -129,7 +136,8 @@ const createCommand = async workflowName => {
129
136
  // execute a possible post-build procedure
130
137
  await performPostBuildProcedure(
131
138
  getWorkflowPostBuildFilename(workflowName),
132
- workflowsDir
139
+ workflowsDir,
140
+ fileName
133
141
  )
134
142
  // read and print mandatory ENV secrets that must be installed beforehand
135
143
  await printMandatorySecretsList(fileName)
@@ -1,7 +1,7 @@
1
1
  const chalk = require('chalk')
2
2
  const path = require('path')
3
3
 
4
- const skeleton = require('../utils/skeleton')
4
+ const skeleton = require('../../utils/skeleton')
5
5
  const yellowBoldText = chalk.yellow.bold
6
6
  const logText = chalk.red.bold
7
7
 
@@ -0,0 +1 @@
1
+ module.exports = require('./alpha-package')
@@ -1,7 +1,7 @@
1
1
  const chalk = require('chalk')
2
2
  const path = require('path')
3
3
 
4
- const skeleton = require('../utils/skeleton')
4
+ const skeleton = require('../../utils/skeleton')
5
5
  const logText = chalk.red.bold
6
6
  const yellowBoldText = chalk.yellow.bold
7
7
  const deployWorkflows = [
@@ -0,0 +1 @@
1
+ module.exports = require('./deploy')
@@ -1,7 +1,7 @@
1
1
  const chalk = require('chalk')
2
2
  const path = require('path')
3
3
 
4
- const skeleton = require('../utils/skeleton')
4
+ const skeleton = require('../../utils/skeleton')
5
5
  const yellowBoldText = chalk.yellow.bold
6
6
 
7
7
  const executePostBuild = ({ destDir }) => {
@@ -0,0 +1 @@
1
+ module.exports = require('./deploy-temploy')
@@ -0,0 +1 @@
1
+ module.exports = require('./integration-tests')
@@ -0,0 +1,126 @@
1
+ const fs = require('fs')
2
+ const chalk = require('chalk')
3
+ const path = require('path')
4
+ const {
5
+ utils: { getPackages },
6
+ } = require('@toptal/davinci-monorepo')
7
+
8
+ const filterPackagesByCommands = require('../../utils/filter-packages-by-commands')
9
+ const setRootEnv = require('../../utils/set-root-env')
10
+ const { readPackageJson } = require('../../utils/skeleton')
11
+ const {
12
+ promptCodeCoverage,
13
+ promptParallelWorkersForMonorepo,
14
+ promptParallelWorkersForSPA,
15
+ } = require('./prompts')
16
+ const { getCoverageReportDir } = require('./nyc')
17
+
18
+ const CONFLUENCE_PAGE =
19
+ 'https://toptal-core.atlassian.net/l/c/qtXKBzvs#Generated-IT-Workflow'
20
+ const yellowBoldText = chalk.yellow.bold
21
+
22
+ const getEnvForMonorepo = async (rootDir, commands) => {
23
+ const packages = getPackages(rootDir)
24
+
25
+ const filteredWorkspaces = filterPackagesByCommands(
26
+ rootDir,
27
+ packages,
28
+ commands
29
+ )
30
+
31
+ if (filteredWorkspaces.length === 0) {
32
+ throw new Error(
33
+ `There are no packages to run integration tests in. Following commands were not found: ${JSON.stringify(
34
+ commands
35
+ )}`
36
+ )
37
+ }
38
+
39
+ const parallelMatrix = JSON.stringify(
40
+ await promptParallelWorkersForMonorepo(filteredWorkspaces)
41
+ )
42
+
43
+ const { command, location } = filteredWorkspaces[0]
44
+
45
+ const coverageReportDir = await getCoverageReportDir(
46
+ path.join(rootDir, location)
47
+ )
48
+
49
+ return {
50
+ PARALLEL_MATRIX: parallelMatrix,
51
+ COMMAND: command,
52
+ COVERAGE_REPORT_DIR: coverageReportDir,
53
+ }
54
+ }
55
+
56
+ const getEnvForSPA = async (rootDir, commands) => {
57
+ const pkg = readPackageJson(rootDir)
58
+ const command = Object.keys(pkg.scripts).find(script =>
59
+ commands.includes(script)
60
+ )
61
+
62
+ if (!command) {
63
+ throw new Error(
64
+ `No command for integration tests was found. Searched for: ${JSON.stringify(
65
+ commands
66
+ )}`
67
+ )
68
+ }
69
+
70
+ const parallelGroups = await promptParallelWorkersForSPA()
71
+ const coverageReportDir = await getCoverageReportDir(rootDir)
72
+
73
+ return {
74
+ PARALLEL_GROUPS: parallelGroups,
75
+ COMMAND: command,
76
+ COVERAGE_REPORT_DIR: coverageReportDir,
77
+ }
78
+ }
79
+
80
+ const executePostBuild = async ({ destDir, workflowName, isMonorepo }) => {
81
+ const workflowPath = path.join(destDir, workflowName)
82
+ const rootDir = path.resolve(destDir, '../../')
83
+ // to support backward compatibility
84
+ const supportedCommands = ['test:integration:ci', 'test:ui:ci', 'test:e2e:ci']
85
+
86
+ try {
87
+ const dynamicEnv = isMonorepo
88
+ ? await getEnvForMonorepo(rootDir, supportedCommands)
89
+ : await getEnvForSPA(rootDir, supportedCommands)
90
+
91
+ const printCoverage = await promptCodeCoverage()
92
+
93
+ setRootEnv(
94
+ {
95
+ ...dynamicEnv,
96
+ PRINT_COVERAGE: printCoverage,
97
+ },
98
+ workflowPath
99
+ )
100
+
101
+ console.log(chalk.green('\nWorkflow has been updated.'))
102
+ } catch (e) {
103
+ fs.unlinkSync(workflowPath)
104
+ console.error(e.stack)
105
+ throw new Error(`Workflow failed to be generated: ${e.message}`)
106
+ }
107
+
108
+ console.log(
109
+ [
110
+ chalk.green(
111
+ '\nIntegration tests (integration-tests) GH Workflow has been generated! \n\n'
112
+ ),
113
+ 'Workflow is located in ',
114
+ chalk.green.bold('.github/workflows/davinci-integration-tests.yml'),
115
+ '\n',
116
+ "But it likely won't work out of the box and will ",
117
+ yellowBoldText('require little bit of adjusting'),
118
+ ' to make it work in your CI\n',
119
+ 'Read more about customization here ',
120
+ chalk.grey.bold(CONFLUENCE_PAGE),
121
+ '\n',
122
+ ].join('')
123
+ )
124
+ }
125
+
126
+ module.exports = executePostBuild
@@ -0,0 +1,22 @@
1
+ const getProgramConfig = require('../../utils/get-program-config')
2
+
3
+ /**
4
+ * @param {string} root
5
+ * @return {Promise<string>}
6
+ */
7
+ const getCoverageReportDir = async root => {
8
+ const config = await getCoverageConfig(root)
9
+
10
+ return config?.['report-dir'] ?? 'coverage'
11
+ }
12
+
13
+ /**
14
+ * @param {string} root
15
+ * @return {Promise<Object<string, any>>}
16
+ */
17
+ const getCoverageConfig = root => getProgramConfig('nyc', root)
18
+
19
+ module.exports = {
20
+ getCoverageConfig,
21
+ getCoverageReportDir,
22
+ }
@@ -0,0 +1,82 @@
1
+ const {
2
+ promptTable,
3
+ promptNumber,
4
+ promptConfirm,
5
+ } = require('../../utils/prompts')
6
+
7
+ /**
8
+ * Return custom message for parallel workers prompt
9
+ * @param {string} [customMessage]
10
+ * @return {string}
11
+ * @private
12
+ */
13
+ const getMessageForParallelWorkers = customMessage => {
14
+ const message = customMessage ? [customMessage] : []
15
+
16
+ message.push(
17
+ 'Here are packages that support integration tests. Select how many parallel workers you want in which package.'
18
+ )
19
+ message.push(
20
+ "If you don't choose anything, package won't be included in integration testing"
21
+ )
22
+
23
+ return message.join('\n')
24
+ }
25
+
26
+ /**
27
+ * Show table prompt with selection of parallel workers for each package
28
+ * @param {Array<{ name: string, location: string }>} packages
29
+ * @param {string} [customMessage]
30
+ * @return {Promise<Array<{ pkg: string, location: string, parallelGroups: number }>>}
31
+ */
32
+ const promptParallelWorkersForMonorepo = async (
33
+ packages,
34
+ customMessage = ''
35
+ ) => {
36
+ const message = getMessageForParallelWorkers(customMessage)
37
+ const columns = Array.from({ length: 10 }, (_, index) => index + 1)
38
+ const rows = packages.map(({ name }) => name)
39
+
40
+ const parallelWorkers = await promptTable(message, {
41
+ columns,
42
+ rows,
43
+ })
44
+
45
+ // At least one package needs to be selected for integration tests
46
+ // so if none is selected, show the prompt again
47
+ if (!parallelWorkers.some(Boolean)) {
48
+ return promptParallelWorkersForMonorepo(
49
+ packages,
50
+ 'At least one package needs to be selected for integration tests!'
51
+ )
52
+ }
53
+
54
+ return packages
55
+ .map(({ name, location }, index) => ({
56
+ pkg: name,
57
+ location,
58
+ parallelGroups: parallelWorkers[index],
59
+ }))
60
+ .filter(({ parallelGroups }) => parallelGroups)
61
+ }
62
+
63
+ const promptParallelWorkersForSPA = () =>
64
+ promptNumber(
65
+ 'Input how many parallel workers you want for integration tests',
66
+ {
67
+ validate: input => input > 0 || 'Please provide a number > 0',
68
+ filter: input => (Number.isNaN(input) || Number(input) <= 0 ? '' : input),
69
+ }
70
+ )
71
+
72
+ const promptCodeCoverage = () =>
73
+ promptConfirm(
74
+ 'Do you want to print code coverage info in your PR? \nYour project must support code-coverage generation',
75
+ false
76
+ )
77
+
78
+ module.exports = {
79
+ promptCodeCoverage,
80
+ promptParallelWorkersForMonorepo,
81
+ promptParallelWorkersForSPA,
82
+ }
@@ -0,0 +1,29 @@
1
+ const path = require('path')
2
+
3
+ /**
4
+ * Return workspace packages that have specified
5
+ * command(s) in their package.json scripts
6
+ * @param {string} rootDir
7
+ * @param {Array<{ name: string, location: string }>} packages
8
+ * @param {Array<string>} commands
9
+ * @return {Array<{ name: string, location: string, command: string }>}
10
+ */
11
+ const filterPackagesByCommands = (rootDir, packages, commands) => {
12
+ return packages
13
+ .map(({ name, location }) => {
14
+ const packageJsonPath = path.join(rootDir, location, 'package.json')
15
+
16
+ const pkg = require(packageJsonPath)
17
+
18
+ return {
19
+ name,
20
+ location,
21
+ command: Object.keys(pkg.scripts).find(script =>
22
+ commands.includes(script)
23
+ ),
24
+ }
25
+ })
26
+ .filter(({ command }) => command)
27
+ }
28
+
29
+ module.exports = filterPackagesByCommands
@@ -0,0 +1,42 @@
1
+ const path = require('path')
2
+ const { cosmiconfig } = require('cosmiconfig')
3
+
4
+ /**
5
+ * Searches for and loads configuration for your program
6
+ * Looks for:
7
+ * - a package.json property
8
+ * - a JSON or YAML, extensionless "rc file"
9
+ * - an "rc file" with the extensions .json, .yaml, .yml, .js, or .cjs
10
+ * - a .config.js or .config.cjs CommonJS module
11
+ * @param program
12
+ * @param [defaultDir] - search up the directory tree from this folder
13
+ * @return {Promise<any|{extends}>}
14
+ */
15
+ const getProgramConfig = async (program, defaultDir) => {
16
+ const explorer = cosmiconfig(program)
17
+
18
+ const defaultConfig = await explorer.search(defaultDir)
19
+
20
+ if (!defaultConfig) {
21
+ return
22
+ }
23
+
24
+ if (!defaultConfig.config.extends) {
25
+ return defaultConfig.config
26
+ }
27
+
28
+ const extendedConfig = await explorer.load(
29
+ path.join(defaultDir, defaultConfig.config.extends)
30
+ )
31
+
32
+ if (!extendedConfig) {
33
+ return defaultConfig.config
34
+ }
35
+
36
+ return {
37
+ ...defaultConfig.config,
38
+ ...extendedConfig.config,
39
+ }
40
+ }
41
+
42
+ module.exports = getProgramConfig
@@ -0,0 +1,74 @@
1
+ const inquirer = require('inquirer')
2
+
3
+ /**
4
+ * @typedef Choices
5
+ * @type {object}
6
+ * @property {string} name
7
+ * @property {string|Object<string, any>} [value]
8
+ */
9
+
10
+ /**
11
+ * Show table prompt
12
+ * @param {string} message
13
+ * @param {Object} additionalProps
14
+ * @param {Array<string | Choices>} additionalProps.columns
15
+ * @param {Array<string | Choices>} additionalProps.rows
16
+ * @return {Promise<Array<any|undefined>>}
17
+ */
18
+ const promptTable = async (message, { columns, rows }) => {
19
+ const { result } = await inquirer.prompt([
20
+ {
21
+ type: 'table',
22
+ name: 'result',
23
+ message,
24
+ columns,
25
+ rows,
26
+ },
27
+ ])
28
+
29
+ return result
30
+ }
31
+
32
+ /**
33
+ * Show number prompts
34
+ * @param {string} message
35
+ * @param {Object} [additionalProps]
36
+ * @return {Promise<number>}
37
+ */
38
+ const promptNumber = async (message, additionalProps) => {
39
+ const { numberValue } = await inquirer.prompt([
40
+ {
41
+ type: 'number',
42
+ name: 'numberValue',
43
+ message,
44
+ ...additionalProps,
45
+ },
46
+ ])
47
+
48
+ return numberValue
49
+ }
50
+
51
+ /**
52
+ * Show confirmation prompt
53
+ * @param {string} message
54
+ * @param {boolean} [defaultValue]
55
+ * @return {Promise<boolean>}
56
+ */
57
+ const promptConfirm = async (message, defaultValue) => {
58
+ const { confirmValue } = await inquirer.prompt([
59
+ {
60
+ type: 'confirm',
61
+ name: 'confirmValue',
62
+ message,
63
+ default: defaultValue,
64
+ },
65
+ ])
66
+
67
+ return confirmValue
68
+ }
69
+
70
+ module.exports = {
71
+ promptTable,
72
+ promptNumber,
73
+ promptConfirm,
74
+ }
@@ -0,0 +1,17 @@
1
+ const yaml = require('js-yaml')
2
+ const fs = require('fs')
3
+
4
+ /**
5
+ * Set/replace root ENV variables in the workflow
6
+ * @param {Object<string, any>} env
7
+ * @param {string} workflowPath
8
+ */
9
+ const setRootEnv = (env, workflowPath) => {
10
+ const workflow = yaml.load(fs.readFileSync(workflowPath, 'utf8'))
11
+
12
+ Object.assign(workflow.env, env)
13
+
14
+ fs.writeFileSync(workflowPath, yaml.dump(workflow))
15
+ }
16
+
17
+ module.exports = setRootEnv
@@ -1,26 +0,0 @@
1
- const chalk = require('chalk')
2
-
3
- const CONFLUENCE_PAGE =
4
- 'https://toptal-core.atlassian.net/l/c/qtXKBzvs#Generated-IT-Workflow'
5
- const yellowBoldText = chalk.yellow.bold
6
-
7
- const executePostBuild = () => {
8
- console.log(
9
- [
10
- chalk.green(
11
- '\nIntegration tests (integration-tests) GH Workflow has been generated! \n\n'
12
- ),
13
- 'Workflow is located in ',
14
- chalk.green.bold('.github/workflows/davinci-integration-tests.yml'),
15
- '\n',
16
- "But it likely won't work out of the box and will ",
17
- yellowBoldText('require little bit of adjusting'),
18
- ' to make it work in your CI\n',
19
- 'Read more about customization here ',
20
- chalk.grey.bold(CONFLUENCE_PAGE),
21
- '\n',
22
- ].join('')
23
- )
24
- }
25
-
26
- module.exports = executePostBuild