UNPKG

@nx/playwright

Version:

The Nx Plugin for Playwright contains executors and generators allowing your workspace to use the powerful Playwright integration testing capabilities.

494 lines (493 loc) 21.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createNodesV2 = exports.createNodes = void 0; const internal_1 = require("@nx/devkit/internal"); const devkit_1 = require("@nx/devkit"); const js_1 = require("@nx/js"); const internal_2 = require("@nx/js/internal"); const minimatch_1 = require("minimatch"); const node_fs_1 = require("node:fs"); const node_path_1 = require("node:path"); const file_hasher_1 = require("nx/src/hasher/file-hasher"); const cache_directory_1 = require("nx/src/utils/cache-directory"); const plugin_cache_utils_1 = require("nx/src/utils/plugin-cache-utils"); const workspace_context_1 = require("nx/src/utils/workspace-context"); const reporters_1 = require("../utils/reporters"); const playwrightConfigGlob = '**/playwright.config.{js,ts,cjs,cts,mjs,mts}'; exports.createNodes = [ playwrightConfigGlob, async (configFilePaths, options, context) => { const optionsHash = (0, file_hasher_1.hashObject)(options); const cachePath = (0, node_path_1.join)(cache_directory_1.workspaceDataDirectory, `playwright-${optionsHash}.hash`); const pluginCache = new plugin_cache_utils_1.PluginCache(cachePath); const packageManager = (0, devkit_1.detectPackageManager)(context.workspaceRoot); const pmc = (0, devkit_1.getPackageManagerCommand)(packageManager); const lockFileName = (0, js_1.getLockFileName)(packageManager); const normalizedOptions = normalizeOptions(options); try { const { entries, preErrors } = await filterPlaywrightConfigs(configFilePaths, context); const projectHashes = await (0, internal_1.calculateHashesForCreateNodes)(entries.map((e) => e.projectRoot), { ...normalizedOptions, CI: process.env.CI }, context, entries.map((e) => [lockFileName, ...e.externalTsconfigInputs])); let results = []; let nodeErrors = []; try { results = await (0, devkit_1.createNodesFromFiles)((configFile, _, ctx, idx) => createNodesInternal(configFile, normalizedOptions, ctx, pluginCache, pmc, entries[idx].externalTsconfigInputs, projectHashes[idx]), entries.map((e) => e.configFile), options, context); } catch (e) { if (e instanceof devkit_1.AggregateCreateNodesError) { results = e.partialResults ?? []; nodeErrors = e.errors; } else { throw e; } } const allErrors = [...preErrors, ...nodeErrors]; if (allErrors.length > 0) { throw new devkit_1.AggregateCreateNodesError(allErrors, results); } return results; } finally { pluginCache.writeToDisk(); } }, ]; exports.createNodesV2 = exports.createNodes; async function createNodesInternal(configFilePath, normalizedOptions, context, pluginCache, pmc, externalTsconfigInputs, hash) { const projectRoot = (0, node_path_1.dirname)(configFilePath); if (!pluginCache.has(hash)) { pluginCache.set(hash, await buildPlaywrightTargets(configFilePath, projectRoot, normalizedOptions, context, pmc, externalTsconfigInputs)); } const { targets, metadata } = pluginCache.get(hash); return { projects: { [projectRoot]: { root: projectRoot, targets, metadata, }, }, }; } async function buildPlaywrightTargets(configFilePath, projectRoot, options, context, pmc, externalTsconfigInputs) { // Playwright forbids importing the `@playwright/test` module twice. This would affect running the tests, // but we're just reading the config so let's delete the variable they are using to detect this. // See: https://github.com/microsoft/playwright/pull/11218/files delete process['__pw_initiator__']; const playwrightConfig = await (0, internal_1.loadConfigFile)((0, node_path_1.join)(context.workspaceRoot, configFilePath)); const namedInputs = (0, internal_1.getNamedInputs)(projectRoot, context); const tsconfigJsonInputs = externalTsconfigInputs.map((file) => ({ json: `{workspaceRoot}/${file}`, fields: ['compilerOptions', 'extends', 'files', 'include'], })); const targets = {}; let metadata; const testOutput = getTestOutput(playwrightConfig); const reporterOutputs = (0, reporters_1.getReporterOutputs)(playwrightConfig); const webserverCommandTasks = getWebserverCommandTasks(playwrightConfig); const baseTargetConfig = { command: 'playwright test', options: { cwd: '{projectRoot}', }, metadata: { technologies: ['playwright'], description: 'Runs Playwright Tests', help: { command: `${pmc.exec} playwright test --help`, example: { options: { workers: 1, }, }, }, }, }; if (webserverCommandTasks.length) { baseTargetConfig.dependsOn = getDependsOn(webserverCommandTasks); } else { baseTargetConfig.parallelism = false; } targets[options.targetName] = { ...baseTargetConfig, cache: true, inputs: [ ...('production' in namedInputs ? ['default', '^production', '^{projectRoot}/tsconfig*.json'] : ['default', '^default']), ...tsconfigJsonInputs, { externalDependencies: ['@playwright/test'] }, ], outputs: getTargetOutputs(testOutput, reporterOutputs, context.workspaceRoot, projectRoot), }; if (options.ciTargetName) { // ensure the blob reporter output is the directory containing the blob // report files const ciReporterOutputs = reporterOutputs.map(([reporter, output]) => reporter === 'blob' && output.endsWith('.zip') ? [reporter, (0, node_path_1.dirname)(output)] : [reporter, output]); const ciBaseTargetConfig = { ...baseTargetConfig, cache: true, inputs: [ ...('production' in namedInputs ? ['default', '^production', '^{projectRoot}/tsconfig*.json'] : ['default', '^default']), ...tsconfigJsonInputs, { externalDependencies: ['@playwright/test'] }, ], outputs: getTargetOutputs(testOutput, ciReporterOutputs, context.workspaceRoot, projectRoot), }; const groupName = 'E2E (CI)'; metadata = { targetGroups: { [groupName]: [] } }; const ciTargetGroup = metadata.targetGroups[groupName]; const testDir = playwrightConfig.testDir ? (0, devkit_1.joinPathFragments)(projectRoot, playwrightConfig.testDir) : projectRoot; // Playwright defaults to the following pattern. playwrightConfig.testMatch ??= '**/*.@(spec|test).?(c|m)[jt]s?(x)'; const dependsOn = []; const testFiles = await getAllTestFiles({ context, path: testDir, config: playwrightConfig, }); for (const testFile of testFiles) { const outputSubfolder = (0, node_path_1.relative)(projectRoot, testFile) .replace(/[\/\\]/g, '-') .replace(/\./g, '-'); const relativeSpecFilePath = (0, devkit_1.normalizePath)((0, node_path_1.relative)(projectRoot, testFile)); if (relativeSpecFilePath.includes('../')) { throw new Error('@nx/playwright/plugin attempted to run tests outside of the project root. This is not supported and should not happen. Please open an issue at https://github.com/nrwl/nx/issues/new/choose with the following information:\n\n' + `\n\n${JSON.stringify({ projectRoot, testFile, testFiles, context, config: playwrightConfig, }, null, 2)}`); } const targetName = `${options.ciTargetName}--${relativeSpecFilePath}`; ciTargetGroup.push(targetName); targets[targetName] = { ...ciBaseTargetConfig, options: { ...ciBaseTargetConfig.options, env: getAtomizedTaskEnvVars(reporterOutputs, outputSubfolder), }, outputs: getAtomizedTaskOutputs(testOutput, reporterOutputs, context.workspaceRoot, projectRoot, outputSubfolder), command: `${baseTargetConfig.command} ${relativeSpecFilePath} --output=${(0, devkit_1.joinPathFragments)(testOutput, outputSubfolder)}`, metadata: { technologies: ['playwright'], description: `Runs Playwright Tests in ${relativeSpecFilePath} in CI`, help: { command: `${pmc.exec} playwright test --help`, example: { options: { workers: 1, }, }, }, }, }; dependsOn.push({ target: targetName, params: 'forward', options: 'forward', }); } targets[options.ciTargetName] ??= {}; targets[options.ciTargetName] = { executor: 'nx:noop', cache: ciBaseTargetConfig.cache, inputs: ciBaseTargetConfig.inputs, outputs: ciBaseTargetConfig.outputs, dependsOn, metadata: { technologies: ['playwright'], description: 'Runs Playwright Tests in CI', nonAtomizedTarget: options.targetName, help: { command: `${pmc.exec} playwright test --help`, example: { options: { workers: 1, }, }, }, }, }; if (!webserverCommandTasks.length) { targets[options.ciTargetName].parallelism = false; } ciTargetGroup.push(options.ciTargetName); // infer the task to merge the reports from the atomized tasks const mergeReportsTargetOutputs = new Set(); for (const [reporter, output] of reporterOutputs) { if (reporter !== 'blob' && output) { mergeReportsTargetOutputs.add(normalizeOutput(output, context.workspaceRoot, projectRoot)); } } targets[options.mergeReportsTargetName] = { executor: '@nx/playwright:merge-reports', continuous: false, cache: true, inputs: ciBaseTargetConfig.inputs, outputs: Array.from(mergeReportsTargetOutputs), options: { config: (0, devkit_1.normalizePath)((0, node_path_1.relative)(projectRoot, configFilePath)), expectedSuites: dependsOn.length, }, metadata: { technologies: ['playwright'], description: 'Merges Playwright blob reports from atomized tasks to produce unified reports for the configured reporters.', }, }; ciTargetGroup.push(options.mergeReportsTargetName); } return { targets, metadata }; } async function getAllTestFiles(opts) { const files = await (0, workspace_context_1.getFilesInDirectoryUsingContext)(opts.context.workspaceRoot, opts.path); const matcher = createMatcher(opts.config.testMatch); const ignoredMatcher = opts.config.testIgnore ? createMatcher(opts.config.testIgnore) : () => false; return files.filter((file) => matcher(file) && !ignoredMatcher(file)); } function createMatcher(pattern) { if (Array.isArray(pattern)) { const matchers = pattern.map((p) => createMatcher(p)); return (path) => matchers.some((m) => m(path)); } else if (pattern instanceof RegExp) { return (path) => pattern.test(path); } else { return (path) => { try { return (0, minimatch_1.minimatch)(path, pattern); } catch (e) { throw new Error(`Error matching ${path} with ${pattern}: ${e.message}`); } }; } } function normalizeOptions(options) { const ciTargetName = options?.ciTargetName ?? 'e2e-ci'; return { ...options, targetName: options?.targetName ?? 'e2e', ciTargetName, mergeReportsTargetName: `${ciTargetName}--merge-reports`, }; } function getTestOutput(playwrightConfig) { const { outputDir } = playwrightConfig; if (outputDir) { return outputDir; } else { return './test-results'; } } function getTargetOutputs(testOutput, reporterOutputs, workspaceRoot, projectRoot) { const outputs = new Set(); outputs.add(normalizeOutput(testOutput, workspaceRoot, projectRoot)); for (const [, output] of reporterOutputs) { if (!output) { continue; } outputs.add(normalizeOutput(output, workspaceRoot, projectRoot)); } return Array.from(outputs); } function getAtomizedTaskOutputs(testOutput, reporterOutputs, workspaceRoot, projectRoot, subFolder) { const outputs = new Set(); outputs.add(normalizeOutput(addSubfolderToOutput(testOutput, subFolder), workspaceRoot, projectRoot)); for (const [reporter, output] of reporterOutputs) { if (!output) { continue; } if (reporter === 'blob') { const blobOutput = normalizeAtomizedTaskBlobReportOutput(output, subFolder); outputs.add(normalizeOutput(blobOutput, workspaceRoot, projectRoot)); continue; } outputs.add(normalizeOutput(addSubfolderToOutput(output, subFolder), workspaceRoot, projectRoot)); } return Array.from(outputs); } function addSubfolderToOutput(output, subfolder) { const parts = (0, node_path_1.parse)(output); if (parts.ext !== '') { return (0, devkit_1.joinPathFragments)(parts.dir, subfolder, parts.base); } return (0, devkit_1.joinPathFragments)(output, subfolder); } function getWebserverCommandTasks(playwrightConfig) { if (!playwrightConfig.webServer) { return []; } const tasks = []; const webServer = Array.isArray(playwrightConfig.webServer) ? playwrightConfig.webServer : [playwrightConfig.webServer]; for (const server of webServer) { if (!server.reuseExistingServer) { continue; } const task = parseTaskFromCommand(server.command); if (task) { tasks.push(task); } } return tasks; } function parseTaskFromCommand(command) { const nxRunRegex = /^(?:(?:npx|yarn|bun|pnpm|pnpm exec|pnpx) )?nx run (\S+:\S+)$/; const infixRegex = /^(?:(?:npx|yarn|bun|pnpm|pnpm exec|pnpx) )?nx (\S+ \S+)$/; const nxRunMatch = command.match(nxRunRegex); if (nxRunMatch) { const [project, target] = nxRunMatch[1].split(':'); return { project, target }; } const infixMatch = command.match(infixRegex); if (infixMatch) { const [target, project] = infixMatch[1].split(' '); return { project, target }; } return null; } function getDependsOn(tasks) { const projectsPerTask = new Map(); for (const { project, target } of tasks) { if (!projectsPerTask.has(target)) { projectsPerTask.set(target, []); } projectsPerTask.get(target).push(project); } return Array.from(projectsPerTask.entries()).map(([target, projects]) => ({ projects, target, })); } function normalizeOutput(path, workspaceRoot, projectRoot) { const fullProjectRoot = (0, node_path_1.resolve)(workspaceRoot, projectRoot); const fullPath = (0, node_path_1.resolve)(fullProjectRoot, path); const pathRelativeToProjectRoot = (0, devkit_1.normalizePath)((0, node_path_1.relative)(fullProjectRoot, fullPath)); if (pathRelativeToProjectRoot.startsWith('..')) { return (0, devkit_1.joinPathFragments)('{workspaceRoot}', (0, node_path_1.relative)(workspaceRoot, fullPath)); } return (0, devkit_1.joinPathFragments)('{projectRoot}', pathRelativeToProjectRoot); } function getAtomizedTaskEnvVars(reporterOutputs, outputSubfolder) { const env = {}; for (let [reporter, output] of reporterOutputs) { if (!output) { continue; } if (reporter === 'blob') { output = normalizeAtomizedTaskBlobReportOutput(output, outputSubfolder); } else { // add subfolder to the output to make them unique output = addSubfolderToOutput(output, outputSubfolder); } const outputExtname = (0, node_path_1.parse)(output).ext; const isFile = outputExtname !== ''; let envVarName; envVarName = `PLAYWRIGHT_${reporter.toUpperCase()}_OUTPUT_${isFile ? 'FILE' : 'DIR'}`; env[envVarName] = output; // Also set PLAYWRIGHT_HTML_REPORT for Playwright prior to 1.45.0. // HTML prior to this version did not follow the pattern of "PLAYWRIGHT_<REPORTER>_OUTPUT_<FILE|DIR>". if (reporter === 'html') { env['PLAYWRIGHT_HTML_REPORT'] = env[envVarName]; } } return env; } function normalizeAtomizedTaskBlobReportOutput(output, subfolder) { // set unique name for the blob report file return output.endsWith('.zip') ? (0, devkit_1.joinPathFragments)((0, node_path_1.dirname)(output), `${subfolder}.zip`) : (0, devkit_1.joinPathFragments)(output, `${subfolder}.zip`); } async function filterPlaywrightConfigs(configFilePaths, context) { const preErrors = []; const candidates = await Promise.all(configFilePaths.map(async (configFile) => { try { const projectRoot = (0, node_path_1.dirname)(configFile); const siblingFiles = (0, node_fs_1.readdirSync)((0, node_path_1.join)(context.workspaceRoot, projectRoot)); if (!siblingFiles.includes('package.json') && !siblingFiles.includes('project.json')) { return null; } const externalTsconfigInputs = collectExternalTsconfigInputs(projectRoot, context.workspaceRoot); return { configFile, projectRoot, externalTsconfigInputs }; } catch (e) { preErrors.push([configFile, e]); return null; } })); return { entries: candidates.filter((c) => c !== null), preErrors, }; } /** * Collects tsconfig files read by the Playwright task that are NOT already * covered by other inputs, returned as workspace-relative paths. * * Sources: * - The project tsconfig's `extends` chain (compile-time config loading) * - The workspace root `tsconfig.json` (read at runtime by * `isUsingTsSolutionSetup`, which `nxE2EPreset` calls from the Playwright * worker to pick the output directory convention) * * Exclusions: * - Files inside the project root — covered by `default` * - The native `TsConfiguration` hash instruction file at the workspace * root (`tsconfig.base.json` when it exists, otherwise `tsconfig.json`) * - Files under `node_modules` — invalidated via the lockfile * - Paths outside the workspace — cannot be expressed as inputs */ function collectExternalTsconfigInputs(projectRoot, workspaceRoot) { const rootTsConfigName = (0, js_1.getRootTsConfigFileName)(); const projectPrefix = `${projectRoot}/`; const collected = []; const seen = new Set(); const visit = (absolutePath) => { const wsRelative = (0, node_path_1.relative)(workspaceRoot, absolutePath) .split(node_path_1.sep) .join('/'); if (seen.has(wsRelative)) return 'continue'; seen.add(wsRelative); if (wsRelative.startsWith('../') || wsRelative === '..') return 'continue'; if (wsRelative.startsWith('node_modules/') || wsRelative.includes('/node_modules/')) { return 'continue'; } if (wsRelative === projectRoot || wsRelative.startsWith(projectPrefix)) { return 'continue'; } if (wsRelative === rootTsConfigName) return 'continue'; collected.push(wsRelative); return 'continue'; }; const projectTsconfig = (0, node_path_1.join)(workspaceRoot, projectRoot, 'tsconfig.json'); if ((0, node_fs_1.existsSync)(projectTsconfig)) { (0, internal_2.walkTsconfigExtendsChain)(projectTsconfig, visit); } const rootTsconfig = (0, node_path_1.join)(workspaceRoot, 'tsconfig.json'); if ((0, node_fs_1.existsSync)(rootTsconfig)) { (0, internal_2.walkTsconfigExtendsChain)(rootTsconfig, visit); } return collected; }