🏡 index : quine-global/webdriverio.git

import fs from 'node:fs/promises'
import fss from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import { parseArgs } from 'node:util'
import { build, context, type BuildOptions, type Plugin } from 'esbuild'
import type { PackageJson } from 'type-fest'

import { getExternal } from './utils.js'
import { log, clear, generateDts, copyEJSTemplates, externalScripts, runBuildScript } from './plugins.js'
import { generateTypes } from './type-generation/index.js'

const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..', '..', '..')
const args = process.argv.slice(2)
const options = {
    project: {
        type: 'string',
        short: 'p',
        multiple: true,
    },
    watch: {
        type: 'boolean',
    },
    clear: {
        type: 'boolean'
    }
} as const

const { values } = parseArgs({ args, options })

/**
 * parse through all packages and filter based on command parameters
 */
const projects = (await fs.readdir(
    path.resolve(rootDir, 'packages')
    // ignore potential stale/empty package directories left over when switching between branches
)).filter((dir) => dir !== 'node_modules' && fss.existsSync(path.resolve(rootDir, 'packages', dir, 'package.json')))

const packages = (
    await Promise.all(
        projects.map(async (project) => {
            const pkg = await import(
                url.pathToFileURL(
                    path.resolve(rootDir, 'packages', project, 'package.json')
                ).href,
                { with: { type: 'json' } }
            )
            return [project, pkg.default]
        })
    ) as [string, PackageJson][]
).filter(([packageDir, pkg]) => (
    !values.project ||
    (pkg.name && values.project.includes(pkg.name)) ||
    values.project.includes(packageDir)
))

/**
 * plugins for the ESM build
 */
const esmPlugins: Record<string, Plugin[]> = {
    '@wdio/protocols': [generateTypes()],
    'create-wdio': [copyEJSTemplates('create-wdio')],
    'webdriverio': [externalScripts()]
}
/**
 * plugins for the cjs build
 */
const cjsPlugins: Record<string, Plugin[]> = {}
/**
 * plugins for the browser build
 */
const browserPlugins: Record<string, Plugin[]> = {}

const WDIO_RESQ_SCRIPT = JSON.stringify(await fs.readFile(path.resolve(rootDir, 'packages', 'webdriverio', 'node_modules', 'resq', 'dist', 'index.js'), 'utf-8'))
const WDIO_FAKER_SCRIPT = JSON.stringify(await fs.readFile(path.resolve(rootDir, 'packages', 'webdriverio', 'third_party', 'fake-timers.js'), 'utf-8'))

/**
 * compose Esbuild configs
 */
const configs = packages.map(([packageDir, pkg]) => {
    const packageBuilds: BuildOptions[] = []
    const exports = (pkg.exports || {}) as PackageJson.ExportConditions

    if (!pkg.name) {
        throw new Error(`Package in ${packageDir} does not have a "name" field, which is required to build the package`)
    }

    /**
     * in cases where we don't have an exports field but a main field
     * we assume that the main field is the main entry point of the package
     */
    if (!pkg.exports && pkg.main) {
        exports['.'] = {
            require: pkg.main
        }
    }

    const exportedModules = Object.entries(exports).filter?.(([, exp]) => typeof exp === 'object' && !Array.isArray(exp)) as [string, PackageJson.ExportConditions][]
    for (const [target, exp] of exportedModules) {
        const absWorkingDir = path.resolve(rootDir, 'packages', packageDir)
        const source = (exp.source as string | undefined) || './src/index.ts'
        const baseConfig: BuildOptions = {
            sourceRoot: absWorkingDir,
            tsconfig: path.resolve(absWorkingDir, 'tsconfig.json'),
            sourcemap: process.env.NODE_ENV !== 'production' ? 'inline' : false,
            external: getExternal(pkg),
            target: 'node18',
            bundle: true,
            absWorkingDir,
            define: {
                WDIO_RESQ_SCRIPT,
                WDIO_FAKER_SCRIPT
            }
        }

        if (typeof exp.import === 'string') {
            const esmSource = (exp.importSource as string | undefined) || source
            const esmBuild: BuildOptions = {
                ...baseConfig,
                entryPoints: [path.resolve(absWorkingDir, esmSource)],
                platform: 'node',
                format: 'esm',
                plugins: []
            }

            if (exp.import.endsWith('*')) {
                esmBuild.outdir = path.resolve(absWorkingDir, path.dirname(exp.import))
            } else {
                esmBuild.outfile = path.resolve(absWorkingDir, exp.import)
            }

            esmBuild.plugins?.push(
                log(esmBuild, pkg),
                ...(esmPlugins[pkg.name] || [])
            )

            /**
             * if types field is a string, generate TypeScript declarations
             */
            if (typeof exp.types === 'string' && target === '.') {
                esmBuild.plugins?.push(generateDts(absWorkingDir, pkg))

                if (pkg.scripts?.build) {
                    esmBuild.plugins?.push(runBuildScript(absWorkingDir, pkg))
                }
            }

            if (values.clear) {
                esmBuild.plugins?.push(clear(esmBuild))
            }

            packageBuilds.push(esmBuild)
        }

        if (typeof exp.require === 'string') {
            const requireSource = (exp.requireSource as string | undefined) || source
            const cjsBuild: BuildOptions = {
                ...baseConfig,
                external: getExternal(pkg),
                entryPoints: [path.resolve(absWorkingDir, requireSource)],
                platform: 'node',
                format: 'cjs',
                plugins: [],
                banner: {
                    'js': 'const __importMetaUrl = require(\'url\').pathToFileURL(__filename).href;'
                },
                define: {
                    'import.meta.url': '__importMetaUrl',
                    'import.meta.resolve': 'require.resolve'
                }
            }

            if (exp.require.endsWith('*')) {
                cjsBuild.outdir = path.resolve(absWorkingDir, path.dirname(exp.require))
            } else {
                cjsBuild.outfile = path.resolve(absWorkingDir, exp.require)
            }

            /**
             * if we define an `index.cts` as require source it is suppose to be a small wrapper
             * around the main source file that exports the main source file within the CJS context,
             * hence we need to mark this export as external
             */
            if (requireSource.endsWith('.cts')) {
                cjsBuild.external?.push('./index.js')
            }

            cjsBuild.plugins?.push(
                log(cjsBuild, pkg),
                ...(cjsPlugins[pkg.name] || [])
            )
            packageBuilds.push(cjsBuild)
        }

        if (typeof exp.browser === 'string') {
            const browserSource = (exp.browserSource as string | undefined) || source
            const browserBuild: BuildOptions = {
                ...baseConfig,
                entryPoints: [path.resolve(absWorkingDir, browserSource)],
                platform: 'browser',
                format: 'esm',
                target: ['es2021', 'chrome90', 'edge90', 'firefox90', 'safari12'],
                plugins: [],
                supported: {
                    'top-level-await': true //browsers can handle top-level-await features
                }
            }

            if (exp.browser.endsWith('*')) {
                browserBuild.outdir = path.resolve(absWorkingDir, path.dirname(exp.browser))
            } else {
                browserBuild.outfile = path.resolve(absWorkingDir, exp.browser)
            }

            browserBuild.plugins?.push(
                log(browserBuild, pkg),
                ...(browserPlugins[pkg.name] || [])
            )

            packageBuilds.push(browserBuild)
        }
    }
    return packageBuilds
}).flat()

if (configs.length === 0) {
    throw new Error(`No packages found to build using params: ${JSON.stringify(values)}`)
}

/**
 * build packages
 */
await Promise.all(configs.map(async (config) => {
    if (values.watch) {
        const ctx = await context(config)
        return ctx.watch()
    }

    const result = await build(config)
    if (result.errors.length > 0) {
        console.error(result.errors)
        throw new Error('Failed to build packages')
    }
}))