@kmcid/cypress-parallel-cli
Version:
CLI app for running parallel cypress tests
1,082 lines (980 loc) • 38.7 kB
JavaScript
/**
* Having problems? Message me
* Maintainer: Krizzchanne Cid <kmcid@mail.ru>
*
* TODOs:
* - Test using windows machine (compatibility)
**/
import Conf from 'conf'
import chalk from 'chalk'
import inquirer from 'inquirer'
import open from 'open'
import { table } from 'table'
import { globSync } from 'glob'
import { parallelLimit } from 'async'
import { v4 as uuidv4 } from 'uuid'
import { fileURLToPath } from 'url'
import { spawn } from 'node:child_process'
import { resolve, basename, parse } from 'path'
import { writeFileSync, readdirSync, existsSync } from 'node:fs'
import { readFileSync, rmSync, renameSync, mkdirSync, copyFileSync } from 'node:fs'
import PressToContinuePrompt from 'inquirer-press-to-continue'
inquirer.registerPrompt('press-to-continue', PressToContinuePrompt)
const __dirname = process.cwd()
const __filename = fileURLToPath(import.meta.url)
if (!existsSync(resolve(__dirname, 'package.json'))) throw new Error('Current directory is not a node project')
const packagejson = JSON.parse(readFileSync(resolve(__dirname, 'package.json')))
// sometimes package.json does not have a name, use folder name
const packagejsonname = packagejson?.name || basename(__dirname)
// default and global variables
const PARALLEL_CLI_CONFIG = 'parallel-cli.json'
const PARALLEL_CLI_CONFIG_PATH = resolve(__dirname, PARALLEL_CLI_CONFIG)
const DEFAULT_CYPRESS_DIR = 'cypress'
const DEFAULT_SPECS_DIR = 'e2e'
const DEFAULT_SPECS = [DEFAULT_SPECS_DIR]
const DEFAULT_BROWSERS = ['electron']
const DEFAULT_PARALLEL = 5
const MAX_PARALLEL_ALLOWED = 20
const DEFAULT_REPORTER = 'parallel-cli-reporter.js'
const DEFAULT_REPORTER_PATH = resolve(__filename, '..', DEFAULT_REPORTER)
const DEFAULT_REPORTER_DIR = 'parallel-cli-results'
const DEFAULT_REPORTER_DIR_PATH = resolve(__dirname, DEFAULT_REPORTER_DIR)
const MAX_PARALLEL_CLOUD = parseInt(process.env.MAX_PARALLEL_CLOUD)
// variables for conf, we do not want to fetch it everytime
let EXITSIGNAL, PROJECTID, PRESET, PRESETS, RECORDKEY, SPECS, ENVVARS, BROWSERS, PARALLEL, DASHBOARD, DASHBOARDCREDS
const useLocalConfig = existsSync(PARALLEL_CLI_CONFIG_PATH)
// configuration of conf storage for parallel cli settings
const config = new Conf({
projectName: packagejsonname,
configName: useLocalConfig ? parse(PARALLEL_CLI_CONFIG).name : null,
cwd: useLocalConfig ? __dirname : null,
schema: {
preset: { type: 'string' },
presets: {
type: 'array',
items: [{ type: 'object' }],
minItems: 0,
},
dashboardcreds: { type: 'string' },
recordkey: { type: 'string' },
specs: {
type: 'array',
items: [{ type: 'string' }],
default: DEFAULT_SPECS,
minItems: 1,
},
envvars: { type: 'string' },
browsers: {
type: 'array',
items: [{ type: 'string' }],
default: DEFAULT_BROWSERS,
minItems: 1,
},
parallel: { type: 'number', default: 5 },
dashboard: { type: 'string' },
init: { type: 'boolean', default: false },
},
})
// set config variables
const setvars = () => {
const dir = readdirSync(__dirname)
const cypressConfigPath = dir.find((x) => x.startsWith('cypress.config'))
const cypressConfig = readFileSync(resolve(__dirname, cypressConfigPath), 'utf-8')
const getProjectIds = [...cypressConfig.matchAll(/projectId:\s+["']\w{6}["']/g)]
if (getProjectIds.length > 0) {
PROJECTID = getProjectIds[0][0].split(/\s/).pop() // get projectId value
PROJECTID = PROJECTID.substring(1, PROJECTID.length - 1) // remove quotes
}
// use default values if config is not found
PRESET = config.get('preset')
PRESETS = config.get('presets') || []
DASHBOARD = config.get('dashboard')
DASHBOARDCREDS = config.get('dashboardcreds')
RECORDKEY = config.get('recordkey')
SPECS = config.get('specs') || DEFAULT_SPECS
ENVVARS = config.get('envvars')
BROWSERS = config.get('browsers') || DEFAULT_BROWSERS
PARALLEL = config.get('parallel') || DEFAULT_PARALLEL
}
// reset cli config
const resetvars = () => {
config.delete('preset')
config.delete('presets')
config.delete('recordkey')
config.delete('envvars')
config.delete('dashboard')
config.delete('dashboardcreds')
config.set('specs', DEFAULT_SPECS)
config.set('browsers', DEFAULT_BROWSERS)
config.set('parallel', DEFAULT_PARALLEL)
setvars()
}
const loadpreset = () => {
if (!PRESET) return
// load presets if available
const preset = PRESETS.find((p) => p.name === PRESET)
if (preset) {
RECORDKEY = preset.recordkey
SPECS = preset.specs || DEFAULT_SPECS
ENVVARS = preset.envvars
BROWSERS = preset.browsers || DEFAULT_BROWSERS
PARALLEL = preset.parallel || DEFAULT_PARALLEL
}
}
// array into chunks
function chunks(arr, n) {
const _arr = []
for (let i = n; i > 0; i--) _arr.push(arr.splice(0, Math.ceil(arr.length / i)))
return _arr
}
// clear cli then display banner
const resetcli = () => {
console.clear()
console.log(chalk.greenBright(generatebanner()))
}
// ASCII Art from: https://patorjk.com/software/taag/#p=display&h=1&v=2&f=Big%20Money-ne&t=parallel%20cli
const generatebanner = () => {
return `
/$$ /$$ /$$ /$$ /$$
| $$| $$ | $$ | $$|__/
/$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ | $$| $$ /$$$$$$ | $$ /$$$$$$$| $$ /$$
/$$__ $$ |____ $$ /$$__ $$|____ $$| $$| $$ /$$__ $$| $$ /$$_____/| $$| $$
| $$ \\ $$ /$$$$$$$| $$ \\__/ /$$$$$$$| $$| $$| $$$$$$$$| $$ | $$ | $$| $$
| $$ | $$ /$$__ $$| $$ /$$__ $$| $$| $$| $$_____/| $$ | $$ | $$| $$
| $$$$$$$/| $$$$$$$| $$ | $$$$$$$| $$| $$| $$$$$$$| $$ | $$$$$$$| $$| $$
| $$____/ \\_______/|__/ \\_______/|__/|__/ \\_______/|__/ \\_______/|__/|__/
| $$ -- parallel cli settings -- RECORDKEY: ${RECORDKEY || '<not set>'} --
| $$ -- SPECS: ${SPECS} -- ENV: ${ENVVARS || '<not set>'} --
| $$ -- BROWSERS: ${BROWSERS.join(',')} -- PARALLEL: ${PARALLEL} --
|__/ -- LATEST DASHBOARD RESULT: ${DASHBOARD || '<not set>'} --`
}
// from the name itself, it gets the directories from provided path
// TODO: refactor this using glob lib
const getdirectories = (source) =>
readdirSync(source, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
// delay timer using async
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// threads for running parallel tests: https://github.com/tnicola/cypress-parallel/blob/master/lib/thread.js
const runnerthread = async (command, index) => {
// from original cypress-parallel implementation. but i do not think we have xvfb in our machine or do we?
// staggered start (when executed in container with xvfb ends up having a race condition causing intermittent failures)
await sleep(index * 2000)
const timeMap = new Map()
const promise = new Promise((resolve) => {
const child = spawn('npx', [command], {
cwd: process.cwd(),
stdio: 'pipe',
shell: true,
detached: false,
env: {
// DEBUG: 'cypress*',
...process.env,
// those colored texts makes the cli pretty
FORCE_COLOR: true,
},
})
const logs = []
child.stdout.on('data', (data) => {
const datastring = data.toString()
console.log(datastring)
// store logs for later processing
logs.push(datastring)
})
child.stderr.on('data', (data) => {
const datastring = data.toString()
console.log(datastring)
// store logs for later processing
logs.push(datastring)
})
child.on('exit', () => {
// find cypress dashboard link and store at conf.dashboard
// this will happen multiple times, overwriting from one run and another, should be ok
const regex = /https:\/\/cloud.cypress.io\/projects\/\w+\/runs\/\d+/gi
const recordedrun = logs
.flat()
.filter((x) => regex.test(x))
.pop()
if (recordedrun) {
const matches = recordedrun.match(regex)
config.set('dashboard', matches.pop())
setvars()
}
// we do not really have a use for this timemap, but leaving it as it is
resolve(timeMap)
})
})
return promise
}
const runtest_canceldashboardrun = async (uuid, callback) => {
// only trigger cancellation once
if (EXITSIGNAL) return
EXITSIGNAL = true
// do not trigger cancellation if no credentials is available
if (!DASHBOARDCREDS) {
console.log() // push following logs downwards
console.log(
chalk.bold.redBright('Cypress dashboard credentials not provided, auto run cancellation will not trigger.')
)
return
}
console.log() // push following logs downwards
console.log(chalk.bold.redBright('Parallel-cli process is killed while running tests, initiating cleanup ...'))
// default headers for cypress dashboard requests
const headers = { 'Content-Type': 'application/json' }
// user authentication using dashboard credentials
const credentials = DASHBOARDCREDS.split(':')
const authResponse = await fetch('https://authenticate.cypress.io/login/local?source=dashboard', {
method: 'POST',
headers,
body: JSON.stringify({
email: credentials[0],
password: credentials[1],
}),
})
// if api responds with 401, show error and terminate
if (authResponse.status === 401) {
console.log() // push following logs downwards
console.log(
chalk.bold.redBright('Cypress dashboard credentials are incorrect, auto run cancellation will not trigger.')
)
return
}
// attach auth cookie to headers, api requests needs cookie to work
headers.Cookie = authResponse.headers.get('set-cookie')
// get filtered project runs
// TODO: add date filter to minify response
const runsResponse = await fetch('https://cloud.cypress.io/graphql', {
method: 'POST',
headers,
body: JSON.stringify({
operationName: 'RunsList',
variables: {
projectId: PROJECTID,
input: {
tags: ['parallel-cli', PRESET],
tagsMatch: 'ANY',
},
},
query:
'query RunsList($projectId: String!, $input: ProjectRunsConnectionInput) { project(id: $projectId) { runs(input: $input) { nodes { id status buildNumber ci { ciBuildId } } } } }',
}),
})
const runsJson = await runsResponse.json()
// find the run that matches our ci build id (custom uuid)
const currentRun = runsJson?.data?.project?.runs?.nodes?.find((x) => x.ci?.ciBuildId === uuid)
// proceed with cancellation if applicable
if (currentRun && currentRun?.status !== 'CANCELLED') {
// trigger cancellation by passing run id
const cancelResponse = await fetch('https://cloud.cypress.io/graphql', {
method: 'POST',
headers,
body: JSON.stringify({
operationName: 'CancelRun',
variables: {
runId: currentRun?.id,
},
query: 'mutation CancelRun($runId: UUID!) { runCancellation(runId: $runId) { id status cancelledAt } }',
}),
})
// handle cancellation errors by logging
const cancelJson = await cancelResponse.json()
if (cancelJson?.data?.runCancellation?.status !== 'CANCELLED') {
console.log() // push following logs downwards
console.log(chalk.bold.redBright(`Auto run cancellation failed. Cancel the run manually via this link:`))
console.log(
chalk.bold.blueBright(`https://cloud.cypress.io/projects/${PROJECTID}/runs/${currentRun.buildNumber}/overview`)
)
}
}
// trigger callback to exit process
if (callback) callback()
}
const runtest = async () => {
// remove existing dashboard results
config.delete('dashboard')
DASHBOARD = ''
// cleanup results directory by FORCE! nobody survives!
if (existsSync(DEFAULT_REPORTER_DIR_PATH)) {
for (const file of readdirSync(DEFAULT_REPORTER_DIR_PATH)) {
rmSync(resolve(DEFAULT_REPORTER_DIR_PATH, file), { recursive: true, force: true })
}
}
// generate uuid to be used as ci-build-id
// https://docs.cypress.io/guides/guides/parallelization#Linking-CI-machines-for-parallelization-or-grouping
const uuid = uuidv4()
const suites = SPECS.map((suite) => `${DEFAULT_CYPRESS_DIR}/${suite}`)
// cypress run command builder
// TODO: find improvements. current threads/parallel runs per browser
// TODO: listing spec files by folder, folder selection and greptags selection
for (const browser of BROWSERS) {
let command = `npx cypress run`
if (ENVVARS) command = command.concat(` --env ${ENVVARS}`)
command = command.concat(` --browser ${browser}`)
// using these options for debugging purposes, maybe allow configuration?
// command = command.concat(` --headed --runner-ui`)
// using this to override projects config, to reduce server memory
command = command.concat(` --config numTestsKeptInMemory=0`)
command = command.concat(` --reporter ${DEFAULT_REPORTER_PATH}`)
if (RECORDKEY) {
// NOTE! i don't know what this is for, investigate, but removing this for now because it stucks cloud runs
// begin reading from stdin so the process does not exit
// process.stdin.resume()
// add listener for process exit, apply only for dashboard recorded runs
;['SIGTERM', 'SIGINT'].forEach((signal) =>
process.on(signal, () => {
runtest_canceldashboardrun(uuid, () => {
process.exit(1)
})
})
)
command = command.concat(` --spec "${suites.join(',')}"`)
command = command.concat(` --group ${browser} --record --key ${RECORDKEY}`)
command = command.concat(` --parallel --ci-build-id ${uuid}`)
if (PRESET) command = command.concat(` --tag parallel-cli,${PRESET}`)
console.log(`${chalk.bold.greenBright(`Running command: `)}${chalk.whiteBright(command)}`)
// run array of threads limited by parallel count
// cypress dashboard will handle parallellization of tests
await Promise.all(
Array(PARALLEL > MAX_PARALLEL_CLOUD ? MAX_PARALLEL_CLOUD : PARALLEL)
.fill(undefined)
.map((_, index) => runnerthread(command, index))
)
} else {
// limit parallel runs by setting
let _PARALLEL = PARALLEL > MAX_PARALLEL_CLOUD ? MAX_PARALLEL_CLOUD : PARALLEL
const specs = [
...new Set(
suites
.map((suite) =>
globSync(`${resolve(__dirname, suite)}/**/*.cy.{ts,js}`, {
withFileTypes: true,
windowsPathsNoEscape: true,
})
.filter((path) => path.isFile())
.map((file) => file.fullpath())
)
.flat()
),
]
if (specs.length === 0) console.log(chalk.bold.redBright('No specs found on selected suites'))
const specsAsChunks = chunks(specs, _PARALLEL)
await parallelLimit(
// use set to filter out redundant specs
specsAsChunks.map((chunk, index) => {
if (!chunk.length) return () => {}
return (callback) => {
const finalcommand = command.concat(` --spec "${chunk.join(',')}"`)
console.log(
`${chalk.bold.greenBright(`[${index + 1}/${specsAsChunks.length}] Running command: `)}${chalk.whiteBright(
finalcommand
)}`
)
runnerthread(finalcommand, index).then(() => callback(null))
}
}),
_PARALLEL
)
}
// move results to browser specific results folder
if (existsSync(DEFAULT_REPORTER_DIR_PATH)) {
for (const dirent of readdirSync(DEFAULT_REPORTER_DIR_PATH, { withFileTypes: true })) {
if (dirent.isDirectory()) continue
const browserdir = resolve(DEFAULT_REPORTER_DIR_PATH, browser)
existsSync(browserdir) || mkdirSync(browserdir)
renameSync(resolve(DEFAULT_REPORTER_DIR_PATH, dirent.name), resolve(browserdir, dirent.name))
}
}
}
}
const askexit2menu = async () => {
// disable when exit is triggered
// TODO: find a better solution for this
if (EXITSIGNAL) return
inquirer
.prompt({
name: 'mainmenu',
type: 'press-to-continue',
anyKey: true,
pressToContinueMessage: 'Press any key to return to main menu ...',
})
.then(() => {
menuprompt()
})
}
const addnewpreset = (presetname) => {
PRESETS = [
...PRESETS,
{
name: presetname,
recordkey: RECORDKEY,
specs: SPECS,
envvars: ENVVARS,
browsers: BROWSERS,
parallel: PARALLEL,
},
]
PRESET = presetname
config.set('preset', PRESET)
config.set('presets', PRESETS)
}
const showcliresultstable = () => {
const spanningcells = []
// results are grouped by browser. extract results from each browser folder, results are stored in JSON
let tabledata = getdirectories(DEFAULT_REPORTER_DIR_PATH).map((dir) => {
const totals = { tests: 0, passes: 0, failures: 0, duration: 0 }
const results = readdirSync(resolve(DEFAULT_REPORTER_DIR_PATH, dir))
// we are using readFileSync here because require(jsonfile) does not work in .mjs, anyway it works the same
.map((file) => JSON.parse(readFileSync(resolve(DEFAULT_REPORTER_DIR_PATH, dir, file))))
.reduce((a, c) => {
a.push([dir, c.file, c.start, c.tests, c.passes, c.failures, c.duration])
// increment our totals counter (for tallying data)
totals.tests += c.tests
totals.passes += c.passes
totals.failures += c.failures
totals.duration += c.duration
return a
}, [])
return [...results, ['Totals', '', '', totals.tests, totals.passes, totals.failures, totals.duration]]
})
// to hard to explain but spanning cells are needed to group repeated cells
// https://www.npmjs.com/package/table#user-content-configspanningcells
for (const group of tabledata) {
// tldr; added some magical calculations in generating spanning cells
const lastspanningcell = spanningcells[spanningcells.length - 1]
const lastrow = lastspanningcell ? lastspanningcell.row + 1 : 1
// spanning cell for browser group, spans the length of results
spanningcells.push({
col: 0,
row: lastrow,
rowSpan: group.length - 1,
verticalAlignment: 'middle',
})
// spanning cell for totals, covers 3 columns, always the row from browser group
spanningcells.push({
col: 0,
row: lastrow + group.length - 1,
colSpan: 3,
alignment: 'center',
})
}
// flatten table data to combine grouped results
tabledata = tabledata.flat()
// colorize table data
tabledata = tabledata.map((x) => [
chalk.bold.cyanBright(x[0]),
chalk.bold.whiteBright(x[1]),
x[2],
chalk.cyanBright(x[3]),
chalk.greenBright(x[4]),
chalk.redBright(x[5]),
chalk.yellowBright(x[6]),
])
// add table headers
tabledata.unshift(
['Browser', 'Spec', 'Date', 'Tests', 'Passed', 'Failed', 'Duration'].map((x) => chalk.bold.greenBright(x))
)
console.log('\n')
console.log(table(tabledata, { columns: [{ alignment: 'center', width: 12 }], spanningCells: spanningcells }))
// optionally display recorded cypress dashboard run link
if (DASHBOARD) {
process.stdout.write(`${chalk.bold.blueBright('⏺️ Cypress dashboard record (click link to navigate): ')}`)
console.log(`${chalk.bold.whiteBright(DASHBOARD)}\n`)
}
}
const menuchoices = () => {
return [
'Run cypress tests',
'Run cypress tests (not recorded)',
'View latest test results',
'Setup parallel cli settings',
`Load preset ${PRESET ? `(preset: ${PRESET})` : ''}`,
'Help me',
'Exit',
]
}
const menuprompt = () => {
resetcli()
inquirer
.prompt({
type: 'list',
name: 'menu',
message: 'What do you like to do?',
choices: menuchoices(),
})
.then(async ({ menu }) => {
switch (menu) {
case menuchoices()[0]:
// blah, blah, blah, r u sure 'bout this?
inquirer
.prompt({
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to run cypress tests with these settings:
-- RECORDKEY: ${RECORDKEY || '<not set>'} --
-- SPECS: ${SPECS} -- ENV: ${ENVVARS || '<not set>'} --
-- BROWSERS: ${BROWSERS} -- PARALLEL: ${PARALLEL} --`,
default: false,
})
.then(async ({ confirm }) => {
if (confirm) {
await runtest()
askexit2menu()
} else menuprompt()
})
break
case menuchoices()[1]:
// remove RECORDKEY before running tests to disable recording
const _RECORDKEY = RECORDKEY
RECORDKEY = ''
await runtest()
askexit2menu()
RECORDKEY = _RECORDKEY
break
case menuchoices()[2]:
// proceed only when results folder exists
if (!existsSync(DEFAULT_REPORTER_DIR_PATH)) {
console.log(chalk.redBright('parallel-cli-results folder does not exist, unable to get test results.'))
console.log(chalk.redBright('maybe you should run your tests first. returning to main menu in 3s...'))
setTimeout(() => menuprompt(), 3000)
} else {
showcliresultstable()
askexit2menu()
}
break
case menuchoices()[3]:
settingsprompt()
break
case menuchoices()[4]:
// TODO: add presets here
if (!PRESET || PRESET === 'CUSTOM') {
console.log(chalk.whiteBright(`No presets are available, creating default preset from current settings`))
addnewpreset('DEFAULT')
await sleep(2000)
config.set('preset', 'DEFAULT')
}
inquirer
.prompt({
type: 'list',
name: 'preset',
message: 'Select which preset to load',
default: PRESET,
choices: PRESETS.map((p) => {
return {
// TODO: customise preset name to be descriptive
name: `${p.name}`,
value: p.name,
}
}),
})
.then(({ preset }) => {
PRESET = preset
config.set('preset', PRESET)
loadpreset()
menuprompt()
})
break
case menuchoices()[5]:
console.log(chalk.bold.whiteBright(`Read README.md to learn how to setup and use parallel-cli`))
console.log(chalk.greenBright('Opening readme documentation in 2s...'))
await sleep(2000)
await open('https://www.npmjs.com/package/@kmcid/cypress-parallel-cli')
askexit2menu()
break
default:
console.clear()
process.exitCode = 1
}
})
}
// browser list: https://docs.cypress.io/guides/guides/launching-browsers
const browserlist = {
'Electron browsers': [{ name: 'Electron', value: 'electron' }],
'Chrome browsers': [
{ name: 'Chrome', value: 'chrome' },
{ name: 'Chromium', value: 'chromium' },
{ name: 'Chrome Beta', value: 'chrome:beta' },
{ name: 'Chrome Canary', value: 'chrome:canary' },
{ name: 'Edge', value: 'edge' },
{ name: 'Edg Canarye', value: 'edge:canary' },
],
'Firefox browsers': [
{ name: 'Firefox', value: 'firefox' },
{ name: 'Firefox Dev', value: 'firefox:dev' },
{ name: 'Firefox Nightly', value: 'firefox:nightly' },
],
'Webkit browsers (experimental)': [{ name: 'Webkit', value: 'webkit' }],
}
const settingschoices = [
'Set cypress dashboard credentials',
'Set project record key',
'Set specs/tests to run',
'Set environment variables',
'Set target browsers',
'Set parallel threads count',
'Save current settings as preset',
'Reset defaults',
'Reset Parallel CLI',
'Back',
]
const settingsprompt = () => {
resetcli()
inquirer
.prompt({
type: 'list',
name: 'settings',
message: 'Select which setting do you want to modify.',
choices: settingschoices,
})
.then(({ settings }) => {
switch (settings) {
case settingschoices[0]:
inquirer
.prompt({
type: 'input',
name: 'dashboardcreds',
message: 'Write your cypress dashboard credentials here (format -> username:password):',
default: DASHBOARDCREDS,
})
.then(({ dashboardcreds }) => {
config.set('dashboardcreds', dashboardcreds)
setvars()
settingsprompt()
})
break
case settingschoices[1]:
// allow setting of project record key, without the recordkey parallel would not work
// this mimics real cypress parallelization, by providing threads and using dashboard to control the flow
inquirer
.prompt({
type: 'input',
name: 'recordkey',
message: 'Write your project record key here:',
default: RECORDKEY,
validate(answer) {
// record key is a uuid version 4 format
if (
answer &&
!/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi.test(
answer
)
)
return 'You provided an invalid record key. Record key should match uuid format.'
return true
},
})
.then(({ recordkey }) => {
config.set('recordkey', recordkey)
setvars()
settingsprompt()
// since settings were changed, show custom preset
PRESET = 'CUSTOM'
})
break
case settingschoices[2]:
// get suites under cypress/e2e
const suites = globSync(`${resolve(__dirname, DEFAULT_CYPRESS_DIR, DEFAULT_SPECS_DIR)}/**`, {
withFileTypes: true,
windowsPathsNoEscape: true,
})
.filter((g) => g.isDirectory())
.map((g) => g.fullpath().replace(resolve(__dirname, DEFAULT_CYPRESS_DIR), '').substring(1))
// generate choices, currently allowing suites/folder choices
const suitechoices = suites.map((suite) => {
return { name: suite, value: suite, checked: SPECS.includes(suite) }
})
// current implementation: allows selection of folders in cypress/e2e
// then each cypress test (1 level) in that folder will be executed
// TODO: allow selection of nested (recursive), multiple spec files
inquirer
.prompt({
type: 'checkbox',
message: 'Select which suites to run',
name: 'specs',
choices: suitechoices,
validate(answer) {
if (answer.length < 1) return 'You must choose at least one suite'
return true
},
})
.then(({ specs }) => {
config.set('specs', specs)
setvars()
settingsprompt()
PRESET = 'CUSTOM'
})
break
case settingschoices[3]:
inquirer
.prompt({
type: 'input',
name: 'envvars',
message: 'Set cypress environment variables (e.g. configFile=qa)',
default: ENVVARS,
})
.then(({ envvars }) => {
config.set('envvars', envvars)
setvars()
settingsprompt()
PRESET = 'CUSTOM'
})
break
case settingschoices[4]:
inquirer
.prompt({
type: 'checkbox',
message: 'Select which browsers to run:',
name: 'browsers',
// build choices using "browserlist"
choices: Object.entries(browserlist).reduce((a, [k, v]) => {
a.push(new inquirer.Separator(k))
v.forEach(({ name, value, enabled }) => {
a.push({
name,
value,
checked: BROWSERS.includes(value),
disabled: enabled ? false : true,
})
})
return a
}, []),
validate(answer) {
if (answer.length < 1) return 'You must choose at least one browser'
return true
},
})
.then(({ browsers }) => {
config.set('browsers', browsers)
setvars()
settingsprompt()
PRESET = 'CUSTOM'
})
break
case settingschoices[5]:
inquirer
.prompt({
type: 'input',
name: 'parallel',
message: 'How many parallel runners do you want:',
default: PARALLEL || MAX_PARALLEL_ALLOWED,
validate(answer) {
if (isNaN(parseInt(answer))) return 'You must provide a number'
if (parseInt(answer) > MAX_PARALLEL_ALLOWED)
return `Current max parallel allowed is ${MAX_PARALLEL_ALLOWED}`
return true
},
})
.then(({ parallel }) => {
// parse to number and return absolute number value
config.set('parallel', Math.abs(parseInt(parallel)))
setvars()
settingsprompt()
PRESET = 'CUSTOM'
})
break
case settingschoices[6]:
inquirer
.prompt({
type: 'input',
name: 'preset',
message: 'What would you like to name this preset?\nThis will overwrite existing preset with same name',
default: PRESET,
validate(answer) {
if (answer.length < 3)
return 'Preset name should be longer than 3 characters, a little bit descriptive please'
if (!/^[A-Z\-]+$/i.test(answer))
return 'Preset name should be not contain any number or special characters other than -'
return true
},
})
.then(({ preset }) => {
// remove existing preset, will be overwritten by new settings
const existsindex = PRESETS.findIndex((p) => p.name === preset)
if (existsindex > -1) PRESETS.splice(existsindex, 1)
addnewpreset(preset)
settingsprompt()
})
break
case settingschoices[7]:
inquirer
.prompt({
type: 'confirm',
name: 'reset',
message: 'Are you sure you want to reset cli settings?',
default: false,
})
.then(({ reset }) => {
if (reset) resetvars()
else settingsprompt()
})
break
case settingschoices[8]:
inquirer
.prompt({
type: 'confirm',
name: 'reset',
message: 'Are you sure you want to reset cli app? This is equivalent to uninstalling the cli app',
default: false,
})
.then(({ reset }) => {
if (reset) {
console.log(chalk.cyanBright('Removing parallel-cli-results folder'))
if (existsSync(DEFAULT_REPORTER_DIR_PATH))
rmSync(DEFAULT_REPORTER_DIR_PATH, { force: true, recursive: true })
console.log(chalk.cyanBright('Removing parallel-cli reporter'))
if (existsSync(resolve(__dirname, DEFAULT_REPORTER))) rmSync(resolve(__dirname, DEFAULT_REPORTER))
console.log(chalk.cyanBright('Cleaning configurations'))
config.clear()
console.log(chalk.greenBright('Parallel CLI reset successful, rerun cli app again to setup'))
process.exitCode = 1
} else settingsprompt()
})
break
default:
menuprompt()
}
})
}
// we do not want someone selecting a browser that is not available, so we control the availability
const getavailablebrowsers = () => {
// "cypress info" returns browsers installed in this machine, extracting it from logs
console.log(chalk.bold.greenBright(`Detecting available browsers using cypress info`))
// create a process and run "cypress info"
return new Promise((resolve) => {
const child = spawn('npx', ['npx cypress info'], {
cwd: process.cwd(),
stdio: 'pipe',
shell: true,
detached: false,
env: {
...process.env,
},
})
const browsers = []
// extracts data from: "1. Chrome\nName: chrome" -> "chrome"
const extractbrowserdetails = (data, browser, regex) =>
data.substring(data.indexOf(browser)).match(regex).pop().split(' ').pop().trim()
child.stdout.on('data', (data) => {
const datastring = data.toString()
// match any word that looks like this: "1. Chrome" or "2. Firefox"
for (const match of datastring.match(/\d\. \w+/g) || []) {
// we can receive multiple browser info from a log string so we iterate those matches
match.split(',').forEach((browser) => {
const name = browser.split('.').pop().trim()
const key = extractbrowserdetails(datastring, browser, /Name: \w+/)
const version = extractbrowserdetails(datastring, browser, /Version: [\d\.]+/)
// log our extracted browser so the user knows something is happening :D
const _browser = { name, key, version }
console.log(chalk.cyanBright(`Browser ${name} detected with version ${version}`))
browsers.push(_browser)
})
}
})
child.on('exit', () => {
// webkit browser is installed using a dependency called "playwright-webkit"
// https://docs.cypress.io/guides/guides/launching-browsers#WebKit-Experimental
console.log(chalk.bold.greenBright(`Detecting availability of webkit browser`))
// check packagejson for installed webkit dependency
const playrightwebkit =
(packagejson.dependencies || {})['playwright-webkit'] ||
(packagejson.devDependencies || {})['playwright-webkit']
if (playrightwebkit) {
const _browser = { name: 'Webkit', key: 'webkit', version: '' }
browsers.push(_browser)
console.log(
chalk.cyanBright(
`Playwright webkit (version ${playrightwebkit}) detected in package.json, ${_browser.name} enabled`
)
)
}
// electron ships with cypress, so yeah store that too
browsers.push({ name: 'Electron', key: 'electron', version: '' })
console.log(chalk.cyanBright(`Electron browser is enabled by default in cypress`))
console.log(chalk.greenBright('Browser detection done, setting registered browsers'))
// iterate found browsers and enable them in var: browserlist
for (const browser of browsers) {
// we do not know where this browser is so we iterate each browser category
for (const category of Object.keys(browserlist)) {
const { key, version } = browser
const match = browserlist[category].find((x) => x.value === key)
if (match) {
// adding browser version to its name, looks cool
match.name = `${match.name} ${version ? `(v${version})` : ''}`
match.enabled = true
}
}
}
resolve()
})
})
}
;(async () => {
// clearing console to remove config warnings
console.clear()
// move conf config file to cwd, and start using it as config
if (!existsSync(PARALLEL_CLI_CONFIG_PATH)) {
copyFileSync(config.path, PARALLEL_CLI_CONFIG_PATH)
console.log(chalk.cyanBright('We have moved parallel-cli config to project folder.'))
console.log(chalk.cyanBright('Restart the app to apply local config.'))
process.exitCode = 1
return
}
// catch RUN command for direct preset runs
const args = process.argv.slice(2)
if (args.length >= 1) {
setvars()
if (args[1] && !PRESETS.find((p) => p.name === args[1]))
throw new Error(`Preset "${args[1]}" was not found in cli presets`)
else {
PRESET = args[1]
loadpreset()
}
if (args[0] === 'run') {
if (!RECORDKEY) throw new Error(`Record key is not set, unable to run tests with recording enabled`)
await runtest()
// disable when exit is triggered
// TODO: find a better solution for this
if (EXITSIGNAL) return
} else if (args[0] === 'run-norecord') {
// remove RECORDKEY before running tests to disable recording
const _RECORDKEY = RECORDKEY
RECORDKEY = ''
await runtest()
RECORDKEY = _RECORDKEY
} else throw new Error(`Unknown cli command "${args[0]}"`)
showcliresultstable()
console.log() // push a newline to logs for aesthetic
return
}
// setup parallel-cli defaults and cli reporter
if (!config.get('init')) {
console.log(chalk.bold.greenBright(`Initialising parallel-cli configuration`))
resetvars()
setvars()
await getavailablebrowsers()
// include parallel-cli files to gitignore
console.log(chalk.greenBright(`Updating .gitignore to include parallel-cli files`))
const gitignorepath = resolve(__dirname, '.gitignore')
const ignoreparallelcli = '# parallel-cli\nparallel-cli-results\n'
if (existsSync(gitignorepath)) {
console.log(chalk.cyanBright('.gitignore found, adding parallel-cli to gitignore now'))
const gitignore = readFileSync(resolve(__dirname, '.gitignore'), 'utf-8')
if (!gitignore.includes('# parallel-cli'))
writeFileSync(gitignorepath, `${gitignore}\n${ignoreparallelcli}`, 'utf-8')
} else {
console.log(chalk.cyanBright('.gitignore not found, creating one now'))
writeFileSync(gitignorepath, ignoreparallelcli, 'utf-8')
}
config.set('init', true)
console.log(chalk.greenBright(`Parallel-cli has been initialised successfully`))
} else {
setvars()
loadpreset()
await getavailablebrowsers()
}
// a little bit of delay before starting cli app
await sleep(1000)
menuprompt()
})()