1 | import fs from 'node:fs/promises';
|
2 | import util, { promisify } from 'node:util';
|
3 | import path, { dirname } from 'node:path';
|
4 | import { fileURLToPath } from 'node:url';
|
5 | import { execSync, spawn } from 'node:child_process';
|
6 | import ejs from 'ejs';
|
7 | import chalk from 'chalk';
|
8 | import inquirer from 'inquirer';
|
9 | import pickBy from 'lodash.pickby';
|
10 | import logger from '@wdio/logger';
|
11 | import readDir from 'recursive-readdir';
|
12 | import { $ } from 'execa';
|
13 | import { readPackageUp } from 'read-pkg-up';
|
14 | import { resolve } from 'import-meta-resolve';
|
15 | import { SevereServiceError } from 'webdriverio';
|
16 | import { ConfigParser } from '@wdio/config/node';
|
17 | import { CAPABILITY_KEYS } from '@wdio/protocols';
|
18 | import { installPackages, getInstallCommand } from './install.js';
|
19 | import { ANDROID_CONFIG, CompilerOptions, DEPENDENCIES_INSTALLATION_MESSAGE, IOS_CONFIG, pkg, QUESTIONNAIRE, TESTING_LIBRARY_PACKAGES, COMMUNITY_PACKAGES_WITH_TS_SUPPORT, usesSerenity, PMs, } from './constants.js';
|
20 | import { EjsHelpers } from './templates/EjsHelpers.js';
|
21 | const log = logger('@wdio/cli:utils');
|
22 | const __dirname = dirname(fileURLToPath(import.meta.url));
|
23 | const NPM_COMMAND = /^win/.test(process.platform) ? 'npm.cmd' : 'npm';
|
24 | const VERSION_REGEXP = /(\d+)\.(\d+)\.(\d+)-(alpha|beta|)\.(\d+)\+(.+)/g;
|
25 | const TEMPLATE_ROOT_DIR = path.join(__dirname, 'templates', 'exampleFiles');
|
26 | export const renderFile = promisify(ejs.renderFile);
|
27 | export class HookError extends SevereServiceError {
|
28 | origin;
|
29 | constructor(message, origin) {
|
30 | super(message);
|
31 | this.origin = origin;
|
32 | }
|
33 | }
|
34 |
|
35 |
|
36 |
|
37 | export 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 |
|
64 |
|
65 |
|
66 |
|
67 |
|
68 | export 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 |
|
89 |
|
90 |
|
91 |
|
92 |
|
93 |
|
94 |
|
95 | export 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 |
|
115 |
|
116 | export 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 |
|
124 | if (!runner) {
|
125 | runner = Object.values(caps).length === 0 || Object.values(caps).some(cap => !cap.capabilities) ? 'undefined' : 'MultiRemote';
|
126 | }
|
127 | return runner;
|
128 | }
|
129 | function 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 | }
|
138 | function buildNewConfigString(str, type, change) {
|
139 | return str.replace(new RegExp(`(${type}: )('\\w*')`), `$1'${change}'`);
|
140 | }
|
141 | export 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 | }
|
149 | export 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 | }
|
160 | export function addServiceDeps(names, packages, update = false) {
|
161 | |
162 |
|
163 |
|
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 |
|
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 |
|
178 |
|
179 | export function convertPackageHashToObject(pkg, hash = '$--$') {
|
180 | const [p, short, purpose] = pkg.split(hash);
|
181 | return { package: p, short, purpose };
|
182 | }
|
183 | export 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 | }
|
221 | export async function getCapabilities(arg) {
|
222 | const optionalCapabilites = {
|
223 | platformVersion: arg.platformVersion,
|
224 | udid: arg.udid,
|
225 | ...(arg.deviceName && { deviceName: arg.deviceName })
|
226 | };
|
227 | |
228 |
|
229 |
|
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 |
|
261 | requiredCaps[parseInt(arg.capabilities, 10)] ||
|
262 |
|
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 |
|
274 |
|
275 |
|
276 |
|
277 | export 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 |
|
287 |
|
288 | export 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
|
293 | : rootTSConfigExist
|
294 | ? CompilerOptions.TS
|
295 | : CompilerOptions.Nil;
|
296 | }
|
297 |
|
298 |
|
299 |
|
300 |
|
301 | export 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 |
|
312 |
|
313 | export 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 | }
|
322 | const TSX_BASED_FRAMEWORKS = ['react', 'preact', 'solid', 'stencil'];
|
323 | export 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 |
|
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 |
|
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 |
|
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 | }
|
354 | async 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 | }
|
375 | async 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 | }
|
394 | export 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 |
|
402 |
|
403 | if (question.when && !question.when(answers)) {
|
404 | continue;
|
405 | }
|
406 | Object.assign(answers, {
|
407 | [question.name]: typeof question.default !== 'undefined'
|
408 | |
409 |
|
410 |
|
411 | ? typeof question.default === 'function'
|
412 | ? await question.default(answers)
|
413 | : await question.default
|
414 | : question.choices && question.choices.length
|
415 | |
416 |
|
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 |
|
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 |
|
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 |
|
452 |
|
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 |
|
466 | when: (answers) => !answers.projectRootCorrect
|
467 | }]
|
468 | : []),
|
469 | ...QUESTIONNAIRE
|
470 | ];
|
471 | return inquirer.prompt(questions);
|
472 | }
|
473 |
|
474 |
|
475 |
|
476 |
|
477 |
|
478 |
|
479 | function generatePathfromAnswer(answers, projectRootDir) {
|
480 | return path.resolve(projectRootDir, path.dirname(answers) === '.' ? path.resolve(answers) : path.dirname(answers));
|
481 | }
|
482 | export 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 | }
|
507 | export 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 |
|
520 |
|
521 |
|
522 |
|
523 |
|
524 | export function specifyVersionIfNeeded(packagesToInstall, version, npmTag) {
|
525 | const { value } = version.matchAll(VERSION_REGEXP).next();
|
526 | const [major, minor, patch, tagName, build] = (value || []).slice(1, -1);
|
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 |
|
540 |
|
541 |
|
542 |
|
543 | export 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 | }
|
560 | export 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 |
|
576 |
|
577 | export async function createPackageJSON(parsedAnswers) {
|
578 | const packageJsonExists = await fs.access(path.resolve(process.cwd(), 'package.json')).then(() => true, () => false);
|
579 |
|
580 | if (packageJsonExists) {
|
581 | return;
|
582 | }
|
583 |
|
584 | if (parsedAnswers.createPackageJSON === false) {
|
585 |
|
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 |
|
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 |
|
609 |
|
610 | const SEP = '\n- ';
|
611 | export async function npmInstall(parsedAnswers, npmTag) {
|
612 | const servicePackages = parsedAnswers.rawAnswers.services.map((service) => convertPackageHashToObject(service));
|
613 | const presetPackage = convertPackageHashToObject(parsedAnswers.rawAnswers.preset || '');
|
614 | |
615 |
|
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 |
|
622 |
|
623 | if (presetPackage.short === 'solid') {
|
624 | parsedAnswers.packagesToInstall.push('solid-js');
|
625 | }
|
626 | |
627 |
|
628 |
|
629 | if (parsedAnswers.includeVisualTesting) {
|
630 | parsedAnswers.packagesToInstall.push('@wdio/visual-service');
|
631 | }
|
632 | |
633 |
|
634 |
|
635 | const preset = getPreset(parsedAnswers);
|
636 | if (preset === 'lit') {
|
637 | parsedAnswers.packagesToInstall.push('lit');
|
638 | }
|
639 | |
640 |
|
641 |
|
642 | if (preset === 'stencil') {
|
643 | parsedAnswers.packagesToInstall.push('@stencil/core');
|
644 | }
|
645 | |
646 |
|
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 |
|
656 |
|
657 | if (parsedAnswers.framework === 'jasmine' && parsedAnswers.isUsingTypeScript) {
|
658 | parsedAnswers.packagesToInstall.push('@types/jasmine');
|
659 | }
|
660 | |
661 |
|
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 |
|
674 |
|
675 | addServiceDeps(servicePackages, parsedAnswers.packagesToInstall);
|
676 | |
677 |
|
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 |
|
696 |
|
697 | export function detectPackageManager(argv = process.argv) {
|
698 | return PMs.find((pm) => (
|
699 |
|
700 |
|
701 |
|
702 |
|
703 | argv[1].includes(`${path.sep}${pm}${path.sep}`) ||
|
704 | argv[1].includes(`${path.sep}.${pm}${path.sep}`))) || 'npm';
|
705 | }
|
706 |
|
707 |
|
708 |
|
709 | export async function setupTypeScript(parsedAnswers) {
|
710 | |
711 |
|
712 |
|
713 | if (!parsedAnswers.isUsingTypeScript) {
|
714 | return;
|
715 | }
|
716 | |
717 |
|
718 |
|
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 |
|
740 |
|
741 |
|
742 | service.startsWith('@wdio') ||
|
743 | |
744 |
|
745 |
|
746 |
|
747 | COMMUNITY_PACKAGES_WITH_TS_SUPPORT.includes(service)))
|
748 | ];
|
749 | const preset = getPreset(parsedAnswers);
|
750 | const config = {
|
751 | compilerOptions: {
|
752 |
|
753 | moduleResolution: 'node',
|
754 | module: !parsedAnswers.esmSupport ? 'commonjs' : 'ESNext',
|
755 | target: 'es2022',
|
756 | lib: ['es2022', 'dom'],
|
757 | types,
|
758 | skipLibCheck: true,
|
759 |
|
760 | noEmit: true,
|
761 | allowImportingTsExtensions: true,
|
762 | resolveJsonModule: true,
|
763 | isolatedModules: true,
|
764 |
|
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 | }
|
807 | function getPreset(parsedAnswers) {
|
808 | const isUsingFramework = typeof parsedAnswers.preset === 'string';
|
809 | return isUsingFramework ? (parsedAnswers.preset || 'lit') : '';
|
810 | }
|
811 |
|
812 |
|
813 |
|
814 | export 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 |
|
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 | }
|
850 | export 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 |
|
872 |
|
873 |
|
874 |
|
875 |
|
876 | export 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 | }
|
885 | export 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 | }
|
915 | export 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 | }
|