UNPKG

playwright-test

Version:

Run mocha, zora, uvu, tape and benchmark.js scripts inside real browsers with playwright.

675 lines (618 loc) 17.3 kB
/* eslint-disable no-console */ import fs from 'fs' import { createServer } from 'http' import { createRequire } from 'module' import path from 'path' import { fileURLToPath, pathToFileURL } from 'url' import { promisify } from 'util' import camelCase from 'camelcase' import esbuild from 'esbuild' import { wasmLoader } from 'esbuild-plugin-wasm' import kleur from 'kleur' // @ts-ignore import mergeOptions from 'merge-options' import ora from 'ora' import polka from 'polka' import sirv from 'sirv' import { globSync } from 'tinyglobby' import V8ToIstanbul from 'v8-to-istanbul' import * as DefaultRunners from '../test-runners.js' const require = createRequire(import.meta.url) const __dirname = path.dirname(fileURLToPath(import.meta.url)) const merge = mergeOptions.bind({ ignoreUndefined: true, concatArrays: true, }) /** * @type {import('../types').RunnerOptions} */ export const defaultOptions = { cwd: process.cwd(), assets: '', browser: 'chromium', debug: false, mode: 'main', // worker incognito: false, input: undefined, extension: false, testRunner: DefaultRunners.none, before: undefined, sw: undefined, cov: false, reportDir: '.nyc_output', extensions: 'js,cjs,mjs,ts,tsx,jsx', buildConfig: {}, buildSWConfig: {}, browserContextOptions: {}, beforeTests: async () => { // noop }, afterTests: async () => { // noop }, } export const log = { /** * @param {string} message * @param {boolean} quiet */ info(message, quiet = false) { if (!quiet) { console.error(kleur.blue('ℹ'), message) } }, /** * @param {string} message * @param {boolean} quiet */ warn(message, quiet = false) { if (!quiet) { console.warn(kleur.yellow('-'), message) } }, /** * @param {string} message * @param {boolean} quiet */ error(message, quiet = false) { if (!quiet) { console.warn(kleur.red('✘'), message) } }, /** * @param {string} message * @param {boolean} quiet */ success(message, quiet = false) { if (!quiet) { console.warn(kleur.green('✔'), message) } }, } /** * @typedef {import('../types').RunnerOptions } RunnerOptions * @typedef {import('esbuild').Plugin} ESBuildPlugin * @typedef {import('esbuild').BuildOptions} ESBuildOptions */ const writeFile = promisify(fs.writeFile) const mkdir = promisify(fs.mkdir) const defaultIgnorePatterns = [ '.git', // Git repository files, see <https://git-scm.com/> '.log', // Log files emitted by tools such as `tsserver`, see <https://github.com/Microsoft/TypeScript/wiki/Standalone-Server-%28tsserver%29> '.nyc_output', // Temporary directory where nyc stores coverage data, see <https://github.com/bcoe/nyc> '.sass-cache', // Cache folder for node-sass, see <https://github.com/sass/node-sass> 'bower_components', // Where Bower packages are installed, see <http://bower.io/> 'coverage', // Standard output directory for code coverage reports, see <https://github.com/gotwarlost/istanbul> 'node_modules', // Where Node modules are installed, see <https://nodejs.org/>, '**/node_modules', '**/__tests__/**/__{helper,fixture}?(s)__/**/*', '**/test?(s)/**/{helper,fixture}?(s)/**/*', ] /** * @param {string[]} extensions * @param {string} file */ function hasExtension(extensions, file) { return extensions.includes(path.extname(file).slice(1)) } /** * @param {any[]} extensions */ function buildExtensionPattern(extensions) { return extensions.length === 1 ? extensions[0] : `{${extensions.join(',')}}` } /** * @param {string[]} extensions */ export function defaultTestPatterns(extensions) { const extensionPattern = buildExtensionPattern(extensions) return [ `test.${extensionPattern}`, `{src,source}/test.${extensionPattern}`, `**/__tests__/**/*.${extensionPattern}`, `**/*.spec.${extensionPattern}`, `**/*.test.${extensionPattern}`, `**/test-*.${extensionPattern}`, `**/test/**/*.${extensionPattern}`, `**/tests/**/*.${extensionPattern}`, ] } /** * @param {string} cwd * @param {string[]} patterns */ function globFiles(cwd, patterns) { const files = globSync(patterns, { absolute: false, caseSensitiveMatch: false, cwd, dot: false, expandDirectories: true, followSymbolicLinks: true, ignore: defaultIgnorePatterns, onlyFiles: true, }) // Return absolute file paths. This has the side-effect of normalizing paths // on Windows. return files.map((file) => path.join(cwd, file)) } /** * Find files * * @param {object} options * @param {string} options.cwd * @param {string[]} options.extensions * @param {string[]} options.filePatterns */ function findFiles({ cwd, extensions, filePatterns }) { return globFiles(cwd, filePatterns).filter((file) => hasExtension(extensions, file) ) } /** * Find the tests files * * @param {object} options * @param {string} options.cwd - Current working directory * @param {string[]} options.extensions - File extensions allowed in the bundle * @param {string[]} options.filePatterns - File patterns to search for */ export function findTests({ cwd, extensions, filePatterns }) { if ( !filePatterns || filePatterns.length === 0 || filePatterns[0] === undefined ) { filePatterns = defaultTestPatterns(extensions) } return findFiles({ cwd, extensions, filePatterns, }).filter((file) => !path.basename(file).startsWith('_')) } /** * workaround to get hidden description * jsonValue() on errors returns {} * * @param {any} arg */ function extractErrorMessage(arg) { // pup-firefox doesnt have this if (arg._remoteObject) { return arg._remoteObject.subtype === 'error' ? arg._remoteObject.description : undefined } } /** @type {Record<string, any>} */ const messageTypeToConsoleFn = { log: console.log, warning: console.warn, error: console.error, info: console.info, assert: console.assert, debug: console.debug, trace: console.trace, dir: console.dir, dirxml: console.dirxml, profile: console.profile, profileEnd: console.profileEnd, startGroup: console.group, startGroupCollapsed: console.groupCollapsed, endGroup: console.groupEnd, table: console.table, count: console.count, timeEnd: console.log, // we ignore calls to console.clear, as we don't want the page to clear our terminal // clear: console.clear } /** * @param {import('playwright-core').ConsoleMessage} msg */ export async function redirectConsole(msg) { const type = msg.type() const consoleFn = messageTypeToConsoleFn[type] if (!consoleFn) { return } const text = msg.text() // skip browser informational warnings if ( text?.includes( 'Synchronous XMLHttpRequest on the main thread is deprecated' ) || text?.includes('Clear-Site-Data') ) { return } // const { url, lineNumber, columnNumber } = msg.location() let msgArgs try { msgArgs = await Promise.all( msg.args().map((arg) => extractErrorMessage(arg) || arg.jsonValue()) ) } catch { // ignore error runner was probably force stopped } if (msgArgs && msgArgs.length > 0) { consoleFn.apply(console, msgArgs) } else if (text) { console.error(kleur.dim(`🌐${text}`)) } } /** * @template {RunnerOptions["browser"]} TBrowser * @param {TBrowser} browserName * @param {boolean} debug * @param {boolean} extension * @returns {Promise<import('playwright-core').BrowserType<import('../types').PwResult<TBrowser>>>} */ export async function getPw(browserName, debug, extension) { if (!['chromium', 'firefox', 'webkit'].includes(String(browserName))) { throw new Error(`Browser not supported: ${browserName}`) } if (browserName === 'chromium' && !debug && !extension) { // @ts-ignore browserName = 'chromium-headless-shell' } // @ts-ignore const { registry } = await import('playwright-core/lib/server') const api = await import('playwright-core') const browser = registry.findExecutable(browserName) // playwright will log browser download progress to stdout, temporarily // redirect the output to stderr const log = console.log const info = console.info try { console.log = console.error console.info = console.error await registry.install([browser]) } finally { console.log = log console.info = info } // @ts-ignore if (browserName === 'chromium-headless-shell') { return api.chromium } // @ts-ignore return api[browserName] } /** * @param {string} filePath */ export function addWorker(filePath) { return ` const w = new Worker("${filePath}", { type: "module" }); w.onmessage = function(e) { if(e.data.pwRunEnded) { self.PW_TEST.end(e.data.pwRunFailed) } if (e.data.pwStdout != null) { self.PW_TEST_STDOUT_WRITE(e.data.pwStdout) } if (e.data.pwStderr != null) { self.PW_TEST_STDERR_WRITE(e.data.pwStderr) } } ` } /** * @param {{ [x: string]: any; }} flags */ export function runnerOptions(flags) { const opts = {} for (const key in flags) { const value = flags[key] const localFlags = [ 'browser', 'runner', 'watch', 'debug', 'mode', 'incognito', 'extension', 'cwd', 'extensions', 'assets', 'before', 'cov', 'config', 'sw', 'report-dir', '_', 'd', 'r', 'b', 'm', 'w', 'i', 'e', ] if (!localFlags.includes(key)) { // @ts-ignore opts[camelCase(key)] = value } } return opts } /** * Build the bundle * * @param {import("../runner").Runner} runner * @param {ESBuildOptions} config - Runner esbuild config * @param {string} tmpl * @param {"bundle" | "before" | "watch"} mode */ export async function build(runner, config = {}, tmpl = '', mode = 'bundle') { const outName = `${mode}-out.js` const outPath = path.join(runner.dir, outName) const files = new Set() const sourceMapSupport = path.join( __dirname, '../vendor/source-map-support.js' ) // main script template let infileContent = ` import { install } from '${sourceMapSupport.replaceAll('\\', '/')}' install() process.env = ${JSON.stringify(runner.env)} import.meta.env = ${JSON.stringify(runner.env)} ${tmpl} ` // before script template if (mode === 'before' && runner.options.before) { infileContent = ` import { install } from '${sourceMapSupport.replaceAll('\\', '/')}' install() process.env = ${JSON.stringify(runner.env)} import.meta.env = ${JSON.stringify(runner.env)} await import('${require .resolve('../../static/setup.js') .replaceAll('\\', '/')}') await import('${require .resolve(path.join(runner.options.cwd, runner.options.before)) .replaceAll('\\', '/')}') ` } /** @type {ESBuildPlugin} */ const nodePlugin = { name: 'node built ins', setup(build) { build.onResolve({ filter: /^path$/ }, () => { return { path: require.resolve('path-browserify') } }) }, } /** @type {ESBuildPlugin} */ const watchPlugin = { name: 'watcher', setup(build) { // @ts-ignore build.onLoad({ filter: /.*/, namespace: 'file' }, (args) => { files.add(args.path) }) }, } /** @type {ESBuildOptions} */ const defaultOptions = { stdin: { contents: infileContent, resolveDir: runner.options.cwd, }, // sourceRoot: runner.dir, bundle: true, sourcemap: 'inline', platform: 'browser', format: 'esm', plugins: [nodePlugin, watchPlugin, wasmLoader()], outfile: outPath, inject: [path.join(__dirname, 'inject-process.js')], external: ['node:async_hooks', 'node:fs', 'msw/node'], define: { global: 'globalThis', PW_TEST_SOURCEMAP: runner.options.debug ? 'false' : 'true', PW_TEST_SOURCEMAP_PATH: JSON.stringify(runner.dir), }, } await esbuild.build(merge(defaultOptions, config, runner.options.buildConfig)) return { outName, files } } /** * Create coverage report in istanbul JSON format * * @param {import("../runner").Runner} runner * @param {any} coverage * @param {string} file * @param {string} outputDir */ export async function createCov(runner, coverage, file, outputDir) { const spinner = ora('Generating code coverage.').start() const entries = {} const { cwd } = runner.options // @ts-ignore const TestExclude = require('test-exclude') const exclude = new TestExclude({ cwd }) // @ts-ignore const f = new Set(exclude.globSync().map((f) => path.join(cwd, f))) for (const entry of coverage) { const filePath = path.resolve(runner.dir, entry.url.replace(runner.url, '')) if (filePath.includes(file)) { // @ts-ignore const converter = new V8ToIstanbul( filePath, 0, { source: entry.source, } // (path) => { // return !f.has(path) // } ) await converter.load() converter.applyCoverage(entry.functions) const instanbul = converter.toIstanbul() for (const key in instanbul) { if (f.has(key)) { // @ts-ignore entries[key] = instanbul[key] } } } } const covPath = path.join(cwd, outputDir) await mkdir(covPath, { recursive: true }) await writeFile( path.join(covPath, 'coverage-pw.json'), JSON.stringify(entries) ) spinner.succeed('Code coverage generated, run "npx nyc report".') } /** * Resolves module id from give base or cwd * * @param {string} id - module id * @param {string} [base] - base path */ export async function resolveModule(id, base = process.cwd()) { try { // Note we need to ensure base has trailing `/` or the the // last entry is going to be dropped during resolution. const path = createRequire(toDirectoryPath(base)).resolve(id) const url = pathToFileURL(path) return await import(url.href) } catch (error) { throw new Error( `Cannot resolve module "${id}" from "${base}"\n${ /** @type {Error} */ (error).message }` ) } } /** * Ensures that path ends with a path separator * * @param {string} source */ export const toDirectoryPath = (source) => source.endsWith(path.sep) ? source : `${source}${path.sep}` /** * * @param {string} runner * @param {string} cwd */ export async function resolveTestRunner(runner, cwd) { const module = await resolveModule(runner, cwd) /** @type {import('../types.js').TestRunner} */ const testRunner = module.playwrightTestRunner if (!testRunner) { throw new Error(`Cannot find playwrightTestRunner export in ${path}`) } return testRunner } /** * Get a free port * * @param {number} port * @param {string} host * @returns {Promise<number>} */ function getPort(port = 3000, host = '127.0.0.1') { const server = createServer() return new Promise((resolve, reject) => { server.on('error', (err) => { // @ts-ignore if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { server.listen(0, host) } else { reject(err) } }) server.on('listening', () => { // @ts-ignore const { port } = server.address() server.close(() => resolve(port)) }) server.listen(port, host) }) } /** * Create polka server * * @param {string} dir - Runner directory * @param {string} cwd - Current working directory * @param {string} assets - Assets directory * @returns {Promise<{ url: string; server: import('http').Server }>} */ export async function createPolka(dir, cwd, assets) { const host = '127.0.0.1' const port = await getPort(0, host) const url = `http://${host}:${port}/` return new Promise((resolve, reject) => { const { server } = polka() .use( // @ts-ignore sirv(dir, { dev: true, setHeaders: ( /** @type {{ setHeader: (arg0: string, arg1: string) => void; }} */ rsp, /** @type {string} */ pathname ) => { if (pathname === '/') { rsp.setHeader('Clear-Site-Data', '"cache", "cookies", "storage"') // rsp.setHeader('Clear-Site-Data', '"cache", "cookies", "storage", "executionContexts"'); } }, }) ) .use( // @ts-ignore sirv(path.join(cwd, assets), { dev: true, setHeaders: ( /** @type {{ setHeader: (arg0: string, arg1: string) => void; }} */ rsp, /** @type {string} */ pathname ) => { // workaround for https://github.com/lukeed/sirv/issues/158 - we // can't unset the `Content-Encoding` header because sirv sets it // after this function is invoked and will only set it if it's not // already set, so we need to set it to a garbage value that will be // ignored by browsers if (pathname.endsWith('.gz')) { rsp.setHeader('Content-Encoding', 'unsupported-encoding') } }, }) ) .listen(port, host, (/** @type {Error} */ err) => { if (err) { return reject(err) } if (!server) { return reject(new Error('No server')) } resolve({ url, server }) }) }) }