/// import fs from 'node:fs/promises' import url from 'node:url' import path from 'node:path' import logger from '@wdio/logger' import LocalRunner from '@wdio/local-runner' import libCoverage, { type CoverageMap } from 'istanbul-lib-coverage' import libReport from 'istanbul-lib-report' import reports from 'istanbul-reports' import type { RunArgs, WorkerInstance } from '@wdio/local-runner' import type { MaybeMocked, MaybeMockedDeep, MaybePartiallyMocked, MaybePartiallyMockedDeep } from '@vitest/spy' import type { InlineConfig } from 'vite' import { ViteServer } from './vite/server.js' import { FRAMEWORK_SUPPORT_ERROR, DEFAULT_COVERAGE_REPORTS, SUMMARY_REPORTER, DEFAULT_REPORTS_DIRECTORY } from './constants.js' import { DEFAULT_HOST } from './vite/constants.js' import updateViteConfig from './vite/frameworks/index.js' import { ServerWorkerCommunicator } from './communicator.js' import { makeHeadless, getCoverageByFactor, adjustWindowInWatchMode } from './utils.js' import type { BrowserRunnerOptions as BrowserRunnerOptionsImport, CoverageOptions, MockFactoryWithHelper } from './types.js' const log = logger('@wdio/browser-runner') export default class BrowserRunner extends LocalRunner { #options: BrowserRunnerOptionsImport #config: WebdriverIO.Config #servers: Set = new Set() #coverageOptions: CoverageOptions #reportsDirectory: string #viteOptimizations: InlineConfig = {} #communicator: ServerWorkerCommunicator constructor( private options: BrowserRunnerOptionsImport, protected _config: WebdriverIO.Config, ) { super(options as never, _config) if (_config.framework !== 'mocha') { throw new Error(FRAMEWORK_SUPPORT_ERROR) } this.#options = options this.#config = _config this.#communicator = new ServerWorkerCommunicator(this.#config) this.#coverageOptions = options.coverage || {} this.#reportsDirectory = this.#coverageOptions.reportsDirectory || path.join(this.#config.rootDir!, DEFAULT_REPORTS_DIRECTORY) /** * transform mochaOpts require params so we can use it in the browser */ if (this.#config.mochaOpts) { this.#config.mochaOpts.require = (this.#config.mochaOpts.require || []) .map((r: 'string') => path.join(this.#config.rootDir || process.cwd(), r)) .map((r: 'string') => url.pathToFileURL(r).pathname) } } /** * for testing purposes */ private _servers = this.#servers /** * nothing to initialize when running locally */ async initialize() { log.info('Initiate browser environment') if (typeof this.#coverageOptions.clean === 'undefined' || this.#coverageOptions.clean) { const reportsDirectoryExist = await fs.access(this.#reportsDirectory) .then(() => true, () => false) if (reportsDirectoryExist) { await fs.rm(this.#reportsDirectory, { recursive: true }) } } /** * make adjustments based on detected frontend frameworks */ try { this.#viteOptimizations = await updateViteConfig(this.#options, this.#config) } catch (err) { log.error(`Failed to optimize Vite config: ${(err as Error).stack}`) } await super.initialize() } async run (runArgs: RunArgs): Promise { runArgs.caps = makeHeadless(this.options, runArgs.caps) runArgs.caps = adjustWindowInWatchMode(this.#config, runArgs.caps) const server = new ViteServer(this.#options, this.#config, this.#viteOptimizations) this.#servers.add(server) try { const port = await server.start() const host = this.#options.host || DEFAULT_HOST runArgs.args.baseUrl = `${host}:${port}` /** * enable Geckodriver to accept Bidi messages from the browser */ if (runArgs.caps.browserName === 'firefox') { runArgs.caps['wdio:geckodriverOptions'] = { allowOrigins: [`http://localhost:${port}`] } } } catch (err) { throw new Error(`Vite server failed to start: ${(err as Error).stack}`) } if (!runArgs.args.baseUrl && runArgs.command === 'run') { runArgs.args.baseUrl = this._config.baseUrl } const worker = await super.run(runArgs) this.#communicator.register(server, worker) return worker } /** * shutdown vite server * * @return {Promise} resolves when vite server has been shutdown */ async shutdown() { await super.shutdown() await Promise.allSettled(Array.from(this.#servers).map((server) => server.close())) return this._generateCoverageReports() } private async _generateCoverageReports () { const coverageMaps = this.#communicator.coverageMaps /** * if no coverage was collected we don't need to do anything * and return `true` which will mark the test as successful */ if (!this.#coverageOptions.enabled || coverageMaps.length === 0) { return true } const firstCoverageMapEntry = coverageMaps.shift() as CoverageMap const coverageMap = libCoverage.createCoverageMap(firstCoverageMapEntry) coverageMaps.forEach((cm) => coverageMap.merge(cm)) const coverageIssues: string[] = [] try { const context = libReport.createContext({ dir: this.#reportsDirectory, defaultSummarizer: 'nested', coverageMap }) const reporter = this.#coverageOptions.reporter ? Array.isArray(this.#coverageOptions.reporter) ? this.#coverageOptions.reporter : [this.#coverageOptions.reporter] : DEFAULT_COVERAGE_REPORTS /** * ensure summary reporter is set as we need it for treshold comparison */ if (!reporter.includes(SUMMARY_REPORTER)) { reporter.push(SUMMARY_REPORTER) } const reportBases = reporter.map((r) => reports.create(r, { projectRoot: this.#config.rootDir, subdir: 'html' })) reportBases.map((reportBase) => reportBase.execute(context)) log.info(`Successfully created coverage reports for ${reporter.join(', ')}`) const summaryFilePath = path.join(this.#reportsDirectory, 'coverage-summary.json') const summary = JSON.parse((await fs.readFile(summaryFilePath)).toString()) coverageIssues.push( ...this.#coverageOptions.perFile ? Object.entries(summary) .filter(([source]) => source !== 'total') // eslint-disable-next-line @typescript-eslint/no-explicit-any .map(([source, summary]: any) => ( getCoverageByFactor(this.#coverageOptions, summary, source.replace(this.#config.rootDir, ''))) ) .flat() : getCoverageByFactor(this.#coverageOptions, summary.total) ) } catch (err) { console.error(`Failed to generate code coverage report: ${(err as Error).message}`) return false } if (coverageIssues.length) { console.log(coverageIssues.join('\n')) return false } return true } } declare global { namespace WebdriverIO { interface BrowserRunnerOptions extends BrowserRunnerOptionsImport {} } } /** * re-export mock types */ export * from '@vitest/spy' /** * The following exports are meaningless and only there to allow proper type support. * The actual implementation can be found in /src/browser.spy.ts */ /** * Makes all imports to passed module to be mocked. * * If there is a factory, will return it's result. The call to `mock` is hoisted to the top of the file, * so you don't have access to variables declared in the global file scope, if you didn't put them before imports! * * If __mocks__ folder with file of the same name exist, all imports will return it. * * If there is no __mocks__ folder or a file with the same name inside, will call original module and mock it. * * @param {string} path Path to the module. * @param {MockFactoryWithHelper} factory (optional) Factory for the mocked module. Has the highest priority. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars export function mock (path: string, factory?: MockFactoryWithHelper) {} /** * Removes module from mocked registry. All subsequent calls to import will return original module even if it was mocked. * * @param path Path to the module. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars export function unmock(moduleName: string) {} /** * Type helpers for TypeScript. In reality just returns the object that was passed. * @example * import example from './example' * vi.mock('./example') * * test('1+1 equals 2' async () => { * vi.mocked(example.calc).mockRestore() * * const res = example.calc(1, '+', 1) * * expect(res).toBe(2) * }) * * @param item Anything that can be mocked * @returns */ export function mocked(item: T, deep?: true): MaybeMockedDeep export function mocked(item: T, deep?: false): MaybeMockedDeep export function mocked(item: T, options: { partial?: false; deep?: false; }): MaybeMocked export function mocked(item: T, options: { partial?: false; deep: true; }): MaybeMockedDeep export function mocked(item: T, options: { partial: true; deep?: false; }): MaybePartiallyMocked export function mocked(item: T, options: { partial: true; deep: true; }): MaybePartiallyMockedDeep export function mocked (_item: unknown, _options: unknown) {}