import cp, { type ExecException } from 'node:child_process' import url from 'node:url' import path from 'node:path' import chalk from 'chalk' import { rimraf } from 'rimraf' import { copy } from 'esbuild-plugin-copy' import type { Plugin, BuildOptions } from 'esbuild' import type { PackageJson } from 'type-fest' const MAX_RETRIES = 3 const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) const tscPath = path.resolve(path.dirname(url.fileURLToPath(import.meta.resolve('typescript'))), '..', 'bin', 'tsc') const l = { format: (format: string = 'unknown') => chalk.yellowBright(format), file: (file: string = 'unknown') => chalk.blueBright(file), name: (name: string = 'unknown') => chalk.hex('#EA5907')(`[${name}]`) } /** * little logger plugin for Esbuild * @param {BuildOptions} options Esbuild options * @param {PackageJson} pkg package.json of given project as object * @returns */ export function log(options: BuildOptions, pkg: PackageJson): Plugin { const srcFile = path.relative(options.absWorkingDir || process.cwd(), (options.entryPoints as string[])[0]) const outFile = path.relative(options.absWorkingDir || process.cwd(), options.outfile || options.outdir as string) return { name: 'LogPlugin', setup(build) { build.onStart(() => { console.log(`${l.name(pkg.name)} 🏗️ Building ${l.format(`${options.platform}:${options.format}`)} package: ${l.file(srcFile)} → ${l.file(outFile)}`) }) build.onEnd((result) => { if (result.errors.length === 0) { console.log(`${l.name(pkg.name)} ✅ Success building ${l.format(`${options.platform}:${options.format}`)} package: ${l.file(srcFile)} → ${l.file(outFile)}`) } }) } } } /** * clear previously generated build files * @param {string} outfile directory that Esbuild usually compiles to * @returns an Esbuild plugin */ export function clear(config: BuildOptions): Plugin { const rimrafOptions = { maxRetries: 1 } const outfile = config.outfile || config.outdir if (!outfile) { throw new Error('No outfile defined') } return { name: 'ClearPlugin', async setup() { await Promise.all([ rimraf(path.dirname(outfile), rimrafOptions), ]) } } } /** * Generates type definition files (d.ts) for a given package. * * @param {string} cwd - The current working directory. * @param {PackageJson} pkg - The package JSON object. * @param {number} [retry=MAX_RETRIES] - The number of retries. * @return {Promise} A promise that resolves when the types are generated. */ function generateTypes(cwd: string, pkg: PackageJson, retry = MAX_RETRIES): Promise { return new Promise((resolve, reject) => { const child = cp.spawn('node', [tscPath, '--emitDeclarationOnly'], { cwd }) let stdout = '' let stderr = '' child.stdout?.on('data', (data) => { stdout += data }) child.stderr?.on('data', (data) => { stderr += data }) child.on('close', (code) => { if (code === 0) { resolve(undefined) } else { const error = new Error(`TypeScript compilation failed with code ${code}`) // @ts-ignore error.stdout = stdout // @ts-ignore error.stderr = stderr reject(error) } }) child.on('error', (error) => { reject(error) }) }).catch((err) => { if (retry > 0) { console.log(`${l.name(pkg.name)} ↻ Retrying (${MAX_RETRIES - (retry - 1)}/${MAX_RETRIES}) building types for ${pkg.name}`) return generateTypes(cwd, pkg, retry - 1) } const error: ExecException = err instanceof Error ? err : new Error(`unknown error: ${err}`) throw new Error(`[${pkg.name}] Failed to generate d.ts files: ${error.message}\n${error.stdout}`) }) } /** * generate type definition files (d.ts) for working dir * @param {string} absWorkingDir absolute path to the working directory * @returns an Esbuild plugin */ export function generateDts(absWorkingDir: string, pkg: PackageJson): Plugin { return { name: 'TypeScriptDeclarationsPlugin', setup(build) { build.onEnd(async (result) => { /** * don't build types if we failed already somewhere else */ if (result.errors.length > 0) { return } await generateTypes(absWorkingDir, pkg) }) } } } /** * Plugin to copy EJS templates from specified package to build directory * @param {string} pkgName Name of the package to copy * @returns {Plugin} an Esbuild plugin */ export function copyEJSTemplates(pkgName: string): Plugin { const cliPackage = path.resolve(__dirname, '..', '..', '..', 'packages', pkgName) return copy({ resolveFrom: cliPackage, assets: [{ from: [path.resolve(cliPackage, 'src', 'templates', '**', '*.ejs')], to: path.resolve(cliPackage, 'build', 'templates') }, { from: [path.resolve(cliPackage, 'src', 'templates', '**', '*.feature')], to: path.resolve(cliPackage, 'build', 'templates') }] }) } /** * Plugin to mark all WebdriverIO scripts in `packages/webdriverio/src/scripts` as external */ export function externalScripts(): Plugin { const name = 'ExternalScripts' return { name, setup(build) { build.onResolve({ filter: /\/scripts\// }, (args) => ({ path: `./scripts/${path.basename(args.path)}`, external: true, pluginName: name })) } } } export function runBuildScript(absWorkingDir: string, pkg: PackageJson): Plugin { return { name: `build-${pkg.name || 'unknown'}`, setup(build) { build.onEnd(async () => { const child = cp.spawn('pnpm', ['run', 'build'], { cwd: absWorkingDir, stdio: 'inherit' } ) child.on('exit', (code) => { if (code !== 0) { console.log(`${l.name(pkg.name)} ❌ Failed run build script for ${pkg.name}`) return } console.log(`${l.name(pkg.name)} ✅ Successfully ran build script for ${pkg.name}: "${pkg.scripts?.build}"`) }) }) } } }