UNPKG

38.3 kBJavaScriptView Raw
1import fs from 'node:fs/promises';
2import util, { promisify } from 'node:util';
3import path, { dirname } from 'node:path';
4import { fileURLToPath } from 'node:url';
5import { execSync, spawn } from 'node:child_process';
6import ejs from 'ejs';
7import chalk from 'chalk';
8import inquirer from 'inquirer';
9import pickBy from 'lodash.pickby';
10import logger from '@wdio/logger';
11import readDir from 'recursive-readdir';
12import { $ } from 'execa';
13import { readPackageUp } from 'read-pkg-up';
14import { resolve } from 'import-meta-resolve';
15import { SevereServiceError } from 'webdriverio';
16import { ConfigParser } from '@wdio/config/node';
17import { CAPABILITY_KEYS } from '@wdio/protocols';
18import { installPackages, getInstallCommand } from './install.js';
19import { ANDROID_CONFIG, CompilerOptions, DEPENDENCIES_INSTALLATION_MESSAGE, IOS_CONFIG, pkg, QUESTIONNAIRE, TESTING_LIBRARY_PACKAGES, COMMUNITY_PACKAGES_WITH_TS_SUPPORT, usesSerenity, PMs, } from './constants.js';
20import { EjsHelpers } from './templates/EjsHelpers.js';
21const log = logger('@wdio/cli:utils');
22const __dirname = dirname(fileURLToPath(import.meta.url));
23const NPM_COMMAND = /^win/.test(process.platform) ? 'npm.cmd' : 'npm';
24const VERSION_REGEXP = /(\d+)\.(\d+)\.(\d+)-(alpha|beta|)\.(\d+)\+(.+)/g;
25const TEMPLATE_ROOT_DIR = path.join(__dirname, 'templates', 'exampleFiles');
26export const renderFile = promisify(ejs.renderFile);
27export class HookError extends SevereServiceError {
28 origin;
29 constructor(message, origin) {
30 super(message);
31 this.origin = origin;
32 }
33}
34/**
35 * run service launch sequences
36 */
37export async function runServiceHook(launcher, hookName, ...args) {
38 const start = Date.now();
39 return Promise.all(launcher.map(async (service) => {
40 try {
41 if (typeof service[hookName] === 'function') {
42 await service[hookName](...args);
43 }
44 }
45 catch (err) {
46 const message = `A service failed in the '${hookName}' hook\n${err.stack}\n\n`;
47 if (err instanceof SevereServiceError || err.name === 'SevereServiceError') {
48 return { status: 'rejected', reason: message, origin: hookName };
49 }
50 log.error(`${message}Continue...`);
51 }
52 })).then(results => {
53 if (launcher.length) {
54 log.debug(`Finished to run "${hookName}" hook in ${Date.now() - start}ms`);
55 }
56 const rejectedHooks = results.filter(p => p && p.status === 'rejected');
57 if (rejectedHooks.length) {
58 return Promise.reject(new HookError(`\n${rejectedHooks.map(p => p && p.reason).join()}\n\nStopping runner...`, hookName));
59 }
60 });
61}
62/**
63 * Run hook in service launcher
64 * @param {Array|Function} hook - can be array of functions or single function
65 * @param {object} config
66 * @param {object} capabilities
67 */
68export async function runLauncherHook(hook, ...args) {
69 if (typeof hook === 'function') {
70 hook = [hook];
71 }
72 const catchFn = (e) => {
73 log.error(`Error in hook: ${e.stack}`);
74 if (e instanceof SevereServiceError) {
75 throw new HookError(e.message, hook[0].name);
76 }
77 };
78 return Promise.all(hook.map((hook) => {
79 try {
80 return hook(...args);
81 }
82 catch (err) {
83 return catchFn(err);
84 }
85 })).catch(catchFn);
86}
87/**
88 * Run onCompleteHook in Launcher
89 * @param {Array|Function} onCompleteHook - can be array of functions or single function
90 * @param {*} config
91 * @param {*} capabilities
92 * @param {*} exitCode
93 * @param {*} results
94 */
95export async function runOnCompleteHook(onCompleteHook, config, capabilities, exitCode, results) {
96 if (typeof onCompleteHook === 'function') {
97 onCompleteHook = [onCompleteHook];
98 }
99 return Promise.all(onCompleteHook.map(async (hook) => {
100 try {
101 await hook(exitCode, config, capabilities, results);
102 return 0;
103 }
104 catch (err) {
105 log.error(`Error in onCompleteHook: ${err.stack}`);
106 if (err instanceof SevereServiceError) {
107 throw new HookError(err.message, 'onComplete');
108 }
109 return 1;
110 }
111 }));
112}
113/**
114 * get runner identification by caps
115 */
116export function getRunnerName(caps = {}) {
117 let runner = caps.browserName ||
118 caps.platformName ||
119 caps['appium:platformName'] ||
120 caps['appium:appPackage'] ||
121 caps['appium:appWaitActivity'] ||
122 caps['appium:app'];
123 // MultiRemote
124 if (!runner) {
125 runner = Object.values(caps).length === 0 || Object.values(caps).some(cap => !cap.capabilities) ? 'undefined' : 'MultiRemote';
126 }
127 return runner;
128}
129function buildNewConfigArray(str, type, change) {
130 const newStr = str
131 .split(`${type}s: `)[1]
132 .replace(/'/g, '');
133 const newArray = newStr.match(/(\w*)/gmi)?.filter(e => !!e).concat([change]) || [];
134 return str
135 .replace('// ', '')
136 .replace(new RegExp(`(${type}s: )((.*\\s*)*)`), `$1[${newArray.map(e => `'${e}'`)}]`);
137}
138function buildNewConfigString(str, type, change) {
139 return str.replace(new RegExp(`(${type}: )('\\w*')`), `$1'${change}'`);
140}
141export function findInConfig(config, type) {
142 let regexStr = `[\\/\\/]*[\\s]*${type}s: [\\s]*\\[([\\s]*['|"]\\w*['|"],*)*[\\s]*\\]`;
143 if (type === 'framework') {
144 regexStr = `[\\/\\/]*[\\s]*${type}: ([\\s]*['|"]\\w*['|"])`;
145 }
146 const regex = new RegExp(regexStr, 'gmi');
147 return config.match(regex);
148}
149export function replaceConfig(config, type, name) {
150 if (type === 'framework') {
151 return buildNewConfigString(config, type, name);
152 }
153 const match = findInConfig(config, type);
154 if (!match || match.length === 0) {
155 return;
156 }
157 const text = match.pop() || '';
158 return config.replace(text, buildNewConfigArray(text, type, name));
159}
160export function addServiceDeps(names, packages, update = false) {
161 /**
162 * install Appium if it is not installed globally if `@wdio/appium-service`
163 * was selected for install
164 */
165 if (names.some(({ short }) => short === 'appium')) {
166 const result = execSync('appium --version || echo APPIUM_MISSING', { stdio: 'pipe' }).toString().trim();
167 if (result === 'APPIUM_MISSING') {
168 packages.push('appium');
169 }
170 else if (update) {
171 // eslint-disable-next-line no-console
172 console.log('\n=======', '\nUsing globally installed appium', result, '\nPlease add the following to your wdio.conf.js:', "\nappium: { command: 'appium' }", '\n=======\n');
173 }
174 }
175}
176/**
177 * @todo add JSComments
178 */
179export function convertPackageHashToObject(pkg, hash = '$--$') {
180 const [p, short, purpose] = pkg.split(hash);
181 return { package: p, short, purpose };
182}
183export function getSerenityPackages(answers) {
184 const framework = convertPackageHashToObject(answers.framework);
185 if (framework.package !== '@serenity-js/webdriverio') {
186 return [];
187 }
188 const isUsingTypeScript = answers.isUsingCompiler === CompilerOptions.TS;
189 const packages = {
190 cucumber: [
191 '@cucumber/cucumber',
192 '@serenity-js/cucumber',
193 ],
194 mocha: [
195 '@serenity-js/mocha',
196 'mocha',
197 isUsingTypeScript && '@types/mocha',
198 ],
199 jasmine: [
200 '@serenity-js/jasmine',
201 'jasmine',
202 isUsingTypeScript && '@types/jasmine',
203 ],
204 common: [
205 '@serenity-js/assertions',
206 '@serenity-js/console-reporter',
207 '@serenity-js/core',
208 '@serenity-js/rest',
209 '@serenity-js/serenity-bdd',
210 '@serenity-js/web',
211 isUsingTypeScript && '@types/node',
212 'npm-failsafe',
213 'rimraf',
214 ]
215 };
216 return [
217 ...packages[framework.purpose],
218 ...packages.common,
219 ].filter(Boolean).sort();
220}
221export async function getCapabilities(arg) {
222 const optionalCapabilites = {
223 platformVersion: arg.platformVersion,
224 udid: arg.udid,
225 ...(arg.deviceName && { deviceName: arg.deviceName })
226 };
227 /**
228 * Parsing of option property and constructing desiredCapabilities
229 * for Appium session. Could be application(1) or browser(2-3) session.
230 */
231 if (/.*\.(apk|app|ipa)$/.test(arg.option)) {
232 return {
233 capabilities: {
234 app: arg.option,
235 ...(arg.option.endsWith('apk') ? ANDROID_CONFIG : IOS_CONFIG),
236 ...optionalCapabilites,
237 }
238 };
239 }
240 else if (/android/.test(arg.option)) {
241 return { capabilities: { browserName: 'Chrome', ...ANDROID_CONFIG, ...optionalCapabilites } };
242 }
243 else if (/ios/.test(arg.option)) {
244 return { capabilities: { browserName: 'Safari', ...IOS_CONFIG, ...optionalCapabilites } };
245 }
246 else if (/(js|ts)$/.test(arg.option)) {
247 const config = new ConfigParser(arg.option);
248 try {
249 await config.initialize();
250 }
251 catch (e) {
252 throw Error(e.code === 'MODULE_NOT_FOUND' ? `Config File not found: ${arg.option}` :
253 `Could not parse ${arg.option}, failed with error : ${e.message}`);
254 }
255 if (typeof arg.capabilities === 'undefined') {
256 throw Error('Please provide index/named property of capability to use from the capabilities array/object in wdio config file');
257 }
258 let requiredCaps = config.getCapabilities();
259 requiredCaps = (
260 // multi capabilities
261 requiredCaps[parseInt(arg.capabilities, 10)] ||
262 // multiremote
263 requiredCaps[arg.capabilities]);
264 const requiredW3CCaps = pickBy(requiredCaps, (_, key) => CAPABILITY_KEYS.includes(key) || key.includes(':'));
265 if (!Object.keys(requiredW3CCaps).length) {
266 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.`);
267 }
268 return { capabilities: { ...requiredW3CCaps } };
269 }
270 return { capabilities: { browserName: arg.option } };
271}
272/**
273 * Checks if certain directory has babel configuration files
274 * @param rootDir directory where this function checks for Babel signs
275 * @returns true, if a babel config was found, otherwise false
276 */
277export function hasBabelConfig(rootDir) {
278 return Promise.all([
279 fs.access(path.join(rootDir, 'babel.js')),
280 fs.access(path.join(rootDir, 'babel.cjs')),
281 fs.access(path.join(rootDir, 'babel.mjs')),
282 fs.access(path.join(rootDir, '.babelrc'))
283 ]).then((results) => results.filter(Boolean).length > 1, () => false);
284}
285/**
286 * detect if project has a compiler file
287 */
288export async function detectCompiler(answers) {
289 const root = await getProjectRoot(answers);
290 const rootTSConfigExist = await fs.access(path.resolve(root, 'tsconfig.json')).then(() => true, () => false);
291 return (await hasBabelConfig(root))
292 ? CompilerOptions.Babel // default to Babel
293 : rootTSConfigExist
294 ? CompilerOptions.TS // default to TypeScript
295 : CompilerOptions.Nil; // default to no compiler
296}
297/**
298 * Check if package is installed
299 * @param {string} package to check existance for
300 */
301export async function hasPackage(pkg) {
302 try {
303 await resolve(pkg, import.meta.url);
304 return true;
305 }
306 catch (err) {
307 return false;
308 }
309}
310/**
311 * generate test files based on CLI answers
312 */
313export async function generateTestFiles(answers) {
314 if (answers.serenityAdapter) {
315 return generateSerenityExamples(answers);
316 }
317 if (answers.runner === 'local') {
318 return generateLocalRunnerTestFiles(answers);
319 }
320 return generateBrowserRunnerTestFiles(answers);
321}
322const TSX_BASED_FRAMEWORKS = ['react', 'preact', 'solid', 'stencil'];
323export async function generateBrowserRunnerTestFiles(answers) {
324 const isUsingFramework = typeof answers.preset === 'string';
325 const preset = getPreset(answers);
326 const tplRootDir = path.join(TEMPLATE_ROOT_DIR, 'browser');
327 await fs.mkdir(answers.destSpecRootPath, { recursive: true });
328 /**
329 * render css file
330 */
331 if (isUsingFramework) {
332 const renderedCss = await renderFile(path.join(tplRootDir, 'Component.css.ejs'), { answers });
333 await fs.writeFile(path.join(answers.destSpecRootPath, 'Component.css'), renderedCss);
334 }
335 /**
336 * render component file
337 */
338 const testExt = `${(answers.isUsingTypeScript ? 'ts' : 'js')}${TSX_BASED_FRAMEWORKS.includes(preset) ? 'x' : ''}`;
339 const fileExt = ['svelte', 'vue'].includes(preset)
340 ? preset
341 : testExt;
342 if (preset) {
343 const componentOutFileName = `Component.${fileExt}`;
344 const renderedComponent = await renderFile(path.join(tplRootDir, `Component.${preset}.ejs`), { answers });
345 await fs.writeFile(path.join(answers.destSpecRootPath, componentOutFileName), renderedComponent);
346 }
347 /**
348 * render test file
349 */
350 const componentFileName = preset ? `Component.${preset}.test.ejs` : 'standalone.test.ejs';
351 const renderedTest = await renderFile(path.join(tplRootDir, componentFileName), { answers });
352 await fs.writeFile(path.join(answers.destSpecRootPath, `Component.test.${testExt}`), renderedTest);
353}
354async function generateLocalRunnerTestFiles(answers) {
355 const testFiles = answers.framework === 'cucumber'
356 ? [path.join(TEMPLATE_ROOT_DIR, 'cucumber')]
357 : [path.join(TEMPLATE_ROOT_DIR, 'mochaJasmine')];
358 if (answers.usePageObjects) {
359 testFiles.push(path.join(TEMPLATE_ROOT_DIR, 'pageobjects'));
360 }
361 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)], []);
362 for (const file of files) {
363 const renderedTpl = await renderFile(file, { answers });
364 const isJSX = answers.preset && TSX_BASED_FRAMEWORKS.includes(answers.preset);
365 const fileEnding = (answers.isUsingTypeScript ? '.ts' : '.js') + (isJSX ? 'x' : '');
366 const destPath = (file.endsWith('page.js.ejs')
367 ? path.join(answers.destPageObjectRootPath, path.basename(file))
368 : file.includes('step_definition')
369 ? path.join(answers.destStepRootPath, path.basename(file))
370 : path.join(answers.destSpecRootPath, path.basename(file))).replace(/\.ejs$/, '').replace(/\.js$/, fileEnding);
371 await fs.mkdir(path.dirname(destPath), { recursive: true });
372 await fs.writeFile(destPath, renderedTpl);
373 }
374}
375async function generateSerenityExamples(answers) {
376 const templateDirectories = {
377 [answers.projectRootDir]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', 'common', 'config'),
378 [answers.destSpecRootPath]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', answers.serenityAdapter),
379 [answers.destSerenityLibRootPath]: path.join(TEMPLATE_ROOT_DIR, 'serenity-js', 'common', 'serenity'),
380 };
381 for (const [destinationRootDir, templateRootDir] of Object.entries(templateDirectories)) {
382 const pathsToTemplates = await readDir(templateRootDir);
383 for (const pathToTemplate of pathsToTemplates) {
384 const extension = answers.isUsingTypeScript ? '.ts' : '.js';
385 const destination = path.join(destinationRootDir, path.relative(templateRootDir, pathToTemplate))
386 .replace(/\.ejs$/, '')
387 .replace(/\.ts$/, extension);
388 const contents = await renderFile(pathToTemplate, { answers, _: new EjsHelpers({ useEsm: answers.esmSupport, useTypeScript: answers.isUsingTypeScript }) });
389 await fs.mkdir(path.dirname(destination), { recursive: true });
390 await fs.writeFile(destination, contents);
391 }
392 }
393}
394export async function getAnswers(yes) {
395 if (yes) {
396 const ignoredQuestions = ['e2eEnvironment'];
397 const filterdQuestionaire = QUESTIONNAIRE.filter((question) => !ignoredQuestions.includes(question.name));
398 const answers = {};
399 for (const question of filterdQuestionaire) {
400 /**
401 * set nothing if question doesn't apply
402 */
403 if (question.when && !question.when(answers)) {
404 continue;
405 }
406 Object.assign(answers, {
407 [question.name]: typeof question.default !== 'undefined'
408 /**
409 * set default value if existing
410 */
411 ? typeof question.default === 'function'
412 ? await question.default(answers)
413 : await question.default
414 : question.choices && question.choices.length
415 /**
416 * pick first choice, select value if it exists
417 */
418 ? typeof question.choices === 'function'
419 ? question.choices(answers)[0].value
420 ? question.choices(answers)[0].value
421 : question.choices(answers)[0]
422 : question.choices[0].value
423 ? question.choices[0].value
424 : question.choices[0]
425 : {}
426 });
427 }
428 /**
429 * some questions have async defaults
430 */
431 answers.isUsingCompiler = await answers.isUsingCompiler;
432 answers.specs = await answers.specs;
433 answers.pages = await answers.pages;
434 return answers;
435 }
436 const projectProps = await getProjectProps(process.cwd());
437 const isProjectExisting = Boolean(projectProps);
438 const projectName = projectProps?.packageJson?.name ? ` named "${projectProps.packageJson.name}"` : '';
439 const questions = [
440 /**
441 * in case the `wdio config` was called using a global installed @wdio/cli package
442 */
443 ...(!isProjectExisting
444 ? [{
445 type: 'confirm',
446 name: 'createPackageJSON',
447 default: true,
448 message: `Couldn't find a package.json in "${process.cwd()}" or any of the parent directories, do you want to create one?`,
449 }]
450 /**
451 * in case create-wdio was used which creates a package.json with name "my-new-project"
452 * we don't need to ask this question
453 */
454 : projectProps?.packageJson?.name !== 'my-new-project'
455 ? [{
456 type: 'confirm',
457 name: 'projectRootCorrect',
458 default: true,
459 message: `A project${projectName} was detected at "${projectProps?.path}", correct?`,
460 }, {
461 type: 'input',
462 name: 'projectRoot',
463 message: 'What is the project root for your test project?',
464 default: projectProps?.path,
465 // only ask if there are more than 1 runner to pick from
466 when: /* istanbul ignore next */ (answers) => !answers.projectRootCorrect
467 }]
468 : []),
469 ...QUESTIONNAIRE
470 ];
471 return inquirer.prompt(questions);
472}
473/**
474 * Generates a valid file path from answers provided.
475 * @param answers The answer from which a file path is to be generated.
476 * @param projectRootDir The root directory of the project.
477 * @returns filePath
478 */
479function generatePathfromAnswer(answers, projectRootDir) {
480 return path.resolve(projectRootDir, path.dirname(answers) === '.' ? path.resolve(answers) : path.dirname(answers));
481}
482export function getPathForFileGeneration(answers, projectRootDir) {
483 const specAnswer = answers.specs || '';
484 const stepDefinitionAnswer = answers.stepDefinitions || '';
485 const pageObjectAnswer = answers.pages || '';
486 const destSpecRootPath = generatePathfromAnswer(specAnswer, projectRootDir).replace(/\*\*$/, '');
487 const destStepRootPath = generatePathfromAnswer(stepDefinitionAnswer, projectRootDir);
488 const destPageObjectRootPath = answers.usePageObjects
489 ? generatePathfromAnswer(pageObjectAnswer, projectRootDir).replace(/\*\*$/, '')
490 : '';
491 const destSerenityLibRootPath = usesSerenity(answers)
492 ? path.resolve(projectRootDir, answers.serenityLibPath || 'serenity')
493 : '';
494 const relativePath = (answers.generateTestFiles && answers.usePageObjects)
495 ? !(convertPackageHashToObject(answers.framework).short === 'cucumber')
496 ? path.relative(destSpecRootPath, destPageObjectRootPath)
497 : path.relative(destStepRootPath, destPageObjectRootPath)
498 : '';
499 return {
500 destSpecRootPath: destSpecRootPath,
501 destStepRootPath: destStepRootPath,
502 destPageObjectRootPath: destPageObjectRootPath,
503 destSerenityLibRootPath: destSerenityLibRootPath,
504 relativePath: relativePath.replaceAll(path.sep, '/')
505 };
506}
507export async function getDefaultFiles(answers, pattern) {
508 const rootdir = await getProjectRoot(answers);
509 const presetPackage = convertPackageHashToObject(answers.preset || '');
510 const isJSX = TSX_BASED_FRAMEWORKS.includes(presetPackage.short || '');
511 const val = pattern.endsWith('.feature')
512 ? path.join(rootdir, pattern)
513 : answers?.isUsingCompiler?.toString().includes('TypeScript')
514 ? `${path.join(rootdir, pattern)}.ts${isJSX ? 'x' : ''}`
515 : `${path.join(rootdir, pattern)}.js${isJSX ? 'x' : ''}`;
516 return val;
517}
518/**
519 * Ensure core WebdriverIO packages have the same version as cli so that if someone
520 * installs `@wdio/cli@next` and runs the wizard, all related packages have the same version.
521 * running `matchAll` to a version like "8.0.0-alpha.249+4bc237701", results in:
522 * ['8.0.0-alpha.249+4bc237701', '8', '0', '0', 'alpha', '249', '4bc237701']
523 */
524export function specifyVersionIfNeeded(packagesToInstall, version, npmTag) {
525 const { value } = version.matchAll(VERSION_REGEXP).next();
526 const [major, minor, patch, tagName, build] = (value || []).slice(1, -1); // drop commit bit
527 return packagesToInstall.map((p) => {
528 if ((p.startsWith('@wdio') && p !== '@wdio/visual-service') ||
529 ['devtools', 'webdriver', 'webdriverio'].includes(p)) {
530 const tag = major && npmTag === 'latest'
531 ? `^${major}.${minor}.${patch}-${tagName}.${build}`
532 : npmTag;
533 return `${p}@${tag}`;
534 }
535 return p;
536 });
537}
538/**
539 * Receive project properties
540 * @returns {@type ProjectProps} if a package.json can be found in cwd or parent directories, otherwise undefined
541 * which means that a new project can be created
542 */
543export async function getProjectProps(cwd = process.cwd()) {
544 try {
545 const { packageJson, path: packageJsonPath } = await readPackageUp({ cwd }) || {};
546 if (!packageJson || !packageJsonPath) {
547 return undefined;
548 }
549 return {
550 esmSupported: (packageJson.type === 'module' ||
551 typeof packageJson.module === 'string'),
552 packageJson,
553 path: path.dirname(packageJsonPath)
554 };
555 }
556 catch (err) {
557 return undefined;
558 }
559}
560export function runProgram(command, args, options) {
561 const child = spawn(command, args, { stdio: 'inherit', ...options });
562 return new Promise((resolve, reject) => {
563 let error;
564 child.on('error', (e) => (error = e));
565 child.on('close', code => {
566 if (code !== 0) {
567 return reject(new Error((error && error.message) ||
568 `Error calling: ${command} ${args.join(' ')}`));
569 }
570 resolve();
571 });
572 });
573}
574/**
575 * create package.json if not already existing
576 */
577export async function createPackageJSON(parsedAnswers) {
578 const packageJsonExists = await fs.access(path.resolve(process.cwd(), 'package.json')).then(() => true, () => false);
579 // Use the exisitng package.json if it already exists.
580 if (packageJsonExists) {
581 return;
582 }
583 // If a user said no to creating a package.json, but it doesn't exist, abort.
584 if (parsedAnswers.createPackageJSON === false) {
585 /* istanbul ignore if */
586 if (!packageJsonExists) {
587 console.log(`No WebdriverIO configuration found in "${parsedAnswers.wdioConfigPath}"`);
588 return !process.env.VITEST_WORKER_ID && process.exit(0);
589 }
590 return;
591 }
592 // Only create if the user gave explicit permission to
593 if (parsedAnswers.createPackageJSON) {
594 console.log(`Creating a ${chalk.bold('package.json')} for the directory...`);
595 await fs.writeFile(path.resolve(process.cwd(), 'package.json'), JSON.stringify({
596 name: 'webdriverio-tests',
597 version: '0.0.0',
598 private: true,
599 license: 'ISC',
600 type: 'module',
601 dependencies: {},
602 devDependencies: {}
603 }, null, 2));
604 console.log(chalk.green(chalk.bold('✔ Success!\n')));
605 }
606}
607/**
608 * run npm install only if required by the user
609 */
610const SEP = '\n- ';
611export async function npmInstall(parsedAnswers, npmTag) {
612 const servicePackages = parsedAnswers.rawAnswers.services.map((service) => convertPackageHashToObject(service));
613 const presetPackage = convertPackageHashToObject(parsedAnswers.rawAnswers.preset || '');
614 /**
615 * install Testing Library dependency if desired
616 */
617 if (parsedAnswers.installTestingLibrary && TESTING_LIBRARY_PACKAGES[presetPackage.short]) {
618 parsedAnswers.packagesToInstall.push(TESTING_LIBRARY_PACKAGES[presetPackage.short], '@testing-library/jest-dom');
619 }
620 /**
621 * add helper package for Solidjs testing
622 */
623 if (presetPackage.short === 'solid') {
624 parsedAnswers.packagesToInstall.push('solid-js');
625 }
626 /**
627 * add visual service if user selected support for it
628 */
629 if (parsedAnswers.includeVisualTesting) {
630 parsedAnswers.packagesToInstall.push('@wdio/visual-service');
631 }
632 /**
633 * add dependency for Lit testing
634 */
635 const preset = getPreset(parsedAnswers);
636 if (preset === 'lit') {
637 parsedAnswers.packagesToInstall.push('lit');
638 }
639 /**
640 * add dependency for Stencil testing
641 */
642 if (preset === 'stencil') {
643 parsedAnswers.packagesToInstall.push('@stencil/core');
644 }
645 /**
646 * add helper for React rendering when not using Testing Library
647 */
648 if (presetPackage.short === 'react') {
649 parsedAnswers.packagesToInstall.push('react');
650 if (!parsedAnswers.installTestingLibrary) {
651 parsedAnswers.packagesToInstall.push('react-dom');
652 }
653 }
654 /**
655 * add Jasmine types if necessary
656 */
657 if (parsedAnswers.framework === 'jasmine' && parsedAnswers.isUsingTypeScript) {
658 parsedAnswers.packagesToInstall.push('@types/jasmine');
659 }
660 /**
661 * add Appium mobile drivers if desired
662 */
663 if (parsedAnswers.purpose === 'macos') {
664 parsedAnswers.packagesToInstall.push('appium-mac2-driver');
665 }
666 if (parsedAnswers.mobileEnvironment === 'android') {
667 parsedAnswers.packagesToInstall.push('appium-uiautomator2-driver');
668 }
669 if (parsedAnswers.mobileEnvironment === 'ios') {
670 parsedAnswers.packagesToInstall.push('appium-xcuitest-driver');
671 }
672 /**
673 * add packages that are required by services
674 */
675 addServiceDeps(servicePackages, parsedAnswers.packagesToInstall);
676 /**
677 * update package version if CLI is a pre release
678 */
679 parsedAnswers.packagesToInstall = specifyVersionIfNeeded(parsedAnswers.packagesToInstall, pkg.version, npmTag);
680 const cwd = await getProjectRoot(parsedAnswers);
681 const pm = detectPackageManager();
682 if (parsedAnswers.npmInstall) {
683 console.log(`Installing packages using ${pm}:${SEP}${parsedAnswers.packagesToInstall.join(SEP)}`);
684 const success = await installPackages(cwd, parsedAnswers.packagesToInstall, true);
685 if (success) {
686 console.log(chalk.green(chalk.bold('✔ Success!\n')));
687 }
688 }
689 else {
690 const installationCommand = getInstallCommand(pm, parsedAnswers.packagesToInstall, true);
691 console.log(util.format(DEPENDENCIES_INSTALLATION_MESSAGE, installationCommand));
692 }
693}
694/**
695 * detect the package manager that was used
696 */
697export function detectPackageManager(argv = process.argv) {
698 return PMs.find((pm) => (
699 // for pnpm check "~/Library/pnpm/store/v3/..."
700 // for NPM check "~/.npm/npx/..."
701 // for Yarn check "~/.yarn/bin/create-wdio"
702 // for Bun check "~/.bun/bin/create-wdio"
703 argv[1].includes(`${path.sep}${pm}${path.sep}`) ||
704 argv[1].includes(`${path.sep}.${pm}${path.sep}`))) || 'npm';
705}
706/**
707 * add ts-node if TypeScript is desired but not installed
708 */
709export async function setupTypeScript(parsedAnswers) {
710 /**
711 * don't create a `tsconfig.json` if user doesn't want to use TypeScript
712 */
713 if (!parsedAnswers.isUsingTypeScript) {
714 return;
715 }
716 /**
717 * don't set up TypeScript if a `tsconfig.json` already exists but ensure we install `ts-node`
718 * as it is a requirement for running TypeScript tests
719 */
720 if (parsedAnswers.hasRootTSConfig) {
721 parsedAnswers.packagesToInstall.push('ts-node');
722 return;
723 }
724 console.log('Setting up TypeScript...');
725 const frameworkPackage = convertPackageHashToObject(parsedAnswers.rawAnswers.framework);
726 const servicePackages = parsedAnswers.rawAnswers.services.map((service) => convertPackageHashToObject(service));
727 parsedAnswers.packagesToInstall.push('ts-node', 'typescript');
728 const serenityTypes = parsedAnswers.serenityAdapter === 'jasmine' ? ['jasmine'] : [];
729 const types = [
730 'node',
731 '@wdio/globals/types',
732 'expect-webdriverio',
733 ...(parsedAnswers.serenityAdapter ? serenityTypes : [frameworkPackage.package]),
734 ...(parsedAnswers.runner === 'browser' ? ['@wdio/browser-runner'] : []),
735 ...servicePackages
736 .map(service => service.package)
737 .filter(service => (
738 /**
739 * given that we know that all "official" services have
740 * typescript support we only include them
741 */
742 service.startsWith('@wdio') ||
743 /**
744 * also include community maintained packages with known
745 * support for TypeScript
746 */
747 COMMUNITY_PACKAGES_WITH_TS_SUPPORT.includes(service)))
748 ];
749 const preset = getPreset(parsedAnswers);
750 const config = {
751 compilerOptions: {
752 // compiler
753 moduleResolution: 'node',
754 module: !parsedAnswers.esmSupport ? 'commonjs' : 'ESNext',
755 target: 'es2022',
756 lib: ['es2022', 'dom'],
757 types,
758 skipLibCheck: true,
759 // bundler
760 noEmit: true,
761 allowImportingTsExtensions: true,
762 resolveJsonModule: true,
763 isolatedModules: true,
764 // linting
765 strict: true,
766 noUnusedLocals: true,
767 noUnusedParameters: true,
768 noFallthroughCasesInSwitch: true,
769 ...Object.assign(preset === 'lit'
770 ? {
771 experimentalDecorators: true,
772 useDefineForClassFields: false
773 }
774 : {}, preset === 'react'
775 ? {
776 jsx: 'react-jsx'
777 }
778 : {}, preset === 'preact'
779 ? {
780 jsx: 'react-jsx',
781 jsxImportSource: 'preact'
782 }
783 : {}, preset === 'solid'
784 ? {
785 jsx: 'preserve',
786 jsxImportSource: 'solid-js'
787 }
788 : {}, preset === 'stencil'
789 ? {
790 experimentalDecorators: true,
791 jsx: 'react',
792 jsxFactory: 'h',
793 jsxFragmentFactory: 'Fragment'
794 }
795 : {})
796 },
797 include: preset === 'svelte'
798 ? ['src/**/*.d.ts', 'src/**/*.ts', 'src/**/*.js', 'src/**/*.svelte']
799 : preset === 'vue'
800 ? ['src/**/*.ts', 'src/**/*.d.ts', 'src/**/*.tsx', 'src/**/*.vue']
801 : ['test', 'wdio.conf.ts']
802 };
803 await fs.mkdir(path.dirname(parsedAnswers.tsConfigFilePath), { recursive: true });
804 await fs.writeFile(parsedAnswers.tsConfigFilePath, JSON.stringify(config, null, 4));
805 console.log(chalk.green(chalk.bold('✔ Success!\n')));
806}
807function getPreset(parsedAnswers) {
808 const isUsingFramework = typeof parsedAnswers.preset === 'string';
809 return isUsingFramework ? (parsedAnswers.preset || 'lit') : '';
810}
811/**
812 * add @babel/register package if not installed
813 */
814export async function setupBabel(parsedAnswers) {
815 if (!parsedAnswers.isUsingBabel) {
816 return;
817 }
818 if (!await hasPackage('@babel/register')) {
819 parsedAnswers.packagesToInstall.push('@babel/register');
820 }
821 /**
822 * setup Babel if no config file exists
823 */
824 const hasBabelConfig = await Promise.all([
825 fs.access(path.join(parsedAnswers.projectRootDir, 'babel.js')),
826 fs.access(path.join(parsedAnswers.projectRootDir, 'babel.cjs')),
827 fs.access(path.join(parsedAnswers.projectRootDir, 'babel.mjs')),
828 fs.access(path.join(parsedAnswers.projectRootDir, '.babelrc'))
829 ]).then((results) => results.filter(Boolean).length > 1, () => false);
830 if (!hasBabelConfig) {
831 console.log('Setting up Babel project...');
832 if (!await hasPackage('@babel/core')) {
833 parsedAnswers.packagesToInstall.push('@babel/core');
834 }
835 if (!await hasPackage('@babel/preset-env')) {
836 parsedAnswers.packagesToInstall.push('@babel/preset-env');
837 }
838 await fs.writeFile(path.join(process.cwd(), 'babel.config.js'), `module.exports = ${JSON.stringify({
839 presets: [
840 ['@babel/preset-env', {
841 targets: {
842 node: 18
843 }
844 }]
845 ]
846 }, null, 4)}`);
847 console.log(chalk.green(chalk.bold('✔ Success!\n')));
848 }
849}
850export async function createWDIOConfig(parsedAnswers) {
851 try {
852 console.log('Creating a WebdriverIO config file...');
853 const tplPath = path.resolve(__dirname, 'templates', 'wdio.conf.tpl.ejs');
854 const renderedTpl = await renderFile(tplPath, {
855 answers: parsedAnswers,
856 _: new EjsHelpers({ useEsm: parsedAnswers.esmSupport, useTypeScript: parsedAnswers.isUsingTypeScript })
857 });
858 await fs.writeFile(parsedAnswers.wdioConfigPath, renderedTpl);
859 console.log(chalk.green(chalk.bold('✔ Success!\n')));
860 if (parsedAnswers.generateTestFiles) {
861 console.log('Autogenerating test files...');
862 await generateTestFiles(parsedAnswers);
863 console.log(chalk.green(chalk.bold('✔ Success!\n')));
864 }
865 }
866 catch (err) {
867 throw new Error(`⚠️ Couldn't write config file: ${err.stack}`);
868 }
869}
870/**
871 * Get project root directory based on questionair answers
872 * @param answers questionair answers
873 * @param projectProps project properties received via `getProjectProps`
874 * @returns project root path
875 */
876export async function getProjectRoot(parsedAnswers) {
877 const root = (await getProjectProps())?.path;
878 if (!root) {
879 throw new Error('Could not find project root directory with a package.json');
880 }
881 return !parsedAnswers || parsedAnswers.projectRootCorrect
882 ? root
883 : parsedAnswers.projectRoot || process.cwd();
884}
885export async function createWDIOScript(parsedAnswers) {
886 const rootDir = await getProjectRoot(parsedAnswers);
887 const pathToWdioConfig = `./${path.join('.', parsedAnswers.wdioConfigPath.replace(rootDir, ''))}`;
888 const wdioScripts = {
889 'wdio': `wdio run ${pathToWdioConfig}`,
890 };
891 const serenityScripts = {
892 'serenity': 'failsafe serenity:update serenity:clean wdio serenity:report',
893 'serenity:update': 'serenity-bdd update',
894 'serenity:clean': 'rimraf target',
895 'wdio': `wdio run ${pathToWdioConfig}`,
896 'serenity:report': 'serenity-bdd run',
897 };
898 const scripts = parsedAnswers.serenityAdapter ? serenityScripts : wdioScripts;
899 for (const [script, command] of Object.entries(scripts)) {
900 const args = ['pkg', 'set', `scripts.${script}=${command}`];
901 try {
902 console.log(`Adding ${chalk.bold(`"${script}"`)} script to package.json`);
903 await runProgram(NPM_COMMAND, args, { cwd: parsedAnswers.projectRootDir });
904 }
905 catch (err) {
906 const [preArgs, scriptPath] = args.join(' ').split('=');
907 console.error(`⚠️ Couldn't add script to package.json: "${err.message}", you can add it manually ` +
908 `by running:\n\n\t${NPM_COMMAND} ${preArgs}="${scriptPath}"`);
909 return false;
910 }
911 }
912 console.log(chalk.green(chalk.bold('✔ Success!')));
913 return true;
914}
915export async function runAppiumInstaller(parsedAnswers) {
916 if (parsedAnswers.e2eEnvironment !== 'mobile') {
917 return;
918 }
919 const answer = await inquirer.prompt({
920 name: 'continueWithAppiumSetup',
921 message: 'Continue with Appium setup using appium-installer (https://github.com/AppiumTestDistribution/appium-installer)?',
922 type: 'confirm',
923 default: true
924 });
925 if (!answer.continueWithAppiumSetup) {
926 return console.log('Ok! You can learn more about setting up mobile environments in the ' +
927 'Appium docs at https://appium.io/docs/en/2.0/quickstart/');
928 }
929 return $({ stdio: 'inherit' }) `npx appium-installer`;
930}