@nx/playwright
Version:
494 lines (493 loc) • 21.2 kB
JavaScript
;
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;
}