@wdio/browser-runner
Version:
A WebdriverIO runner to run unit tests tests in the browser.
1,402 lines (1,376 loc) • 53.5 kB
JavaScript
// 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