import url from 'node:url'
import path from 'node:path'
import { builtinModules } from 'node:module'
import logger from '@wdio/logger'
import { polyfillPath } from 'modern-node-polyfills'
import { deepmerge } from 'deepmerge-ts'
import type { Plugin } from 'vite'
import {
WebDriverProtocol, MJsonWProtocol, AppiumProtocol,
ChromiumProtocol, SauceLabsProtocol, SeleniumProtocol, GeckoProtocol,
type Protocol
} from '@wdio/protocols'
import { SESSIONS } from '../../constants.js'
import { getTemplate, getErrorTemplate, normalizeId } from '../utils.js'
const log = logger('@wdio/browser-runner:plugin')
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const commands = deepmerge<any>(
WebDriverProtocol, MJsonWProtocol, AppiumProtocol,
ChromiumProtocol, SauceLabsProtocol, SeleniumProtocol, GeckoProtocol
) as Protocol
const protocolCommandList = Object.values(commands).map(
(endpoint) => Object.values(endpoint).map(
({ command }) => command
)
).flat()
const virtualModuleId = 'virtual:wdio'
const resolvedVirtualModuleId = '\0' + virtualModuleId
* these modules are used in Node.js environments only and
* don't need to be compiled, we just have them point to a
* mocked module that returns a matching interface without
* functionality
*/
const 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'
]
const POLYFILLS = [
...builtinModules,
...builtinModules.map((m) => `node:${m}`)
]
export function testrunner(options: WebdriverIO.BrowserRunnerOptions): Plugin[] {
const browserModules = path.resolve(__dirname, 'browser')
const automationProtocolPath = `/@fs${url.pathToFileURL(path.resolve(browserModules, 'driver.js')).pathname}`
const mockModulePath = path.resolve(browserModules, 'mock.js')
const setupModulePath = path.resolve(browserModules, 'setup.js')
const spyModulePath = path.resolve(browserModules, 'spy.js')
const wdioExpectModulePath = path.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', '')))
}
* fake the content of this package and load the implementation of the mock
* features from the browser module directory
*/
if (id === '@wdio/browser-runner') {
return spyModulePath
}
* allow to load the setup script from a script tag
*/
if (id.endsWith('@wdio/browser-runner/setup')) {
return setupModulePath
}
* run WebdriverIO assertions within the Node.js context so we can do things like
* visual assertions or snapshot testing
*/
if (id === 'expect-webdriverio') {
return wdioExpectModulePath
}
* mock out imports that we can't transpile into browser land
*/
if (MODULES_TO_MOCK.includes(id)) {
return mockModulePath
}
* redirect requests to 3rd party dependencies
*/
if (id.startsWith('/@wdio/browser-runner/third_party/')) {
return path.resolve(__dirname, ...id.split('/').slice(3))
}
},
load(id) {
* provide a list of protocol commands to generate the prototype in the browser
*/
if (id === resolvedVirtualModuleId) {
return `
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) => {
log.info(`Received request for: ${req.originalUrl}`)
* don't return test page when sourcemaps are requested
*/
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 = url.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)) {
log.error(`No environment found for ${cid || 'non determined environment'}`)
return next()
}
if (!spec) {
log.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)
log.debug(`Render template for ${req.originalUrl}`)
res.end(await server.transformIndexHtml(`${req.originalUrl}`, template))
} catch (err) {
const template = getErrorTemplate(req.originalUrl, err as Error)
log.error(`Failed to render template: ${(err as Error).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 }
},
}]
}