UNPKG

@wdio/browser-runner

Version:
1,402 lines (1,376 loc) 53.5 kB
// src/index.ts import fs4 from "node:fs/promises"; import url7 from "node:url"; import path9 from "node:path"; import logger9 from "@wdio/logger"; import LocalRunner from "@wdio/local-runner"; import libCoverage2 from "istanbul-lib-coverage"; import libReport from "istanbul-lib-report"; import reports from "istanbul-reports"; // src/vite/server.ts import path5 from "node:path"; import { EventEmitter } from "node:events"; import getPort from "get-port"; import logger5 from "@wdio/logger"; import istanbulPlugin from "vite-plugin-istanbul"; import { deepmerge as deepmerge2 } from "deepmerge-ts"; import { createServer } from "vite"; // src/vite/plugins/testrunner.ts import url2 from "node:url"; import path2 from "node:path"; import { builtinModules } from "node:module"; import logger2 from "@wdio/logger"; import { polyfillPath } from "modern-node-polyfills"; import { deepmerge } from "deepmerge-ts"; import { WebDriverProtocol, MJsonWProtocol, AppiumProtocol, ChromiumProtocol, SauceLabsProtocol, SeleniumProtocol, GeckoProtocol } from "@wdio/protocols"; // src/constants.ts var SESSIONS = /* @__PURE__ */ new Map(); var WDIO_EVENT_NAME = "wdio:workerMessage"; var FRAMEWORK_SUPPORT_ERROR = 'Currently only "mocha" is supported as framework when using @wdio/browser-runner.'; var DEFAULT_INCLUDE = ["**"]; var DEFAULT_FILE_EXTENSIONS = [".js", ".cjs", ".mjs", ".ts", ".mts", ".cts", ".tsx", ".jsx", ".vue", ".svelte"]; var DEFAULT_REPORTS_DIRECTORY = "coverage"; var DEFAULT_AUTOMOCK = true; var DEFAULT_MOCK_DIRECTORY = "__mocks__"; var SUMMARY_REPORTER = "json-summary"; var COVERAGE_FACTORS = ["lines", "functions", "branches", "statements"]; var DEFAULT_COVERAGE_REPORTS = ["text", "html", "clover", SUMMARY_REPORTER]; var GLOBAL_TRESHOLD_REPORTING = "ERROR: Coverage for %s (%s%) does not meet global threshold (%s%)"; var FILE_TRESHOLD_REPORTING = "ERROR: Coverage for %s (%s%) does not meet threshold (%s%) for %s"; var MOCHA_VARIABELS = ( /*css*/ `:root { --mocha-color: #000; --mocha-bg-color: #fff; --mocha-pass-icon-color: #00d6b2; --mocha-pass-color: #fff; --mocha-pass-shadow-color: rgba(0, 0, 0, .2); --mocha-pass-mediump-color: #c09853; --mocha-pass-slow-color: #b94a48; --mocha-test-pending-color: #0b97c4; --mocha-test-pending-icon-color: #0b97c4; --mocha-test-fail-color: #c00; --mocha-test-fail-icon-color: #c00; --mocha-test-fail-pre-color: #000; --mocha-test-fail-pre-error-color: #c00; --mocha-test-html-error-color: #000; --mocha-box-shadow-color: #eee; --mocha-box-bottom-color: #ddd; --mocha-test-replay-color: #000; --mocha-test-replay-bg-color: #eee; --mocha-stats-color: #888; --mocha-stats-em-color: #000; --mocha-stats-hover-color: #eee; --mocha-error-color: #c00; --mocha-code-comment: #ddd; --mocha-code-init: #2f6fad; --mocha-code-string: #5890ad; --mocha-code-keyword: #8a6343; --mocha-code-number: #2f6fad; } @media (prefers-color-scheme: dark) { :root { --mocha-color: #fff; --mocha-bg-color: #222; --mocha-pass-icon-color: #00d6b2; --mocha-pass-color: #222; --mocha-pass-shadow-color: rgba(255, 255, 255, .2); --mocha-pass-mediump-color: #f1be67; --mocha-pass-slow-color: #f49896; --mocha-test-pending-color: #0b97c4; --mocha-test-pending-icon-color: #0b97c4; --mocha-test-fail-color: #f44; --mocha-test-fail-icon-color: #f44; --mocha-test-fail-pre-color: #fff; --mocha-test-fail-pre-error-color: #f44; --mocha-test-html-error-color: #fff; --mocha-box-shadow-color: #444; --mocha-box-bottom-color: #555; --mocha-test-replay-color: #fff; --mocha-test-replay-bg-color: #444; --mocha-stats-color: #aaa; --mocha-stats-em-color: #fff; --mocha-stats-hover-color: #444; --mocha-error-color: #f44; --mocha-code-comment: #ddd; --mocha-code-init: #9cc7f1; --mocha-code-string: #80d4ff; --mocha-code-keyword: #e3a470; --mocha-code-number: #4ca7ff; } } ` ); // src/vite/utils.ts import fs from "node:fs/promises"; import url from "node:url"; import path from "node:path"; import logger from "@wdio/logger"; import { resolve } from "import-meta-resolve"; var log = logger("@wdio/browser-runner"); var __dirname = path.dirname(url.fileURLToPath(import.meta.url)); async function getTemplate(options, env, spec, p = process) { const root = options.rootDir || process.cwd(); const isHeadless = options.headless || Boolean(process.env.CI); const alias = options.viteConfig?.resolve?.alias || {}; const usesTailwindCSS = await hasFileByExtensions(path.join(root, "tailwind.config")); if ("runner" in env.config) { delete env.config.runner; } let sourceMapScript = ""; let sourceMapSetupCommand = ""; try { const sourceMapSupportDir = await resolve("source-map-support", import.meta.url); sourceMapScript = /*html*/ `<script src="/@fs/${url.fileURLToPath(path.dirname(sourceMapSupportDir))}/browser-source-map-support.js"></script>`; sourceMapSetupCommand = "sourceMapSupport.install()"; } catch (err) { log.error(`Failed to setup source-map-support: ${err.message}`); } return ( /* html */ ` <!doctype html> <html> <head> <title>WebdriverIO Browser Test</title> <link rel="icon" type="image/x-icon" href="https://webdriver.io/img/favicon.png"> ${usesTailwindCSS ? ( /*html*/ '<link rel="stylesheet" href="/node_modules/tailwindcss/tailwind.css">' ) : ""} <script type="module"> const alias = ${JSON.stringify(alias)} window.__wdioMockCache__ = new Map() window.WDIO_EVENT_NAME = '${WDIO_EVENT_NAME}' window.wdioImport = function (modName, mod) { /** * attempt to resolve direct import */ if (window.__wdioMockCache__.get(modName)) { return window.__wdioMockCache__.get(modName) } /** * if above fails, check if we have an alias for it */ for (const [aliasName, aliasPath] of Object.entries(alias)) { if (modName.slice(0, aliasName.length) === aliasName) { modName = modName.replace(aliasName, aliasPath) } } if (window.__wdioMockCache__.get(modName)) { return window.__wdioMockCache__.get(modName) } return mod } </script> <link rel="stylesheet" href="@wdio/browser-runner/third_party/mocha.css"> <script type="module" src="@wdio/browser-runner/third_party/mocha.js"></script> ${sourceMapScript} <script type="module"> ${sourceMapSetupCommand} /** * Inject environment variables */ window.__wdioEnv__ = ${JSON.stringify(env)} window.__wdioSpec__ = '${spec}' window.__wdioEvents__ = [] /** * listen to window errors during bootstrap phase */ window.__wdioErrors__ = [] addEventListener('error', (ev) => window.__wdioErrors__.push({ filename: ev.filename, message: ev.message, error: ev.error.stack })) /** * mock process */ window.process = window.process || { platform: 'browser', env: ${JSON.stringify(p.env)}, stdout: {}, stderr: {}, cwd: () => ${JSON.stringify(p.cwd())}, } </script> <script type="module" src="@wdio/browser-runner/setup"></script> <style> ${MOCHA_VARIABELS} body { width: calc(100% - 500px); padding: 0; margin: 0; } </style> </head> <body> <mocha-framework spec="${spec}" ${isHeadless ? 'style="display: none"' : ""}></mocha-framework> </body> </html>` ); } async function userfriendlyImport(preset, pkg) { if (!pkg) { return {}; } try { return await import(pkg); } catch { throw new Error( `Couldn't load preset "${preset}" given important dependency ("${pkg}") is not installed. Please run: npm install ${pkg} or yarn add --dev ${pkg}` ); } } function normalizeId(id, base) { if (base && id.startsWith(base)) { id = `/${id.slice(base.length)}`; } return id.replace(/^\/@id\/__x00__/, "\0").replace(/^\/@id\//, "").replace(/^__vite-browser-external:/, "").replace(/^node:/, "").replace(/[?&]v=\w+/, "?").replace(/\?$/, ""); } async function getFilesFromDirectory(dir) { const isExisting = await fs.access(dir).then(() => true, () => false); if (!isExisting) { return []; } let files = await fs.readdir(dir); files = (await Promise.all(files.map(async (file) => { const filePath = path.join(dir, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { return getFilesFromDirectory(filePath); } else if (stats.isFile()) { return filePath; } }))).filter(Boolean); return files.reduce((all, folderContents) => all.concat(folderContents), []); } var mockedModulesList; async function getManualMocks(automockDir) { if (!mockedModulesList) { mockedModulesList = (await getFilesFromDirectory(automockDir)).map((filePath) => [ filePath, filePath.slice(automockDir.length + 1).slice(0, -path.extname(filePath).length) ]); } return mockedModulesList; } var EXTENSION = [".js", ".ts", ".mjs", ".cjs", ".mts"]; async function hasFileByExtensions(p, extensions = EXTENSION) { return (await Promise.all([ fs.access(p).then(() => p, () => void 0), ...extensions.map((ext) => fs.access(p + ext).then(() => p + ext, () => void 0)) ])).filter(Boolean)[0]; } function hasDir(p) { return fs.stat(p).then((s) => s.isDirectory(), () => false); } function getErrorTemplate(filename, error) { return ( /*html*/ ` <pre>${error.stack}</pre> <script type="module"> window.__wdioErrors__ = [{ filename: "${filename}", message: \`${error.message}\` }] </script> ` ); } // src/vite/plugins/testrunner.ts var log2 = logger2("@wdio/browser-runner:plugin"); var __dirname2 = url2.fileURLToPath(new URL(".", import.meta.url)); var commands = deepmerge( WebDriverProtocol, MJsonWProtocol, AppiumProtocol, ChromiumProtocol, SauceLabsProtocol, SeleniumProtocol, GeckoProtocol ); var protocolCommandList = Object.values(commands).map( (endpoint) => Object.values(endpoint).map( ({ command }) => command ) ).flat(); var virtualModuleId = "virtual:wdio"; var resolvedVirtualModuleId = "\0" + virtualModuleId; var MODULES_TO_MOCK = [ "import-meta-resolve", "puppeteer-core", "archiver", "glob", "ws", "decamelize", "geckodriver", "safaridriver", "edgedriver", "@puppeteer/browsers", "locate-app", "wait-port", "lodash.isequal", "@wdio/repl", "jszip" ]; var POLYFILLS = [ ...builtinModules, ...builtinModules.map((m) => `node:${m}`) ]; function testrunner(options) { const browserModules = path2.resolve(__dirname2, "browser"); const automationProtocolPath = `/@fs${url2.pathToFileURL(path2.resolve(browserModules, "driver.js")).pathname}`; const mockModulePath = path2.resolve(browserModules, "mock.js"); const setupModulePath = path2.resolve(browserModules, "setup.js"); const spyModulePath = path2.resolve(browserModules, "spy.js"); const wdioExpectModulePath = path2.resolve(browserModules, "expect.js"); return [{ name: "wdio:testrunner", enforce: "pre", resolveId: async (id) => { if (id === virtualModuleId) { return resolvedVirtualModuleId; } if (POLYFILLS.includes(id)) { return polyfillPath(normalizeId(id.replace("/promises", ""))); } if (id === "@wdio/browser-runner") { return spyModulePath; } if (id.endsWith("@wdio/browser-runner/setup")) { return setupModulePath; } if (id === "expect-webdriverio") { return wdioExpectModulePath; } if (MODULES_TO_MOCK.includes(id)) { return mockModulePath; } if (id.startsWith("/@wdio/browser-runner/third_party/")) { return path2.resolve(__dirname2, ...id.split("/").slice(3)); } }, load(id) { if (id === resolvedVirtualModuleId) { return ( /*js*/ ` import { fn } from '@wdio/browser-runner' export const commands = ${JSON.stringify(protocolCommandList)} export const automationProtocolPath = ${JSON.stringify(automationProtocolPath)} export const wrappedFn = (...args) => fn()(...args) ` ); } }, transform(code, id) { if (id.includes(".vite/deps/expect.js")) { return { code: code.replace( "var fs = _interopRequireWildcard(require_graceful_fs());", "var fs = {};" ).replace( "var expect_default = require_build11();", "var expect_default = require_build11();\nwindow.expect = expect_default.default;" ).replace( "process.stdout.isTTY", "false" ) }; } return { code }; }, configureServer(server) { return () => { server.middlewares.use(async (req, res, next) => { log2.info(`Received request for: ${req.originalUrl}`); if (!req.originalUrl || req.url?.endsWith(".map") || req.url?.endsWith(".wasm")) { return next(); } const cookies = (req.headers.cookie && req.headers.cookie.split(";") || []).map((c) => c.trim()); const urlParsed = url2.parse(req.originalUrl); const urlParamString = new URLSearchParams(urlParsed.query || ""); const cid = urlParamString.get("cid") || cookies.find((c) => c.includes("WDIO_CID"))?.split("=").pop(); const spec = urlParamString.get("spec") || cookies.find((c) => c.includes("WDIO_SPEC"))?.split("=").pop(); if (!cid || !SESSIONS.has(cid)) { log2.error(`No environment found for ${cid || "non determined environment"}`); return next(); } if (!spec) { log2.error("No spec file was defined to run for this environment"); return next(); } const env = SESSIONS.get(cid); try { const template = await getTemplate(options, env, spec); log2.debug(`Render template for ${req.originalUrl}`); res.end(await server.transformIndexHtml(`${req.originalUrl}`, template)); } catch (err) { const template = getErrorTemplate(req.originalUrl, err); log2.error(`Failed to render template: ${err.message}`); res.end(await server.transformIndexHtml(`${req.originalUrl}`, template)); } return next(); }); }; } }, { name: "modern-node-polyfills", async resolveId(id, _, ctx) { if (ctx.ssr || !builtinModules.includes(id)) { return; } id = normalizeId(id); return { id: await polyfillPath(id), moduleSideEffects: false }; } }]; } // src/vite/plugins/mockHoisting.ts import os from "node:os"; import url3 from "node:url"; import path3 from "node:path"; import fs2 from "node:fs/promises"; import logger3 from "@wdio/logger"; import { parse, print, visit, types } from "recast"; import typescriptParser from "recast/parsers/typescript.js"; var log3 = logger3("@wdio/browser-runner:mockHoisting"); var INTERNALS_TO_IGNORE = [ "@vite/client", "vite/dist/client", "/webdriverio/build/", "/@wdio/", "/webdriverio/node_modules/", "virtual:wdio", "?html-proxy", "/__fixtures__/", "/__mocks__/", "/.vite/deps/@testing-library_vue.js" ]; var b = types.builders; var MOCK_PREFIX = "/@mock"; function mockHoisting(mockHandler) { let spec = null; let isTestDependency = false; const sessionMocks = /* @__PURE__ */ new Set(); const importMap = /* @__PURE__ */ new Map(); return [{ name: "wdio:mockHoisting:pre", enforce: "pre", resolveId: mockHandler.resolveId.bind(mockHandler), load: async function(id) { if (id.startsWith(MOCK_PREFIX)) { try { const orig = await fs2.readFile(id.slice(MOCK_PREFIX.length + (os.platform() === "win32" ? 1 : 0))); return orig.toString(); } catch (err) { log3.error(`Failed to read file (${id}) for mocking: ${err.message}`); return ""; } } } }, { name: "wdio:mockHoisting", enforce: "post", transform(code, id) { const isSpecFile = id === spec; if (isSpecFile) { isTestDependency = true; } if ( // where loading was inititated through the test file !isTestDependency && // however when files are inlined they will be loaded when parsing file under test // in this case we want to transform them, but make sure we exclude these paths (id.includes("/node_modules/") || id.includes("/?cid=") || id.startsWith("virtual:")) || // are not Vite or WebdriverIO internals INTERNALS_TO_IGNORE.find((f) => id.includes(f)) || // when the spec file is actually mocking any dependencies !isSpecFile && sessionMocks.size === 0 ) { return { code }; } let ast; const start = Date.now(); try { ast = parse(code, { parser: typescriptParser, sourceFileName: id, sourceRoot: path3.dirname(id) }); log3.trace(`Parsed file for mocking: ${id} in ${Date.now() - start}ms`); } catch { return { code }; } let importIndex = 0; let mockFunctionName; let unmockFunctionName; const mockCalls = []; visit(ast, { /** * find function name for mock and unmock calls */ visitImportDeclaration: function(path10) { const dec = path10.value; const source = dec.source.value; if (!dec.specifiers || dec.specifiers.length === 0 || source !== "@wdio/browser-runner") { return this.traverse(path10); } const mockSpecifier = dec.specifiers.filter((s) => s.type === types.namedTypes.ImportSpecifier.toString()).find((s) => s.imported.name === "mock"); if (mockSpecifier && mockSpecifier.local) { mockFunctionName = mockSpecifier.local.name; } const unmockSpecifier = dec.specifiers.filter((s) => s.type === types.namedTypes.ImportSpecifier.toString()).find((s) => s.imported.name === "unmock"); if (unmockSpecifier && unmockSpecifier.local) { unmockFunctionName = unmockSpecifier.local.name; } mockCalls.push(dec); path10.prune(); return this.traverse(path10); }, /** * detect which modules are supposed to be mocked */ ...isSpecFile ? { visitExpressionStatement: function(path10) { const exp = path10.value; if (exp.expression.type !== types.namedTypes.CallExpression.toString()) { return this.traverse(path10); } const callExp = exp.expression; const isUnmockCall = unmockFunctionName && callExp.callee.name === unmockFunctionName; const isMockCall = mockFunctionName && callExp.callee.name === mockFunctionName; if (!isMockCall && !isUnmockCall) { return this.traverse(path10); } if (isUnmockCall && callExp.arguments[0] && typeof callExp.arguments[0].value === "string") { mockHandler.unmock(callExp.arguments[0].value); } else if (isMockCall) { const mockCall = exp.expression; if (mockCall.arguments.length === 1) { mockHandler.manualMocks.push(mockCall.arguments[0].value); } else { if (exp.expression.arguments.length) { sessionMocks.add(exp.expression.arguments[0].value); } mockCalls.push(exp); } } path10.prune(); this.traverse(path10); } } : {} }); visit(ast, { /** * rewrite import statements */ visitImportDeclaration: function(nodePath) { const dec = nodePath.value; const source = dec.source.value; if (!dec.specifiers || dec.specifiers.length === 0) { return this.traverse(nodePath); } const newImportIdentifier = `__wdio_import${importIndex++}`; const isMockedModule = Boolean( // matches if a dependency is mocked sessionMocks.has(source) || // matches if a relative file is mocked source.startsWith(".") && [...sessionMocks.values()].find((m) => { const fileImportPath = path3.resolve(path3.dirname(id), source); const fileImportPathSliced = fileImportPath.slice(0, path3.extname(fileImportPath).length * -1); const testMockPath = path3.resolve(path3.dirname(spec || "/"), m); const testMockPathSliced = testMockPath.slice(0, path3.extname(testMockPath).length * -1); return fileImportPathSliced === testMockPathSliced && fileImportPathSliced.length > 0; }) ); if (isMockedModule && isSpecFile) { importMap.set(source, newImportIdentifier); } if (!isSpecFile || isMockedModule) { const newNode = b.importDeclaration( [b.importNamespaceSpecifier(b.identifier(newImportIdentifier))], b.literal(source) ); mockCalls.unshift(newNode); } const mockExtensionLengh = path3.extname(source).length * -1 || Infinity; const wdioImportModuleIdentifier = source.startsWith(".") || source.startsWith("/") ? url3.pathToFileURL(path3.resolve(path3.dirname(id), source).slice(0, mockExtensionLengh)).pathname : source; const isNamespaceImport = dec.specifiers.length === 1 && dec.specifiers[0].type === types.namedTypes.ImportNamespaceSpecifier.toString(); const mockImport = isSpecFile && !isMockedModule ? b.variableDeclaration("const", [ b.variableDeclarator( isNamespaceImport ? dec.specifiers[0].local : b.objectPattern(dec.specifiers.map((s) => { if (s.type === types.namedTypes.ImportDefaultSpecifier.toString()) { return b.property("init", b.identifier("default"), b.identifier(s.local.name)); } return b.property("init", b.identifier(s.imported.name), b.identifier(s.local.name)); })), b.awaitExpression(b.importExpression(b.literal(source))) ) ]) : b.variableDeclaration("const", [ b.variableDeclarator( dec.specifiers.length === 1 && dec.specifiers[0].type === types.namedTypes.ImportNamespaceSpecifier.toString() ? b.identifier(dec.specifiers[0].local.name) : b.objectPattern(dec.specifiers.map((s) => { if (s.type === types.namedTypes.ImportDefaultSpecifier.toString()) { return b.property("init", b.identifier("default"), b.identifier(s.local.name)); } return b.property("init", b.identifier(s.imported.name), b.identifier(s.local.name)); })), b.callExpression( b.identifier("wdioImport"), [ b.literal(wdioImportModuleIdentifier), b.identifier(newImportIdentifier) ] ) ) ]); nodePath.replace(mockImport); this.traverse(nodePath); } }); ast.program.body.unshift(...mockCalls.map((mc) => { const exp = mc; if (exp.expression && exp.expression.type === types.namedTypes.CallExpression.toString()) { const mockCallExpression = exp.expression; const mockedModule = mockCallExpression.arguments[0].value; const mockFactory = mockCallExpression.arguments[1]; if (importMap.has(mockedModule)) { mockCallExpression.arguments.push(b.identifier(importMap.get(mockedModule))); } else if (mockFactory.params.length > 0) { const newImportIdentifier = `__wdio_import${importIndex++}`; ast.program.body.unshift(b.importDeclaration( [b.importNamespaceSpecifier(b.identifier(newImportIdentifier))], b.literal(mockedModule) )); mockCallExpression.arguments.push(b.identifier(newImportIdentifier)); } return b.expressionStatement(b.awaitExpression(mockCallExpression)); } return mc; })); try { const newCode = print(ast, { sourceMapName: id }); log3.trace(`Transformed file for mocking: ${id} in ${Date.now() - start}ms`); return newCode; } catch (err) { log3.trace(`Failed to transformed file (${id}) for mocking: ${err.stack}`); return { code }; } }, configureServer(server) { return () => { server.middlewares.use("/", async (req, res, next) => { if (!req.originalUrl) { return next(); } const urlParsed = url3.parse(req.originalUrl); const urlParamString = new URLSearchParams(urlParsed.query || ""); const specParam = urlParamString.get("spec"); if (specParam) { mockHandler.resetMocks(); isTestDependency = false; spec = os.platform() === "win32" ? specParam.slice(1) : specParam; } return next(); }); }; } }]; } // src/vite/plugins/worker.ts function workerPlugin(onSocketEvent) { return { name: "wdio:worker", configureServer({ ws }) { ws.on(WDIO_EVENT_NAME, onSocketEvent); } }; } // src/vite/mock.ts import path4 from "node:path"; var FIXTURE_PREFIX = "/@fixture/"; var MockHandler = class { #automock; #automockDir; #manualMocksList; #mocks = /* @__PURE__ */ new Map(); #unmocked = []; manualMocks = []; constructor(options, config) { this.#automock = typeof options.automock === "boolean" ? options.automock : DEFAULT_AUTOMOCK; this.#automockDir = path4.resolve(config.rootDir, options.automockDir || DEFAULT_MOCK_DIRECTORY); this.#manualMocksList = getManualMocks(this.#automockDir); } get mocks() { return this.#mocks; } unmock(moduleName) { this.#unmocked.push(moduleName); } async resolveId(id) { const manualMocksList = await this.#manualMocksList; const mockPath = manualMocksList.find((m) => ( // e.g. someModule id === m[1].replace(path4.sep, "/") || // e.g. @some/module id.slice(1) === m[1].replace(path4.sep, "/") )); if ((this.manualMocks.includes(id) || this.#automock) && mockPath && !this.#unmocked.includes(id)) { return mockPath[0]; } if (id.startsWith(FIXTURE_PREFIX)) { return path4.resolve(this.#automockDir, id.slice(FIXTURE_PREFIX.length)); } } /** * reset manual mocks between tests */ resetMocks() { this.manualMocks = []; this.#unmocked = []; } }; // src/vite/constants.ts import logger4 from "@wdio/logger"; import topLevelAwait from "vite-plugin-top-level-await"; import { esbuildCommonjs } from "@originjs/vite-plugin-commonjs"; // src/vite/plugins/esbuild.ts import fs3 from "node:fs/promises"; function codeFrameFix() { return { name: "wdio:codeFrameFix", setup(build) { build.onLoad( { filter: /@babel\/code-frame/, namespace: "file" }, /** * mock @babel/code-frame as it fails in Safari due * to usage of chalk */ async ({ path: id }) => { const code = await fs3.readFile(id).then( (buf) => buf.toString(), () => void 0 ); if (!code) { return; } return { contents: code.replace( 'require("@babel/highlight");', /*js*/ `{ shouldHighlight: false, reset: () => {} }` ) }; } ); } }; } // src/vite/constants.ts var log4 = logger4("@wdio/browser-runner:vite"); var DEFAULT_PROTOCOL = "http"; var DEFAULT_HOSTNAME = "localhost"; var DEFAULT_HOST = `${DEFAULT_PROTOCOL}://${DEFAULT_HOSTNAME}`; var PRESET_DEPENDENCIES = { react: ["@vitejs/plugin-react", "default", { babel: { assumptions: { setPublicClassFields: true }, parserOpts: { plugins: ["decorators-legacy", "classProperties"] } } }], preact: ["@preact/preset-vite", "default", void 0], vue: ["@vitejs/plugin-vue", "default", void 0], svelte: ["@sveltejs/vite-plugin-svelte", "svelte", void 0], solid: ["vite-plugin-solid", "default", void 0], stencil: void 0, lit: void 0 }; var DEFAULT_VITE_CONFIG = { configFile: false, server: { host: DEFAULT_HOSTNAME }, logLevel: "info", plugins: [topLevelAwait()], build: { sourcemap: "inline", commonjsOptions: { include: [/node_modules/] } }, optimizeDeps: { /** * the following deps are CJS packages and need to be optimized (compiled to ESM) by Vite */ include: [ "expect", "minimatch", "css-shorthand-properties", "lodash.merge", "lodash.zip", "ws", "lodash.clonedeep", "lodash.pickby", "lodash.flattendeep", "aria-query", "grapheme-splitter", "css-value", "rgb2hex", "p-iteration", "deepmerge-ts", "jest-util", "jest-matcher-utils", "split2" ], esbuildOptions: { logLevel: "silent", // Node.js global to browser globalThis define: { global: "globalThis" }, // Enable esbuild polyfill plugins plugins: [ esbuildCommonjs(["@testing-library/vue"]), /** * cast to "any" here as Vite's esbuild dependency and WebdriverIOs one * may differ and cause type issues here. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any codeFrameFix() ] } }, customLogger: { info: (msg) => log4.info(msg), warn: (msg) => log4.warn(msg), warnOnce: (msg) => log4.warn(msg), error: (msg) => log4.error(msg), clearScreen: () => { }, hasErrorLogged: () => false, hasWarned: false } }; // src/vite/server.ts var log5 = logger5("@wdio/browser-runner:ViteServer"); var DEFAULT_CONFIG_ENV = { command: "serve", mode: process.env.NODE_ENV === "production" ? "production" : "development" }; var ViteServer = class extends EventEmitter { #options; #config; #viteConfig; #server; #mockHandler; #socketEventHandler = []; get config() { return this.#viteConfig; } constructor(options, config, optimizations) { super(); this.#options = options; this.#config = config; this.#mockHandler = new MockHandler(options, config); const root = options.rootDir || config.rootDir || process.cwd(); this.#viteConfig = deepmerge2(DEFAULT_VITE_CONFIG, optimizations, { root, plugins: [ testrunner(options), mockHoisting(this.#mockHandler), workerPlugin((payload, client) => this.#socketEventHandler.forEach( (handler) => handler(payload, client) )) ] }); if (options.coverage && options.coverage.enabled) { log5.info("Capturing test coverage enabled"); const plugin = istanbulPlugin; this.#viteConfig.plugins?.push(plugin({ cwd: config.rootDir, include: DEFAULT_INCLUDE, extension: DEFAULT_FILE_EXTENSIONS, forceBuildInstrument: true, ...options.coverage })); } } onBrowserEvent(handler) { this.#socketEventHandler.push(handler); } async start() { if (this.#options.preset) { const [pkg, importProp, opts] = PRESET_DEPENDENCIES[this.#options.preset] || []; const plugin = (await userfriendlyImport(this.#options.preset, pkg))[importProp || "default"]; if (plugin) { this.#viteConfig.plugins.push(plugin(opts)); } } if (this.#options.viteConfig) { const { plugins, ...configToMerge } = typeof this.#options.viteConfig === "string" ? (await import(path5.resolve(this.#config.rootDir || process.cwd(), this.#options.viteConfig))).default : typeof this.#options.viteConfig === "function" ? await this.#options.viteConfig(DEFAULT_CONFIG_ENV) : this.#options.viteConfig; this.#viteConfig = deepmerge2(this.#viteConfig, configToMerge); this.#viteConfig.plugins = [...plugins || [], ...this.#viteConfig.plugins]; } const vitePort = await getPort(); this.#viteConfig = deepmerge2(this.#viteConfig, { server: { ...this.#viteConfig.server, port: vitePort } }); this.#server = await createServer(this.#viteConfig); await this.#server.listen(); log5.info(`Vite server started successfully on port ${vitePort}, root directory: ${this.#viteConfig.root}`); return vitePort; } async close() { await this.#server?.close(); } }; // src/vite/frameworks/nuxt.ts import url4 from "node:url"; import path6 from "node:path"; import logger6 from "@wdio/logger"; import { resolve as resolve2 } from "import-meta-resolve"; var log6 = logger6("@wdio/browser-runner:NuxtOptimization"); async function isNuxtFramework(rootDir) { return (await Promise.all([ hasFileByExtensions(path6.join(rootDir, "nuxt.config")), hasDir(path6.join(rootDir, ".nuxt")) ])).filter(Boolean).length > 0; } async function optimizeForNuxt(options, config) { const Unimport = (await import("unimport/unplugin")).default; const { scanDirExports, scanExports } = await import("unimport"); const { loadNuxtConfig } = await import("@nuxt/kit"); const rootDir = config.rootDir || process.cwd(); const nuxtOptions = await loadNuxtConfig({ rootDir }); if (nuxtOptions.imports?.autoImport === false) { return {}; } const nuxtDepPath = await resolve2("nuxt", import.meta.url); const composablesDirs = []; for (const layer of nuxtOptions._layers) { composablesDirs.push(path6.resolve(layer.config.srcDir, "composables")); composablesDirs.push(path6.resolve(layer.config.srcDir, "utils")); for (const dir of layer.config.imports?.dirs ?? []) { if (!dir) { continue; } composablesDirs.push(path6.resolve(layer.config.srcDir, dir)); } } const composableImports = await Promise.all([ scanDirExports([ ...composablesDirs, path6.resolve(path6.dirname(url4.fileURLToPath(nuxtDepPath)), "app", "components") ]), scanExports(path6.resolve(path6.dirname(url4.fileURLToPath(nuxtDepPath)), "app", "composables", "index.js")) ]).then((scannedExports) => scannedExports.flat().map((ci) => { if (ci.from.includes("/nuxt/dist/app/composables")) { ci.from = "virtual:wdio"; ci.name = "wrappedFn"; } return ci; })); const viteConfig = { resolve: { alias: nuxtOptions.alias || {} }, plugins: [Unimport.vite({ presets: ["vue"], imports: composableImports })] }; log6.info(`Optimized Vite config for Nuxt project at ${rootDir}`); return viteConfig; } // src/vite/frameworks/tailwindcss.ts import url5 from "node:url"; import path7 from "node:path"; import { resolve as resolve3 } from "import-meta-resolve"; function isUsingTailwindCSS(rootDir) { return Promise.all([ hasFileByExtensions(path7.join(rootDir, "tailwind.config")), hasFileByExtensions(path7.join(rootDir, "postcss.config")) ]).then(([hasTailwindConfig, hasPostCSSConfig]) => { return hasTailwindConfig && !hasPostCSSConfig; }); } async function optimizeForTailwindCSS(rootDir) { const viteConfig = {}; const tailwindcssPath = await resolve3("tailwindcss", url5.pathToFileURL(path7.resolve(rootDir, "index.js")).href); const tailwindcss = (await import(tailwindcssPath)).default; viteConfig.css = { postcss: { plugins: [tailwindcss] } }; return viteConfig; } // src/vite/frameworks/stencil.ts import path8 from "node:path"; import url6 from "node:url"; import { findStaticImports, parseStaticImport } from "mlly"; var __dirname3 = url6.fileURLToPath(new URL(".", import.meta.url)); var STENCIL_IMPORT = "@stencil/core"; async function isUsingStencilJS(rootDir, options) { return Boolean(options.preset === "stencil" || await hasFileByExtensions(path8.join(rootDir, "stencil.config"))); } async function optimizeForStencil(rootDir) { const stencilConfig = await importStencilConfig(rootDir); const stencilPlugins = stencilConfig.config.plugins; const stencilOptimizations = { plugins: [await stencilVitePlugin(rootDir)], optimizeDeps: { include: [] } }; if (stencilPlugins) { const esbuildPlugin = stencilPlugins.find((plugin) => plugin.name === "esbuild-plugin"); if (esbuildPlugin) { stencilOptimizations.optimizeDeps?.include?.push(...esbuildPlugin.options.include); } } stencilOptimizations.optimizeDeps?.include?.push( "@wdio/browser-runner/stencil > @stencil/core/internal/testing/index.js" ); return stencilOptimizations; } async function stencilVitePlugin(rootDir) { const { transpileSync, ts } = await import("@stencil/core/compiler/stencil.js"); const stencilHelperPath = path8.resolve(__dirname3, "browser", "integrations", "stencil.js"); return { name: "wdio-stencil", enforce: "pre", resolveId(source) { if (source === "@wdio/browser-runner/stencil") { return stencilHelperPath; } }, transform: function(code, id) { const staticImports = findStaticImports(code); const stencilImports = staticImports.filter((imp) => imp.specifier === STENCIL_IMPORT).map((imp) => parseStaticImport(imp)); const isStencilComponent = stencilImports.some((imp) => "Component" in (imp.namedImports || {})); if (!isStencilComponent) { const stencilHelperImport = staticImports.find((imp) => imp.specifier === "@wdio/browser-runner/stencil"); if (stencilHelperImport) { const imports = parseStaticImport(stencilHelperImport); if ("render" in (imports.namedImports || {})) { code = injectStencilImports(code, stencilImports); } } return { code }; } const tsCompilerOptions = getCompilerOptions(ts, rootDir); const opts = { componentExport: "module", componentMetadata: "compilerstatic", coreImportPath: "@stencil/core/internal/client", currentDirectory: rootDir, file: path8.basename(id), module: "esm", sourceMap: "inline", style: "static", proxy: "defineproperty", styleImportData: "queryparams", transformAliasedImportPaths: process.env.__STENCIL_TRANSPILE_PATHS__ === "true", target: tsCompilerOptions?.target || "es2018", paths: tsCompilerOptions?.paths, baseUrl: tsCompilerOptions?.baseUrl }; const transpiledCode = transpileSync(code, opts); let transformedCode = transpiledCode.code.replace( "static get style()", "static set style(_) {}\n static get style()" ); transformedCode = injectStencilImports(transformedCode, stencilImports); findStaticImports(transformedCode).filter((imp) => imp.specifier.includes("&encapsulation=shadow")).forEach((imp) => { const cssPath = path8.resolve(path8.dirname(id), imp.specifier); transformedCode = transformedCode.replace( imp.code, `import ${imp.imports.trim()} from '/@fs/${cssPath}&inline'; ` ); }); return { ...transpiledCode, code: transformedCode, inputFilePath: id }; } }; } function injectStencilImports(code, imports) { const hasRenderFunctionImport = imports.some((imp) => "h" in (imp.namedImports || {})); if (!hasRenderFunctionImport) { code = `import { h } from '@stencil/core/internal/client'; ${code}`; } const hasFragmentImport = imports.some((imp) => "Fragment" in (imp.namedImports || {})); if (!hasFragmentImport) { code = `import { Fragment } from '@stencil/core/internal/client'; ${code}`; } return code; } var _tsCompilerOptions = null; function getCompilerOptions(ts, rootDir) { if (_tsCompilerOptions) { return _tsCompilerOptions; } if (typeof rootDir !== "string") { return null; } const tsconfigFilePath = ts.findConfigFile(rootDir, ts.sys.fileExists); if (!tsconfigFilePath) { return null; } const tsconfigResults = ts.readConfigFile(tsconfigFilePath, ts.sys.readFile); if (tsconfigResults.error) { throw new Error(tsconfigResults.error); } const parseResult = ts.parseJsonConfigFileContent( tsconfigResults.config, ts.sys, rootDir, void 0, tsconfigFilePath ); _tsCompilerOptions = parseResult.options; return _tsCompilerOptions; } async function importStencilConfig(rootDir) { const configPath = path8.join(rootDir, "stencil.config.ts"); const config = await import(configPath).catch(() => ({ config: {} })); if ("default" in config) { return config.default; } return config; } // src/vite/frameworks/index.ts async function updateViteConfig(options, config) { const optimizations = {}; const rootDir = options.rootDir || config.rootDir || process.cwd(); const isNuxt = await isNuxtFramework(rootDir); if (isNuxt) { Object.assign(optimizations, await optimizeForNuxt(options, config)); } const isTailwind = await isUsingTailwindCSS(rootDir); if (isTailwind) { Object.assign(optimizations, await optimizeForTailwindCSS(rootDir)); } if (await isUsingStencilJS(rootDir, options)) { Object.assign(optimizations, await optimizeForStencil(rootDir)); } return optimizations; } // src/communicator.ts import libSourceMap from "istanbul-lib-source-maps"; import libCoverage from "istanbul-lib-coverage"; import logger7 from "@wdio/logger"; import { MESSAGE_TYPES } from "@wdio/types"; var log7 = logger7("@wdio/browser-runner"); var ServerWorkerCommunicator = class { #mapStore = libSourceMap.createSourceMapStore(); #config; #msgId = 0; /** * keep track of custom commands per session */ #customCommands = /* @__PURE__ */ new Map(); /** * keep track of request/response messages on browser/worker level */ #pendingMessages = /* @__PURE__ */ new Map(); coverageMaps = []; constructor(config) { this.#config = config; } register(server, worker) { server.onBrowserEvent((data, client) => this.#onBrowserEvent(data, client, worker)); worker.on("message", this.#onWorkerMessage.bind(this)); } async #onWorkerMessage(payload) { if (payload.name === "sessionStarted" && !SESSIONS.has(payload.cid)) { SESSIONS.set(payload.cid, { args: this.#config.mochaOpts || {}, config: this.#config, capabilities: payload.content.capabilities, sessionId: payload.content.sessionId, injectGlobals: payload.content.injectGlobals }); } if (payload.name === "sessionEnded") { SESSIONS.delete(payload.cid); } if (payload.name === "workerEvent" && payload.args.type === MESSAGE_TYPES.coverageMap) { const coverageMapData = payload.args.value; this.coverageMaps.push( await this.#mapStore.transformCoverage(libCoverage.createCoverageMap(coverageMapData)) ); } if (payload.name === "workerEvent" && payload.args.type === MESSAGE_TYPES.customCommand) { const { commandName, cid } = payload.args.value; if (!this.#customCommands.has(cid)) { this.#customCommands.set(cid, /* @__PURE__ */ new Set()); } const customCommands = this.#customCommands.get(cid) || /* @__PURE__ */ new Set(); customCommands.add(commandName); return; } if (payload.name === "workerResponse") { const msg = this.#pendingMessages.get(payload.args.id); if (!msg) { return log7.error(`Couldn't find message with id ${payload.args.id} from type ${payload.args.message.type}`); } this.#pendingMessages.delete(payload.args.id); return msg.client.send(WDIO_EVENT_NAME, payload.args.message); } } #onBrowserEvent(message, client, worker) { if (message.type === MESSAGE_TYPES.initiateBrowserStateRequest) { const result = { type: MESSAGE_TYPES.initiateBrowserStateResponse, value: { customCommands: [...this.#customCommands.get(message.value.cid) || []] } }; return client.send(WDIO_EVENT_NAME, result); } const id = this.#msgId++; const msg = { id, client }; this.#pendingMessages.set(id, msg); const args = { id, message }; return worker.postMessage("workerRequest", args, true); } }; // src/utils.ts import util from "node:util"; import { deepmerge as deepmerge3 } from "deepmerge-ts"; import logger8 from "@wdio/logger"; var log8 = logger8("@wdio/browser-runner"); function makeHeadless(options, caps) { const capability = caps.alwaysMatch || caps; if (!capability.browserName) { throw new Error( 'No "browserName" defined in capability object. It seems you are trying to run tests in a non web environment, however WebdriverIOs browser runner only supports web environments' ); } if ( // either user sets headless option implicitly typeof options.headless === "boolean" && !options.headless || // or CI environment is set typeof process.env.CI !== "undefined" && !process.env.CI || // or non are set typeof options.headless !== "boolean" && typeof process.env.CI === "undefined" ) { return caps; } if (capability.browserName === "chrome") { return deepmerge3(capability, { "goog:chromeOptions": { args: ["headless", "disable-gpu"] } }); } else if (capability.browserName === "firefox") { return deepmerge3(capability, { "moz:firefoxOptions": { args: ["-headless"] } }); } else if (capability.browserName === "msedge" || capability.browserName === "edge") { return deepmerge3(capability, { "ms:edgeOptions": { args: ["--headless"] } }); } log8.error(`Headless mode not supported for browser "${capability.browserName}"`); return caps; } function adjustWindowInWatchMode(config, caps) { if (!config.watch) { return caps; } const capability = caps.alwaysMatch || caps; if (config.watch && capability.browserName === "chrome") { return deepmerge3(capability, { "goog:chromeOptions": { args: ["auto-open-devtools-for-tabs", "window-size=1600,1200"], prefs: { devtools: { preferences: { "panel-selectedTab": '"console"' } } } } }); } return caps; } function getCoverageByFactor(options, summary, fileName) { return COVERAGE_FACTORS.map((factor) => { const treshold = options[factor]; if (!treshold) { return; } if (summary[factor].pct >= treshold) { return; } return fileName ? util.format(FILE_TRESHOLD_REPORTING, factor, summary[factor].pct, treshold, fileName) : util.format(GLOBAL_TRESHOLD_REPORTING, factor, summary[factor].pct, treshold); }).filter(Boolean); } // src/index.ts export * from "@vitest/spy"; var log9 = logger9("@wdio/browser-runner"); var BrowserRunner = class extends LocalRunner { constructor(options, _config) { super(options, _config); this.options = options; this._config = _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 || path9.join(this.#config.rootDir, DEFAULT_REPORTS_DIRECTORY); if (this.#config.mochaOpts) { this.#config.mochaOpts.require = (this.#config.mochaOpts.require || []).map((r) => path9.join(this.#config.rootDir || process.cwd(), r)).map((r) => url7.pathToFileURL(r).pathname); } } #options; #config; #servers = /* @__PURE__ */ new Set(); #coverageOptions; #reportsDirectory; #viteOptimizations = {}; #communicator; /** * for testing purposes */ _servers = this.#servers; /** * nothing to initialize when running locally */ async initialize() { log9.info("Initiate browser environment"); if (typeof this.#coverageOptions.clean === "undefined" || this.#coverageOptions.clean) { const reportsDirectoryExist = await fs4.access(this.#reportsDirectory).then(() => true, () => false); if (reportsDirectoryExist) { await fs4.rm(this.#reportsDirectory, { recursive: true }); } } try { this.#viteOptimizations = await upda