feat: integrate the create-wdio package (#14618)

* chore: create sub-package of `create-wdio`

* docs: copy and adjustment for changing repository

* chore: copy a code of create-wdio

* chore: update compiler plugin

* chore: adjust npm script

* chore(wdio-cli): delete the code copied to `create-wdio`

* chore: remove compiler plugin for @wdio/cli

* docs: update root readme

* chore: update package.json

* style: apply format rule

* fix: remove unnecessary export `installPackages`

* test: remove unnecessary mocking in wdio-cli

---------

Co-authored-by: Christian Bromann <git@bromann.dev>
This commit is contained in:
mato533 2025-07-09 08:13:28 +09:00 committed by GitHub
parent 6464dc7d4a
commit 3f1e7ef93a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
98 changed files with 4646 additions and 2702 deletions

View file

@ -134,6 +134,7 @@ __Did you build a WebdriverIO service or reporter?__ That's awesome! Please add
### Others
- [create-wdio](https://github.com/webdriverio/webdriverio/tree/main/packages/create-wdio) - A CLI and utility to install and setup a WebdriverIO
- [eslint-plugin-wdio](https://github.com/webdriverio/webdriverio/tree/main/packages/eslint-plugin-wdio) - Eslint rules for WebdriverIO
- [@wdio/smoke-test-reporter](https://github.com/webdriverio/webdriverio/tree/main/packages/wdio-smoke-test-reporter) - A WebdriverIO utility to smoke test reporters for internal testing purposes
- [@wdio/smoke-test-service](https://github.com/webdriverio/webdriverio/tree/main/packages/wdio-smoke-test-service) - A WebdriverIO utility to smoke test services for internal testing purposes

19
__mocks__/commander.ts Normal file
View file

@ -0,0 +1,19 @@
import { vi } from 'vitest'
import * as path from 'node:path'
const command: Record<string, unknown> = {}
command.name = vi.fn().mockReturnValue(command)
command.version = vi.fn().mockReturnValue(command)
command.usage = vi.fn().mockReturnValue(command)
command.arguments = vi.fn().mockReturnValue(command)
command.action = vi.fn((cb) => {
cb(`${path.sep}foo${path.sep}bar${path.sep}someProjectName`)
return command
})
command.option = vi.fn().mockReturnValue(command)
command.allowUnknownOption = vi.fn().mockReturnValue(command)
command.on = vi.fn().mockReturnValue(command)
command.parse = vi.fn().mockReturnValue(command)
command.opts = vi.fn().mockReturnValue('foobar')
export const Command = vi.fn().mockReturnValue(command)

View file

@ -60,7 +60,7 @@ const packages = (
*/
const esmPlugins: Record<string, Plugin[]> = {
'@wdio/protocols': [generateTypes()],
'@wdio/cli': [copyEJSTemplates()],
'create-wdio': [copyEJSTemplates('create-wdio')],
'webdriverio': [externalScripts()]
}
/**

View file

@ -109,11 +109,12 @@ export function generateDts(absWorkingDir: string, pkg: PackageJson): Plugin {
}
/**
* Plugin to copy EJS templates from the `@wdio/cli` package to build directory
* Plugin to copy EJS templates from specified package to build directory
* @param {string} pkgName Name of the package to copy
* @returns {Plugin} an Esbuild plugin
*/
export function copyEJSTemplates() {
const cliPackage = path.resolve(__dirname, '..', '..', '..', 'packages', 'wdio-cli')
export function copyEJSTemplates(pkgName: string): Plugin {
const cliPackage = path.resolve(__dirname, '..', '..', '..', 'packages', pkgName)
return copy({
resolveFrom: cliPackage,
assets: [{

View file

@ -35,7 +35,7 @@
"test": "run-s test:eslint test:ejslint test:typings test:unit:run test:smoke test:component",
"test:depcheck": "tsx ./scripts/depcheck.ts",
"test:eslint": "eslint --cache packages examples scripts tests",
"test:ejslint": "ejslint packages/wdio-cli/src/templates/**/*",
"test:ejslint": "ejslint packages/wdio-cli/src/templates/**/* packages/create-wdio/src/templates/**/*",
"test:interop": "pnpm -r --filter=@wdio/test-interop-cjs run test",
"test:typings": "pnpm install && run-p test:typings:*",
"test:typings:webdriver": "cd tests/typings/webdriver && npx tsc --skipLibCheck",

View file

@ -0,0 +1,4 @@
src
tests
tsconfig.json
tsconfig.prod.json

View file

@ -0,0 +1,20 @@
Copyright (c) OpenJS Foundation and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,69 @@
WebdriverIO Starter Toolkit [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-green.svg)](https://github.com/webdriverio/webdriverio/blob/main/CONTRIBUTING.md)
===========================
<img alt="Logo" align="right" src="https://webdriver.io/assets/images/robot-3677788dd63849c56aa5cb3f332b12d5.svg" width="20%" />
One command to create a fresh WebdriverIO project or add WebdriverIO to an existing project.
- [Get Started Guide](https://webdriver.io/docs/gettingstarted) - How to get started with WebdriverIO
- [Supported Options](#supported-options) - command line parameters
`create-wdio` works on macOS, Windows, and Linux.<br>
If something doesn’t work, please [file an issue](https://github.com/webdriverio/webdriverio/issues/new).<br>
If you have questions or need help, please ask in our [Discord Support channel](https://discord.webdriver.io).
<p align="center">
<img src="https://raw.githubusercontent.com/webdriverio/webdriverio/main/packages/create-wdio/assets/demo.gif" alt="Example" />
</p>
## Usage
To install a WebdriverIO project, you may choose one of the following methods:
#### npx
```sh
npx create-wdio@latest ./e2e
```
_[`npx`](https://medium.com/@maybekatz/introducing-npx-an-npm-package-runner-55f7d4bd282b) is a package runner tool that comes with npm 5.2+ and higher, see [instructions for older npm versions](https://gist.github.com/gaearon/4064d3c23a77c74a3614c498a8bb1c5f)_
#### npm
```sh
npm init wdio@latest ./e2e
```
_[`npm init <initializer>`](https://docs.npmjs.com/cli/v10/commands/npm-init) is available in npm 6+_
#### yarn
```sh
yarn create wdio@latest ./e2e
```
_[`yarn create <starter-kit-package>`](https://yarnpkg.com/lang/en/docs/cli/create/) is available in Yarn 0.25+_
#### pnpm
```sh
pnpm create wdio ./e2e
```
_[`pnpm create <starter-kit-package>`](https://pnpm.io/cli/create) is available in pnpm v7+_
It will create a directory called `e2e` inside the current folder.
Then it will run the configuration wizard that will help you set-up your framework.
## Supported Options
You can pass the following command line flags to modify the bootstrap mechanism:
* `--dev` - Install all packages as `devDependencies` (default: `true`)
* `--yes` - Will fill in all config defaults without prompting (default: `false`)
* `--npm-tag` - use a specific NPM tag for `@wdio/cli` package (default: `latest`)
----
For more information on WebdriverIO see the [homepage](https://webdriver.io).

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,005 KiB

View file

@ -0,0 +1,9 @@
#!/usr/bin/env node
/**
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { run } from '../build/index.js'
run()

View file

@ -0,0 +1,80 @@
{
"name": "create-wdio",
"version": "9.0.0",
"description": "Install and setup a WebdriverIO project with all its dependencies in a single run",
"author": "WebdriverIO Team <mail@webdriver.io>",
"homepage": "https://github.com/webdriverio/webdriverio/tree/main/packages/create-wdio",
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./build/index.d.ts",
"import": "./build/index.js",
"importSource": "./src/index.ts"
},
"./config/cli": {
"types": "./build/config/cli.d.ts",
"import": "./build/config/cli.js",
"importSource": "./src/config/cli.ts"
},
"./install/cli": {
"types": "./build/install/cli.d.ts",
"import": "./build/install/cli.js",
"importSource": "./src/install/cli.ts"
},
"./utils": {
"types": "./build/utils/index.d.ts",
"import": "./build/utils/index.js",
"importSource": "./src/utils/index.ts"
}
},
"types": "./build/index.d.ts",
"typeScriptVersion": "3.8.3",
"engines": {
"node": ">=12.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/webdriverio/webdriverio.git",
"directory": "packages/create-wdio"
},
"keywords": [
"webdriverio",
"create-wdio",
"wdio",
"installer",
"e2e"
],
"bugs": {
"url": "https://github.com/webdriverio/webdriverio/issues"
},
"bin": {
"create-wdio": "./bin/wdio.js"
},
"dependencies": {
"chalk": "^5.3.0",
"commander": "^14.0.0",
"cross-spawn": "^7.0.3",
"ejs": "^3.1.10",
"execa": "^9.6.0",
"import-meta-resolve": "^4.1.0",
"inquirer": "^12.7.0",
"normalize-package-data": "^7.0.0",
"read-pkg-up": "^10.1.0",
"recursive-readdir": "^2.2.3",
"semver": "^7.6.3",
"type-fest": "^4.41.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/cross-spawn": "^6.0.6",
"@types/ejs": "^3.1.5",
"@types/fs-extra": "^11.0.4",
"@types/normalize-package-data": "^2.4.4",
"@types/semver": "^7.5.8",
"@types/yargs": "^17.0.33"
},
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,61 @@
import fss from 'node:fs'
import type { Argv } from 'yargs'
import { CLI_EPILOGUE
} from '../constants.js'
import {
parseAnswers,
runConfigCommand,
} from './utils.js'
import type { ConfigCommandArguments } from '../types.js'
let hasYarnLock = false
try {
fss.accessSync('yarn.lock')
hasYarnLock = true
} catch {
hasYarnLock = false
}
export const command = 'config'
export const desc = 'Initialize WebdriverIO and setup configuration in your current project.'
export const cmdArgs = {
yarn: {
type: 'boolean',
desc: 'Install packages via Yarn package manager.',
default: hasYarnLock
},
yes: {
alias: 'y',
desc: 'will fill in all config defaults without prompting',
type: 'boolean',
default: false
},
npmTag: {
alias: 't',
desc: 'define NPM tag to use for WebdriverIO related packages',
type: 'string',
default: 'latest'
}
} as const
export const builder = (yargs: Argv) => {
return yargs
.options(cmdArgs)
.epilogue(CLI_EPILOGUE)
.help()
}
export async function handler(argv: ConfigCommandArguments, runConfigCmd = runConfigCommand) {
const parsedAnswers = await parseAnswers(argv.yes)
await runConfigCmd(parsedAnswers, argv.npmTag)
return {
success: true,
parsedAnswers,
installedPackages: parsedAnswers.packagesToInstall.map((pkg) => pkg.split('--')[0])
}
}
export { missingConfigurationPrompt, canAccessConfigPath } from './utils.js'
export { formatConfigFilePaths } from '../utils.js'

View file

@ -1,4 +1,3 @@
import fs from 'node:fs/promises'
import path from 'node:path'
@ -10,12 +9,14 @@ import {
findInConfig,
addServiceDeps,
convertPackageHashToObject,
formatConfigFilePaths,
detectPackageManager
} from '../utils.js'
import { installPackages } from '../install.js'
import { formatConfigFilePaths, canAccessConfigPath, missingConfigurationPrompt } from './config.js'
import { missingConfigurationPrompt } from './config.js'
import { SUPPORTED_PACKAGES, CLI_EPILOGUE } from '../constants.js'
import type { InstallCommandArguments, SupportedPackage } from '../types.js'
import { canAccessConfigPath } from './utils.js'
const supportedInstallations = {
runner: SUPPORTED_PACKAGES.runner.map(({ value }) => convertPackageHashToObject(value)),
@ -65,8 +66,10 @@ export async function handler(argv: InstallCommandArguments) {
*/
if (!Object.keys(supportedInstallations).includes(type)) {
console.log(`Type ${type} is not supported.`)
if (process.env.WDIO_UNIT_TESTS) {
return
}
process.exit(0)
return
}
/**
@ -79,9 +82,10 @@ export async function handler(argv: InstallCommandArguments) {
`Available options for a ${type} are:\n` +
`- ${options.join('\n- ')}`
)
if (process.env.WDIO_UNIT_TESTS) {
return
}
process.exit(0)
// keep return for unit test purposes
return
}
const defaultPath = path.resolve(process.cwd(), 'wdio.conf')
@ -95,8 +99,6 @@ export async function handler(argv: InstallCommandArguments) {
return handler(argv)
} catch {
process.exit(1)
// keep return for unit test purposes
return
}
}
@ -107,8 +109,6 @@ export async function handler(argv: InstallCommandArguments) {
if (match && match[0].includes(name)) {
console.log(`The ${type} ${name} is already part of your configuration.`)
process.exit(0)
// keep return for unit test purposes
return
}
const selectedPackage = supportedInstallations[type].find(({ short }) => short === name) as SupportedPackage
@ -122,8 +122,6 @@ export async function handler(argv: InstallCommandArguments) {
if (!success) {
process.exit(1)
// keep return for unit test purposes
return
}
console.log(`Package "${selectedPackage.package}" installed successfully.`)

View file

@ -1,57 +1,70 @@
import fs from 'node:fs/promises'
import fss from 'node:fs'
import path from 'node:path'
import fs from 'node:fs/promises'
import inquirer from 'inquirer'
import type { Argv } from 'yargs'
import { configHelperSuccessMessage, CONFIG_HELPER_SERENITY_BANNER, SUPPORTED_CONFIG_FILE_EXTENSION, CONFIG_HELPER_INTRO, isNuxtProject, SUPPORTED_PACKAGES } from '../constants.js'
import type { ParsedAnswers } from '../types.js'
import { createPackageJSON, setupTypeScript, npmInstall, createWDIOConfig, createWDIOScript, runAppiumInstaller, convertPackageHashToObject, getAnswers, getPathForFileGeneration, getProjectProps, getProjectRoot, getSerenityPackages } from '../utils.js'
import {
CONFIG_HELPER_INTRO, CLI_EPILOGUE, SUPPORTED_PACKAGES,
configHelperSuccessMessage, isNuxtProject, SUPPORTED_CONFIG_FILE_EXTENSION, CONFIG_HELPER_SERENITY_BANNER,
} from '../constants.js'
import {
convertPackageHashToObject, getAnswers, getPathForFileGeneration, getProjectProps,
getProjectRoot, createPackageJSON, setupTypeScript, npmInstall,
createWDIOConfig, createWDIOScript, runAppiumInstaller, getSerenityPackages
} from '../utils.js'
import type { ConfigCommandArguments, ParsedAnswers } from '../types.js'
export async function runConfigCommand(parsedAnswers: ParsedAnswers, npmTag: string) {
console.log('\n')
let hasYarnLock = false
try {
fss.accessSync('yarn.lock')
hasYarnLock = true
} catch {
hasYarnLock = false
await createPackageJSON(parsedAnswers)
await setupTypeScript(parsedAnswers)
await npmInstall(parsedAnswers, npmTag)
await createWDIOConfig(parsedAnswers)
await createWDIOScript(parsedAnswers)
/**
* print success message
*/
console.log(
configHelperSuccessMessage({
projectRootDir: parsedAnswers.projectRootDir,
runScript: parsedAnswers.serenityAdapter ? 'serenity' : 'wdio',
extraInfo: parsedAnswers.serenityAdapter ? CONFIG_HELPER_SERENITY_BANNER : ''
}),
)
await runAppiumInstaller(parsedAnswers)
}
export const command = 'config'
export const desc = 'Initialize WebdriverIO and setup configuration in your current project.'
/**
* Helper utility used in `run` and `install` command to create config if none exist
* @param {string} command to be executed by user
* @param {string} configPath the path to a wdio.conf.[js/ts] file
* @param {Function} runConfigCmd runConfig method to be replaceable for unit testing
*/
export async function missingConfigurationPrompt(command: string, configPath: string, runConfigCmd: Function = runConfigCommand) {
const message = (
`Could not execute "${command}" due to missing configuration, file ` +
`"${path.parse(configPath).name}[.js/.ts]" not found! ` +
'Would you like to create one?'
)
export const cmdArgs = {
yarn: {
type: 'boolean',
desc: 'Install packages via Yarn package manager.',
default: hasYarnLock
},
yes: {
alias: 'y',
desc: 'will fill in all config defaults without prompting',
type: 'boolean',
const { config } = await inquirer.prompt({
type: 'confirm',
name: 'config',
message,
default: false
},
npmTag: {
alias: 't',
desc: 'define NPM tag to use for WebdriverIO related packages',
type: 'string',
default: 'latest'
}
} as const
})
export const builder = (yargs: Argv) => {
return yargs
.options(cmdArgs)
.epilogue(CLI_EPILOGUE)
.help()
/**
* don't exit if running unit tests
*/
if (!config) {
/* istanbul ignore next */
console.log(`No WebdriverIO configuration found in "${process.cwd()}"`)
/* istanbul ignore next */
if (!process.env.WDIO_UNIT_TESTS) {
process.exit(0)
}
return configPath
}
const parsedAnswers = await parseAnswers(false)
await runConfigCmd(parsedAnswers, 'latest')
return configPath
}
export const parseAnswers = async function (yes: boolean): Promise<ParsedAnswers> {
@ -145,59 +158,13 @@ export const parseAnswers = async function (yes: boolean): Promise<ParsedAnswers
}
}
export async function runConfigCommand(parsedAnswers: ParsedAnswers, npmTag: string) {
console.log('\n')
await createPackageJSON(parsedAnswers)
await setupTypeScript(parsedAnswers)
await npmInstall(parsedAnswers, npmTag)
await createWDIOConfig(parsedAnswers)
await createWDIOScript(parsedAnswers)
/**
* print success message
*/
console.log(
configHelperSuccessMessage({
projectRootDir: parsedAnswers.projectRootDir,
runScript: parsedAnswers.serenityAdapter ? 'serenity' : 'wdio',
extraInfo: parsedAnswers.serenityAdapter ? CONFIG_HELPER_SERENITY_BANNER : ''
}),
)
await runAppiumInstaller(parsedAnswers)
}
export async function handler(argv: ConfigCommandArguments, runConfigCmd = runConfigCommand) {
const parsedAnswers = await parseAnswers(argv.yes)
await runConfigCmd(parsedAnswers, argv.npmTag)
return {
success: true,
parsedAnswers,
installedPackages: parsedAnswers.packagesToInstall.map((pkg) => pkg.split('--')[0])
}
}
/**
* Helper utility used in `run` and `install` command to format a provided config path,
* giving it back as an absolute path, and a version without the file extension
* @param config the initially given file path to the WDIO config file
*/
export async function formatConfigFilePaths(config: string) {
const fullPath = path.isAbsolute(config)
? config
: path.join(process.cwd(), config)
const fullPathNoExtension = fullPath.substring(0, fullPath.lastIndexOf(path.extname(fullPath)))
return { fullPath, fullPathNoExtension }
}
/**
* Helper utility used in `run` and `install` command to check whether a config file currently exists
* @param configPathNoExtension the file path to the WDIO config file without extension
* @param configPath the file path to the WDIO config file that is checked first if set
* @returns {string} the path to the config file that exists, otherwise undefined
*/
export async function canAccessConfigPath(configPathNoExtension:string, configPath?:string) {
export async function canAccessConfigPath(configPathNoExtension:string, configPath?:string): Promise<string|undefined> {
return new Promise<string | undefined>((resolve, reject)=>{
if (configPath) {
fs.access(configPath).then(()=>resolve(configPath), reject)
@ -213,42 +180,3 @@ export async function canAccessConfigPath(configPathNoExtension:string, configPa
))
}
/**
* Helper utility used in `run` and `install` command to create config if none exist
* @param {string} command to be executed by user
* @param {string} configPath the path to a wdio.conf.[js/ts] file
* @param {boolean} useYarn parameter set to true if yarn is used
* @param {Function} runConfigCmd runConfig method to be replaceable for unit testing
*/
export async function missingConfigurationPrompt(command: string, configPath: string, runConfigCmd = runConfigCommand) {
const message = (
`Could not execute "${command}" due to missing configuration, file ` +
`"${path.parse(configPath).name}[.js/.ts]" not found! ` +
'Would you like to create one?'
)
const { config } = await inquirer.prompt({
type: 'confirm',
name: 'config',
message,
default: false
})
/**
* don't exit if running unit tests
*/
if (!config) {
/* istanbul ignore next */
console.log(`No WebdriverIO configuration found in "${process.cwd()}"`)
/* istanbul ignore next */
if (!process.env.WDIO_UNIT_TESTS) {
process.exit(0)
}
return configPath
}
const parsedAnswers = await parseAnswers(false)
await runConfigCmd(parsedAnswers, 'latest')
return configPath
}

View file

@ -0,0 +1 @@
export * as config from '../cli/config.js'

View file

@ -0,0 +1,737 @@
import { convertPackageHashToObject, detectPackageManager, getDefaultFiles, detectCompiler, getProjectRoot } from './utils.js'
import pkg from '../package.json' with { type:'json' }
import path from 'node:path'
import fs from 'node:fs'
import type { Questionnair } from './types.js'
import chalk from 'chalk'
export { pkg }
export const colorItBold = chalk.bold.rgb(234, 89, 6)
export const colorIt = chalk.rgb(234, 89, 6)
export type PACKAGE_MANAGER = 'npm' | 'pnpm' | 'yarn' | 'bun'
export const DEFAULT_NPM_TAG = 'latest'
export const ASCII_ROBOT = `
-:...........................-:.
+ +
\`\` + \`...\` \`...\` + \`
./+/ + .:://:::\` \`::///::\` \` + ++/.
.+oo+ + /:+ooo+-/ /-+ooo+-/ ./ + +oo+.
-ooo+ + /-+ooo+-/ /-+ooo+-/ .: + +ooo.
-+o+ + \`::///:-\` \`::///::\` + +o+-
\`\`. /. \`\`\`\`\` \`\`\`\`\` .: .\`\`
.----------------------------.
\`-::::::::::::::::::::::::::::::::::::::::-\`
.+oooo/:------------------------------:/oooo+.
\`.--/oooo- :oooo/--.\`
.::-\`\`:oooo\` .oooo-\`\`-::.
./-\` -oooo\`--.: :.-- .oooo- \`-/.
-/\` \`-/oooo////////////////////////////////////oooo/.\` \`/-
\`+\` \`/+oooooooooooooooooooooooooooooooooooooooooooooooo+:\` .+\`
-/ +o/.:oooooooooooooooooooooooooooooooooooooooooooo:-/o/ +.
-/ .o+ -oooosoooososssssooooo------------------:oooo- \`oo\` +.
-/ .o+ -oooodooohyyssosshoooo\` .oooo- oo. +.
-/ .o+ -oooodooysdooooooyyooo\` \`.--.\`\` .:::-oooo- oo. +.
-/ .o+ -oooodoyyodsoooooyyooo.//-..-:/:.\`.//.\`./oooo- oo. +.
-/ .o+ -oooohsyoooyysssysoooo+-\` \`-:::. .oooo- oo. +.
-/ .o+ -ooooosooooooosooooooo+//////////////////oooo- oo. +.
-/ .o+ -oooooooooooooooooooooooooooooooooooooooooooo- oo. +.
-/ .o+ -oooooooooooooooooooooooooooooooooooooooooooo- oo. +.
-+////o+\` -oooo---:///:----://::------------------:oooo- \`oo////+-
+ooooooo/\`-oooo\`\`:-\`\`\`.:\`.:.\`.+/- .::::::::::\` .oooo-\`+ooooooo+
oooooooo+\`-oooo\`-- \`/\` .:+ -/-\`/\` .:::::::::: .oooo-.+oooooooo
+-/+://-/ -oooo-\`:\`.o-\`:.:-\`\`\`\`.: .///:\`\`\`\`\`\` -oooo-\`/-//:+:-+
: :..--:-:.+ooo+/://o+/-.-:////:-....-::::-....--/+ooo+.:.:--.-- /
- /./\`-:-\` .:///+/ooooo/+///////////////+++ooooo/+///:. .-:.\`+./ :
:-:/. :\`ooooo\`/\` .:.ooooo : ./---
:\`ooooo\`/\` .:.ooooo :
:\`ooooo./\` .:-ooooo :
:\`ooooo./\` .:-ooooo :
\`...:-+++++:/. ./:+++++-:...\`
:-.\`\`\`\`\`\`\`\`/../ /.-:\`\`\`\`\`\`\`\`.:-
-/::::::::://:/+ \`+/:+::::::::::+.
:oooooooooooo++/ +++oooooooooooo-
`
export const PROGRAM_TITLE = `
${colorItBold('Webdriver.IO')}
${colorIt('Next-gen browser and mobile automation')}
${colorIt('test framework for Node.js')}
`
export const UNSUPPORTED_NODE_VERSION = (
'⚠️ Unsupported Node.js Version Error ⚠️\n' +
`You are using Node.js ${process.version} which is too old to be used with WebdriverIO.\n` +
'Please update to Node.js v20 to continue.\n'
)
export const INSTALL_COMMAND: Record<PACKAGE_MANAGER, string> = {
npm: 'install',
pnpm: 'add',
yarn: 'add',
bun: 'install'
} as const
export const EXECUTER: Record<PACKAGE_MANAGER, string> = {
npm: 'npx',
pnpm: 'pnpm',
yarn: 'yarn',
bun: 'bunx'
} as const
export const EXECUTE_COMMAND: Record<PACKAGE_MANAGER, string> = {
npm: '',
pnpm: 'exec',
yarn: 'exec',
bun: ''
} as const
export const DEV_FLAG: Record<PACKAGE_MANAGER, string> = {
npm: '--save-dev',
pnpm: '--save-dev',
yarn: '--dev',
bun: '--dev'
} as const
export const SUPPORTED_PACKAGE_MANAGERS = Object.keys(INSTALL_COMMAND) as PACKAGE_MANAGER[]
// transport from
export const CONFIG_HELPER_INTRO = `
===============================
🤖 WDIO Configuration Wizard 🧙
===============================
`
export const CLI_EPILOGUE = `Documentation: https://webdriver.io\n@wdio/cli (v${pkg.version})`
/**
* We have to use a string hash for value because InquirerJS default values do not work if we have
* objects as a `value` to be stored from the user's answers.
*/
export const SUPPORTED_PACKAGES = {
runner: [
{ name: 'E2E Testing - of Web or Mobile Applications', value: '@wdio/local-runner$--$local$--$e2e' },
{ name: 'Component or Unit Testing - in the browser\n > https://webdriver.io/docs/component-testing', value: '@wdio/browser-runner$--$browser$--$component' },
{ name: 'Desktop Testing - of Electron Applications\n > https://webdriver.io/docs/desktop-testing/electron', value: '@wdio/local-runner$--$local$--$electron' },
{ name: 'Desktop Testing - of MacOS Applications\n > https://webdriver.io/docs/desktop-testing/macos', value: '@wdio/local-runner$--$local$--$macos' },
{ name: 'VS Code Extension Testing\n > https://webdriver.io/docs/vscode-extension-testing', value: '@wdio/local-runner$--$local$--$vscode' },
{ name: 'Roku Testing - of OTT apps running on RokuOS\n > https://webdriver.io/docs/wdio-roku-service', value: '@wdio/local-runner$--$local$--$roku' }
],
framework: [
{ name: 'Mocha (https://mochajs.org/)', value: '@wdio/mocha-framework$--$mocha' },
{ name: 'Mocha with Serenity/JS (https://serenity-js.org/)', value: '@serenity-js/webdriverio$--$@serenity-js/webdriverio$--$mocha' },
{ name: 'Jasmine (https://jasmine.github.io/)', value: '@wdio/jasmine-framework$--$jasmine' },
{ name: 'Jasmine with Serenity/JS (https://serenity-js.org/)', value: '@serenity-js/webdriverio$--$@serenity-js/webdriverio$--$jasmine' },
{ name: 'Cucumber (https://cucumber.io/)', value: '@wdio/cucumber-framework$--$cucumber' },
{ name: 'Cucumber with Serenity/JS (https://serenity-js.org/)', value: '@serenity-js/webdriverio$--$@serenity-js/webdriverio$--$cucumber' },
],
reporter: [
{ name: 'spec', value: '@wdio/spec-reporter$--$spec', checked: true },
{ name: 'dot', value: '@wdio/dot-reporter$--$dot' },
{ name: 'junit', value: '@wdio/junit-reporter$--$junit' },
{ name: 'allure', value: '@wdio/allure-reporter$--$allure' },
{ name: 'sumologic', value: '@wdio/sumologic-reporter$--$sumologic' },
{ name: 'concise', value: '@wdio/concise-reporter$--$concise' },
{ name: 'json', value: '@wdio/json-reporter$--$json' },
// external
{ name: 'reportportal', value: 'wdio-reportportal-reporter$--$reportportal' },
{ name: 'video', value: 'wdio-video-reporter$--$video' },
{ name: 'cucumber-json', value: 'wdio-cucumberjs-json-reporter$--$cucumberjs-json' },
{ name: 'mochawesome', value: 'wdio-mochawesome-reporter$--$mochawesome' },
{ name: 'timeline', value: 'wdio-timeline-reporter$--$timeline' },
{ name: 'html-nice', value: 'wdio-html-nice-reporter$--$html-nice' },
{ name: 'slack', value: '@moroo/wdio-slack-reporter$--$slack' },
{ name: 'teamcity', value: 'wdio-teamcity-reporter$--$teamcity' },
{ name: 'delta', value: '@delta-reporter/wdio-delta-reporter-service$--$delta' },
{ name: 'testrail', value: '@wdio/testrail-reporter$--$testrail' },
{ name: 'light', value: 'wdio-light-reporter$--$light' },
{ name: 'wdio-json-html-reporter', value: 'wdio-json-html-reporter$--$jsonhtml' }
],
plugin: [
{ name: 'wait-for: utilities that provide functionalities to wait for certain conditions till a defined task is complete.\n > https://www.npmjs.com/package/wdio-wait-for', value: 'wdio-wait-for$--$wait-for' },
{ name: 'angular-component-harnesses: support for Angular component test harnesses\n > https://www.npmjs.com/package/@badisi/wdio-harness', value: '@badisi/wdio-harness$--$harness' },
{ name: 'Testing Library: utilities that encourage good testing practices laid down by dom-testing-library.\n > https://testing-library.com/docs/webdriverio-testing-library/intro', value: '@testing-library/webdriverio$--$testing-library' }
],
service: [
// internal or community driver services
{ name: 'visual', value: '@wdio/visual-service$--$visual' },
{ name: 'vite', value: 'wdio-vite-service$--$vite' },
{ name: 'nuxt', value: 'wdio-nuxt-service$--$nuxt' },
{ name: 'firefox-profile', value: '@wdio/firefox-profile-service$--$firefox-profile' },
{ name: 'gmail', value: 'wdio-gmail-service$--$gmail' },
{ name: 'sauce', value: '@wdio/sauce-service$--$sauce' },
{ name: 'testingbot', value: '@wdio/testingbot-service$--$testingbot' },
{ name: 'browserstack', value: '@wdio/browserstack-service$--$browserstack' },
{ name: 'lighthouse', value: '@wdio/lighthouse-service$--$lighthouse' },
{ name: 'vscode', value: 'wdio-vscode-service$--$vscode' },
{ name: 'electron', value: 'wdio-electron-service$--$electron' },
{ name: 'appium', value: '@wdio/appium-service$--$appium' },
// external
{ name: 'eslinter-service', value: 'wdio-eslinter-service$--$eslinter' },
{ name: 'lambdatest', value: 'wdio-lambdatest-service$--$lambdatest' },
{ name: 'zafira-listener', value: 'wdio-zafira-listener-service$--$zafira-listener' },
{ name: 'reportportal', value: 'wdio-reportportal-service$--$reportportal' },
{ name: 'docker', value: 'wdio-docker-service$--$docker' },
{ name: 'ui5', value: 'wdio-ui5-service$--$ui5' },
{ name: 'wiremock', value: 'wdio-wiremock-service$--$wiremock' },
{ name: 'ng-apimock', value: 'wdio-ng-apimock-service$--$ng-apimock' },
{ name: 'slack', value: 'wdio-slack-service$--$slack' },
{ name: 'cucumber-viewport-logger', value: 'wdio-cucumber-viewport-logger-service$--$cucumber-viewport-logger' },
{ name: 'intercept', value: 'wdio-intercept-service$--$intercept' },
{ name: 'docker', value: 'wdio-docker-service$--$docker' },
{ name: 'novus-visual-regression', value: 'wdio-novus-visual-regression-service$--$novus-visual-regression' },
{ name: 'rerun', value: 'wdio-rerun-service$--$rerun' },
{ name: 'winappdriver', value: 'wdio-winappdriver-service$--$winappdriver' },
{ name: 'ywinappdriver', value: 'wdio-ywinappdriver-service$--$ywinappdriver' },
{ name: 'performancetotal', value: 'wdio-performancetotal-service$--$performancetotal' },
{ name: 'cleanuptotal', value: 'wdio-cleanuptotal-service$--$cleanuptotal' },
{ name: 'aws-device-farm', value: 'wdio-aws-device-farm-service$--$aws-device-farm' },
{ name: 'ms-teams', value: 'wdio-ms-teams-service$--$ms-teams' },
{ name: 'tesults', value: 'wdio-tesults-service$--$tesults' },
{ name: 'azure-devops', value: '@gmangiapelo/wdio-azure-devops-service$--$azure-devops' },
{ name: 'google-Chat', value: 'wdio-google-chat-service$--$google-chat' },
{ name: 'qmate-service', value: '@sap_oss/wdio-qmate-service$--$qmate-service' },
{ name: 'robonut', value: 'wdio-robonut-service$--$robonut' },
{ name: 'qunit', value: 'wdio-qunit-service$--$qunit' },
{ name: 'roku', value: 'wdio-roku-service$--$roku' },
{ name: 'obsidian', value: 'wdio-obsidian-service$--$obsidian' }
]
}
export const configHelperSuccessMessage = ({ projectRootDir, runScript, extraInfo = '' }: { projectRootDir: string, runScript: string, extraInfo: string }) => `
🤖 Successfully setup project at ${ projectRootDir } 🎉
Join our Discord Community Server and instantly find answers to your issues or queries. Or just join and say hi 👋!
🔗 https://discord.webdriver.io
Visit the project on GitHub to report bugs 🐛 or raise feature requests 💡:
🔗 https://github.com/webdriverio/webdriverio
${ extraInfo }
To run your tests, execute:
$ cd ${ projectRootDir }
$ npm run ${ runScript }
`
export const isNuxtProject = [
path.join(process.cwd(), 'nuxt.config.js'),
path.join(process.cwd(), 'nuxt.config.ts'),
path.join(process.cwd(), 'nuxt.config.mjs'),
path.join(process.cwd(), 'nuxt.config.mts')
].map((p) => {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}).some(Boolean)
export const SUPPORTED_CONFIG_FILE_EXTENSION = ['js', 'ts', 'mjs', 'mts', 'cjs', 'cts']
export const CONFIG_HELPER_SERENITY_BANNER = `
Learn more about Serenity/JS:
🔗 https://serenity-js.org/
🔗 https://serenity-js.org/handbook/test-runners/webdriverio/
`
export function usesSerenity (answers: Questionnair) {
return answers.framework.includes('serenity-js')
}
enum ProtocolOptions {
HTTPS = 'https',
HTTP = 'http'
}
export enum BackendChoice {
Local = 'On my local machine',
Experitest = 'In the cloud using Experitest',
Saucelabs = 'In the cloud using Sauce Labs',
Browserstack = 'In the cloud using BrowserStack',
OtherVendors = 'In the cloud using Testingbot or LambdaTest or a different service',
Grid = 'I have my own Selenium cloud'
}
export enum RegionOptions {
US = 'us',
EU = 'eu'
}
export enum ElectronBuildToolChoice {
ElectronForge = 'Electron Forge (https://www.electronforge.io/)',
ElectronBuilder = 'electron-builder (https://www.electron.build/)',
SomethingElse = 'Something else'
}
export const SUPPORTED_BROWSER_RUNNER_PRESETS = [
{ name: 'Lit (https://lit.dev/)', value: '$--$' },
{ name: 'Vue.js (https://vuejs.org/)', value: '@vitejs/plugin-vue$--$vue' },
{ name: 'Svelte (https://svelte.dev/)', value: '@sveltejs/vite-plugin-svelte$--$svelte' },
{ name: 'SolidJS (https://www.solidjs.com/)', value: 'vite-plugin-solid$--$solid' },
{ name: 'StencilJS (https://stenciljs.com/)', value: '$--$stencil' },
{ name: 'React (https://reactjs.org/)', value: '@vitejs/plugin-react$--$react' },
{ name: 'Preact (https://preactjs.com/)', value: '@preact/preset-vite$--$preact' },
{ name: 'Other', value: null }
]
function isBrowserRunner (answers: Questionnair) {
return answers.runner === SUPPORTED_PACKAGES.runner[1].value
}
function getTestingPurpose (answers: Questionnair) {
return convertPackageHashToObject(answers.runner).purpose as 'e2e' | 'electron' | 'component' | 'vscode' | 'macos' | 'roku'
}
export const TESTING_LIBRARY_PACKAGES: Record<string, string> = {
react: '@testing-library/react',
preact: '@testing-library/preact',
vue: '@testing-library/vue',
svelte: '@testing-library/svelte',
solid: 'solid-testing-library'
}
export const E2E_ENVIRONMENTS = [
{ name: 'Web - web applications in the browser', value: 'web' },
{ name: 'Mobile - native, hybrid and mobile web apps, on Android or iOS', value: 'mobile' }
]
export const MOBILE_ENVIRONMENTS = [
{ name: 'Android - native, hybrid and mobile web apps, tested on emulators and real devices\n > using UiAutomator2 (https://www.npmjs.com/package/appium-uiautomator2-driver)', value: 'android' },
{ name: 'iOS - applications on iOS, iPadOS, and tvOS\n > using XCTest (https://appium.github.io/appium-xcuitest-driver)', value: 'ios' }
]
export const BROWSER_ENVIRONMENTS = [
{ name: 'Chrome', value: 'chrome', checked: true },
{ name: 'Firefox', value: 'firefox' },
{ name: 'Safari', value: 'safari' },
{ name: 'Microsoft Edge', value: 'MicrosoftEdge' }
]
function selectDefaultService (serviceNames: string | string[]) {
serviceNames = Array.isArray(serviceNames) ? serviceNames : [serviceNames]
return SUPPORTED_PACKAGES.service
/* istanbul ignore next */
.filter(({ name }) => serviceNames.includes(name))
.map(({ value }) => value)
}
function prioServiceOrderFor (serviceNamesParam: string | string[]) {
const serviceNames = Array.isArray(serviceNamesParam) ? serviceNamesParam : [serviceNamesParam]
let services = SUPPORTED_PACKAGES.service
for (const serviceName of serviceNames) {
const index = services.findIndex(({ name }) => name === serviceName)
services = [services[index], ...services.slice(0, index), ...services.slice(index + 1)]
}
return services
}
export const QUESTIONNAIRE = [{
type: 'list',
name: 'runner',
message: 'What type of testing would you like to do?',
choices: SUPPORTED_PACKAGES.runner
}, {
type: 'list',
name: 'preset',
message: 'Which framework do you use for building components?',
choices: SUPPORTED_BROWSER_RUNNER_PRESETS,
// only ask if there are more than 1 runner to pick from
when: /* istanbul ignore next */ isBrowserRunner
}, {
type: 'confirm',
name: 'installTestingLibrary',
message: 'Do you like to use Testing Library (https://testing-library.com/) as test utility?',
default: true,
// only ask if there are more than 1 runner to pick from
when: /* istanbul ignore next */ (answers: Questionnair) => (
isBrowserRunner(answers) &&
/**
* Only show if Testing Library has an add-on for framework
*/
answers.preset && TESTING_LIBRARY_PACKAGES[convertPackageHashToObject(answers.preset!).short]
)
}, {
type: 'list',
name: 'electronBuildTool',
message: 'Which tool are you using to build your Electron app?',
choices: Object.values(ElectronBuildToolChoice),
when: /* instanbul ignore next */ (answers: Questionnair) => getTestingPurpose(answers) === 'electron'
}, {
type: 'input',
name: 'electronAppBinaryPath',
message: 'What is the path to the binary of your built Electron app?',
when: /* istanbul ignore next */ (answers: Questionnair) => getTestingPurpose(answers) === 'electron' && (answers.electronBuildTool === ElectronBuildToolChoice.SomethingElse)
}, {
type: 'list',
name: 'backend',
message: 'Where is your automation backend located?',
choices: Object.values(BackendChoice),
when: /* instanbul ignore next */ (answers: Questionnair) => getTestingPurpose(answers) === 'e2e'
}, {
type: 'list',
name: 'e2eEnvironment',
message: 'Which environment you would like to automate?',
choices: E2E_ENVIRONMENTS,
default: 'web',
when: /* istanbul ignore next */ (answers: Questionnair) => getTestingPurpose(answers) === 'e2e'
}, {
type: 'list',
name: 'mobileEnvironment',
message: 'Which mobile environment you\'d like to automate?',
choices: MOBILE_ENVIRONMENTS,
when: /* instanbul ignore next */ (answers: Questionnair) => (
getTestingPurpose(answers) === 'e2e' &&
answers.e2eEnvironment === 'mobile'
)
}, {
type: 'checkbox',
name: 'browserEnvironment',
message: 'With which browser should we start?',
choices: BROWSER_ENVIRONMENTS,
when: /* instanbul ignore next */ (answers: Questionnair) => (
getTestingPurpose(answers) === 'e2e' &&
answers.e2eEnvironment === 'web'
)
}, {
type: 'input',
name: 'hostname',
message: 'What is the host address of that cloud service?',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.indexOf('different service') > -1
}, {
type: 'input',
name: 'port',
message: 'What is the port on which that service is running?',
default: '80',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.indexOf('different service') > -1
}, {
type: 'input',
name: 'expEnvAccessKey',
message: 'Access key from Experitest Cloud',
default: 'EXPERITEST_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Experitest
}, {
type: 'input',
name: 'expEnvHostname',
message: 'Environment variable for cloud url',
default: 'example.experitest.com',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Experitest
}, {
type: 'input',
name: 'expEnvPort',
message: 'Environment variable for port',
default: '443',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Experitest
}, {
type: 'list',
name: 'expEnvProtocol',
message: 'Choose a protocol for environment variable',
default: ProtocolOptions.HTTPS,
choices: Object.values(ProtocolOptions),
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend === BackendChoice.Experitest &&
answers.expEnvPort !== '80' &&
answers.expEnvPort !== '443'
)
}, {
type: 'input',
name: 'env_user',
message: 'Environment variable for username',
default: 'LT_USERNAME',
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend && answers.backend.indexOf('LambdaTest') > -1 &&
answers.hostname!.indexOf('lambdatest.com') > -1
)
}, {
type: 'input',
name: 'env_key',
message: 'Environment variable for access key',
default: 'LT_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend && answers.backend.indexOf('LambdaTest') > -1 &&
answers.hostname!.indexOf('lambdatest.com') > -1
)
}, {
type: 'input',
name: 'env_user',
message: 'Environment variable for username',
default: 'BROWSERSTACK_USERNAME',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Browserstack
}, {
type: 'input',
name: 'env_key',
message: 'Environment variable for access key',
default: 'BROWSERSTACK_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Browserstack
}, {
type: 'input',
name: 'env_user',
message: 'Environment variable for username',
default: 'SAUCE_USERNAME',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Saucelabs
}, {
type: 'input',
name: 'env_key',
message: 'Environment variable for access key',
default: 'SAUCE_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Saucelabs
}, {
type: 'list',
name: 'region',
message: 'In which region do you want to run your Sauce Labs tests in?',
choices: Object.values(RegionOptions),
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Saucelabs
}, {
type: 'confirm',
name: 'useSauceConnect',
message: (
'Are you testing a local application and need Sauce Connect to be set-up?\n' +
'Read more on Sauce Connect at: https://docs.saucelabs.com/secure-connections/#sauce-connect-proxy'
),
default: isNuxtProject,
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend === BackendChoice.Saucelabs &&
!isNuxtProject
)
}, {
type: 'input',
name: 'hostname',
message: 'What is the IP or URI to your Selenium standalone or grid server?',
default: 'localhost',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.toString().indexOf('own Selenium cloud') > -1
}, {
type: 'input',
name: 'port',
message: 'What is the port which your Selenium standalone or grid server is running on?',
default: '4444',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.toString().indexOf('own Selenium cloud') > -1
}, {
type: 'input',
name: 'path',
message: 'What is the path to your browser driver or grid server?',
default: '/',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.toString().indexOf('own Selenium cloud') > -1
}, {
type: 'list',
name: 'framework',
message: 'Which framework do you want to use?',
choices: /* instanbul ignore next */ (answers: Questionnair) => {
/**
* browser runner currently supports only Mocha framework
*/
if (isBrowserRunner(answers)) {
return SUPPORTED_PACKAGES.framework.slice(0, 1)
}
/**
* Serenity tests don't come with proper ElectronJS example files
*/
if (getTestingPurpose(answers) === 'electron') {
return SUPPORTED_PACKAGES.framework.filter(
({ value }) => !value.startsWith('@serenity-js')
)
}
return SUPPORTED_PACKAGES.framework
}
}, {
type: 'confirm',
name: 'isUsingTypeScript',
message: 'Do you want to use Typescript to write tests?',
when: /* istanbul ignore next */ (answers: Questionnair) => {
/**
* StencilJS only supports TypeScript - use the default
*/
if (answers.preset?.includes('stencil')) {
return false
}
return true
},
default: /* istanbul ignore next */ (answers: Questionnair) => answers.preset?.includes('stencil') || detectCompiler(answers)
}, {
type: 'confirm',
name: 'generateTestFiles',
message: 'Do you want WebdriverIO to autogenerate some test files?',
default: true,
when: /* istanbul ignore next */ (answers: Questionnair) => {
/**
* we only have examples for Mocha and Jasmine
*/
if (['vscode', 'electron', 'macos'].includes(getTestingPurpose(answers)) && answers.framework.includes('cucumber')) {
return false
}
return true
}
}, {
type: 'input',
name: 'specs',
message: 'What should be the location of your spec files?',
default: /* istanbul ignore next */ (answers: Questionnair) => {
const pattern = isBrowserRunner(answers) ? 'src/**/*.test' : 'test/specs/**/*'
return getDefaultFiles(answers, pattern)
},
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && /(mocha|jasmine)/.test(answers.framework)
}, {
type: 'input',
name: 'specs',
message: 'What should be the location of your feature files?',
default: (answers: Questionnair) => getDefaultFiles(answers, 'features/**/*.feature'),
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && answers.framework.includes('cucumber')
}, {
type: 'input',
name: 'stepDefinitions',
message: 'What should be the location of your step definitions?',
default: (answers: Questionnair) => getDefaultFiles(answers, 'features/step-definitions/steps'),
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && answers.framework.includes('cucumber')
}, {
type: 'confirm',
name: 'usePageObjects',
message: 'Do you want to use page objects (https://martinfowler.com/bliki/PageObject.html)?',
default: true,
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.generateTestFiles &&
/**
* page objects aren't common for component testing
*/
!isBrowserRunner(answers) &&
/**
* and also not needed when running VS Code tests since the service comes with
* its own page object implementation, nor when running Electron or MacOS tests
*/
!['vscode', 'electron', 'macos'].includes(getTestingPurpose(answers)) &&
/**
* Serenity/JS generates Lean Page Objects by default, so there's no need to ask about it
* See https://serenity-js.org/handbook/web-testing/page-objects-pattern/
*/
!usesSerenity(answers)
)
}, {
type: 'input',
name: 'pages',
message: 'Where are your page objects located?',
default: /* istanbul ignore next */ (answers: Questionnair) => (
answers.framework.match(/(mocha|jasmine)/)
? getDefaultFiles(answers, 'test/pageobjects/**/*')
: getDefaultFiles(answers, 'features/pageobjects/**/*')
),
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && answers.usePageObjects
}, {
type: 'input',
name: 'serenityLibPath',
message: 'What should be the location of your Serenity/JS Screenplay Pattern library?',
default: /* istanbul ignore next */ async (answers: Questionnair) => {
const projectRootDir = await getProjectRoot(answers)
const specsDir = path.resolve(projectRootDir, path.dirname(answers.specs || '').replace(/\*\*$/, ''))
return path.resolve(specsDir, '..', 'serenity')
},
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && usesSerenity(answers)
}, {
type: 'checkbox',
name: 'reporters',
message: 'Which reporter do you want to use?',
choices: SUPPORTED_PACKAGES.reporter
}, {
type: 'checkbox',
name: 'plugins',
message: 'Do you want to add a plugin to your test setup?',
choices: SUPPORTED_PACKAGES.plugin,
default: []
}, {
type: 'confirm',
name: 'includeVisualTesting',
message: 'Would you like to include Visual Testing to your setup? For more information see https://webdriver.io/docs/visual-testing!',
default: false,
when: /* istanbul ignore next */ (answers: Questionnair) => {
/**
* visual testing mostly makes sense for e2e and component tests
*/
return ['e2e', 'component'].includes(getTestingPurpose(answers))
}
}, {
type: 'checkbox',
name: 'services',
message: 'Do you want to add a service to your test setup?',
choices: (answers: Questionnair) => {
const services: string[] = []
if (answers.backend === BackendChoice.Browserstack) {
services.push('browserstack')
} else if (answers.backend === BackendChoice.Saucelabs) {
services.push('sauce')
}
if (answers.e2eEnvironment === 'mobile') {
services.push('appium')
}
if (getTestingPurpose(answers) === 'e2e' && isNuxtProject) {
services.push('nuxt')
}
if (getTestingPurpose(answers) === 'vscode') {
return [SUPPORTED_PACKAGES.service.find(({ name }) => name === 'vscode')]
} else if (getTestingPurpose(answers) === 'electron') {
return [SUPPORTED_PACKAGES.service.find(({ name }) => name === 'electron')]
} else if (getTestingPurpose(answers) === 'macos') {
return [SUPPORTED_PACKAGES.service.find(({ name }) => name === 'appium')]
} else if (getTestingPurpose(answers) === 'roku') {
return [SUPPORTED_PACKAGES.service.find(({ name }) => name === 'roku')]
}
return prioServiceOrderFor(services)
},
default: (answers: Questionnair) => {
const defaultServices: string[] = []
if (answers.backend === BackendChoice.Browserstack) {
defaultServices.push('browserstack')
} else if (answers.backend === BackendChoice.Saucelabs) {
defaultServices.push('sauce')
}
if (answers.e2eEnvironment === 'mobile' || getTestingPurpose(answers) === 'macos') {
defaultServices.push('appium')
}
if (getTestingPurpose(answers) === 'vscode') {
defaultServices.push('vscode')
} else if (getTestingPurpose(answers) === 'electron') {
defaultServices.push('electron')
} else if (getTestingPurpose(answers) === 'roku') {
defaultServices.push('roku')
}
if (isNuxtProject) {
defaultServices.push('nuxt')
}
if (answers.includeVisualTesting) {
defaultServices.push('visual')
}
return selectDefaultService(defaultServices)
}
}, {
type: 'input',
name: 'outputDir',
message: 'In which directory should the xunit reports get stored?',
default: './',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.reporters.includes('junit')
}, {
type: 'input',
name: 'outputDir',
message: 'In which directory should the json reports get stored?',
default: './',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.reporters.includes('json')
}, {
type: 'input',
name: 'outputDir',
message: 'In which directory should the mochawesome json reports get stored?',
default: './',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.reporters.includes('mochawesome')
}, {
type: 'confirm',
name: 'npmInstall',
message: () => `Do you want me to run \`${detectPackageManager()} install\``,
default: true
}]
export const COMMUNITY_PACKAGES_WITH_TS_SUPPORT = [
'wdio-electron-service',
'wdio-vscode-service',
'wdio-nuxt-service',
'wdio-vite-service',
'wdio-gmail-service',
'wdio-roku-service',
'wdio-obsidian-service'
]
export const DEPENDENCIES_INSTALLATION_MESSAGE = `
To install dependencies, execute:
%s
`

View file

@ -0,0 +1,135 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { execSync } from 'node:child_process'
import chalk from 'chalk'
import semver from 'semver'
import { Command } from 'commander'
import { resolve } from 'import-meta-resolve'
import { runProgram, getPackageVersion } from './utils.js'
import {
ASCII_ROBOT, PROGRAM_TITLE, UNSUPPORTED_NODE_VERSION, DEFAULT_NPM_TAG,
INSTALL_COMMAND, DEV_FLAG, SUPPORTED_PACKAGE_MANAGERS, EXECUTER, EXECUTE_COMMAND
} from './constants.js'
import type { ProgramOpts } from './types.js'
const WDIO_COMMAND = 'wdio'
let projectDir: string | undefined
export async function run(operation = createWebdriverIO) {
const version = await getPackageVersion()
/**
* print program ASCII art
*/
if (!(process.argv.includes('--version') || process.argv.includes('-v'))) {
console.log(ASCII_ROBOT, PROGRAM_TITLE)
}
/**
* ensure right Node.js version is used
*/
const unsupportedNodeVersion = !semver.satisfies(process.version, '>=18.18.0')
if (unsupportedNodeVersion) {
console.log(chalk.yellow(UNSUPPORTED_NODE_VERSION))
return
}
const program = new Command(WDIO_COMMAND)
.version(version, '-v, --version')
.arguments('[project-path]')
.usage(`${chalk.green('[project-path]')} [options]`)
.action(name => (projectDir = name))
.option('-t, --npm-tag <tag>', 'Which NPM version you like to install, e.g. @next', DEFAULT_NPM_TAG)
.option('-y, --yes', 'will fill in all config defaults without prompting', false)
.option('-d, --dev', 'Install all packages as into devDependencies', true)
.allowUnknownOption()
.on('--help', () => console.log())
.parse(process.argv)
return operation(program.opts())
}
/**
* detects the package manager that was used
* uses the environment variable `npm_config_user_agent` to detect the package manager
* falls back to `npm` if no package manager could be detected
*/
function detectPackageManager() {
if (!process.env.npm_config_user_agent) {
return 'npm'
}
const detectedPM = process.env.npm_config_user_agent.split('/')[0].toLowerCase()
const matchedPM = SUPPORTED_PACKAGE_MANAGERS.find(pm => pm.toLowerCase() === detectedPM)
return matchedPM || 'npm'
}
export async function createWebdriverIO(opts: ProgramOpts) {
const npmTag = opts.npmTag.startsWith('@') ? opts.npmTag : `@${opts.npmTag}`
const root = path.resolve(process.cwd(), projectDir || '')
/**
* find package manager that was used to create project
*/
const pm = detectPackageManager()
const hasPackageJson = await fs.access(path.resolve(root, 'package.json')).then(() => true).catch(() => false)
if (!hasPackageJson) {
await fs.mkdir(root, { recursive: true })
await fs.writeFile(path.resolve(root, 'package.json'), JSON.stringify({
name: root.substring(root.lastIndexOf(path.sep) + 1),
type: 'module'
}, null, 2))
}
const cliInstalled = await isCLIInstalled(root)
if (!cliInstalled) {
console.log(`\nInstalling ${chalk.bold('@wdio/cli')} to initialize project...`)
const args = [INSTALL_COMMAND[pm]]
if (opts.dev) {
args.push(DEV_FLAG[pm])
}
args.push(`@wdio/cli${npmTag}`)
await runProgram(pm, args, { cwd: root, stdio: 'ignore' })
console.log(chalk.green.bold('✔ Success!'))
}
return runProgram(EXECUTER[pm], [
EXECUTE_COMMAND[pm],
WDIO_COMMAND,
'config',
...(opts.yes ? ['--yes'] : []),
...(opts.npmTag ? ['--npm-tag', opts.npmTag] : [])
].filter(i => !!i), { cwd: root })
}
async function isCLIInstalled(path: string) {
try {
// can be replaced with import.meta.resolve('@wdio/cli', new URL(`file:///${root}`).href) in the future
// check if the cli is installed in the project
resolve('@wdio/cli', pathToFileURL(path).href)
return true
} catch {
// check of the cli is installed globally
// wrap in try/catch as it can fail on Windows
try {
const output = execSync('npm ls -g', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore']
})
if (output.includes('@wdio/cli')) {
return true
}
} catch {
return false
}
return false
}
}

View file

@ -1,15 +1,15 @@
import { execa } from 'execa'
import { detectPackageManager } from './utils.js'
import type { PM } from './types.js'
import type { PACKAGE_MANAGER } from './constants.js'
const installCommand: Record<PM, string> = {
const installCommand: Record<PACKAGE_MANAGER, string> = {
npm: 'install',
pnpm: 'add',
yarn: 'add',
bun: 'install'
}
const devFlag: Record<PM, string> = {
const devFlag: Record<PACKAGE_MANAGER, string> = {
npm: '--save-dev',
pnpm: '--save-dev',
yarn: '--dev',
@ -42,7 +42,7 @@ export async function installPackages (cwd: string, packages: string[], dev: boo
return true
}
export function getInstallCommand (pm: PM, packages: string[], dev: boolean) {
export function getInstallCommand (pm: PACKAGE_MANAGER, packages: string[], dev: boolean) {
const devParam = dev ? devFlag[pm] : ''
return `${pm} ${installCommand[pm]} ${packages.join(' ')} ${devParam}`
}

View file

@ -0,0 +1 @@
export * as install from '../cli/install.js'

View file

@ -0,0 +1,107 @@
import type { BackendChoice, RegionOptions, ElectronBuildToolChoice, } from './constants.js'
import type { PackageJson as typeFestPackageJson } from 'type-fest'
import type { Package as normalizePackage } from 'normalize-package-data'
export type NormalizedPackageJson = PackageJson & normalizePackage
export type PackageJson = typeFestPackageJson
export interface ProgramOpts {
info: boolean
dev: boolean
yes: boolean
npmTag: string
}
export interface Questionnair {
runner: string
preset?: string
installTestingLibrary?: boolean
electronAppBinaryPath?: string
electronBuildTool?: ElectronBuildToolChoice
electronBuilderConfigPath?: string
backend?: BackendChoice
hostname?: string
port?: string
path?: string
expEnvAccessKey?: string
expEnvHostname?: string
expEnvPort?: string
expEnvProtocol?: 'http' | 'https'
env_user?: string
env_key?: string
region?: RegionOptions
useSauceConnect?: boolean
framework: string
specs?: string
stepDefinitions?: string
generateTestFiles: boolean
usePageObjects?: boolean
pages?: string
isUsingTypeScript: boolean
reporters: string[]
services: string[]
serenityLibPath?: string
plugins: string[]
outputDir?: string
includeVisualTesting: boolean
npmInstall: boolean
createPackageJSON?: boolean
projectRootCorrect?: boolean
projectRoot?: string
e2eEnvironment?: 'web' | 'mobile'
mobileEnvironment?: 'android' | 'ios'
browserEnvironment?: ('chrome' | 'firefox' | 'safari' | 'microsoftedge')[]
}
export interface ParsedAnswers extends Omit<Questionnair, 'runner' | 'framework' | 'reporters' | 'services' | 'plugins'> {
rawAnswers: Questionnair
runner: 'local' | 'browser'
projectName: string
framework: string
purpose: string
reporters: string[]
plugins: string[]
services: string[]
packagesToInstall: string[]
isUsingTypeScript: boolean
serenityAdapter: string | false
esmSupport: boolean
isSync: boolean
_async: string
_await: string
projectRootDir: string
destSpecRootPath: string
destPageObjectRootPath: string
destStepRootPath: string;
destSerenityLibRootPath: string
relativePath: string
hasRootTSConfig: boolean
tsConfigFilePath: string
tsProject: string
wdioConfigPath: string
}
export interface ConfigCommandArguments {
yarn: boolean
yes: boolean
npmTag: string
}
export interface SupportedPackage {
package: string
short: string
purpose: string
}
export interface ProjectProps {
esmSupported: boolean
path: string
packageJson: NormalizedPackageJson
}
export interface InstallCommandArguments {
config?: string
type: 'service' | 'reporter' | 'framework' | 'plugin'
name: string
}

View file

@ -0,0 +1,887 @@
import url from 'node:url'
import path from 'node:path'
import util, { promisify } from 'node:util'
import fs from 'node:fs/promises'
import { execSync, } from 'node:child_process'
import readDir from 'recursive-readdir'
import { $ } from 'execa'
import { readPackageUp } from 'read-pkg-up'
import { EjsHelpers } from './templates/EjsHelpers.js'
import inquirer from 'inquirer'
import ejs from 'ejs'
import type { SpawnOptions } from 'node:child_process'
import spawn from 'cross-spawn'
import { COMMUNITY_PACKAGES_WITH_TS_SUPPORT, DEPENDENCIES_INSTALLATION_MESSAGE, pkg, SUPPORTED_PACKAGE_MANAGERS, QUESTIONNAIRE, TESTING_LIBRARY_PACKAGES, usesSerenity } from './constants.js'
import type { ParsedAnswers, ProjectProps, Questionnair, SupportedPackage } from './types.js'
import chalk from 'chalk'
import { getInstallCommand, installPackages } from './install.js'
const NPM_COMMAND = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
process.on('SIGINT', () => printAndExit(undefined, 'SIGINT'))
const TEMPLATE_ROOT_DIR = path.join(__dirname, 'templates', 'exampleFiles')
export function runProgram (command: string, args: string[], options: SpawnOptions) {
const child = spawn(command, args, { stdio: 'inherit', ...options })
return new Promise<void>((resolve, rejects) => {
let error: Error
child.on('error', (e) => (error = e))
child.on('close', (code, signal) => {
if (code !== 0) {
const errorMessage = (error && error.message) || `Error calling: ${command} ${args.join(' ')}`
printAndExit(errorMessage, signal)
return rejects(errorMessage)
}
resolve()
})
})
}
export async function getPackageVersion() {
try {
const pkgJsonPath = path.join(__dirname, '..', 'package.json')
const pkg = JSON.parse((await fs.readFile(pkgJsonPath)).toString())
return `v${pkg.version}`
} catch {
/* ignore */
}
return 'unknown'
}
function printAndExit (error?: string, signal?: NodeJS.Signals | null) {
if (signal === 'SIGINT') {
console.log('\n\nGoodbye 👋')
} else {
console.log(`\n\n⚠️ Ups, something went wrong${error ? `: ${error}` : ''}!`)
}
process.exit(1)
}
// transported from cli
export function convertPackageHashToObject(pkg: string, hash = '$--$'): SupportedPackage {
const [p, short, purpose] = pkg.split(hash)
return { package: p, short, purpose }
}
export async function getAnswers(yes: boolean): Promise<Questionnair> {
if (yes) {
const ignoredQuestions = ['e2eEnvironment']
const filteredQuestionaire = QUESTIONNAIRE.filter((question) => !ignoredQuestions.includes(question.name))
const answers = {} as Questionnair
for (const question of filteredQuestionaire) {
/**
* set nothing if question doesn't apply
*/
if (question.when && !question.when(answers)) {
continue
}
Object.assign(answers, {
[question.name]: typeof question.default !== 'undefined'
/**
* set default value if existing
*/
? typeof question.default === 'function'
? await question.default(answers)
: question.default
: question.choices && question.choices.length
/**
* pick first choice, select value if it exists
*/
? typeof question.choices === 'function'
? (question.choices(answers)[0] as unknown as { value: unknown }).value
? (question.choices(answers)[0] as unknown as { value: unknown }).value
: question.choices(answers)[0]
: (question.choices[0] as { value: unknown }).value
? question.type === 'checkbox'
? [(question.choices[0] as { value: unknown }).value]
: (question.choices[0] as { value: unknown }).value
: question.choices[0]
: {}
})
}
/**
* some questions have async defaults
*/
answers.isUsingTypeScript = await answers.isUsingTypeScript
answers.specs = await answers.specs
answers.pages = await answers.pages
return answers
}
const projectProps = await getProjectProps(process.cwd())
const isProjectExisting = Boolean(projectProps)
const nameInPackageJsonIsNotCreateWdioDefault = projectProps?.packageJson?.name !== 'my-new-project'
const projectName = projectProps?.packageJson?.name ? ` named "${projectProps.packageJson.name}"` : ''
const projectRootQuestions = [
{
type: 'confirm',
name: 'createPackageJSON',
default: true,
message: `Couldn't find a package.json in "${process.cwd()}" or any of the parent directories, do you want to create one?`,
when: !isProjectExisting
},
{
type: 'confirm',
name: 'projectRootCorrect',
default: true,
message: `A project${projectName} was detected at "${projectProps?.path}", correct?`,
when: isProjectExisting && nameInPackageJsonIsNotCreateWdioDefault
}, {
type: 'input',
name: 'projectRoot',
message: 'What is the project root for your test project?',
default: projectProps?.path,
// only ask if there are more than 1 runner to pick from
when: /* istanbul ignore next */ (answers: Questionnair) => isProjectExisting && nameInPackageJsonIsNotCreateWdioDefault && !answers.projectRootCorrect
}
]
// @ts-ignore
const answers = await inquirer.prompt(projectRootQuestions)
if (answers.createPackageJSON) {
answers.projectRoot = process.cwd()
}
// @ts-ignore
return inquirer.prompt(QUESTIONNAIRE, answers)
}
/**
* Receive project properties
* @returns {@type ProjectProps} if a package.json can be found in cwd or parent directories, otherwise undefined
* which means that a new project can be created
*/
export async function getProjectProps(cwd = process.cwd()): Promise<ProjectProps | undefined> {
try {
const { packageJson, path: packageJsonPath } = await readPackageUp({ cwd }) || {}
if (!packageJson || !packageJsonPath) {
return undefined
}
return {
esmSupported: (
packageJson.type === 'module' ||
typeof packageJson.module === 'string'
),
packageJson,
path: path.dirname(packageJsonPath)
}
} catch {
return undefined
}
}
export function getPathForFileGeneration(answers: Questionnair, projectRootDir: string) {
const specAnswer = answers.specs || ''
const stepDefinitionAnswer = answers.stepDefinitions || ''
const pageObjectAnswer = answers.pages || ''
const destSpecRootPath = generatePathFromAnswer(specAnswer, projectRootDir).replace(/\*\*$/, '')
const destStepRootPath = generatePathFromAnswer(stepDefinitionAnswer, projectRootDir)
const destPageObjectRootPath = answers.usePageObjects
? generatePathFromAnswer(pageObjectAnswer, projectRootDir).replace(/\*\*$/, '')
: ''
const destSerenityLibRootPath = usesSerenity(answers)
? path.resolve(projectRootDir, answers.serenityLibPath || 'serenity')
: ''
const relativePath = (answers.generateTestFiles && answers.usePageObjects)
? !(convertPackageHashToObject(answers.framework).short === 'cucumber')
? path.relative(destSpecRootPath, destPageObjectRootPath)
: path.relative(destStepRootPath, destPageObjectRootPath)
: ''
return {
destSpecRootPath: destSpecRootPath,
destStepRootPath: destStepRootPath,
destPageObjectRootPath: destPageObjectRootPath,
destSerenityLibRootPath: destSerenityLibRootPath,
relativePath: relativePath.replaceAll(path.sep, '/')
}
}
/**
* Generates a valid file path from answers provided.
* @param answers The answer from which a file path is to be generated.
* @param projectRootDir The root directory of the project.
* @returns filePath
*/
function generatePathFromAnswer(answers:string, projectRootDir:string):string {
return path.resolve(
projectRootDir, path.dirname(answers) === '.' ? path.resolve(answers) : path.dirname(answers))
}
/**
* Get project root directory based on questionaire answers
* @param answers questionaire answers
* @param projectProps project properties received via `getProjectProps`
* @returns project root path
*/
export async function getProjectRoot (parsedAnswers?: Questionnair) {
if (parsedAnswers?.createPackageJSON && parsedAnswers.projectRoot) {
return parsedAnswers.projectRoot
}
const root = (await getProjectProps())?.path
if (!root) {
throw new Error('Could not find project root directory with a package.json')
}
return !parsedAnswers || parsedAnswers.projectRootCorrect
? root
: parsedAnswers.projectRoot || process.cwd()
}
/**
* create package.json if not already existing
*/
export async function createPackageJSON(parsedAnswers: ParsedAnswers) {
const packageJsonExists = await fs.access(path.resolve(process.cwd(), 'package.json')).then(() => true, () => false)
// Use the existing package.json if it already exists.
if (packageJsonExists) {
return
}
// If a user said no to creating a package.json, but it doesn't exist, abort.
if (parsedAnswers.createPackageJSON === false) {
/* istanbul ignore if */
if (!packageJsonExists) {
console.log(`No WebdriverIO configuration found in "${parsedAnswers.wdioConfigPath}"`)
return !process.env.WDIO_UNIT_TESTS && process.exit(0)
}
return
}
// Only create if the user gave explicit permission to
if (parsedAnswers.createPackageJSON) {
console.log(`Creating a ${chalk.bold('package.json')} for the directory...`)
await fs.writeFile(path.resolve(process.cwd(), 'package.json'), JSON.stringify({
name: 'webdriverio-tests',
version: '0.0.0',
private: true,
license: 'ISC',
type: 'module',
dependencies: {},
devDependencies: {}
}, null, 2))
console.log(chalk.green(chalk.bold('✔ Success!\n')))
}
}
/**
* set up TypeScript if it is desired but not installed
*/
export async function setupTypeScript(parsedAnswers: ParsedAnswers) {
/**
* don't create a `tsconfig.json` if user doesn't want to use TypeScript
*/
if (!parsedAnswers.isUsingTypeScript) {
return
}
/**
* don't set up TypeScript if a `tsconfig.json` already exists but ensure we install `tsx`
* as it is a requirement for running tests with TypeScript
*/
if (parsedAnswers.hasRootTSConfig) {
return
}
console.log('Setting up TypeScript...')
const frameworkPackage = convertPackageHashToObject(parsedAnswers.rawAnswers.framework)
const servicePackages = parsedAnswers.rawAnswers.services.map((service) => convertPackageHashToObject(service))
const serenityTypes = parsedAnswers.serenityAdapter === 'jasmine' ? ['jasmine'] : []
const types = [
'node',
...(parsedAnswers.framework === 'jasmine' ? ['jasmine'] : []),
'@wdio/globals/types',
...(parsedAnswers.framework === 'jasmine' ? ['expect-webdriverio/jasmine'] : ['expect-webdriverio']),
...(parsedAnswers.serenityAdapter ? serenityTypes : [frameworkPackage.package]),
...(parsedAnswers.runner === 'browser' ? ['@wdio/browser-runner'] : []),
...servicePackages
.filter(service => (
/**
* given that we know that all "official" services have
* typescript support we only include them
*/
service.package.startsWith('@wdio') ||
/**
* also include community maintained packages with known
* support for TypeScript
*/
COMMUNITY_PACKAGES_WITH_TS_SUPPORT.includes(service.package)
)).map(service => service.package)
]
const preset = getPreset(parsedAnswers)
const config = {
compilerOptions: {
// compiler
moduleResolution: 'node',
module: !parsedAnswers.esmSupport ? 'commonjs' : 'ESNext',
target: 'es2022',
lib: ['es2022', 'dom'],
types,
skipLibCheck: true,
// bundler
noEmit: true,
allowImportingTsExtensions: true,
resolveJsonModule: true,
isolatedModules: true,
// linting
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
noFallthroughCasesInSwitch: true,
...Object.assign(
preset === 'lit'
? {
experimentalDecorators: true,
useDefineForClassFields: false
}
: {},
preset === 'react'
? {
jsx: 'react-jsx'
}
: {},
preset === 'preact'
? {
jsx: 'react-jsx',
jsxImportSource: 'preact'
}
: {},
preset === 'solid'
? {
jsx: 'preserve',
jsxImportSource: 'solid-js'
}
: {},
preset === 'stencil'
? {
experimentalDecorators: true,
jsx: 'react',
jsxFactory: 'h',
jsxFragmentFactory: 'Fragment'
}
: {}
)
},
include: preset === 'svelte'
? ['src/**/*.d.ts', 'src/**/*.ts', 'src/**/*.js', 'src/**/*.svelte']
: preset === 'vue'
? ['src/**/*.ts', 'src/**/*.d.ts', 'src/**/*.tsx', 'src/**/*.vue']
: ['test', 'wdio.conf.ts']
}
if (parsedAnswers.framework === 'cucumber') {
config.include.push('features')
}
await fs.mkdir(path.dirname(parsedAnswers.tsConfigFilePath), { recursive: true })
await fs.writeFile(
parsedAnswers.tsConfigFilePath,
JSON.stringify(config, null, 4)
)
console.log(chalk.green(chalk.bold('✔ Success!\n')))
}
/**
* run npm install only if required by the user
*/
const SEP = '\n- '
export async function npmInstall(parsedAnswers: ParsedAnswers, npmTag: string) {
const servicePackages = parsedAnswers.rawAnswers.services.map((service) => convertPackageHashToObject(service))
const presetPackage = convertPackageHashToObject(parsedAnswers.rawAnswers.preset || '')
/**
* install Testing Library dependency if desired
*/
if (parsedAnswers.installTestingLibrary && TESTING_LIBRARY_PACKAGES[presetPackage.short]) {
parsedAnswers.packagesToInstall.push(
TESTING_LIBRARY_PACKAGES[presetPackage.short],
'@testing-library/jest-dom'
)
}
/**
* add helper package for Solidjs testing
*/
if (presetPackage.short === 'solid') {
parsedAnswers.packagesToInstall.push('solid-js')
}
/**
* add visual service if user selected support for it
*/
if (parsedAnswers.includeVisualTesting) {
parsedAnswers.packagesToInstall.push('@wdio/visual-service')
}
/**
* add dependency for Lit testing
*/
const preset = getPreset(parsedAnswers)
if (preset === 'lit') {
parsedAnswers.packagesToInstall.push('lit')
}
/**
* add dependency for Stencil testing
*/
if (preset === 'stencil') {
parsedAnswers.packagesToInstall.push('@stencil/core')
}
/**
* add helper for React rendering when not using Testing Library
*/
if (presetPackage.short === 'react') {
parsedAnswers.packagesToInstall.push('react')
if (!parsedAnswers.installTestingLibrary) {
parsedAnswers.packagesToInstall.push('react-dom')
}
}
/**
* add Jasmine types if necessary
*/
if (parsedAnswers.framework === 'jasmine' && parsedAnswers.isUsingTypeScript) {
parsedAnswers.packagesToInstall.push('@types/jasmine')
}
/**
* add Appium mobile drivers if desired
*/
if (parsedAnswers.purpose === 'macos') {
parsedAnswers.packagesToInstall.push('appium-mac2-driver')
}
if (parsedAnswers.mobileEnvironment === 'android') {
parsedAnswers.packagesToInstall.push('appium-uiautomator2-driver')
}
if (parsedAnswers.mobileEnvironment === 'ios') {
parsedAnswers.packagesToInstall.push('appium-xcuitest-driver')
}
/**
* add packages that are required by services
*/
addServiceDeps(servicePackages, parsedAnswers.packagesToInstall)
/**
* update package version if CLI is a pre release
*/
parsedAnswers.packagesToInstall = specifyVersionIfNeeded(parsedAnswers.packagesToInstall, pkg.version, npmTag)
const cwd = await getProjectRoot(parsedAnswers)
const pm = detectPackageManager()
if (parsedAnswers.npmInstall) {
console.log(`Installing packages using ${pm}:${SEP}${parsedAnswers.packagesToInstall.join(SEP)}`)
const success = await installPackages(cwd, parsedAnswers.packagesToInstall, true)
if (success) {
console.log(chalk.green(chalk.bold('✔ Success!\n')))
}
} else {
const installationCommand = getInstallCommand(pm, parsedAnswers.packagesToInstall, true)
console.log(util.format(DEPENDENCIES_INSTALLATION_MESSAGE, installationCommand))
}
}
function getPreset (parsedAnswers: ParsedAnswers) {
const isUsingFramework = typeof parsedAnswers.preset === 'string'
return isUsingFramework ? (parsedAnswers.preset || 'lit') : ''
}
export function addServiceDeps(names: SupportedPackage[], packages: string[], update = false) {
/**
* install Appium if it is not installed globally if `@wdio/appium-service`
* was selected for install
*/
if (names.some(({ short }) => short === 'appium')) {
const result = execSync('appium --version || echo APPIUM_MISSING', { stdio: 'pipe' }).toString().trim()
if (result === 'APPIUM_MISSING') {
packages.push('appium')
} else if (update) {
console.log(
'\n=======',
'\nUsing globally installed appium', result,
'\nPlease add the following to your wdio.conf.js:',
"\nappium: { command: 'appium' }",
'\n=======\n')
}
}
}
export async function createWDIOConfig(parsedAnswers: ParsedAnswers) {
try {
console.log('Creating a WebdriverIO config file...')
const tplPath = path.resolve(__dirname, 'templates', 'wdio.conf.tpl.ejs')
const renderedTpl = await renderFile(tplPath, {
answers: parsedAnswers,
_: new EjsHelpers({ useEsm: parsedAnswers.esmSupport, useTypeScript: parsedAnswers.isUsingTypeScript })
})
await fs.writeFile(parsedAnswers.wdioConfigPath, renderedTpl)
console.log(chalk.green(chalk.bold('✔ Success!\n')))
if (parsedAnswers.generateTestFiles) {
console.log('Autogenerate test files...')
await generateTestFiles(parsedAnswers)
console.log(chalk.green(chalk.bold('✔ Success!\n')))
}
} catch (err) {
throw new Error(`⚠️ Couldn't write config file: ${(err as Error).stack}`)
}
}
export const renderFile = promisify(ejs.renderFile) as (path: string, data: Record<string, unknown>) => Promise<string>
/* c8 ignore start */
export async function createWDIOScript(parsedAnswers: ParsedAnswers) {
const rootDir = await getProjectRoot(parsedAnswers)
const pathToWdioConfig = `./${path.join('.', parsedAnswers.wdioConfigPath.replace(rootDir, ''))}`
const wdioScripts = {
'wdio': `wdio run ${ pathToWdioConfig }`,
}
const serenityScripts = {
'serenity': 'failsafe serenity:clean wdio serenity:report',
'serenity:clean': 'rimraf target',
'wdio': `wdio run ${ pathToWdioConfig }`,
'serenity:report': 'serenity-bdd run',
}
const scripts = parsedAnswers.serenityAdapter ? serenityScripts : wdioScripts
for (const [script, command] of Object.entries(scripts)) {
const args = ['pkg', 'set', `scripts.${ script }=${ command }`]
try {
console.log(`Adding ${chalk.bold(`"${ script }"`)} script to package.json`)
await runProgram(NPM_COMMAND, args, { cwd: parsedAnswers.projectRootDir })
} catch (err) {
const [preArgs, scriptPath] = args.join(' ').split('=')
console.error(
`⚠️ Couldn't add script to package.json: "${(err as Error).message}", you can add it manually ` +
`by running:\n\n\t${NPM_COMMAND} ${preArgs}="${scriptPath}"`
)
return false
}
}
console.log(chalk.green(chalk.bold('✔ Success!')))
return true
}
/* c8 ignore stop */
export async function runAppiumInstaller(parsedAnswers: ParsedAnswers) {
if (parsedAnswers.e2eEnvironment !== 'mobile') {
return
}
const answer = await inquirer.prompt({
name: 'continueWithAppiumSetup',
message: 'Continue with Appium setup using appium-installer (https://github.com/AppiumTestDistribution/appium-installer)?',
type: 'confirm',
default: true
})
if (!answer.continueWithAppiumSetup) {
return console.log(
'Ok! You can learn more about setting up mobile environments in the ' +
'Appium docs at https://appium.io/docs/en/latest/quickstart/'
)
}
return $({ stdio: 'inherit' })`npx appium-installer`
}
export function getSerenityPackages(answers: Questionnair): string[] {
const framework = convertPackageHashToObject(answers.framework)
if (framework.package !== '@serenity-js/webdriverio') {
return []
}
const packages: Record<string, Array<string | false>> = {
cucumber: [
'@cucumber/cucumber',
'@serenity-js/cucumber',
],
mocha: [
'@serenity-js/mocha',
'mocha',
],
jasmine: [
'@serenity-js/jasmine',
'jasmine',
],
common: [
'@serenity-js/assertions',
'@serenity-js/console-reporter',
'@serenity-js/core',
'@serenity-js/rest',
'@serenity-js/serenity-bdd',
'@serenity-js/web',
'npm-failsafe',
'rimraf',
]
}
if (answers.isUsingTypeScript) {
packages.mocha.push('@types/mocha')
packages.jasmine.push('@types/jasmine')
packages.common.push('@types/node')
}
return [
...packages[framework.purpose],
...packages.common,
].filter(Boolean).sort() as string[]
}
/**
* detect the package manager that was used
* uses the environment variable `npm_config_user_agent` to detect the package manager
* falls back to `npm` if no package manager could be detected
*/
export function detectPackageManager() {
if (!process.env.npm_config_user_agent) {
return 'npm'
}
const detectedPM = process.env.npm_config_user_agent.split('/')[0].toLowerCase()
const matchedPM = SUPPORTED_PACKAGE_MANAGERS.find(pm => pm.toLowerCase() === detectedPM)
return matchedPM || 'npm'
}
export async function getDefaultFiles(answers: Questionnair, pattern: string) {
const rootDir = await getProjectRoot(answers)
const presetPackage = convertPackageHashToObject(answers.preset || '')
const isJSX = TSX_BASED_FRAMEWORKS.includes(presetPackage.short || '')
const val = pattern.endsWith('.feature')
? path.join(rootDir, pattern)
: answers?.isUsingTypeScript
? `${path.join(rootDir, pattern)}.ts${isJSX ? 'x' : ''}`
: `${path.join(rootDir, pattern)}.js${isJSX ? 'x' : ''}`
return val
}
const TSX_BASED_FRAMEWORKS = ['react', 'preact', 'solid', 'stencil']
/**
* detect if project has a compiler file
*/
export async function detectCompiler(answers: Questionnair) {
// obviously there is no compiler, if there is no package.json
if (answers.createPackageJSON) {
return false
}
const root = await getProjectRoot(answers)
const hasRootTSConfig = await fs.access(path.resolve(root, 'tsconfig.json')).then(() => true, () => false)
return hasRootTSConfig
}
const VERSION_REGEXP = /(\d+)\.(\d+)\.(\d+)-(alpha|beta|)\.(\d+)\+(.+)/g
/**
* Ensure core WebdriverIO packages have the same version as cli so that if someone
* installs `@wdio/cli@next` and runs the wizard, all related packages have the same version.
* running `matchAll` to a version like "8.0.0-alpha.249+4bc237701", results in:
* ['8.0.0-alpha.249+4bc237701', '8', '0', '0', 'alpha', '249', '4bc237701']
*/
export function specifyVersionIfNeeded(packagesToInstall: string[], version: string, npmTag: string) {
const { value } = version.matchAll(VERSION_REGEXP).next()
const [major, minor, patch, tagName, build] = (value || []).slice(1, -1) // drop commit bit
return packagesToInstall.map((p) => {
if (
(p.startsWith('@wdio') && p !== '@wdio/visual-service') ||
['webdriver', 'webdriverio'].includes(p)
) {
const tag = major && npmTag === 'latest'
? `^${major}.${minor}.${patch}-${tagName}.${build}`
: npmTag
return `${p}@${tag}`
}
return p
})
}
/**
* generate test files based on CLI answers
*/
export async function generateTestFiles(answers: ParsedAnswers) {
if (answers.serenityAdapter) {
return generateSerenityExamples(answers)
}
if (answers.runner === 'local') {
return generateLocalRunnerTestFiles(answers)
}
return generateBrowserRunnerTestFiles(answers)
}
/* c8 ignore start */
async function generateSerenityExamples(answers: ParsedAnswers): Promise<void> {
const templateDirectories = Object.entries({
[answers.projectRootDir]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', 'common', 'config'),
[answers.destSpecRootPath]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', answers.serenityAdapter as string),
[answers.destSerenityLibRootPath]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', 'common', 'serenity'),
})
await Promise.all(templateDirectories.map(async ([destinationRootDir, templateRootDir]) => {
const pathsToTemplates = await readDir(templateRootDir)
await Promise.all(pathsToTemplates.map(async (pathToTemplate) => {
const extension = answers.isUsingTypeScript ? '.ts' : '.js'
const destination = path.join(destinationRootDir, path.relative(templateRootDir, pathToTemplate))
.replace(/\.ejs$/, '')
.replace(/\.ts$/, extension)
const contents = await renderFile(
pathToTemplate,
{ answers, _: new EjsHelpers({ useEsm: answers.esmSupport, useTypeScript: answers.isUsingTypeScript }) },
)
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.writeFile(destination, contents)
}))
}))
}
/* c8 ignore stop */
async function generateLocalRunnerTestFiles(answers: ParsedAnswers) {
const testFiles = answers.framework === 'cucumber'
? [path.join(TEMPLATE_ROOT_DIR, 'cucumber')]
: [path.join(TEMPLATE_ROOT_DIR, 'mochaJasmine')]
if (answers.usePageObjects) {
testFiles.push(path.join(TEMPLATE_ROOT_DIR, 'pageobjects'))
}
const files = (await Promise.all(testFiles.map((dirPath) => readDir(
dirPath,
[(file, stats) => !stats.isDirectory() && !(file.endsWith('.ejs') || file.endsWith('.feature'))]
)))).reduce((cur, acc) => [...acc, ...(cur)], [])
await Promise.all(files.map(async (file) => {
const renderedTpl = await renderFile(file, { answers })
const isJSX = answers.preset && TSX_BASED_FRAMEWORKS.includes(answers.preset)
const fileEnding = (answers.isUsingTypeScript ? '.ts' : '.js') + (isJSX ? 'x' : '')
const destPath = (
file.endsWith('page.js.ejs')
? path.join(answers.destPageObjectRootPath, path.basename(file))
: file.includes('step_definition')
? path.join(answers.destStepRootPath, path.basename(file))
: path.join(answers.destSpecRootPath, path.basename(file))
).replace(/\.ejs$/, '').replace(/\.js$/, fileEnding)
await fs.mkdir(path.dirname(destPath), { recursive: true })
await fs.writeFile(destPath, renderedTpl)
}))
}
export async function generateBrowserRunnerTestFiles(answers: ParsedAnswers) {
const isUsingFramework = typeof answers.preset === 'string'
const preset = getPreset(answers)
const tplRootDir = path.join(TEMPLATE_ROOT_DIR, 'browser')
await fs.mkdir(answers.destSpecRootPath, { recursive: true })
/**
* render css file
*/
if (isUsingFramework) {
const renderedCss = await renderFile(path.join(tplRootDir, 'Component.css.ejs'), { answers })
await fs.writeFile(path.join(answers.destSpecRootPath, 'Component.css'), renderedCss)
}
/**
* render component file
*/
const testExt = `${(answers.isUsingTypeScript ? 'ts' : 'js')}${TSX_BASED_FRAMEWORKS.includes(preset) ? 'x' : ''}`
const fileExt = ['svelte', 'vue'].includes(preset as 'svelte' | 'vue')
? preset!
: testExt
if (preset) {
const componentOutFileName = `Component.${fileExt}`
const renderedComponent = await renderFile(path.join(tplRootDir, `Component.${preset}.ejs`), { answers })
await fs.writeFile(path.join(answers.destSpecRootPath, componentOutFileName), renderedComponent)
}
/**
* render test file
*/
const componentFileName = preset ? `Component.${preset}.test.ejs` : 'standalone.test.ejs'
const renderedTest = await renderFile(path.join(tplRootDir, componentFileName), { answers })
await fs.writeFile(path.join(answers.destSpecRootPath, `Component.test.${testExt}`), renderedTest)
}
/**
* Helper utility used in `run` and `install` command to format a provided config path,
* giving it back as an absolute path, and a version without the file extension
* @param config the initially given file path to the WDIO config file
*/
export async function formatConfigFilePaths(config: string) {
const fullPath = path.isAbsolute(config)
? config
: path.join(process.cwd(), config)
const fullPathNoExtension = fullPath.substring(0, fullPath.lastIndexOf(path.extname(fullPath)))
return { fullPath, fullPathNoExtension }
}
export function findInConfig(config: string, type: string) {
let regexStr = `[\\/\\/]*[\\s]*${type}s: [\\s]*\\[([\\s]*['|"]\\w*['|"],*)*[\\s]*\\]`
if (type === 'framework') {
regexStr = `[\\/\\/]*[\\s]*${type}: ([\\s]*['|"]\\w*['|"])`
}
const regex = new RegExp(regexStr, 'gmi')
return config.match(regex)
}
function buildNewConfigArray(str: string, type: string, change: string) {
const newStr = str
.split(`${type}s: `)[1]
.replace(/'/g, '')
const newArray = newStr.match(/(\w*)/gmi)?.filter(e => !!e).concat([change]) || []
return str
.replace('// ', '')
.replace(
new RegExp(`(${type}s: )((.*\\s*)*)`), `$1[${newArray.map(e => `'${e}'`)}]`
)
}
function buildNewConfigString(str: string, type: string, change: string) {
return str.replace(new RegExp(`(${type}: )('\\w*')`), `$1'${change}'`)
}
export function replaceConfig(config: string, type: string, name: string) {
if (type === 'framework') {
return buildNewConfigString(config, type, name)
}
const match = findInConfig(config, type)
if (!match || match.length === 0) {
return
}
const text = match.pop() || ''
return config.replace(text, buildNewConfigArray(text, type, name))
}

View file

@ -0,0 +1,2 @@
export { SUPPORTED_PACKAGE_MANAGERS } from '../constants.js'

View file

@ -0,0 +1,432 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`onboarding templates > Svelte > with testing-library 1`] = `
"
import { $, expect } from '@wdio/globals'
import { render, fireEvent } from '@testing-library/svelte'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
import ExampleComponent from './Component.svelte'
import './Component.css'
describe('Svelte Component Testing', () => {
it('should test component with Testing Library', async () => {
const { getByText } = render(ExampleComponent)
const component = getByText(/count is 0/i)
expect(component).toBeInTheDocument()
await fireEvent.click(component)
await fireEvent.click(component)
expect(getByText(/count is 2/i)).toBeInTheDocument()
})
it('should test component with WebdriverIO', async () => {
render(ExampleComponent)
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > Svelte > without testing-library 1`] = `
"
import { $, expect } from '@wdio/globals'
import ExampleComponent from './Component.svelte'
import './Component.css'
describe('Svelte Component Testing', () => {
let container: Element
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
container?.remove()
})
it('should test component with WebdriverIO', async () => {
new ExampleComponent({ target: container, props: {} })
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > Vue > with testing-library 1`] = `
"
import { $, expect } from '@wdio/globals'
import { render, fireEvent } from '@testing-library/vue'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
import ExampleComponent from './Component.vue'
import './Component.css'
describe('Vue Component Testing', () => {
it('should test component with Testing Library', async () => {
// The render method returns a collection of utilities to query your component.
const { getByText } = render(ExampleComponent, {
props: { msg: 'WebdriverIO Component Testing' }
})
const component = getByText(/count is 0/i)
expect(component).toBeInTheDocument()
await fireEvent.click(component)
await fireEvent.click(component)
expect(getByText(/count is 2/i)).toBeInTheDocument()
})
it('should test component with WebdriverIO', async () => {
render(ExampleComponent, { props: { msg: 'WebdriverIO Component Testing' } })
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > Vue > without testing-library 1`] = `
"
import { $, expect } from '@wdio/globals'
import { createApp } from 'vue'
import ExampleComponent from './Component.vue'
import './Component.css'
describe('Vue Component Testing', () => {
let container: Element
beforeEach(() => {
container = document.createElement('div')
container.setAttribute('id', 'app')
document.body.appendChild(container)
})
afterEach(() => {
container?.remove()
})
it('should test component with WebdriverIO', async () => {
createApp(ExampleComponent, { msg: 'WebdriverIO Component Testing' }).mount(container)
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > lit > without testing-library 1`] = `
"import { html, render } from 'lit'
import { $, expect } from '@wdio/globals'
import './Component.js'
import './Component.css'
describe('Lit component testing', () => {
it('should increment value on click', async () => {
render(
html\`<my-element id="root">WebdriverIO Component Testing</my-element>\`,
document.body
)
const button = await $('my-element').$('button')
await expect(button).toHaveText('count is 0')
await button.click()
await button.click()
await expect(button).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > preact > with testing-library 1`] = `
"
import { expect, $ } from '@wdio/globals'
import { h } from 'preact'
import { render, screen, fireEvent } from '@testing-library/preact'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
import ExampleComponent from './Component'
describe('Preact Component Tests', () => {
it('should test component with Testing Library', async () => {
render(<ExampleComponent />)
const component = screen.getByText(/count is 0/i)
expect(component).toBeInTheDocument()
await fireEvent.click(component)
await fireEvent.click(component)
expect(screen.getByText(/count is 2/i)).toBeInTheDocument()
})
it('should test component with WebdriverIO', async () => {
render(<ExampleComponent />)
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > preact > without testing-library 1`] = `
"
import { expect, $ } from '@wdio/globals'
import { h, render } from 'preact'
import htm from 'https://esm.sh/htm'
// Initialize htm with Preact
const html = htm.bind(h);
import ExampleComponent from './Component'
describe('Preact Component Tests', () => {
let container: Element
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
container?.remove()
})
it('should test component with WebdriverIO', async () => {
render(html\`<\${ExampleComponent} />\`, container)
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > react > with testing-library 1`] = `
"
import React from 'react'
import { expect, $ } from '@wdio/globals'
import { render, screen, fireEvent } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
import ExampleComponent from './Component'
describe('React Component Tests', () => {
it('should test component with Testing Library', async () => {
render(<ExampleComponent />)
const component = screen.getByText(/count is 0/i)
expect(component).toBeInTheDocument()
await fireEvent.click(component)
await fireEvent.click(component)
expect(screen.getByText(/count is 2/i)).toBeInTheDocument()
})
it('should test component with WebdriverIO', async () => {
render(<ExampleComponent />)
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > react > without testing-library 1`] = `
"
import React from 'react'
import { expect, $ } from '@wdio/globals'
import { createRoot } from 'react-dom/client'
import ExampleComponent from './Component'
describe('React Component Tests', () => {
let container: Element
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
container?.remove()
})
it('should test component with WebdriverIO', async () => {
const root = createRoot(container)
root.render(<ExampleComponent />)
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > solid > with testing-library 1`] = `
"
import { expect, $ } from '@wdio/globals'
import { cleanup, render, screen, fireEvent } from 'solid-testing-library'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
import ExampleComponent from './Component'
describe('Preact Component Tests', () => {
afterEach(cleanup)
it('should test component with Testing Library', async () => {
render(() => <ExampleComponent />)
const component = screen.getByText(/count is 0/i)
expect(component).toBeInTheDocument()
await fireEvent.click(component)
await fireEvent.click(component)
expect(screen.getByText(/count is 2/i)).toBeInTheDocument()
})
it('should test component with WebdriverIO', async () => {
render(() => <ExampleComponent />)
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;
exports[`onboarding templates > solid > without testing-library 1`] = `
"
import { expect, $ } from '@wdio/globals'
import { render } from 'solid-js/web'
import ExampleComponent from './Component'
describe('Preact Component Tests', () => {
let container: Element
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
container?.remove()
})
it('should test component with WebdriverIO', async () => {
render(<ExampleComponent />, container)
const component = await $('button*=count is')
await expect(component).toBePresent()
await expect(component).toHaveText('count is 0')
await component.click()
await component.click()
await expect(component).toHaveText('count is 2')
})
})
"
`;

View file

@ -0,0 +1,70 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`getAnswers 1`] = `
{
"backend": "On my local machine",
"framework": "@wdio/mocha-framework$--$mocha",
"generateTestFiles": true,
"includeVisualTesting": false,
"isUsingTypeScript": false,
"npmInstall": true,
"plugins": [],
"reporters": [
"@wdio/spec-reporter$--$spec",
],
"runner": "@wdio/local-runner$--$local$--$e2e",
"services": [],
"usePageObjects": true,
}
`;
exports[`npmInstall 1`] = `
[
[
"/foo/bar",
[
"foo$--$bar",
"bar$--$foo",
"@testing-library/vue",
"@testing-library/jest-dom",
"@wdio/visual-service",
"@types/jasmine",
],
true,
],
]
`;
exports[`setupTypeScript 1`] = `
"{
"compilerOptions": {
"moduleResolution": "node",
"module": "ESNext",
"target": "es2022",
"lib": [
"es2022",
"dom"
],
"types": [
"node",
"@wdio/globals/types",
"expect-webdriverio",
"foo",
"wdio-electron-service"
],
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"test",
"wdio.conf.ts"
]
}"
`;

View file

@ -0,0 +1,65 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`handler 1`] = `
{
"installedPackages": [
"@wdio/local-runner",
"@wdio/mocha-framework",
"@sveltejs/vite-plugin-svelte",
],
"parsedAnswers": {
"_async": "async ",
"_await": "await ",
"backend": "On my local machine",
"baseUrl": "http://localhost",
"destPageObjectRootPath": "",
"destSerenityLibRootPath": "",
"destSpecRootPath": "/foo/bar",
"destStepRootPath": "/foo/bar",
"esmSupport": true,
"framework": "mocha",
"generateTestFiles": false,
"hasRootTSConfig": false,
"installTestingLibrary": false,
"isSync": false,
"isUsingTypeScript": true,
"npmInstall": true,
"packagesToInstall": [
"@wdio/local-runner",
"@wdio/mocha-framework",
"@sveltejs/vite-plugin-svelte",
],
"plugins": [],
"preset": "svelte",
"projectName": "my-module",
"projectRootDir": "/foo/bar",
"purpose": undefined,
"rawAnswers": {
"backend": "On my local machine",
"baseUrl": "http://localhost",
"framework": "@wdio/mocha-framework$--$mocha",
"generateTestFiles": false,
"isUsingTypeScript": true,
"npmInstall": true,
"plugins": [],
"preset": "@sveltejs/vite-plugin-svelte$--$svelte",
"reporters": [],
"runner": "@wdio/local-runner$--$local",
"services": [],
},
"relativePath": "",
"reporters": [],
"runner": "local",
"serenityAdapter": false,
"services": [],
"specs": undefined,
"stepDefinitions": undefined,
"tsConfigFilePath": "/foo/bar/tsconfig.json",
"tsProject": "./tsconfig.json",
"usePageObjects": false,
"useSauceConnect": undefined,
"wdioConfigPath": "/foo/bar/wdio.conf.ts",
},
"success": true,
}
`;

View file

@ -0,0 +1,101 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`parseAnswers 1`] = `
{
"_async": "async ",
"_await": "await ",
"backend": "On my local machine",
"baseUrl": "http://localhost",
"destPageObjectRootPath": "/tmp/foobar",
"destSerenityLibRootPath": "",
"destSpecRootPath": "/tmp/foobar",
"destStepRootPath": "/tmp/foobar",
"esmSupport": true,
"framework": "mocha",
"generateTestFiles": true,
"hasRootTSConfig": true,
"installTestingLibrary": false,
"isSync": false,
"isUsingTypeScript": false,
"npmInstall": true,
"packagesToInstall": [
"@wdio/local-runner",
"@wdio/mocha-framework",
"@sveltejs/vite-plugin-svelte",
"@wdio/spec-reporter",
"wdio-wait-for",
"@wdio/sauce-service",
],
"pages": "/tmp/foobar/pageobjects",
"plugins": [
"wait-for",
],
"preset": "svelte",
"projectName": "my-module",
"projectRootDir": "/foo/bar",
"purpose": undefined,
"rawAnswers": {
"backend": "On my local machine",
"baseUrl": "http://localhost",
"framework": "@wdio/mocha-framework$--$mocha",
"generateTestFiles": true,
"isUsingTypeScript": false,
"npmInstall": true,
"pages": "/tmp/foobar/pageobjects",
"plugins": [
"wdio-wait-for$--$wait-for",
],
"preset": "@sveltejs/vite-plugin-svelte$--$svelte",
"reporters": [
"@wdio/spec-reporter$--$spec",
],
"runner": "@wdio/local-runner$--$local",
"services": [
"@wdio/sauce-service$--$sauce",
],
"specs": "/tmp/foobar/specs",
"stepDefinitions": "/tmp/foobar/steps",
"usePageObjects": true,
},
"relativePath": "",
"reporters": [
"spec",
],
"runner": "local",
"serenityAdapter": false,
"services": [
"sauce",
],
"specs": "./../../tmp/foobar/specs",
"stepDefinitions": "./../../tmp/foobar/steps",
"tsConfigFilePath": "/tmp/foobar/tsconfig.json",
"tsProject": "./../../tmp/foobar/tsconfig.json",
"usePageObjects": true,
"useSauceConnect": undefined,
"wdioConfigPath": "/foo/bar/wdio.conf.js",
}
`;
exports[`runConfigCommand 1`] = `
[
[
"
",
],
[
"
🤖 Successfully setup project at /foo/bar 🎉
Join our Discord Community Server and instantly find answers to your issues or queries. Or just join and say hi 👋!
🔗 https://discord.webdriver.io
Visit the project on GitHub to report bugs 🐛 or raise feature requests 💡:
🔗 https://github.com/webdriverio/webdriverio
To run your tests, execute:
$ cd /foo/bar
$ npm run wdio
",
],
]
`;

View file

@ -0,0 +1,84 @@
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import {
builder,
handler,
} from '../../src/cli/config.js'
import {
getAnswers,
} from '../../src/utils.js'
import { BackendChoice } from '../../src/constants.js'
const consoleLog = console.log.bind(console)
beforeEach(() => {
console.log = vi.fn()
})
afterEach(() => {
console.log = consoleLog
})
const isUsingWindows = os.platform() === 'win32'
process.cwd = () => '/foo/bar'
vi.mock('node:fs/promises', async (orig) => ({
...(await orig()) as any,
default: {
access: vi.fn().mockRejectedValue('Yay')
}
}))
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
vi.mock('../../src/utils.js', async () => {
const actual = await vi.importActual('../../src/utils.js') as Promise<object>
return {
...actual,
getAnswers: vi.fn(),
getProjectProps: vi.fn().mockResolvedValue({
path: '/foo/bar',
esmSupported: true,
packageJson: {
name: 'my-module'
}
}),
getProjectRoot: vi.fn().mockReturnValue('/foo/bar'),
createPackageJSON: vi.fn(),
npmInstall: vi.fn(),
createWDIOConfig: vi.fn(),
createWDIOScript: vi.fn(),
runAppiumInstaller: vi.fn()
}
})
test('builder', () => {
const yargs = {} as any
yargs.options = vi.fn().mockReturnValue(yargs)
yargs.epilogue = vi.fn().mockReturnValue(yargs)
yargs.help = vi.fn().mockReturnValue(yargs)
builder(yargs)
expect(yargs.options).toBeCalledTimes(1)
expect(yargs.options).toBeCalledWith(expect.any(Object))
expect(yargs.epilogue).toBeCalledTimes(1)
expect(yargs.help).toBeCalledTimes(1)
})
test.skipIf(isUsingWindows)('handler', async () => {
vi.mocked(getAnswers).mockResolvedValue({
backend: BackendChoice.Local,
generateTestFiles: false,
baseUrl: 'http://localhost',
runner: '@wdio/local-runner$--$local',
framework: '@wdio/mocha-framework$--$mocha',
preset: '@sveltejs/vite-plugin-svelte$--$svelte',
isUsingTypeScript: true,
reporters: [],
plugins: [],
services: [],
npmInstall: true
} as any)
const runConfigCmd = vi.fn()
expect(await handler({} as any, runConfigCmd)).toMatchSnapshot()
expect(runConfigCmd).toBeCalledTimes(1)
})

View file

@ -1,11 +1,10 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest'
// @ts-expect-error mock
import { yargs } from 'yargs'
import * as installCmd from '../../src/commands/install.js'
import * as configCmd from '../../src/commands/config.js'
import * as installCmd from '../../src/cli/install.js'
import * as configCmd from '../../src/cli/config.js'
import * as utils from '../../src/utils.js'
import { installPackages } from '../../src/install.js'
@ -18,7 +17,6 @@ vi.mock('node:fs/promises', async (orig) => ({
writeFile: vi.fn().mockResolvedValue({})
}
}))
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
vi.mock('../../src/install', () => ({
installPackages: vi.fn(),
getInstallCommand: vi.fn().mockReturnValue('npm install foo bar --save-dev')
@ -36,7 +34,7 @@ const findInConfigMock = vi.spyOn(utils, 'findInConfig')
describe('Command: install', () => {
beforeEach(() => {
vi.spyOn(console, 'log')
vi.spyOn(process, 'exit').mockImplementation((code?: number): never => code as never)
vi.spyOn(process, 'exit').mockImplementation(((code?: number): never => code as never) as any)
vi.spyOn(configCmd, 'missingConfigurationPrompt').mockImplementation(() => Promise.resolve() as any)
vi.spyOn(utils, 'addServiceDeps')
vi.spyOn(utils, 'replaceConfig').mockReturnValueOnce({ foo: 123 } as any)
@ -67,7 +65,7 @@ describe('Command: install', () => {
})
it('should prompt missing configuration', async () => {
vi.spyOn(configCmd, 'formatConfigFilePaths').mockReturnValue({ fullPath: '/absolute/path/to/wdio.conf.js', fullPathNoExtension: '/absolute/path/to/wdio.conf' } as any)
vi.spyOn(utils, 'formatConfigFilePaths').mockReturnValue({ fullPath: '/absolute/path/to/wdio.conf.js', fullPathNoExtension: '/absolute/path/to/wdio.conf' } as any)
vi.spyOn(configCmd, 'missingConfigurationPrompt').mockImplementation(() => Promise.reject())
vi.mocked(fs.access).mockRejectedValue(new Error('Doesn\'t exist'))

View file

@ -1,41 +1,14 @@
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'
import inquirer from 'inquirer'
import {
builder,
canAccessConfigPath,
handler,
missingConfigurationPrompt,
parseAnswers,
runConfigCommand,
} from '../../src/commands/config.js'
import {
createPackageJSON,
createWDIOConfig,
createWDIOScript,
getAnswers,
npmInstall,
runAppiumInstaller,
} from '../../src/utils.js'
import { vi, test, expect, describe, beforeEach, it } from 'vitest'
import { canAccessConfigPath, missingConfigurationPrompt, parseAnswers, runConfigCommand } from '../../src/cli/utils.js'
import { createPackageJSON, npmInstall, createWDIOConfig, createWDIOScript, runAppiumInstaller, getAnswers } from '../../src/utils.js'
import { BackendChoice } from '../../src/constants.js'
import inquirer from 'inquirer'
import path from 'node:path'
import type { Questionnair } from '../../src/types.js'
const consoleLog = console.log.bind(console)
beforeEach(() => {
console.log = vi.fn()
})
afterEach(() => {
console.log = consoleLog
})
const isUsingWindows = os.platform() === 'win32'
process.cwd = () => '/foo/bar'
vi.mock('node:fs/promises', async (orig) => ({
...(await orig()) as any,
default: {
@ -43,7 +16,6 @@ vi.mock('node:fs/promises', async (orig) => ({
}
}))
vi.mock('inquirer')
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
vi.mock('../../src/utils.js', async () => {
const actual = await vi.importActual('../../src/utils.js') as Promise<object>
return {
@ -65,44 +37,13 @@ vi.mock('../../src/utils.js', async () => {
}
})
test('builder', () => {
const yargs = {} as any
yargs.options = vi.fn().mockReturnValue(yargs)
yargs.epilogue = vi.fn().mockReturnValue(yargs)
yargs.help = vi.fn().mockReturnValue(yargs)
builder(yargs)
expect(yargs.options).toBeCalledTimes(1)
expect(yargs.options).toBeCalledWith(expect.any(Object))
expect(yargs.epilogue).toBeCalledTimes(1)
expect(yargs.help).toBeCalledTimes(1)
})
vi.mock('../src/install', () => ({
installPackages: vi.fn(),
getInstallCommand: vi.fn().mockReturnValue('npm install foo bar --save-dev')
}))
global.console.log = vi.fn()
test.skipIf(isUsingWindows)('parseAnswers', async () => {
vi.mocked(getAnswers).mockResolvedValue({
backend: BackendChoice.Local,
specs: '/tmp/foobar/specs',
pages: '/tmp/foobar/pageobjects',
generateTestFiles: true,
usePageObjects: true,
baseUrl: 'http://localhost',
runner: '@wdio/local-runner$--$local',
framework: '@wdio/mocha-framework$--$mocha',
preset: '@sveltejs/vite-plugin-svelte$--$svelte',
isUsingTypeScript: false,
reporters: [
'@wdio/spec-reporter$--$spec'
],
plugins: [
'wdio-wait-for$--$wait-for'
],
services: [
'@wdio/sauce-service$--$sauce'
],
npmInstall: true
})
const parsedAnswers = await parseAnswers(true)
expect(parsedAnswers).toMatchSnapshot()
})
const isUsingWindows = os.platform() === 'win32'
test('runConfigCommand', async () => {
await runConfigCommand({ projectRootDir: '/foo/bar' } as any, 'next')
@ -114,64 +55,6 @@ test('runConfigCommand', async () => {
expect(vi.mocked(console.log).mock.calls).toMatchSnapshot()
})
test.skipIf(isUsingWindows)('handler', async () => {
vi.mocked(getAnswers).mockResolvedValue({
backend: BackendChoice.Local,
generateTestFiles: false,
baseUrl: 'http://localhost',
runner: '@wdio/local-runner$--$local',
framework: '@wdio/mocha-framework$--$mocha',
preset: '@sveltejs/vite-plugin-svelte$--$svelte',
isUsingTypeScript: true,
reporters: [],
plugins: [],
services: [],
npmInstall: true
})
const runConfigCmd = vi.fn()
expect(await handler({} as any, runConfigCmd)).toMatchSnapshot()
expect(runConfigCmd).toBeCalledTimes(1)
})
test('missingConfigurationPrompt does not init wizard if user does not want to', async () => {
vi.mocked(getAnswers).mockResolvedValue({
backend: BackendChoice.Local,
generateTestFiles: false,
baseUrl: 'http://localhost',
runner: '@wdio/local-runner$--$local',
framework: '@wdio/mocha-framework$--$mocha',
preset: '@sveltejs/vite-plugin-svelte$--$svelte',
reporters: [],
plugins: [],
services: [],
npmInstall: true
})
const runConfigCmd = vi.fn()
vi.mocked(inquirer.prompt).mockResolvedValue({})
await missingConfigurationPrompt('config', 'foobar', runConfigCmd)
expect(runConfigCmd).toBeCalledTimes(0)
})
test('missingConfigurationPrompt does run config if user agrees', async () => {
vi.mocked(getAnswers).mockResolvedValue({
backend: BackendChoice.Local,
generateTestFiles: false,
baseUrl: 'http://localhost',
runner: '@wdio/local-runner$--$local',
framework: '@wdio/mocha-framework$--$mocha',
preset: '@sveltejs/vite-plugin-svelte$--$svelte',
reporters: [],
plugins: [],
services: [],
npmInstall: true
})
const runConfigCmd = vi.fn()
vi.mocked(inquirer.prompt).mockResolvedValue({ config: true })
await missingConfigurationPrompt('config', 'foobar', runConfigCmd)
expect(runConfigCmd).toBeCalledTimes(1)
})
describe('canAccessConfigPath', () => {
beforeEach(()=>{
vi.mocked(fs.access).mockClear()
@ -212,6 +95,52 @@ describe('canAccessConfigPath', () => {
expect(fs.access).toBeCalledWith('/foo/bar.mts')
})
})
test('missingConfigurationPrompt does not init wizard if user does not want to', async () => {
vi.mocked(getAnswers).mockResolvedValue({
backend: BackendChoice.Local,
generateTestFiles: false,
baseUrl: 'http://localhost',
runner: '@wdio/local-runner$--$local',
framework: '@wdio/mocha-framework$--$mocha',
preset: '@sveltejs/vite-plugin-svelte$--$svelte',
reporters: [],
plugins: [],
services: [],
npmInstall: true
} as any)
const runConfigCmd = vi.fn()
vi.mocked(inquirer.prompt).mockResolvedValue({})
await missingConfigurationPrompt('config', 'foobar', runConfigCmd)
expect(runConfigCmd).toBeCalledTimes(0)
})
test.skipIf(isUsingWindows)('parseAnswers', async () => {
vi.mocked(getAnswers).mockResolvedValue({
backend: BackendChoice.Local,
specs: '/tmp/foobar/specs',
pages: '/tmp/foobar/pageobjects',
stepDefinitions: '/tmp/foobar/steps',
generateTestFiles: true,
usePageObjects: true,
baseUrl: 'http://localhost',
runner: '@wdio/local-runner$--$local',
framework: '@wdio/mocha-framework$--$mocha',
preset: '@sveltejs/vite-plugin-svelte$--$svelte',
isUsingTypeScript: false,
reporters: [
'@wdio/spec-reporter$--$spec'
],
plugins: [
'wdio-wait-for$--$wait-for'
],
services: [
'@wdio/sauce-service$--$sauce'
],
npmInstall: true
} as any)
const parsedAnswers = await parseAnswers(true)
expect(parsedAnswers).toMatchSnapshot()
})
describe('Serenity/JS project generation', () => {
@ -231,7 +160,7 @@ describe('Serenity/JS project generation', () => {
services: [],
npmInstall: true,
isUsingTypeScript: false,
}
} as any
it('marks serenityAdapter as false and destSerenityLibRootPath as blank if not using Serenity/JS', async () => {
vi.mocked(getAnswers).mockResolvedValue({
@ -456,3 +385,23 @@ describe('Serenity/JS project generation', () => {
})
})
})
test('missingConfigurationPrompt does run config if user agrees', async () => {
vi.mocked(getAnswers).mockResolvedValue({
backend: BackendChoice.Local,
generateTestFiles: false,
baseUrl: 'http://localhost',
runner: '@wdio/local-runner$--$local',
framework: '@wdio/mocha-framework$--$mocha',
preset: '@sveltejs/vite-plugin-svelte$--$svelte',
reporters: [],
plugins: [],
services: [],
npmInstall: true
} as any)
const runConfigCmd = vi.fn()
vi.mocked(inquirer.prompt).mockResolvedValue({ config: true })
await missingConfigurationPrompt('config', 'foobar', runConfigCmd)
expect(runConfigCmd).toBeCalledTimes(1)
})

View file

@ -0,0 +1,6 @@
import { expect, test } from 'vitest'
test('should export config', async ()=>{
const { config }= await import('../../src/config/cli.js')
expect(config).toBeDefined()
})

View file

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { convertPackageHashToObject } from '../src/utils.js'
import { SUPPORTED_PACKAGES } from '../src/constants.js'
const supportedInstallations = [
...SUPPORTED_PACKAGES.runner.map(({ value }) => convertPackageHashToObject(value)),
...SUPPORTED_PACKAGES.plugin.map(({ value }) => convertPackageHashToObject(value)),
...SUPPORTED_PACKAGES.service.map(({ value }) => convertPackageHashToObject(value)),
...SUPPORTED_PACKAGES.reporter.map(({ value }) => convertPackageHashToObject(value)),
...SUPPORTED_PACKAGES.framework.map(({ value }) => convertPackageHashToObject(value))
]
describe('constants', () => {
describe('plugin list', () => {
it('should provide all a short name', () => {
const pluginsWithoutShorts = supportedInstallations.filter((plugin) => !plugin.short)
expect(pluginsWithoutShorts).toHaveLength(0)
})
})
})

View file

@ -0,0 +1,333 @@
import fs from 'node:fs/promises'
import semver from 'semver'
import { resolve } from 'import-meta-resolve'
import { vi, test, expect, beforeEach, afterEach, describe, it } from 'vitest'
import { Command } from 'commander'
import { execSync } from 'node:child_process'
import { execa } from 'execa'
import { run, createWebdriverIO } from '../src/index.js'
import { runProgram } from '../src/utils.js'
import type { ProgramOpts } from '../src/types.js'
import { getInstallCommand, installPackages } from '../src/install.js'
vi.mock('node:fs/promises', () => ({
default: {
access: vi.fn(),
mkdir: vi.fn(),
writeFile: vi.fn()
}
}))
vi.mock('node:child_process', () => ({
execSync: vi.fn()
}))
vi.mock('import-meta-resolve', () => ({
resolve: vi.fn()
}))
vi.mock('commander')
vi.mock('semver', () => ({
default: {
satisfies: vi.fn().mockReturnValue(true)
}
}))
vi.mock('../src/utils.js', async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import('../src/utils.js')>()
return {
...actual,
runProgram: vi.fn(),
colorItBold: vi.fn((log) => log),
colorIt: vi.fn((log) => log),
getPackageVersion: vi.fn().mockReturnValue('0.1.1'),
shouldUseYarn: vi.fn().mockReturnValue(true)
}
})
const consoleLog = console.log.bind(console)
beforeEach(() => {
console.log = vi.fn()
vi.mocked(runProgram).mockClear()
vi.mocked(resolve).mockImplementation(() => {
throw new Error('foo')
})
vi.mocked(execSync).mockReturnValue(`
├── corepack@0.20.0
├── npm@10.2.0
└── yarn@1.22.19
`)
vi.mocked(fs.access).mockResolvedValue()
})
test('run', async () => {
const op = vi.fn().mockResolvedValue({})
await run(op)
expect(op).toBeCalledTimes(1)
expect(op).toBeCalledWith('foobar')
expect(new Command().arguments).toBeCalledWith('[project-path]')
expect(console.log).toBeCalledTimes(1)
expect(vi.mocked(console.log).mock.calls[0][0]).toContain('oooooooooooo')
expect(vi.mocked(console.log).mock.calls[0][1]).toContain('Next-gen browser and mobile automation')
})
test('does not run if Node.js version is too low', async () => {
const consoleLog = vi.spyOn(console, 'log')
vi.mocked(semver.satisfies).mockReturnValue(false)
const op = vi.fn().mockResolvedValue({})
await run(op)
expect(op).toBeCalledTimes(0)
expect(consoleLog).toBeCalledWith(expect.stringContaining('Please update to Node.js v20 to continue.'))
})
test('createWebdriverIO with Yarn', async () => {
vi.stubEnv('npm_config_user_agent', 'yarn/4.5.0 npm/? node/v20.11.0 darwin arm64')
await createWebdriverIO({ npmTag: 'latest' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'yarn',
['add', '@wdio/cli@latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'yarn',
['exec', 'wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('createWebdriverIO with NPM', async () => {
vi.stubEnv('npm_config_user_agent', 'npm/10.2.4 node/v20.11.0 darwin arm64 workspaces/false')
await createWebdriverIO({ npmTag: 'latest' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npm',
['install', '@wdio/cli@latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('createWebdriverIO with invalid agent should run npm commands', async () => {
vi.stubEnv('npm_config_user_agent', 'invalid/10.2.4 node/v20.11.0 darwin arm64 workspaces/false')
await createWebdriverIO({ npmTag: 'latest' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npm',
['install', '@wdio/cli@latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('createWebdriverIO with no npm user agent should run npm commands', async () => {
vi.stubEnv('npm_config_user_agent', '')
await createWebdriverIO({ npmTag: 'latest' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npm',
['install', '@wdio/cli@latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('createWebdriverIO with pnpm', async () => {
vi.stubEnv('npm_config_user_agent', 'pnpm/9.10.0 npm/? node/v20.11.0 darwin arm64')
await createWebdriverIO({ npmTag: 'latest' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'pnpm',
['add', '@wdio/cli@latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'pnpm',
['exec', 'wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('createWebdriverIO with bun', async () => {
vi.stubEnv('npm_config_user_agent', 'bun/1.1.27 npm/? node/v22.6.0 darwin arm64')
await createWebdriverIO({ npmTag: 'latest' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'bun',
['install', '@wdio/cli@latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'bunx',
['wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('creates a directory if it does not exist', async () => {
vi.stubEnv('npm_config_user_agent', 'npm/10.2.4 node/v20.11.0 darwin arm64 workspaces/false')
await createWebdriverIO({ npmTag: 'latest', dev: true } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npm',
['install', '--save-dev', '@wdio/cli@latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('does not install the @wdio/cli package when the @wdio/cli package is already installed in the current project', async () => {
vi.stubEnv('npm_config_user_agent', 'npm/10.2.4 node/v20.11.0 darwin arm64 workspaces/false')
vi.mocked(resolve).mockReturnValue('/Users/user/dev/my-monorepo/package.json')
await createWebdriverIO({ npmTag: 'latest' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(1)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('does not install the @wdio/cli package when the @wdio/cli package is already installed globally', async () => {
vi.stubEnv('npm_config_user_agent', 'npm/10.2.4 node/v20.11.0 darwin arm64 workspaces/false')
vi.mocked(execSync).mockReturnValue(`
├── @wdio/cli@8.24.3
├── corepack@0.20.0
├── npm@10.2.0
└── yarn@1.22.19
`)
await createWebdriverIO({ npmTag: 'latest' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(1)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('runs the wdio config command with --yes when the yes option is set to true', async () => {
vi.stubEnv('npm_config_user_agent', 'npm/10.2.4 node/v20.11.0 darwin arm64 workspaces/false')
await createWebdriverIO({ npmTag: 'latest', yes: true } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npm',
['install', '@wdio/cli@latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--yes', '--npm-tag', 'latest'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
test('does create a package.json to be used by the wdio config command when one does not exist', async () => {
vi.stubEnv('npm_config_user_agent', 'npm/10.2.4 node/v20.11.0 darwin arm64 workspaces/false')
vi.mocked(fs.access).mockRejectedValue(new Error('not existing'))
await createWebdriverIO({ npmTag: 'next' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npm',
['install', '@wdio/cli@next'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--npm-tag', 'next'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(1)
expect(fs.writeFile).toBeCalledTimes(1)
expect(fs.writeFile).toBeCalledWith(
expect.any(String),
JSON.stringify({ name: 'someProjectName', type: 'module' }, null, 2),
)
})
test('installs the next version when the npmTag option is set to "next"', async () => {
vi.stubEnv('npm_config_user_agent', 'npm/10.2.4 node/v20.11.0 darwin arm64 workspaces/false')
await createWebdriverIO({ npmTag: 'next' } as ProgramOpts)
expect(runProgram).toBeCalledWith(
'npm',
['install', '@wdio/cli@next'],
expect.any(Object)
)
expect(runProgram).toBeCalledWith(
'npx',
['wdio', 'config', '--npm-tag', 'next'],
expect.any(Object)
)
expect(runProgram).toBeCalledTimes(2)
expect(fs.mkdir).toBeCalledTimes(0)
expect(fs.writeFile).toBeCalledTimes(0)
})
afterEach(() => {
vi.mocked(fs.access).mockClear()
vi.mocked(fs.mkdir).mockClear()
vi.mocked(fs.writeFile).mockClear()
vi.mocked(runProgram).mockClear()
vi.mocked(resolve).mockClear()
vi.mocked(execSync).mockClear()
console.log = consoleLog
})
vi.mock('execa', () => ({
execa: vi.fn().mockResolvedValue({ stdout: 'foo', stderr: 'bar', exitCode: 0 })
}))
vi.mock('detect', () => ({
detect: vi.fn().mockResolvedValue('pnpm')
}))
describe('install', () => {
it('installPackages passing', async () => {
expect(await installPackages('/foo/bar', ['foo', 'bar'], true))
.toBe(true)
})
it('installPackages failing', async () => {
vi.mocked(execa).mockResolvedValue({ stdout: 'foo', stderr: 'bar', exitCode: 1 } as any)
expect(await installPackages('/foo/bar', ['foo', 'bar'], false))
.toBe(false)
})
it('getInstallCommand', () => {
expect(getInstallCommand('pnpm', ['foo', 'bar'], true))
.toBe('pnpm add foo bar --save-dev')
})
})

View file

@ -0,0 +1,6 @@
import { expect, test } from 'vitest'
test('should export install', async ()=>{
const { install }= await import('../../src/install/cli.js')
expect(install).toBeDefined()
})

View file

@ -0,0 +1,736 @@
import fs from 'node:fs/promises'
import * as cp from 'node:child_process'
import { vi, test, expect, beforeEach, afterEach, describe, it } from 'vitest'
import readDir from 'recursive-readdir'
import ejs from 'ejs'
import { $ } from 'execa'
import { runProgram, getPackageVersion,
convertPackageHashToObject,
generateTestFiles,
getPathForFileGeneration,
getDefaultFiles,
getProjectRoot,
getAnswers,
getProjectProps,
createPackageJSON,
npmInstall,
setupTypeScript,
createWDIOConfig,
createWDIOScript,
runAppiumInstaller,
detectCompiler,
findInConfig,
replaceConfig,
formatConfigFilePaths,
} from '../src/utils.js'
import { parseAnswers } from '../src/cli/utils.js'
import path from 'node:path'
import { readPackageUp } from 'read-pkg-up'
import type { Questionnair } from '../src/types.js'
import inquirer from 'inquirer'
import { installPackages } from '../src/install.js'
const consoleLog = console.log.bind(console)
const processExit = process.exit.bind(process)
vi.mock('node:fs/promises', () => ({
default: {
access: vi.fn().mockRejectedValue(new Error('ENOENT')),
mkdir: vi.fn(),
writeFile: vi.fn().mockReturnValue(Promise.resolve())
}
}))
vi.mock('child_process', () => {
const m = {
execSyncRes: 'APPIUM_MISSING',
execSync: () => m.execSyncRes,
exec: vi.fn(),
spawn: vi.fn().mockReturnValue({ on: vi.fn().mockImplementation((ev, fn) => fn(0)) })
}
return m
})
vi.mock('inquirer')
vi.mock('read-pkg-up')
vi.mock('ejs')
vi.mock('execa', () => ({
execa: vi.fn(()=>({ stdout:'', stderr:'', exitCode:0 })),
$: vi.fn().mockReturnValue(async (sh: string) => sh)
}))
vi.mock('recursive-readdir', () => ({
default: vi.fn().mockResolvedValue([
'/foo/bar/loo/page.js.ejs',
'/foo/bar/example.e2e.js'
] as any)
}))
vi.mock('../src/install.js', () => ({
installPackages: vi.fn(),
getInstallCommand: vi.fn().mockReturnValue('npm install foo bar --save-dev')
}))
beforeEach(() => {
process.exit = vi.fn() as any
console.log = vi.fn()
vi.mocked(readPackageUp).mockResolvedValue({
path: '/foo/package.json',
packageJson: {
name: 'cool-test-module',
type: 'module'
}
})
})
afterEach(() => {
process.exit = processExit
console.log = consoleLog
})
test('runProgram', async () => {
expect(await runProgram('echo', ['123'], {})).toBe(undefined)
await runProgram('node', ['-e', 'throw new Error(\'ups\')'], {}).catch((e) => e)
expect(vi.mocked(console.log).mock.calls[0][0]).toMatch(/Error calling: node -e throw new Error/)
expect(process.exit).toBeCalledTimes(1)
await runProgram('foobarloo', [], {}).catch((e) => e)
expect(vi.mocked(console.log).mock.calls[1][0]).toMatch(/spawn foobarloo ENOENT/)
expect(process.exit).toBeCalledTimes(2)
})
test('getPackageVersion', async () => {
expect(await getPackageVersion()).toEqual(expect.any(String))
})
describe('convertPackageHashToObject', () => {
it('works with default `$--$` hash', () => {
expect(convertPackageHashToObject('test/package-name$--$package-name')).toMatchObject({
package: 'test/package-name',
short: 'package-name'
})
})
it('works with custom hash', () => {
expect(convertPackageHashToObject('test/package-name##-##package-name', '##-##')).toMatchObject({
package: 'test/package-name',
short: 'package-name'
})
})
})
describe('generateTestFiles', () => {
it('Mocha with page objects', async () => {
vi.mocked(readDir).mockResolvedValue([
'/foo/bar/loo/page.js.ejs',
'/foo/bar/example.e2e.js'
] as any)
const answers = {
runner: 'local',
framework: 'mocha',
usePageObjects: true,
generateTestFiles: true,
destPageObjectRootPath: '/tests/page/objects/model',
destSpecRootPath: '/tests/specs'
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(2)
expect(vi.mocked(readDir).mock.calls[0][0]).toContain('mocha')
expect(vi.mocked(readDir).mock.calls[1][0]).toContain('pageobjects')
/**
* test readDir callback
*/
const readDirCb = vi.mocked(readDir).mock.calls[0][1][0] as Function
const stats = { isDirectory: vi.fn().mockReturnValue(false) }
expect(readDirCb('/foo/bar.lala', stats)).toBe(true)
expect(readDirCb('/foo/bar.js.ejs', stats)).toBe(false)
expect(readDirCb('/foo/bar.feature', stats)).toBe(false)
stats.isDirectory.mockReturnValue(true)
expect(readDirCb('/foo/bar.lala', stats)).toBe(false)
expect(readDirCb('/foo/bar.js.ejs', stats)).toBe(false)
expect(readDirCb('/foo/bar.feature', stats)).toBe(false)
expect(ejs.renderFile).toBeCalledTimes(4)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/loo/page.js.ejs',
{ answers },
expect.any(Function)
)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/example.e2e.js',
{ answers },
expect.any(Function)
)
expect(fs.mkdir).toBeCalledTimes(4)
expect((vi.mocked(fs.writeFile).mock.calls[0][0] as string)
.endsWith(`${path.sep}page${path.sep}objects${path.sep}model${path.sep}page.js`))
.toBe(true)
expect((vi.mocked(fs.writeFile).mock.calls[1][0] as string)
.endsWith(`${path.sep}example.e2e.js`))
.toBe(true)
})
it('jasmine with page objects', async () => {
const answers = {
runner: 'local',
framework: 'jasmine',
usePageObjects: true,
generateTestFiles: true,
destPageObjectRootPath: '/tests/page/objects/model',
destSpecRootPath: '/tests/specs'
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(2)
expect(vi.mocked(readDir).mock.calls[0][0]).toContain('mochaJasmine')
expect(vi.mocked(readDir).mock.calls[1][0]).toContain('pageobjects')
/**
* test readDir callback
*/
const readDirCb = vi.mocked(readDir).mock.calls[0][1][0] as Function
const stats = { isDirectory: vi.fn().mockReturnValue(false) }
expect(readDirCb('/foo/bar.lala', stats)).toBe(true)
expect(readDirCb('/foo/bar.js.ejs', stats)).toBe(false)
expect(readDirCb('/foo/bar.feature', stats)).toBe(false)
stats.isDirectory.mockReturnValue(true)
expect(readDirCb('/foo/bar.lala', stats)).toBe(false)
expect(readDirCb('/foo/bar.js.ejs', stats)).toBe(false)
expect(readDirCb('/foo/bar.feature', stats)).toBe(false)
expect(ejs.renderFile).toBeCalledTimes(4)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/loo/page.js.ejs',
{ answers },
expect.any(Function)
)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/example.e2e.js',
{ answers },
expect.any(Function)
)
expect(fs.mkdir).toBeCalledTimes(4)
expect((vi.mocked(fs.writeFile).mock.calls[0][0] as string)
.endsWith(`${path.sep}page${path.sep}objects${path.sep}model${path.sep}page.js`))
.toBe(true)
expect((vi.mocked(fs.writeFile).mock.calls[1][0] as string)
.endsWith(`${path.sep}example.e2e.js`))
.toBe(true)
})
it('Jasmine with page generation and no pageObjects', async () => {
vi.mocked(readDir).mockResolvedValue([] as any)
const answers = {
runner: 'local',
specs: './tests/e2e/**/*.js',
framework: 'jasmine',
generateTestFiles: false,
usePageObjects: false
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(1)
expect(ejs.renderFile).toBeCalledTimes(0)
})
it('Cucumber with page generation and no pageObjects', async () => {
vi.mocked(readDir).mockResolvedValue([] as any)
const answers = {
runner: 'local',
specs: './tests/e2e/**/*.js',
framework: 'cucumber',
generateTestFiles: false,
usePageObjects: false,
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(1)
expect(ejs.renderFile).toBeCalledTimes(0)
})
it('Cucumber without page objects', async () => {
vi.mocked(readDir).mockResolvedValue([
'/foo/bar/loo/step_definition/example.step.js',
'/foo/bar/example.feature'
] as any)
const answers = {
runner: 'local',
specs: './tests/e2e/*.js',
framework: 'cucumber',
stepDefinitions: '/some/step/defs',
usePageObjects: false,
generateTestFiles: true,
destSpecRootPath: '/tests/specs',
destStepRootPath: '/tests/stepDefinitions'
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(1)
expect(vi.mocked(readDir).mock.calls[0][0]).toContain('cucumber')
expect(ejs.renderFile).toBeCalledTimes(2)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/loo/step_definition/example.step.js',
{ answers },
expect.any(Function)
)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/example.feature',
{ answers },
expect.any(Function)
)
expect(fs.mkdir).toBeCalledTimes(2)
})
it('Cucumber with page objects and TypeScript', async () => {
vi.mocked(readDir).mockResolvedValue([
'/foo/bar/loo/page.js.ejs',
'/foo/bar/loo/step_definition/example.step.js',
'/foo/bar/example.feature'
] as any)
const answers = {
runner: 'local',
framework: 'cucumber',
usePageObjects: true,
isUsingTypeScript: true,
destStepRootPath: '/tests/stepDefinitions',
destSpecRootPath: '/tests/specs',
destPageObjectRootPath: '/some/page/objects',
relativePath: '../page/object'
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(2)
expect(vi.mocked(readDir).mock.calls[0][0]).toContain('cucumber')
expect(ejs.renderFile).toBeCalledTimes(6)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/loo/step_definition/example.step.js',
{ answers },
expect.any(Function)
)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/example.feature',
{ answers },
expect.any(Function)
)
expect(fs.mkdir).toBeCalledTimes(6)
expect(
(vi.mocked(fs.writeFile).mock.calls[0][0] as string)
.endsWith(`${path.sep}some${path.sep}page${path.sep}objects${path.sep}page.ts`)
).toBe(true)
expect(
(vi.mocked(fs.writeFile).mock.calls[2][0] as string)
.endsWith(`${path.sep}example.feature`)
).toBe(true)
})
})
describe('findInConfig', () => {
it('finds text for services', () => {
const str = "services: ['foo', 'bar'],"
expect(findInConfig(str, 'service')).toMatchObject([
'services: [\'foo\', \'bar\']'
])
})
it('finds text for frameworks', () => {
const str = "framework: 'mocha'"
expect(findInConfig(str, 'framework')).toMatchObject([
"framework: 'mocha'"
])
})
})
describe('formatConfigFilePaths', () => {
it('should format properly', async () => {
expect(await formatConfigFilePaths('/path/to/foo.js')).toMatchObject({
fullPath:'/path/to/foo.js',
fullPathNoExtension:'/path/to/foo'
})
})
})
describe('replaceConfig', () => {
it('correctly changes framework', () => {
const fakeConfig = `exports.config = {
runner: 'local',
specs: [
'./test/specs/**/*.js'
],
framework: 'mocha',
}`
expect(replaceConfig(fakeConfig, 'framework', 'jasmine')).toBe(
`exports.config = {
runner: 'local',
specs: [
'./test/specs/**/*.js'
],
framework: 'jasmine',
}`
)
})
it('correctly changes service', () => {
const fakeConfig = `exports.config = {
runner: 'local',
specs: [
'./test/specs/**/*.js'
],
services: [],
framework: 'mocha',
}`
expect(replaceConfig(fakeConfig, 'service', 'sauce')).toBe(
`exports.config = {
runner: 'local',
specs: [
'./test/specs/**/*.js'
],
services: ['sauce'],
framework: 'mocha',
}`
)
})
})
describe('getPathForFileGeneration', () => {
it('Cucumber with pageobjects default values', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
stepDefinitions: './features/step-definitions/steps.js',
pages: './features/pageobjects/**/*.js',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$cucumber'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../pageobjects')
})
it('Cucumber with pageobjects default different path', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
stepDefinitions: './features/step-definitions/steps.js',
pages: './features/page/objects/**/*.js',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$cucumber'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../page/objects')
})
it('Cucumber with pageobjects and steps different path', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
stepDefinitions: 'cucumber/features/steps',
pages: 'cucumber/features/pages',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$cucumber'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('')
})
it('Cucumber with answer that is not a path', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
stepDefinitions: 'y',
pages: 'h',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$cucumber'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../h')
})
it('Mocha with pageobjects default values', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
specs: './test/specs/**/*.js',
pages: './test/pageobjects/**/*.js',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$mocha'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../pageobjects')
})
it('Mocha with pageobjects different path', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
specs: './test/specs/files/**/*.js',
pages: './test/pageobjects/**/*.js',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$mocha'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../../pageobjects')
})
it('Do not auto generate file', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
specs: './test/specs/files/**/*.js',
pages: './test/pageobjects/**/*.js',
generateTestFiles: false,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$mocha'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('')
})
it('Do not use PageObjects', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
specs: './test/specs/files/**/*.js',
pages: './test/pageobjects/**/*.js',
generateTestFiles: true,
usePageObjects: false,
framework: '@wdio/cucumber-service$--$mocha'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('')
})
})
test('getDefaultFiles', async () => {
const files = '/foo/bar'
expect(await getDefaultFiles({ projectRootCorrect: false, projectRoot: '/bar', isUsingTypeScript: true } as any, files))
.toBe(path.join('/bar', 'foo', 'bar.ts'))
expect(await getDefaultFiles({ projectRootCorrect: false, projectRoot: '/bar', isUsingTypeScript: true, preset: 'vite-plugin-solid$--$solid' } as any, files))
.toBe(path.join('/bar', 'foo', 'bar.tsx'))
expect(await getDefaultFiles({ projectRootCorrect: false, projectRoot: '/bar', isUsingTypeScript: false } as any, files))
.toBe(path.join('/bar', 'foo', 'bar.js'))
})
test('original implementation of getDefaultFiles handles projectRoot with no package.json', async () => {
const files = '/foo/bar'
vi.mocked(readPackageUp).mockRestore()
expect(await getDefaultFiles({ createPackageJSON: true, projectRoot: '/project-root-with-no-package.json', isUsingTypeScript: false } as any, files))
.toBe(path.join('/project-root-with-no-package.json', 'foo', 'bar.js'))
})
test('getProjectRoot', async () => {
expect(await getProjectRoot()).toBe('/foo')
expect(await getProjectRoot({
projectRootCorrect: true
} as any)).toBe('/foo')
expect(await getProjectRoot({
projectRootCorrect: false,
projectRoot: '/bar/foo'
} as any)).toBe('/bar/foo')
})
test('detectCompiler', async () => {
vi.mocked(readPackageUp).mockRestore()
const answers = { createPackageJSON: true } as Questionnair
expect(await detectCompiler(answers)).toBe(false)
})
test('getAnswers', async () => {
let answers = await getAnswers(true)
delete answers.pages // delete so it doesn't fail in Windows
delete answers.specs // delete so it doesn't fail in Windows
expect(answers).toMatchSnapshot()
vi.mocked(inquirer.prompt).mockReturnValue('some value' as any)
answers = await getAnswers(false)
delete answers.pages // delete so it doesn't fail in Windows
delete answers.specs // delete so it doesn't fail in Windows
expect(answers).toBe('some value')
expect(inquirer.prompt).toBeCalledTimes(2)
// @ts-ignore
expect(vi.mocked(inquirer.prompt).mock.calls[0][0][0].when).toBe(false)
vi.mocked(readPackageUp).mockResolvedValue(undefined)
vi.mocked(inquirer.prompt).mockClear()
expect(await getAnswers(false)).toBe('some value')
expect(inquirer.prompt).toBeCalledTimes(2)
// @ts-ignore
expect(vi.mocked(inquirer.prompt).mock.calls[0][0][0].when).toBe(true)
})
test('getProjectProps', async () => {
vi.mocked(readPackageUp).mockResolvedValue(undefined)
expect(await getProjectProps('/foo/bar')).toBe(undefined)
expect(readPackageUp).toBeCalledWith({ cwd: '/foo/bar' })
vi.mocked(readPackageUp).mockResolvedValue({
path: '/foo/bar',
packageJson: {
name: 'cool-test-module2',
}
})
expect(await getProjectProps('/foo/bar')).toEqual({
esmSupported: false,
packageJson: { name: 'cool-test-module2' },
path: '/foo'
})
})
test('createPackageJSON', async () => {
await createPackageJSON({} as any)
expect(fs.writeFile).toBeCalledTimes(0)
await createPackageJSON({
createPackageJSON: true
} as any)
expect(console.log).toBeCalledTimes(2)
})
test('npmInstall', async () => {
const parsedAnswers = {
rawAnswers: {
services: ['foo$--$bar'],
preset: 'barfoo$--$vue'
},
projectRootCorrect: false,
projectRoot: '/foo/bar',
isUsingTypeScript: true,
framework: 'jasmine',
installTestingLibrary: true,
packagesToInstall: ['foo$--$bar', 'bar$--$foo'],
npmInstall: true,
includeVisualTesting: true
} as any
await npmInstall(parsedAnswers, 'next')
expect(installPackages).toBeCalledTimes(1)
expect(vi.mocked(installPackages).mock.calls).toMatchSnapshot()
})
test('not npmInstall', async () => {
const parsedAnswers = {
rawAnswers: {
services: ['foo$--$bar'],
preset: 'barfoo$--$vue'
},
installTestingLibrary: true,
packagesToInstall: ['foo$--$bar', 'bar$--$foo'],
npmInstall: false
} as any
await npmInstall(parsedAnswers, 'next')
expect(installPackages).toBeCalledTimes(0)
expect(vi.mocked(console.log).mock.calls[0][0]).toContain('To install dependencies, execute')
})
test('setupTypeScript', async () => {
await setupTypeScript({} as any)
expect(fs.writeFile).toBeCalledTimes(0)
const parsedAnswers = {
isUsingTypeScript: true,
esmSupport: true,
rawAnswers: {
framework: 'foo',
services: [
'wdio-foobar-service$--$foobar',
'wdio-electron-service$--$electron'
]
},
packagesToInstall: [],
tsConfigFilePath: '/foobar/tsconfig.json'
} as any
await setupTypeScript(parsedAnswers)
expect(vi.mocked(fs.writeFile).mock.calls[0][1]).toMatchSnapshot()
})
test('setupTypeScript does not create tsconfig.json if TypeScript was not selected', async () => {
const parsedAnswers = {
isUsingTypeScript: false,
esmSupport: true,
rawAnswers: {
framework: 'foo',
services: [
'wdio-foobar-service$--$foobar',
'wdio-electron-service$--$electron'
]
},
packagesToInstall: [],
tsConfigFilePath: '/foobar/tsconfig.json'
} as any
await setupTypeScript(parsedAnswers)
expect(fs.writeFile).not.toBeCalled()
expect(parsedAnswers.packagesToInstall).toEqual([])
})
test('setupTypeScript does not create tsconfig.json if there is already one', async () => {
const parsedAnswers = {
isUsingTypeScript: true,
esmSupport: true,
rawAnswers: {
framework: 'foo',
services: [
'wdio-foobar-service$--$foobar',
'wdio-electron-service$--$electron'
]
},
packagesToInstall: [],
tsConfigFilePath: '/foobar/tsconfig.json',
hasRootTSConfig: true
} as any
await setupTypeScript(parsedAnswers)
expect(fs.writeFile).not.toBeCalled()
})
describe.skip('createWDIOScript', () => {
it('can run with success', async () => {
const promise = createWDIOScript({ wdioConfigPath: '/foo/bar/wdio.conf.js' } as any)
expect(await promise)
.toBe(true)
expect(cp.spawn).toBeCalledTimes(1)
})
it('does not fail the process if spawn errors out', async () => {
vi.mocked(cp.spawn).mockReturnValue({ on: vi.fn().mockImplementation((ev, fn) => fn(1)) } as any)
expect(await createWDIOScript({ wdioConfigPath: '/foo/bar/wdio.conf.js' } as any))
.toBe(false)
expect(cp.spawn).toBeCalledTimes(1)
})
})
test('createWDIOConfig', async () => {
const answers = await parseAnswers(true)
answers.destSpecRootPath = '/tests/specs'
answers.destPageObjectRootPath = '/tests/specs'
answers.stepDefinitions = './foo/bar'
answers.specs = '/foo/bar/**'
await createWDIOConfig(answers as any)
expect(fs.writeFile).toBeCalledTimes(7)
expect(
vi.mocked(fs.writeFile).mock.calls[0][0]
.toString()
.endsWith(path.resolve('wdio.conf.js'))
).toBe(true)
})
test('runAppiumInstaller', async () => {
expect(await runAppiumInstaller({ e2eEnvironment: 'web' } as any))
.toBe(undefined)
expect(console.log).toBeCalledTimes(0)
expect($).toBeCalledTimes(0)
vi.mocked(inquirer.prompt).mockResolvedValue({
continueWithAppiumSetup: false
})
expect(await runAppiumInstaller({ e2eEnvironment: 'mobile' } as any))
.toBe(undefined)
expect(console.log).toBeCalledTimes(1)
expect($).toBeCalledTimes(0)
vi.mocked(inquirer.prompt).mockResolvedValue({
continueWithAppiumSetup: true
})
expect(await runAppiumInstaller({ e2eEnvironment: 'mobile' } as any))
.toEqual(['npx appium-installer'])
expect($).toBeCalledTimes(1)
})
afterEach(()=>{
vi.mocked(inquirer.prompt).mockClear()
vi.mocked(readDir).mockClear()
vi.mocked(ejs.renderFile).mockClear()
vi.mocked(fs.writeFile).mockClear()
vi.mocked(fs.mkdir).mockClear()
vi.mocked(cp.spawn).mockClear()
vi.mocked(installPackages).mockClear()
})

View file

@ -0,0 +1,7 @@
import { expect, test } from 'vitest'
import { SUPPORTED_PACKAGE_MANAGERS } from '../../src/utils/index.js'
test('should export SUPPORTED_PACKAGE_MANAGERS', async ()=>{
expect(SUPPORTED_PACKAGE_MANAGERS).toBeDefined()
})

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig",
"compilerOptions": {
"baseUrl": ".",
"outDir": "./build",
"rootDir": "./src"
},
"include": [
"src/**/*"
]
}

View file

@ -50,6 +50,7 @@
"async-exit-hook": "^2.0.1",
"chalk": "^5.2.0",
"chokidar": "^4.0.0",
"create-wdio": "workspace:*",
"dotenv": "^16.3.1",
"ejs": "^3.1.9",
"execa": "^9.2.0",
@ -59,7 +60,6 @@
"lodash.pickby": "^4.6.0",
"lodash.union": "^4.6.0",
"read-pkg-up": "^10.0.0",
"recursive-readdir": "^2.2.3",
"tsx": "^4.7.2",
"webdriverio": "workspace:*",
"yargs": "^17.7.2"

View file

@ -1,5 +1,7 @@
import * as config from './config.js'
import * as install from './install.js'
import { config } from 'create-wdio/config/cli'
import { install } from 'create-wdio/install/cli'
import * as repl from './repl.js'
import * as run from './run.js'

View file

@ -5,10 +5,10 @@ import type { Argv } from 'yargs'
import Launcher from '../launcher.js'
import Watcher from '../watcher.js'
import { formatConfigFilePaths, canAccessConfigPath, missingConfigurationPrompt } from './config.js'
import { coerceOptsFor } from '../utils.js'
import { coerceOptsFor, } from '../utils.js'
import { CLI_EPILOGUE } from '../constants.js'
import type { RunCommandArguments } from '../types.js'
import { config } from 'create-wdio/config/cli'
export const command = 'run <configPath>'
@ -175,11 +175,11 @@ export async function launch(wdioConfPath: string, params: Partial<RunCommandArg
export async function handler(argv: RunCommandArguments) {
const { configPath = 'wdio.conf.js', ...params } = argv
const wdioConf = await formatConfigFilePaths(configPath)
const confAccess = await canAccessConfigPath(wdioConf.fullPathNoExtension, wdioConf.fullPath)
const wdioConf = await config.formatConfigFilePaths(configPath)
const confAccess = await config.canAccessConfigPath(wdioConf.fullPathNoExtension, wdioConf.fullPath)
if (!confAccess) {
try {
await missingConfigurationPrompt('run', wdioConf.fullPathNoExtension)
await config.missingConfigurationPrompt('run', wdioConf.fullPathNoExtension)
if (process.env.WDIO_UNIT_TESTS) {
return
}

View file

@ -1,59 +1,14 @@
import fs from 'node:fs'
import path from 'node:path'
import module from 'node:module'
import { HOOK_DEFINITION } from '@wdio/utils'
import type { Options, Services, Reporters } from '@wdio/types'
import {
detectCompiler,
getDefaultFiles,
convertPackageHashToObject,
getProjectRoot,
detectPackageManager,
} from './utils.js'
import type { Questionnair } from './types.js'
const require = module.createRequire(import.meta.url)
const pkgJSON = require('../package.json')
export const pkg = pkgJSON
export const CLI_EPILOGUE = `Documentation: https://webdriver.io\n@wdio/cli (v${pkg.version})`
export const CONFIG_HELPER_INTRO = `
===============================
🤖 WDIO Configuration Wizard 🧙
===============================
`
export const SUPPORTED_COMMANDS = ['run', 'install', 'config', 'repl']
export const PMs = ['npm', 'yarn', 'pnpm', 'bun'] as const
export const SUPPORTED_CONFIG_FILE_EXTENSION = ['js', 'ts', 'mjs', 'mts', 'cjs', 'cts']
export const configHelperSuccessMessage = ({ projectRootDir, runScript, extraInfo = '' }: { projectRootDir: string, runScript: string, extraInfo: string }) => `
🤖 Successfully setup project at ${ projectRootDir } 🎉
Join our Discord Community Server and instantly find answers to your issues or queries. Or just join and say hi 👋!
🔗 https://discord.webdriver.io
Visit the project on GitHub to report bugs 🐛 or raise feature requests 💡:
🔗 https://github.com/webdriverio/webdriverio
${ extraInfo }
To run your tests, execute:
$ cd ${ projectRootDir }
$ npm run ${ runScript }
`
export const CONFIG_HELPER_SERENITY_BANNER = `
Learn more about Serenity/JS:
🔗 https://serenity-js.org/
🔗 https://serenity-js.org/handbook/test-runners/webdriverio/
`
export const DEPENDENCIES_INSTALLATION_MESSAGE = `
To install dependencies, execute:
%s
`
export const NPM_INSTALL = ''
export const ANDROID_CONFIG = {
platformName: 'Android',
@ -66,616 +21,7 @@ export const IOS_CONFIG = {
automationName: 'XCUITest',
deviceName: 'iPhone Simulator'
}
/**
* We have to use a string hash for value because InquirerJS default values do not work if we have
* objects as a `value` to be stored from the user's answers.
*/
export const SUPPORTED_PACKAGES = {
runner: [
{ name: 'E2E Testing - of Web or Mobile Applications', value: '@wdio/local-runner$--$local$--$e2e' },
{ name: 'Component or Unit Testing - in the browser\n > https://webdriver.io/docs/component-testing', value: '@wdio/browser-runner$--$browser$--$component' },
{ name: 'Desktop Testing - of Electron Applications\n > https://webdriver.io/docs/desktop-testing/electron', value: '@wdio/local-runner$--$local$--$electron' },
{ name: 'Desktop Testing - of MacOS Applications\n > https://webdriver.io/docs/desktop-testing/macos', value: '@wdio/local-runner$--$local$--$macos' },
{ name: 'VS Code Extension Testing\n > https://webdriver.io/docs/vscode-extension-testing', value: '@wdio/local-runner$--$local$--$vscode' },
{ name: 'Roku Testing - of OTT apps running on RokuOS\n > https://webdriver.io/docs/wdio-roku-service', value: '@wdio/local-runner$--$local$--$roku' }
],
framework: [
{ name: 'Mocha (https://mochajs.org/)', value: '@wdio/mocha-framework$--$mocha' },
{ name: 'Mocha with Serenity/JS (https://serenity-js.org/)', value: '@serenity-js/webdriverio$--$@serenity-js/webdriverio$--$mocha' },
{ name: 'Jasmine (https://jasmine.github.io/)', value: '@wdio/jasmine-framework$--$jasmine' },
{ name: 'Jasmine with Serenity/JS (https://serenity-js.org/)', value: '@serenity-js/webdriverio$--$@serenity-js/webdriverio$--$jasmine' },
{ name: 'Cucumber (https://cucumber.io/)', value: '@wdio/cucumber-framework$--$cucumber' },
{ name: 'Cucumber with Serenity/JS (https://serenity-js.org/)', value: '@serenity-js/webdriverio$--$@serenity-js/webdriverio$--$cucumber' },
],
reporter: [
{ name: 'spec', value: '@wdio/spec-reporter$--$spec', checked: true },
{ name: 'dot', value: '@wdio/dot-reporter$--$dot' },
{ name: 'junit', value: '@wdio/junit-reporter$--$junit' },
{ name: 'allure', value: '@wdio/allure-reporter$--$allure' },
{ name: 'sumologic', value: '@wdio/sumologic-reporter$--$sumologic' },
{ name: 'concise', value: '@wdio/concise-reporter$--$concise' },
{ name: 'json', value: '@wdio/json-reporter$--$json' },
// external
{ name: 'reportportal', value: 'wdio-reportportal-reporter$--$reportportal' },
{ name: 'video', value: 'wdio-video-reporter$--$video' },
{ name: 'cucumber-json', value: 'wdio-cucumberjs-json-reporter$--$cucumberjs-json' },
{ name: 'mochawesome', value: 'wdio-mochawesome-reporter$--$mochawesome' },
{ name: 'timeline', value: 'wdio-timeline-reporter$--$timeline' },
{ name: 'html-nice', value: 'wdio-html-nice-reporter$--$html-nice' },
{ name: 'slack', value: '@moroo/wdio-slack-reporter$--$slack' },
{ name: 'teamcity', value: 'wdio-teamcity-reporter$--$teamcity' },
{ name: 'delta', value: '@delta-reporter/wdio-delta-reporter-service$--$delta' },
{ name: 'testrail', value: '@wdio/testrail-reporter$--$testrail' },
{ name: 'light', value: 'wdio-light-reporter$--$light' },
{ name: 'wdio-json-html-reporter', value: 'wdio-json-html-reporter$--$jsonhtml' }
],
plugin: [
{ name: 'wait-for: utilities that provide functionalities to wait for certain conditions till a defined task is complete.\n > https://www.npmjs.com/package/wdio-wait-for', value: 'wdio-wait-for$--$wait-for' },
{ name: 'angular-component-harnesses: support for Angular component test harnesses\n > https://www.npmjs.com/package/@badisi/wdio-harness', value: '@badisi/wdio-harness$--$harness' },
{ name: 'Testing Library: utilities that encourage good testing practices laid down by dom-testing-library.\n > https://testing-library.com/docs/webdriverio-testing-library/intro', value: '@testing-library/webdriverio$--$testing-library' }
],
service: [
// internal or community driver services
{ name: 'visual', value: '@wdio/visual-service$--$visual' },
{ name: 'vite', value: 'wdio-vite-service$--$vite' },
{ name: 'nuxt', value: 'wdio-nuxt-service$--$nuxt' },
{ name: 'firefox-profile', value: '@wdio/firefox-profile-service$--$firefox-profile' },
{ name: 'gmail', value: 'wdio-gmail-service$--$gmail' },
{ name: 'sauce', value: '@wdio/sauce-service$--$sauce' },
{ name: 'testingbot', value: '@wdio/testingbot-service$--$testingbot' },
{ name: 'browserstack', value: '@wdio/browserstack-service$--$browserstack' },
{ name: 'lighthouse', value: '@wdio/lighthouse-service$--$lighthouse' },
{ name: 'vscode', value: 'wdio-vscode-service$--$vscode' },
{ name: 'electron', value: 'wdio-electron-service$--$electron' },
{ name: 'appium', value: '@wdio/appium-service$--$appium' },
// external
{ name: 'eslinter-service', value: 'wdio-eslinter-service$--$eslinter' },
{ name: 'lambdatest', value: 'wdio-lambdatest-service$--$lambdatest' },
{ name: 'zafira-listener', value: 'wdio-zafira-listener-service$--$zafira-listener' },
{ name: 'reportportal', value: 'wdio-reportportal-service$--$reportportal' },
{ name: 'docker', value: 'wdio-docker-service$--$docker' },
{ name: 'ui5', value: 'wdio-ui5-service$--$ui5' },
{ name: 'wiremock', value: 'wdio-wiremock-service$--$wiremock' },
{ name: 'ng-apimock', value: 'wdio-ng-apimock-service$--$ng-apimock' },
{ name: 'slack', value: 'wdio-slack-service$--$slack' },
{ name: 'cucumber-viewport-logger', value: 'wdio-cucumber-viewport-logger-service$--$cucumber-viewport-logger' },
{ name: 'intercept', value: 'wdio-intercept-service$--$intercept' },
{ name: 'docker', value: 'wdio-docker-service$--$docker' },
{ name: 'novus-visual-regression', value: 'wdio-novus-visual-regression-service$--$novus-visual-regression' },
{ name: 'rerun', value: 'wdio-rerun-service$--$rerun' },
{ name: 'winappdriver', value: 'wdio-winappdriver-service$--$winappdriver' },
{ name: 'ywinappdriver', value: 'wdio-ywinappdriver-service$--$ywinappdriver' },
{ name: 'performancetotal', value: 'wdio-performancetotal-service$--$performancetotal' },
{ name: 'cleanuptotal', value: 'wdio-cleanuptotal-service$--$cleanuptotal' },
{ name: 'aws-device-farm', value: 'wdio-aws-device-farm-service$--$aws-device-farm' },
{ name: 'ms-teams', value: 'wdio-ms-teams-service$--$ms-teams' },
{ name: 'tesults', value: 'wdio-tesults-service$--$tesults' },
{ name: 'azure-devops', value: '@gmangiapelo/wdio-azure-devops-service$--$azure-devops' },
{ name: 'google-Chat', value: 'wdio-google-chat-service$--$google-chat' },
{ name: 'qmate-service', value: '@sap_oss/wdio-qmate-service$--$qmate-service' },
{ name: 'robonut', value: 'wdio-robonut-service$--$robonut' },
{ name: 'qunit', value: 'wdio-qunit-service$--$qunit' },
{ name: 'roku', value: 'wdio-roku-service$--$roku' },
{ name: 'obsidian', value: 'wdio-obsidian-service$--$obsidian' }
]
}
export const SUPPORTED_BROWSER_RUNNER_PRESETS = [
{ name: 'Lit (https://lit.dev/)', value: '$--$' },
{ name: 'Vue.js (https://vuejs.org/)', value: '@vitejs/plugin-vue$--$vue' },
{ name: 'Svelte (https://svelte.dev/)', value: '@sveltejs/vite-plugin-svelte$--$svelte' },
{ name: 'SolidJS (https://www.solidjs.com/)', value: 'vite-plugin-solid$--$solid' },
{ name: 'StencilJS (https://stenciljs.com/)', value: '$--$stencil' },
{ name: 'React (https://reactjs.org/)', value: '@vitejs/plugin-react$--$react' },
{ name: 'Preact (https://preactjs.com/)', value: '@preact/preset-vite$--$preact' },
{ name: 'Other', value: null }
]
export const TESTING_LIBRARY_PACKAGES: Record<string, string> = {
react: '@testing-library/react',
preact: '@testing-library/preact',
vue: '@testing-library/vue',
svelte: '@testing-library/svelte',
solid: 'solid-testing-library'
}
export enum BackendChoice {
Local = 'On my local machine',
Experitest = 'In the cloud using Experitest',
Saucelabs = 'In the cloud using Sauce Labs',
Browserstack = 'In the cloud using BrowserStack',
OtherVendors = 'In the cloud using Testingbot or LambdaTest or a different service',
Grid = 'I have my own Selenium cloud'
}
export enum ElectronBuildToolChoice {
ElectronForge = 'Electron Forge (https://www.electronforge.io/)',
ElectronBuilder = 'electron-builder (https://www.electron.build/)',
SomethingElse = 'Something else'
}
enum ProtocolOptions {
HTTPS = 'https',
HTTP = 'http'
}
export enum RegionOptions {
US = 'us',
EU = 'eu'
}
export const E2E_ENVIRONMENTS = [
{ name: 'Web - web applications in the browser', value: 'web' },
{ name: 'Mobile - native, hybrid and mobile web apps, on Android or iOS', value: 'mobile' }
]
export const MOBILE_ENVIRONMENTS = [
{ name: 'Android - native, hybrid and mobile web apps, tested on emulators and real devices\n > using UiAutomator2 (https://www.npmjs.com/package/appium-uiautomator2-driver)', value: 'android' },
{ name: 'iOS - applications on iOS, iPadOS, and tvOS\n > using XCTest (https://appium.github.io/appium-xcuitest-driver)', value: 'ios' }
]
export const BROWSER_ENVIRONMENTS = [
{ name: 'Chrome', value: 'chrome', checked: true },
{ name: 'Firefox', value: 'firefox' },
{ name: 'Safari', value: 'safari' },
{ name: 'Microsoft Edge', value: 'MicrosoftEdge' }
]
function isBrowserRunner (answers: Questionnair) {
return answers.runner === SUPPORTED_PACKAGES.runner[1].value
}
export function usesSerenity (answers: Questionnair) {
return answers.framework.includes('serenity-js')
}
function getTestingPurpose (answers: Questionnair) {
return convertPackageHashToObject(answers.runner).purpose as 'e2e' | 'electron' | 'component' | 'vscode' | 'macos' | 'roku'
}
export const isNuxtProject = [
path.join(process.cwd(), 'nuxt.config.js'),
path.join(process.cwd(), 'nuxt.config.ts'),
path.join(process.cwd(), 'nuxt.config.mjs'),
path.join(process.cwd(), 'nuxt.config.mts')
].map((p) => {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}).some(Boolean)
function selectDefaultService (serviceNames: string | string[]) {
serviceNames = Array.isArray(serviceNames) ? serviceNames : [serviceNames]
return SUPPORTED_PACKAGES.service
/* istanbul ignore next */
.filter(({ name }) => serviceNames.includes(name))
.map(({ value }) => value)
}
function prioServiceOrderFor (serviceNamesParam: string | string[]) {
const serviceNames = Array.isArray(serviceNamesParam) ? serviceNamesParam : [serviceNamesParam]
let services = SUPPORTED_PACKAGES.service
for (const serviceName of serviceNames) {
const index = services.findIndex(({ name }) => name === serviceName)
services = [services[index], ...services.slice(0, index), ...services.slice(index + 1)]
}
return services
}
export const QUESTIONNAIRE = [{
type: 'list',
name: 'runner',
message: 'What type of testing would you like to do?',
choices: SUPPORTED_PACKAGES.runner
}, {
type: 'list',
name: 'preset',
message: 'Which framework do you use for building components?',
choices: SUPPORTED_BROWSER_RUNNER_PRESETS,
// only ask if there are more than 1 runner to pick from
when: /* istanbul ignore next */ isBrowserRunner
}, {
type: 'confirm',
name: 'installTestingLibrary',
message: 'Do you like to use Testing Library (https://testing-library.com/) as test utility?',
default: true,
// only ask if there are more than 1 runner to pick from
when: /* istanbul ignore next */ (answers: Questionnair) => (
isBrowserRunner(answers) &&
/**
* Only show if Testing Library has an add-on for framework
*/
answers.preset && TESTING_LIBRARY_PACKAGES[convertPackageHashToObject(answers.preset!).short]
)
}, {
type: 'list',
name: 'electronBuildTool',
message: 'Which tool are you using to build your Electron app?',
choices: Object.values(ElectronBuildToolChoice),
when: /* instanbul ignore next */ (answers: Questionnair) => getTestingPurpose(answers) === 'electron'
}, {
type: 'input',
name: 'electronAppBinaryPath',
message: 'What is the path to the binary of your built Electron app?',
when: /* istanbul ignore next */ (answers: Questionnair) => getTestingPurpose(answers) === 'electron' && (answers.electronBuildTool === ElectronBuildToolChoice.SomethingElse)
}, {
type: 'list',
name: 'backend',
message: 'Where is your automation backend located?',
choices: Object.values(BackendChoice),
when: /* instanbul ignore next */ (answers: Questionnair) => getTestingPurpose(answers) === 'e2e'
}, {
type: 'list',
name: 'e2eEnvironment',
message: 'Which environment you would like to automate?',
choices: E2E_ENVIRONMENTS,
default: 'web',
when: /* istanbul ignore next */ (answers: Questionnair) => getTestingPurpose(answers) === 'e2e'
}, {
type: 'list',
name: 'mobileEnvironment',
message: 'Which mobile environment you\'d like to automate?',
choices: MOBILE_ENVIRONMENTS,
when: /* instanbul ignore next */ (answers: Questionnair) => (
getTestingPurpose(answers) === 'e2e' &&
answers.e2eEnvironment === 'mobile'
)
}, {
type: 'checkbox',
name: 'browserEnvironment',
message: 'With which browser should we start?',
choices: BROWSER_ENVIRONMENTS,
when: /* instanbul ignore next */ (answers: Questionnair) => (
getTestingPurpose(answers) === 'e2e' &&
answers.e2eEnvironment === 'web'
)
}, {
type: 'input',
name: 'hostname',
message: 'What is the host address of that cloud service?',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.indexOf('different service') > -1
}, {
type: 'input',
name: 'port',
message: 'What is the port on which that service is running?',
default: '80',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.indexOf('different service') > -1
}, {
type: 'input',
name: 'expEnvAccessKey',
message: 'Access key from Experitest Cloud',
default: 'EXPERITEST_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Experitest
}, {
type: 'input',
name: 'expEnvHostname',
message: 'Environment variable for cloud url',
default: 'example.experitest.com',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Experitest
}, {
type: 'input',
name: 'expEnvPort',
message: 'Environment variable for port',
default: '443',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Experitest
}, {
type: 'list',
name: 'expEnvProtocol',
message: 'Choose a protocol for environment variable',
default: ProtocolOptions.HTTPS,
choices: Object.values(ProtocolOptions),
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend === BackendChoice.Experitest &&
answers.expEnvPort !== '80' &&
answers.expEnvPort !== '443'
)
}, {
type: 'input',
name: 'env_user',
message: 'Environment variable for username',
default: 'LT_USERNAME',
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend && answers.backend.indexOf('LambdaTest') > -1 &&
answers.hostname!.indexOf('lambdatest.com') > -1
)
}, {
type: 'input',
name: 'env_key',
message: 'Environment variable for access key',
default: 'LT_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend && answers.backend.indexOf('LambdaTest') > -1 &&
answers.hostname!.indexOf('lambdatest.com') > -1
)
}, {
type: 'input',
name: 'env_user',
message: 'Environment variable for username',
default: 'BROWSERSTACK_USERNAME',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Browserstack
}, {
type: 'input',
name: 'env_key',
message: 'Environment variable for access key',
default: 'BROWSERSTACK_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Browserstack
}, {
type: 'input',
name: 'env_user',
message: 'Environment variable for username',
default: 'SAUCE_USERNAME',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Saucelabs
}, {
type: 'input',
name: 'env_key',
message: 'Environment variable for access key',
default: 'SAUCE_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Saucelabs
}, {
type: 'list',
name: 'region',
message: 'In which region do you want to run your Sauce Labs tests in?',
choices: Object.values(RegionOptions),
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend === BackendChoice.Saucelabs
}, {
type: 'confirm',
name: 'useSauceConnect',
message: (
'Are you testing a local application and need Sauce Connect to be set-up?\n' +
'Read more on Sauce Connect at: https://docs.saucelabs.com/secure-connections/#sauce-connect-proxy'
),
default: isNuxtProject,
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend === BackendChoice.Saucelabs &&
!isNuxtProject
)
}, {
type: 'input',
name: 'hostname',
message: 'What is the IP or URI to your Selenium standalone or grid server?',
default: 'localhost',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.toString().indexOf('own Selenium cloud') > -1
}, {
type: 'input',
name: 'port',
message: 'What is the port which your Selenium standalone or grid server is running on?',
default: '4444',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.toString().indexOf('own Selenium cloud') > -1
}, {
type: 'input',
name: 'path',
message: 'What is the path to your browser driver or grid server?',
default: '/',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.backend && answers.backend.toString().indexOf('own Selenium cloud') > -1
}, {
type: 'list',
name: 'framework',
message: 'Which framework do you want to use?',
choices: /* instanbul ignore next */ (answers: Questionnair) => {
/**
* browser runner currently supports only Mocha framework
*/
if (isBrowserRunner(answers)) {
return SUPPORTED_PACKAGES.framework.slice(0, 1)
}
/**
* Serenity tests don't come with proper ElectronJS example files
*/
if (getTestingPurpose(answers) === 'electron') {
return SUPPORTED_PACKAGES.framework.filter(
({ value }) => !value.startsWith('@serenity-js')
)
}
return SUPPORTED_PACKAGES.framework
}
}, {
type: 'confirm',
name: 'isUsingTypeScript',
message: 'Do you want to use Typescript to write tests?',
when: /* istanbul ignore next */ (answers: Questionnair) => {
/**
* StencilJS only supports TypeScript - use the default
*/
if (answers.preset?.includes('stencil')) {
return false
}
return true
},
default: /* istanbul ignore next */ (answers: Questionnair) => answers.preset?.includes('stencil') || detectCompiler(answers)
}, {
type: 'confirm',
name: 'generateTestFiles',
message: 'Do you want WebdriverIO to autogenerate some test files?',
default: true,
when: /* istanbul ignore next */ (answers: Questionnair) => {
/**
* we only have examples for Mocha and Jasmine
*/
if (['vscode', 'electron', 'macos'].includes(getTestingPurpose(answers)) && answers.framework.includes('cucumber')) {
return false
}
return true
}
}, {
type: 'input',
name: 'specs',
message: 'What should be the location of your spec files?',
default: /* istanbul ignore next */ (answers: Questionnair) => {
const pattern = isBrowserRunner(answers) ? 'src/**/*.test' : 'test/specs/**/*'
return getDefaultFiles(answers, pattern)
},
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && /(mocha|jasmine)/.test(answers.framework)
}, {
type: 'input',
name: 'specs',
message: 'What should be the location of your feature files?',
default: (answers: Questionnair) => getDefaultFiles(answers, 'features/**/*.feature'),
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && answers.framework.includes('cucumber')
}, {
type: 'input',
name: 'stepDefinitions',
message: 'What should be the location of your step definitions?',
default: (answers: Questionnair) => getDefaultFiles(answers, 'features/step-definitions/steps'),
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && answers.framework.includes('cucumber')
}, {
type: 'confirm',
name: 'usePageObjects',
message: 'Do you want to use page objects (https://martinfowler.com/bliki/PageObject.html)?',
default: true,
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.generateTestFiles &&
/**
* page objects aren't common for component testing
*/
!isBrowserRunner(answers) &&
/**
* and also not needed when running VS Code tests since the service comes with
* its own page object implementation, nor when running Electron or MacOS tests
*/
!['vscode', 'electron', 'macos'].includes(getTestingPurpose(answers)) &&
/**
* Serenity/JS generates Lean Page Objects by default, so there's no need to ask about it
* See https://serenity-js.org/handbook/web-testing/page-objects-pattern/
*/
!usesSerenity(answers)
)
}, {
type: 'input',
name: 'pages',
message: 'Where are your page objects located?',
default: /* istanbul ignore next */ (answers: Questionnair) => (
answers.framework.match(/(mocha|jasmine)/)
? getDefaultFiles(answers, 'test/pageobjects/**/*')
: getDefaultFiles(answers, 'features/pageobjects/**/*')
),
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && answers.usePageObjects
}, {
type: 'input',
name: 'serenityLibPath',
message: 'What should be the location of your Serenity/JS Screenplay Pattern library?',
default: /* istanbul ignore next */ async (answers: Questionnair) => {
const projectRootDir = await getProjectRoot(answers)
const specsDir = path.resolve(projectRootDir, path.dirname(answers.specs || '').replace(/\*\*$/, ''))
return path.resolve(specsDir, '..', 'serenity')
},
when: /* istanbul ignore next */ (answers: Questionnair) => answers.generateTestFiles && usesSerenity(answers)
}, {
type: 'checkbox',
name: 'reporters',
message: 'Which reporter do you want to use?',
choices: SUPPORTED_PACKAGES.reporter
}, {
type: 'checkbox',
name: 'plugins',
message: 'Do you want to add a plugin to your test setup?',
choices: SUPPORTED_PACKAGES.plugin,
default: []
}, {
type: 'confirm',
name: 'includeVisualTesting',
message: 'Would you like to include Visual Testing to your setup? For more information see https://webdriver.io/docs/visual-testing!',
default: false,
when: /* istanbul ignore next */ (answers: Questionnair) => {
/**
* visual testing mostly makes sense for e2e and component tests
*/
return ['e2e', 'component'].includes(getTestingPurpose(answers))
}
}, {
type: 'checkbox',
name: 'services',
message: 'Do you want to add a service to your test setup?',
choices: (answers: Questionnair) => {
const services: string[] = []
if (answers.backend === BackendChoice.Browserstack) {
services.push('browserstack')
} else if (answers.backend === BackendChoice.Saucelabs) {
services.push('sauce')
}
if (answers.e2eEnvironment === 'mobile') {
services.push('appium')
}
if (getTestingPurpose(answers) === 'e2e' && isNuxtProject) {
services.push('nuxt')
}
if (getTestingPurpose(answers) === 'vscode') {
return [SUPPORTED_PACKAGES.service.find(({ name }) => name === 'vscode')]
} else if (getTestingPurpose(answers) === 'electron') {
return [SUPPORTED_PACKAGES.service.find(({ name }) => name === 'electron')]
} else if (getTestingPurpose(answers) === 'macos') {
return [SUPPORTED_PACKAGES.service.find(({ name }) => name === 'appium')]
} else if (getTestingPurpose(answers) === 'roku') {
return [SUPPORTED_PACKAGES.service.find(({ name }) => name === 'roku')]
}
return prioServiceOrderFor(services)
},
default: (answers: Questionnair) => {
const defaultServices: string[] = []
if (answers.backend === BackendChoice.Browserstack) {
defaultServices.push('browserstack')
} else if (answers.backend === BackendChoice.Saucelabs) {
defaultServices.push('sauce')
}
if (answers.e2eEnvironment === 'mobile' || getTestingPurpose(answers) === 'macos') {
defaultServices.push('appium')
}
if (getTestingPurpose(answers) === 'vscode') {
defaultServices.push('vscode')
} else if (getTestingPurpose(answers) === 'electron') {
defaultServices.push('electron')
} else if (getTestingPurpose(answers) === 'roku') {
defaultServices.push('roku')
}
if (isNuxtProject) {
defaultServices.push('nuxt')
}
if (answers.includeVisualTesting) {
defaultServices.push('visual')
}
return selectDefaultService(defaultServices)
}
}, {
type: 'input',
name: 'outputDir',
message: 'In which directory should the xunit reports get stored?',
default: './',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.reporters.includes('junit')
}, {
type: 'input',
name: 'outputDir',
message: 'In which directory should the json reports get stored?',
default: './',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.reporters.includes('json')
}, {
type: 'input',
name: 'outputDir',
message: 'In which directory should the mochawesome json reports get stored?',
default: './',
when: /* istanbul ignore next */ (answers: Questionnair) => answers.reporters.includes('mochawesome')
}, {
type: 'confirm',
name: 'npmInstall',
message: () => `Do you want me to run \`${detectPackageManager()} install\``,
default: true
}]
const SUPPORTED_SNAPSHOTSTATE_OPTIONS = ['all', 'new', 'none'] as const
export const COMMUNITY_PACKAGES_WITH_TS_SUPPORT = [
'wdio-electron-service',
'wdio-vscode-service',
'wdio-nuxt-service',
'wdio-vite-service',
'wdio-gmail-service',
'wdio-roku-service',
'wdio-obsidian-service'
]
export const TESTRUNNER_DEFAULTS: Options.Definition<Options.Testrunner & { capabilities: unknown }> = {
/**

View file

@ -1,79 +1,8 @@
import type { Options, Reporters } from '@wdio/types'
import type { NormalizedPackageJson } from 'read-pkg-up'
import type { BackendChoice, RegionOptions, ElectronBuildToolChoice, PMs } from './constants.js'
import type { SUPPORTED_PACKAGE_MANAGERS } from 'create-wdio/utils'
export type PM = typeof PMs[number]
export interface Questionnair {
runner: string
preset?: string
installTestingLibrary?: boolean
electronAppBinaryPath?: string
electronBuildTool?: ElectronBuildToolChoice
electronBuilderConfigPath?: string
backend?: BackendChoice
hostname?: string
port?: string
path?: string
expEnvAccessKey?: string
expEnvHostname?: string
expEnvPort?: string
expEnvProtocol?: 'http' | 'https'
env_user?: string
env_key?: string
region?: RegionOptions
useSauceConnect?: boolean
framework: string
specs?: string
stepDefinitions?: string
generateTestFiles: boolean
usePageObjects?: boolean
pages?: string
isUsingTypeScript: boolean
reporters: string[]
services: string[]
serenityLibPath?: string
plugins: string[]
outputDir?: string
includeVisualTesting: boolean
npmInstall: boolean
createPackageJSON?: boolean
projectRootCorrect?: boolean
projectRoot?: string
e2eEnvironment?: 'web' | 'mobile'
mobileEnvironment?: 'android' | 'ios'
browserEnvironment?: ('chrome' | 'firefox' | 'safari' | 'microsoftedge')[]
}
export interface ParsedAnswers extends Omit<Questionnair, 'runner' | 'framework' | 'reporters' | 'services' | 'plugins'> {
rawAnswers: Questionnair
runner: 'local' | 'browser'
projectName: string
framework: string
purpose: string
reporters: string[]
plugins: string[]
services: string[]
packagesToInstall: string[]
isUsingTypeScript: boolean
serenityAdapter: string | false
esmSupport: boolean
isSync: boolean
_async: string
_await: string
projectRootDir: string
destSpecRootPath: string
destPageObjectRootPath: string
destStepRootPath: string;
destSerenityLibRootPath: string
relativePath: string
hasRootTSConfig: boolean
tsConfigFilePath: string
tsProject: string
wdioConfigPath: string
}
export type PM = typeof SUPPORTED_PACKAGE_MANAGERS[number]
export interface RunCommandArguments {
coverage?: boolean
@ -120,12 +49,6 @@ export interface InstallCommandArguments {
name: string
}
export interface ConfigCommandArguments {
yarn: boolean
yes: boolean
npmTag: string
}
export interface SupportedPackage {
package: string
short: string

View file

@ -1,52 +1,25 @@
import fs from 'node:fs/promises'
import util, { promisify } from 'node:util'
import path, { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { SpawnOptions } from 'node:child_process'
import { execSync, spawn } from 'node:child_process'
import { promisify } from 'node:util'
import ejs from 'ejs'
import chalk from 'chalk'
import inquirer from 'inquirer'
import pickBy from 'lodash.pickby'
import logger from '@wdio/logger'
import readDir from 'recursive-readdir'
import { $ } from 'execa'
import { readPackageUp } from 'read-pkg-up'
import { resolve } from 'import-meta-resolve'
import { SevereServiceError } from 'webdriverio'
import { ConfigParser } from '@wdio/config/node'
import { CAPABILITY_KEYS } from '@wdio/protocols'
import type { Capabilities, Services } from '@wdio/types'
import { installPackages, getInstallCommand } from './install.js'
import {
ANDROID_CONFIG,
DEPENDENCIES_INSTALLATION_MESSAGE,
IOS_CONFIG,
pkg,
QUESTIONNAIRE,
TESTING_LIBRARY_PACKAGES,
COMMUNITY_PACKAGES_WITH_TS_SUPPORT,
usesSerenity,
PMs,
} from './constants.js'
import type {
OnCompleteResult,
ParsedAnswers,
ProjectProps,
Questionnair,
ReplCommandArguments,
SupportedPackage,
} from './types.js'
import { EjsHelpers } from './templates/EjsHelpers.js'
const log = logger('@wdio/cli:utils')
const __dirname = dirname(fileURLToPath(import.meta.url))
const NPM_COMMAND = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'
const VERSION_REGEXP = /(\d+)\.(\d+)\.(\d+)-(alpha|beta|)\.(\d+)\+(.+)/g
const TEMPLATE_ROOT_DIR = path.join(__dirname, 'templates', 'exampleFiles')
export const renderFile = promisify(ejs.renderFile) as (path: string, data: Record<string, unknown>) => Promise<string>
export class HookError extends SevereServiceError {
@ -172,24 +145,6 @@ export function getRunnerName(caps: WebdriverIO.Capabilities = {}) {
return runner
}
function buildNewConfigArray(str: string, type: string, change: string) {
const newStr = str
.split(`${type}s: `)[1]
.replace(/'/g, '')
const newArray = newStr.match(/(\w*)/gmi)?.filter(e => !!e).concat([change]) || []
return str
.replace('// ', '')
.replace(
new RegExp(`(${type}s: )((.*\\s*)*)`), `$1[${newArray.map(e => `'${e}'`)}]`
)
}
function buildNewConfigString(str: string, type: string, change: string) {
return str.replace(new RegExp(`(${type}: )('\\w*')`), `$1'${change}'`)
}
export function findInConfig(config: string, type: string) {
let regexStr = `[\\/\\/]*[\\s]*${type}s: [\\s]*\\[([\\s]*['|"]\\w*['|"],*)*[\\s]*\\]`
@ -201,93 +156,6 @@ export function findInConfig(config: string, type: string) {
return config.match(regex)
}
export function replaceConfig(config: string, type: string, name: string) {
if (type === 'framework') {
return buildNewConfigString(config, type, name)
}
const match = findInConfig(config, type)
if (!match || match.length === 0) {
return
}
const text = match.pop() || ''
return config.replace(text, buildNewConfigArray(text, type, name))
}
export function addServiceDeps(names: SupportedPackage[], packages: string[], update = false) {
/**
* install Appium if it is not installed globally if `@wdio/appium-service`
* was selected for install
*/
if (names.some(({ short }) => short === 'appium')) {
const result = execSync('appium --version || echo APPIUM_MISSING', { stdio: 'pipe' }).toString().trim()
if (result === 'APPIUM_MISSING') {
packages.push('appium')
} else if (update) {
console.log(
'\n=======',
'\nUsing globally installed appium', result,
'\nPlease add the following to your wdio.conf.js:',
"\nappium: { command: 'appium' }",
'\n=======\n')
}
}
}
/**
* @todo add JSComments
*/
export function convertPackageHashToObject(pkg: string, hash = '$--$'): SupportedPackage {
const [p, short, purpose] = pkg.split(hash)
return { package: p, short, purpose }
}
export function getSerenityPackages(answers: Questionnair): string[] {
const framework = convertPackageHashToObject(answers.framework)
if (framework.package !== '@serenity-js/webdriverio') {
return []
}
const packages: Record<string, Array<string | false>> = {
cucumber: [
'@cucumber/cucumber',
'@serenity-js/cucumber',
],
mocha: [
'@serenity-js/mocha',
'mocha',
],
jasmine: [
'@serenity-js/jasmine',
'jasmine',
],
common: [
'@serenity-js/assertions',
'@serenity-js/console-reporter',
'@serenity-js/core',
'@serenity-js/rest',
'@serenity-js/serenity-bdd',
'@serenity-js/web',
'npm-failsafe',
'rimraf',
]
}
if (answers.isUsingTypeScript) {
packages.mocha.push('@types/mocha')
packages.jasmine.push('@types/jasmine')
packages.common.push('@types/node')
}
return [
...packages[framework.purpose],
...packages.common,
].filter(Boolean).sort() as string[]
}
export async function getCapabilities(arg: ReplCommandArguments) {
const optionalCapabilites = {
platformVersion: arg.platformVersion,
@ -337,723 +205,6 @@ export async function getCapabilities(arg: ReplCommandArguments) {
return { capabilities: { browserName: arg.option } }
}
/**
* detect if project has a compiler file
*/
export async function detectCompiler(answers: Questionnair) {
// obviously there is no compiler, if there is no package.json
if (answers.createPackageJSON) {
return false
}
const root = await getProjectRoot(answers)
const hasRootTSConfig = await fs.access(path.resolve(root, 'tsconfig.json')).then(() => true, () => false)
return hasRootTSConfig
}
/**
* Check if package is installed
* @param {string} package to check existance for
*/
export async function hasPackage(pkg: string) {
try {
await resolve(pkg, import.meta.url)
return true
} catch {
return false
}
}
/**
* generate test files based on CLI answers
*/
export async function generateTestFiles(answers: ParsedAnswers) {
if (answers.serenityAdapter) {
return generateSerenityExamples(answers)
}
if (answers.runner === 'local') {
return generateLocalRunnerTestFiles(answers)
}
return generateBrowserRunnerTestFiles(answers)
}
const TSX_BASED_FRAMEWORKS = ['react', 'preact', 'solid', 'stencil']
export async function generateBrowserRunnerTestFiles(answers: ParsedAnswers) {
const isUsingFramework = typeof answers.preset === 'string'
const preset = getPreset(answers)
const tplRootDir = path.join(TEMPLATE_ROOT_DIR, 'browser')
await fs.mkdir(answers.destSpecRootPath, { recursive: true })
/**
* render css file
*/
if (isUsingFramework) {
const renderedCss = await renderFile(path.join(tplRootDir, 'Component.css.ejs'), { answers })
await fs.writeFile(path.join(answers.destSpecRootPath, 'Component.css'), renderedCss)
}
/**
* render component file
*/
const testExt = `${(answers.isUsingTypeScript ? 'ts' : 'js')}${TSX_BASED_FRAMEWORKS.includes(preset) ? 'x' : ''}`
const fileExt = ['svelte', 'vue'].includes(preset as 'svelte' | 'vue')
? preset!
: testExt
if (preset) {
const componentOutFileName = `Component.${fileExt}`
const renderedComponent = await renderFile(path.join(tplRootDir, `Component.${preset}.ejs`), { answers })
await fs.writeFile(path.join(answers.destSpecRootPath, componentOutFileName), renderedComponent)
}
/**
* render test file
*/
const componentFileName = preset ? `Component.${preset}.test.ejs` : 'standalone.test.ejs'
const renderedTest = await renderFile(path.join(tplRootDir, componentFileName), { answers })
await fs.writeFile(path.join(answers.destSpecRootPath, `Component.test.${testExt}`), renderedTest)
}
async function generateLocalRunnerTestFiles(answers: ParsedAnswers) {
const testFiles = answers.framework === 'cucumber'
? [path.join(TEMPLATE_ROOT_DIR, 'cucumber')]
: [path.join(TEMPLATE_ROOT_DIR, 'mochaJasmine')]
if (answers.usePageObjects) {
testFiles.push(path.join(TEMPLATE_ROOT_DIR, 'pageobjects'))
}
const files = (await Promise.all(testFiles.map((dirPath) => readDir(
dirPath,
[(file, stats) => !stats.isDirectory() && !(file.endsWith('.ejs') || file.endsWith('.feature'))]
)))).reduce((cur, acc) => [...acc, ...(cur)], [])
await Promise.all(files.map(async (file) => {
const renderedTpl = await renderFile(file, { answers })
const isJSX = answers.preset && TSX_BASED_FRAMEWORKS.includes(answers.preset)
const fileEnding = (answers.isUsingTypeScript ? '.ts' : '.js') + (isJSX ? 'x' : '')
const destPath = (
file.endsWith('page.js.ejs')
? path.join(answers.destPageObjectRootPath, path.basename(file))
: file.includes('step_definition')
? path.join(answers.destStepRootPath, path.basename(file))
: path.join(answers.destSpecRootPath, path.basename(file))
).replace(/\.ejs$/, '').replace(/\.js$/, fileEnding)
await fs.mkdir(path.dirname(destPath), { recursive: true })
await fs.writeFile(destPath, renderedTpl)
}))
}
async function generateSerenityExamples(answers: ParsedAnswers): Promise<void> {
const templateDirectories = Object.entries({
[answers.projectRootDir]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', 'common', 'config'),
[answers.destSpecRootPath]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', answers.serenityAdapter as string),
[answers.destSerenityLibRootPath]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', 'common', 'serenity'),
})
await Promise.all(templateDirectories.map(async ([destinationRootDir, templateRootDir]) => {
const pathsToTemplates = await readDir(templateRootDir)
await Promise.all(pathsToTemplates.map(async (pathToTemplate) => {
const extension = answers.isUsingTypeScript ? '.ts' : '.js'
const destination = path.join(destinationRootDir, path.relative(templateRootDir, pathToTemplate))
.replace(/\.ejs$/, '')
.replace(/\.ts$/, extension)
const contents = await renderFile(
pathToTemplate,
{ answers, _: new EjsHelpers({ useEsm: answers.esmSupport, useTypeScript: answers.isUsingTypeScript }) },
)
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.writeFile(destination, contents)
}))
}))
}
export async function getAnswers(yes: boolean): Promise<Questionnair> {
if (yes) {
const ignoredQuestions = ['e2eEnvironment']
const filterdQuestionaire = QUESTIONNAIRE.filter((question) => !ignoredQuestions.includes(question.name))
const answers = {} as Questionnair
for (const question of filterdQuestionaire) {
/**
* set nothing if question doesn't apply
*/
if (question.when && !question.when(answers)) {
continue
}
Object.assign(answers, {
[question.name]: typeof question.default !== 'undefined'
/**
* set default value if existing
*/
? typeof question.default === 'function'
? await question.default(answers)
: question.default
: question.choices && question.choices.length
/**
* pick first choice, select value if it exists
*/
? typeof question.choices === 'function'
? (question.choices(answers)[0] as unknown as { value: unknown }).value
? (question.choices(answers)[0] as unknown as { value: unknown }).value
: question.choices(answers)[0]
: (question.choices[0] as { value: unknown }).value
? question.type === 'checkbox'
? [(question.choices[0] as { value: unknown }).value]
: (question.choices[0] as { value: unknown }).value
: question.choices[0]
: {}
})
}
/**
* some questions have async defaults
*/
answers.isUsingTypeScript = await answers.isUsingTypeScript
answers.specs = await answers.specs
answers.pages = await answers.pages
return answers
}
const projectProps = await getProjectProps(process.cwd())
const isProjectExisting = Boolean(projectProps)
const nameInPackageJsonIsNotCreateWdioDefault = projectProps?.packageJson?.name !== 'my-new-project'
const projectName = projectProps?.packageJson?.name ? ` named "${projectProps.packageJson.name}"` : ''
const projectRootQuestions = [
{
type: 'confirm',
name: 'createPackageJSON',
default: true,
message: `Couldn't find a package.json in "${process.cwd()}" or any of the parent directories, do you want to create one?`,
when: !isProjectExisting
},
{
type: 'confirm',
name: 'projectRootCorrect',
default: true,
message: `A project${projectName} was detected at "${projectProps?.path}", correct?`,
when: isProjectExisting && nameInPackageJsonIsNotCreateWdioDefault
}, {
type: 'input',
name: 'projectRoot',
message: 'What is the project root for your test project?',
default: projectProps?.path,
// only ask if there are more than 1 runner to pick from
when: /* istanbul ignore next */ (answers: Questionnair) => isProjectExisting && nameInPackageJsonIsNotCreateWdioDefault && !answers.projectRootCorrect
}
]
// @ts-expect-error
const answers = await inquirer.prompt(projectRootQuestions)
if (answers.createPackageJSON) {
answers.projectRoot = process.cwd()
}
// @ts-expect-error
return inquirer.prompt(QUESTIONNAIRE, answers)
}
/**
* Generates a valid file path from answers provided.
* @param answers The answer from which a file path is to be generated.
* @param projectRootDir The root directory of the project.
* @returns filePath
*/
function generatePathfromAnswer(answers:string, projectRootDir:string):string {
return path.resolve(
projectRootDir, path.dirname(answers) === '.' ? path.resolve(answers) : path.dirname(answers))
}
export function getPathForFileGeneration(answers: Questionnair, projectRootDir: string) {
const specAnswer = answers.specs || ''
const stepDefinitionAnswer = answers.stepDefinitions || ''
const pageObjectAnswer = answers.pages || ''
const destSpecRootPath = generatePathfromAnswer(specAnswer, projectRootDir).replace(/\*\*$/, '')
const destStepRootPath = generatePathfromAnswer(stepDefinitionAnswer, projectRootDir)
const destPageObjectRootPath = answers.usePageObjects
? generatePathfromAnswer(pageObjectAnswer, projectRootDir).replace(/\*\*$/, '')
: ''
const destSerenityLibRootPath = usesSerenity(answers)
? path.resolve(projectRootDir, answers.serenityLibPath || 'serenity')
: ''
const relativePath = (answers.generateTestFiles && answers.usePageObjects)
? !(convertPackageHashToObject(answers.framework).short === 'cucumber')
? path.relative(destSpecRootPath, destPageObjectRootPath)
: path.relative(destStepRootPath, destPageObjectRootPath)
: ''
return {
destSpecRootPath: destSpecRootPath,
destStepRootPath: destStepRootPath,
destPageObjectRootPath: destPageObjectRootPath,
destSerenityLibRootPath: destSerenityLibRootPath,
relativePath: relativePath.replaceAll(path.sep, '/')
}
}
export async function getDefaultFiles(answers: Questionnair, pattern: string) {
const rootdir = await getProjectRoot(answers)
const presetPackage = convertPackageHashToObject(answers.preset || '')
const isJSX = TSX_BASED_FRAMEWORKS.includes(presetPackage.short || '')
const val = pattern.endsWith('.feature')
? path.join(rootdir, pattern)
: answers?.isUsingTypeScript
? `${path.join(rootdir, pattern)}.ts${isJSX ? 'x' : ''}`
: `${path.join(rootdir, pattern)}.js${isJSX ? 'x' : ''}`
return val
}
/**
* Ensure core WebdriverIO packages have the same version as cli so that if someone
* installs `@wdio/cli@next` and runs the wizard, all related packages have the same version.
* running `matchAll` to a version like "8.0.0-alpha.249+4bc237701", results in:
* ['8.0.0-alpha.249+4bc237701', '8', '0', '0', 'alpha', '249', '4bc237701']
*/
export function specifyVersionIfNeeded(packagesToInstall: string[], version: string, npmTag: string) {
const { value } = version.matchAll(VERSION_REGEXP).next()
const [major, minor, patch, tagName, build] = (value || []).slice(1, -1) // drop commit bit
return packagesToInstall.map((p) => {
if (
(p.startsWith('@wdio') && p !== '@wdio/visual-service') ||
['webdriver', 'webdriverio'].includes(p)
) {
const tag = major && npmTag === 'latest'
? `^${major}.${minor}.${patch}-${tagName}.${build}`
: npmTag
return `${p}@${tag}`
}
return p
})
}
/**
* Receive project properties
* @returns {@type ProjectProps} if a package.json can be found in cwd or parent directories, otherwise undefined
* which means that a new project can be created
*/
export async function getProjectProps(cwd = process.cwd()): Promise<ProjectProps | undefined> {
try {
const { packageJson, path: packageJsonPath } = await readPackageUp({ cwd }) || {}
if (!packageJson || !packageJsonPath) {
return undefined
}
return {
esmSupported: (
packageJson.type === 'module' ||
typeof packageJson.module === 'string'
),
packageJson,
path: path.dirname(packageJsonPath)
}
} catch {
return undefined
}
}
export function runProgram(command: string, args: string[], options: SpawnOptions) {
const child = spawn(command, args, { stdio: 'inherit', ...options })
return new Promise<void>((resolve, reject) => {
let error: Error
child.on('error', (e) => (error = e))
child.on('close', code => {
if (code !== 0) {
return reject(new Error(
(error && error.message) ||
`Error calling: ${command} ${args.join(' ')}`
))
}
resolve()
})
})
}
/**
* create package.json if not already existing
*/
export async function createPackageJSON(parsedAnswers: ParsedAnswers) {
const packageJsonExists = await fs.access(path.resolve(process.cwd(), 'package.json')).then(() => true, () => false)
// Use the exisitng package.json if it already exists.
if (packageJsonExists) {
return
}
// If a user said no to creating a package.json, but it doesn't exist, abort.
if (parsedAnswers.createPackageJSON === false) {
/* istanbul ignore if */
if (!packageJsonExists) {
console.log(`No WebdriverIO configuration found in "${parsedAnswers.wdioConfigPath}"`)
return !process.env.WDIO_UNIT_TESTS && process.exit(0)
}
return
}
// Only create if the user gave explicit permission to
if (parsedAnswers.createPackageJSON) {
console.log(`Creating a ${chalk.bold('package.json')} for the directory...`)
await fs.writeFile(path.resolve(process.cwd(), 'package.json'), JSON.stringify({
name: 'webdriverio-tests',
version: '0.0.0',
private: true,
license: 'ISC',
type: 'module',
dependencies: {},
devDependencies: {}
}, null, 2))
console.log(chalk.green(chalk.bold('✔ Success!\n')))
}
}
/**
* run npm install only if required by the user
*/
const SEP = '\n- '
export async function npmInstall(parsedAnswers: ParsedAnswers, npmTag: string) {
const servicePackages = parsedAnswers.rawAnswers.services.map((service) => convertPackageHashToObject(service))
const presetPackage = convertPackageHashToObject(parsedAnswers.rawAnswers.preset || '')
/**
* install Testing Library dependency if desired
*/
if (parsedAnswers.installTestingLibrary && TESTING_LIBRARY_PACKAGES[presetPackage.short]) {
parsedAnswers.packagesToInstall.push(
TESTING_LIBRARY_PACKAGES[presetPackage.short],
'@testing-library/jest-dom'
)
}
/**
* add helper package for Solidjs testing
*/
if (presetPackage.short === 'solid') {
parsedAnswers.packagesToInstall.push('solid-js')
}
/**
* add visual service if user selected support for it
*/
if (parsedAnswers.includeVisualTesting) {
parsedAnswers.packagesToInstall.push('@wdio/visual-service')
}
/**
* add dependency for Lit testing
*/
const preset = getPreset(parsedAnswers)
if (preset === 'lit') {
parsedAnswers.packagesToInstall.push('lit')
}
/**
* add dependency for Stencil testing
*/
if (preset === 'stencil') {
parsedAnswers.packagesToInstall.push('@stencil/core')
}
/**
* add helper for React rendering when not using Testing Library
*/
if (presetPackage.short === 'react') {
parsedAnswers.packagesToInstall.push('react')
if (!parsedAnswers.installTestingLibrary) {
parsedAnswers.packagesToInstall.push('react-dom')
}
}
/**
* add Jasmine types if necessary
*/
if (parsedAnswers.framework === 'jasmine' && parsedAnswers.isUsingTypeScript) {
parsedAnswers.packagesToInstall.push('@types/jasmine')
}
/**
* add Appium mobile drivers if desired
*/
if (parsedAnswers.purpose === 'macos') {
parsedAnswers.packagesToInstall.push('appium-mac2-driver')
}
if (parsedAnswers.mobileEnvironment === 'android') {
parsedAnswers.packagesToInstall.push('appium-uiautomator2-driver')
}
if (parsedAnswers.mobileEnvironment === 'ios') {
parsedAnswers.packagesToInstall.push('appium-xcuitest-driver')
}
/**
* add packages that are required by services
*/
addServiceDeps(servicePackages, parsedAnswers.packagesToInstall)
/**
* update package version if CLI is a pre release
*/
parsedAnswers.packagesToInstall = specifyVersionIfNeeded(parsedAnswers.packagesToInstall, pkg.version, npmTag)
const cwd = await getProjectRoot(parsedAnswers)
const pm = detectPackageManager()
if (parsedAnswers.npmInstall) {
console.log(`Installing packages using ${pm}:${SEP}${parsedAnswers.packagesToInstall.join(SEP)}`)
const success = await installPackages(cwd, parsedAnswers.packagesToInstall, true)
if (success) {
console.log(chalk.green(chalk.bold('✔ Success!\n')))
}
} else {
const installationCommand = getInstallCommand(pm, parsedAnswers.packagesToInstall, true)
console.log(util.format(DEPENDENCIES_INSTALLATION_MESSAGE, installationCommand))
}
}
/**
* detect the package manager that was used
* uses the environment variable `npm_config_user_agent` to detect the package manager
* falls back to `npm` if no package manager could be detected
*/
export function detectPackageManager() {
if (!process.env.npm_config_user_agent) {
return 'npm'
}
const detectedPM = process.env.npm_config_user_agent.split('/')[0].toLowerCase()
const matchedPM = PMs.find(pm => pm.toLowerCase() === detectedPM)
return matchedPM || 'npm'
}
/**
* set up TypeScript if it is desired but not installed
*/
export async function setupTypeScript(parsedAnswers: ParsedAnswers) {
/**
* don't create a `tsconfig.json` if user doesn't want to use TypeScript
*/
if (!parsedAnswers.isUsingTypeScript) {
return
}
/**
* don't set up TypeScript if a `tsconfig.json` already exists but ensure we install `tsx`
* as it is a requirement for running tests with TypeScript
*/
if (parsedAnswers.hasRootTSConfig) {
return
}
console.log('Setting up TypeScript...')
const frameworkPackage = convertPackageHashToObject(parsedAnswers.rawAnswers.framework)
const servicePackages = parsedAnswers.rawAnswers.services.map((service) => convertPackageHashToObject(service))
const serenityTypes = parsedAnswers.serenityAdapter === 'jasmine' ? ['jasmine'] : []
const types = [
'node',
...(parsedAnswers.framework === 'jasmine' ? ['jasmine'] : []),
'@wdio/globals/types',
...(parsedAnswers.framework === 'jasmine' ? ['expect-webdriverio/jasmine'] : ['expect-webdriverio']),
...(parsedAnswers.serenityAdapter ? serenityTypes : [frameworkPackage.package]),
...(parsedAnswers.runner === 'browser' ? ['@wdio/browser-runner'] : []),
...servicePackages
.filter(service => (
/**
* given that we know that all "official" services have
* typescript support we only include them
*/
service.package.startsWith('@wdio') ||
/**
* also include community maintained packages with known
* support for TypeScript
*/
COMMUNITY_PACKAGES_WITH_TS_SUPPORT.includes(service.package)
)).map(service => service.package)
]
const preset = getPreset(parsedAnswers)
const config = {
compilerOptions: {
// compiler
moduleResolution: 'node',
module: !parsedAnswers.esmSupport ? 'commonjs' : 'ESNext',
target: 'es2022',
lib: ['es2022', 'dom'],
types,
skipLibCheck: true,
// bundler
noEmit: true,
allowImportingTsExtensions: true,
resolveJsonModule: true,
isolatedModules: true,
// linting
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
noFallthroughCasesInSwitch: true,
...Object.assign(
preset === 'lit'
? {
experimentalDecorators: true,
useDefineForClassFields: false
}
: {},
preset === 'react'
? {
jsx: 'react-jsx'
}
: {},
preset === 'preact'
? {
jsx: 'react-jsx',
jsxImportSource: 'preact'
}
: {},
preset === 'solid'
? {
jsx: 'preserve',
jsxImportSource: 'solid-js'
}
: {},
preset === 'stencil'
? {
experimentalDecorators: true,
jsx: 'react',
jsxFactory: 'h',
jsxFragmentFactory: 'Fragment'
}
: {}
)
},
include: preset === 'svelte'
? ['src/**/*.d.ts', 'src/**/*.ts', 'src/**/*.js', 'src/**/*.svelte']
: preset === 'vue'
? ['src/**/*.ts', 'src/**/*.d.ts', 'src/**/*.tsx', 'src/**/*.vue']
: ['test', 'wdio.conf.ts']
}
if (parsedAnswers.framework === 'cucumber') {
config.include.push('features')
}
await fs.mkdir(path.dirname(parsedAnswers.tsConfigFilePath), { recursive: true })
await fs.writeFile(
parsedAnswers.tsConfigFilePath,
JSON.stringify(config, null, 4)
)
console.log(chalk.green(chalk.bold('✔ Success!\n')))
}
function getPreset (parsedAnswers: ParsedAnswers) {
const isUsingFramework = typeof parsedAnswers.preset === 'string'
return isUsingFramework ? (parsedAnswers.preset || 'lit') : ''
}
export async function createWDIOConfig(parsedAnswers: ParsedAnswers) {
try {
console.log('Creating a WebdriverIO config file...')
const tplPath = path.resolve(__dirname, 'templates', 'wdio.conf.tpl.ejs')
const renderedTpl = await renderFile(tplPath, {
answers: parsedAnswers,
_: new EjsHelpers({ useEsm: parsedAnswers.esmSupport, useTypeScript: parsedAnswers.isUsingTypeScript })
})
await fs.writeFile(parsedAnswers.wdioConfigPath, renderedTpl)
console.log(chalk.green(chalk.bold('✔ Success!\n')))
if (parsedAnswers.generateTestFiles) {
console.log('Autogenerating test files...')
await generateTestFiles(parsedAnswers)
console.log(chalk.green(chalk.bold('✔ Success!\n')))
}
} catch (err) {
throw new Error(`⚠️ Couldn't write config file: ${(err as Error).stack}`)
}
}
/**
* Get project root directory based on questionair answers
* @param answers questionair answers
* @param projectProps project properties received via `getProjectProps`
* @returns project root path
*/
export async function getProjectRoot (parsedAnswers?: Questionnair) {
if (parsedAnswers?.createPackageJSON && parsedAnswers.projectRoot) {
return parsedAnswers.projectRoot
}
const root = (await getProjectProps())?.path
if (!root) {
throw new Error('Could not find project root directory with a package.json')
}
return !parsedAnswers || parsedAnswers.projectRootCorrect
? root
: parsedAnswers.projectRoot || process.cwd()
}
export async function createWDIOScript(parsedAnswers: ParsedAnswers) {
const rootDir = await getProjectRoot(parsedAnswers)
const pathToWdioConfig = `./${path.join('.', parsedAnswers.wdioConfigPath.replace(rootDir, ''))}`
const wdioScripts = {
'wdio': `wdio run ${ pathToWdioConfig }`,
}
const serenityScripts = {
'serenity': 'failsafe serenity:clean wdio serenity:report',
'serenity:clean': 'rimraf target',
'wdio': `wdio run ${ pathToWdioConfig }`,
'serenity:report': 'serenity-bdd run',
}
const scripts = parsedAnswers.serenityAdapter ? serenityScripts : wdioScripts
for (const [script, command] of Object.entries(scripts)) {
const args = ['pkg', 'set', `scripts.${ script }=${ command }`]
try {
console.log(`Adding ${chalk.bold(`"${ script }"`)} script to package.json`)
await runProgram(NPM_COMMAND, args, { cwd: parsedAnswers.projectRootDir })
} catch (err) {
const [preArgs, scriptPath] = args.join(' ').split('=')
console.error(
`⚠️ Couldn't add script to package.json: "${(err as Error).message}", you can add it manually ` +
`by running:\n\n\t${NPM_COMMAND} ${preArgs}="${scriptPath}"`
)
return false
}
}
console.log(chalk.green(chalk.bold('✔ Success!')))
return true
}
export async function runAppiumInstaller(parsedAnswers: ParsedAnswers) {
if (parsedAnswers.e2eEnvironment !== 'mobile') {
return
}
const answer = await inquirer.prompt({
name: 'continueWithAppiumSetup',
message: 'Continue with Appium setup using appium-installer (https://github.com/AppiumTestDistribution/appium-installer)?',
type: 'confirm',
default: true
})
if (!answer.continueWithAppiumSetup) {
return console.log(
'Ok! You can learn more about setting up mobile environments in the ' +
'Appium docs at https://appium.io/docs/en/latest/quickstart/'
)
}
return $({ stdio: 'inherit' })`npx appium-installer`
}
const cucumberTypes: Record<string, string> = {
paths: 'array',
backtrace: 'boolean',

View file

@ -1,23 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`getAnswers 1`] = `
{
"backend": "On my local machine",
"framework": "@wdio/mocha-framework$--$mocha",
"generateTestFiles": true,
"includeVisualTesting": false,
"isUsingTypeScript": false,
"npmInstall": true,
"plugins": [],
"reporters": [
"@wdio/spec-reporter$--$spec",
],
"runner": "@wdio/local-runner$--$local$--$e2e",
"services": [],
"usePageObjects": true,
}
`;
exports[`getCapabilities > should get capability from wdio.conf.js 1`] = `
{
"capabilities": {
@ -98,54 +80,3 @@ exports[`getCapabilities > should throw capability not provided 1`] = `[Error: P
exports[`getCapabilities > should throw config not found error 1`] = `[Error: Config File not found: ./test.js]`;
exports[`getCapabilities > should throw config not found error 2`] = `[Error: Could not parse ./test.js, failed with error: ups]`;
exports[`npmInstall 1`] = `
[
[
"/foo/bar",
[
"foo$--$bar",
"bar$--$foo",
"@testing-library/vue",
"@testing-library/jest-dom",
"@wdio/visual-service",
"@types/jasmine",
],
true,
],
]
`;
exports[`setupTypeScript 1`] = `
"{
"compilerOptions": {
"moduleResolution": "node",
"module": "ESNext",
"target": "es2022",
"lib": [
"es2022",
"dom"
],
"types": [
"node",
"@wdio/globals/types",
"expect-webdriverio",
"foo",
"wdio-electron-service"
],
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"test",
"wdio.conf.ts"
]
}"
`;

View file

@ -5,7 +5,7 @@ import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest'
// @ts-expect-error mock
import { yargs } from 'yargs'
import * as runCmd from '../../src/commands/run.js'
import * as configCmd from '../../src/commands/config.js'
import { config as configCmd } from 'create-wdio/config/cli'
vi.mock('yargs')
vi.mock('node:child_process', () => ({
@ -16,12 +16,21 @@ vi.mock('node:child_process', () => ({
exec: vi.fn()
}))
vi.mock('node:fs/promises', async (orig) => ({
...(await orig()) as any,
// ...(await orig()) as any,
default: {
access: vi.fn().mockResolvedValue(''),
readFile: vi.fn()
}
}))
vi.mock('create-wdio/config/cli', async (importActual)=>{
const actual = await importActual() as any
return {
config: {
...actual.config,
missingConfigurationPrompt:vi.fn(),
}
}
})
vi.mock('./../../src/launcher', () => ({
default: class {
constructor (public wdioConfPath: string) {}
@ -48,7 +57,7 @@ describe('Command: run', () => {
beforeEach(() => {
vi.mocked(fs.access).mockResolvedValue()
vi.spyOn(configCmd, 'missingConfigurationPrompt').mockImplementation((): Promise<never> => {
vi.mocked(configCmd.missingConfigurationPrompt).mockImplementation((): Promise<never> => {
return undefined as never
})
vi.spyOn(console, 'error')

View file

@ -1,19 +0,0 @@
import { test, vi, expect } from 'vitest'
import { getAnswers } from '../src/utils.js'
vi.mock('node:fs/promises', async (orig) => ({
...(await orig()) as any,
default: {
access: vi.fn().mockRejectedValue(new Error('ENOENT'))
}
}))
test('runConfig with yes param', async () => {
const answers = await getAnswers(true)
expect(answers.backend).toEqual('On my local machine')
expect(answers.includeVisualTesting).toEqual(false)
expect(answers.generateTestFiles).toBeTruthy()
expect(answers.usePageObjects).toBeTruthy()
expect(answers.framework).toEqual('@wdio/mocha-framework$--$mocha')
expect(answers.specs).toEqual(expect.any(String))
})

View file

@ -1,26 +1,9 @@
import { describe, it, expect } from 'vitest'
import { TESTRUNNER_DEFAULTS } from '../src/constants.js'
import { convertPackageHashToObject } from '../src/utils.js'
import { SUPPORTED_PACKAGES } from '../src/constants.js'
const supportedInstallations = [
...SUPPORTED_PACKAGES.runner.map(({ value }) => convertPackageHashToObject(value)),
...SUPPORTED_PACKAGES.plugin.map(({ value }) => convertPackageHashToObject(value)),
...SUPPORTED_PACKAGES.service.map(({ value }) => convertPackageHashToObject(value)),
...SUPPORTED_PACKAGES.reporter.map(({ value }) => convertPackageHashToObject(value)),
...SUPPORTED_PACKAGES.framework.map(({ value }) => convertPackageHashToObject(value))
]
class CustomReporter {}
describe('constants', () => {
describe('plugin list', () => {
it('should provide all a short name', () => {
const pluginsWithoutShorts = supportedInstallations.filter((plugin) => !plugin.short)
expect(pluginsWithoutShorts).toHaveLength(0)
})
})
describe('TESTRUNNER_DEFAULTS', () => {
it('should expect specs to be an array', () => {
@ -53,11 +36,8 @@ describe('constants', () => {
TESTRUNNER_DEFAULTS.capabilities!.validate!([{}])
TESTRUNNER_DEFAULTS.capabilities!.validate!({})
// @ts-expect-error wrong parameter
expect(() => TESTRUNNER_DEFAULTS.capabilities!.validate!('foobar')).toThrow()
// @ts-expect-error wrong parameter
expect(() => TESTRUNNER_DEFAULTS.capabilities!.validate!(false)).toThrow()
// @ts-expect-error wrong parameter
expect(() => TESTRUNNER_DEFAULTS.capabilities!.validate!([{}, 'foobar'])).toThrow()
})

View file

@ -1,31 +0,0 @@
import { describe, it, vi, expect } from 'vitest'
import { execa } from 'execa'
import { installPackages, getInstallCommand } from '../src/install.js'
vi.mock('execa', () => ({
execa: vi.fn().mockResolvedValue({ stdout: 'foo', stderr: 'bar', exitCode: 0 })
}))
vi.mock('detect', () => ({
detect: vi.fn().mockResolvedValue('pnpm')
}))
describe('install', () => {
it('installPackages passing', async () => {
expect(await installPackages('/foo/bar', ['foo', 'bar'], true))
.toBe(true)
})
it('installPackages failing', async () => {
vi.mocked(execa).mockResolvedValue({ stdout: 'foo', stderr: 'bar', exitCode: 1 } as any)
expect(await installPackages('/foo/bar', ['foo', 'bar'], false))
.toBe(false)
})
it('getInstallCommand', () => {
expect(getInstallCommand('pnpm', ['foo', 'bar'], true))
.toBe('pnpm add foo bar --save-dev')
})
})

View file

@ -3,7 +3,6 @@ import * as cp from 'node:child_process'
import fs from 'node:fs/promises'
import { vi, describe, it, expect, afterEach, beforeEach, test } from 'vitest'
import { $ } from 'execa'
import ejs from 'ejs'
import inquirer from 'inquirer'
import readDir from 'recursive-readdir'
@ -17,31 +16,8 @@ import {
runServiceHook,
getRunnerName,
findInConfig,
replaceConfig,
addServiceDeps,
convertPackageHashToObject,
getCapabilities,
generateTestFiles,
getPathForFileGeneration,
getDefaultFiles,
hasPackage,
specifyVersionIfNeeded,
getProjectRoot,
getAnswers,
getProjectProps,
runProgram,
createPackageJSON,
npmInstall,
setupTypeScript,
createWDIOConfig,
createWDIOScript,
runAppiumInstaller,
detectPackageManager,
detectCompiler
} from '../src/utils.js'
import { parseAnswers } from '../src/commands/config.js'
import { installPackages } from '../src/install.js'
import type { Questionnair } from '../src/types.js'
vi.mock('ejs')
vi.mock('inquirer')
@ -83,11 +59,6 @@ vi.mock('execa', () => ({
$: vi.fn().mockReturnValue(async (sh: string) => sh)
}))
vi.mock('../src/install', () => ({
installPackages: vi.fn(),
getInstallCommand: vi.fn().mockReturnValue('npm install foo bar --save-dev')
}))
beforeEach(() => {
global.console.log = vi.fn()
@ -287,86 +258,6 @@ describe('findInConfig', () => {
})
})
describe('replaceConfig', () => {
it('correctly changes framework', () => {
const fakeConfig = `exports.config = {
runner: 'local',
specs: [
'./test/specs/**/*.js'
],
framework: 'mocha',
}`
expect(replaceConfig(fakeConfig, 'framework', 'jasmine')).toBe(
`exports.config = {
runner: 'local',
specs: [
'./test/specs/**/*.js'
],
framework: 'jasmine',
}`
)
})
it('correctly changes service', () => {
const fakeConfig = `exports.config = {
runner: 'local',
specs: [
'./test/specs/**/*.js'
],
services: [],
framework: 'mocha',
}`
expect(replaceConfig(fakeConfig, 'service', 'sauce')).toBe(
`exports.config = {
runner: 'local',
specs: [
'./test/specs/**/*.js'
],
services: ['sauce'],
framework: 'mocha',
}`
)
})
})
describe('addServiceDeps', () => {
it('should add appium', () => {
const packages: any = []
addServiceDeps([{ package: '@wdio/appium-service', short: 'appium', purpose: 'e2e' }], packages)
expect(packages).toEqual(['appium'])
})
it('should not add appium if globally installed', () => {
// @ts-ignore
cp.execSyncRes = '1.13.0'
const packages: any = []
addServiceDeps([{ package: '@wdio/appium-service', short: 'appium', purpose: 'e2e' }], packages)
expect(packages).toEqual([])
})
afterEach(() => {
vi.mocked(global.console.log).mockClear()
})
})
describe('convertPackageHashToObject', () => {
it('works with default `$--$` hash', () => {
expect(convertPackageHashToObject('test/package-name$--$package-name')).toMatchObject({
package: 'test/package-name',
short: 'package-name'
})
})
it('works with custom hash', () => {
expect(convertPackageHashToObject('test/package-name##-##package-name', '##-##')).toMatchObject({
package: 'test/package-name',
short: 'package-name'
})
})
})
describe('getCapabilities', () => {
it('should return driver with capabilities for android', async () => {
expect(await getCapabilities({ option: 'foo.apk' } as any)).toMatchSnapshot()
@ -431,574 +322,6 @@ describe('getCapabilities', () => {
})
})
test('hasPackage', async () => {
expect(await hasPackage('yargs')).toBe(true)
expect(await hasPackage('foobar')).toBe(false)
})
describe('generateTestFiles', () => {
it('Mocha with page objects', async () => {
vi.mocked(readDir).mockResolvedValue([
'/foo/bar/loo/page.js.ejs',
'/foo/bar/example.e2e.js'
] as any)
const answers = {
runner: 'local',
framework: 'mocha',
usePageObjects: true,
generateTestFiles: true,
destPageObjectRootPath: '/tests/page/objects/model',
destSpecRootPath: '/tests/specs'
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(2)
expect(vi.mocked(readDir).mock.calls[0][0]).toContain('mocha')
expect(vi.mocked(readDir).mock.calls[1][0]).toContain('pageobjects')
/**
* test readDir callback
*/
const readDirCb = vi.mocked(readDir).mock.calls[0][1][0] as Function
const stats = { isDirectory: vi.fn().mockReturnValue(false) }
expect(readDirCb('/foo/bar.lala', stats)).toBe(true)
expect(readDirCb('/foo/bar.js.ejs', stats)).toBe(false)
expect(readDirCb('/foo/bar.feature', stats)).toBe(false)
stats.isDirectory.mockReturnValue(true)
expect(readDirCb('/foo/bar.lala', stats)).toBe(false)
expect(readDirCb('/foo/bar.js.ejs', stats)).toBe(false)
expect(readDirCb('/foo/bar.feature', stats)).toBe(false)
expect(ejs.renderFile).toBeCalledTimes(4)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/loo/page.js.ejs',
{ answers },
expect.any(Function)
)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/example.e2e.js',
{ answers },
expect.any(Function)
)
expect(fs.mkdir).toBeCalledTimes(4)
expect((vi.mocked(fs.writeFile).mock.calls[0][0] as string)
.endsWith(`${path.sep}page${path.sep}objects${path.sep}model${path.sep}page.js`))
.toBe(true)
expect((vi.mocked(fs.writeFile).mock.calls[1][0] as string)
.endsWith(`${path.sep}example.e2e.js`))
.toBe(true)
})
it('jasmine with page objects', async () => {
const answers = {
runner: 'local',
framework: 'jasmine',
usePageObjects: true,
generateTestFiles: true,
destPageObjectRootPath: '/tests/page/objects/model',
destSpecRootPath: '/tests/specs'
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(2)
expect(vi.mocked(readDir).mock.calls[0][0]).toContain('mochaJasmine')
expect(vi.mocked(readDir).mock.calls[1][0]).toContain('pageobjects')
/**
* test readDir callback
*/
const readDirCb = vi.mocked(readDir).mock.calls[0][1][0] as Function
const stats = { isDirectory: vi.fn().mockReturnValue(false) }
expect(readDirCb('/foo/bar.lala', stats)).toBe(true)
expect(readDirCb('/foo/bar.js.ejs', stats)).toBe(false)
expect(readDirCb('/foo/bar.feature', stats)).toBe(false)
stats.isDirectory.mockReturnValue(true)
expect(readDirCb('/foo/bar.lala', stats)).toBe(false)
expect(readDirCb('/foo/bar.js.ejs', stats)).toBe(false)
expect(readDirCb('/foo/bar.feature', stats)).toBe(false)
expect(ejs.renderFile).toBeCalledTimes(4)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/loo/page.js.ejs',
{ answers },
expect.any(Function)
)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/example.e2e.js',
{ answers },
expect.any(Function)
)
expect(fs.mkdir).toBeCalledTimes(4)
expect((vi.mocked(fs.writeFile).mock.calls[0][0] as string)
.endsWith(`${path.sep}page${path.sep}objects${path.sep}model${path.sep}page.js`))
.toBe(true)
expect((vi.mocked(fs.writeFile).mock.calls[1][0] as string)
.endsWith(`${path.sep}example.e2e.js`))
.toBe(true)
})
it('Jasmine with page generation and no pageObjects', async () => {
vi.mocked(readDir).mockResolvedValue([] as any)
const answers = {
runner: 'local',
specs: './tests/e2e/**/*.js',
framework: 'jasmine',
generateTestFiles: false,
usePageObjects: false
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(1)
expect(ejs.renderFile).toBeCalledTimes(0)
})
it('Cucumber with page generation and no pageObjects', async () => {
vi.mocked(readDir).mockResolvedValue([] as any)
const answers = {
runner: 'local',
specs: './tests/e2e/**/*.js',
framework: 'cucumber',
generateTestFiles: false,
usePageObjects: false,
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(1)
expect(ejs.renderFile).toBeCalledTimes(0)
})
it('Cucumber without page objects', async () => {
vi.mocked(readDir).mockResolvedValue([
'/foo/bar/loo/step_definition/example.step.js',
'/foo/bar/example.feature'
] as any)
const answers = {
runner: 'local',
specs: './tests/e2e/*.js',
framework: 'cucumber',
stepDefinitions: '/some/step/defs',
usePageObjects: false,
generateTestFiles: true,
destSpecRootPath: '/tests/specs',
destStepRootPath: '/tests/stepDefinitions'
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(1)
expect(vi.mocked(readDir).mock.calls[0][0]).toContain('cucumber')
expect(ejs.renderFile).toBeCalledTimes(2)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/loo/step_definition/example.step.js',
{ answers },
expect.any(Function)
)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/example.feature',
{ answers },
expect.any(Function)
)
expect(fs.mkdir).toBeCalledTimes(2)
})
it('Cucumber with page objects and TypeScript', async () => {
vi.mocked(readDir).mockResolvedValue([
'/foo/bar/loo/page.js.ejs',
'/foo/bar/loo/step_definition/example.step.js',
'/foo/bar/example.feature'
] as any)
const answers = {
runner: 'local',
framework: 'cucumber',
usePageObjects: true,
isUsingTypeScript: true,
destStepRootPath: '/tests/stepDefinitions',
destSpecRootPath: '/tests/specs',
destPageObjectRootPath: '/some/page/objects',
relativePath: '../page/object'
}
await generateTestFiles(answers as any)
expect(readDir).toBeCalledTimes(2)
expect(vi.mocked(readDir).mock.calls[0][0]).toContain('cucumber')
expect(ejs.renderFile).toBeCalledTimes(6)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/loo/step_definition/example.step.js',
{ answers },
expect.any(Function)
)
expect(ejs.renderFile).toBeCalledWith(
'/foo/bar/example.feature',
{ answers },
expect.any(Function)
)
expect(fs.mkdir).toBeCalledTimes(6)
expect(
(vi.mocked(fs.writeFile).mock.calls[0][0] as string)
.endsWith(`${path.sep}some${path.sep}page${path.sep}objects${path.sep}page.ts`)
).toBe(true)
expect(
(vi.mocked(fs.writeFile).mock.calls[2][0] as string)
.endsWith(`${path.sep}example.feature`)
).toBe(true)
})
})
describe('getPathForFileGeneration', () => {
it('Cucumber with pageobjects default values', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
stepDefinitions: './features/step-definitions/steps.js',
pages: './features/pageobjects/**/*.js',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$cucumber'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../pageobjects')
})
it('Cucumber with pageobjects default different path', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
stepDefinitions: './features/step-definitions/steps.js',
pages: './features/page/objects/**/*.js',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$cucumber'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../page/objects')
})
it('Cucumber with pageobjects and steps different path', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
stepDefinitions: 'cucumber/features/steps',
pages: 'cucumber/features/pages',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$cucumber'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('')
})
it('Cucumber with answer that is not a path', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
stepDefinitions: 'y',
pages: 'h',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$cucumber'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../h')
})
it('Mocha with pageobjects default values', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
specs: './test/specs/**/*.js',
pages: './test/pageobjects/**/*.js',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$mocha'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../pageobjects')
})
it('Mocha with pageobjects different path', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
specs: './test/specs/files/**/*.js',
pages: './test/pageobjects/**/*.js',
generateTestFiles: true,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$mocha'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('../../pageobjects')
})
it('Do not auto generate file', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
specs: './test/specs/files/**/*.js',
pages: './test/pageobjects/**/*.js',
generateTestFiles: false,
usePageObjects: true,
framework: '@wdio/cucumber-service$--$mocha'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('')
})
it('Do not use PageObjects', () => {
const generatedPaths = getPathForFileGeneration({
runner: 'local',
specs: './test/specs/files/**/*.js',
pages: './test/pageobjects/**/*.js',
generateTestFiles: true,
usePageObjects: false,
framework: '@wdio/cucumber-service$--$mocha'
} as any, '/foo/bar')
expect(generatedPaths.relativePath).toEqual('')
})
})
test('getDefaultFiles', async () => {
const files = '/foo/bar'
expect(await getDefaultFiles({ projectRootCorrect: false, projectRoot: '/bar', isUsingTypeScript: true } as any, files))
.toBe(path.join('/bar', 'foo', 'bar.ts'))
expect(await getDefaultFiles({ projectRootCorrect: false, projectRoot: '/bar', isUsingTypeScript: true, preset: 'vite-plugin-solid$--$solid' } as any, files))
.toBe(path.join('/bar', 'foo', 'bar.tsx'))
expect(await getDefaultFiles({ projectRootCorrect: false, projectRoot: '/bar', isUsingTypeScript: false } as any, files))
.toBe(path.join('/bar', 'foo', 'bar.js'))
})
test('original implementation of getDefaultFiles handles projectRoot with no package.json', async () => {
const files = '/foo/bar'
vi.mocked(readPackageUp).mockRestore()
expect(await getDefaultFiles({ createPackageJSON: true, projectRoot: '/project-root-with-no-package.json', isUsingTypeScript: false } as any, files))
.toBe(path.join('/project-root-with-no-package.json', 'foo', 'bar.js'))
})
test('specifyVersionIfNeeded', () => {
expect(specifyVersionIfNeeded(
['webdriverio', '@wdio/spec-reporter'],
'8.0.0-alpha.249+4bc237701',
'latest'
)).toEqual([
'webdriverio@^8.0.0-alpha.249',
'@wdio/spec-reporter@^8.0.0-alpha.249'
])
})
test('getProjectRoot', async () => {
expect(await getProjectRoot()).toBe('/foo')
expect(await getProjectRoot({
projectRootCorrect: true
} as any)).toBe('/foo')
expect(await getProjectRoot({
projectRootCorrect: false,
projectRoot: '/bar/foo'
} as any)).toBe('/bar/foo')
})
test('detectCompiler', async () => {
vi.mocked(readPackageUp).mockRestore()
const answers = { createPackageJSON: true } as Questionnair
expect(await detectCompiler(answers)).toBe(false)
})
test('getAnswers', async () => {
let answers = await getAnswers(true)
delete answers.pages // delete so it doesn't fail in Windows
delete answers.specs // delete so it doesn't fail in Windows
expect(answers).toMatchSnapshot()
vi.mocked(inquirer.prompt).mockReturnValue('some value' as any)
answers = await getAnswers(false)
delete answers.pages // delete so it doesn't fail in Windows
delete answers.specs // delete so it doesn't fail in Windows
expect(answers).toBe('some value')
expect(inquirer.prompt).toBeCalledTimes(2)
// @ts-ignore
expect(vi.mocked(inquirer.prompt).mock.calls[0][0][0].when).toBe(false)
vi.mocked(readPackageUp).mockResolvedValue(undefined)
vi.mocked(inquirer.prompt).mockClear()
expect(await getAnswers(false)).toBe('some value')
expect(inquirer.prompt).toBeCalledTimes(2)
// @ts-ignore
expect(vi.mocked(inquirer.prompt).mock.calls[0][0][0].when).toBe(true)
})
test('getProjectProps', async () => {
vi.mocked(readPackageUp).mockResolvedValue(undefined)
expect(await getProjectProps('/foo/bar')).toBe(undefined)
expect(readPackageUp).toBeCalledWith({ cwd: '/foo/bar' })
vi.mocked(readPackageUp).mockResolvedValue({
path: '/foo/bar',
packageJson: {
name: 'cool-test-module2',
}
})
expect(await getProjectProps('/foo/bar')).toEqual({
esmSupported: false,
packageJson: { name: 'cool-test-module2' },
path: '/foo'
})
})
test('runProgram', () => {
runProgram('foobar', [1, 2] as any, { foo: 'bar' } as any)
expect(cp.spawn).toBeCalledWith('foobar', [1, 2], { foo: 'bar', stdio: 'inherit' })
})
test('createPackageJSON', async () => {
await createPackageJSON({} as any)
expect(fs.writeFile).toBeCalledTimes(0)
await createPackageJSON({
createPackageJSON: true
} as any)
expect(console.log).toBeCalledTimes(2)
})
test('npmInstall', async () => {
const parsedAnswers = {
rawAnswers: {
services: ['foo$--$bar'],
preset: 'barfoo$--$vue'
},
projectRootCorrect: false,
projectRoot: '/foo/bar',
isUsingTypeScript: true,
framework: 'jasmine',
installTestingLibrary: true,
packagesToInstall: ['foo$--$bar', 'bar$--$foo'],
npmInstall: true,
includeVisualTesting: true
} as any
await npmInstall(parsedAnswers, 'next')
expect(installPackages).toBeCalledTimes(1)
expect(vi.mocked(installPackages).mock.calls).toMatchSnapshot()
})
test('not npmInstall', async () => {
const parsedAnswers = {
rawAnswers: {
services: ['foo$--$bar'],
preset: 'barfoo$--$vue'
},
installTestingLibrary: true,
packagesToInstall: ['foo$--$bar', 'bar$--$foo'],
npmInstall: false
} as any
await npmInstall(parsedAnswers, 'next')
expect(installPackages).toBeCalledTimes(0)
expect(vi.mocked(console.log).mock.calls[0][0]).toContain('To install dependencies, execute')
})
test('setupTypeScript', async () => {
await setupTypeScript({} as any)
expect(fs.writeFile).toBeCalledTimes(0)
const parsedAnswers = {
isUsingTypeScript: true,
esmSupport: true,
rawAnswers: {
framework: 'foo',
services: [
'wdio-foobar-service$--$foobar',
'wdio-electron-service$--$electron'
]
},
packagesToInstall: [],
tsConfigFilePath: '/foobar/tsconfig.json'
} as any
await setupTypeScript(parsedAnswers)
expect(vi.mocked(fs.writeFile).mock.calls[0][1]).toMatchSnapshot()
})
test('setupTypeScript does not create tsconfig.json if TypeScript was not selected', async () => {
const parsedAnswers = {
isUsingTypeScript: false,
esmSupport: true,
rawAnswers: {
framework: 'foo',
services: [
'wdio-foobar-service$--$foobar',
'wdio-electron-service$--$electron'
]
},
packagesToInstall: [],
tsConfigFilePath: '/foobar/tsconfig.json'
} as any
await setupTypeScript(parsedAnswers)
expect(fs.writeFile).not.toBeCalled()
expect(parsedAnswers.packagesToInstall).toEqual([])
})
test('setupTypeScript does not create tsconfig.json if there is already one', async () => {
const parsedAnswers = {
isUsingTypeScript: true,
esmSupport: true,
rawAnswers: {
framework: 'foo',
services: [
'wdio-foobar-service$--$foobar',
'wdio-electron-service$--$electron'
]
},
packagesToInstall: [],
tsConfigFilePath: '/foobar/tsconfig.json',
hasRootTSConfig: true
} as any
await setupTypeScript(parsedAnswers)
expect(fs.writeFile).not.toBeCalled()
})
test('createWDIOConfig', async () => {
const answers = await parseAnswers(true)
answers.destSpecRootPath = '/tests/specs'
answers.destPageObjectRootPath = '/tests/specs'
answers.stepDefinitions = './foo/bar'
answers.specs = '/foo/bar/**'
await createWDIOConfig(answers as any)
expect(fs.writeFile).toBeCalledTimes(7)
expect(
vi.mocked(fs.writeFile).mock.calls[0][0]
.toString()
.endsWith(path.resolve('wdio.conf.js'))
).toBe(true)
})
describe('createWDIOScript', () => {
it('can run with success', async () => {
expect(await createWDIOScript({ wdioConfigPath: '/foo/bar/wdio.conf.js' } as any))
.toBe(true)
expect(cp.spawn).toBeCalledTimes(1)
})
it('does not fail the process if spawn errors out', async () => {
vi.mocked(cp.spawn).mockReturnValue({ on: vi.fn().mockImplementation((ev, fn) => fn(1)) } as any)
expect(await createWDIOScript({ wdioConfigPath: '/foo/bar/wdio.conf.js' } as any))
.toBe(false)
expect(cp.spawn).toBeCalledTimes(1)
})
})
test('runAppiumInstaller', async () => {
expect(await runAppiumInstaller({ e2eEnvironment: 'web' } as any))
.toBe(undefined)
expect(console.log).toBeCalledTimes(0)
expect($).toBeCalledTimes(0)
vi.mocked(inquirer.prompt).mockResolvedValue({
continueWithAppiumSetup: false
})
expect(await runAppiumInstaller({ e2eEnvironment: 'mobile' } as any))
.toBe(undefined)
expect(console.log).toBeCalledTimes(1)
expect($).toBeCalledTimes(0)
vi.mocked(inquirer.prompt).mockResolvedValue({
continueWithAppiumSetup: true
})
expect(await runAppiumInstaller({ e2eEnvironment: 'mobile' } as any))
.toEqual(['npx appium-installer'])
expect($).toBeCalledTimes(1)
})
test.each([
['with empty variable should fallback to npm', '', 'npm'],
['with pnpm should return pnpm', 'pnpm/9.10.0 npm/? node/v20.11.0 darwin arm64', 'pnpm'],
['with yarn should return yarn', 'yarn/4.5.0 npm/? node/v20.11.0 darwin arm64', 'yarn'],
['with npm should return npm', 'npm/10.2.4 node/v20.11.0 darwin arm64 workspaces/false', 'npm'],
['with bun should return bun', 'bun/1.1.27 npm/? node/v22.6.0 darwin arm64', 'bun'],
['with unsupported package manager should return npm', 'not supported package manager/x.x.x npm/? node/v22.6.0 darwin arm64', 'npm'],
])('detectPackageManager %s', async (_, stub, pm) => {
vi.stubEnv('npm_config_user_agent', stub)
expect(detectPackageManager()).toEqual(pm)
})
afterEach(() => {
vi.mocked(inquirer.prompt).mockClear()
vi.mocked(console.log).mockRestore()
@ -1007,5 +330,4 @@ afterEach(() => {
vi.mocked(cp.spawn).mockClear()
vi.mocked(fs.mkdir).mockClear()
vi.mocked(ejs.renderFile).mockClear()
vi.mocked(installPackages).mockClear()
})

View file

@ -480,6 +480,67 @@ importers:
infra/lernaPatch: {}
packages/create-wdio:
dependencies:
chalk:
specifier: ^5.3.0
version: 5.4.1
commander:
specifier: ^14.0.0
version: 14.0.0
cross-spawn:
specifier: ^7.0.3
version: 7.0.6
ejs:
specifier: ^3.1.10
version: 3.1.10
execa:
specifier: ^9.6.0
version: 9.6.0
import-meta-resolve:
specifier: ^4.1.0
version: 4.1.0
inquirer:
specifier: ^12.7.0
version: 12.7.0(@types/node@22.15.34)
normalize-package-data:
specifier: ^7.0.0
version: 7.0.0
read-pkg-up:
specifier: ^10.1.0
version: 10.1.0
recursive-readdir:
specifier: ^2.2.3
version: 2.2.3
semver:
specifier: ^7.6.3
version: 7.7.2
type-fest:
specifier: ^4.41.0
version: 4.41.0
yargs:
specifier: ^17.7.2
version: 17.7.2
devDependencies:
'@types/cross-spawn':
specifier: ^6.0.6
version: 6.0.6
'@types/ejs':
specifier: ^3.1.5
version: 3.1.5
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
'@types/normalize-package-data':
specifier: ^2.4.4
version: 2.4.4
'@types/semver':
specifier: ^7.5.8
version: 7.7.0
'@types/yargs':
specifier: ^17.0.33
version: 17.0.33
packages/eslint-plugin-wdio:
devDependencies:
'@types/estree':
@ -774,6 +835,9 @@ importers:
chokidar:
specifier: ^4.0.0
version: 4.0.3
create-wdio:
specifier: workspace:*
version: link:../create-wdio
dotenv:
specifier: ^16.3.1
version: 16.6.1
@ -801,9 +865,6 @@ importers:
read-pkg-up:
specifier: ^10.0.0
version: 10.1.0
recursive-readdir:
specifier: ^2.2.3
version: 2.2.3
tsx:
specifier: ^4.7.2
version: 4.20.3
@ -3939,6 +4000,15 @@ packages:
'@types/node':
optional: true
'@inquirer/checkbox@4.1.9':
resolution: {integrity: sha512-DBJBkzI5Wx4jFaYm221LHvAhpKYkhVS0k9plqHwaHhofGNxvYB7J3Bz8w+bFJ05zaMb0sZNHo4KdmENQFlNTuQ==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/confirm@4.0.1':
resolution: {integrity: sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==}
engines: {node: '>=18'}
@ -3952,6 +4022,15 @@ packages:
'@types/node':
optional: true
'@inquirer/confirm@5.1.13':
resolution: {integrity: sha512-EkCtvp67ICIVVzjsquUiVSd+V5HRGOGQfsqA4E4vMWhYnB7InUL0pa0TIWt1i+OfP16Gkds8CdIu6yGZwOM1Yw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@10.1.13':
resolution: {integrity: sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==}
engines: {node: '>=18'}
@ -3961,6 +4040,15 @@ packages:
'@types/node':
optional: true
'@inquirer/core@10.1.14':
resolution: {integrity: sha512-Ma+ZpOJPewtIYl6HZHZckeX1STvDnHTCB2GVINNUlSEn2Am6LddWwfPkIGY0IUFVjUUrr/93XlBwTK6mfLjf0A==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@9.2.1':
resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==}
engines: {node: '>=18'}
@ -3978,6 +4066,15 @@ packages:
'@types/node':
optional: true
'@inquirer/editor@4.2.14':
resolution: {integrity: sha512-yd2qtLl4QIIax9DTMZ1ZN2pFrrj+yL3kgIWxm34SS6uwCr0sIhsNyudUjAo5q3TqI03xx4SEBkUJqZuAInp9uA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/expand@3.0.1':
resolution: {integrity: sha512-ToG8d6RIbnVpbdPdiN7BCxZGiHOTomOX94C2FaT5KOHupV40tKEDozp12res6cMIfRKrXLJyexAZhWVHgbALSQ==}
engines: {node: '>=18'}
@ -3991,6 +4088,15 @@ packages:
'@types/node':
optional: true
'@inquirer/expand@4.0.16':
resolution: {integrity: sha512-oiDqafWzMtofeJyyGkb1CTPaxUkjIcSxePHHQCfif8t3HV9pHcw1Kgdw3/uGpDvaFfeTluwQtWiqzPVjAqS3zA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@1.0.12':
resolution: {integrity: sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==}
engines: {node: '>=18'}
@ -4008,6 +4114,15 @@ packages:
'@types/node':
optional: true
'@inquirer/input@4.2.0':
resolution: {integrity: sha512-opqpHPB1NjAmDISi3uvZOTrjEEU5CWVu/HBkDby8t93+6UxYX0Z7Ps0Ltjm5sZiEbWenjubwUkivAEYQmy9xHw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/number@2.0.1':
resolution: {integrity: sha512-QpR8jPhRjSmlr/mD2cw3IR8HRO7lSVOnqUvQa8scv1Lsr3xoAMMworcYW3J13z3ppjBFBD2ef1Ci6AE5Qn8goQ==}
engines: {node: '>=18'}
@ -4021,6 +4136,15 @@ packages:
'@types/node':
optional: true
'@inquirer/number@3.0.16':
resolution: {integrity: sha512-kMrXAaKGavBEoBYUCgualbwA9jWUx2TjMA46ek+pEKy38+LFpL9QHlTd8PO2kWPUgI/KB+qi02o4y2rwXbzr3Q==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/password@3.0.1':
resolution: {integrity: sha512-haoeEPUisD1NeE2IanLOiFr4wcTXGWrBOyAyPZi1FfLJuXOzNmxCJPgUrGYKVh+Y8hfGJenIfz5Wb/DkE9KkMQ==}
engines: {node: '>=18'}
@ -4034,6 +4158,15 @@ packages:
'@types/node':
optional: true
'@inquirer/password@4.0.16':
resolution: {integrity: sha512-g8BVNBj5Zeb5/Y3cSN+hDUL7CsIFDIuVxb9EPty3lkxBaYpjL5BNRKSYOF9yOLe+JOcKFd+TSVeADQ4iSY7rbg==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/prompts@6.0.1':
resolution: {integrity: sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==}
engines: {node: '>=18'}
@ -4047,6 +4180,15 @@ packages:
'@types/node':
optional: true
'@inquirer/prompts@7.6.0':
resolution: {integrity: sha512-jAhL7tyMxB3Gfwn4HIJ0yuJ5pvcB5maYUcouGcgd/ub79f9MqZ+aVnBtuFf+VC2GTkCBF+R+eo7Vi63w5VZlzw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/rawlist@3.0.1':
resolution: {integrity: sha512-VgRtFIwZInUzTiPLSfDXK5jLrnpkuSOh1ctfaoygKAdPqjcjKYmGh6sCY1pb0aGnCGsmhUxoqLDUAU0ud+lGXQ==}
engines: {node: '>=18'}
@ -4060,6 +4202,15 @@ packages:
'@types/node':
optional: true
'@inquirer/rawlist@4.1.4':
resolution: {integrity: sha512-5GGvxVpXXMmfZNtvWw4IsHpR7RzqAR624xtkPd1NxxlV5M+pShMqzL4oRddRkg8rVEOK9fKdJp1jjVML2Lr7TQ==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/search@2.0.1':
resolution: {integrity: sha512-r5hBKZk3g5MkIzLVoSgE4evypGqtOannnB3PKTG9NRZxyFRKcfzrdxXXPcoJQsxJPzvdSU2Rn7pB7lw0GCmGAg==}
engines: {node: '>=18'}
@ -4073,6 +4224,15 @@ packages:
'@types/node':
optional: true
'@inquirer/search@3.0.16':
resolution: {integrity: sha512-POCmXo+j97kTGU6aeRjsPyuCpQQfKcMXdeTMw708ZMtWrj5aykZvlUxH4Qgz3+Y1L/cAVZsSpA+UgZCu2GMOMg==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/select@3.0.1':
resolution: {integrity: sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q==}
engines: {node: '>=18'}
@ -4086,6 +4246,15 @@ packages:
'@types/node':
optional: true
'@inquirer/select@4.2.4':
resolution: {integrity: sha512-unTppUcTjmnbl/q+h8XeQDhAqIOmwWYWNyiiP2e3orXrg6tOaa5DHXja9PChCSbChOsktyKgOieRZFnajzxoBg==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/type@2.0.0':
resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==}
engines: {node: '>=18'}
@ -4504,6 +4673,46 @@ packages:
'@originjs/vite-plugin-commonjs@1.0.3':
resolution: {integrity: sha512-KuEXeGPptM2lyxdIEJ4R11+5ztipHoE7hy8ClZt3PYaOVQ/pyngd2alaSrPnwyFeOW1UagRBaQ752aA1dTMdOQ==}
'@oxlint/darwin-arm64@1.6.0':
resolution: {integrity: sha512-m3wyqBh1TOHjpr/dXeIZY7OoX+MQazb+bMHQdDtwUvefrafUx+5YHRvulYh1sZSQ449nQ3nk3qj5qj535vZRjg==}
cpu: [arm64]
os: [darwin]
'@oxlint/darwin-x64@1.6.0':
resolution: {integrity: sha512-75fJfF/9xNypr7cnOYoZBhfmG1yP7ex3pUOeYGakmtZRffO9z1i1quLYhjZsmaDXsAIZ3drMhenYHMmFKS3SRg==}
cpu: [x64]
os: [darwin]
'@oxlint/linux-arm64-gnu@1.6.0':
resolution: {integrity: sha512-YhXGf0FXa72bEt4F7eTVKx5X3zWpbAOPnaA/dZ6/g8tGhw1m9IFjrabVHFjzcx3dQny4MgA59EhyElkDvpUe8A==}
cpu: [arm64]
os: [linux]
'@oxlint/linux-arm64-musl@1.6.0':
resolution: {integrity: sha512-T3JDhx8mjGjvh5INsPZJrlKHmZsecgDYvtvussKRdkc1Nnn7WC+jH9sh5qlmYvwzvmetlPVNezAoNvmGO9vtMg==}
cpu: [arm64]
os: [linux]
'@oxlint/linux-x64-gnu@1.6.0':
resolution: {integrity: sha512-Dx7ghtAl8aXBdqofJpi338At6lkeCtTfoinTYQXd9/TEJx+f+zCGNlQO6nJz3ydJBX48FDuOFKkNC+lUlWrd8w==}
cpu: [x64]
os: [linux]
'@oxlint/linux-x64-musl@1.6.0':
resolution: {integrity: sha512-7KvMGdWmAZtAtg6IjoEJHKxTXdAcrHnUnqfgs0JpXst7trquV2mxBeRZusQXwxpu4HCSomKMvJfsp1qKaqSFDg==}
cpu: [x64]
os: [linux]
'@oxlint/win32-arm64@1.6.0':
resolution: {integrity: sha512-iSGC9RwX+dl7o5KFr5aH7Gq3nFbkq/3Gda6mxNPMvNkWrgXdIyiINxpyD8hJu566M+QSv1wEAu934BZotFDyoQ==}
cpu: [arm64]
os: [win32]
'@oxlint/win32-x64@1.6.0':
resolution: {integrity: sha512-jOj3L/gfLc0IwgOTkZMiZ5c673i/hbAmidlaylT0gE6H18hln9HxPgp5GCf4E4y6mwEJlW8QC5hQi221+9otdA==}
cpu: [x64]
os: [win32]
'@paralleldrive/cuid2@2.2.2':
resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==}
@ -5402,6 +5611,9 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cross-spawn@6.0.6':
resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==}
'@types/dateformat@5.0.3':
resolution: {integrity: sha512-hvskLa9ObgUwJe0rtfkyBbU+VIK8Ia3BACsxRsp4CPnyPBAOyIHbqImfBGFfaVVxn82wGx5SD8Twll6jMc/BaQ==}
@ -5447,6 +5659,9 @@ packages:
'@types/express@5.0.3':
resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==}
'@types/fs-extra@11.0.4':
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
'@types/gitconfiglocal@2.0.3':
resolution: {integrity: sha512-W6hyZux6TrtKfF2I9XNLVcsFr4xRr0T+S6hrJ9nDkhA2vzsFPIEAbnY4vgb6v2yKXQ9MJVcbLsARNlMfg4EVtQ==}
@ -5507,6 +5722,9 @@ packages:
'@types/json-stringify-safe@5.0.3':
resolution: {integrity: sha512-oNOjRxLfPeYbBSQ60maucaFNqbslVOPU4WWs5t/sHvAh6tyo/CThXSG+E24tEzkgh/fzvxyDrYdOJufgeNy1sQ==}
'@types/jsonfile@6.1.4':
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
@ -5626,6 +5844,9 @@ packages:
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
'@types/send@0.17.5':
resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
@ -6841,6 +7062,10 @@ packages:
resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==}
engines: {node: '>=18'}
commander@14.0.0:
resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@ -8720,6 +8945,10 @@ packages:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
hosted-git-info@8.1.0:
resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==}
engines: {node: ^18.17.0 || >=20.5.0}
hpack.js@2.1.6:
resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==}
@ -8994,6 +9223,15 @@ packages:
'@types/node':
optional: true
inquirer@12.7.0:
resolution: {integrity: sha512-KKFRc++IONSyE2UYw9CJ1V0IWx5yQKomwB+pp3cWomWs+v2+ZsG11G2OVfAjFS6WWCppKw+RfKmpqGfSzD5QBQ==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
inquirer@8.2.6:
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
engines: {node: '>=12.0.0'}
@ -10497,6 +10735,10 @@ packages:
resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==}
engines: {node: ^16.14.0 || >=18.0.0}
normalize-package-data@7.0.0:
resolution: {integrity: sha512-k6U0gKRIuNCTkwHGZqblCfLfBRh+w1vI6tBo+IeJwq2M8FUiOqhX7GH+GArQGScA7azd1WfyRCvxoXDO3hQDIA==}
engines: {node: ^18.17.0 || >=20.5.0}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@ -10687,6 +10929,11 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
oxlint@1.6.0:
resolution: {integrity: sha512-jtaD65PqzIa1udvSxxscTKBxYKuZoFXyKGLiU1Qjo1ulq3uv/fQDtoV1yey1FrQZrQjACGPi1Widsy1TucC7Jg==}
engines: {node: '>=8.*'}
hasBin: true
p-cancelable@1.1.0:
resolution: {integrity: sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==}
engines: {node: '>=6'}
@ -11480,6 +11727,11 @@ packages:
resolution: {integrity: sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==}
engines: {node: '>=4'}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
pretty-bytes@5.6.0:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'}
@ -12083,6 +12335,10 @@ packages:
resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==}
engines: {node: '>=0.12.0'}
run-async@4.0.4:
resolution: {integrity: sha512-2cgeRHnV11lSXBEhq7sN7a5UVjTKm9JTb9x8ApIT//16D7QL96AgnNeWSGoB4gIHc0iYw/Ha0Z+waBaCYZVNhg==}
engines: {node: '>=0.12.0'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@ -16899,6 +17155,16 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/checkbox@4.1.9(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/figures': 1.0.12
'@inquirer/type': 3.0.7(@types/node@22.15.34)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/confirm@4.0.1':
dependencies:
'@inquirer/core': 9.2.1
@ -16911,6 +17177,13 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/confirm@5.1.13(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/type': 3.0.7(@types/node@22.15.34)
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/core@10.1.13(@types/node@20.19.2)':
dependencies:
'@inquirer/figures': 1.0.12
@ -16924,6 +17197,19 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/core@10.1.14(@types/node@22.15.34)':
dependencies:
'@inquirer/figures': 1.0.12
'@inquirer/type': 3.0.7(@types/node@22.15.34)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/core@9.2.1':
dependencies:
'@inquirer/figures': 1.0.12
@ -16953,6 +17239,14 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/editor@4.2.14(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/type': 3.0.7(@types/node@22.15.34)
external-editor: 3.1.0
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/expand@3.0.1':
dependencies:
'@inquirer/core': 9.2.1
@ -16967,6 +17261,14 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/expand@4.0.16(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/type': 3.0.7(@types/node@22.15.34)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/figures@1.0.12': {}
'@inquirer/input@3.0.1':
@ -16981,6 +17283,13 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/input@4.2.0(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/type': 3.0.7(@types/node@22.15.34)
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/number@2.0.1':
dependencies:
'@inquirer/core': 9.2.1
@ -16993,6 +17302,13 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/number@3.0.16(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/type': 3.0.7(@types/node@22.15.34)
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/password@3.0.1':
dependencies:
'@inquirer/core': 9.2.1
@ -17007,6 +17323,14 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/password@4.0.16(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/type': 3.0.7(@types/node@22.15.34)
ansi-escapes: 4.3.2
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/prompts@6.0.1':
dependencies:
'@inquirer/checkbox': 3.0.1
@ -17035,6 +17359,21 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/prompts@7.6.0(@types/node@22.15.34)':
dependencies:
'@inquirer/checkbox': 4.1.9(@types/node@22.15.34)
'@inquirer/confirm': 5.1.13(@types/node@22.15.34)
'@inquirer/editor': 4.2.14(@types/node@22.15.34)
'@inquirer/expand': 4.0.16(@types/node@22.15.34)
'@inquirer/input': 4.2.0(@types/node@22.15.34)
'@inquirer/number': 3.0.16(@types/node@22.15.34)
'@inquirer/password': 4.0.16(@types/node@22.15.34)
'@inquirer/rawlist': 4.1.4(@types/node@22.15.34)
'@inquirer/search': 3.0.16(@types/node@22.15.34)
'@inquirer/select': 4.2.4(@types/node@22.15.34)
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/rawlist@3.0.1':
dependencies:
'@inquirer/core': 9.2.1
@ -17049,6 +17388,14 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/rawlist@4.1.4(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/type': 3.0.7(@types/node@22.15.34)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/search@2.0.1':
dependencies:
'@inquirer/core': 9.2.1
@ -17065,6 +17412,15 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/search@3.0.16(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/figures': 1.0.12
'@inquirer/type': 3.0.7(@types/node@22.15.34)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/select@3.0.1':
dependencies:
'@inquirer/core': 9.2.1
@ -17083,6 +17439,16 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/select@4.2.4(@types/node@22.15.34)':
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/figures': 1.0.12
'@inquirer/type': 3.0.7(@types/node@22.15.34)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.15.34
'@inquirer/type@2.0.0':
dependencies:
mute-stream: 1.0.0
@ -17091,6 +17457,10 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
'@inquirer/type@3.0.7(@types/node@22.15.34)':
optionalDependencies:
'@types/node': 22.15.34
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@ -17725,6 +18095,30 @@ snapshots:
dependencies:
esbuild: 0.14.54
'@oxlint/darwin-arm64@1.6.0':
optional: true
'@oxlint/darwin-x64@1.6.0':
optional: true
'@oxlint/linux-arm64-gnu@1.6.0':
optional: true
'@oxlint/linux-arm64-musl@1.6.0':
optional: true
'@oxlint/linux-x64-gnu@1.6.0':
optional: true
'@oxlint/linux-x64-musl@1.6.0':
optional: true
'@oxlint/win32-arm64@1.6.0':
optional: true
'@oxlint/win32-x64@1.6.0':
optional: true
'@paralleldrive/cuid2@2.2.2':
dependencies:
'@noble/hashes': 1.8.0
@ -18806,6 +19200,10 @@ snapshots:
dependencies:
'@types/node': 20.19.2
'@types/cross-spawn@6.0.6':
dependencies:
'@types/node': 20.19.2
'@types/dateformat@5.0.3': {}
'@types/debug@4.1.12':
@ -18868,6 +19266,11 @@ snapshots:
'@types/express-serve-static-core': 5.0.6
'@types/serve-static': 1.15.8
'@types/fs-extra@11.0.4':
dependencies:
'@types/jsonfile': 6.1.4
'@types/node': 20.19.2
'@types/gitconfiglocal@2.0.3': {}
'@types/glob@7.2.0':
@ -18930,6 +19333,10 @@ snapshots:
'@types/json-stringify-safe@5.0.3': {}
'@types/jsonfile@6.1.4':
dependencies:
'@types/node': 20.19.2
'@types/keyv@3.1.4':
dependencies:
'@types/node': 20.19.2
@ -19055,6 +19462,8 @@ snapshots:
dependencies:
'@types/node': 20.19.2
'@types/semver@7.7.0': {}
'@types/send@0.17.5':
dependencies:
'@types/mime': 1.3.5
@ -20620,6 +21029,8 @@ snapshots:
commander@12.0.0: {}
commander@14.0.0: {}
commander@2.20.3: {}
commander@4.1.1: {}
@ -22922,6 +23333,10 @@ snapshots:
dependencies:
lru-cache: 10.4.3
hosted-git-info@8.1.0:
dependencies:
lru-cache: 10.4.3
hpack.js@2.1.6:
dependencies:
inherits: 2.0.4
@ -23224,6 +23639,18 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.2
inquirer@12.7.0(@types/node@22.15.34):
dependencies:
'@inquirer/core': 10.1.14(@types/node@22.15.34)
'@inquirer/prompts': 7.6.0(@types/node@22.15.34)
'@inquirer/type': 3.0.7(@types/node@22.15.34)
ansi-escapes: 4.3.2
mute-stream: 2.0.0
run-async: 4.0.4
rxjs: 7.8.2
optionalDependencies:
'@types/node': 22.15.34
inquirer@8.2.6:
dependencies:
ansi-escapes: 4.3.2
@ -25233,6 +25660,12 @@ snapshots:
semver: 7.7.2
validate-npm-package-license: 3.0.4
normalize-package-data@7.0.0:
dependencies:
hosted-git-info: 8.1.0
semver: 7.7.2
validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {}
normalize-range@0.1.2: {}
@ -25492,6 +25925,17 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
oxlint@1.6.0:
optionalDependencies:
'@oxlint/darwin-arm64': 1.6.0
'@oxlint/darwin-x64': 1.6.0
'@oxlint/linux-arm64-gnu': 1.6.0
'@oxlint/linux-arm64-musl': 1.6.0
'@oxlint/linux-x64-gnu': 1.6.0
'@oxlint/linux-x64-musl': 1.6.0
'@oxlint/win32-arm64': 1.6.0
'@oxlint/win32-x64': 1.6.0
p-cancelable@1.1.0: {}
p-cancelable@2.1.1: {}
@ -26330,6 +26774,8 @@ snapshots:
prepend-http@2.0.0: {}
prettier@3.6.2: {}
pretty-bytes@5.6.0: {}
pretty-error@4.0.0:
@ -27071,6 +27517,11 @@ snapshots:
run-async@3.0.0: {}
run-async@4.0.4:
dependencies:
oxlint: 1.6.0
prettier: 3.6.2
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3