@wdio/cli
Version:
WebdriverIO testrunner command line interface
931 lines (930 loc) • 38.3 kB
JavaScript
import fs from 'node:fs/promises';
import util, { promisify } from 'node:util';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync, spawn } from 'node:child_process';
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 { installPackages, getInstallCommand } from './install.js';
import { ANDROID_CONFIG, CompilerOptions, DEPENDENCIES_INSTALLATION_MESSAGE, IOS_CONFIG, pkg, QUESTIONNAIRE, TESTING_LIBRARY_PACKAGES, COMMUNITY_PACKAGES_WITH_TS_SUPPORT, usesSerenity, PMs, } from './constants.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);
export class HookError extends SevereServiceError {
origin;
constructor(message, origin) {
super(message);
this.origin = origin;
}
}
/**
* run service launch sequences
*/
export async function runServiceHook(launcher, hookName, ...args) {
const start = Date.now();
return Promise.all(launcher.map(async (service) => {
try {
if (typeof service[hookName] === 'function') {
await service[hookName](...args);
}
}
catch (err) {
const message = `A service failed in the '${hookName}' hook\n${err.stack}\n\n`;
if (err instanceof SevereServiceError || err.name === 'SevereServiceError') {
return { status: 'rejected', reason: message, origin: hookName };
}
log.error(`${message}Continue...`);
}
})).then(results => {
if (launcher.length) {
log.debug(`Finished to run "${hookName}" hook in ${Date.now() - start}ms`);
}
const rejectedHooks = results.filter(p => p && p.status === 'rejected');
if (rejectedHooks.length) {
return Promise.reject(new HookError(`\n${rejectedHooks.map(p => p && p.reason).join()}\n\nStopping runner...`, hookName));
}
});
}
/**
* Run hook in service launcher
* @param {Array|Function} hook - can be array of functions or single function
* @param {object} config
* @param {object} capabilities
*/
export async function runLauncherHook(hook, ...args) {
if (typeof hook === 'function') {
hook = [hook];
}
const catchFn = (e) => {
log.error(`Error in hook: ${e.stack}`);
if (e instanceof SevereServiceError) {
throw new HookError(e.message, hook[0].name);
}
};
return Promise.all(hook.map((hook) => {
try {
return hook(...args);
}
catch (err) {
return catchFn(err);
}
})).catch(catchFn);
}
/**
* Run onCompleteHook in Launcher
* @param {Array|Function} onCompleteHook - can be array of functions or single function
* @param {*} config
* @param {*} capabilities
* @param {*} exitCode
* @param {*} results
*/
export async function runOnCompleteHook(onCompleteHook, config, capabilities, exitCode, results) {
if (typeof onCompleteHook === 'function') {
onCompleteHook = [onCompleteHook];
}
return Promise.all(onCompleteHook.map(async (hook) => {
try {
await hook(exitCode, config, capabilities, results);
return 0;
}
catch (err) {
log.error(`Error in onCompleteHook: ${err.stack}`);
if (err instanceof SevereServiceError) {
throw new HookError(err.message, 'onComplete');
}
return 1;
}
}));
}
/**
* get runner identification by caps
*/
export function getRunnerName(caps = {}) {
let runner = caps.browserName ||
caps.platformName ||
caps['appium:platformName'] ||
caps['appium:appPackage'] ||
caps['appium:appWaitActivity'] ||
caps['appium:app'];
// MultiRemote
if (!runner) {
runner = Object.values(caps).length === 0 || Object.values(caps).some(cap => !cap.capabilities) ? 'undefined' : 'MultiRemote';
}
return runner;
}
function buildNewConfigArray(str, type, change) {
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, type, change) {
return str.replace(new RegExp(`(${type}: )('\\w*')`), `$1'${change}'`);
}
export function findInConfig(config, type) {
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);
}
export function replaceConfig(config, type, name) {
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, packages, 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) {
// eslint-disable-next-line no-console
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, hash = '$--$') {
const [p, short, purpose] = pkg.split(hash);
return { package: p, short, purpose };
}
export function getSerenityPackages(answers) {
const framework = convertPackageHashToObject(answers.framework);
if (framework.package !== '@serenity-js/webdriverio') {
return [];
}
const isUsingTypeScript = answers.isUsingCompiler === CompilerOptions.TS;
const packages = {
cucumber: [
'@cucumber/cucumber',
'@serenity-js/cucumber',
],
mocha: [
'@serenity-js/mocha',
'mocha',
isUsingTypeScript && '@types/mocha',
],
jasmine: [
'@serenity-js/jasmine',
'jasmine',
isUsingTypeScript && '@types/jasmine',
],
common: [
'@serenity-js/assertions',
'@serenity-js/console-reporter',
'@serenity-js/core',
'@serenity-js/rest',
'@serenity-js/serenity-bdd',
'@serenity-js/web',
isUsingTypeScript && '@types/node',
'npm-failsafe',
'rimraf',
]
};
return [
...packages[framework.purpose],
...packages.common,
].filter(Boolean).sort();
}
export async function getCapabilities(arg) {
const optionalCapabilites = {
platformVersion: arg.platformVersion,
udid: arg.udid,
...(arg.deviceName && { deviceName: arg.deviceName })
};
/**
* Parsing of option property and constructing desiredCapabilities
* for Appium session. Could be application(1) or browser(2-3) session.
*/
if (/.*\.(apk|app|ipa)$/.test(arg.option)) {
return {
capabilities: {
app: arg.option,
...(arg.option.endsWith('apk') ? ANDROID_CONFIG : IOS_CONFIG),
...optionalCapabilites,
}
};
}
else if (/android/.test(arg.option)) {
return { capabilities: { browserName: 'Chrome', ...ANDROID_CONFIG, ...optionalCapabilites } };
}
else if (/ios/.test(arg.option)) {
return { capabilities: { browserName: 'Safari', ...IOS_CONFIG, ...optionalCapabilites } };
}
else if (/(js|ts)$/.test(arg.option)) {
const config = new ConfigParser(arg.option);
try {
await config.initialize();
}
catch (e) {
throw Error(e.code === 'MODULE_NOT_FOUND' ? `Config File not found: ${arg.option}` :
`Could not parse ${arg.option}, failed with error : ${e.message}`);
}
if (typeof arg.capabilities === 'undefined') {
throw Error('Please provide index/named property of capability to use from the capabilities array/object in wdio config file');
}
let requiredCaps = config.getCapabilities();
requiredCaps = (
// multi capabilities
requiredCaps[parseInt(arg.capabilities, 10)] ||
// multiremote
requiredCaps[arg.capabilities]);
const requiredW3CCaps = pickBy(requiredCaps, (_, key) => CAPABILITY_KEYS.includes(key) || key.includes(':'));
if (!Object.keys(requiredW3CCaps).length) {
throw Error(`No capability found in given config file with the provided capability indexed/named property: ${arg.capabilities}. Please check the capability in your wdio config file.`);
}
return { capabilities: { ...requiredW3CCaps } };
}
return { capabilities: { browserName: arg.option } };
}
/**
* Checks if certain directory has babel configuration files
* @param rootDir directory where this function checks for Babel signs
* @returns true, if a babel config was found, otherwise false
*/
export function hasBabelConfig(rootDir) {
return Promise.all([
fs.access(path.join(rootDir, 'babel.js')),
fs.access(path.join(rootDir, 'babel.cjs')),
fs.access(path.join(rootDir, 'babel.mjs')),
fs.access(path.join(rootDir, '.babelrc'))
]).then((results) => results.filter(Boolean).length > 1, () => false);
}
/**
* detect if project has a compiler file
*/
export async function detectCompiler(answers) {
const root = await getProjectRoot(answers);
const rootTSConfigExist = await fs.access(path.resolve(root, 'tsconfig.json')).then(() => true, () => false);
return (await hasBabelConfig(root))
? CompilerOptions.Babel // default to Babel
: rootTSConfigExist
? CompilerOptions.TS // default to TypeScript
: CompilerOptions.Nil; // default to no compiler
}
/**
* Check if package is installed
* @param {string} package to check existance for
*/
export async function hasPackage(pkg) {
try {
await resolve(pkg, import.meta.url);
return true;
}
catch (err) {
return false;
}
}
/**
* generate test files based on CLI answers
*/
export async function generateTestFiles(answers) {
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) {
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)
? 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) {
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)], []);
for (const file of files) {
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) {
const templateDirectories = {
[answers.projectRootDir]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', 'common', 'config'),
[answers.destSpecRootPath]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', answers.serenityAdapter),
[answers.destSerenityLibRootPath]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', 'common', 'serenity'),
};
for (const [destinationRootDir, templateRootDir] of Object.entries(templateDirectories)) {
const pathsToTemplates = await readDir(templateRootDir);
for (const pathToTemplate of pathsToTemplates) {
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) {
if (yes) {
const ignoredQuestions = ['e2eEnvironment'];
const filterdQuestionaire = QUESTIONNAIRE.filter((question) => !ignoredQuestions.includes(question.name));
const answers = {};
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)
: await question.default
: question.choices && question.choices.length
/**
* pick first choice, select value if it exists
*/
? typeof question.choices === 'function'
? question.choices(answers)[0].value
? question.choices(answers)[0].value
: question.choices(answers)[0]
: question.choices[0].value
? question.choices[0].value
: question.choices[0]
: {}
});
}
/**
* some questions have async defaults
*/
answers.isUsingCompiler = await answers.isUsingCompiler;
answers.specs = await answers.specs;
answers.pages = await answers.pages;
return answers;
}
const projectProps = await getProjectProps(process.cwd());
const isProjectExisting = Boolean(projectProps);
const projectName = projectProps?.packageJson?.name ? ` named "${projectProps.packageJson.name}"` : '';
const questions = [
/**
* in case the `wdio config` was called using a global installed @wdio/cli package
*/
...(!isProjectExisting
? [{
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?`,
}]
/**
* in case create-wdio was used which creates a package.json with name "my-new-project"
* we don't need to ask this question
*/
: projectProps?.packageJson?.name !== 'my-new-project'
? [{
type: 'confirm',
name: 'projectRootCorrect',
default: true,
message: `A project${projectName} was detected at "${projectProps?.path}", correct?`,
}, {
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) => !answers.projectRootCorrect
}]
: []),
...QUESTIONNAIRE
];
return inquirer.prompt(questions);
}
/**
* 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, projectRootDir) {
return path.resolve(projectRootDir, path.dirname(answers) === '.' ? path.resolve(answers) : path.dirname(answers));
}
export function getPathForFileGeneration(answers, projectRootDir) {
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, pattern) {
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?.isUsingCompiler?.toString().includes('TypeScript')
? `${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, version, npmTag) {
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') ||
['devtools', '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()) {
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 (err) {
return undefined;
}
}
export function runProgram(command, args, options) {
const child = spawn(command, args, { stdio: 'inherit', ...options });
return new Promise((resolve, reject) => {
let 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) {
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.VITEST_WORKER_ID && 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, npmTag) {
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
*/
export function detectPackageManager(argv = process.argv) {
return PMs.find((pm) => (
// for pnpm check "~/Library/pnpm/store/v3/..."
// for NPM check "~/.npm/npx/..."
// for Yarn check "~/.yarn/bin/create-wdio"
// for Bun check "~/.bun/bin/create-wdio"
argv[1].includes(`${path.sep}${pm}${path.sep}`) ||
argv[1].includes(`${path.sep}.${pm}${path.sep}`))) || 'npm';
}
/**
* add ts-node if TypeScript is desired but not installed
*/
export async function setupTypeScript(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 `ts-node`
* as it is a requirement for running TypeScript tests
*/
if (parsedAnswers.hasRootTSConfig) {
parsedAnswers.packagesToInstall.push('ts-node');
return;
}
console.log('Setting up TypeScript...');
const frameworkPackage = convertPackageHashToObject(parsedAnswers.rawAnswers.framework);
const servicePackages = parsedAnswers.rawAnswers.services.map((service) => convertPackageHashToObject(service));
parsedAnswers.packagesToInstall.push('ts-node', 'typescript');
const serenityTypes = parsedAnswers.serenityAdapter === 'jasmine' ? ['jasmine'] : [];
const types = [
'node',
'@wdio/globals/types',
'expect-webdriverio',
...(parsedAnswers.serenityAdapter ? serenityTypes : [frameworkPackage.package]),
...(parsedAnswers.runner === 'browser' ? ['@wdio/browser-runner'] : []),
...servicePackages
.map(service => service.package)
.filter(service => (
/**
* given that we know that all "official" services have
* typescript support we only include them
*/
service.startsWith('@wdio') ||
/**
* also include community maintained packages with known
* support for TypeScript
*/
COMMUNITY_PACKAGES_WITH_TS_SUPPORT.includes(service)))
];
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']
};
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) {
const isUsingFramework = typeof parsedAnswers.preset === 'string';
return isUsingFramework ? (parsedAnswers.preset || 'lit') : '';
}
/**
* add @babel/register package if not installed
*/
export async function setupBabel(parsedAnswers) {
if (!parsedAnswers.isUsingBabel) {
return;
}
if (!await hasPackage('@babel/register')) {
parsedAnswers.packagesToInstall.push('@babel/register');
}
/**
* setup Babel if no config file exists
*/
const hasBabelConfig = await Promise.all([
fs.access(path.join(parsedAnswers.projectRootDir, 'babel.js')),
fs.access(path.join(parsedAnswers.projectRootDir, 'babel.cjs')),
fs.access(path.join(parsedAnswers.projectRootDir, 'babel.mjs')),
fs.access(path.join(parsedAnswers.projectRootDir, '.babelrc'))
]).then((results) => results.filter(Boolean).length > 1, () => false);
if (!hasBabelConfig) {
console.log('Setting up Babel project...');
if (!await hasPackage('@babel/core')) {
parsedAnswers.packagesToInstall.push('@babel/core');
}
if (!await hasPackage('@babel/preset-env')) {
parsedAnswers.packagesToInstall.push('@babel/preset-env');
}
await fs.writeFile(path.join(process.cwd(), 'babel.config.js'), `module.exports = ${JSON.stringify({
presets: [
['@babel/preset-env', {
targets: {
node: 18
}
}]
]
}, null, 4)}`);
console.log(chalk.green(chalk.bold('✔ Success!\n')));
}
}
export async function createWDIOConfig(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.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) {
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) {
const rootDir = await getProjectRoot(parsedAnswers);
const pathToWdioConfig = `./${path.join('.', parsedAnswers.wdioConfigPath.replace(rootDir, ''))}`;
const wdioScripts = {
'wdio': `wdio run ${pathToWdioConfig}`,
};
const serenityScripts = {
'serenity': 'failsafe serenity:update serenity:clean wdio serenity:report',
'serenity:update': 'serenity-bdd update',
'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.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) {
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/2.0/quickstart/');
}
return $({ stdio: 'inherit' }) `npx appium-installer`;
}